于飞
发布于 2026-05-14 / 2 阅读
0
0

C/C++多线程与异步编程全解析:从锁到协程的进化之路

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(仅限进程内)。要跨进程使用,需要:

  1. 将 mutex 放在共享内存中(mmapshm_open
  2. 设置 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_createepoll_ctlepoll_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_awaitco_yieldco_return
  • 底层工具:std::coroutine_handle<T>std::suspend_alwaysstd::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_readsteady_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 的小工具 pollselect(跨平台则用 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 给了你开箱即用的效率——技术选型取决于你的约束条件。


评论