【Linux】线程同步与互斥

作者:道尔柯南日期:2026/9/19

🎬 个人主页道尔柯南

专栏传送门:《C语言》《C++》《Linux操作系统

昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

  • 从互斥到线程池: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)、exchangefetch_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_guardstd::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. 条件变量:等待与通知

互斥锁解决“不能同时访问”,条件变量解决“条件不满足时该怎么办”。

例如消费者发现队列为空,它不能一直占着锁空转,也不能直接退出。正确做法是:

  1. 加锁。
  2. 检查条件。
  3. 条件不满足,调用 wait 等待。
  4. 被唤醒后重新检查条件。
  5. 条件满足,访问资源。
  6. 解锁。

课件强调了 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_tokenstd::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_semaphorestd::binary_semaphore
  • 信号量能表达“资源数量”,条件变量更适合表达“某个条件成立”。

6. 线程池:任务与执行解耦

线程池维护一组工作线程,从任务队列中取任务执行。好处:

  • 避免频繁创建销毁线程;
  • 控制并发数量,防止资源耗尽;
  • 提高响应速度,适合短小任务。

课件实现了一个固定线程数线程池,核心成员包括:

  • std::vector<Thread>:工作线程;
  • std::queue<T>:任务队列;
  • MutexCond:保护队列和等待任务;
  • _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. 死锁与常见锁

死锁是指多个执行流互相持有对方需要的资源,并永久等待。

死锁四个必要条件:

  1. 互斥:资源一次只能被一个执行流占用。
  2. 请求与保持:持有资源的同时请求新资源。
  3. 不可剥夺:已获得的资源不能被强行夺走。
  4. 循环等待:存在头尾相接的资源等待环。

避免死锁:

  • 破坏循环等待:所有线程按相同顺序加锁;
  • 一次性申请所有资源;
  • 使用超时锁 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】线程同步与互斥》 是转载文章,点击查看原文


相关推荐


PyTorch训练坏样本自动定位:避免任务跑几小时后突然中断
ai小陈2026/9/11

深度学习任务运行数小时后,突然因损坏图片、空标注或异常尺寸报错,是GPU算力平台上的高频问题。此时GPU本身通常没有故障,真正原因是数据集没有在训练前完成校验。本文以PyTorch为例,搭建“预扫描—运行捕获—隔离清单—复核修复”的坏样本定位流程。 一、问题背景 常见异常包括图片无法解码、标签越界、文本编码错误、输入包含NaN,以及不同样本Shape无法组成Batch。小规模调试可能碰不到这些文件,大模型训练或长周期深度学习实验一旦随机读取到坏样本,整个进程就可能退出。 GPU服务器租用往


【AI】大模型本地部署与量化:Ollama、transformers、llama.cpp实践
漂流瓶jz2026/9/3

部署即在本地电脑中下载并运行模型,就像使用网络上的大模型API一样,但区别在于模型是运行在本地的,不收费也不会泄露信息。但模型可能很大,本地电脑可能会内存不足,这时候就需要量化来尝试缩小模型存储空间,同时尽量避免模型性能损失。 Ollama Ollama是一个大模型部署工具,用它只需要执行几个命令,就可以在本地电脑下载和部署大模型。官网列出了非常多可以部署的模型,有官方模型,也有用户训练/调整过的模型。 使用Ollama部署 首先安装Ollama本身,然后执行命令行。这里我们以Qwen3:0.6


本体论的基本核心概念
Shawn_Shawn2026/8/26

核心概念 Ontology(本体) 本体不是某一张表,而是整个组织共享的语义模型:它定义了企业里有哪些实体类型、实体有哪些属性、实体之间如何关联、可以对实体执行哪些操作。 在许多应用场景中,本体充当了组织的“数字孪生”(Digital Twin),兼具支持各类用例所需的语义元素(对象、属性、链接)和动力学元素(操作、函数、动态安全管控)。 对象类型(Object type) 定义了组织中的一个实体或事件。 属性(Property) 定义了对象类型的特征。 链接类型(Link type) 定义了


uni-app 三方库与插件管理体系全解析:从原生开发者视角彻底讲透
90后晨仔2026/8/13

