LLM 面试备考
首页/分布式与可靠性

并发模型:进程、线程、协程与异步 I/O(Process, Thread, Coroutine & Async I/O)

所属:01-fundamentals / 07-distributed-systems
面试权重:★★
来源:通用操作系统与分布式知识,System Design 101(ByteByteGo)/ MIT 6.1800(计算机系统工程)等公开资料;本仓库两本参考书基本未覆盖,只标渠道来源、未做一手考证。GIL、epoll、io_uring 属语言实现与内核机制的定义性事实
相关知识点:queue-pubsub.md(并发之后的排队与背压)、timeout-retry-idempotency.md(超时 / 重试落在哪个执行体上)、transaction-wal.md(持久化与恢复)、../06-agent-infra/10-agent-runtime.md(Agent 运行时的资源占用与隔离边界)、../04-agent-architecture/11-async-event-driven.md(Agent 侧的异步事件驱动 —— 与这篇讲的系统层并发模型是两件事)

一句话#

进程、线程、协程是同一根轴上的三档取舍 —— 隔离边界画在哪、谁来做调度、切换要付多少代价;面试真正要考的不是背定义,而是知道阻塞点在哪、以及并发为什么不是并行:高并发服务被 I/O 卡住,所以用异步把「等」变成结构(单线程事件循环,await 处即让出点),而不是靠起更多执行体去堆。

为什么重要(面试角度)#

  • 它是几乎所有「高并发」问题的底层词汇。 网关、Agent 运行时、沙箱、消息队列,问到容量与延迟最后都会落到「你的执行体是什么、在哪里阻塞」。
  • 它专治一组高频的混用词。 「阻塞 / 非阻塞」和「同步 / 异步」是两个正交维度,被混着说的人立刻暴露底子;面试官常拿一句「非阻塞是不是就是异步」判分层。
  • 它有一个必须能当场纠正的错觉:「协程并行」。 协程是单线程上的协作式调度,给的是并发(重叠等待)而不是并行(同时执行)—— 说错等于承认没写过。
  • 它把 Agent 的话题接回系统层。 Agent Runtime 的时间绝大部分花在等模型、等工具(I/O 密集),所以异步是默认形态;但工具执行本身是 CPU / 内存密集,必须挪出事件循环。这一正一反是本篇最值钱的落点。
  • 诚实划界:这套账我会这么算,但没有在生产里调过多线程 / 事件循环。讲的时候用「原理上应该这么选」,不要说成「我们线上就是这么调的」。

核心内容#

1. 进程、线程、协程:同一根轴上的三档#

三者不是三个并列的概念,而是**「隔离得多严 / 谁来调度 / 切一次多贵」**这根轴上的三个刻度。

维度 进程 Process 线程 Thread 协程 Coroutine
隔离边界 独立地址空间,默认互不可见 同进程内共享地址空间 同一线程内共享地址空间
调度主体 内核(抢占式) 内核(抢占式,主流 1:1 模型) 运行时 / 用户态(协作式,M:N 或 N:1)
切换代价 最贵:换地址空间 + 换页表 / TLB 中等:要陷入内核,但不换地址空间 最便宜:纯用户态保存寄存器与栈
共享内存 不共享,通信要显式 IPC 默认共享(堆、全局变量),要加锁 默认共享,但同一时刻只有一个在跑
典型数量级 几十 ~ 几千 几百 ~ 几千 几万 ~ 几十万

几个必须说清的细节:

  • 「切换更贵」差在哪。 进程切换要换页表、会打掉 TLB,之后一段时间的访存都要重新填 TLB;线程切换省掉了这一步,但仍是一次用户态 → 内核态 → 用户态的往返。所以量级上是「协程 < 线程 < 进程」,具体数字随硬件、负载、缓存命中而变,只记量级,不要背死数字。
  • 「用户态调度」意味着控制权在运行时手里。 协程只有在主动让出(await / yield)时才切换;没人让出,一个协程就能把整个线程占死。
  • 线程的 1:1 与协程的 M:N。 主流 OS 线程是「一个用户线程对一个内核调度实体」;Go 的 goroutine、Rust 的 async 任务则是「M 个用户态任务跑在 N 个内核线程上」。用户态任务数可以远大于内核线程数,这正是协程能开到几万、线程开不到的原因。

