从进程到协程:计算机的并发编程之路

2 天前
 HikariLan

从第一台分时操作系统的横空出世,到 Intel 推出双核 CPU 打破摩尔定律的诅咒,新的技术迫使人们不断探索并发编程之路,以试图触碰人类几千年以来知识结晶的最大高度。

引言

如果你了解过计算机操作系统的历史,那么你一定知道,早期的计算机操作系统并不支持多用户功能,这是因为单个 CPU 无法同时处理来自多个用户的输入输出,同样,程序也无法同时运行,只能按顺序运行。后来被发明的分时操作系统解决了这个问题,同时也为程序员带来了“并发”的概念。

在计算机科学中,“并发( Concurrency )”描述的是一种计算机程序的运行状态,即通过时间片轮转的方式,允许多个计算机程序在一段连续时间内以一定机制在一个或多个 CPU 核心上轮流运行,以营造一种所有计算机程序在同时运行的假象

诚然,这种基于操作系统抢占式调度的时间片轮转机制对于应用程序开发者是透明的,但是随着应用程序规模的不断膨胀和用户更多的需求产生,应用程序开发者意识到他们有时需要占用不止一个 CPU 资源,于是,并发编程应运而生。

多进程模型

早期操作系统其实是没有线程的概念的,一个进程只能有唯一一个线程存在,进程同时担任着最小的资源分配单位和最小的 CPU 调度单位的职责。在这种情况下,多进程是非常自然就能想到的并发模型。在这种模型下,进程与进程之间通过管道、Socket 等机制进行数据交换,并使用操作系统提供的并发原语来进行同步。

多线程模型

后来人们发现,进程并不适合担任 CPU 调度的最小单位,因此,线程横空出世。线程最大的特点是与同一个进程内的其他线程共享地址空间和操作系统资源(比如 I/O 句柄),这使得操作系统在调度到同一个进程的其他线程时只需要更换程序调用栈( CPU 寄存器)即可,避免了高额的性能开销;同时,由于线程共享地址空间,线程与线程之间交换数据可以通过更高效(尽管有时并不安全)的共享内存方式实现,进一步优化了程序的运行效率。

同时,基于共享的地址空间,一种比操作系统并发原语更轻量的同步方式也应运而生,那就是 CAS 。

从 Mutex 到 CAS:我们是否真的需要操作系统介入同步

在传统的并发同步过程中,人们经常使用以 Mutex 互斥锁为主的各种并发原语以保证多个线程的执行顺序符合预期:

var value = 0;

var mutex = new Mutex()

func setValueWithMutex() {
	mutex.lock() // syscall here
	
	// critical section
	value++;
	
	finally mutex.unlock() // syscall here
}

Mutex 仅允许一个线程对其进行加锁,如果其他线程试图为一个已加锁的 Mutex 继续加锁,那么该线程会被阻塞,直到 Mutex 被解锁。这种机制成功的保证了同一时刻内仅有一个线程可以进入被锁机制保护的临界区,保证了并发安全。

但是人们随后注意到,这种并发同步机制其实是一种悲观思想,即,锁机制总是认为线程会试图进入已被其他线程进入的临界区,因此,无论临界区是否确实被其他线程进入,应用程序都需要试图向操作系统申请锁 —— 这种频繁的 syscall 导致的用户态/内核态上下文切换无疑对应用程序性能产生了挑战。

于是,一种基于用户态的同步机制 —— CAS 同步机制被提出。CAS 是 Compare-And-Swap 的缩写,意为 “比较并交换”,其本质是由 CPU 提供的一系列指令,由 CPU 保证原子的执行以下操作:

func compareAndSwap(ref value, newValue, expectedValue) {
   if (value != expectValue) return false
   value = newValue
   return true
}

一句话来讲,就是(原子的)比较某个内存地址的值是否符合期望值,如果符合,则将一个新值插入,否则什么都不做。

