QQ20260311-185459

前言

多线程编程的核心挑战,在于如何让并发执行的线程安全访问共享资源 —— 若缺乏有效控制,会出现数据竞争、超卖、死锁等问题(比如多线程售票系统出现负票数)。而线程同步与互斥,正是解决这些问题的核心技术:互斥保证 “同一时间只有一个线程访问临界资源”,同步保证 “线程按预期顺序执行”。

本文聚焦 “互斥锁、条件变量、信号量、生产者消费者模型、线程安全与死锁” 五大核心模块,从基础概念到实战代码,再到原理拆解,层层递进,帮你彻底掌握多线程安全协作的底层逻辑。

同步和互斥是多线程 / 多进程并发执行时必然出现的现象,核心根源是多个执行单元会访问同一共享资源且执行顺序由操作系统调度无法完全控制;其中互斥是为避免多个执行单元同时争抢共享资源导致数据错乱,保证同一时间仅一个执行单元操作该资源,而同步是为协调有执行顺序依赖的线程,确保它们按预期的先后节奏执行,二者共同保障并发程序能正确运行。

一、线程互斥:解决 “同时访问” 的冲突问题

我们先来了解一下互斥,了解互斥要先知道一些基本概念:

1.1 核心概念:临界资源与临界区

⼤部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。而多个线程并发的操作共享变量,会带来⼀些问题。

多线程冲突的根源,是 “多个线程同时操作共享资源”。我们先明确三个关键概念,这是理解互斥的基础:

  • 临界资源:多线程共享的资源(如全局变量、文件、网络连接),是冲突的核心来源;
  • 临界区:线程中访问临界资源的代码段(需保护的核心逻辑);
  • 互斥:保证任意时刻只有一个线程进入临界区,避免数据竞争;
  • 原子性:操作不会被任何调度机制打断,要么完成、要么未完成(如硬件提供的swap指令)。

1.2 经典问题:无锁导致的并发错误

以一个简单的多线程模拟售票系统为例,无锁情况下会出现 “超卖”(票数为负),直观展示问题本质,同时理解上述的概念:

#include<stdio.h>
#include<unistd.h>
#include<iostream>

using namespace ThreadMoudle;

int ticket = 100;

