
🎬 个人主页:道尔柯南
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。
文章目录
- 从互斥到线程池:C++ 多线程同步与互斥实战笔记
-
- 1. 为什么需要线程同步与互斥?
- 2. 互斥量:给临界区上锁
- 3. 条件变量:等待与通知
- 4. 生产者消费者模型
- 5. 信号量与环形队列
- 6. 线程池:任务与执行解耦
- 7. 日志、策略模式与单例
- 8. 线程安全与可重入
- 9. 死锁与常见锁
- 10. STL 与智能指针的线程安全
- 11. 总结
- 结尾
从互斥到线程池:C++ 多线程同步与互斥实战笔记
本文基于《11. 线程同步与互斥》课件内容整理,并补充了一些现代 C++、操作系统和并发编程中的额外知识。目标不是复述课件,而是把“为什么需要同步”“怎么用锁”“怎么设计生产消费模型和线程池”串成一条可落地的学习路线。
1. 为什么需要线程同步与互斥?
多线程最吸引人的地方是并发,但并发也带来了共享数据竞争。
课件里用了一个售票系统例子:多个线程同时卖票,最终可能卖出 -1、-2 张票。原因不是 ticket-- 看起来简单,而是它在汇编层面通常对应三条指令:
1load ; 把 ticket 从内存读到寄存器 2sub ; 寄存器减 1 3store ; 把新值写回内存 4
这三步不是原子的。线程 A 执行完 load 后被切走,线程 B 也读到同一个旧值,最后两次 store 会丢失一次更新。
由此引出几个关键概念:
- 共享资源:多个线程都能访问的数据。
- 临界资源:需要被保护的共享资源。
- 临界区:访问临界资源的代码段。
- 互斥:任何时刻只允许一个执行流进入临界区。
- 原子性:操作要么完成,要么未完成,不会被调度打断。
补充知识:
现代 CPU 提供原子指令,比如 CAS(Compare-And-Swap)、exchange、fetch_add。C++11 起可以用 std::atomic<T> 表达原子操作,例如:
1std::atomic<int> ticket{100}; 2int old = ticket.fetch_sub(1); 3
但原子变量只适合简单共享变量。复杂临界区仍然需要锁。
2. 互斥量:给临界区上锁
POSIX 线程库提供 pthread_mutex_t,C++11 提供 std::mutex。课件中演示了两种初始化方式:
- 静态初始化:
PTHREAD_MUTEX_INITIALIZER - 动态初始化:
pthread_mutex_init
加锁解锁接口:
1pthread_mutex_lock(&mutex); 2// 临界区 3pthread_mutex_unlock(&mutex); 4
但手动加解锁容易在异常、提前 return 时漏掉。所以课件进一步封装了 RAII 风格的 LockGuard:
1class LockGuard { 2public: 3 explicit LockGuard(Mutex& m) : _m(m) { _m.Lock(); } 4 ~LockGuard() { _m.Unlock(); } 5private: 6 Mutex& _m; 7}; 8
C++11 也提供了 std::lock_guard、std::unique_lock:
1std::mutex mtx; 2{ 3 std::lock_guard<std::mutex> lock(mtx); 4 // 临界区 5} 6
补充知识:
- 互斥锁底层常基于 futex(Fast Userspace Mutex)。无竞争时在用户态完成,有竞争时进入内核挂起。
- 锁的粒度要尽量小,但也不能把共享数据拆得太碎,否则锁竞争和缓存一致性开销会上升。
- 自旋锁适合临界区极短的场景;互斥锁适合可能阻塞较久的场景。
- 不要销毁一个已加锁的互斥量,也不要在已销毁的锁上继续加锁。
改进后的售票逻辑可以写成:
1std::mutex mtx; 2int ticket = 100; 3 4void sell(const std::string& id) { 5 while (true) { 6 std::lock_guard<std::mutex> lock(mtx); 7 if (ticket <= 0) break; 8 std::cout << id << " sells " << ticket-- << std::endl; 9 } 10} 11
3. 条件变量:等待与通知
互斥锁解决“不能同时访问”,条件变量解决“条件不满足时该怎么办”。
例如消费者发现队列为空,它不能一直占着锁空转,也不能直接退出。正确做法是:
- 加锁。
- 检查条件。
- 条件不满足,调用
wait等待。 - 被唤醒后重新检查条件。
- 条件满足,访问资源。
- 解锁。
课件强调了 pthread_cond_wait 的关键行为:
- 让调用线程等待;
- 自动释放已持有的互斥量;
- 被唤醒后重新竞争互斥量,成功后才返回。
因此条件变量必须和互斥量配合使用。典型结构:
1std::unique_lock<std::mutex> lock(mtx); 2cv.wait(lock, [&]{ return !queue.empty(); }); 3// 处理数据 4
补充知识:
- 必须用
while或带谓词的wait,防止 虚假唤醒。 notify_one只唤醒一个等待线程,notify_all唤醒全部。- 通知可能丢失:如果通知时没有线程在等,信号就没了。所以“条件状态”必须由共享变量记录,而不是依赖条件变量本身。
- C++20 引入了
std::stop_token、std::jthread,让线程停止更优雅。
4. 生产者消费者模型
生产者消费者模型可以概括为“321 原则”:
- 3 种关系:生产者与消费者互斥、生产者与生产者互斥、消费者与消费者互斥。
- 2 个角色:生产者、消费者。
- 1 个缓冲区:阻塞队列或环形队列。
它的价值在于:
- 解耦:生产者和消费者不直接通信。
- 支持并发:生产和消费可以同时进行。
- 支持忙闲不均:缓冲区平衡处理速度。
课件用 BlockingQueue 演示了基于 std::queue 的实现。简化版:
1template<typename T> 2class BlockingQueue { 3public: 4 explicit BlockingQueue(size_t cap) : _cap(cap) {} 5 6 void push(const T& x) { 7 std::unique_lock<std::mutex> lock(_mtx); 8 _not_full.wait(lock, [&]{ return _q.size() < _cap; }); 9 _q.push(x); 10 _not_empty.notify_one(); 11 } 12 13 T pop() { 14 std::unique_lock<std::mutex> lock(_mtx); 15 _not_empty.wait(lock, [&]{ return !_q.empty(); }); 16 T x = _q.front(); 17 _q.pop(); 18 _not_full.notify_one(); 19 return x; 20 } 21 22private: 23 std::queue<T> _q; 24 size_t _cap; 25 std::mutex _mtx; 26 std::condition_variable _not_full; 27 std::condition_variable _not_empty; 28}; 29
补充知识:
- 有界队列能提供 背压,防止生产者无限生产导致内存爆炸。
- 无锁队列、Disruptor 环形队列适合高性能场景,但实现复杂度高。
- 多生产者多消费者时,可以用两个条件变量分别通知“非空”和“非满”,减少无效唤醒。
5. 信号量与环形队列
POSIX 信号量也用于同步,接口包括:
1sem_init(&sem, 0, value); 2sem_wait(&sem); // P 操作,减 1 3sem_post(&sem); // V 操作,加 1 4sem_destroy(&sem); 5
信号量本质是一个计数器:
- 值大于 0,
sem_wait成功并减 1; - 值等于 0,
sem_wait阻塞; sem_post增加计数并唤醒等待者。
环形队列非常适合用信号量实现:
room_sem:表示剩余空间,生产者关心;data_sem:表示已有数据,消费者关心;- 两个 mutex 分别保护生产下标和消费下标。
简化逻辑:
1void push(const T& x) { 2 room_sem.P(); 3 { 4 std::lock_guard<std::mutex> lock(prod_mtx); 5 ring[prod] = x; 6 prod = (prod + 1) % cap; 7 } 8 data_sem.V(); 9} 10 11T pop() { 12 data_sem.P(); 13 T x; 14 { 15 std::lock_guard<std::mutex> lock(cons_mtx); 16 x = ring[cons]; 17 cons = (cons + 1) % cap; 18 } 19 room_sem.V(); 20 return x; 21} 22
补充知识:
- 单生产者单消费者时,生产下标和消费下标互不干扰,可以不加 mutex,只靠信号量同步。
- C++20 提供了
std::counting_semaphore和std::binary_semaphore。 - 信号量能表达“资源数量”,条件变量更适合表达“某个条件成立”。
6. 线程池:任务与执行解耦
线程池维护一组工作线程,从任务队列中取任务执行。好处:
- 避免频繁创建销毁线程;
- 控制并发数量,防止资源耗尽;
- 提高响应速度,适合短小任务。
课件实现了一个固定线程数线程池,核心成员包括:
std::vector<Thread>:工作线程;std::queue<T>:任务队列;Mutex、Cond:保护队列和等待任务;_isrunning:控制退出。
工作线程循环:
1while (true) { 2 std::unique_lock<std::mutex> lock(_mtx); 3 _cv.wait(lock, [&]{ return !_tasks.empty() || !_isrunning; }); 4 5 if (!_isrunning && _tasks.empty()) break; 6 7 auto task = std::move(_tasks.front()); 8 _tasks.pop(); 9 lock.unlock(); 10 11 task(); 12} 13
提交任务:
1void submit(std::function<void()> task) { 2 { 3 std::lock_guard<std::mutex> lock(_mtx); 4 _tasks.push(std::move(task)); 5 } 6 _cv.notify_one(); 7} 8
补充知识:
- 工业级线程池通常有:核心线程数、最大线程数、空闲超时、任务队列容量、拒绝策略。
- 拒绝策略包括:直接抛异常、丢弃任务、丢弃最旧任务、调用者执行。
- 任务窃取(work stealing)可以提升多队列线程池的负载均衡。
- 单例线程池常用 DCLP(双重检查锁定)或 C++11 静态局部变量实现。
C++11 静态局部变量单例:
1class ThreadPool { 2public: 3 static ThreadPool& instance() { 4 static ThreadPool pool; 5 return pool; 6 } 7}; 8
这种写法由编译器保证线程安全,比手写 DCLP 更简洁。
7. 日志、策略模式与单例
课件中的日志系统用到了 策略模式:
LogStrategy:抽象策略;ConsoleLogStrategy:输出到控制台;FileLogStrategy:输出到文件;Logger:持有策略,对外提供日志接口。
一条日志通常包含:
- 时间戳;
- 日志等级;
- 进程/线程 ID;
- 文件名、行号;
- 消息内容。
C++ 可以用流式风格:
1LOG(LogLevel::INFO) << "hello " << 123 << 3.14; 2
补充知识:
- 异步日志常用“双缓冲”或“无锁队列”,避免业务线程阻塞在磁盘 I/O。
- 日志级别一般包括 DEBUG、INFO、WARNING、ERROR、FATAL。
- 单例模式分饿汉和懒汉。饿汉在程序启动时创建,简单但可能拖慢启动;懒汉延迟创建,但要考虑线程安全。
- 现代 C++ 中,优先用
static局部变量或std::call_once实现线程安全单例。
8. 线程安全与可重入
线程安全:多个线程并发访问共享资源时,程序仍能正确执行。
可重入:同一个函数被不同执行流重复进入,结果仍然正确。
关系可以这样理解:
- 可重入函数通常是线程安全的;
- 线程安全函数不一定是可重入的;
- 如果一个线程安全函数内部用了锁,而信号处理函数中又调用它,就可能死锁,因此它不可重入。
常见线程不安全:
- 不保护共享变量;
- 函数状态随调用变化;
- 返回静态变量指针;
- 调用线程不安全函数。
常见不可重入:
- 调用
malloc/free; - 调用标准 I/O 库;
- 使用静态数据结构。
补充知识:
errno在现代 glibc 中是线程局部存储,所以多线程下各自独立。strtok不可重入,strtok_r可重入。- 信号处理函数中只能调用 async-signal-safe 函数。
9. 死锁与常见锁
死锁是指多个执行流互相持有对方需要的资源,并永久等待。
死锁四个必要条件:
- 互斥:资源一次只能被一个执行流占用。
- 请求与保持:持有资源的同时请求新资源。
- 不可剥夺:已获得的资源不能被强行夺走。
- 循环等待:存在头尾相接的资源等待环。
避免死锁:
- 破坏循环等待:所有线程按相同顺序加锁;
- 一次性申请所有资源;
- 使用超时锁
try_lock_for; - 使用
std::scoped_lock(C++17)同时锁多个互斥量; - 避免锁未释放,优先 RAII。
常见锁类型:
- 互斥锁:最常用,阻塞等待。
- 自旋锁:忙等,适合短临界区。
- 读写锁:读共享、写独占,适合读多写少。
- 递归锁:同一线程可重复加锁,但容易掩盖设计问题。
- 悲观锁:先加锁再访问。
- 乐观锁:先访问,更新时用 CAS 或版本号检查。
- RCU:读多写少场景,延迟释放。
补充知识:
- 银行家算法用于死锁避免,但实际工程中较少直接使用。
- 死锁检测可以通过资源分配图找环。
- 分布式锁常用 Redis、ZooKeeper、etcd 实现。
10. STL 与智能指针的线程安全
STL 容器默认不是线程安全的。标准库只保证:
- 多个线程读同一容器是安全的;
- 多个线程写不同容器是安全的;
- 同一容器同时读写、多线程写,需要外部加锁。
智能指针:
unique_ptr:独占所有权,不涉及共享,通常无线程安全问题。shared_ptr:引用计数是原子的,但对象本身不是线程安全的。weak_ptr:配合shared_ptr解决循环引用。
补充知识:
shared_ptr的控制块引用计数原子,但shared_ptr对象的读写不是原子的。- C++20 提供
std::atomic<std::shared_ptr<T>>。 - 多线程访问同一对象时,即使通过
shared_ptr持有,也需要同步。
11. 总结
线程同步与互斥的核心可以浓缩成几句话:
- 共享数据必须保护,临界区必须互斥;
- 锁要 RAII 管理,条件变量要配合谓词和
while; - 生产消费模型用队列解耦,信号量适合表达资源计数;
- 线程池用任务队列复用线程,单例要保证线程安全;
- 死锁要破坏四个必要条件,优先统一加锁顺序;
- 线程安全和可重入不是一回事,STL 与智能指针也不是天然线程安全。
真正写并发程序时,除了 API 用法,更要关注:锁的粒度、等待通知的时序、任务队列的背压、退出流程的优雅性。把这些细节处理好,多线程代码才会从“能跑”变成“可靠”。
结尾
uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!
| 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! **不要忘记给博主“一键四连”哦! “今日目标达成!” |
|---|
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!
《【Linux】线程同步与互斥》 是转载文章,点击查看原文。