通信方式也随隔离边界变化:进程之间要管道 / socket / 共享内存 + 信号量;线程之间直接读写同一块堆内存,但必须加锁或走无锁结构(数据竞争是默认风险);协程之间通常在同一线程内,靠单线程语义天然免锁,代价是任何一处阻塞都会拖住整个 loop。

2. 阻塞 / 非阻塞 与 同步 / 异步:两个正交维度#

这是最容易被混为一谈的一组词。它们是两个独立的维度,四个组合都真实存在:

同步(调用方自己等结果) 异步(结果稍后告知 / 回调)
阻塞 read() 一直阻塞到数据到达 —— 最简单、最常见 线程池里某个线程阻塞地跑,调用方拿 Future
非阻塞 轮询:立刻返回 EAGAIN「还没好」,调用方不停重试 epoll + 事件循环:注册兴趣,就绪时被通知
  • 阻塞 / 非阻塞描述的是一次调用的返回时机:资源没准备好时,这个调用是把你挂起,还是立刻返回一个「还没好」。
  • 同步 / 异步描述的是结果怎么交付:是调用方一直等着拿返回值,还是先干别的、结果通过回调 / future / 事件送回来。

「非阻塞」不等于「异步」。 非阻塞的调用也可以是同步的 —— 你自己写个 while 循环反复问「好了没」,那就是非阻塞 + 同步(忙等,白烧 CPU)。反过来,异步也不一定非阻塞 —— 把一个阻塞的 read() 丢进线程池,调用方是异步的,干活的线程照样阻塞。分开说,才是对的。

3. 并发 vs 并行:结构 vs 执行#

  • 并发(Concurrency)是结构:多个任务在重叠的时间窗口里都向前推进 —— 一个任务在等 I/O 时,另一个可以跑。
  • 并行(Parallelism)是执行:多个任务在同一瞬间真正同时执行 —— 需要多个核。

关键推论:单核上可以有并发,但不可能有并行。 单线程事件循环同时「照看」一万个连接,是并发;它不是并行,因为任何一刻只有一段代码在跑。协程调到同一个线程上,也一样 —— 协程给你的是并发,不是并行。

一句话记法(Rob Pike 的说法):并发是「同时应付很多事」,并行是「同时做很多事」。

4. 事件循环与 async/await:单线程上的协作式调度#

异步 I/O 的骨架是事件循环(event loop):一个常驻循环,从就绪队列取出可以推进的任务、跑到它让出为止、再取下一个。

  • await 点就是让出点。 一段协程从开始到下一个 await 之间是一口气跑完、不受抢占的;碰到 await(本质是「我要等一个 I/O」),它把后续步骤注册成回调(或挂起),把控制权交回循环,让别的任务跑。
  • 调度器是运行时,不是内核。 内核不参与协程切换,运行时按「谁让出了」来选下一个 —— 这叫协作式(cooperative)调度,对比内核线程的抢占式(preemptive)。
  • 代价也很直接:既然没人能抢占你,一段 CPU 密集的计算塞在协程里,就会把整个 loop 卡住 —— 期间所有连接、所有别的任务一起停摆。这是异步模型最常踩的坑。

所以 async/await 的本质是:用「在等待处显式让出」换来了单线程管理海量 I/O 的能力。它不创造并行度;它把「等」这件原本要占一个线程的事,变成了不占线程的结构。

5. I/O 是瓶颈:从 C10K 到 epoll / io_uring#

一台服务器要做的事情里,真正烧 CPU 的往往很少,大部分时间在等:等网络、等磁盘、等下游。传统的「一个连接一个阻塞线程」模型(thread-per-connection)在连接数上万时就会崩 —— 不是 CPU 不够,而是每个线程都有栈(内存开销)、每个线程的阻塞切换都要内核介入,绝大多数线程都在睡。这就是 C10K 问题。

解法的核心是就绪通知(readiness notification):

  • select / poll:每次调用都要把整个监听集合交给内核,内核逐个扫描哪些就绪 —— 每次调用的代价随监听总数 n 增长,记为 O(n)。连接多、活跃少时,绝大部分扫描是浪费。
  • epoll(Linux)/ kqueue(BSD):文件描述符注册一次,内核维护兴趣列表;有事件时只把就绪的那些还给你。就绪通知这一步不随监听总数增长,可以记为 O(1)(代价正比于活跃连接数,不是总连接数)。于是一个线程就能照看大量连接。
  • io_uring(一句话):用内核与用户态共享的提交 / 完成环形队列,把「发起 I/O」和「收割结果」变成批量、少系统调用的真正异步接口,是 epoll 之后 Linux I/O 的下一站。

