线程同步与互斥

1. 线程互斥

1-1进程线程间的互斥相关背景概念

• 共享资源

• 临界资源:多线程执⾏流被保护的共享的资源就叫做临界资源

• 临界区:每个线程内部,访问临界资源的代码,就叫做临界区

• 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤

• 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

临界资源和临界区

进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

而多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。

例如,我们只需要在全局区定义一个count变量,让新线程每隔一秒对该变量加一操作,让主线程每隔一秒获取count变量的值进行打印。

cpp 复制代码
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int count = 0;

void* Routine(void* arg)
{
    while(1)
    {
        count++;
        sleep(1);
    }
    pthread_exit((void*)0);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,Routine,NULL);
    while(1)
    {
        printf("count %d\n",count);
        sleep(1);
    }
    pthread_join(tid,NULL);
    return 0;
}

此时我们相当于实现了主线程和新线程之间的通信,其中全局变量count就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问。

1-2互斥和原子性

• ⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。

• 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

• 多个线程并发的操作共享变量,会带来⼀些问题。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
// 操作共享变量会有问题的售票系统代码

void* route(void *arg)
{
    char *id = (char*)arg;
    while(1)
    {
        if(ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n",id,ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return NULL;
}

int main()
{
    pthread_t t1,t2,t3,t4;
    pthread_create(&t1,NULL,route,(void*)"thread 1");
    pthread_create(&t2,NULL,route,(void*)"thread 2");
    pthread_create(&t3,NULL,route,(void*)"thread 3");
    pthread_create(&t4,NULL,route,(void*)"thread 4");

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);

    return 0;
}

运行结果显然不符合我们的预期,因为其中出现了剩余票数为负数的情况。

该代码中记录剩余票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及--tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。

这里出现负数可以从宏观和微观来看

共享资源:tickets是存放在物理内存里的变量,所有线程都能访问它。

硬件上下文(寄存器):CPU 的寄存器是线程私有的,每个线程执行时,会把共享资源tickets从内存拷贝到自己的寄存器里运算,运算完再写回内存。

指令执行步骤:线程执行时会按

1.取指令 / 数据

2.分析指令

3.执行指令的流程进行,这个过程可能被 CPU 的线程切换打断。

(宏观原因)

第一层:临界区无互斥保护
if(ticket>0) 和 ticket-- 是两段独立的代码,中间可以被 CPU 线程切换打断

当 ticket=1 时,线程 A、B、C 会先后通过 if 判断(此时ticket还没被修改,都是 1),全部进入临界区

接下来 3 个线程依次执行 ticket--,相当于 1 张票被卖了 3 次,最终ticket从 1 → 0 → -1 → -2

(微观本质)

第二层:ticket-- 本身不是原子 操作

你说的三步完全正确,ticket-- 会被 CPU 拆成 3 条独立指令:

读:从物理内存把ticket的值,加载到当前线程的 CPU 寄存器(线程私有上下文)

算:在寄存器中对值做减 1 运算(此时修改的是线程私有数据,不影响内存)

写:把寄存器中的新值,写回物理内存

--操作对应的汇编代码如下:

eg:

1.初始物理内存:ticket = 1000

2.线程 A 执行ticket--,刚完成第一步(从内存读1000到自己的寄存器),就被线程 B 打断。

3.线程 B 在时间片内执行了 100 次ticket--,把内存里的ticket从1000改成900。

4.线程 A 恢复执行,继续完成剩下的两步(把寄存器里的1000减 1 得到999,再写回内存)。

5.最终内存里的ticket变成999,而不是899,数据完全错乱。

1-3互斥量mutex

要解决以上问题,需要做到三点:

• 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。

• 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程进⼊该临界区。

• 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。

要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫互斥量。

举个例子

自习室:对应共享资源(比如多线程里的tickets变量)

钥匙:对应互斥锁

人:对应线程 / 进程

1.拿钥匙开锁 → 线程调用lock()获取锁,只有拿到锁(钥匙)的线程才能进入临界区(自习室)操作共享资源。

2.后面的人等待 → 其他线程会进入阻塞状态,直到持有锁的线程释放锁。

3.出来归还钥匙 → 线程调用unlock()释放锁,其他线程才能竞争获取锁,进入临界区。


互斥量的接口

初始化互斥量

初始化互斥量有两种⽅法:

• ⽅法1,静态分配:

cpp 复制代码
 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 

⽅法2,动态分配:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const 
pthread_mutexattr_t *restrict attr);

