刚接触单线程异步,很容易产生疑惑,单个线程怎么处理大量并发请求。原理并不复杂,绝大多数请求不需要CPU持续占用。发起网络请求之后,远端传回数据要几十毫秒甚至数秒,这段时间CPU原地等待就是资源浪费。单线程异步的处理逻辑,发起请求后立刻交出控制权,事件循环继续处理下一项任务。等到数据接收完成,操作系统通知事件循环,再回来执行对应的回调函数。全程只运行一条线程,在不同任务之间切换,切换触发条件是I/O就绪事件,不是时间片耗尽。

事件循环本身就是死循环,每一轮都会检查是否存在就绪的I/O事件,存在就执行对应的回调,没有就持续等待。Node.js区分微任务队列和宏任务队列,Promise回调归入微任务,setTimeout属于宏任务,每执行完一个宏任务,就清空全部微任务队列。这套设计,是为了保障高优先级回调优先运行,不会被大量定时器阻塞。Python的asyncio机制相近,协程执行到await位置交出控制权,事件循环把控制权交给下一个就绪协程。
这套机制处理I/O密集任务效率很高。上万条并发连接,绝大多数时间都在等待数据,单线程事件循环完全可以承载,CPU真正运算耗时很短。但它有明确的局限,一旦回调内部包含大量计算,事件循环直接卡住,其余任务只能排队等待。这也是CPU密集型任务不适合放在单线程异步模型里的原因。
有人会想到,多开几条线程,每条线程单独跑事件循环,就能处理更多任务。这个想法看着合理,实际运行经常出现性能下滑。需要从多个层面拆解背后原因。
最先要考虑的就是上下文切换。操作系统切换线程不是零成本,每次切换都要保存当前线程寄存器、程序计数器、栈指针,再加载下一条线程对应的信息。当线程数量超过CPU核心数量,切换频率会急剧上涨。原本想依靠多线程分摊负载,结果CPU大量算力消耗在线程切换,而不是业务运算。更麻烦的是切换之后,CPU的L1、L2缓存基本失效,新线程需要的数据,要重新从内存读取,延迟高出寄存器操作两个数量级。
Python的情况更加特殊。GIL全局解释器锁,同一时刻只允许一条线程执行Python字节码。开启十条线程运行事件循环,本质依旧串行执行,GIL在线程之间轮流放行。GIL切换同样存在开销,线程反复释放、获取锁,I/O密集场景开销不明显,一旦混入计算任务,锁竞争会变得激烈。有测试数据显示,Python多线程处理CPU密集任务,性能甚至不如单线程,GIL切换带来的损耗超过并行带来的收益。
锁带来的问题不只存在Python。任意多线程程序,只要存在共享状态,就要依靠锁保护。锁粒度太粗,线程排队等待;粒度太细,加锁解锁的开销上升。还有一个隐蔽问题叫伪共享:两条线程修改同一缓存行内不同变量,逻辑层面不存在冲突,硬件层面缓存行在多个核心之间来回传递,性能莫名下跌。这类问题在单线程异步模型完全不存在,没有共享资源,不需要锁,也不会出现缓存行反复跳转。
内存模型差异同样会拖累性能。单线程异步的全部回调都在同一个线程执行,变量访问就是普通内存读写,编译器可以充分优化。多线程环境为保证数据可见性,需要内存屏障、原子操作、volatile这类指令,限制编译器和CPU的乱序执行,实际运行效率低于单线程。代码写法看着一致,底层运行逻辑完全不同。
还有容易被忽略的一点,并行模拟异步时,任务分配本身就会产生难题。单线程事件循环自带中心调度器,任务先后顺序清晰。多条线程各自运行事件循环,任务怎么分配?按连接分配还是按请求分配?分配不均衡,部分线程负载拉满,其余线程闲置。工作窃取算法可以缓解,但是窃取操作需要加锁、同步,新增额外开销。I/O就绪事件由操作系统推送,多条线程同时等待I/O,事件到来后唤醒哪一条线程,这个判断本身有成本。唤醒错误线程,任务需要迁移,迁移又会造成缓存失效。
所以在I/O密集场景,并行模拟异步往往得不偿失。I/O瓶颈集中在网卡、磁盘、远端服务器,不在CPU。单线程异步已经压低CPU等待时长,叠加多线程之后,新增大量调度和同步开销,实际有效运算时长没有提升。只有计算、I/O混合场景,并且计算占比偏高,多线程或者多进程才有价值。这种场景更合适的方案,是把计算任务交给独立线程池、进程池,事件循环继续处理I/O,而不是多个事件循环互相抢占资源。
归根结底,单线程异步的设计思路,用事件驱动替代线程阻塞,依靠协作式调度替换抢占式调度。优势源于简化,没有锁,不存在上下文切换,没有缓存一致性开销。并行化一旦打破这种简洁,新增的成本很容易超过收益。不是所有场景都适合并行,尤其是那些大部分时间都在等待的任务。