籍此指令,我们可以制造一个新的无锁并发机制:

volatile var value = 0

func setValueWithCAS() {
  while (true) {
    var currentValue = value;
    var newValue = value + 1;
    if (compareAndSwap(value, value + 1, currentValue)) {
      break;
    }
  }
}

在上述代码中,线程将不断执行 CAS 指令以设置变量值,如果设置失败(说明有其他线程抢先设置了),则重新设置。CAS 始终假设没有其他线程试图抢占设置值,因此是一种乐观的并发机制。由于整个过程并不需要进行内核上下文切换,(在写冲突不多的情况下,)这种乐观机制的性能要远好于使用操作系统互斥锁的悲观机制,CAS 机制的发明也间接提醒了人们,实现预期的并发编程并不一定要依赖操作系统调用的支持。

事件循环和 I/O 多路复用

多线程模型看起来很好,但是却忽略了线程本身的性能开销:操作系统创建一个线程大约需要占用 8 KB 左右的物理内存空间,而对于需要高并发的应用程序,这无疑对物理机的物理内存空间提出了很大的挑战(别忘了我们还没有考虑应用程序线程栈的大小和进程堆的内存占用)而更重要的是,对于 I/O 密集型应用程序(例如 Web Server ),一个线程的大多数时间可能并没有在占用 CPU 资源进行计算,相反,它们多在因等待操作系统的 I/O 系统调用返回而陷入阻塞 —— 而这部分线程的内存无疑被浪费了。为了解决这个问题,操作系统提供了 I/O 多路复用的功能,允许应用程序在单线程中一次处理多个 I/O 请求。

不同操作系统面向 I/O 多路复用提供了不同的解决方案,我们这里以 Linux 操作系统的 epoll 系统调用(水平触发模式)作为例子,创建一个简易的 echo 程序:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <string.h>

#define MAX_EVENTS 1024
#define BUFFER_SIZE 1024

int epoll_fd;

int coming_events_cnt;
struct epoll_event coming_events[MAX_EVENTS];

void on_request(const int fd) {
    struct epoll_event event;
    event.events = EPOLLIN | EPOLLOUT;
    event.data.fd = fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) {
        perror("epoll_ctl");
        close(epoll_fd);
        exit(EXIT_FAILURE);
    }
}

void create_epoll() {
    epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }
}

void destroy_epoll() {
    close(epoll_fd);
}

void epoll() {
    coming_events_cnt = epoll_wait(epoll_fd, coming_events, MAX_EVENTS, -1); // block until any event is available
    if (coming_events_cnt == -1) {
        perror("epoll_wait");
        close(epoll_fd);
        exit(EXIT_FAILURE);
    }
}

int handle_events() {
    for (int i = 0; i < coming_events_cnt; i++) {
        char buf[BUFFER_SIZE];
        const ssize_t count = read(coming_events[i].data.fd, buf, sizeof(buf));
        if (count == 6 && strncmp(buf, ".exit", 5) == 0) {
            return 0;
        }
        if (count > 0) {
            write(coming_events[i].data.fd, buf, count);
        }
    }
    return 1;
}

int main() {
    create_epoll();

    on_request(STDIN_FILENO);

    do {
        epoll();
    } while (handle_events());

    destroy_epoll();
    return 0;
}

首先 create_epoll 函数向操作系统创建了 epoll 实例,on_request 函数将需要监听的 I/O 事件传入 epoll,接下来程序通过不断调用 epoll 函数,检索是否有可以读取的 I/O 流,在本例中,一旦用户从标准输入流输入数据,那么原本被阻塞的 epoll_wait 函数便会返回产生变动的的 I/O 流事件数组,随后 handle_events 函数即可循环处理这些发生更改的 I/O 流(本例直接将输入的内容输出回控制台,如果输入了 .exit,还会直接退出循环),最后 destroy_epoll 函数向操作系统宣告可以销毁 epoll 实例。通过这种模式创建的应用程序,仅需要单线程便可同时处理多个 I/O 请求,相比多线程模型来说要高效得多。