参数:

mutex:要初始化的互斥量

attr:初始化互斥量的属性,一般设置为NULL即可。

返回值说明:

互斥量初始化成功返回0,失败返回错误码。

销毁互斥量

销毁互斥量需要注意:

• 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁

• 不要销毁⼀个已经加锁的互斥量

• 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:

mutex:需要销毁的互斥量。

返回值说明:

互斥量销毁成功返回0,失败返回错误码。

互斥量加锁

互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);

参数说明:

mutex:需要加锁的互斥量。

返回值说明:

互斥量加锁成功返回0,失败返回错误码。

调⽤ pthread_ lock 时,可能会遇到以下情况:

• 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功

• 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。

互斥量解锁

互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);

改进上⾯的售票系统:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t mutex;//所有线程都要申请所以是全局变量


void* route(void *arg)
{
    char *id = (char*)arg;
    while(1)
    {
        pthread_mutex_lock(&mutex);
        if(ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n",id,ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }

    }
    return NULL;
}

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

    pthread_mutex_init(&mutex,NULL);
    pthread_create(&t1,NULL,route,(void*)"thread 1");
    pthread_create(&t2,NULL,route,(void*)"thread 2");
    pthread_create(&t3,NULL,route,(void*)"thread 3");
    pthread_create(&t4,NULL,route,(void*)"thread 4");

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);
    pthread_mutex_destroy(&mutex);

    return 0;
}

运行代码,此时在抢票过程中就不会出现票数剩余为负数的情况了。

互斥量实现原理探究

引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。

临界区内的线程可能进行线程切换吗?

会发生线程切换。

但因为互斥锁还未释放,其他线程无法获取锁进入临界区,只能阻塞等待。

所以切换不影响互斥性,临界区仍然安全。

锁是否需要被保护?

我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。

锁本身是线程安全的,lock 和 unlock 是原子操作,

所以锁不需要被额外保护。

锁的作用是保护共享资源,而不是被保护。

如何保证申请锁的过程是原子的?

为什么会出现负数 / 数据错误?

本质是因为操作系统的时钟中断(Timer Interrupt)机制。

为了实现并发(Concurrency)和公平,操作系统会每隔一段时间(时间片)就打断当前正在运行的进程 / 线程,让它让出 CPU。

这就导致:

对共享资源的操作(比如 ticket--)不是一个不可打断的整体,中间随时可能被时钟中断打断,导致多个线程交叉执行,最终数据错乱。

  1. 硬件方法:关闭中断(Disable Interrupts)

    原理:在执行关键代码(临界区)之前,手动向 CPU 发送指令,关闭所有外部中断。

    效果:此时 CPU 不再响应时钟信号,也不会被切换走。这段代码会一口气执行完,直到你开中断为止。

  2. 软件方法:让指令变成 "原子级"(Single Instruction / Atomic Instruction)

    原理:CPU 提供了一些原子指令(比如你刚才看到的 xchg)。这些指令本身就是一条汇编语句。

    效果:CPU 保证在执行这一条指令的过程中,绝对不会发生中断。

这是申请锁的汇编代码

初始状态

物理内存 mutex = 1(锁空闲)

线程 A、线程 B 同时申请锁

第一步:线程 A 先抢到 CPU,执行 xchgb 原子指令

movb $1, %al ; 线程A把自己的AL寄存器设为1
xchgb %al, mutex; 原子交换AL和mutex的值

交换后:

物理内存 mutex = 1 → 变成 0(锁被占用)

