Linux(十七)深入多线程编程:同步原语与实战指南

本文承接「线程共享地址空间」的底层结论,从竞态问题的根源 出发,逐层深入互斥锁、条件变量、信号量三大同步原语,补充线程安全与可重入、锁类型选型 两大核心概念,再上升到生产者消费者经典模型 ,最终落地到异步日志、线程池、线程安全单例三大工程实战。每一部分都遵循「问题本质 → 核心语义 → 底层原理 → 工程封装 → 常见坑点 」的讲解逻辑,所有封装保持与你现有 my_lock 完全一致的代码风格,可直接集成到你的项目中。

多线程编程的一切同步问题,根源都来自「线程共享地址空间」:同一套页表让所有线程可以直接读写同一块物理内存,带来了零成本通信的便利,也带来了**数据竞争(Data Race)**的风险。互斥锁、条件变量、信号量就是解决这类问题的三件核心工具:

  • 互斥锁:解决「同一时间多人修改共享数据」的互斥问题,保证临界区原子执行,是实现线程安全的基础手段。
  • 条件变量:解决「条件不满足时线程空转」的效率问题,让线程睡眠等待通知。
  • 信号量:解决「资源数量管控」的同步问题,通过计数实现资源分配与顺序协调。

三大原语不是孤立存在的,它们最经典的组合应用就是生产者消费者模型,而异步日志、线程池、线程安全单例等工程组件,本质上都是这个模型的具体落地。


1 互斥锁:临界区的原子性守门员

互斥锁(Mutex,Mutual Exclusion)是最基础、最常用的同步原语,也是条件变量和复杂同步结构的基石。

1.1 问题根源:竞态与临界区

我们先从你的日志系统场景切入:如果两个线程同时调用 LOG_INFO 向终端输出,会发生什么?

cpp 复制代码
// 线程1输出 "Hello from thread 1"
// 线程2输出 "Hello from thread 2"
// 无锁情况下,可能输出交错的乱码:
// Hello from thread Hello from thread 21

这种多个线程同时访问共享资源、执行结果依赖于线程调度时序的现象,称为竞态条件(Race Condition)

  • 共享资源:终端输出流、文件句柄、全局变量、队列等被多个线程访问的资源。
  • 临界区(Critical Section):访问共享资源的代码片段,同一时刻只能有一个线程进入。

互斥锁的核心作用,就是把临界区包裹起来,保证同一时刻只有一个线程执行临界区代码,让并发访问变成串行访问,从根源上消除竞态。

1.2 现有封装回顾与优化

你已有的 my_lockmy_lock_guard 是非常标准的 RAII 封装,我们先回顾并做工程化补强,补齐防拷贝、移动语义、异常安全等细节。

1.2.1 互斥锁基础封装
cpp 复制代码
// my_lock.hpp
#pragma once
#include <pthread.h>
#include <cassert>

class my_lock {
public:
    my_lock()  { 
        int ret = pthread_mutex_init(&_mutex, nullptr); 
        assert(ret == 0);
    }
    
    ~my_lock() { 
        pthread_mutex_destroy(&_mutex); 
    }

    // 禁止拷贝:锁是独占资源,不可复制
    my_lock(const my_lock&) = delete;
    my_lock& operator=(const my_lock&) = delete;

    // 加锁:拿不到锁则阻塞睡眠
    void lock()   { 
        pthread_mutex_lock(&_mutex); 
    }

    // 解锁
    void unlock() { 
        pthread_mutex_unlock(&_mutex); 
    }

    // 非阻塞尝试加锁:拿到锁返回true,拿不到立即返回false
    bool try_lock() {
        return pthread_mutex_trylock(&_mutex) == 0;
    }

    // 获取原生互斥量指针,供条件变量使用
    pthread_mutex_t* get() { return &_mutex; }

private:
    pthread_mutex_t _mutex;
};
1.2.2 RAII 锁守卫:my_lock_guard

手动调用 lock/unlock 有两个致命问题:

  1. 临界区中途异常退出时,会忘记解锁,导致死锁。
  2. 多出口函数容易遗漏解锁路径。

my_lock_guard 利用 RAII 思想,构造时加锁,析构时自动解锁,无论函数正常返回还是异常抛出,都能保证锁被正确释放。

cpp 复制代码
class my_lock_guard {
public:
    explicit my_lock_guard(my_lock& lock) : _lock(lock) { 
        _lock.lock(); 
    }

    ~my_lock_guard() { 
        _lock.unlock(); 
    }

    // 禁止拷贝与移动
    my_lock_guard(const my_lock_guard&) = delete;
    my_lock_guard& operator=(const my_lock_guard&) = delete;

private:
    my_lock& _lock;
};

这也是你日志系统中正在使用的模式:用 my_lock_guard 保护输出流写入,天然具备异常安全性。

1.3 互斥锁的底层原理

很多人以为互斥锁就是简单的"加个标记",但真正的 pthread 互斥锁是「用户态自旋 + 内核态休眠」的两级实现,兼顾了低延迟和低 CPU 占用。

1.3.1 互斥锁的状态

互斥锁内部本质是一个原子变量,有两种状态:

  • 未锁定:值为 0,没有线程持有。
  • 已锁定:值为持有线程的 ID,或标记为 1(取决于实现)。

加锁时通过原子指令(如 x86 的 cmpxchg)尝试修改状态:

  • 修改成功:直接拿到锁,全程在用户态完成,开销极小。
  • 修改失败:说明锁被占用,先自旋等待一小段时间(用户态忙等),如果锁还没释放,再通过 futex 系统调用陷入内核态睡眠。
1.3.2 futex:内核态的等待队列

Linux 下互斥锁的底层依赖 futex(Fast Userspace Mutex) 系统调用:

  • 内核为每个互斥锁维护一个等待队列,拿不到锁的线程会挂到队列上睡眠,不占用 CPU。
  • 解锁时如果等待队列为空,直接用户态修改状态即可;如果有等待线程,则调用 futex 唤醒其中一个。

这种设计的优势:无竞争时完全在用户态完成,只有发生竞争时才陷入内核,兼顾了高性能和高并发下的效率。

1.4 死锁:互斥锁最常见的陷阱

死锁指多个线程互相持有对方需要的锁,同时等待对方释放,导致所有线程永久阻塞。

1.4.1 死锁的四个必要条件
  1. 互斥条件:资源是独占的,同一时间只能一个线程持有。
  2. 持有并等待:线程持有至少一个资源,同时等待其他线程持有的资源。
  3. 不可剥夺:资源只能由持有者主动释放,不能被强行抢占。
  4. 循环等待:线程之间形成环形等待链,每个线程都在等下一个线程的资源。