注意措辞:epoll 的 O(1) 说的是「就绪通知不用全量扫描」,不是「整个服务 O(1)」。业务处理、数据拷贝、协议解析的代价照旧,epoll 只解决了「怎么知道谁就绪」这一步。

6. GIL:CPython 的一把全局锁#

GIL(Global Interpreter Lock)是 CPython 的实现细节,不是 Python 语言的规范 —— Jython、一些无 GIL 实现都没有它。它的作用是:同一时刻只允许一个线程执行 Python 字节码。

由此得出的两条结论,缺一不可:

  1. 多线程不能并行跑 Python 字节码 —— 多个线程在多核上跑纯 Python 计算,不会变快,反而因为抢锁更慢。CPU 密集的任务要绕开它(多进程 multiprocessing,每个进程有独立的解释器和 GIL;或把热点下沉到释放 GIL 的 C 扩展,如 NumPy)。
  2. 但 I/O 密集的多线程仍然有用 —— GIL 在阻塞式 I/O 调用期间会被释放(部分耗时 C 扩展也会主动释放)。所以一个线程卡在网络等待里时,别的线程可以照跑。「GIL 让多线程完全没用」是错的,正确的说法是「GIL 让多线程无法用于 CPU 并行,但仍然适合 I/O 并发」。

留白与时效:无 GIL 的 CPython(PEP 703,free-threaded 构建)仍在演进中,默认是否启用、生态兼容到什么程度在变。面试里把它当作「方向」说,别当作既成事实背。

7. 选择判据:先看瓶颈是等还是算#

场景 首选 为什么
CPU 密集 多进程(或原生 / 别的语言) 要的是并行:占满多核;进程各有 GIL,互不干扰
I/O 密集、连接多 异步 / 协程 单线程事件循环就能扛住大量并发等待,省线程栈与切换开销
I/O 密集、逻辑简单 线程池 写起来最直白,把阻塞调用丢进去即可;连接数不至于爆炸时够用
两者混合 分离:I/O 走事件循环,CPU 活挪出去 保持 loop 不被长计算卡住

判据一句话:先量「时间花在等外部资源上,还是花在算上」。等占大头 → 异步 / 协程;算占大头 → 并行(多进程)。也可以用 Little 定律(L=λWL=\lambda W,并发数 = 到达率 × 平均停留时间)反推:给定到达率和服务时长,需要同时「在办」多少请求 —— 这个数直接决定你该选线程还是协程。

8. 接回 Agent:为什么运行时默认是异步,但工具必须隔离#

  • Agent Runtime 是 I/O 密集的。 一轮 Agent 循环里,绝大部分墙上时间在等模型返回、等工具返回。所以「事件循环 + 异步」是自然形态,../04-agent-architecture/11-async-event-driven.md 讲的正是在这套循环之上怎么管事件、安全点与取消(那篇是 Agent 语义层,本篇是它脚下的系统层)。
  • 但工具执行本身往往不是 I/O 密集的。 编译代码、跑测试、处理大文件、装依赖 —— 这些是 CPU / 内存密集,一旦内联执行,就会把事件循环焊死,期间同 loop 上所有别的任务一起停摆。
  • 正确做法:工具执行挪出 loop,放进独立进程 / 沙箱。 隔离强度怎么选(进程 / 容器 / microVM / 完整 VM、以及「沙箱要防三个方向」)见 ../06-agent-infra/10-agent-runtime.md §2。一个 30 秒的 CPU 循环,不应该让一百个 Agent 任务陪着一起等。
  • 一条能直接背的判据:「等什么」和「算什么」要放在不同的执行体上。 等模型的代码留在事件循环里,算东西的代码丢到进程 / 沙箱里去 —— 这正是 Agent 平台把「编排」与「工具沙箱」分成两层的底层原因之一。

常见面试问法#

Q:进程、线程、协程有什么区别? A:从三个维度答:隔离边界、调度主体、切换代价。 ① 进程有独立地址空间,是最强隔离;线程共享同一地址空间;协程在同一线程内。② 进程和线程由内核调度(抢占式),协程由运行时 / 用户态调度(协作式,只在 await / yield 处让出)。③ 切换代价 协程 < 线程 < 进程:协程是纯用户态保存寄存器;线程要陷入内核但不换地址空间;进程还要换页表、打掉 TLB,所以最贵。数量级上进程 / 线程是几百到几千,协程能到几万以上,因为用户态任务数可以远超内核线程数。

