本文将为你梳理现代并发编程的重要知识点。
多线程(OS线程)
OS线程(操作系统线程)是最常见的并发模型,大部分现代编程语言都会提供相关 API(如 C++、C#、Java、Rust 中的 thread
)。
除了OS线程自身的概念(比如与进程的差异),其余重要内容无非两点:线程同步与资源共享。
资源共享(互斥问题)
线程间可以直接共享内存,因此资源共享实际需要解决的是互斥访问的问题。实际应用中主要涉及以下内容:
- 互斥锁(Mutex):最常见的锁,等待时会挂起线程
- 自旋锁(Spin):忙等待的锁
- 适合极短时间等待的场景,此时挂起恢复线程的耗时可能比等待更久
- 读写锁(RwMutex):允许并发读、独占写
- 适合数据库访问等读请求显著多于写请求的场景
- 原子变量(Atomic):通过底层 CPU 指令保证互斥的读写,比锁更快
- 涉及内存次序
- 仅基本数据类型可用
- 消息传递(channel):一种队列的互斥封装,一端写入、另一端读取
- 读写操作内含互斥封装
- 适合生产者消费者场景
线程同步
按照 channel
的一般实现,在读空队列或写满队列时允许挂起线程直到操作成功,因此可用于线程同步。
此外还有下列三种常见同步原语:
- 条件变量(condition-variable):获取锁后检查条件是否成立,如果条件不成立则释放锁并等待唤醒。唤醒后获取锁并重复上面的检查,如果条件成立则可进行后续操作(此时持有锁、没有释放)。
- 信号量(semaphore):
- 二进制信号量:0 表示未激活,1 表示已经激活。前置任务完成后将信号量置 1,后续任务等待信号量变成 1 后再开始(同时重置信号量回 0 )。
- 时间线(计数)信号量:保持单调递增,不同的任务需要等待不同的时间点。当前任务完成后增加信号量的值、从而开始下一个任务。
- 屏障(barrier):如同一个屏障分隔前后两批任务,所有前置任务都完成才会释放屏障,后续任务才能开始。
并发模型
OS 线程是最常见的并发模型,但OS线程的内存占用和调度开销太大,不适合直接用于网络服务等场景(高并发、久等待)。因此存在更多的并发模型用于解决此类问题:
- IO多路复用(I/O Multiplexing):
- 每个 OS 线程处理多条 IO 请求。
- 可以使用轮询的方式依次处理每个IO任务,若IO还在进行中则直接跳过(不等待)。
- 借助操作系统 API (如Linux的epoll)实现响应式的IO处理,避免轮询开销。
- 常见于 C 风格的网络编程,如 Nginx 和 Redis 的实现。
- 注意区分 IO模型 与 并发模型 。
- 协程(Coroutine):
- 需编程语言支持。
- 指可挂起和恢复的函数,函数将被封装成某种状态机对象。
- (无栈协程)使用一块堆内存存放函数的执行状态与局部变量,因此可按需挂起和恢复。
- 参考 C++20 的
Coroutine
。
- 用户级线程(User-level Thread):
- 需编程语言支持。
- 由程序级的调度器管理,负责分配到OS线程上执行。
- 比OS线程更轻量,因此支持网络等高并发场景。
- 编程风格与OS线程类似。
- 参考 Go 的
Goroutine
。
- 异步(Asynchronous):
- 需编程语言支持。
- 使用
async
关键字将函数声明为状态机。 - 函数调用返回状态机对象
promise/future
,内含函数执行状态与局部变量。 - 使用
await
关键字执行目标状态机并挂起当前状态机(直到目标完成)。 - 由程序级的“运行时(调度器)”管理这些状态机的挂起与执行。
- 通过类似同步的代码实现异步的效果。
- 参考 JS、C#、Rust 的
async/await
。
有人会将 Go 的
Goroutine
也称之为“协程”,迷枵认为这是不准确的。不过协程、用户级线程与异步的实现原理确实存在很多相似之处。
学习建议
请通过以下角度理解这些内容:
- 为什么需要它?(存在什么问题需要解决?)
- 它是如何解决这个问题的?
- 它是如何实现的?
- 它是否还存在问题?