1.4.2 典型死锁场景
cpp 复制代码
// 线程1:先拿锁A再拿锁B
// 线程2:先拿锁B再拿锁A
// 并发执行时会发生死锁
1.4.3 避免死锁的工程方法
  1. 固定加锁顺序:所有线程都按相同的顺序加锁(比如永远先A后B),破坏循环等待条件。
  2. 使用 RAII 守卫:避免遗漏解锁,破坏不可剥夺场景下的异常死锁。
  3. 使用 try_lock:拿不到全部锁就全部释放重试,避免永久等待。
  4. 避免锁嵌套:尽量减少临界区嵌套,一个临界区内不要再次加锁。

1.5 互斥锁的进阶类型

pthread 支持多种互斥锁属性,通过 pthread_mutexattr_t 设置:

  • 普通锁(默认):最常用,线程重复加锁会自己卡死自己。
  • 递归锁(PTHREAD_MUTEX_RECURSIVE):同一个线程可以多次加锁,内部维护计数,解锁次数等于加锁次数才真正释放。缺点是开销略大,容易掩盖设计问题。
  • 错误检查锁:重复加锁会返回错误,适合调试阶段排查问题。
  • 自适应锁:根据锁的竞争情况自动调整自旋时间,多核场景下性能更好。

1.6 线程安全与可重入:两个极易混淆的核心概念

我们使用互斥锁、原子操作等同步工具,最终目的都是实现线程安全。而「可重入」是一个更早、更严格的概念,二者经常被混淆,这里从本质上彻底厘清。

1.6.1 什么是线程安全

线程安全:一个函数在多线程环境下并发调用时,始终能产生正确的结果,不会出现竞态条件、数据错乱,就称这个函数是线程安全的。

线程安全的核心判定:多个线程同时调用,是否会破坏共享数据的一致性

  • 如果函数只使用局部变量、不访问任何共享数据,它天然就是线程安全的。
  • 如果函数访问全局变量、静态变量、共享文件句柄等共享资源,就需要通过同步机制保护,才能做到线程安全。

例如:

  • malloc 内部有锁保护,是线程安全的。
  • strtok 内部使用静态变量保存位置,不加锁的情况下不是线程安全的。
1.6.2 什么是可重入

可重入:一个函数在执行过程中被中断(比如硬件中断、信号),中断处理程序中又再次调用该函数,返回原执行流后依然能得到正确结果,就称这个函数是可重入的。

可重入的核心判定:函数能否被"打断后重入执行"而不出错。它不依赖多线程,单线程+中断场景下就存在可重入问题。

可重入函数的基本要求:

  1. 不能使用全局变量、静态变量,因为中断重入时会修改同一份数据,导致原执行流的数据被破坏。
  2. 不能调用不可重入的函数(如 mallocprintf)。
  3. 不能返回静态数据的指针,必须使用调用者提供的缓冲区。
  4. 本质上:函数只能操作自己栈上的数据,不依赖任何共享状态。
1.6.3 线程安全 vs 可重入:核心区别

二者是交叉关系,而非包含关系:

维度 线程安全 可重入
关注场景 多线程并发访问 单线程被中断后重入
核心问题 多线程同时修改共享数据 中断重入破坏执行状态
解决手段 加锁、原子操作等同步机制 不使用共享数据,纯栈上运行
严格程度 允许使用共享数据,只要加锁保护 不允许使用任何可被修改的共享数据
关系 线程安全的函数不一定可重入 可重入的函数一定是线程安全的

关键结论

  1. 可重入是比线程安全更强的要求:一个可重入函数,天然就是线程安全的;但线程安全的函数,不一定可重入。
  2. 典型反例:加了锁的 printf 是线程安全的,但绝对不可重入------如果在信号处理函数里调用 printf,而原执行流刚好也持有 printf 的锁,就会发生死锁。
1.6.4 呼应信号机制:信号处理函数的可重入要求

这正好呼应了我们之前讲信号时提到的规则:信号处理函数中只能调用异步信号安全的函数

  • 异步信号安全,本质上就是「可重入」的另一种表述。
  • 信号处理函数是在中断上下文里异步插入执行的,如果调用不可重入函数,就可能破坏原执行流的状态,甚至死锁。
  • 这也是为什么信号处理函数里绝对不能加锁、不能调用 printf、不能调用 malloc 的根本原因。
1.6.5 实现线程安全的常用手段
  1. 互斥锁:保护临界区,串行化访问共享资源,最通用的手段。
  2. 线程本地存储(TLS) :每个线程使用独立副本,从根源上消除共享,比如 errno 的实现。
  3. 原子操作:针对简单的单变量读写、计数,用 CPU 原子指令完成,开销远小于锁。
  4. 不可变数据:共享数据只读不写,天然线程安全,不需要任何同步。

1.7 常见锁类型横向对比与选型

互斥锁只是锁家族中的一员,不同场景下有更适合的锁类型。理解各类锁的特性,才能在工程中做出最优选择。

1.7.1 自旋锁(Spinlock)
  • 工作方式:拿不到锁时,不会睡眠,而是在用户态循环尝试获取锁(忙等),直到拿到为止。
  • 优点:没有上下文切换开销,锁持有时间很短时性能极高。
  • 缺点:长时间拿不到锁会持续占用 CPU,浪费资源;单核场景下自旋完全无效。
  • 适用场景:锁持有时间极短(几十条指令以内)、多核 CPU 环境,比如内核中断上下文、高频短临界区。
  • 与互斥锁的核心区别:互斥锁拿不到锁就睡眠让出 CPU,自旋锁拿不到锁就一直忙等。
1.7.2 读写锁(Reader-Writer Lock)
  • 工作方式 :把访问者分为读者和写者,读者之间共享锁,写者独占锁。
    • 读共享:多个读者可以同时持有读锁,并发读取数据。
    • 写独占:只要有一个写者持有写锁,其他所有读者、写者都必须等待。
  • 优点:读多写少场景下,并发性能远高于普通互斥锁。
  • 缺点:实现复杂,开销比普通互斥锁大;处理不好会出现写者饥饿(一直有读者进来,写者永远拿不到锁)。
  • 适用场景:配置数据、缓存等读多写少的共享资源。
1.7.3 递归锁(Recursive Lock)
  • 工作方式:允许同一个线程多次加锁同一把锁,内部维护引用计数,解锁次数等于加锁次数时才真正释放。
  • 优点:方便嵌套调用场景,避免自己把自己锁死。
  • 缺点:额外的计数开销;容易掩盖代码结构问题,让锁的持有逻辑变得模糊。
  • 适用场景:递归函数中需要加锁、多层调用共用同一把锁的特殊场景;工程上优先通过重构代码避免使用递归锁。
1.7.4 锁粒度与优化原则

