
🎬 个人主页:道尔柯南
❄专栏传送门:《C语言》《C++》《Linux操作系统》
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。
文章目录
- 从互斥到线程池:C++ 多线程同步与互斥实战笔记
-
- 1. 为什么需要线程同步与互斥?
- 2. 互斥量:给临界区上锁
- 3. 条件变量:等待与通知
- 4. 生产者消费者模型
- 5. 信号量与环形队列
- 6. 线程池:任务与执行解耦
- 7. 日志、策略模式与单例
- 8. 线程安全与可重入
- 9. 死锁与常见锁
- 10. STL 与智能指针的线程安全
- 11. 总结
- 结尾
从互斥到线程池:C++ 多线程同步与互斥实战笔记
本文基于《11. 线程同步与互斥》课件内容整理,并补充了一些现代 C++、操作系统和并发编程中的额外知识。目标不是复述课件,而是把“为什么需要同步”“怎么用锁”“怎么设计生产消费模型和线程池”串成一条可落地的学习路线。
1. 为什么需要线程同步与互斥?
多线程最吸引人的地方是并发,但并发也带来了共享数据竞争。
课件里用了一个售票系统例子:多个线程同时卖票,最终可能卖出 -1、-2 张票。原因不是 ticket– 看起来简单,而是它在汇编层面通常对应三条指令:
load ; 把 ticket 从内存读到寄存器
sub ; 寄存器减 1
store ; 把新值写回内存
这三步不是原子的。线程 A 执行完 load 后被切走,线程 B 也读到同一个旧值,最后两次 store 会丢失一次更新。
由此引出几个关键概念:
- 共享资源:多个线程都能访问的数据。
- 临界资源:需要被保护的共享资源。
- 临界区:访问临界资源的代码段。
- 互斥:任何时刻只允许一个执行流进入临界区。
- 原子性:操作要么完成,要么未完成,不会被调度打断。
补充知识: 现代 CPU 提供原子指令,比如 CAS(Compare-And-Swap)、exchange、fetch_add。C++11 起可以用 std::atomic<T> 表达原子操作,例如:
std::atomic<int> ticket{100};
int old = ticket.fetch_sub(1);
但原子变量只适合简单共享变量。复杂临界区仍然需要锁。
2. 互斥量:给临界区上锁
POSIX 线程库提供 pthread_mutex_t,C++11 提供 std::mutex。课件中演示了两种初始化方式:
- 静态初始化:PTHREAD_MUTEX_INITIALIZER
- 动态初始化:pthread_mutex_init
加锁解锁接口:
pthread_mutex_lock(&mutex);
// 临界区
pthread_mutex_unlock(&mutex);
但手动加解锁容易在异常、提前 return 时漏掉。所以课件进一步封装了 RAII 风格的 LockGuard:
class LockGuard {
public:
explicit LockGuard(Mutex& m) : _m(m) { _m.Lock(); }
~LockGuard() { _m.Unlock(); }
private:
Mutex& _m;
};
C++11 也提供了 std::lock_guard、std::unique_lock:
std::mutex mtx;
{
std::lock_guard<std::mutex> lock(mtx);
// 临界区
}
补充知识:
- 互斥锁底层常基于 futex(Fast Userspace Mutex)。无竞争时在用户态完成,有竞争时进入内核挂起。
- 锁的粒度要尽量小,但也不能把共享数据拆得太碎,否则锁竞争和缓存一致性开销会上升。
- 自旋锁适合临界区极短的场景;互斥锁适合可能阻塞较久的场景。
- 不要销毁一个已加锁的互斥量,也不要在已销毁的锁上继续加锁。
改进后的售票逻辑可以写成:
std::mutex mtx;
int ticket = 100;
void sell(const std::string& id) {
while (true) {
std::lock_guard<std::mutex> lock(mtx);
if (ticket <= 0) break;
std::cout << id << " sells " << ticket— << std::endl;
}
}
3. 条件变量:等待与通知
互斥锁解决“不能同时访问”,条件变量解决“条件不满足时该怎么办”。
例如消费者发现队列为空,它不能一直占着锁空转,也不能直接退出。正确做法是:
课件强调了 pthread_cond_wait 的关键行为:
- 让调用线程等待;
- 自动释放已持有的互斥量;
- 被唤醒后重新竞争互斥量,成功后才返回。
因此条件变量必须和互斥量配合使用。典型结构:
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [&]{ return !queue.empty(); });
// 处理数据
补充知识:
- 必须用 while 或带谓词的 wait,防止 虚假唤醒。
- notify_one 只唤醒一个等待线程,notify_all 唤醒全部。
- 通知可能丢失:如果通知时没有线程在等,信号就没了。所以“条件状态”必须由共享变量记录,而不是依赖条件变量本身。
- C++20 引入了 std::stop_token、std::jthread,让线程停止更优雅。
4. 生产者消费者模型
生产者消费者模型可以概括为“321 原则”:
- 3 种关系:生产者与消费者互斥、生产者与生产者互斥、消费者与消费者互斥。
- 2 个角色:生产者、消费者。
- 1 个缓冲区:阻塞队列或环形队列。
它的价值在于:
- 解耦:生产者和消费者不直接通信。
- 支持并发:生产和消费可以同时进行。
- 支持忙闲不均:缓冲区平衡处理速度。
课件用 BlockingQueue 演示了基于 std::queue 的实现。简化版:
template<typename T>
class BlockingQueue {
public:
explicit BlockingQueue(size_t cap) : _cap(cap) {}
void push(const T& x) {
std::unique_lock<std::mutex> lock(_mtx);
_not_full.wait(lock, [&]{ return _q.size() < _cap; });
_q.push(x);
_not_empty.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(_mtx);
_not_empty.wait(lock, [&]{ return !_q.empty(); });
T x = _q.front();
_q.pop();
_not_full.notify_one();
return x;
}
private:
std::queue<T> _q;
size_t _cap;
std::mutex _mtx;
std::condition_variable _not_full;
std::condition_variable _not_empty;
};
补充知识:
- 有界队列能提供 背压,防止生产者无限生产导致内存爆炸。
- 无锁队列、Disruptor 环形队列适合高性能场景,但实现复杂度高。
- 多生产者多消费者时,可以用两个条件变量分别通知“非空”和“非满”,减少无效唤醒。
5. 信号量与环形队列
POSIX 信号量也用于同步,接口包括:
sem_init(&sem, 0, value);
sem_wait(&sem); // P 操作,减 1
sem_post(&sem); // V 操作,加 1
sem_destroy(&sem);
信号量本质是一个计数器:
- 值大于 0,sem_wait 成功并减 1;
- 值等于 0,sem_wait 阻塞;
- sem_post 增加计数并唤醒等待者。
环形队列非常适合用信号量实现:
- room_sem:表示剩余空间,生产者关心;
- data_sem:表示已有数据,消费者关心;
- 两个 mutex 分别保护生产下标和消费下标。
简化逻辑:
void push(const T& x) {
room_sem.P();
{
std::lock_guard<std::mutex> lock(prod_mtx);
ring[prod] = x;
prod = (prod + 1) % cap;
}
data_sem.V();
}
T pop() {
data_sem.P();
T x;
{
std::lock_guard<std::mutex> lock(cons_mtx);
x = ring[cons];
cons = (cons + 1) % cap;
}
room_sem.V();
return x;
}
补充知识:
- 单生产者单消费者时,生产下标和消费下标互不干扰,可以不加 mutex,只靠信号量同步。
- C++20 提供了 std::counting_semaphore 和 std::binary_semaphore。
- 信号量能表达“资源数量”,条件变量更适合表达“某个条件成立”。
6. 线程池:任务与执行解耦
线程池维护一组工作线程,从任务队列中取任务执行。好处:
- 避免频繁创建销毁线程;
- 控制并发数量,防止资源耗尽;
- 提高响应速度,适合短小任务。
课件实现了一个固定线程数线程池,核心成员包括:
- std::vector<Thread>:工作线程;
- std::queue<T>:任务队列;
- Mutex、Cond:保护队列和等待任务;
- _isrunning:控制退出。
工作线程循环:
while (true) {
std::unique_lock<std::mutex> lock(_mtx);
_cv.wait(lock, [&]{ return !_tasks.empty() || !_isrunning; });
if (!_isrunning && _tasks.empty()) break;
auto task = std::move(_tasks.front());
_tasks.pop();
lock.unlock();
task();
}
提交任务:
void submit(std::function<void()> task) {
{
std::lock_guard<std::mutex> lock(_mtx);
_tasks.push(std::move(task));
}
_cv.notify_one();
}
补充知识:
- 工业级线程池通常有:核心线程数、最大线程数、空闲超时、任务队列容量、拒绝策略。
- 拒绝策略包括:直接抛异常、丢弃任务、丢弃最旧任务、调用者执行。
- 任务窃取(work stealing)可以提升多队列线程池的负载均衡。
- 单例线程池常用 DCLP(双重检查锁定)或 C++11 静态局部变量实现。
C++11 静态局部变量单例:
class ThreadPool {
public:
static ThreadPool& instance() {
static ThreadPool pool;
return pool;
}
};
这种写法由编译器保证线程安全,比手写 DCLP 更简洁。
7. 日志、策略模式与单例
课件中的日志系统用到了 策略模式:
- LogStrategy:抽象策略;
- ConsoleLogStrategy:输出到控制台;
- FileLogStrategy:输出到文件;
- Logger:持有策略,对外提供日志接口。
一条日志通常包含:
- 时间戳;
- 日志等级;
- 进程/线程 ID;
- 文件名、行号;
- 消息内容。
C++ 可以用流式风格:
LOG(LogLevel::INFO) << "hello " << 123 << 3.14;
补充知识:
- 异步日志常用“双缓冲”或“无锁队列”,避免业务线程阻塞在磁盘 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有所帮助,不要忘记给博主“一键四连”哦!




