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

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 )。

后记

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

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

3186 次点击
所在节点    Linux
27 条回复
lxdlam
2 天前
async/await 真正的含义并不是“显式切换上下文”,如此说来为何 goroutine 也需要 "go func()" 才能启动并发调度?这难道不是一种类似于 "yield something" 的显式切换上下文吗?

从 Delimited Continuation 的角度说,async/await 是 F#/C# 发明出来用来模仿 Delimited Continuation 的 shift/reset, prompt/control 算子的,这一定程度上解释了函数染色问题从何而来:我们需要显式标记哪些部分能够被编译期改写,这样才能准确地把 k : Continuation 传到这些部分。与之相对的是 Scheme 的 continuation ,任何 continuation 都是无界的,也就是说会捕获到整个 runtime ,这对一个**后续接入** continuation 的语言实现有巨大的 breaking change (在 chez 中执行 `(call/cc (lambda (cc) (cc cc)))`,你会得到 `#<system continuation in new-cafe>`)。

而这样说有一些吊书袋,我们不妨简单思考一下所谓的 coroutine 是怎么编译的?

当我们遇到一个类似于如下的代码

```
let coro = async { time.sleep(5000) }

// some busywork

await coro
```
时,我们单纯从语法考虑,我们如何将 coro 正确编译成我们想要的结构呢?我们自然能想到很多种做法,至少我们可以为 coro 创建一个简单的 reference object ,去轮询来看它是否完成,并把 await 自动重写成 while not coro.ref.isFinished() {} 的一个 busyloop 就可以了(对于有栈来说可以是用户态的 uthread.join(),对于无栈来说可以是 state in [Done, Exception])。

所以,本质来说,async/await 是定界,界定了到底哪些代码需要被识别成一个 remote track 的对象,并正确插入调度代码。

这个的后续遗留了更多好玩的问题,感兴趣的朋友可以从这些起点思考:
1. 根据前文描述,"go func()" 某种程度也是定界,那跟 async/await 的核心区别是什么?这将为你导向有栈和无栈最核心的区别,究竟“栈”如何让 goroutine 不需要考虑函数染色问题?
2. 如何实现一个可用级别的编译器,编译我们的协程?他们的底层模型其实很相似,但是要搞懂无栈协程的编译难度比较大,你可能需要真的理解 continuation 和 cps conversion 才能明白看似简单的东西背后有多少篇 paper 和大佬支撑。
songray
2 天前
其实我认为有栈协程和无栈协程这两个名字就不太好,实际上二者都没什么相似之处。
无栈协程本质是程序的挂起和恢复,是一种状态机转换机制,可以是纯编译器实现也可以是纯运行时实现。
而有栈协程就是通过运行时对线程进行模拟,把线程切的更小。

把这两种机制都叫"协程"容易造成混淆,因为它们的实现原理和使用场景都很不同。
我认为无栈协程应该直接叫「异步函数」,而有栈协程应该叫「纤程」或者「用户线程」。
songray
2 天前
@Gress 因为有栈协程也是有负担的,你要实现一个完整的的调度器。协程性能的收益必须要对冲掉直接使用多线程的开销才行。
所以互联网时代(高并发)这玩意才大规模流行。
CloveAndCurrant
2 天前
写的很好,比那些拿着 Wikipedia 当圣旨一知半解的人强太多👍
Edwinxedwin
1 天前
赞👍,感觉以后写文章也可以发布上来
James369
1 天前
所以文中代码 switchToAnotherCoroutine() 是如何切换到其他协程呢?
特别是如果引用了第三方模块,当第三方模块也存在协程时,那么主模块是怎么感知到第三方模块的协程,从而切换过去呢?(想必有一个全局的协程注册表)
2218431632
1 天前
@HikariLan 那这种是属于逻辑上的同时的,并不是物理上的同时

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

https://tanronggui.xyz/t/1110155

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

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

© 2021 V2EX