锁的性能开销,很大程度上取决于锁的粒度和使用方式:

  1. 缩小锁的粒度:能保护局部数据就不要保护全局,能锁小对象就不要锁大对象,减少锁持有时间。
  2. 缩小锁的范围:临界区只放必须保护的代码,耗时操作(如 IO、计算)尽量放到锁外。
  3. 读写分离:读多写少场景优先用读写锁,而不是普通互斥锁。
  4. 避免锁嵌套:非必要不嵌套加锁,必须嵌套时严格保持加锁顺序,预防死锁。
  5. 无锁优先:简单计数、标志位优先用原子操作,不要上来就加锁。

2 条件变量:让线程优雅地等待通知

互斥锁解决了临界区的互斥问题,但无法解决「条件不满足时怎么办」的问题。如果消费者发现队列为空,只能循环加锁检查、解锁休息,也就是忙等(Busy Waiting),会白白浪费大量 CPU。

条件变量(Condition Variable)就是专门解决这个问题的:条件不满足时,线程释放锁并进入睡眠;条件满足时,由其他线程唤醒它继续执行

2.1 为什么忙等不可接受

以异步日志的消费者线程为例,如果只用互斥锁:

cpp 复制代码
// 消费者线程:轮询检查队列,忙等浪费CPU
while (running) {
    my_lock_guard lock(mutex);
    if (!queue.empty()) {
        // 取出消息处理
    }
    // 这里如果不sleep,CPU 100%;sleep时间太长又响应不及时
}

忙等的本质是「用 CPU 时间换响应速度」,在真实系统中完全不可接受。条件变量的思路是「用睡眠换 CPU 效率」:没数据就睡觉,有数据再被叫醒。

2.2 条件变量的封装:保持与 my_lock 一致的风格

条件变量永远不能单独使用,必须和互斥锁绑定 ,这是由它的原子语义决定的。我们先封装一个 my_cond 类,和你的现有代码风格统一。

cpp 复制代码
// my_cond.hpp
#pragma once
#include <pthread.h>
#include "my_lock.hpp"
#include <cassert>
#include <ctime>

class my_cond {
public:
    my_cond() {
        int ret = pthread_cond_init(&_cond, nullptr);
        assert(ret == 0);
    }

    ~my_cond() {
        pthread_cond_destroy(&_cond);
    }

    // 禁止拷贝
    my_cond(const my_cond&) = delete;
    my_cond& operator=(const my_cond&) = delete;

    // 等待条件成立
    // 原子操作:释放互斥锁 + 进入睡眠;被唤醒后重新持有互斥锁
    void wait(my_lock& lock) {
        pthread_cond_wait(&_cond, lock.get());
    }

    // 带超时的等待,超时返回false
    bool timed_wait(my_lock& lock, long timeout_ms) {
        struct timespec ts;
        clock_gettime(CLOCK_REALTIME, &ts);
        ts.tv_sec += timeout_ms / 1000;
        ts.tv_nsec += (timeout_ms % 1000) * 1000000;
        if (ts.tv_nsec >= 1000000000) {
            ts.tv_sec += 1;
            ts.tv_nsec -= 1000000000;
        }
        return pthread_cond_timedwait(&_cond, lock.get(), &ts) == 0;
    }

    // 唤醒一个等待线程
    void signal() {
        pthread_cond_signal(&_cond);
    }

    // 唤醒所有等待线程
    void broadcast() {
        pthread_cond_broadcast(&_cond);
    }

private:
    pthread_cond_t _cond;
};

2.3 核心疑问:为什么必须配合互斥锁?

这是条件变量最核心的底层问题,答案是:为了保证「检查条件」和「进入睡眠」是原子操作

我们反证一下:如果条件变量不需要锁,会发生什么?

cpp 复制代码
// 错误逻辑:检查条件和睡眠不是原子的
// 消费者
if (queue.empty()) {       // 1. 检查条件:队列为空
    // 【窗口时间】生产者在这里插入数据并发送唤醒信号
    cond_wait();            // 2. 进入睡眠:但唤醒信号已经发过了,线程会永远睡下去
}

检查条件和睡眠之间存在窗口期,生产者的唤醒信号可能在窗口期内发出,导致消费者永远等不到唤醒,这就是丢失唤醒问题

条件变量 + 互斥锁的组合,完美解决了这个问题:

  1. 调用 wait 之前必须持有互斥锁,保证检查条件时不会有其他线程修改共享数据。
  2. wait 函数内部是原子操作:先把线程加入等待队列,再释放互斥锁,然后才进入睡眠。
  3. 整个过程没有窗口期,唤醒信号要么发生在 wait 之前(此时条件已满足,不会进入睡眠),要么发生在 wait 之后(此时线程已在等待队列,能被正常唤醒)。

一句话总结:互斥锁保护的是「共享条件」,条件变量负责「条件变化的通知」,二者缺一不可。

2.4 虚假唤醒:为什么必须用 while 而不是 if

条件变量有一个铁则:等待条件必须用 while 循环,绝对不能用 if

cpp 复制代码
// 正确写法:while 循环
while (queue.empty()) {
    cond.wait(lock);
}

// 错误写法:if 判断
if (queue.empty()) {
    cond.wait(lock); // 被唤醒后直接认为条件满足,可能出错
}
什么是虚假唤醒?

线程被 signal/broadcast 唤醒后,当它重新拿到锁时,条件可能已经不成立了,这种现象叫虚假唤醒(Spurious Wakeup)

产生虚假唤醒的原因主要有两个:

  1. 多线程竞争signal 唤醒了一个线程,但在它拿到锁之前,另一个线程抢先把数据取走了,队列又变回空。
  2. 内核实现原因:为了简化内核实现、提升性能,POSIX 标准允许条件变量在没有被显式唤醒时,自发地、无理由地唤醒等待线程。

因此,线程被唤醒后,必须重新检查条件是否成立 ,不成立就继续睡眠。只有 while 循环能保证这一点。

2.5 signal 与 broadcast 的区别

操作 行为 适用场景 风险
signal() 只唤醒等待队列中的一个线程 单生产者单消费者、资源只够一个线程使用 唤醒错线程可能导致丢失信号
broadcast() 唤醒等待队列中的所有线程 多消费者、条件变化对所有线程都有意义 会引发惊群效应
惊群效应

broadcast 会把所有睡眠的线程都叫醒,但只有一个线程能拿到锁、拿到数据,其他线程拿到锁后发现条件不满足,又会重新睡回去。

  • 大量线程频繁被唤醒又睡回去,会造成不必要的上下文切换,浪费 CPU 资源。
  • 工程实践中,能用 signal 解决的就不要用 broadcast

2.6 条件变量标准使用范式

