线程, 协程, 无栈协程
目前, C++20, Rust 已经都支持协程特性了.
可以说, 现在已经没有多少语言不支持协程. 那么, 什么是协程, 是不是应该在全部场景下All In 协程呢?
这里, 我按照我对这些模型的理解, 尝试探究一下各自的关系, 如果有哪里出现纰漏, 欢迎吐槽.
线程
首先, 谈到线程, 我们肯定是要谈到它的"爸爸", 进程.
进程的概念很简单, 相信大家在各种面试中这种八股都快背吐了(笑. 什么"操作系统的基本单元, 代表一个独立程序的执行"
简单来说, 在操作系统的视角下, 其实就是一个虚拟内存空间+一系列打开的文件, Pipe, Socket的资源集合体而已.
在操作系统早期, 没有多线程的情况下, 一个进程对应一个"执行流", 当CPU开始运行这个进程的时候, 读取对应的代码段, 按照代码的控制在这个进程的虚拟内存空间下执行不同的指令.
此时, 进程除了是一个资源集合外, 还是一个调度单元. 不同的进程交替运行. 比如, A程序希望读取一个Socket, 但是这个Socket还没有有数据传入, 那就简单地将A挂起, 让其他程序运行. 等Socket数据就位了之后, 让A开始运行. 到这里, 一切都很完美, 对吧.
内核级线程
内核级线程是一个"原教旨主义"的线程, 它基本就是我们常说的线程的概念. 在CPU密集或者IO密集的时候都可以用.
让我们重新回到刚才的话题, 比较早期的操作系统, 一般是以进程为调度单位.
但是, 现在的硬件那么发达, 已经很难见到单核心的机器了, 假如我有一个"高端的"双核处理器, 需要为一个比较大的数组排序, 如果只是使用单个核心, 岂不是很浪费?
那怎么办? 用两个进程? 怎么共享数据呢? 用Pipe肯定不太好, 太蠢了.
用进程间共享内存? 听上去是个好办法, 把这个数组放在两个进程共享的内存中, 进程A为上半部分排序, 进程B为下半部分排序, 然后把两个有序数组归并一下, 是个好主意!
但是, 如果我只是希望充分利用这个"高端双核CPU"的话, 如果用两个进程, 似乎还是有一些overhead, 比如, 如果运行两个进程, 那么在内核中, 势必要维护两份进程的信息, 是我们不需要的.
并且, 很可能同一个核心在上一次运行进程A, 在下一次调度运行进程B, 这样内核会做一系列的进程资源的逻辑, 以及不必要的页表刷新.
其实优化这里很简单, 让进程A和进程B的各种资源信息指向同一个结构体就好. 这样, 二者共享同一个内存空间, 同一个资源列表, 就可以省掉很多冗余开销了.
想象一下, 一个进程, 在内存中应该如何描述呢, 举个🌰:
struct process{
struct cpu_context{/* 进程运行的cpu上下文, 各个寄存器(如pc, fp)的值 */};
struct memory_contxt{/* 保存进程的虚拟内存空间的信息, 比如页表所在位置 */};
struct open_files{/* 保存这个进程当前打开了哪些文件, 包括socket, pipe, regular file 等等等 */};
/* 其余项目.... */
};
如果想要我们上面提到的优化, 那么我们应该将process的资源和运行环境单独抽象一下, 比如, 运行环境命名为 cpu_context, 内存空间, 打开的资源文件, 我们统一用process_resources包裹, 像这样
struct process{
struct cpu_context{/* 进程运行的cpu上下文, 各个寄存器(如pc, fp)的值 */};
struct process_resources
struct memory_contxt{/* 保存进程的虚拟内存空间的信息, 比如页表所在位置 */};
struct open_files{/* 保存这个进程当前打开了哪些文件, 包括socket, pipe, regular file 等等等 */};
} * resource_ptr;
/* 其余项目.... */
};
这样, 通过结构体内指针的形式, 我们就可以实现两个进程共享资源. 其实到这里, 这个进程已经不能再称之为进程了, 这其实就是Linux下的LWP(Light-weight process). Linux基本就是通过这个实现的多线程功能. 并且, 在Linux下, 我们上面杜撰出来的process结构体, 其实叫 task_struct 更为合适一些. 在内核做调度时, 并不理会什么进程线程的, 它眼中的只有各个task_struct结构体提供的信息等着它来调度.
这, 也就是我们上面提到的"内核线程".
当然, 这种轻量级线程只是Linux为多线程的实现提供的一种解决方案. 比如windows, 就不是这种.
用户级线程
假如我们还在使用着一个比较古早的操作系统, 并不支持多线程的功能. 每个进程只能有一个执行序列, 同一时刻只能使用一个cpu核心.
但是, 我们的任务, 是为多个用户提供一个简单的web http服务. 在这种简陋的操作系统下, 几乎是不现实的, 如果只能运行于一个核心, 那就意味着, 我们的程序没办法同时读写多个socket.
除了换掉这个倒霉的操作系统, 还能怎么办呢?
有办法.
我们知道, 网络IO的延迟, 往往是以毫秒甚至秒来计量, 而即使是很老旧的CPU, 在一毫秒内, 也足够做很多事情了. 那么, 我们其实可以在这里打一个"时间差".
首先, 还是需要这个可怜的操作系统, 它应当提供一种"非阻塞调用"的功能. 比如, 我们的程序同时打开了4个socket, 当我们尝试读取第一个socket时, 如果此时它的数据还没有就绪, 我们希望操作系统告诉我们:"这个socket没有数据!"而不是直接把进程挂起来, 等到这个socket就绪才恢复运行. 这样, 我们就可以逐一尝试每个socket, 如果4个中存在就绪状态的socket, 就去读取这个socket, 执行逻辑. 接着尝试下一个socket. 不断循环.
这样, 借着网络IO与CPU速度之间的差异, 我们就可以只用一个核心, 同时服务多个客户端啦.
有没有熟悉的感觉? IO多路复用了解一下?
那么, 我们如何实现这种一个核心, 同时服务多个socket的逻辑呢? 最简单又是最复杂的, 我们可以使用事件循环+回调.
比如, 我们创建4个函数, 分别命名为a, b, c, d; 然后用一个死循环来循环尝试4个Socket(A, B, C, D), 如果A就绪了, 运行函数a来服务; B就绪了, 就运行b, 以此类推.
这样其实差不多能达到目的了, 但是现实往往比这复杂, 比如, abcd中可能有其他的逻辑, 也需要等待, 那我们就要写一摞摞罄竹难书的, 让以后接盘代码的人疯狂问候我们家人的, 回调地狱式的代码了.
那有没有不写回调, 又能实现我们目标的代码呢? 有的, 不仅有, 而且至少有两种.
有栈协程
先说第一种.
既然这个破旧操作系统不支持多线程, 那我们在用户态自己实现一套多线程调度算法不就结了?
我们完全可以在应用程序中, 模仿内核中线程的定义, 记录线程的上下文(包括pc指针, 栈区指向等等), 然后, 在上面的死循环中, 自行调度.
举个🌰, 还是同样的4个Socket(A, B, C, D), 我们可以在应用程序中, 模拟4个线程(1,2,3,4), 当A就绪了, 运行"线程1"来执行服务内容; B就绪了, 就运行"线程2", 以此类推.
还是上面的问题, 假如"线程1,2,3,4"中有其他的等待逻辑, 我们也在应用程序的用户态中, 模拟一个支持多线程的操作系统一样, 记录哪些"线程"挂起, 哪些"线程"可以运行.
这, 就是所谓的"用户态线程"
但是(对没错, 又一个但是 😛 ), 这样做有两点问题:
其一, 没有内核态的帮忙, 没办法实现抢占式的调度, 比如, 我们的"线程1"中有一个死循环的bug, 那么在用户态的调度器通常来说是没办法强制夺取"线程1"的运行的, 其他"线程"就再也没机会运行(也就是"饿死"了).
其二, 虽然说, 线程切换的开销很小, 但并不意味着就不存在. 相对于写回调来说, 显然反复dump/load cpu寄存器的开销要更大, 更别说在这个过程中, 还有可能存在浮点寄存器的反复读取了.
提出问题, 我们就要开始解决问题了.
对于第一点, 比较简单解决方案就是, 在涉及到一些可能耗时的逻辑中, 我们可以频繁插入一个检查的逻辑, 如果当前"线程"运行了太久, 就主动让出运行权, 让我们在应用程序中实现的调度器转而运行其他"线程即可". 这样, 就可以很大程度上缓解"线程被饿死"的情况.
或者另一个方案, 我们也可以利用一些操作系统提供的机制, 比如, 如果这个操作系统内核实现了信号机制的话, 我们可以再起一个进程, 每隔一段时间向我们的程序发送一个信号, 当程序收到信号后, 操作系统会使应用程序转而运行一段信号处理的代码, 这样, 我们就有机会让调度器"抢占"这个非常耗时的"线程"啦.
嘿嘿 没错! 方案一是早期的golang实现, 方案二是目前的golang实现.
到这里, 我们在用户态实现的"线程"
- 可以主动让出自己的运行 --> 协作
- 有自己独立的运行上下文(CPU 寄存器的保存相对来说并不占多少空间, 栈才是最重要的) --> 有栈
不如, 我们就叫它有栈协程吧!
无栈协程
上文说到, 线程切换的开销虽然很小, 但并不是不存在, 如果想再给力一点, 追求更高的性能, 应该怎么办?
又不想写回调代码, 又不想要线程切换的额外开销?? 不好办, 但也不是不能办.
首先, 回调这种机制肯定少不了的了, 它的开销就是一次函数调用, 不会再有比它代价更小的方法了(也不能全内联吧...).
那既然, 我们不想写, 不如换个人写? 让编译器来做这件事如何?
举个🌰, 假如我们有这样一个需求, 从一个socket中读取数据, 再写入到另一个socket, 如果是用回调的形式, 那大概需要分成三段来实现
copy() {
read(socket1, 1024, callback1); // 此处read是一个我们假想的api, 它尝试从socket1中读取1024字节的数据, 如果成功, 调用callback1来处理
}
callback1(socket s, uint8_t data, int size){
print("read done!");
write(socket2, data, size, callback2); //此处write同样是一个我们假想的api, 它尝试向socket2中写入size字节的数据, 如果成功, 调用callback2来处理
}
callback2(socket s1){
print("write done!");
/* 做一些收尾工作 */
}
如果我们用过一些支持异步的语言, 比如python3, js或者rust之类的, 就会看到async/await关键字, 在这对关键字的帮助下, 我们可以将代码重写为这样:
async def copy(in: socket, out: socket){
data = await read(socket1, 1024)
# callback1
print("read done")
await write(out, data, 1024)
# callback2
print("write done")
# 一些收尾工作
}
在编译器的帮助下, 这类异步的代码, 会被根据async函数内await的位置, 将其拆分为多个小函数片段, 如果在不同的片段中, 有着共同引用的变量, 就简单地使用一个闭包或者结构体保存即可.
还是以上面的代码为例, 在这里, in/out两个socket就是各个片段都会使用的, 那么, 编译器可以用类似于下面这种方式来处理copy函数:
class copy(){
socket in;
socket out;
uint8_t data;
int size = 1024;
void step1(){
data = read(in, size);
}
void step2(){
print("read done");
write(out, data, size);
}
void step3(){
print("write done")
/* 收尾工作 */
}
}
然后, 通过一个调度器, 结合非阻塞调用, 在合适的时机, 逐一调用copy对象的不同步骤, 即可实现原有逻辑. 这样做基本与回调时等效的, 只不过在参数传递上可能有一些差异, 不过大差不差.
通过这种方式, 我们可以依旧按照用线程的思路写程序逻辑, 不用纠结回调的问题, 同时, 每个任务又没有自己的线程栈, 只需要关注一些跨区域会引用的变量即可.
不如, 我们就叫它无栈协程如何?

浙公网安备 33010602011771号