线程 A 的 AL 寄存器 = 1(交换前 mutex 的旧值)

判断 if(al > 0) → 1>0,加锁成功,进入临界区

第二步:线程 A 刚执行完xchg,时钟中断触发,CPU 切换到线程 B

线程 B 执行同样的指令:
movb $1, %al ; 线程B把自己的AL寄存器设为1
xchgb %al, mutex; 原子交换AL和mutex的值

交换后:

物理内存 mutex = 0 → 还是 0(锁已被占用)

线程 B 的 AL 寄存器 = 0(交换前 mutex 的旧值)

判断 if(al > 0) → 0 不大于 0,加锁失败,线程 B 挂起等待

第三步:线程 A 执行完临界区,解锁
movb $1, mutex ; 把mutex设回1(锁空闲)

唤醒等待的线程B

线程 B 被唤醒,回到lock流程,重新执行xchg,这次就能拿到锁了

「谁先执行xchg,谁就上锁」

xchgb 是CPU 硬件级原子指令:

执行期间绝对不会被中断、不会被其他 CPU 打断

多个线程同时抢锁时,xchg 会串行执行,第一个执行的线程直接把 mutex 从 1 改成 0,后面的线程只能拿到 0

这就是互斥锁「互斥性」的硬件底层保证

1-4互斥量的封装

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>

namespace LockModule
{
    // 锁本身的封装
    class Mutex
    {
    public:
        // 锁不能拷贝!删除拷贝构造 + 赋值
        Mutex(const Mutex &) = delete;  
        const Mutex &operator=(const Mutex &) = delete;

        // 构造:初始化锁
        Mutex()
        {
            int n = pthread_mutex_init(&_mutex, nullptr);
            (void)n; // 忽略返回值,不处理错误
        }

        // 加锁
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }

        // 解锁
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }

        // 给条件变量用:获取原生锁指针
        pthread_mutex_t *GetMutexOriginal()
        {
            return &_mutex;
        }

        // 析构:销毁锁
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            (void)n;
        }

    private:
        pthread_mutex_t _mutex; // 系统底层锁
    }; 

    // RAII 自动管理锁
    class LockGuard
    {
    public:
        // 构造时 加锁
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        // 析构时 解锁
        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex; // 必须用引用!指向外部真实锁
    }; 
}

抢票的代码就可以更新成为

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"
using namespace LockModule;
int ticket = 1000;
Mutex mutex;
void *route(void *arg)
{
 char *id = (char *)arg;
 while (1)
 {
 LockGuard lockguard(mutex); // 使⽤RAII⻛格的锁 
 if (ticket > 0)
 {
 usleep(1000);
 printf("%s sells ticket:%d\n", id, ticket);
 ticket--;
 }
 else
 {
 break;
 }
 }
 return nullptr;
}
int main(void)
{
 pthread_t t1, t2, t3, t4;
 pthread_create(&t1, NULL, route, (void*)"thread 1");
 pthread_create(&t2, NULL, route, (void*)"thread 2");
 pthread_create(&t3, NULL, route, (void*)"thread 3");
 pthread_create(&t4, NULL, route, (void*)"thread 4");
 pthread_join(t1, NULL);
 pthread_join(t2, NULL);
 pthread_join(t3, NULL);
 pthread_join(t4, NULL);
}

2.可重入VS线程安全

概念

线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。

重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。

常见的线程不安全的情况

  1. 读写共享全局变量 / 静态变量(最典型)
cpp 复制代码
int ticket = 100;  // 全局变量
void sell() {
    ticket--;      // 多线程同时操作 → 数据错乱
}

读 - 改 - 写不是原子操作

结果:少减、多减、负数、覆盖

  1. 不保护临界区(if + 修改 分开)
cpp 复制代码
if (ticket > 0) {
    ticket--;
}

多个线程同时进 if

结果:超卖、负数票

  1. 线程内返回栈地址(野指针)