消费者范式
cpp 复制代码
my_lock_guard lock(mutex);
while (条件不成立) {
    cond.wait(lock);
}
// 条件成立,处理共享数据
生产者范式
cpp 复制代码
{
    my_lock_guard lock(mutex);
    // 修改共享数据,让条件成立
}
// 最佳实践:解锁后再发唤醒信号,避免唤醒的线程因拿不到锁立即又睡回去
cond.signal();

3 信号量:自带计数的资源管理器

信号量(Semaphore)是一种广义的同步原语,通过一个整数计数器来管控资源数量,支持两种原子操作:

  • P 操作(wait / down):计数器减 1。如果减完 ≥ 0,直接返回;如果减完 < 0,线程阻塞等待。
  • V 操作(post / up):计数器加 1。如果有线程在等待,唤醒其中一个。

3.1 信号量的封装

Posix 信号量用 sem_t 表示,我们同样封装成与现有风格一致的 my_sem 类:

cpp 复制代码
// my_sem.hpp
#pragma once
#include <semaphore.h>
#include <cassert>

class my_sem {
public:
    // 构造:设置初始计数值,0表示无名信号量(线程间共享)
    explicit my_sem(unsigned int init_value = 0) {
        int ret = sem_init(&_sem, 0, init_value);
        assert(ret == 0);
    }

    ~my_sem() {
        sem_destroy(&_sem);
    }

    // 禁止拷贝
    my_sem(const my_sem&) = delete;
    my_sem& operator=(const my_sem&) = delete;

    // P操作:计数-1,计数为0则阻塞
    void wait() {
        sem_wait(&_sem);
    }

    // 非阻塞P操作:成功返回true,失败立即返回false
    bool try_wait() {
        return sem_trywait(&_sem) == 0;
    }

    // V操作:计数+1,唤醒一个等待线程
    void post() {
        sem_post(&_sem);
    }

    // 获取当前计数值
    int get_value() const {
        int val;
        sem_getvalue(&_sem, &val);
        return val;
    }

private:
    sem_t _sem;
};

3.2 信号量的两种典型用法

用法一:二元信号量(实现互斥)

初始值设为 1 的信号量叫二元信号量,功能上看起来和互斥锁很像:

  • P 操作拿锁,V 操作放锁,同一时间只有一个线程能进入临界区。
  • 二元信号量 ≠ 互斥锁,二者有本质区别,见下文。
用法二:计数信号量(实现同步/资源计数)

初始值设为 N(N>1),表示有 N 个可用资源,这是信号量最擅长的场景:

  • 连接池:初始值等于最大连接数,取连接 P 操作,归还连接 V 操作。
  • 环形队列:两个信号量分别表示空槽位数和已填充数,天然匹配生产者-消费者模型。
  • 线程同步:初始值为 0,实现「线程A等待线程B完成某个任务」的顺序控制。

3.3 二元信号量 vs 互斥锁:看似相同,本质不同

很多人觉得"初始值为1的信号量就是互斥锁",这是错误认知,二者核心差异在于所有权

特性 互斥锁 二元信号量
所有权 有明确的持有者,谁加锁谁解锁 无所有权概念,任何线程都可以执行 V 操作释放
可重入 普通锁不可重入,递归锁可重入 不可重入,同一线程重复 P 操作会死锁
安全性 线程退出未解锁会导致资源泄漏 任何线程都能释放,容错性更强但风险更高
优先级反转 有优先级继承协议缓解 原生不支持

工程原则:保护临界区永远优先用互斥锁,不要用二元信号量替代。信号量的核心价值在于「资源计数」和「线程同步」,而不是互斥。

3.4 信号量 vs 条件变量:最核心的区别是「是否有状态」

这是三者中最容易混淆的一组,我们从本质上划清界限:

条件变量:无状态,只发通知
  • 条件变量本身不保存任何状态,它只负责「发信号唤醒人」。
  • 如果发 signal 的时候没有线程在等待,这个信号就直接丢失了,后面再来的线程也不会知道曾经有过信号。
  • 条件本身(比如队列空/满)是由互斥锁保护的共享变量存储的,条件变量不负责保存条件。
信号量:有状态,自带计数
  • 信号量本身有计数器,它的状态是持久的。
  • 执行 post 的时候就算没有线程在等待,计数也会 +1 保留下来;后面再来的线程执行 wait 会直接减计数通过,不会阻塞。
  • 信号量自己就承载了"资源数量"这个状态,不需要额外的共享变量配合。
直观对比示例
场景 条件变量行为 信号量行为
先发1次通知,后有线程来等 线程会阻塞(信号已丢失) 线程直接通过(计数保留了)
发3次通知,后有线程来等 线程阻塞(只保留最后一次?不,是全部丢失) 前3个线程都能直接通过

一句话总结:条件变量是「门铃」,没人在家按了白按;信号量是「令牌桶」,放进去的令牌会一直留着,来人就可以拿走用。

3.5 Posix 信号量的两种类型

  1. 无名信号量:基于内存的信号量,没有文件名,用于同一进程内的线程间同步,就是我们上面封装的类型。
  2. 命名信号量 :有文件路径名,内核持久化存在,可用于不同进程之间的同步,通过 sem_open 创建/打开。

4 三者横向对比与选型原则

4.1 完整对比表

维度 互斥锁(Mutex) 条件变量(Condition) 信号量(Semaphore)
核心作用 临界区互斥访问 条件变化的等待与通知 资源计数与线程同步
内部状态 有(持有/未持有) 无(仅通知,不保留状态) 有(整数计数器)
是否需要配合锁 自身就是锁 必须和互斥锁配合使用 自身独立,保护数据时仍需额外锁
操作原子性 加锁/解锁原子 wait原子释放锁+睡眠 P/V操作全程原子
唤醒特性 解锁时自动让竞争者竞争 signal唤醒1个,broadcast唤醒全部 post固定唤醒1个
所有权 有,谁加锁谁解锁 无,任何线程都可post
典型场景 保护共享变量、队列、文件等 生产者-消费者、阻塞队列、线程池 环形缓冲区、连接池、线程顺序控制

4.2 工程选型原则

  1. 只要是保护共享数据的临界区,首选互斥锁
  2. 需要等待某个条件成立、避免忙等,用条件变量 + 互斥锁,这是生产者-消费者的标准解法。
  3. 需要管控资源数量、实现线程间的顺序同步,用信号量;环形队列这类天然带计数的场景,信号量比条件变量更简洁。
  4. 能用互斥锁解决的不要用信号量,能用条件变量解决的不要自己用信号量造轮子。