void* sell_ticket(void* args)
{
    char* name = (char*)args;
    while(1)
    {
        if(ticket > 0)
        {
            usleep(1000); // 模拟业务耗时(放大冲突概率)
            printf("%s sells ticket: %d\n", name, ticket);
            ticket--; // 非原子操作,导致数据竞争
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main() {
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, NULL, sell_ticket, (void*)"thread1");
    pthread_create(&t2, NULL, sell_ticket, (void*)"thread2");
    pthread_create(&t3, NULL, sell_ticket, (void*)"thread3");
    pthread_create(&t4, NULL, sell_ticket, (void*)"thread4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    return 0;
}

我们先来看一下执行结果:
QQ20260313-114924

可以发现票在卖完后竟然变成了负数,但是在我们的代码逻辑中明明对其做过了判断,ticket为负数时应该直接退出,那为什么会发生出现负数的现象呢?我们根据这个小程序对上述的概念解释一下就明白了:

  1. 临界资源(Critical Resource)

    定义:被多个执行单元(线程 / 进程)共享,且同一时间只能被一个执行单元访问的资源。

    代码对应:

    int ticket = 100; // 全局变量 ticket
    
    • ticket 是 4 个售票线程(t1~t4)共享的资源,所有线程都要读取它的值、修改它(ticket--)。
    • 如果多个线程同时操作它,会破坏数据一致性(比如重复卖票、票号变为负数),因此它是典型的临界资源
  2. 临界区(Critical Section)

    定义:访问临界资源的那段代码片段,是多线程竞争的核心区域。

    代码对应:

    if(ticket > 0)
    {
        usleep(1000); // 模拟业务耗时(放大冲突概率)
        printf("%s sells ticket: %d\n", name, ticket);
        ticket--; // 非原子操作,导致数据竞争
    }
    
    • 这段代码包含对临界资源 ticket读取(判断 ticket>0、打印时读取值)和修改ticket--),是直接操作 ticket 的区域,因此是临界区
    • 多线程会同时进入这段代码,引发数据竞争。
  3. 互斥(Mutual Exclusion)

    定义:多个线程必须互斥地进入临界区,同一时间只能有一个线程执行临界区代码,以此保护临界资源的一致性。

    代码问题体现:

    当前代码没有任何互斥机制(比如互斥锁),4 个线程可以自由进入临界区:

    • 线程 1 和线程 2 同时读到 ticket=100,都进入 if 分支;
    • 两者都打印 sells ticket: 100,然后各自执行 ticket--
    • 最终 ticket 变成 98,但实际上只应该卖出 1 张票,出现重复售票
    • 极端情况:多个线程在 ticket=1 时同时进入 if 分支,会导致 ticket 变为负数。

    互斥的作用:如果给临界区加互斥锁(比如 pthread_mutex_t),就能保证同一时间只有 1 个线程执行临界区代码,避免数据错乱。

  4. 原子性(Atomicity)

    定义:一个操作是不可分割的,要么完全执行完成,要么完全不执行,中间不会被其他线程打断。

    代码问题体现:

    代码中的 ticket-- 看似是一行代码,但底层实际对应三条汇编指令:

    1. load :将共享变量ticket从内存加载到寄存器中 ;
    2. update : 更新寄存器里面的值,执行-1操作 ;
    3. store :将新值从寄存器写回共享变量ticket的内存地址 。

    这三步不是原子的,多线程调度时会在中间打断:

    • 线程 A 读取 ticket=100
    • 线程 B 也读取 ticket=100
    • 线程 A 减 1,写回 99;
    • 线程 B 减 1,写回 99;

    最终 ticket 只减了 1 次,而不是 2 次,这就是非原子操作导致的数据竞争。

    同时,if(ticket > 0)ticket-- 也不是原子操作:

    • 线程 A 判断 ticket>0 后进入分支,被调度打断;
    • 线程 B、C 也判断 ticket>0 并进入分支;
    • 线程 A 执行 ticket--ticket=0,线程 B、C 仍执行 ticket--,导致 ticket 变为负数。

    原子性的意义:只有保证临界区操作的原子性(或通过互斥让临界区整体原子化),才能让共享资源的修改符合预期。

💡 总结

概念 核心本质 代码对应
临界资源 多线程共享、需保护的资源 全局变量 ticket
临界区 访问临界资源的代码片段 if(ticket>0)ticket-- 代码块
互斥 同一时间仅一个线程进入临界区 代码缺失,导致重复售票 / 票号负数
原子性 操作不可分割,要么全做要么不做 ticket-- 是非原子操作,引发数据竞争

这段代码的核心问题就是:没有互斥保护临界区,临界资源被非原子操作并发修改,最终导致数据不一致。

要解决以上问题,需要做到三点:• 代码必须要有互斥行为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。• 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程进⼊该临界区。• 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫互斥量。

1.3 互斥量(mutex):线程互斥的核心工具

为了解决这种问题,我们就需要让多线程在访问同一份资源时互斥,Linux 提供互斥量(pthread_mutex_t 实现互斥,本质是 “一把锁”,通过lock/unlock保证临界区原子执行,满足三个核心要求:

  1. 同一时间只有一个线程进入临界区;
  2. 多个线程竞争临界区时,仅允许一个线程进入;
  3. 未进入临界区的线程,不影响其他线程竞争。
1.3.1 核心函数接口

下面是互斥量的核心接口:

接口 功能 关键说明
pthread_mutex_init(mutex, attr) 初始化互斥量 attr=NULL使用默认属性;静态初始化可用PTHREAD_MUTEX_INITIALIZER
pthread_mutex_lock(mutex) 加锁 阻塞等待,直到获取锁;若已加锁,当前线程挂起
pthread_mutex_trylock(mutex) 加锁 非阻塞等待
pthread_mutex_unlock(mutex) 解锁 释放锁,唤醒等待队列中的一个线程
pthread_mutex_destroy(mutex) 销毁互斥量 静态初始化的互斥量无需销毁;不可销毁已加锁的互斥量
1.3.1.1 pthread_mutex_init

我们先来看使用(初始化)锁的两种方式:

// 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 
// 这些宏只能在声明时初始化 pthread_mutex_t 变量(全局 / 静态变量),不能在运行时赋值,是轻量的初始化方式

//运行期动态操作互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
//参数:
//	mutex:指向要初始化的互斥锁变量
//	mutexattr:互斥锁属性(传 NULL 表示使用默认属性,等价于 PTHREAD_MUTEX_INITIALIZER)

注意

  • 不能对已初始化的锁重复调用,否则行为未定义
  • 静态初始化宏和函数初始化二选一,不可混用
1.3.3.2 pthread_mutex_lock
int pthread_mutex_lock(pthread_mutex_t *mutex);

作用

  • 阻塞式加锁,尝试获取互斥锁

行为

  • 锁空闲:立即获取锁,返回 0
  • 锁被占用:线程阻塞等待,直到锁被释放

错误场景(错误检查锁):

  • 同一线程重复加锁 → 返回 EDEADLK
  • 锁已被销毁 → 返回 EINVAL
1.3.3.3 pthread_mutex_trylock
int pthread_mutex_trylock(pthread_mutex_t *mutex);

作用

  • 非阻塞式加锁,尝试获取互斥锁

行为

  • 锁空闲:立即获取锁,返回 0
  • 锁被占用:不阻塞,直接返回 EBUSY 错误码

适用场景

  • 需要快速判断锁状态、避免线程长时间阻塞的逻辑
1.3.3.4 pthread_mutex_unlock
int pthread_mutex_unlock(pthread_mutex_t *mutex);

作用

  • 解锁互斥锁,释放给其他线程

约束

  • 必须由持有锁的线程调用,否则行为未定义(错误检查锁会返回 EPERM
  • 解锁后会唤醒一个正在等待该锁的阻塞线程

注意

  • 递归锁需要解锁次数 = 加锁次数,才能真正释放锁
1.3.3.5 pthread_mutex_destroy
int pthread_mutex_destroy(pthread_mutex_t *mutex);

作用

  • 销毁已初始化的互斥锁,释放内核资源

约束

  • 必须确保没有线程持有该锁,也没有线程在等待该锁
  • 销毁后不能再对该锁执行任何操作(加锁 / 解锁等)
  • 静态初始化的锁无需调用此函数销毁

了解了锁的使用,那么让我们对上述的代码加锁后看看能否解决问题:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int ticket = 100;
pthread_mutex_t mutex; // 互斥量,因为每一个线程都需要去使用锁,所以要定义为全局的

void *sell_ticket(void *arg) {
    char *tid = (char*)arg;
    while (1) {
        pthread_mutex_lock(&mutex); // 加锁,进入临界区
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket: %d\n", tid, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex); // 解锁,退出临界区
        } else {
            pthread_mutex_unlock(&mutex); // 必须解锁后再退出
            break;
        }
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&mutex, NULL); // 初始化互斥量
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, sell_ticket, "thread1");
    pthread_create(&t2, NULL, sell_ticket, "thread2");
    pthread_create(&t3, NULL, sell_ticket, "thread3");
    pthread_create(&t4, NULL, sell_ticket, "thread4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex); // 销毁互斥量
    return 0;
}

来看一下运行结果:
QQ20260315-124120

可以看到加锁后就不会出现超卖的现象了,因为临界区的执行变为了原子执行

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

锁提供的能力的本质实际上是将执行临界区代码时由并行转为串行,也就是一个接一个的,而不是一起的。所以在一个线程执行代码期间不会被其他线程打扰也是一种变相的原子性的表现。

对临界资源进行保护的本质就是用锁来对临界区代码进行保护,那么在加锁之后,在临界区内部允许线程切换吗?答案是肯定的,因为进程的调度与是否加锁没有关系,当前线程持有锁后还没有释放锁,其他线程也得等得到锁的线程执行完后释放锁才能展开对锁的竞争从而进入临界区,也就是说就算线程切换了,其他线程依旧无法得到锁。

1.3.2 互斥量实现原理

线程之间去竞争申请锁时,多线程都要先“看到”锁,所以锁本身就是临界资源!因此线程在申请锁的过程必须是原子的!

为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令(单条指令,不可打断),这些指令的作用是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,⼀个处理器上的交换指令执行时另⼀个处理器的交换指令只能等待总线周期。 lockunlock的简化伪代码如下:

lock:
    movb $0, %al        ; 寄存器al置0(代表“未获取锁”)
    xchgb %al, mutex    ; 交换al和mutex的值(原子操作)
    if (%al > 0) {      ; 若交换后al>0,说明之前mutex是1(未锁),获取成功
        return 0;
    } else {            ; 若al=0,说明之前mutex是0(已锁),挂起等待
        挂起当前线程,加入等待队列;
        goto lock;      ; 被唤醒后重新尝试获取锁
    }

unlock:
    movb $1, mutex     ; mutex置1(释放锁)
    唤醒等待队列中的一个线程;
    return 0;

申请锁是原子性的关键在于xchgb %al, mutex指令,线程在加锁时先将al寄存器中的值初始化为0,然后再将al寄存器中的值和锁mutex的值进行交换(是交换不是拷贝):

  • 如果mutex中的值是1,那么交换后al寄存器的值就是1,mutex的值为0,就成功申请到锁;
  • 如果mutex中的值是0,那么交换后al寄存器的值就是0,mutex的值也为0,该线程就挂起等待锁。

进程/线程切换:CPU内的寄存器硬件只有一套,但CPU内寄存器的数据可以由多份,也就是当前执行流的上下文。

所以把一个变量的内容交换到CPU寄存器的内部本质上是把该变量的内容获取到当前执行流的硬件上下文中,而执行流的硬件上下文是进程/线程私有的,所以我们swap、exchange将内存中的变量交换到CPU的寄存器中本质上就是获取锁,因为是交换,所以1(锁)只有一份,谁申请到就是谁的!

1.4 RAII 风格互斥量封装(避免漏解锁)

手动lock/unlock容易因异常、跳转导致漏解锁,推荐用 RAII(资源获取即初始化)封装,析构时自动解锁,代码更安全优雅。C++11提供了RAII风格的互斥锁:std::lock_guard,它的核心特性:

  • 基于 RAII 机制(构造时加锁,析构时自动解锁),无需手动调用 unlock(),即使临界区抛出异常也能安全解锁,彻底避免漏解锁问题。

我们通过头文件<mutex>就可以使用。当然我们也可以简单的自己封装一个RAII风格的互斥锁:

// Lock.hpp
#pragma once
#include <pthread.h>

namespace LockModule {
    class Mutex {
        public:
        Mutex() { pthread_mutex_init(&_mutex, nullptr); }
        ~Mutex() { pthread_mutex_destroy(&_mutex); }
        void Lock() { pthread_mutex_lock(&_mutex); }
        void Unlock() { pthread_mutex_unlock(&_mutex); }
        pthread_mutex_t* GetRawMutex() { return &_mutex; }

        // 禁止拷贝赋值(避免锁失效,防止多个对象操作同一锁)
        Mutex(const Mutex&) = delete;
        Mutex& operator=(const Mutex&) = delete;

        private:
        pthread_mutex_t _mutex;
    };

    // RAII锁守卫:构造时加锁,析构时自动解锁
    class LockGuard {
        public:
        LockGuard(Mutex& mutex) : _mutex(mutex) { _mutex.Lock(); }
        ~LockGuard() { _mutex.Unlock(); }

        private:
        Mutex& _mutex; // 引用互斥量,避免拷贝
    };
} // namespace LockModule

在并发编程中,当多个执行单元(线程 / 进程)访问共享的临界资源,且至少有一个执行单元会修改该资源时,若最终结果依赖于这些执行单元的相对执行时序,则称存在竞态条件。我们上述的买票就存在竟态条件,可以通过互斥锁来解决。

二、线程同步:解决 “执行顺序” 的协调问题

互斥保证了 “同一时间只有一个线程访问临界资源”,但无法控制线程执行顺序 —— 例如⼀个线程访问队列时,发现队列为空,它只能等待,直到其它线程将⼀个节点添加到队列中。线程同步的核心,是让线程按预期顺序执行,避免 “无效等待” 或 “执行时机错误”。

2.1 核心工具 1:条件变量(pthread_cond_t

条件变量用于 “线程等待某个条件满足”,需与互斥量配合使用,核心价值是 “高效等待 + 精准通知”,避免线程轮询浪费 CPU 资源。

2.1.1 条件变量核心接口
接口 功能 关键说明
pthread_cond_init(cond, attr) 初始化条件变量 attr=NULL使用默认属性;静态初始化可用PTHREAD_COND_INITIALIZER
pthread_cond_wait(cond, mutex) 等待条件 原子操作:1. 解锁mutex;2. 挂起线程;3. 被唤醒后重新加锁mutex
pthread_cond_signal(cond) 唤醒一个线程 从等待队列中随机唤醒一个线程,避免 “惊群效应”
pthread_cond_broadcast(cond) 唤醒所有线程 适用于需要所有等待线程同时响应的场景(如批量任务通知)
pthread_cond_destroy(cond) 销毁条件变量 不可销毁仍有线程等待的条件变量

我们先来简单的了解一下条件变量的使用再具体看一看条件变量的用途。

条件变量的使用与锁大致上是相同的,条件变量的初始化与锁相同,有两种方式:

QQ20260316-162559

一种是静态初始化,一种运行期动态操作条件变量,剩下的接口如下代码所示:

#include <iostream>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *active( void *arg )
{
    std::string name = static_cast<const char*>(arg);
    while (true){
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 活动..." << std::endl;
        pthread_mutex_unlock(&mutex);
    }
} 

int main( void )
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, active, (void*)"thread-1");
    pthread_create(&t2, NULL, active, (void*)"thread-2");
    sleep(3); // 可有可⽆,这⾥确保两个线程已经在运⾏
    while(true)
    {
        // 对⽐测试
        // pthread_cond_signal(&cond); // 唤醒⼀个线程
        pthread_cond_broadcast(&cond); // 唤醒所有线程
        sleep(1);
    }
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
}

运行结果:

QQ20260316-163831

  • 若用 pthread_cond_signal:每次只唤醒 1 个线程,输出会是 thread-1thread-2 交替打印
  • 若用 pthread_cond_broadcast:每次唤醒 2 个线程,输出会是 thread-1thread-2 连续打印(顺序由调度决定)

两个线程被 pthread_cond_broadcast 唤醒后,一定会争抢互斥锁(mutex) —— 这正是互斥锁的核心作用:同一时间只允许一个线程持有锁,所有想进入临界区的线程都必须排队抢锁

一、唤醒后抢锁的完整过程(结合代码例子)

我们用之前的代码场景,拆解两个线程(thread-1/thread-2)被唤醒后的抢锁逻辑:

  1. 唤醒前的状态

两个线程都卡在 pthread_cond_wait(&cond, &mutex) 这一行:

  • 此时 mutex释放状态(wait 内部会自动释放锁);
  • 线程处于「阻塞态」(不占用 CPU,也不抢锁)。
  1. 唤醒瞬间(broadcast 触发)

pthread_cond_broadcast(&cond) 会把两个线程从「阻塞态」唤醒,变成「就绪态」—— 但它们不会直接执行后续代码,而是立刻去抢 mutex

  • 操作系统的「线程调度器」会决定谁先拿到锁(调度策略可能是 FIFO、优先级、时间片等,对开发者来说是 “随机” 的);
  • 没抢到锁的线程会卡在「等待获取 mutex」的队列里(依然是就绪态,只是没执行权),直到锁被释放。

使用条件变量最经典的场景就是生产者 - 消费者模型,消费者消费时需要有资源可拿,生产者生产时需要有地方放资源,我们把资源当成苹果,放苹果的地方我们叫做篮子,我们知道篮子能容纳苹果的数量是有限的,所以:

  • 生产者生产苹果时如果篮子满了,那么生产者就要等待有消费者拿走苹果后才能继续生产;
  • 同理,消费者要拿取苹果时需要篮子中存在苹果,如果篮子中没有苹果时就要等待生产者向篮子中放苹果。

条件变量是多线程同步的核心工具,专门用于解决 “线程等待某个条件满足后再执行” 的问题,在这个场景里,它的核心价值是:

  • 避免消费者线程空轮询浪费 CPU—— 当队列空(条件不满足)时,消费者不再反复检查队列,而是通过条件变量进入阻塞等待;等生产者放了苹果后,再通过条件变量唤醒消费者,实现高效协作。

消费者线程(拿苹果的人)执行逻辑

  • 加锁(获取互斥锁),尝试从队列取苹果。
  • 发现队列空(条件不满足):调用条件变量的 wait 方法。
  • wait 做了两件原子性的事:释放锁(让生产者能拿到锁放苹果) + 进入阻塞等待(CPU 不再调度该线程,不浪费资源)。
  • 直到被生产者唤醒后,才会重新获取锁,再检查队列是否有苹果,有则取出。

生产者线程(放苹果的人)执行逻辑

  • 加锁,往队列里放苹果(修改共享资源)。
  • 放完苹果后,调用条件变量的 signal(或 broadcast)方法:唤醒一个 / 所有等待在该条件变量上的消费者线程
  • 最后释放锁

条件变量是线程间的 “通知开关”:队列空时,消费者通过它释放锁并躺平等通知;队列有数据时,生产者通过它叫醒消费者,全程配合锁保证资源安全,彻底避免空轮询的资源浪费。

2.1.2 关键问题:为什么pthread_cond_wait需要互斥量?

很多初学者会疑惑 “条件变量为什么要和互斥量绑定”,核心原因有两点:

  1. 保护条件判断:条件的判断依赖共享资源(如队列是否为空),需互斥量保证判断过程的原子性;
  2. 避免信号丢失:若先解锁再等待(非原子操作),可能出现 “解锁后、等待前” 条件已满足,信号已发出,但线程未收到,导致永久阻塞。pthread_cond_wait将 “解锁 + 等待” 封装为原子操作,完美解决此问题。
2.1.3 条件变量使用规范(必记!)

错误使用条件变量会导致伪唤醒、死锁等问题,需严格遵循以下规范:

// 等待条件的线程(消费者)
pthread_mutex_lock(&mutex);
while (条件不满足) { // 用while而非if,避免伪唤醒(系统误唤醒线程)
    pthread_cond_wait(&cond, &mutex);
}
// 操作临界资源(如从队列取数据)
pthread_mutex_unlock(&mutex);

// 通知条件的线程(生产者)
pthread_mutex_lock(&mutex);
// 修改条件(如向队列加数据,使条件满足);
pthread_cond_signal(&cond); // 唤醒等待线程
pthread_mutex_unlock(&mutex);

2.2 核心工具 2:POSIX 信号量(sem_t

信号量可看作 “带计数的锁”,核心用于 “资源计数” 和 “同步控制”,支持线程间 / 进程间共享。POSIX 信号量比 System V 信号量更简洁,是多线程同步的常用工具。

信号量的本质是一个计数器,描述的是临界资源中资源数量的多少,对于临界资源,我们既可以将其看作一整块资源,也可以将其分为若干块,一块一块的使用。所以当我们把临界资源当成一块一块的时候,访问时就要先申请信号量,信号量的作用就是使得不同的线程访问不同的位置,同时保证不会有过多的线程访问临界资源,导致资源不够分。信号量的本质是对资源的“预定”机制。

信号量只有1或者0两态的信号量叫做二元信号量。

2.2.1 信号量核心接口
接口 功能 关键说明
sem_init(sem, pshared, value) 初始化信号量 pshared=0:线程间共享,非零表示进程间共享 ;value:信号量初始值(资源数量)
sem_wait(sem) 等待信号量(P 操作) 信号量值 - 1;若值为 0,阻塞等待
sem_post(sem) 发布信号量(V 操作) 信号量值 + 1;唤醒一个等待线程
sem_destroy(sem) 销毁信号量 不可销毁仍有线程等待的信号量

信号量的PV操作都是原子的。

2.2.2 信号量与互斥量的区别
特性 互斥量 信号量
核心用途 保证临界区互斥访问 资源计数、同步控制
所有权 加锁线程必须解锁(所有权绑定) 无所有权,任意线程可post
计数范围 0 或 1(二值锁) 任意非负整数
适用场景 单个临界资源的互斥访问 多个同类资源的并发访

具体的使用我们结合接下来的场景来具体体会。

2.3 经典应用:生产者消费者模型

生产者消费者模型是同步与互斥的典型应用,通过 “阻塞队列 / 环形队列” 解耦生产者(生成数据)和消费者(处理数据),核心遵循 “321 原则”,便于记忆:

  • 3 种关系:生产者 - 生产者(互斥)、消费者 - 消费者(互斥)、生产者 - 消费者(同步 + 互斥);
  • 2 种角色:生产者(写入数据)、消费者(读取数据);
  • 1 个容器:阻塞队列 / 环形队列(缓存数据,平衡生产 / 消费速度)。

⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能力。这个阻塞队列就是用来给⽣产者和消费者解耦的。并且能够提高整体生产消费的效率。

2.3.1 生产者消费者模型的核心价值
  • 解耦:生产者和消费者无需直接通信,通过队列间接交互,降低代码耦合度;
  • 提高效率:多个生产者 / 消费者可同时工作,并发执行,充分利用 CPU 资源;
  • 平衡忙闲不均:当生产者生产速度快于消费者时,队列缓存数据;反之,消费者等待数据,避免资源浪费。
2.3.2 实现 1:基于BlockingQueue的生产消费模型(条件变量)

在多线程编程中阻塞队列(Blocking Queue)是⼀种常⽤于实现⽣产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列⾥存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

QQ20260316-234743

我们就用C++的queue模拟阻塞队列的生产消费模型:

BlockQueue.hpp

#pragma once

#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>

const int defaultcap = 5; // for test

template <typename T>
class BlockQueue
{
private:
    bool IsFull() { return _q.size() >= _cap; }
    bool IsEmpty() { return _q.empty(); }

public:
    BlockQueue(int cap = defaultcap)
        : _cap(cap), _csleep_num(0), _psleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }
    void Equeue(const T &in)
    {
        pthread_mutex_lock(&_mutex);
        // 生产者调用
        while (IsFull())
        {
            _psleep_num++;
            std::cout << "生产者,进入休眠了: _psleep_num" <<  _psleep_num << std::endl;
            pthread_cond_wait(&_full_cond, &_mutex);
            _psleep_num--;
        }
        // 100%确定:队列有空间
        _q.push(in);

        // 临时方案
        // v2
        if(_csleep_num>0)
        {
            pthread_cond_signal(&_empty_cond);
            std::cout << "唤醒消费者..." << std::endl;
        }

        // pthread_cond_signal(&_empty_cond); // 可以
        pthread_mutex_unlock(&_mutex); // TODO
        // pthread_cond_signal(&_empty_cond); // 可以
    }
    T Pop()
    {
        // 消费者调用
        pthread_mutex_lock(&_mutex);
        while (IsEmpty())
        {
            _csleep_num++;
            pthread_cond_wait(&_empty_cond, &_mutex);
            _csleep_num--;
        }
        T data = _q.front();
        _q.pop();

        if(_psleep_num > 0)
        {
            pthread_cond_signal(&_full_cond);
            std::cout << "唤醒消费者" << std::endl;
        }

        // pthread_cond_signal(&_full_cond);
        pthread_mutex_unlock(&_mutex);
        return data;
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    std::queue<T> _q; // 临界资源!
    int _cap;         // 容量大小

    pthread_mutex_t _mutex;
    pthread_cond_t _full_cond;
    pthread_cond_t _empty_cond;

    int _csleep_num; // 消费者休眠的个数
    int _psleep_num; // 生产者休眠的个数
};

注意:这⾥采用模版,是因为队列中不仅仅可以放置内置类型,比如int,对象也可以作为任务来参与生产消费的过程哦。

main.cpp

#include "BlockQueue.hpp"
#include "Task.hpp"
#include <iostream>
#include <pthread.h>
#include <unistd.h>

void *consumer(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);

    while (true)
    {
        sleep(10);
        // 1. 消费任务
        task_t t = bq->Pop();

        // 2. 处理任务 -- 处理任务的时候,这个任务,已经被拿到线程的上下文中了,不属于队列了
        t();
    }
}

void *productor(void *args)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    while (true)
    {
        // 生产任务
        bq->Equeue(Download);
    }
}

int main()
{
    // 申请阻塞队列
    BlockQueue<task_t> *bq = new BlockQueue<task_t>();

    // 构建生产和消费者
    pthread_t c[2], p[3];

    pthread_create(c, nullptr, consumer, bq);
    pthread_create(c+1, nullptr, consumer, bq);
    pthread_create(p, nullptr, productor, bq);
    pthread_create(p+1, nullptr, productor, bq);
    pthread_create(p+2, nullptr, productor, bq);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;
}
2.3.3 实现 2:基于环形队列的生产消费模型(信号量)

环形队列采用数组模拟,用模运算来模拟环状特性,容量固定,适合数据量稳定的场景,核心用信号量实现同步。

QQ20260318-210745

环形结构起始状态和结束状态都是⼀样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留⼀个空的位置,作为满的状态。

使用环形队列时只要不访问同一个位置,那么生产和消费就可以同时进行,因为它们修改的位置不同,相互之间不会影响,那么如果是同一个位置呢?可以发现,只有当队列为满或者为空时才会使得生产和消费指向同一个位置,那么这个时候消费和生产之间就要互斥了,所以:

  • 为空时:说明现在队列中还没有任务,所以需要生产者先同步运行;
  • 为满时:说明现在队列中已经放不下任务了,所以需要消费者先同步运行。

我们知道,生产者是要领先消费者的,但是生产者不能把消费者套一个圈以上,同时,消费者也不能超过生产者!

要满足上面的几点,我们可以使用信号量这个计数器,就能很简单的进行多线程间的同步过程。

RingQueue.hpp

#pragma once

#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"

static const int gcap = 5; // for debug

using namespace SemModule;
using namespace MutexModule;

template <typename T>
class RingQueue
{
public:
    RingQueue(int cap = gcap)
        : _cap(cap),
          _rq(cap),
          _blank_sem(cap),
          _p_step(0),
          _data_sem(0),
          _c_step(0)
    {
    }
    void Equeue(const T &in)
    {
        // 生产者
        // 1. 申请信号量,空位置信号量
        _blank_sem.P();
        {
            LockGuard lockguard(_pmutex);
            // 2. 生产
            _rq[_p_step] = in;
            // 3. 更新下标
            ++_p_step;
            // 4. 维持环形特性
            _p_step %= _cap;
        }
        _data_sem.V();
    }
    void Pop(T *out)
    {
        // 消费者
        // 1. 申请信号量,数据信号量
        _data_sem.P();
        {
            LockGuard lockguard(_cmutex);
            // 2. 消费
            *out = _rq[_c_step];
            // 3. 更新下标
            ++_c_step;
            // 4. 维持环形特性
            _c_step %= _cap;
        }
        _blank_sem.V();
    }

private:
    std::vector<T> _rq;
    int _cap;

    // 生产者
    Sem _blank_sem; // 空位置
    int _p_step;
    // 消费者
    Sem _data_sem; // 数据
    int _c_step;

    // 维护多生产,多消费, 2把锁
    Mutex _cmutex;
    Mutex _pmutex;
};
  • 如果资源可以划分使用,那就可以考虑使用sem;
  • 如果资源是整体使用,那就使用mutex。

三、关键概念:线程安全与可重入

线程安全与可重入是多线程编程的高频考点,也是避免隐蔽 bug 的核心,两者容易混淆,需明确区分:

3.1 线程安全

  • 定义:多线程并发访问时,程序能正确执行,无数据竞争、死锁等问题,最终结果符合预期;
  • 线程安全的场景:
    1. 仅访问局部变量(线程私有资源);
    2. 对共享资源的访问有互斥保护(如加锁);
    3. 仅读取共享资源(无写入操作);
  • 常见线程不安全场景:
    1. 未保护的全局变量、静态变量(如无锁的售票系统);
    2. 函数状态随调用变化(如依赖静态变量计数的函数);
    3. 返回指向静态变量的指针(多个线程同时访问该指针)。

3.2 可重入函数

  • 定义:同一函数被多个执行流(线程 / 信号)重复调用时,即使前一次调用未完成,后续调用仍能得到正确结果;
  • 可重入的条件:
    1. 不使用全局变量、静态变量;
    2. 不调用malloc/free(依赖全局堆管理);
    3. 不调用不可重入函数(如printf,依赖全局缓冲区);
    4. 所有数据由调用者提供(或使用局部变量);
  • 常见不可重入场景:
    1. 函数内有全局 / 静态变量(如int count = 0; void func() { count++; });
    2. 调用malloc/free或标准 I/O 库函数(如fprintf);
    3. 返回静态数据的指针(如char* func() { static char buf[1024]; return buf; })。

3.3 线程安全与可重入的关系

不要被上⾯绕口令式的话语唬住,你只要仔细观察,其实对应概念说的都是⼀回事。

可重⼊与线程安全联系:

  • 函数是可重入的,那就是线程安全的(其实知道这⼀句话就够了) ;
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题;
  • 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。

可重⼊与线程安全区别:

  • 可重入函数是线程安全函数的⼀种;
  • 线程安全不⼀定是可重入的,但可重⼊函数则⼀定是线程安全的;
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还未释放则会产⽣死锁,因此是不可重入的。

如果不考虑信号导致⼀个执行流重复进入函数这种重⼊情况,线程安全和重⼊在安全角度不做区分。因为重入的 “天然触发点” 是信号(比如线程 A 在执行 func () 到一半时,收到信号,信号处理函数又调用了 func ());如果没有信号,单执行流不会自己 “重复进入” 函数。

去掉了 “单执行流被信号打断后重复进入函数” 这个特殊场景,此时:

  • 重入问题的唯一来源,就只剩下多线程并发调用函数(因为单执行流不会自己重入);
  • 线程安全问题的核心,也是多线程并发调用函数;

此时二者的 “安全目标” 完全一致:保证函数在 “并发 / 重复调用” 下的执行结果正确,从 “安全角度” 看,二者的要求和效果没有区别。

简单总结一下:

  • 可重入函数一定是线程安全的(无共享资源依赖,自然不会有并发冲突);
  • 线程安全函数不一定是可重入的(如加锁保护共享资源的函数,重入时可能导致死锁);
  • 核心区别:线程安全关注 “多线程并发访问共享资源的安全性”,可重入关注 “函数自身是否能被重复调用”。

STL、智能指针和线程安全:

  • STL不是线程安全的:STL 的设计初衷是将性能挖掘到极致,而⼀旦涉及到加锁保证线程安全,会对性能造成巨⼤的影响。而且对于不同的容器,加锁⽅式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。

  • 对于 unique_ptr,由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。

    对于 shared_ptr,多个对象需要共用⼀个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够⾼效的原⼦的操作引用计数。

四、避坑指南:死锁的产生与避免

死锁是多线程编程的 “噩梦”—— 线程永久等待资源,程序无法继续执行,且难以排查。掌握死锁的产生条件和避免方法,是多线程开发的必备技能。

下面来认识一下什么是死锁:
假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问:

QQ20260319-224234

申请⼀把锁是原⼦的,但是申请两把锁就不⼀定了:

QQ20260319-224332

所以造成的结果是:

QQ20260319-224355

这就造成了死锁!

4.1 死锁的四个必要条件

死锁的产生必须同时满足以下四个条件,缺一不可:

  1. 互斥条件:资源只能被一个线程占用(如互斥量锁定后,其他线程无法获取);

  2. 请求与保持条件:线程持有已获资源,同时请求其他线程的资源(如线程 A 持有锁 1,再请求锁 2);

    QQ20260319-224550

  3. 不剥夺条件:资源不能被强制剥夺(如线程 A 持有锁 1,其他线程不能强行抢占);

    QQ20260319-224741

  4. 循环等待条件:线程间形成 “你等我、我等你” 的循环(如线程 A 等锁 2,线程 B 等锁 1)。

    QQ20260319-224808

4.2 避免死锁的核心方法

避免死锁的本质是 “破坏四个必要条件之一”,其中最实用的是破坏 “循环等待条件”,具体方法如下:

  1. 按固定顺序加锁:所有线程按统一顺序获取多个锁(如先锁 1 后锁 2),避免循环等待;

    // 正确:所有线程统一先加锁1,再加锁2
    pthread_mutex_lock(&mutex1);
    pthread_mutex_lock(&mutex2);
    // 操作资源
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
    
  2. 一次性申请所有资源:线程启动时一次性获取所需全部资源,避免 “持有部分资源再请求其他资源”;

  3. 超时机制:加锁时设置超时(如pthread_mutex_timedlock),超时则释放已获资源,重新尝试;

  4. 避免锁嵌套:尽量减少锁的嵌套使用,若必须嵌套,控制嵌套层数(越少越好)。

五、其他常见锁概念(拓展认知)

除了互斥量、条件变量、信号量,还有以下常见锁概念,帮你建立全面认知:

  • 悲观锁:假设数据会被修改,访问前先加锁(如互斥量、读写锁),适合写操作频繁的场景;
  • 乐观锁:假设数据不会被修改,更新前检查是否被修改(如 CAS 操作、版本号机制),适合读操作频繁的场景;
  • 自旋锁:锁被占用时,线程循环等待(不挂起),适合锁占用时间短的场景(避免线程切换开销);
  • 读写锁:区分读锁(共享,多个线程可同时获取)和写锁(互斥,仅一个线程可获取),提升读多写少场景的并发效率。

六、总结

线程同步与互斥是 Linux 多线程编程的 “内功基石”,更是从 “能写多线程” 到 “写好高质量多线程” 的关键跨越。掌握互斥量的原子性原理、条件变量的同步逻辑、信号量的资源计数机制,不仅能帮你避开数据竞争、死锁、伪唤醒等常见 “坑”,更能让你在设计并发程序时,实现 “安全与效率的平衡”—— 既保证共享资源的访问正确性,又不牺牲程序的并发性能。

无论是日常开发中的多线程协作,还是高性能场景下的生产者消费者模型设计,同步与互斥的思想都贯穿始终。如果想进一步巩固所学,建议动手实现一个多生产者多消费者的任务调度队列(结合条件变量与互斥量,支持任务优先级),或尝试封装一个读写锁(优化读多写少场景的并发效率)—— 这些实践能让你更深刻地理解 “锁粒度”“同步时机” 的核心逻辑。

希望本文能帮你打通线程同步与互斥的 “任督二脉”,在并发编程的道路上走得更稳、更扎实。后续若需深入探讨自旋锁、屏障(barrier)等高级同步机制,或多线程调试技巧,随时可以进一步交流!

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

更多内容可见主页

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