cpp 复制代码
void *func() {
    char buf[1024];
    return buf;  // 栈内存,函数返回就失效
}

其他线程访问 → 段错误、乱码

  1. 多线程同时操作同一个文件描述符 / 标准输出
cpp 复制代码
printf("hello %d\n", i);
printf 内部有缓冲,多线程并发输出会乱序、拼接
  1. 调用不可重入函数

    典型不可重入函数:

    strtok

    rand / srand

    ctime / localtime

    asctime

    gethostbyname

    它们内部用静态缓冲区,多线程调用会互相覆盖。

  2. 未加锁的递归 / 重入锁误用

    普通互斥锁,同一个线程加锁两次 → 直接死锁

cpp 复制代码
lock();
lock();  // 自己锁自己
  1. 线程内使用 errno(全局变量)

    A 线程刚设置 errno,被 B 线程打断修改,A 再读就错了。

  2. 多线程同时 free 同一块内存

    重复释放、野指针、堆破坏。

常见的不可重入的情况

1.调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。

2。调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。

3.可重入函数体内使用了静态的数据结构。

可重入与线程安全联系

1.函数是可重入的,那就是线程安全的。

2.函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。

3.如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

1.可重入函数是线程安全函数的一种。

2.线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

3.如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。

3. 死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

A 拿着锁 1,想要锁 2

B 拿着锁 2,想要锁 1

谁都不松手

谁都拿不到新的

永远卡死 → 死锁

单执行流可能产生死锁吗?

单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。

cpp 复制代码
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t mutex;
void* Routine(void* arg)
{
	pthread_mutex_lock(&mutex);
	pthread_mutex_lock(&mutex);
	
	pthread_exit((void*)0);
}
int main()
{
	pthread_t tid;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&tid, NULL, Routine, NULL);
	
	pthread_join(tid, NULL);
	pthread_mutex_destroy(&mutex);
	return 0;
}

运行代码,此时该程序实际就处于一种被挂起的状态。

什么叫做阻塞?

进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。

在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。

例如,当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:

那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。

此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。

直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。

总结

站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了。

站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。

这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。

死锁的四个必要条件

互斥条件: 一个资源每次只能被一个执行流使用。

请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。

不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。

循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。

注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。

避免死锁

破坏死锁的四个必要条件。

加锁顺序一致。

避免锁未释放的场景。

资源一次性分配。

除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。

4. Linux线程同步

举个例子

自习室只有 1 把钥匙(互斥锁)

线程 A(第一个人):

🔑 拿到钥匙 → 进自习室 → 出来还钥匙 → 立刻又抢钥匙

线程 B、C、D(其他人):

每次刚要伸手拿钥匙,线程 A 已经抢回去了

结果:线程 A 永远占着锁,其他线程永远抢不到,永远阻塞 → 线程饥饿

我们要做的就是:

同步的目的之一

线程释放锁之后,不能立刻再抢,

必须排到等待队列的最后面,

让前面等待的线程先执行。

4-1条件变量

• 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

• 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列中。这种情况就需要⽤到条件变量。

互斥、饥饿、同步的核心理解

单纯加锁 = 互斥

保证同一时间只有一个线程进入临界区,数据安全没问题。

但它不保证公平,谁抢到是谁的。

饥饿问题

如果某个线程竞争力极强,释放锁后立刻又抢到,

其他线程长时间拿不到锁,就会出现线程饥饿。

典型场景:

写线程一直抢锁

读线程一直饿死

结果:数据写满了也没人读,效率极低。

引入同步 = 保证顺序 + 公平

增加一条规则:

线程释放锁后,不能立即重新申请,必须排到等待队列尾部。

这样下一个拿到锁的一定是队首线程,

所有线程按顺序轮流使用临界资源,

既安全又高效,不会出现饥饿。

B申请锁检查没有苹果,之后进入等待。此时就没有线程和a抢锁,此时,他申请锁放入苹果,释放锁,敲响铃铛B拿到苹果。

如果没有铃铛,B线程一直申请锁,导致A一直无法放入数据