4.3 常见认知误区纠偏

  1. ❌ 误区:信号量比条件变量高级。
    ✅ 正解:二者解决不同问题,条件变量+锁在复杂条件判断上更灵活,信号量更适合计数场景。
  2. ❌ 误区:条件变量可以脱离锁使用。
    ✅ 正解:脱离锁会出现丢失唤醒,这是设计层面的必然问题,不是bug。
  3. ❌ 误区:虚假唤醒是bug,可以避免。
    ✅ 正解:虚假唤醒是标准允许的行为,while循环是唯一正确的处理方式。
  4. ❌ 误区:加锁了就不会有竞态。
    ✅ 正解:锁只保证临界区原子,条件判断、逻辑设计错误依然会有竞态。
  5. ❌ 误区:线程安全就是可重入。
    ✅ 正解:可重入是更强的要求,可重入函数一定线程安全,线程安全函数不一定可重入。

5 经典同步模型:生产者消费者问题

三大同步原语最经典、最通用的组合应用,就是生产者消费者模型(Producer-Consumer Pattern),也叫有限缓冲问题。异步日志、线程池、消息队列等几乎所有并发组件,底层都是这个模型。

5.1 模型本质与核心三要素

生产者消费者模型的核心思想是:通过一个共享缓冲区解耦生产者和消费者,生产者只负责往缓冲区里放数据,消费者只负责从缓冲区里取数据,二者不需要直接交互。

模型包含三个核心要素:

  1. 互斥:缓冲区是共享资源,同一时间只能有一个线程(生产者或消费者)操作缓冲区,由互斥锁保证。
  2. 同步(满则阻塞):缓冲区满时,生产者必须阻塞等待,直到消费者取走数据腾出空间。
  3. 同步(空则阻塞):缓冲区空时,消费者必须阻塞等待,直到生产者放入数据。

简单说:锁保证缓冲区操作安全,同步机制保证双方不会在错误的状态下操作缓冲区

5.2 为什么需要生产者消费者模型

  1. 解耦:生产者和消费者不需要知道彼此的存在,只依赖缓冲区,修改一方不影响另一方。
  2. 削峰填谷:突发流量下,缓冲区可以暂存请求,避免消费者被瞬间冲垮,起到缓冲作用。
  3. 并发提升:生产者和消费者是独立的执行流,可以并行运行,充分利用多核 CPU。
  4. 异步化:生产者放入数据后立即返回,不需要等待消费完成,提升响应速度。

5.3 实现方式一:互斥锁 + 条件变量

这是最灵活、最常用的实现方式,用一把互斥锁保护缓冲区,两个条件变量分别控制"非空"和"非满"。

标准实现框架
cpp 复制代码
#include <queue>
#include "my_lock.hpp"
#include "my_cond.hpp"

template <typename T>
class blocking_queue {
public:
    explicit blocking_queue(size_t max_size = 1024) : _max_size(max_size) {}

    // 生产者:放入数据,队列满则阻塞
    void push(const T& value) {
        my_lock_guard lock(_mutex);
        while (_queue.size() >= _max_size) {
            _not_full.wait(lock); // 队列满了,等待非满条件
        }
        _queue.push(value);
        _not_empty.signal(); // 有新数据了,通知消费者
    }

    // 消费者:取出数据,队列空则阻塞
    T pop() {
        my_lock_guard lock(_mutex);
        while (_queue.empty()) {
            _not_empty.wait(lock); // 队列空了,等待非空条件
        }
        T value = _queue.front();
        _queue.pop();
        _not_full.signal(); // 腾出空间了,通知生产者
        return value;
    }

    bool empty() const {
        my_lock_guard lock(_mutex);
        return _queue.empty();
    }

    size_t size() const {
        my_lock_guard lock(_mutex);
        return _queue.size();
    }

private:
    std::queue<T> _queue;
    size_t _max_size;
    mutable my_lock _mutex;
    my_cond _not_empty;  // 条件:队列非空
    my_cond _not_full;   // 条件:队列非满
};
特点
  • 优点:逻辑清晰,条件判断灵活,可以支持复杂的唤醒条件。
  • 适用场景:大多数阻塞队列、任务队列,尤其是需要动态调整边界的场景。

5.4 实现方式二:双信号量 + 互斥锁

用两个信号量分别代表「空槽位数量」和「已填充数量」,一把互斥锁保护缓冲区操作。这是环形缓冲区的经典实现。

标准实现框架
cpp 复制代码
#include <vector>
#include "my_lock.hpp"
#include "my_sem.hpp"

template <typename T>
class ring_buffer {
public:
    explicit ring_buffer(size_t capacity)
        : _capacity(capacity), _buffer(capacity),
          _head(0), _tail(0),
          _empty_slots(capacity), // 初始空槽位=总容量
          _filled_slots(0)        // 初始已填充=0
    {}

    // 生产者:放入数据
    void push(const T& value) {
        _empty_slots.wait(); // 等待空槽位
        {
            my_lock_guard lock(_mutex);
            _buffer[_tail] = value;
            _tail = (_tail + 1) % _capacity;
        }
        _filled_slots.post(); // 增加已填充计数
    }

    // 消费者:取出数据
    T pop() {
        _filled_slots.wait(); // 等待有数据
        T value;
        {
            my_lock_guard lock(_mutex);
            value = _buffer[_head];
            _head = (_head + 1) % _capacity;
        }
        _empty_slots.post(); // 归还空槽位
        return value;
    }

private:
    size_t _capacity;
    std::vector<T> _buffer;
    size_t _head;
    size_t _tail;

    my_sem _empty_slots;  // 空槽位计数
    my_sem _filled_slots; // 已填充计数
    my_lock _mutex;       // 保护读写指针
};
特点
  • 优点:代码简洁,计数天然正确,不容易写错条件判断。
  • 适用场景:固定大小的环形缓冲区、有界队列,性能通常略高于条件变量版本。

5.5 单生产者 vs 多生产者

  • 单生产者单消费者(SPSC):最简单的场景,只有一个生产者和一个消费者,部分场景下甚至可以优化掉互斥锁,仅靠内存屏障实现无锁队列。
  • 多生产者多消费者(MPMC):最通用的场景,多个生产者同时写、多个消费者同时读,必须靠互斥锁保证缓冲区操作的原子性。我们上面给出的两个实现都天然支持 MPMC。

注意:无论哪种实现,生产者消费者模型都只能保证操作的线程安全,不能保证顺序。如果需要严格保序,需要额外的序号机制。


6 实战一:升级异步日志系统

生产者消费者模型最典型的落地场景,就是异步日志。我们基于你现有的日志框架,逐步升级出完整的异步日志。

6.1 为什么要做异步日志

你当前的同步日志有一个性能瓶颈:每次写日志都要直接调用 IO 操作(写终端、写文件),而磁盘 IO 是很慢的:

  • 业务线程会阻塞在 IO 上,拖慢业务逻辑。
  • 高并发下大量线程抢锁,锁竞争严重。

