最近在写一个 web 服务器,涉及到异步并发,正好记录一下。
一个 web 服务器肯定会涉及多人访问(并发1),最简单的实现多人访问不阻塞,是为每个请求开辟一个线程。但是线程开太多的话,频繁的上下文切换还是很耗 CPU 的,而且线程也不能无限的开辟。
分析一下#
像这种一个请求一般不会占用太长时间(一般毫秒级别),而是需要频繁读取 IO, 这种场景就是一种 IO 密集型任务。
而处理这种 IO 密集型任务,我首先想到的是 Node 这种事件驱动、非阻塞 I/O 模型(就是一个线程内有个 loop,有异步函数时,就注册一个回调函数,然后继续往下执行任务,当回调触发时,加入 loop 中)。
不过这种单线程的模型,有 2 个问题:
- 遇到一个长任务(需要一直占用 CPU)时,其他任务会阻塞。
- 现在 CPU 都是多核结构,单线程只能跑到一个处理器核心上,无法实现并行2。
对于第一个问题,我们可以建立一个线程池,当一个线程里有长任务阻塞时,可以交由其他线程执行。同时因为有多个线程执行,所以第二个问题也可以解决。
不过,还有个问题,线程池的线程是有限的,而长任务可能同时有很多,且比线程的数量还多,这时必然要将暂时处理不了的请求保存下来。所以我们可以建立队列来保存暂时处理不了的请求。而建立队列就有两种方法:
- 建立一个全局队列,每个线程处理完当前任务后去全局队列中获取任务,而这种就会涉及跨线程读写问题。
- 为每个线程建立一个队列,而这种可能会出现一个线程的队列有很多任务,而其他线程没任务,这时候就涉及窃取别的线程的任务在自己线程执行(任务窃取)。
这两种,建立全局队列感觉不太可行,明明我有多个线程来并发,可是得同步的获取任务(因为锁的原因),同时跨线程的性能也是个问题。而为每个线程建立一个队列,就算没有实现任务窃取也是并发的。
结果:我们建立一个线程池,为每个线程建立一个队列,线程实现非阻塞 I/O 模型。
协程#
不知道,大家有没有疑问,跟非阻塞 I/O 这种模型实现功能差不多的,别的语言有吗?有的,不过在别的语言里叫协程。
协程:也就是在执行到异步任务时,可以自动让出 CPU 时间片,供同线程内其他协程运行。
而当协程让出 CPU 时间片时,必然要保存当前的状态,以备再次执行时,恢复以前的状态。而保存和恢复,就有两种方式来解决:有栈协程和无栈协程。
有栈协程#
有栈协程状态切换和线程切换差不多,都是在切换时保存相关上下文,但是有栈协程比线程切换所需要的上下文占用空间要小。
有栈协程的上下文的大小是已知的,所以可保存到栈中也可保存到堆上。当然保持到栈上性能更好。
无传染性:一个函数异步,不用它的父函数也异步。
上边说了一下「线程开太多的话,频繁的上下文切换还是很耗 CPU 的」,为啥?这就涉及到每次切换线程时,用户态都要发起中断来切换到核心态,而切换就导致需要保存当前的栈信息,程序计数器,以及一些寄存器信息。有栈协程相比代价就比较小。
无栈协程#
无栈协程就相当于实现一个状态机3,来保存当前状态。因为当前状态的大小是未知的,所以只会保存到堆上,访问速度比栈慢。相比有栈协程开销会少。
有传染性:一个函数异步,一直传染到顶部函数都得用异步(除非特殊处理)。
如下实现了一个最小的 C 语言 Demo:
int fun() { static int i, state = 0; switch (state) { case 0: for (i = 0; i < 10; i++) { state = 1; return i; case 1:; } } return i;}
第一次执行返回 0, 而第二次执行的时候,因为 state 已经被设置为 1,所以会跳转到 case 1
这,相当于上次返回的点。
多线程#
绿色线程(M 多对多)#
既然协程有的语言都内置了,那线程池呢?也有,我了解的就有 Go。
Go 实现就是一种 M
Go 使用了一种 GPM 调度模型来处理并发,如下:
M(Machine):系统线程
P(Processor): 逻辑处理单元,给 G 提供了相关的执行环境
G(Goroutine): 轻量级的线程,存储 goroutine 的运行堆栈、状态以及任务函数。
使用 Go 关键字创建 goroutine,优先加入某个 P 维护的局部队列(当局部队列已满时加入全局队列),P 需要持有或者绑定一个 M,而 M 会启动一个系统线程,不断的从 P 的本地队列取出 G 并执行,M 执行完 P 维护的局部队列后,它会尝试从全局队列寻找 G,如果全局队列为空,则从其他的 P 维护的队列里窃取 G 到自己的队列。重复以上直到 G 执行完。
说到线程,就不得说一下线程安全这个问题。
Go 并发哲学是:不通过共享内存来通信(利用锁来保证安全),而是通过通信来共享内存(使用 channel 来保持线程安全,而 channel 本身是一个队列,进出队列都是原子性的)。
原生线程(1<1>1> 一对一)#
原生线程:1<1>1> 线程模型,这个模型就不多说了,就是建立一个线程执行一个任务。
说一下 Rust,目前 Rust 实现的是这个一对一模型,异步是无栈协程。
Rust 利用所有权来保证安全,就是说一个线程用到另一个线程的变量时,另一个线程就把这个变量给了它,以后也不能访问,这样保证只有一个线程持有这个变量,也就不会有同步问题。
当需要多个线程持有时且需要修改,则可以对这个变量使用智能指针,然后每个线程都复制一份智能指针。但是还是会用到锁,那跟别的语言有啥不同,不同之处就是不用 GC(垃圾回收),智能指针使这个变量有个计数器,每个智能指针离开作用域时销毁然后计数器减一,计数器为 0 时,销毁这个变量。
Actor 模型#
一种处理并发的模型(Erlang 语言使用的就是这种模型)。每个任务都运行在 Actor 上,每个 Actor 完全独立,可以理解为轻量级的进程。
每个 Actor 通过 Mailbox 来异步传递消息给另一个 Actor(正因为异步,不用依赖别的 Actor,就不会出现像 Go 和 Rust 的死锁4问题),但是每个 Actor 只能顺序地处理消息。
在处理异常这方面,其他语言通过捕获异常来保证系统的稳定性(防御式编程)。而 Erlang 引入了一个「任其崩溃」的哲学理念: 通过一个叫 Supervisor 的 Actor 来监控别的 Actor,当 Actor 崩溃时,Supervisor 就会修复这个 Actor。会有很多修复策略,最常见的是根据初始状态重启 Actor。
参考资料#
写着写着,有时候发现自己就是个“ Copy 机器”,果然做啥事都离不开前人的经验。所以大家如果有兴趣的话,可以顺便阅读下面的参考资料。