4-2同步概念与竞态条件

• 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从⽽有效避免饥饿问题,叫做同步

• 竞态条件:因为时序问题,⽽导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

4-3条件变量函数

条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

条件变量主要包括两个动作:

1.一个线程等待条件变量的条件成立而被挂起。

2.另一个线程使条件成立后唤醒等待的线程。

条件变量通常需要配合互斥锁一起使用。

初始化条件变量

初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

参数说明:

cond:需要初始化的条件变量。

attr:初始化条件变量的属性,一般设置为NULL即可。

调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

销毁条件变量

销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond);

销毁条件变量需要注意:

使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。

等待条件变量满足

等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:

参数说明:

cond:需要等待的条件变量。

mutex:当前线程所处临界区对应的互斥锁。

唤醒等待

唤醒等待的函数有以下两个:

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

区别:

pthread_cond_signal函数用于唤醒等待队列中首个线程。

pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。

参数说明:

cond:唤醒在cond条件变量下等待的线程。、

例如,下面我们用主线程创建二个新线程,让主线程控制这二个新线程活动。这二个新线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入时才唤醒一个等待线程,如此进行下去。

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

using namespace std;

// ==================== 全局变量 ====================
// 条件变量:用于线程之间的等待 + 唤醒
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 互斥锁:保护条件变量,保证 wait/signal 安全
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// ==================== 线程执行例程 ====================
void *active(void *arg)
{
    // 把传入的参数转为字符串(线程名)
    string name = static_cast<const char *>(arg);

    // 线程死循环,持续等待被唤醒
    while (true)
    {
        // 1. 先加锁:条件变量必须配合互斥锁使用
        pthread_mutex_lock(&mutex);

        // ==================== 核心重点 ====================
        // pthread_cond_wait 内部做三件事:
        // 1) 自动解锁 mutex(让别人也能 wait)
        // 2) 阻塞等待,直到被 signal/broadcast 唤醒
        // 3) 被唤醒后,**自动重新加锁 mutex**
        // ===================================================
        pthread_cond_wait(&cond, &mutex);

        // 被唤醒后,开始执行任务(临界区)
        cout << name << " 被唤醒,开始活动 ..." << endl;

        // 2. 任务执行完,解锁
        pthread_mutex_unlock(&mutex);

        // 解锁后,线程再次进入循环,重新 lock + wait
    }

    return nullptr;
}

// ==================== 主线程 ====================
int main(void)
{
    pthread_t t1, t2;

    // 创建线程1,名字 "thread-1"
    pthread_create(&t1, nullptr, active, (void *)"thread-1");
    // 创建线程2,名字 "thread-2"
    pthread_create(&t2, nullptr, active, (void *)"thread-2");

    // 主线程 sleep 3s
    // 作用:确保两个子线程已经启动并进入 wait 状态
    sleep(3);
    cout << "主线程:开始每隔1秒唤醒一次线程 ..." << endl;

    // 主线程死循环,定时发唤醒信号
    while (true)
    {
        // 唤醒方式1:唤醒**一个**正在等待的线程
        // pthread_cond_signal(&cond);

        // 唤醒方式2:唤醒**所有**正在等待的线程(当前用这个)
        pthread_cond_broadcast(&cond);

        // 每隔 1 秒唤醒一次
        sleep(1);
    }

    // 等待线程退出(实际这里永远跑不到)
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    return 0;
}

signal 会发生什么

主线程喊一声 signal,操作系统只挑队首的一个线程(比如 thread-2)唤醒。

thread-2 执行完打印并解锁。

主线程再次 signal,这时候操作系统会挑下一个线程(thread-1)唤醒。

结果就是严格的轮流执行:1 -> 2 -> 1 -> 2...

broadcast

thread-2 和 thread-1 都是在等待锁,唤醒后都要竞争同一个 mutex。

假设 thread-2 先抢到锁,它就能执行打印;

如果 thread-1 先抢到锁,它就能执行打印。