异步日志就是典型的生产者消费者模型:

  • 生产者:业务线程,只负责把日志消息格式化好,推入内存队列,立即返回,不阻塞在 IO 上。
  • 消费者:后台写盘线程,专门负责从队列中取出消息,批量写入磁盘。
  • 缓冲区:日志消息队列,解耦生产和消费。

这种设计既提升了业务线程的响应速度,又通过批量写盘提升了 IO 效率。

6.2 版本一:互斥锁 + 条件变量实现无界异步日志

使用互斥锁保护队列,条件变量实现「队列为空时后台线程睡眠,有消息时唤醒」。

完整实现代码
cpp 复制代码
// async_logger.hpp
#pragma once
#include <string>
#include <queue>
#include <fstream>
#include "my_lock.hpp"
#include "my_cond.hpp"
#include <pthread.h>

class async_logger {
public:
    // 构造:启动后台线程
    async_logger(const std::string& filename = "async.log") 
        : _running(true), _file(filename, std::ios::app) {
        pthread_create(&_worker, nullptr, &async_logger::worker_thread, this);
    }

    // 析构:优雅停止,确保剩余日志全部落盘
    ~async_logger() {
        {
            my_lock_guard lock(_mutex);
            _running = false;
            _cond.signal(); // 唤醒后台线程,让它检查退出标志
        }
        pthread_join(_worker, nullptr);
        if (_file.is_open()) {
            _file.flush();
            _file.close();
        }
    }

    // 对外接口:业务线程调用,推入日志消息(生产者)
    void append(const std::string& msg) {
        my_lock_guard lock(_mutex);
        _queue.push(msg);
        _cond.signal(); // 有新消息,唤醒后台线程
    }

private:
    // 后台线程入口(消费者)
    static void* worker_thread(void* arg) {
        async_logger* self = static_cast<async_logger*>(arg);
        
        while (true) {
            std::queue<std::string> tmp_queue; // 局部队列,用于批量交换

            {
                my_lock_guard lock(self->_mutex);
                // while循环:等待队列非空 或 停止运行
                while (self->_queue.empty() && self->_running) {
                    self->_cond.wait(self->_mutex);
                }

                // 退出条件:停止运行 且 队列已空
                if (!self->_running && self->_queue.empty()) {
                    break;
                }

                // 批量交换:把整个队列换出来,缩小锁的粒度
                tmp_queue.swap(self->_queue);
            } // 这里就解锁了,后续写文件在锁外执行,不阻塞生产者

            // 批量写入文件,锁外执行,减少锁持有时间
            while (!tmp_queue.empty()) {
                self->_file << tmp_queue.front() << '\n';
                tmp_queue.pop();
            }
            self->_file.flush();
        }
        return nullptr;
    }

    std::queue<std::string> _queue;
    my_lock _mutex;
    my_cond _cond;
    pthread_t _worker;
    bool _running;
    std::ofstream _file;
};
设计亮点
  1. 批量写入 :用 swap 把整个队列交换到局部变量,锁只保护队列交换操作,写文件在锁外进行,大幅减少锁持有时间,降低锁竞争。
  2. 优雅退出 :析构时设置 _running=false 并唤醒线程,线程会把剩余日志全部写完再退出,不会丢日志。
  3. while 循环等待:严格遵循条件变量最佳实践,避免虚假唤醒问题。

6.3 版本二:信号量实现有界环形队列日志

无界队列的问题是:如果生产速度远大于消费速度,队列会无限增长,最终撑爆内存。我们用环形队列 + 双信号量实现有界缓冲,限制最大队列长度。

这就是生产者消费者模型的信号量实现方式在日志场景的直接落地,代码与上一章的 ring_buffer 逻辑完全对应,具体实现可参考前文环形队列示例,将元素类型替换为日志字符串即可。

6.4 进阶优化:双缓冲区异步日志

如果想进一步降低锁竞争、提升性能,可以采用**双缓冲区(Double Buffer)**方案:

  • 准备两个缓冲区:前台缓冲区(前端写入)、后台缓冲区(后端写入磁盘)。
  • 当前台缓冲区写满时,在锁内交换前后台缓冲区,后台线程拿着整个后台缓冲区去写盘,完全不影响前端继续写新的前台缓冲区。
  • 优势:锁的持有时间极短,只有交换指针的开销,业务线程几乎不会阻塞在锁上。
  • 这也是 muduo、spdlog 等高性能日志库的核心设计思想。

6.5 与原有同步日志的兼容

你不需要替换掉现有的同步日志,可以在你的 complete_log 类中增加模式切换:

  • 同步模式:直接调用原有的 file/console 输出策略。
  • 异步模式:调用 async_logger::append 推入队列。
    对外的 LOG 宏接口保持完全不变,内部根据配置选择输出路径,做到对业务代码透明。

7 实战二:基于同步原语实现线程池

生产者消费者模型另一个最常见的落地场景,就是线程池(Thread Pool)。它本质上是一个「任务生产者-线程消费者」模型,用固定数量的线程消费任务队列,避免频繁创建销毁线程的开销。

7.1 线程池的本质与价值

为什么需要线程池?

  1. 降低资源消耗:线程创建和销毁有不小的开销,线程池复用已有线程,避免频繁创建销毁。
  2. 控制并发数量:通过限制线程总数,避免系统因线程过多导致上下文切换过载。
  3. 提高响应速度:任务到达时,直接从池中取线程执行,不需要等待线程创建。
  4. 统一管理:可以统一监控、调优、拒绝任务,便于资源管控。

7.2 线程池的核心组成

一个标准线程池由三部分组成:

  1. 任务队列:存放待执行的任务,是生产者和消费者之间的共享缓冲区。
  2. 工作线程组:若干个常驻线程,循环从任务队列取任务执行,队列为空时睡眠等待。
  3. 管理者(可选):监控任务队列长度和线程负载,动态调整线程数量。

从同步原语角度看:

  • 互斥锁:保护任务队列,保证多线程并发读写的安全。
  • 条件变量:任务队列为空时,让工作线程睡眠;有新任务时,唤醒工作线程。
  • 这完全就是生产者消费者模型的直接应用:提交任务的线程是生产者,工作线程是消费者,任务队列就是缓冲区。

7.3 完整线程池实现

我们用你已有的 my_lockmy_cond,实现一个简洁、标准的固定大小线程池。

cpp 复制代码
// thread_pool.hpp
#pragma once
#include <vector>
#include <queue>
#include <functional>
#include "my_lock.hpp"
#include "my_cond.hpp"
#include <pthread.h>

class thread_pool {
public:
    using Task = std::function<void()>;

