1 锁的基本分类与使用场景
1.1 C++标准库提供的锁
C++11 标准库提供了多种互斥锁,覆盖了大部分单机多线程场景:
| 锁类型 | 说明 |
|---|---|
std::mutex |
独占互斥锁,同一时间只有一个线程能持有 |
std::recursive_mutex |
递归锁,允许同一线程多次加锁 |
std::timed_mutex |
支持超时等待的互斥锁 |
std::shared_mutex (C++17) |
读写锁,多读单写 |
配合的 RAII 管理类:std::lock_guard(简单作用域锁)、std::unique_lock(支持延迟加锁和超时)、std::shared_lock(读锁)、std::scoped_lock(同时锁定多个锁)。
1.2 自旋锁的原理
自旋锁的本质是不放弃CPU,忙等待:
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等待,相当于自己写了个循环在检测flag
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
自旋锁的优缺点非常鲜明:
| 优点 | 缺点 |
|---|---|
| 无上下文切换,延迟极低 | 等待期间持续占用CPU |
| 不进入内核态 | 不适合长时间等待 |
| 实现简单 | 仅在多核上有意义 |
适用场景:临界区极短(几条指令)、多核环境、不允许睡眠的内核态(如中断处理程序)。
1.3 Windows 临界区
CRITICAL_SECTION 是 Windows 用户态锁,无竞争时不进入内核,性能极高。Linux 中最接近的对应是 pthread_mutex 基于 futex 的实现:
- 无竞争时:完全在用户态自旋
- 有竞争时:进入内核挂起线程
两者原理一致,性能在同一量级(无竞争时约 30-50ns)。
1.4 递归锁会进入内核吗?
无竞争时:不进入内核。重入只是在用户态将计数器加一,极快(几十个周期)。
有竞争时:必然进入内核(基于 futex 的 futex_wait 系统调用)。
可以用 strace -e futex 验证。
2 跨进程同步
2.1 Linux 跨进程锁
Linux 的 pthread_mutex_t 默认是 PTHREAD_PROCESS_PRIVATE(仅限进程内)。要跨进程使用,需要:
- 将 mutex 放在共享内存中(
mmap或shm_open) - 设置
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED)
2.2 健壮锁(Robust Mutex)
当持有跨进程锁的进程崩溃时,其他等待的进程会收到 EOWNERDEAD 错误码。Linux 需要手动调用 pthread_mutex_consistent() 标记锁已恢复;Windows 通过 WAIT_ABANDONED 返回值实现类似功能,但没有类似 consistent() 的 API。
2.3 Windows 何时使用 Mutex?
| 场景 | 推荐 |
|---|---|
| 单进程内 | CRITICAL_SECTION(性能好) |
| 跨进程同步 | Mutex(唯一选择) |
| 需要超时/等待多个内核对象 | Mutex / Event |
2.4 跨平台的 C++ 方案
C++ 标准库没有跨进程锁。推荐使用 Boost.Interprocess:
#include <boost/interprocess/sync/named_mutex.hpp>
using namespace boost::interprocess;
named_mutex mutex(open_or_create, "my_mutex");
mutex.lock();
// 临界区
mutex.unlock();
3 I/O 多路复用的演进
3.1 select → poll → epoll
这是 Linux I/O 多路复用的演进路径:
| 特性 | select | poll | epoll |
|---|---|---|---|
| 监控上限 | 1024(FD_SETSIZE) | 无固定上限 | 无上限 |
| 输入方式 | 每次全量传入 fd_set | 每次全量传入 pollfd 数组 | 一次注册,永久监控 |
| 返回结果 | 遍历全部 fd 检查 revents | 遍历全部 fd 检查 revents | 只返回活跃事件数组 |
| 时间复杂度 | O(n) | O(n) | O(1)(只处理活跃 fd) |
3.2 从用户使用角度理解区别
poll 的使用方式:每次调用都要把整个数组传进去,返回后要遍历全部,检查哪个 fd 有事件。
epoll 的使用方式:三个接口(epoll_create → epoll_ctl → epoll_wait),内核替你记住监控列表,epoll_wait 返回的只包含有事件的 fd,遍历量大大减少。
3.3 epfd 是什么?
epfd 是 epoll 实例的文件描述符,本质是指向内核中事件监控管理器的句柄。它和普通 fd 类型相同(int),但不指向文件,而是指向一个内核数据结构。所有后续操作(增、删、改、查)都通过它来指定“操作哪一个 epoll 实例”。
3.4 水平触发 vs 边缘触发
边缘触发(ET):只在状态发生变化时通知一次,要求程序一次性把数据全部读写完(直到 read 返回 EAGAIN)。
水平触发(LT):只要条件满足就重复通知,更安全、不易出错。
3.5 epoll 需要一次性读完吗?
不一定要在同一个线程读完,但要在本次通知周期内有人负责读干净。可以交给另一个线程去慢慢读,只要循环读到 EAGAIN 即可。
3.6 Windows IOCP
IOCP 与 select/epoll 有本质区别:
select/poll/epoll是就绪通知:通知你“可以读了”,你自己调用recv- IOCP 是完成通知:通知你“已经读完了”,数据已在缓冲区
IOCP 是 Windows 最高性能的网络模型,但编程复杂度高。文件 I/O 场景下,用线程池做同步读写更简单可靠。
4 C++20 协程:语言设施还是运行时?
4.1 C++20 到底提供了什么?
C++20 提供的只是语言设施,不是开箱即用的异步运行时:
- 三个关键字:
co_await、co_yield、co_return - 底层工具:
std::coroutine_handle<T>、std::suspend_always、std::suspend_never - Promise 约定(用于定制协程行为)
它没有提供:异步 Socket、Timer、调度器、线程池——这些都需要你通过第三方库(如 Boost.Asio、cppcoro)获取。
4.2 协程底层用的是 epoll 还是线程池?
C++20 协程本身两者都不是。它只是一个无栈状态机的生成器。具体用 epoll/IOCP(事件驱动)还是线程池(并发执行),完全取决于你使用的协程库:
| 库 | 底层实现 |
|---|---|
| Boost.Asio | 可配置(epoll/Linux、IOCP/Windows、线程池) |
| cppcoro | 主要基于 epoll + 事件循环 |
| liburing + coro | Linux io_uring |
4.3 你调用的是 C++20 的接口还是协程库的接口?
co_await关键字 → C++20 语言awaitable<T>、async_read、steady_timer→ 协程库
C++20 只给了语法和基础类型,具体实现全靠库。
5 Go 语言的并发模型
5.1 goroutine vs C++20 协程
| 特性 | Go goroutine | C++20 coroutine |
|---|---|---|
| 内核实现 | 有栈协程(初始栈 ~2KB) | 无栈协程(状态机) |
| 调度器 | 内置运行时(M:N 调度) | 无,需库或自己实现 |
| 通信 | 内置 channel(CSP 模型) |
无,需库 |
| 使用门槛 | 极低(go func() 即可) |
较高(需理解 Promise/ Awaitable 等) |
| 适用场景 | 高并发网络服务、微服务 | 极致性能的底层系统、游戏引擎 |
5.2 Go 的 CSP 通信模型
Go 推荐:“不要通过共享内存来通信,而应该通过通信来共享内存”。
ch := make(chan int)
go func() { ch <- 42 }()
value := <-ch
channel 本身提供了同步机制,有效避免数据竞争,编程范式清晰又安全。
6 总结与选型建议
6.1 锁的选型
| 场景 | 推荐 |
|---|---|
| 单进程内一般互斥 | std::mutex + lock_guard |
| 单进程内读多写少 | std::shared_mutex |
| 同一线程可能重入 | std::recursive_mutex |
| 临界区极短(若干条指令) | 自旋锁 |
| 跨进程同步 | Linux: pthread_mutex + PSHARED,Windows: 命名 Mutex,跨平台: Boost.Interprocess |
| 避免多锁死锁 | std::scoped_lock (C++17) |
6.2 I/O 模型的选型
| 场景 | 推荐 |
|---|---|
| Linux 高并发网络服务器(>1000 连接) | epoll |
| 连接数 < 100 的小工具 | poll 或 select(跨平台则用 select) |
| Windows 高性能网络 | IOCP |
| 不想手撕底层 I/O | libevent / libuv / Boost.Asio 封装库 |
| 文件 I/O | 线程池 + 同步读写(比异步更简单可靠) |
6.3 异步编程的最佳实践
网络 I/O → 必须用异步(epoll/IOCP)。
文件 I/O → 优先用线程池 + 同步读写。
计算任务 → 同步执行或用工作线程分离。
这个模型可以覆盖绝大多数后端开发场景。
6.4 C++20 协程的使用建议
在 C++ 中享受协程的便捷,直接用 Boost.Asio 是最成熟、最主流的选择:
boost::asio::awaitable<void> my_handler() {
co_await timer.async_wait(use_awaitable);
}
底层 epoll / IOCP 由 Asio 自动处理,你只需要关注业务逻辑。
而如果追求极致的开发效率,Go 语言仍然是高并发网络服务的最优解之一——它的并发模型是语言第一天性,开箱即用。
6.5 一句话总结
C++ 给了你极致控制的自由,Go 给了你开箱即用的效率——技术选型取决于你的约束条件。