Skip to content

约 6 分钟阅读

进程、线程和协程:谁在替请求干活 ​

订单服务同时收到一千个请求,总得有“人”去执行它们。这个“人”可以是一个进程、一个线程,也可以是一个协程。三个词经常被混着用,但它们在内存里的样子、创建的代价和出问题时的影响范围差别很大。读完这一篇,你应该能说清三者分别是什么、彼此是什么关系,并能在 Linux 上数出一个服务实际有几个进程和线程。

先看实验:同样扣一次库存,结果不一样 ​

下面的实验做两件事。第一件:一个全局变量 stock = 100,分别让一个线程和一个子进程把它减 1,看主程序事后看到多少。第二件:分别创建一批进程、线程和协程,让它们都停在“等待”状态,比较创建一个要花多久、占多少内存。保存为 workers.py:

python
import asyncio, os, threading, time

def rss_kib():
    for line in open("/proc/self/status"):
        if line.startswith("VmRSS:"):
            return int(line.split()[1])

def smaps(pid):
    fields = dict(line.split(":", 1) for line in open(f"/proc/{pid}/smaps_rollup") if ":" in line and not line.startswith(("0", "a", "f")))
    kib = lambda k: int(fields[k].split()[0])
    return kib("Rss"), kib("Private_Clean") + kib("Private_Dirty")

stock = 100

def sell_one():
    global stock
    stock -= 1

t = threading.Thread(target=sell_one); t.start(); t.join()
print(f"线程扣减一次后,主程序看到 stock = {stock}")
pid = os.fork()
if pid == 0:
    sell_one()
    os._exit(0)
os.waitpid(pid, 0)
print(f"子进程扣减一次后,主程序看到 stock = {stock}")

N = 200
r, w = os.pipe()
t0 = time.perf_counter()
children = []
for _ in range(N):
    pid = os.fork()
    if pid == 0:
        os.close(w)
        os.read(r, 1)          # 等父进程关掉写端
        os._exit(0)
    children.append(pid)
cost = (time.perf_counter() - t0) / N * 1e6
time.sleep(1)
rss, private = (sum(v) / N for v in zip(*(smaps(p) for p in children)))
print(f"进程   ×{N}:创建 {cost:6.0f} µs/个,RSS {rss:6.0f} KiB/个,其中独占 {private:4.0f} KiB/个")
os.close(w)
for p in children:
    os.waitpid(p, 0)

N = 1000
base, done = rss_kib(), threading.Event()
t0 = time.perf_counter()
threads = [threading.Thread(target=done.wait) for _ in range(N)]
for t in threads:
    t.start()
cost = (time.perf_counter() - t0) / N * 1e6
print(f"线程   ×{N}:创建 {cost:6.0f} µs/个,内存 {(rss_kib() - base) / N:5.1f} KiB/个,"
      f"/proc/self/task 有 {len(os.listdir('/proc/self/task'))} 项")
done.set()
for t in threads:
    t.join()

async def coroutines(n):
    gate = asyncio.Event()
    base = rss_kib()
    t0 = time.perf_counter()
    tasks = [asyncio.create_task(gate.wait()) for _ in range(n)]
    await asyncio.sleep(0)
    cost = (time.perf_counter() - t0) / n * 1e6
    print(f"协程 ×{n}:创建 {cost:6.1f} µs/个,内存 {(rss_kib() - base) / n:5.1f} KiB/个,"
          f"线程仍是 {len(os.listdir('/proc/self/task'))} 个")
    gate.set()
    await asyncio.gather(*tasks)

asyncio.run(coroutines(100_000))
bash
docker run --rm -v "$PWD":/lab -w /lab capacity-lab python workers.py

实际输出:

text
线程扣减一次后,主程序看到 stock = 99
子进程扣减一次后,主程序看到 stock = 99
进程   ×200:创建    188 µs/个,RSS  21176 KiB/个,其中独占  663 KiB/个
线程   ×1000:创建     60 µs/个,内存  17.6 KiB/个,/proc/self/task 有 1001 项
协程 ×100000:创建    2.0 µs/个,内存   0.9 KiB/个,线程仍是 1 个

前两行最值得注意。线程把 stock 从 100 减到 99,主程序看到了 99;子进程又减了一次,主程序看到的还是 99,子进程改的是它自己的那一份。后三行是代价:一个进程的创建时间是线程的三倍左右,独占内存是线程的三十多倍;协程又比线程便宜一个数量级以上,十万个协程只占了一个线程。这个实验跑了三次,进程创建在 188–278 微秒、线程在 55–94 微秒之间波动,内存数字三次一致。

进程:一个独立的工作间 ​

进程、线程和协程在内存中的关系

进程是一个正在运行的程序,加上操作系统分给它的全部资源:一块独立的虚拟地址空间(代码、全局变量、堆)、打开的文件和 Socket、当前目录、用户身份。可以把它想成一个独立的工作间,里面的东西别的工作间看不见、碰不到。