    // 构造:创建指定数量的工作线程
    explicit thread_pool(size_t thread_num)
        : _running(true) {
        for (size_t i = 0; i < thread_num; ++i) {
            pthread_t tid;
            pthread_create(&tid, nullptr, &worker, this);
            _threads.push_back(tid);
        }
    }

    // 析构:停止所有线程,等待剩余任务执行完毕
    ~thread_pool() {
        {
            my_lock_guard lock(_mutex);
            _running = false;
            _cond.broadcast(); // 唤醒所有工作线程
        }
        for (pthread_t tid : _threads) {
            pthread_join(tid, nullptr);
        }
    }

    // 提交任务(生产者接口)
    void submit(Task task) {
        my_lock_guard lock(_mutex);
        if (!_running) return;
        _tasks.push(std::move(task));
        _cond.signal(); // 唤醒一个工作线程
    }

private:
    // 工作线程入口(消费者)
    static void* worker(void* arg) {
        thread_pool* self = static_cast<thread_pool*>(arg);
        while (true) {
            Task task;
            {
                my_lock_guard lock(self->_mutex);
                // while循环:等待有任务 或 停止运行
                while (self->_tasks.empty() && self->_running) {
                    self->_cond.wait(self->_mutex);
                }
                // 退出条件:停止运行 且 无剩余任务
                if (!self->_running && self->_tasks.empty()) {
                    break;
                }
                // 取出一个任务
                task = std::move(self->_tasks.front());
                self->_tasks.pop();
            } // 解锁,执行任务在锁外进行
            task(); // 执行任务
        }
        return nullptr;
    }

    std::vector<pthread_t> _threads;
    std::queue<Task> _tasks;
    my_lock _mutex;
    my_cond _cond;
    bool _running;
};

7.4 使用示例

cpp 复制代码
#include <cstdio>
#include <unistd.h>
#include <sys/syscall.h>

int main() {
    thread_pool pool(4); // 创建4个工作线程的线程池

    for (int i = 0; i < 10; ++i) {
        pool.submit([i] {
            printf("任务 %d 在线程 %ld 中执行\n", i, syscall(SYS_gettid));
            sleep(1);
        });
    }

    // 等待所有任务执行完毕(pool析构时会自动等待)
    return 0;
}

7.5 进阶扩展方向

  1. 有界任务队列:当前实现是无界队列,可加入最大任务数限制,队列满时阻塞提交或执行拒绝策略。
  2. 动态线程数:支持核心线程数、最大线程数,任务繁忙时自动扩容,空闲时自动缩容。
  3. 拒绝策略:队列满时的处理策略,比如丢弃、阻塞、调用者执行、抛出异常。
  4. 任务返回值 :配合 std::future 实现可获取返回值的任务提交。

8 实战三:线程安全的单例模式

单例模式是互斥锁最经典的应用场景之一,也是面试高频考点。它的核心目标是:保证一个类在整个程序中只有一个实例,并提供全局访问点。日志管理器、配置管理器、线程池等组件,天然适合做成单例。

8.1 单例模式的核心要求

  1. 构造函数私有,禁止外部直接创建对象。
  2. 禁止拷贝和赋值,保证实例唯一。
  3. 提供一个全局静态接口,获取唯一的实例指针。
  4. 多线程环境下,依然保证只创建一个实例(线程安全)。

8.2 饿汉式:天生线程安全

饿汉式的核心思想:程序启动时就创建好实例,用的时候直接返回

cpp 复制代码
// 饿汉式单例
class singleton_hungry {
public:
    // 获取唯一实例
    static singleton_hungry* get_instance() {
        return &_instance;
    }

    // 禁止拷贝和赋值
    singleton_hungry(const singleton_hungry&) = delete;
    singleton_hungry& operator=(const singleton_hungry&) = delete;

private:
    // 构造函数私有
    singleton_hungry() = default;
    ~singleton_hungry() = default;

    // 静态成员变量,程序启动时就初始化
    static singleton_hungry _instance;
};

// 类外定义静态成员,程序启动时就创建
singleton_hungry singleton_hungry::_instance;
  • 优点:实现简单,程序启动时就完成初始化,天生线程安全,没有锁开销。
  • 缺点:无论用不用,程序一启动就创建对象,浪费资源;如果多个单例之间有依赖关系,初始化顺序无法保证。
  • 适用场景:对象不大、初始化快、确定一定会用到的单例,比如你的日志管理器。

8.3 懒汉式:基础版(非线程安全)

懒汉式的核心思想:延迟加载,第一次调用 get_instance 时才创建实例

基础版本在单线程下没问题,但多线程下有竞态:

cpp 复制代码
// 懒汉式基础版:多线程下不安全
class singleton_lazy_naive {
public:
    static singleton_lazy_naive* get_instance() {
        if (_instance == nullptr) {       // 竞态点:多个线程同时判断为nullptr
            _instance = new singleton_lazy_naive(); // 会重复创建多个实例
        }
        return _instance;
    }

private:
    singleton_lazy_naive() = default;
    static singleton_lazy_naive* _instance;
};

singleton_lazy_naive* singleton_lazy_naive::_instance = nullptr;
  • 问题 :两个线程同时进入 if (_instance == nullptr) 判断,都会认为实例为空,各自 new 一个对象,最终产生多个实例,破坏单例语义,还会造成内存泄漏。

8.4 懒汉式:加锁版(线程安全,但性能差)

最简单的修复方式:给整个 get_instance 加锁,保证同一时间只有一个线程进入创建逻辑。

我们用你自己的 my_lock 来实现:

cpp 复制代码
#include "my_lock.hpp"

class singleton_lazy_locked {
public:
    static singleton_lazy_locked* get_instance() {
        my_lock_guard lock(_mutex); // 每次调用都加锁
        if (_instance == nullptr) {
            _instance = new singleton_lazy_locked();
        }
        return _instance;
    }

private:
    singleton_lazy_locked() = default;
    static singleton_lazy_locked* _instance;
    static my_lock _mutex; // 静态互斥锁
};

singleton_lazy_locked* singleton_lazy_locked::_instance = nullptr;
my_lock singleton_lazy_locked::_mutex;
  • 优点:实现简单,绝对线程安全。
  • 缺点每次调用 get_instance 都要加锁,哪怕实例早就创建好了,依然要走加锁解锁流程,性能开销大。而实际上,只有第一次创建实例的时候才需要锁,之后读操作完全不需要锁。

8.5 双重检查锁定(DCL):性能优化版

为了解决「每次都加锁」的性能问题,出现了双重检查锁定(Double-Checked Locking, DCL):在加锁前后各检查一次实例指针。

cpp 复制代码
#include "my_lock.hpp"