作者视角: 本文面向从 iOS/Android/鸿蒙原生开发转型 uni-app 跨端开发的工程师。你习惯了 CocoaPods、Gradle、OHPM 那套成熟的包管理体系,来到 uni-app 后大概率会困惑: "我的依赖到底该放哪?谁管版本?谁解析传递依赖?" 这篇文章将一次性把这些困惑讲透。 一、为什么 uni-app 的依赖管理"看起来复杂"? 1.1 原生世界的"一平台一管家" 在纯原生开发中,每个平台有唯一、权威的包管理器:


CentOS Stream 9 Redis 7.2.7 源码编译一键安装脚本
☆凡尘清心☆2026/8/4

CentOS Stream 9 Redis 7.2.7 源码编译一键安装脚本 自动编译、自动配置、自动 systemd 托管开启 AOF 持久化 + 密码 + 远程访问安装完直接可用 #!/bin/bash set -euo pipefail # 版本与路径 REDIS_VERSION="7.2.7" INSTALL_DIR="/usr/local/redis" DATA_DIR="/data/redis" LOG_DIR="/var/log/redis" CONF_DIR="${INSTA


我用 AI Agent 重构了日常开发工作流,效果出乎意料
吴琼琼2026/7/27

我用 AI Agent 重构了日常开发工作流,效果出乎意料 写代码 5 年,我第一次觉得 AI 不只是「自动补全」 前言 不知道你有没有这种感觉——AI 编程工具用了一堆,但总觉得差点意思。 GitHub Copilot 帮你补全代码,但补完你还是要自己调试。Cursor 让你和 AI 聊天,但聊完你还是要自己改。ChatGPT 给你写函数,但写完你还得自己组装。 这些工具更像是一个「超级自动补全」,而不是一个「真正的开发者」。 直到我开始尝试 AI Agent——让你的 AI 不再是只会回


从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
胡萝卜术2026/7/19

从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路 当我们从数组和链表的“冰冷内存”转向字符串的“流式字符”时,滑动窗口才真正展现出它最优雅的一面。这道题,就是滑动窗口思想的“封神之作”。 前言 在连续攻克了链表专题的重重关卡——从反转链表(206)到LRU缓存(146)——之后,是时候进入一个全新的数据结构领域了。今天,我们首先要面对的,是字符串/数组专题中最经典、最基础、也是面试中出现频率最高的题目之一——力扣3. 无重复字符的最长子串(Longest Substr


MCP 入门实战:写一个能读本地文件的极简服务
To_OC2026/7/11

前几天折腾 AI IDE 的时候,一直有个特别烦人的痛点:大模型只能跟你聊代码逻辑,没法直接读我本地的项目文件。每次想让它帮我看个配置、改个脚本,都得手动复制一大段内容粘贴进去,文件长了特别折腾。 直到我看到有人提 MCP,说能让大模型直接调用本地工具。我寻思不就是读个文件嘛,应该不难,索性自己动手写个最简单的文件读取 MCP 服务。结果真上手才发现,坑全在细节里,折腾了小半天才跑通。今天顺着我当时的思路捋一遍,省得后面有人跟我一样走弯路。 先搞懂:MCP 到底在中间干了啥 说实话,最开始我对


Gson → kotlinx.serialization
plainGeek2026/7/3

Gson → kotlinx.serialization 老写法(Java + Gson) Gson gson = new Gson(); // 序列化 Item item = new Item(1, "商品", 9.99); String json = gson.toJson(item); // 反序列化 Item parsed = gson.fromJson(json, Item.class); List<Item> list = gson.fromJson(jsonArray,


图解 MongoDB 12|索引与查询优化地图:一条主线,三个判断轴
十三Tech2026/6/25

到这里,索引与查询优化这个阶段就讲完了。从第 04 篇的索引模型,到第 11 篇的慢查询排查闭环,中间穿过了索引类型、ESR 原则、explain、覆盖查询。这些不是孤立的知识点,而是一条连贯的主线——每一步都在回答「怎么让查询又快又省」。 这一篇是阶段的收束,不引入新机制,而是把前面讲过的东西收成一张地图和三个判断轴,方便你在实际工作中快速调用。后面进入存储引擎与内存阶段(13–17)时,会从「查询怎么用索引」下沉到「索引和数据怎么在内存里」。 一条主线 这条主线有六个节点,对应这个阶段的六

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读