子进程刚 fork() 出来时,它的地址空间是父进程的一份副本。Linux 并不立刻复制内存,而是让父子共用同一批物理页,谁要改某一页,才给谁复制一份,这叫写时复制。所以实验里每个子进程的 RSS 有 21 MiB,但其中真正独占的只有约 660 KiB,其余都和父进程共用。子进程执行 stock -= 1 时,被改的那一页复制了一份,父进程那一页不受影响,于是主程序看到的还是 99。

这种隔离是进程最大的价值:一个进程崩溃、内存越界或者泄漏,不会直接破坏其他进程。代价是进程之间交换数据必须经过操作系统,比如管道、Socket、共享内存或者消息队列,不能像改变量那样直接改。

实验里的进程是从一个已经运行的 Python 进程 fork() 出来的,所以很快。如果是从头启动一个新程序,还要加载可执行文件和库、初始化运行时。同样的镜像里,启动一个全新的 Python 解释器约需 6 毫秒,是 fork() 的三十倍;Java 这类运行时启动更慢。所以服务通常在启动时一次性创建好进程,而不是每来一个请求开一个。

线程:同一个工作间里的几个人 ​

线程是进程里的一条执行路线。同一个进程里的所有线程共用地址空间、打开的文件和全局变量,每个线程只自己保存执行到哪一行(寄存器和程序计数器)和一段调用栈。所以线程改的 stock,主程序马上能看到。

在 Linux 内核眼里,线程和进程都是“任务”(task),用同一个 clone() 系统调用创建,区别只在于共享多少东西。调度器以线程为单位分配 CPU。实验里 /proc/self/task 下有 1001 项,就是 1000 个线程加主线程,每一项是一个内核能独立调度的任务。

线程比进程便宜,因为不用复制地址空间的映射关系,只要分配一段栈和内核的任务结构。实验里每个线程实际占用约 17.6 KiB 内存;它的栈预留了 8 MiB 虚拟地址(由 ulimit -s 决定),但只有真正用到的部分才占物理内存。

共享内存让线程之间协作很方便,也带来了问题:两个线程同时改同一个变量,结果可能出错。这会在第 04 篇专门讨论。另外,一个线程的严重错误,比如空指针导致的段错误,会让整个进程连同所有线程一起退出。

协程:一个人手上的好几件事 ​

协程是可以在中途暂停、之后再从暂停处继续的函数。它不由内核调度,而是由程序自己的事件循环管理:协程执行到 await,比如在等数据库返回,就把控制权交回事件循环,事件循环挑一个已经可以继续的协程接着执行。整个过程都在一个线程里完成,内核根本不知道协程的存在,所以实验里十万个协程之后线程数还是 1。

协程便宜,是因为暂停时只需要保存这个函数自己的局部状态,不需要内核参与,也不需要单独的栈(Python 的协程如此;Go 的 goroutine 有自己的小栈,起始只有几 KiB)。代价是协作式的:协程只在 await 处让出。如果一个协程执行了一段很长的计算,或者调用了一个会阻塞的同步函数,整个事件循环上的所有协程都要等它。Python 里的具体表现和处理方法,见 asyncio 的事件循环模型 和 同步库与异步系统的边界。

三者对比 ​

进程线程协程
谁来调度内核内核程序里的事件循环或运行时
内存独立地址空间共用进程的地址空间,各有一段栈共用线程,只保存自己的局部状态
实验里的创建代价约 0.2 ms(fork),启动新程序约 6 ms约 0.06 ms约 0.002 ms
实验里的独占内存约 660 KiB约 17.6 KiB约 0.9 KiB
一个出错影响谁只有自己整个进程整个线程上的所有协程
什么时候切换内核决定内核决定只在 await 处

真实的服务往往把三者组合起来用。Nginx 启动几个工作进程,每个进程里一个事件循环管理成千上万条连接;Java 的 Tomcat 在一个进程里用线程池,一个请求占一个线程;Go 运行时把大量 goroutine 分配到少量系统线程上;Node.js 主要靠单线程事件循环,再用少量后台线程处理文件读写等操作。选择哪种组合,取决于请求是在等待还是在计算,这是下一篇的主题。

在服务器上数一数 ​

在 Linux 上,下面几条命令能看到一个服务实际有几个进程、几个线程(把 PID 换成服务的进程号):

bash
ps -eo pid,ppid,nlwp,rss,comm --forest | head -30   # nlwp 是每个进程的线程数
ps -L -p PID -o pid,lwp,stat,pcpu,comm              # 列出一个进程里的每个线程
grep Threads /proc/PID/status
top -H -p PID                                        # 按线程看 CPU

ps 里的 LWP 就是线程 ID。看到一个 Java 服务有两三百个线程,通常是线程池、垃圾回收和 JIT 编译线程加在一起,属于正常;如果线程数随时间一直上涨,多半是某处在不断创建线程而不回收。协程在这些命令里看不到,要用语言自己的工具,例如 Go 的 pprof 或 Python 的 asyncio.all_tasks()。

进程和线程的父子关系、状态和退出回收,在 Linux 进程管理 里有更详细的说明。

现在知道了谁在干活。下一个问题是:开了更多的线程,事情就能做得更快吗?答案取决于它们是在等待还是在计算。下一篇:02 并发和并行:等待型任务与计算型任务。

别急,先让缓存热一下。