欢迎光临
我们一直在努力

【Linux】线程同步与互斥

在这里插入图片描述

🎬 个人主页:道尔柯南

❄专栏传送门:《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. 条件变量:等待与通知

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

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

  • 加锁。
  • 检查条件。
  • 条件不满足,调用 wait 等待。
  • 被唤醒后重新检查条件。
  • 条件满足,访问资源。
  • 解锁。
  • 课件强调了 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有所帮助,不要忘记给博主“一键四连”哦!

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】线程同步与互斥
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址