Q:阻塞 / 非阻塞 和 同步 / 异步,区别是什么? A:两个正交维度,别混。 阻塞 / 非阻塞说的是一次调用的返回时机(资源没好时是把你挂起,还是立刻返回「还没好」);同步 / 异步说的是结果怎么交付(一直等着拿,还是稍后回调 / future 送回来)。四个组合都存在:read() 是阻塞 + 同步;忙等轮询是非阻塞 + 同步;epoll + 事件循环是非阻塞 + 异步;把阻塞调用丢进线程池、调用方拿 future 是阻塞 + 异步。所以「非阻塞就是异步」是错的 —— 非阻塞也可以同步(忙等)。

Q:为什么高并发服务用异步?epoll 比 select 强在哪? A:因为瓶颈是「等」不是「算」。thread-per-connection 在连接上万时会崩:每线程有栈开销,阻塞线程还要内核介入,绝大多数线程在睡觉(C10K)。异步把「等」变成不占线程的结构:一个事件循环照看海量连接。差别在就绪通知:select / poll 每次调用都要把整个监听集合交给内核逐个扫描,代价随监听总数 n 增长(O(n)),而连接多、活跃少时大部分扫描是浪费;epoll / kqueue 注册一次,内核只把就绪的还给你,就绪通知不随监听总数增长(记 O(1),实际代价正比于活跃连接数)。再往前一站是 io_uring:共享环形队列、批量提交与收割、少系统调用。

Q:Python 有 GIL,是不是多线程就没用了? A:不能这么说,要分任务类型。 GIL 保证同一时刻只有一个线程执行 Python 字节码,所以多线程无法并行跑 CPU 密集的纯 Python 计算 —— 这时该用多进程(各进程独立 GIL)或释放 GIL 的 C 扩展。但 GIL 在阻塞式 I/O(及部分 C 扩展)期间会释放,所以多线程对 I/O 密集任务仍然有效:一个线程等网络时,别的线程照跑。准确说法是「GIL 挡住的是 CPU 并行,不是 I/O 并发」。另外它是 CPython 的实现细节,不是语言规范;无 GIL 构建仍在演进,别当既成事实背。

Q:Agent 平台该用哪种并发模型? A:分两层看,因为它的负载是「外层 I/O 密集、内层 CPU 密集」。 外层 —— Agent 循环大量时间在等模型、等工具,属 I/O 密集,默认用事件循环 + 异步,一个 loop 照看很多任务。内层 —— 工具执行(编译、测试、装依赖)是 CPU / 内存密集,必须挪出 loop、放进独立进程 / 沙箱,否则会把整个循环卡死。判据是 「等什么」和「算什么」放不同执行体。隔离强度与工具环境怎么选,见 ../06-agent-infra/10-agent-runtime.md。

易错点#

易错说法 问题 应该说
「协程/异步能并行」 混淆并发与并行 协程是单线程协作式调度,给的是并发不是并行;同时只跑一段代码
「非阻塞就是异步」 两个维度被压成一个 阻塞/非阻塞 = 返回时机;同步/异步 = 结果交付;非阻塞也可同步(忙等)
「多线程就是并行」 漏了前提 并行要多核 + 无 GIL 等前提;同核上线程只是分时并发
「进程比线程慢,就因为重」 没说清差在哪 差在换地址空间 + TLB;线程切换省掉这步,但仍是内核态往返
「GIL 让 Python 多线程完全没用」 忽略释放时机 GIL 在阻塞 I/O 时释放;I/O 密集仍可用多线程,CPU 并行才要绕开
「连接多就多起线程」 选错执行体 线程有栈开销与切换成本;连接上万应上事件循环 / 协程
「epoll 是 O(1),所以服务整体 O(1)」 夸大范围 O(1) 指就绪通知不扫描全量;业务处理、拷贝、解析代价照旧
「工具跑在事件循环里没关系」 会卡死整个 loop CPU / 内存密集工具要挪出 loop、放独立进程 / 沙箱(见 ../06-agent-infra/10-agent-runtime.md)
把本篇讲成「我们线上就是这么调的」 触碰仓库硬规矩 这是公开资料的系统知识,非一手经验;用「这套账我会这么算」口径讲

