Article
并发编程
什么是并发编程
并发编程的核心目标,是让多个任务在同一时间段内共同推进。这里的”共同推进”并不一定表示真正同时运行,而是指多个任务可以通过调度机制交替执行
通过这种方式,程序可以在等待 I/O、处理用户事件或执行后台任务时保持响应,从而提高吞吐能力和资源利用率
并发编程真正关注的对象不是进程、线程或协程本身,而是 任务(Task)。任务表示程序中可以被独立调度、暂停或继续执行的一段逻辑,例如一次网络请求、一次文件读写、一次计算过程或一次用户事件处理
进程、线程和协程则是承载任务的不同执行抽象。它们分别对应不同层级的资源隔离、调度方式和运行开销
并发与并行
并发和并行容易混淆,但二者关注点不同
并发(Concurrency) 强调任务组织方式:系统允许多个任务在同一时间段内交替推进。即使只有一个 CPU 核心,也可以通过任务切换实现并发
并行(Parallelism) 强调任务执行状态:多个任务在同一时刻真正同时运行,通常依赖多核 CPU 或多处理器硬件支持
因此,并发是程序设计层面的概念,而并行是硬件执行层面的能力。并发程序可以在多核 CPU 上并行运行,但并发本身并不等于并行
任务与执行单元
并发编程中的任务需要依附某种执行单元运行。常见的执行单元包括 进程、线程和协程
它们之间的关系可以理解为:
- 任务是要完成的逻辑工作
- 进程、线程、协程是推进任务执行的不同载体
不同执行单元的差异主要体现在三个方面:
| 执行单元 | 主要特点 | 调度层级 | 适用场景 |
|---|---|---|---|
| 进程 | 地址空间独立,隔离性强 | 操作系统 | 多程序隔离、服务进程 |
| 线程 | 共享进程资源,切换较轻 | 操作系统 | CPU 密集型并行任务 |
| 协程 | 用户态调度,轻量级 | 运行时 / 事件循环 | 高并发 I/O 任务 |
进程
一个进程通常对应一个正在运行的程序实例。它既包含用户态的虚拟地址空间,也包含内核态维护的进程控制信息,例如文件描述符表、调度状态、内存映射信息等
其中,进程控制块(PCB,Process Control Block)可以理解为操作系统管理进程的核心数据结构。操作系统通过 PCB 保存进程状态,并在调度切换时恢复对应的运行上下文
不同进程之间拥有相互独立的虚拟地址空间。因此,一个进程中的内存错误通常不会直接破坏另一个进程的内存空间
这种隔离机制提高了系统的安全性和稳定性,但也带来了更高的创建、切换和通信成本
进程之间如果需要交换数据,通常要借助进程间通信机制,例如管道、消息队列、共享内存、Socket 等。相比线程内部共享内存,进程间通信更复杂,但隔离性也更强
// 进程控制块(PCB)简化结构:用于保存进程的运行上下文以及相关管理信息
pub struct ProcessControlBlock {
pid: u16,
state: ProcessState,
// CPU 上下文(Context):用于在进程切换(context switch)时
// 保存和恢复当前进程的执行现场,确保恢复后能够从中断位置继续执行
program_counter: u16,
general_purpose_registers: [u8; 4],
instruction_register: u8,
flags: [u1, 3],
stack_pointer: u16,
index_registers: [u16; 2],
...
// 内存管理信息:描述进程的虚拟地址空间边界,
// 用于实现进程间的内存隔离,保证各进程地址空间相互独立
memory_limits: [u16; 2],
...
io_devices: Vec<IO_Device>,
open_files: Vec<File>,
parent: Arc<ProcessControlBlock>,
children: Vec<Arc<ProcessControlBlock>>
}
pub enum ProcessState {
New,
Running,
Ready,
Waiting,
Terminated
}线程
线程是进程内部的执行流,也是操作系统进行 CPU 调度的重要单位。一个进程可以包含多个线程,这些线程共享所属进程的虚拟地址空间、打开的文件以及其他进程级资源
线程自身只保留少量私有运行状态,例如:
- 程序计数器
- 寄存器上下文
- 线程栈
- 调度状态
因此,可以在原有的进程控制结构上加入线程信息,为一个进程维护多个可调度的执行流。线程切换通常比进程切换更轻量,因为它不需要完整切换进程地址空间
多个线程可以在同一进程内执行相同的代码逻辑,只是它们的执行位置、调用栈和运行状态彼此独立
这也是很多语言在线程库中要求提供 线程入口函数、闭包或任务对象 的原因:创建线程时,本质上需要告诉系统这个新线程从哪里开始执行、执行什么逻辑
不过,线程共享进程资源也带来了新的问题。当多个线程同时访问同一份可变数据时,就可能产生竞态条件、数据不一致、死锁等并发错误
因此,线程编程通常需要借助锁、原子操作、条件变量等同步机制来保护共享数据
pub struct ProcessControlBlock {
pid: u16,
memory_limits: [u16; 2],
io_devices: Vec<IO_Device>,
open_files: Vec<File>,
parent: Arc<ProcessControlBlock>,
children: Vec<Arc<ProcessControlBlock>>,
threads: Vec<Thread_t>,
}
pub struct Thread_t {
tid: u16,
state: ProcessState,
program_counter: u16,
general_purpose_registers: [u8; 4],
instruction_register: u8,
flags: [u1, 3],
stack_pointer: u16,
index_registers: [u16; 2],
// process_ref: Arc<ProcessControlBlock>,
}协程
协程是一种比线程更轻量的并发抽象,但它并不是脱离线程独立运行的执行实体。更准确地说,协程通常运行在线程之上
操作系统仍然负责调度线程,而语言运行时、异步运行时或事件循环负责在用户态把多个协程安排到一个或多个线程上执行
在线程模型中,操作系统可以通过时间片在任意时刻抢占当前线程。而在协程模型中,运行时通常不会强制打断正在执行的协程,协程需要在合适的位置主动让出执行权
例如遇到 await、异步 I/O 或显式 yield 操作时,当前协程会暂停执行。运行时调度器随后推进同一线程上的其他协程
这种关系可以理解为:线程提供真正占用 CPU 的执行上下文,协程则是在这个执行上下文中被切换的轻量任务
一个线程可以承载大量协程,因为大多数 I/O 任务并不会一直占用 CPU。当某个协程等待网络、文件或定时器事件时,线程可以继续执行其他已经就绪的协程,而不是长期闲置
因此,协程特别适合 I/O 密集型场景,例如:
- 同时处理大量网络连接
- 高并发 Web 服务
- 异步文件读写
- 消息队列消费
- 多路事件监听
协程的优势是创建成本低、切换成本小,能够用较少线程承载大量任务。但它也有一个前提:任务必须在合适的位置主动让出执行权
如果某个协程长时间执行 CPU 密集型计算,或者在协程中直接调用会阻塞线程的同步 I/O,那么被占住的不只是这个协程,而是承载它的线程。同一线程上的其他协程也会因此得不到运行机会
对于需要真正并行消耗 CPU 的任务,仍然需要依赖多线程或多进程,把计算分散到多个 CPU 核心上
抢占式并发
抢占式并发是操作系统常用的调度方式。在线程或进程运行过程中,操作系统调度器可以在任意时刻中断当前执行单元,并切换到另一个执行单元继续运行
这种方式的特点是:任务不需要主动让出 CPU,操作系统会根据时间片、优先级、阻塞状态等因素统一调度
抢占式并发的典型载体是线程。开发者可以通过线程库显式创建多个线程,将程序中的不同任务拆分出来,交由操作系统调度
在线程数量和硬件核心数合适的情况下,这些任务不仅可以并发推进,还可以在多核 CPU 上并行执行
最简单的线程示例
两个线程各自打印数字,互不干扰:
use std::thread;
fn main() {
let handler = thread::spawn(|| {
for i in 1..10 {
println!("worker: {i}");
}
});
for i in 100..110 {
println!("main: {i}");
}
handler.join().unwrap();
}实际应用场景
Web 服务器:为每个连接创建一个线程,同时处理多个客户端请求
例如:Apache HTTP Server 的 worker 模式,每个请求由独立线程处理
数据库连接池:预先创建多个数据库连接线程,避免频繁创建销毁
例如:HikariCP 连接池维护固定数量的连接线程,提高查询效率
图像处理:将大图分块,每个线程处理一块,最后合并结果
例如:视频编码器使用多线程并行处理不同帧,加速渲染
共享数据的挑战
当多个线程访问共享数据时,就必须考虑同步和互斥。否则,线程可能在任意位置被切换,导致共享数据处于不一致状态
例如,两个线程同时修改同一个计数器,如果不加锁保护,最终结果可能是错误的。这种情况需要使用互斥锁(Mutex)、原子操作(Atomic)或其他同步机制来保护共享数据
这里先保留模型层面的判断:抢占式线程能被系统在任意位置切走,因此共享数据需要额外同步。锁、原子操作和条件变量的完整讨论放到后文延伸阅读中
协作式并发
协作式并发与抢占式并发相对应。它不依赖操作系统强制中断当前任务,而是由任务在特定位置主动让出执行权
在协作式并发中,任务之间的切换通常发生在:
- await
- 异步 I/O
- yield
- 定时器等待
- 事件等待
这种方式的好处是调度成本较低,任务切换点也更加明确。因此,在大量 I/O 等待场景下,协作式并发通常非常高效
现代异步编程模型,例如 JS 的事件循环、Rust 的 async/await、Python 的 asyncio,都属于协作式并发的重要实践
但是,协作式并发并不适合直接执行长时间的 CPU 密集型任务。如果某个任务一直占用线程而不让出执行权,其他任务就无法获得运行机会,整个异步运行时的响应能力也会下降
核心概念示例
协作式并发的关键是任务主动让出执行权。以下是一个简化的概念示例:
use futures::executor::LocalPool;
use futures::task::LocalSpawnExt;
async fn first_task() {
for i in 1..5 {
println!("first task: {i}");
// 主动让出执行权,让其他任务有机会运行
tokio::task::yield_now().await;
}
}
async fn second_task() {
for i in 100..105 {
println!("second task: {i}");
tokio::task::yield_now().await;
}
}
#[tokio::main]
async fn main() {
// 两个任务在同一个线程上交替执行
tokio::join!(first_task(), second_task());
}输出展示了两个任务交替执行,而不是一个任务完全执行完再执行另一个:
实际应用场景
高并发 Web 服务:Node.js、Go、Rust 的异步运行时可以用少量线程处理数万并发连接
例如:Node.js 的事件循环可以在单线程上处理 10000+ WebSocket 连接
异步文件处理:同时读取多个文件,不阻塞主线程
例如:Tokio 异步运行时可以并发读取数百个日志文件进行分析
消息队列消费:同时监听多个消息队列,高效处理异步事件
例如:Kafka 消费者使用异步 I/O 同时处理多个分区的消息
共享资源与消息传递
并发模型回答的是“任务如何被推进”,但真实程序还要回答“任务之间如何协作”。这一层通常可以分成共享内存、共享资源和消息传递
| 协作方式 | 关注点 | 常见工具 |
|---|---|---|
| 共享内存 | 同一份数据如何避免并发读写冲突 | Mutex、RwLock、Atomic、Condvar、Barrier |
| 共享资源 | 有限资源如何限制同时使用数量 | Semaphore |
| 消息传递 | 任务之间如何解耦和传递工作 | Channel、Queue、Actor |
这三类问题和进程、线程、协程不是同一个维度。线程可以使用锁保护共享数据,协程也可以通过 Channel 传递消息,异步服务还经常用 Semaphore 控制数据库连接或远端 API 的并发量
本文只保留这层关系,避免偏离“并发模型总览”主线。锁、Channel、消息队列、Actor、生产者-消费者等细节单独放到另一篇文章中介绍:
延伸阅读:共享资源与消息传递
总结
-
如果任务是 CPU 密集型,例如图像处理、矩阵计算、数据压缩、模型推理等,任务本身需要大量占用 CPU,那么更适合使用多线程或多进程,将任务分配到多个 CPU 核心上并行执行
-
如果任务是 I/O 密集型,例如网络请求、数据库访问、文件读写、消息监听等,任务的大部分时间都在等待外部资源返回结果,那么异步和协程通常更加合适。它们可以用较少的线程承载大量并发任务,避免线程在等待期间被浪费
参考文献
- Core Dumped 的视频包含一些关于进程与线程调度的动画演示,适合初学者建立直观认识
- RustOS Blog从操作系统层面介绍并发与并行机制,主要使用 Rust 语言讲解,适合继续理解系统级实现
- 本文的代码部分参考了 Rust 圣经 以及 Modern C++
- 如果不想在本机配置环境,C++ 代码片段可以放到 C++ Playground 运行,Rust 代码片段可以放到 Rust Playground 运行