**

4-4为什么pthread_cond_wait需要互斥量

条件变量的pthread_cond_wait函数必须配合互斥量使用,核心原因在于临界资源访问的线程安全与操作原子性保障:

1.线程在进入等待状态前,需要先对临界资源进行访问,以判断当前条件是否满足执行要求。而临界资源的访问本身属于临界区操作,必须通过互斥量实现独占访问,因此必须先申请互斥锁,再访问临界资源完成条件判断。

2.若在申请锁之前就访问临界资源、判断条件,会产生严重的竞态条件问题:线程观测到条件不满足时,其他线程可能已在同一时刻修改了临界资源,使条件变为满足,并发送了唤醒信号;但由于当前线程尚未进入等待状态,会直接错过该唤醒信号,最终导致线程永久阻塞在等待操作中,引发程序死锁。

eg:

不加锁判断条件,会出现一个致命时间差

cpp 复制代码
// 不加锁,直接判断
if (条件不满足) {
    pthread_cond_wait(&cond, &mutex);
}

步骤 A:你判断条件不满足

步骤 B:你还没调用 wait

步骤 C:别的线程突然改条件 + 发 signal

步骤 D:你终于调用 wait,进去睡觉

结果:

信号发完了,你才睡 → 永远醒不来

这就是竞态条件(race condition)。

为什么加锁就不会?

cpp 复制代码
pthread_mutex_lock(&mutex);

while(条件不满足) {
    pthread_cond_wait(&cond, &mutex);
}

pthread_mutex_unlock(&mutex);

这三件事被锁捆在一起,原子化:

判断条件

不满足 → 立刻进 wait

wait 内部原子解锁 + 睡觉

别人根本插不进步骤,不可能在你判断完、还没睡觉时改条件。

3.我要等 → 必须把锁让出去

你等待的条件,一定是别的线程才能改变的。

比如:

队列空 → 要等生产者放数据

数据没准备好 → 等其他线程计算完

资源不够 → 等别人释放

这些事情你自己做不到,必须等别人。

那别人要做这些事,必须先进临界区,必须拿到锁。

所以:

你必须解锁,别人才能干活,你才有机会被唤醒。

你不解锁,别人就无法改变条件,你就永远醒不来。

所以 wait 必须自动解锁,让别人能进来干活。

  1. 那为什么醒来又自动加锁?
    因为你醒了 → 条件满足了 → 你要立刻访问临界资源
    访问临界资源 → 必须重新加锁保证安全
    所以:
    进去等待:自动解锁(让别人干活)
    被唤醒:自动加锁(自己安全干活)

总结

等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。

条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。

pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。

条件变量使用规范

等待条件变量的代码

cpp 复制代码
pthread_mutex_lock(&mutex);
while (条件为假)
     pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);

唤醒等待线程的代码

cpp 复制代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
相关推荐
坐吃山猪4 小时前
Python09_正则表达式
开发语言·python·正则表达式
AI科技星4 小时前
v=c 物理理论核心参数转换表达式大全
开发语言·线性代数·算法·数学建模·平面
oldmao_20004 小时前
第八章 设计并发代码
开发语言·c++·多线程编程·并发编程
SomeB1oody4 小时前
【Python深度学习】2.1. 卷积神经网络(CNN)模型理论(基础):卷积运算、池化、ReLU函数
开发语言·人工智能·python·深度学习·机器学习·cnn
Java面试题总结4 小时前
2026年Java面试题最新整理,附白话答案
java·开发语言·jvm·笔记·spring·intellij-idea
ZPC82104 小时前
RViz 虚拟机械臂 / 真实机械臂 / Gazebo 仿真
linux·人工智能·机器人
大鹏说大话4 小时前
前端性能优化全链路指南:从资源加载到渲染的极致体验
开发语言
芒果披萨4 小时前
日志管理 logging
java·开发语言·c++
unicrom_深圳市由你创科技4 小时前
C# 如何实现对象序列化
开发语言·c#