自测#

1. 面试官说:「我们用 Go 的 goroutine,几万个并发很轻松;你用 Python 线程,几百个就吃力。差别在哪?」你怎么答?

答案

核心差别不是「语言快慢」,而是「执行体的调度层次不同」。

  • goroutine 是用户态 / 运行时调度的协程。 切换是纯用户态保存寄存器与栈,代价极低;而且它是 M:N —— 几万个 goroutine 跑在少量内核线程上,内核完全不知道它们的存在。
  • Python 线程是内核线程(1:1)。 每个线程都有独立的栈(内存开销),每次调度都要内核态往返,数量一多,内存和上下文切换成本一起上。
  • 再叠一层 GIL:CPython 里就算起了很多线程,同一时刻也只有一个能跑 Python 字节码,所以线程多并不能带来 CPU 并行。

所以正确的表述是:goroutine 赢在用户态调度 + 更小的切换代价 + M:N 映射;Python 的线程瓶颈在内核线程的固定开销,而 GIL 又限制了它在 CPU 密集下的收益。注意不要说成「协程能并行」 —— goroutine 几万并发是高并发,不是几万核并行。

2. 有人写了个服务:async def handle() 里先 await 调模型,再不 await** 地跑一段 5 秒的本地计算,最后 await 写数据库。上线后高峰期大量请求超时。为什么?**

答案

那段不 await 的 5 秒计算把事件循环卡住了。

  • 事件循环是协作式的:一段协程只有在碰到 await(让出点) 时才会把控制权交回循环。在两次 await 之间,它一口气跑完、不受抢占。
  • 中间那 5 秒计算没有让出点,于是整个 loop 停摆 5 秒 —— 期间所有别的协程、所有连接一起卡住,不只是这一个请求。
  • 请求一多、计算一叠加,超时面迅速扩大。

修法:把CPU / 内存密集的部分挪出事件循环 —— 丢到线程池 / 进程池,或独立的进程 / 沙箱(Agent 场景下的做法见 ../06-agent-infra/10-agent-runtime.md)。判据就是那句话:「等什么」留在 loop 里,「算什么」挪出去。

别答反:这不是「asyncio 有 bug」,也不是「服务器核不够」—— 是用错了执行体。

3. 一个网关要同时代理上万个长连接,大部分时间只是转发很少的数据。你会选:每个连接一个线程、一个线程池、还是事件循环?说清判据。

答案

选事件循环(异步 I/O)。判据是「瓶颈在等,不在算」。

  • 上万个长连接、数据很稀 —— 典型 I/O 密集、活跃比例低。select / poll 会为每个请求逐个扫描全部监听 fd(O(n)),绝大部分是空扫;epoll / kqueue 注册一次、只返回就绪的(就绪通知记 O(1),实际代价正比于活跃连接数),刚好匹配这个分布。
  • thread-per-connection 会崩(C10K):每线程有栈开销、阻塞时内核介入,上万线程绝大部分在睡觉 —— 内存和调度一起爆。
  • 线程池适合「I/O 密集但连接数不至于爆炸、逻辑直白」的场景;上万个长连接更适合事件循环。

要补的两句边界:①「epoll 是 O(1)」指的是就绪通知,不是整条转发链路;② 转发逻辑里若混入 CPU 密集处理,同样要挪出 loop。

4. 「我们的服务是 CPU 密集的,多开线程就能提速吗?」—— 用 Node.js 和 CPython 各答一遍。

答案

关键看这个运行时的执行体是「能不能真并行」。

  • CPython:不行。GIL 让同一时刻只有一个线程执行 Python 字节码,多线程跑纯 Python 计算不会变快(抢锁还可能更慢)。要 CPU 并行就用多进程(各进程独立 GIL),或把热点下沉到释放 GIL 的 C 扩展(如 NumPy)。
  • Node.js:同样不行。主线程是单线程事件循环,多起「任务」也不会并行;CPU 密集要么用 worker_threads(真线程,但要注意没有 GIL 但有别的同步成本),要么把计算移出主进程。
  • 通用判据:CPU 密集要的是「并行」,就得落到多个核上,且要确认运行时没有全局锁挡住并行 —— CPython 的 GIL 就是典型的挡路者。

别答成「多线程一定更快」:无论哪种运行时,都要先问「这个运行时的线程能不能真的并行执行我的计算」。