这便是 I/O 多路复用的工作原理,而这种通过不断轮询查询是否有新的事件产生的模式就是事件循环,如果你是一名 JavaScript 程序员,那么你对它一定很熟悉,因为 JavaScript ( V8 )的的异步任务系统就是基于事件循环机制建立起来的,同样 Redis 也采用了这种高效的模型来帮助它处理用户请求。

不过聪明的你也一定发现了,I/O 多路复用机制需要操作系统的系统调用支持才可完成,除了可能产生的系统调用开销外,这种机制并不能通用化的在所有操作系统中运行(例如 Java NIO 就支持在 Linux 操作系统下使用 epoll 处理 I/O 请求),而且,从多线程模型迁移到 I/O 多路复用模型需要更改原有的程序架构,也对开发者存在一定心智负担。

走向用户态并发:协程

说了这么多,那么有没有一种又不会占用太多物理内存,又不需要操作系统参与,使用起来心智负担又不高的的技术呢?虽然计算机科学没有免费的午餐,但是在并发编程这条路上,计算机科学家们还是找到了一条足够低价的并发编程大餐 ——协程。

协程( Coroutine )是一种用户态线程,其上下文切换完全由应用程序管理,对操作系统透明。由于不涉及操作系统参与,无需内核态切换,协程可以实现低成本的并发,并且由于协程栈相比线程栈要小得多,因此可以轻易支持数以万计的协程创建。不过要说明的是,协程只是更好的事件循环,可以提供低成本的并发,但(在同一个线程中)却不能像真正的操作系统线程一样并行运行

在协程的世界中,有三个重要的概念:延续( Continuation )挂起( Suspend )恢复( Resume )延续是一个上下文集合,包含了协程的全部上下文(类似于线程栈);挂起用于暂停协程当前的工作,保存上下文现场;恢复则相反,用于读取上下文现场,恢复协程工作。当然,上述概念只是笼统地概括,你很快会发现,不同协程方案之间在保存和读取上下文中有一些区别。将不同语言使用的协程模型进行分组,协程大体上可以被分为有栈协程无栈协程两种。

有栈协程和无栈协程

