从首台分时操作系统惊艳问世,开辟计算领域新篇,到 Intel 大胆推出双核 CPU,强势突破摩尔定律的限制,每一次技术变革都如汹涌浪潮,推动着人类不断探索并发编程的前沿之路。在这条充满挑战与未知的道路上,我们凭借着创新的勇气与智慧,试图触摸人类数千年来知识沉淀的巅峰,向着无限可能的未来奋勇迈进 。
引言
当你回溯计算机操作系统的历史长河,便会清晰地发现,早期的计算机操作系统并不具备多用户功能。究其根源,在于单个CPU的能力有限,无法同时处理来自多个用户的输入输出任务。与之类似,程序也无法实现同时运行,只能依照顺序依次执行。后来,分时操作系统的横空出世,成功攻克了这一难题,与此同时,也为程序员们引入了“并发”这一全新概念。
在计算机科学领域,“并发(Concurrency)” 用于描述计算机程序的一种特定运行状态。它借助时间片轮转的技术手段,让多个计算机程序在一段连续的时间范围内,通过某种既定机制,在一个或多个CPU核心上交替运行。这就如同一位技艺精湛的魔术师,巧妙地营造出所有计算机程序都在同一时刻并行运作的奇妙假象 。
不可否认,基于操作系统抢占式调度的时间片轮转机制,对于应用程序开发者而言,是隐匿于幕后、不易察觉的。然而,随着应用程序规模如滚雪球般日益庞大,用户需求也愈发多样化和复杂,应用程序开发者逐渐意识到,有时仅依靠单个CPU资源已无法满足程序运行的需求。于是,在这样的背景下,并发编程顺势而生,成为解决这一困境的关键利器。
多进程模型
在早期操作系统的架构中,线程这一概念尚未出现。那时,一个进程仅能容纳唯一的线程,进程同时肩负着最小资源分配单位与最小CPU调度单位的双重重要职责。在这样的情形下,多进程作为一种实现并发的方式,是自然而然就会被考虑到的。
在多进程并发模型里,进程之间借助管道、Socket等机制来实现数据的交换与传递。同时,为了确保各进程在并发执行过程中的有序性和一致性,它们会使用操作系统所提供的并发原语来进行同步控制,从而保障整个系统稳定、高效地运行。
多线程模型
随着计算机技术的不断发展与深入探索,人们逐渐意识到,进程在担任CPU调度的最小单位时存在着诸多局限,并不契合这一关键职责。于是,线程在这样的背景下应运而生,迅速成为计算机编程领域的重要概念。
线程最显著的特性,在于它能够与同一个进程内的其他线程共享地址空间以及操作系统资源,像I/O句柄这类资源,都可以实现共享。这一特性为操作系统的调度工作带来了极大的便利。当操作系统需要调度到同一个进程内的其他线程时,仅仅需要更换程序调用栈,也就是CPU寄存器即可,这一过程避免了传统进程调度时所产生的高额性能开销,使得系统运行更加流畅高效。
此外,得益于共享地址空间这一优势,线程之间的数据交换得以通过共享内存的方式来实现。尽管这种方式在某些情况下可能存在数据安全隐患,但不可否认的是,它极大地提高了数据交换的效率,相较于其他数据交换方式,能够更快速地完成数据传递,进一步优化了程序的整体运行效率,使得程序在处理复杂任务时能够更加游刃有余。
基于共享的地址空间,一种更为轻量级的同步方式——CAS(Compare and Swap,比较并交换)也顺势诞生。与传统的操作系统并发原语相比,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的英文缩写,中文意为“比较并交换” 。从本质上讲,CAS是由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 多路复用
多线程模型乍看之下近乎完美,它允许多个线程并发执行,极大提升了程序的执行效率,在处理复杂任务时展现出强大的优势。然而,这一模型却存在着一个容易被忽视的短板——线程本身带来的性能开销不容小觑。在操作系统层面,每创建一个线程,大约需要占用8KB左右的物理内存空间。对于那些对高并发有着强烈需求的应用程序而言,这无疑是一个沉重的负担,对物理机的内存容量提出了极高的要求。这里还尚未将应用程序线程栈的大小以及进程堆所占用的内存考虑在内,倘若将这些因素一并纳入考量,内存压力将更为显著。
更为关键的是,对于I/O密集型应用程序,例如常见的Web Server,线程的运行状态存在着极大的资源浪费现象。在这类程序中,一个线程的大部分时间并非用于占用CPU资源进行数据计算,恰恰相反,它们往往因为等待操作系统的I/O系统调用返回结果,而长时间陷入阻塞状态。在这段阻塞时间内,线程所占用的内存实际上处于闲置状态,并未得到有效利用,这无疑是一种内存资源的浪费。
为了有效化解这一难题,操作系统引入了I/O多路复用功能。该功能的出现,让应用程序能够在单线程的环境下,同时处理多个I/O请求。这不仅避免了多线程带来的高额内存开销,还显著提升了I/O操作的效率,使得系统资源得到更为合理的分配与利用。
不同的操作系统针对I/O多路复用这一需求,提供了各具特色的解决方案。接下来,我们以Linux操作系统的epoll
系统调用(水平触发模式)为例,着手创建一个简易的echo程序,以此来深入了解I/O多路复用在实际应用中的运作机制。
#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
函数,直到函数正常返回(而不是挂起返回)。很容易注意到,无栈协程的本质其实是状态机,其通过*延续*的状态将函数恢复到上一次挂起的位置,这也导致无栈协程仅能在编译器插入的挂起点被挂起。
整体来看的话,有栈协程和无栈协程的主要区别可以列表如下:
特性 | 有栈协程(Stackful Coroutine) | 无栈协程(Stackless Coroutine) |
---|---|---|
栈使用方式 | 具有独立的调用栈,可在任意函数间自由切换 | 没有独立的调用栈,依赖编译器或运行时管理 |
调度方式 | 可以在深层函数调用中进行调度 | 只能在最外层的调度点(如 yield )进行调度 |
切换粒度 | 可以在函数内部任意位置进行切换 | 只能在显式标记的调度点进行切换 |
实现复杂度 | 需要手动管理栈切换,通常涉及汇编或底层API | 依赖编译器或解释器提供的语法支持,通常更简单 |
性能开销 | 开销较大,需要维护完整的栈和上下文 | 开销较小,通常仅涉及局部变量和少量上下文信息 |
跨函数支持 | 可以跨多个函数进行协程切换 | 只能在局部范围(单个函数)内进行切换 |
适用场景 | 适用于复杂并发任务,如用户态线程、协程调度器 | 适用于轻量级异步任务,如 Python 的 async/await |
绿色线程:Java早期对用户态并发的一次探索
在协程已广泛融入现代程序语言的2025年,鲜有人留意到,早在遥远的上世纪,Java就已迈出了支持“协程”的步伐,它所引入的便是“绿色线程”。
绿色线程,是一类由运行环境或虚拟机负责调度,而非依赖本地底层操作系统进行调度的线程。它并不依托底层操作系统的支持,而是通过模拟的方式实现多线程运行。这种线程的调度在用户空间内完成,而非内核空间,这使得它能够在缺乏原生线程支持的环境中正常工作。
然而,绿色线程存在着明显的局限性。一方面,它无法像如今的goroutine那样,实现在多个线程上协同工作;另一方面,一旦某个绿色线程发生阻塞,那么所有绿色线程所处的整个操作系统线程都会随之陷入阻塞状态。这些缺陷导致最终仅有Solaris操作系统下的JVM采用了这种线程模型。在后续的Java版本迭代中,Java果断放弃了绿色线程,转而采用操作系统线程。当然,在Java 21版本中,Java迎来了更为完善的协程形式——虚拟线程(Virtual Thread),为并发编程带来了全新的解决方案 。
后记
这便是计算机并发编程的前世今生,从进程到协程,人们不断探索低成本且方便的并发编程方式,以期在最大化资源利用的同时,最大限度地降低开发者的心智负担。
文章来源: https://study.disign.me/article/202508/1.concurrency-programming-road.md
发布时间: 2025-02-17
作者: 技术书栈编辑