class singleton_dcl {
public:
    static singleton_dcl* get_instance() {
        // 第一次检查:不加锁,快速判断
        if (_instance == nullptr) {
            my_lock_guard lock(_mutex); // 只有实例为空时才加锁
            // 第二次检查:加锁后再判断一次,防止多个线程同时通过第一次检查
            if (_instance == nullptr) {
                _instance = new singleton_dcl();
            }
        }
        return _instance;
    }

private:
    singleton_dcl() = default;
    static singleton_dcl* _instance;
    static my_lock _mutex;
};

singleton_dcl* singleton_dcl::_instance = nullptr;
my_lock singleton_dcl::_mutex;
为什么要两次检查?
  1. 第一次检查:实例创建完成后,后续所有调用都直接走这个判断,不需要加锁,性能接近无锁。
  2. 加锁:只有第一次实例为空时,才会进入加锁逻辑,保证只有一个线程能创建实例。
  3. 第二次检查:防止多个线程同时通过第一次检查、排队等锁。如果没有第二次检查,第一个线程创建完实例释放锁后,第二个线程拿到锁又会创建一次,依然会重复实例化。
指令重排问题与C++解决方案

早期 C++ 中,DCL 存在指令重排问题:new 操作可能被编译器/CPU 重排为「先分配内存,再把指针指向内存,最后调用构造函数」。此时另一个线程可能拿到一个「指向了未初始化内存的指针」,使用时就会崩溃。

C++11 之后,通过 std::atomic 配合内存序可以完美解决这个问题;但在工程实践中,有更简洁的方案。

8.6 最佳实践:局部静态变量(Meyers Singleton)

C++11 标准明确规定:函数内的局部静态变量,在多线程环境下只会被初始化一次,且初始化过程是线程安全的。利用这个特性,可以写出最简洁的线程安全单例,也叫 Meyers 单例。

cpp 复制代码
class singleton_meyers {
public:
    static singleton_meyers& get_instance() {
        // 局部静态变量:C++11保证多线程下只初始化一次
        static singleton_meyers instance;
        return instance;
    }

    // 禁止拷贝和赋值
    singleton_meyers(const singleton_meyers&) = delete;
    singleton_meyers& operator=(const singleton_meyers&) = delete;

private:
    singleton_meyers() = default;
    ~singleton_meyers() = default;
};
  • 优点
    1. 代码极简,不需要手动加锁,编译器保证线程安全。
    2. 懒加载,第一次调用时才初始化。
    3. 没有指针,不需要手动管理内存。
  • 缺点:C++03 标准下不保证线程安全,C++11 及以上才可靠。
  • 工程地位:这是现代 C++ 中最推荐的单例实现方式,简洁、安全、高效。

8.7 单例模式与你的日志系统结合

你的日志管理器非常适合做成单例:

  1. 整个程序只需要一个日志实例,统一管理文件句柄、异步队列、后台线程。
  2. 所有模块都可以通过 logger::get_instance() 全局访问,不需要到处传递日志对象。
  3. 使用 Meyers 单例实现,代码简洁且天然线程安全。

9 关联其他底层知识点

  1. 与原子操作的关系:互斥锁底层依赖原子指令实现状态修改;对于简单的计数、标志位,优先用原子操作,开销比锁小得多。但复杂的临界区、多变量同步,依然要用锁。
  2. 与 volatile 的关系:volatile 只能保证编译期不优化内存读写,完全不能替代互斥锁。锁的底层包含内存屏障,能保证多核缓存一致性,这是 volatile 做不到的。
  3. 与线程、信号的关系:互斥锁保护的临界区内,不要执行不可重入函数、不要长时间阻塞;信号处理函数中绝对不能调用加锁操作,否则极易死锁。
  4. 与页表共享的关系:正因为线程共享地址空间、共享物理内存,才会有数据竞争,才需要同步原语;进程间通信之所以复杂,正是因为它们不共享页表,无法直接通过共享变量同步。
  5. 与可重入的关系:加锁能实现线程安全,但会让函数变得不可重入;因此信号处理函数中绝对不能加锁,也不能调用加锁的函数。

10 全文总结

  1. 三大同步原语是基础
    • 互斥锁负责临界区互斥,保证共享数据安全,是实现线程安全的核心手段;
    • 条件变量负责等待与通知,解决忙等问题,必须配合互斥锁使用;
    • 信号量负责资源计数,适合有明确数量管控的场景。
  2. 线程安全与可重入是核心概念
    • 线程安全关注多线程并发下的数据一致性,可重入关注中断重入下的执行正确性;
    • 可重入是更强的要求,可重入函数一定线程安全,线程安全函数不一定可重入。
  3. 锁的选型有章法
    • 普通场景用互斥锁,读多写少用读写锁,极短临界区用自旋锁;
    • 缩小锁粒度、减少锁持有时间,是锁优化的核心原则。
  4. 生产者消费者模型是核心范式
    • 通过缓冲区解耦生产与消费,用互斥锁保证安全,用条件变量/信号量实现同步,是绝大多数并发组件的底层模型。
  5. 三大实战场景落地
    • 异步日志:业务线程生产日志消息,后台线程消费写盘,提升业务响应速度和 IO 效率;
    • 线程池:调用者生产任务,工作线程消费执行,复用线程资源,控制并发量级;
    • 线程安全单例:用锁或语言特性保证全局唯一实例,是日志、配置等管理器的标准实现。

从同步原语到核心概念,再到经典模型,最后落地到工程组件,整条链路是完全自洽的:底层原语支撑起经典模型,经典模型落地为具体组件。理解了这条完整链路,你再看任何多线程并发组件,都能快速拆解出它的底层同步逻辑,做到知其然也知其所以然。

相关推荐
老马聊技术1 小时前
Rocky Linux 9.0 安装 MySQL8.x详细教程
linux·数据库
xinyu3912 小时前
旧版Ubuntu Docker镜像 apt 失败
linux·ubuntu·docker
JZC_xiaozhong2 小时前
OA调价审批后,采购调价单如何自动同步到ERP?解决漏录错价难题
大数据·linux·服务器·前端·数据库·数据孤岛解决方案·数据集成与应用集成
ljs6482739512 小时前
Linux 实用命令:环境变量、文件传输、压缩解压、跨服务器传文件
linux·运维·服务器
AliCloudROS2 小时前
一次真实录屏:我只说每月别超过 200 块,小程序后端就搭好了
运维·人工智能·云计算
库克克2 小时前
【C++】类和对象--this指针详解
java·开发语言·c++
__Witheart__3 小时前
lunch选择的版型与版型对应的配置文件
android·linux
ch0sen1pm3 小时前
加班之余从零写了个 spdlog 风格的日志库
c++
A小调的码农3 小时前
给垃圾平板“一生E本” 装新系统—————Armbian 编译 KYX T7Y (RK3326) 踩坑全记录
linux·嵌入式硬件