从用户角度来看有栈协程和无栈协程的话,前者使用上和正常的线程完全相同,用户完全可以以使用线程的方式使用协程,看不出一点区别(例如 Go 的 goroutine 、Java 的 Virtual Thread );而无栈协程,如果你用过 async/await 或是 yield 这样的关键字,那么这些语言则支持无栈协程(当然也不完全如此,例如 C# 的 async2 机制便是通过 JIT 自动插入 async/await 代码,不需要用户手动添加)。

当然上述区别都是较为感性的表象区别,而有栈协程和无栈协程实际上的关键区别,则是运行时是否存在函数调用栈。有栈协程系统可以通过直接切换协程的函数调用栈以进行调度,这使得协程恢复后可以像操作系统恢复进程/线程上下文一样,将协程的程序计数器直接跳转到挂起前的位置,显然,这种支持是需要对程序运行时做一些改造的;无栈协程则通过一个对象(延续)保存函数运行中所需的全部上下文信息,函数需要在适当的时机(挂起点主动让出协程的使用权,保存上下文信息到延续中,提前返回函数(但在用户看来并没有返回),以待随后从后续的调度中恢复,当需要恢复函数时,函数会被重新调用,并根据保存的状态恢复执行。

如果用伪代码表示无栈协程的运行模式,大概是这样的:

// origin version

func foo() {
	string returnValue = ""
	doSomething();
	returnValue = await doAnotherThing()
}

func main() {
	println(await foo())
}

// compiled version

struct Continuation {
	context: Record<string, object>
	state: 0 | 1 | 2
}

func suspend(continuation, nextState) {
  continuation.context = collectFuncContext()
  continuation.state = nextState
  switchToAnotherCoroutine();
}

func resume(continuation) {
	resumeFuncContext(continuation)
}

func foo(continuation) {
	resume(continuation)

	switch(continuation["state"]) {
	  case 0:
	  	doSomething()
	  case 1:
	  	continuation["returnValue"] = doAnotherThing()
	  	suspend(continuation, 2)
	  	return
	  case 2:
	  	return context["returnValue"]
	}
}

func main() {
	var continuation = Continuation {
		context: {},
		state: 0
	}
	var returnValue;
	while(continuation.state != 2) {
		returnValue = foo(continuation)
	}
	println(returnValue)
}

foo 函数被编译器切割成不同的代码单元,执行方通过轮询( Poll )编译后的 foo 函数,直到函数正常返回(而不是挂起返回)。很容易注意到,无栈协程的本质其实是状态机,其通过延续的状态将函数恢复到上一次挂起的位置,这也导致无栈协程仅能在编译器插入的挂起点被挂起。

整体来看的话,有栈协程和无栈协程的主要区别可以列表如下:

区别 有栈协程 无栈协程
有单独的程序调用栈
无需运行时支持
调试友好
无需显式切换上下文[^1]
支持在任意函数处挂起
更小的上下文切换开销

[^1]: 指应用程序是否需要通过手动添加类似 async/await 或 yield 关键字的方式手动挂起协程函数(也即协作式调度模式)

绿色线程:Java 早期对用户态并发的一次探索

在协程早已遍布现代程序语言的 2025 年,很少有人注意到,其实早在上世纪,Java 便引入了自己的“协程”支持,被称为“绿色线程”。

绿色线程是一种由运行环境或虚拟机调度,而不是由本地底层操作系统调度的线程。绿色线程并不依赖于底层的操作系统提供的支持,而是通过模拟来实现运行多线程,这种线程的调度发生在用户空间而不是内核空间,所以它们可以在没有原生线程支持的环境中工作

不过遗憾的是绿色线程并不支持在多个线程上工作(正如 goroutine 所做的那样),而且绿色线程一旦阻塞,所有绿色线程所在的整个操作系统线程都会被阻塞,最后只有 Solaris 操作系统下的 JVM 使用了这种模型;后续的 Java 版本也放弃了这种线程,改为使用操作系统线程。当然在 Java 21 中,Java 也引入了更完善的协程:虚拟线程( Virtual Thread )。

后记

这便是计算机并发编程的前世今生,从进程到协程,人们不断探索低成本且方便的并发编程方式,以期在最大化资源利用的同时,最大限度地降低开发者的心智负担。

本文编写耗时两天,部分内容可能并不准确,如有错误请不吝赐教!

3170 次点击
所在节点    Linux
27 条回复
vkillwucy
2 天前
赞👍🏻
xierqii
2 天前
👍
ShadowPower
2 天前
写得挺好的,我第一次知道 async/await 的实现方式
lisongeee
2 天前
无栈协程 还有一个好处是无需升级运行环境

比如可以将 js 的 async/await 语法转译到低版本 api 即可在低版本浏览器上运行

同理也可以在 jdk8 的环境上运行 kotlin 协程

与之相反就是你不能将 java19 的虚拟线程代码编译到 jdk8 上运行
HikariLan
2 天前
@lisongeee 文章里提了一嘴,就是「无需运行时支持」
nicoley
2 天前
先赞后看👍
agagega
2 天前
👍

还想到文章里没有提到的几个问题:

- IO 多路复用和异步 IO 在概念上有点区别,后面也出来了 io_uring 这个东西
- 类 Unix 系统和 Windows 似乎对多进程/多线程有不同偏好
- 最开始接触协程的时候我很难把「 async/await 」「生成器」「协程」这三个东西联系起来,最核心的问题在于:不明白当 await/yield 的时候,到底把程序执行权交还给了谁?后来才理解需要有个 Runtime ,自己实现过当然能理解,但很多文章并不会深入提及
- 有栈协程也需要调度器,而且需要维护完整的调用栈,这个栈怎么分,分多少,溢出了怎么办,似乎都是问题
- 记得学操作系统的时候,书上都会提到 M:N 线程模型,这个概念上就和现在的虚拟线程看起来差不多,但以前反而认为这是一种被淘汰的技术,为什么当时不行,现在又复兴了?
- 无栈协程的上下文约可以看作状态机里的某个状态+当前所有对象的值,想到闭包其实也相当于持有被捕获变量的一个对象+一个 call 方法,总觉得这俩形式上很像,能导向某种共通的东西
kandaakihito
2 天前
漂亮漂亮漂亮漂亮,先收藏一下,明天上班时间再次细看。
起码光是有栈无栈协程的介绍,就已经比简中大部分的文章要来得清晰明了。
netabare
2 天前
我对协程和线程的理解倒是,协程就像函数一样是有组合性,可以自由组合、混入的。线程就没有这个能力。不过这个视角可能对于自底向上从操作系统角度看进程和协程来说没有太大意义。
iintothewind
2 天前
不是很认同 async-await 这样的异步处理就一定是协程, 对于 js, python 来说是的, 但类似的语法在比如未加入 virutal thread 特性的早期版本 jvm 上也是可以实现的. 但底层执行的时候还是内核级线程在执行的.

所以我认为最本质的区别还是看最低粒度下任务的上下文调度和执行方式是在用户级还是内核级, 完全由应用程序维护的就是协程, 需要系统做调度的就是线程.

至于 async-await, promise, future, 还是 message channel, 只是异步上语法实现上的差异, 本质都是解耦不同任务之间的调度, 以及上下文传递方式上的不同实现而已.
2218431632
2 天前
“通过这种模式创建的应用程序,仅需要单线程便可同时处理多个 I/O 请求,相比多线程模型来说要高效得多。”既然是单线程去处理多个 I/O 请求,那怎么可以同时去处理?只有一个线程,同一时间不是只能处理一个 I/O 请求吗?
JackyYang
2 天前
NB ,感谢分享
HikariLan
2 天前
@2218431632 操作系统会负责帮你「监听」多个 I/O 请求,然后在有至少一个请求可用的时候通知你,这时你只需要遍历这些可用的请求然后进行处理就行
mightybruce
2 天前
先不要把进程和协程 和 IO 放在一起谈,另外像 cas 指令不同 cpu 架构 x86, arm 下实现是不一样的。
mightybruce
2 天前
协程这个东西,要理解其实最好还是用 c 语言实现实现比较好, 我提供一个 github 链接参考
https://github.com/theanarkh/read-libtask-code
Gress
2 天前
@agagega - 记得学操作系统的时候,书上都会提到 M:N 线程模型,这个概念上就和现在的虚拟线程看起来差不多,但以前反而认为这是一种被淘汰的技术,为什么当时不行,现在又复兴了?
------------------------------------------------------------------------------------------
屎真香。就跟以前 Java 的框架都喜欢用 XML ,现在又用回代码配置一样的
w568w
2 天前
「无栈协程=状态机」这样的思想在 Rust 这种无默认运行时的语言中表现得更明显: https://doc.rust-lang.org/stable/std/future/trait.Future.html

从上面的文档可以看到,Rust 对 Future 的定义就是:「一个有 poll(context) 方法的对象,每次执行返回 Pending 或者 Ready(result) 两种状态之一」。任何实现了 poll 方法的对象都被承认为一种协程。
1125677925
2 天前
能转发吗?
HikariLan
2 天前
@1125677925 🉑,标注原作者和 CC BY-SA 4.0 协议就行
lesismal
2 天前
踏踏实实的技术帖, 比隔壁凭借哪些特质走到现在可是赞太多了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://tanronggui.xyz/t/1110155

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX