【linux线程(二)】线程互斥、线程同步、条件变量详细剖析

🎬 个人主页:HABuo

📖 个人专栏:《C++系列》 《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》

⛰️ 如果再也不能见到你,祝你早安,午安,晚安


目录

📚一、线程互斥相关背景介绍

📚二、线程互斥

[📖2.1 互斥锁的接口使用](#📖2.1 互斥锁的接口使用)

[📖2.2 如何理解锁](#📖2.2 如何理解锁)

📚三、死锁

📚四、线程安全和可重入

📚五、线程同步

[📖5.1 条件变量](#📖5.1 条件变量)

[📖5.2 条件变量的接口使用](#📖5.2 条件变量的接口使用)

📚六、总结


前言:

本篇博客我们接续上篇博客线程的讲解,上篇博客我们认识了线程、并清楚了如何操作线程,本篇博客我们来认识,多线程锁面临的问题,以及如何解决这些问题!

本章重点:

需要加锁使线程互斥的原因、互斥锁是什么,为什么要加锁,怎样理解锁、造成死锁的原因、为什么需要条件变量、条件变量的概念及使用


📚一、线程互斥相关背景介绍

在学习线程互斥前,先补充一些相关概念:

  • 临界资源: 多线程执行流安全访问的共享资源 叫做临界资源
  • 临界区: 多个执行流中,访问临界资源的代码,叫做临界区
  • 互斥: 任何时刻,互斥是保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性: 对一个资源进行访问时,不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

线程使用的数据大多都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量(当然也可以通过一些方式获得)。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互,多个线程并发的操作共享变量,会带来一些问题,如下面的例子:

我们最常见的高铁售票系统,可以把买票操作分为三步: 第一步: 判断现在还有无车票.第二步: 乘客付款后,钱包金额减少. 第三步: 乘客获得一张车票,高铁的总票数减一.多个执行流执行这三步时可能会出现问题,如下图:

代码示例:

cpp 复制代码
int ticket = 100;
void *route(void *arg)
{
	char *id = (char*)arg;
	while ( true ) {
		if ( ticket > 0 ) {
			usleep(1000);
			printf("%s sells ticket:%d\n", id, ticket);
			ticket--;
		} else {
		break;
		}
	}
}
int main( void )
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, route, "thread 1");
	pthread_create(&t2, NULL, route, "thread 2");
	pthread_create(&t3, NULL, route, "thread 3");
	pthread_create(&t4, NULL, route, "thread 4");
	//等待线程结束
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
}

发现多次执行这段代码得到的结果可能不同

为什么会出现不同的结果?

有以下几点原因:

  1. if 语句判断条件为真以后,代码可以并发的切换到其他线程
  2. usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  3. - -ticket操作本身就不是一个原子操作

根本原因就在于多个线程切换,寄存器存储的上下文仍然是上次线程的,导致出现多个线程都认为自己存储的还是1,使得最终内存为1时进行了多次--

📚二、线程互斥

为啥上面可能会出现多种结果?究其原因就是**- -ticket操作本身就不是一个原子操作,使得多个线程进行操作**

为什么- -ticket操作不是原子的?其实我们鉴定一个操作是不是原子性的可以查看这个操作的汇编代码,若汇编代码只有一条,则我们认为这个操作是原子的,反之则这个操作不是原子性的,可以来看看减减的汇编代码是有三条:

要解决上面的问题,需要满足以下条件:

  • 代码必须要有互斥行为:代码进入临界区执行时,不允许其他线程进入该临界区。即串行运行
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

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

任何一个时间,只允许一个线程获得这把锁并且继续向后执行,没拿到锁的线程默认只能在加锁处阻塞等待其他线程释放掉锁才能继续往后走,多个线程来竞争一把锁,它们的关系就是互斥

📖2.1 互斥锁的接口使用

函数 描述
pthread_mutex_init 动态初始化互斥锁
pthread_mutex_destroy 销毁互斥锁
pthread_mutex_lock 加锁(阻塞直到获得锁)
pthread_mutex_trylock 尝试加锁,若锁被占用则立即返回 EBUSY
pthread_mutex_timedlock 限时加锁,超时后返回 ETIMEDOUT
pthread_mutex_unlock 解锁

互斥锁的使用一般分为四个步骤:

  1. 初始化互斥锁
  2. 在到达临界区前加锁
  3. 在跑完临界区代码后解锁
  4. 用完互斥锁后进行销毁

第一步: 初始化互斥锁

方法一, 静态分配

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法二, 动态分配

第二步和第三步: 加解锁

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

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

第四步: 销毁互斥锁

所以现在可以更改一下售票系统:

cpp 复制代码
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;
		}
	}
}
int main( void )
{
	pthread_t t1, t2, t3, t4;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&t1, NULL, route, "thread 1");
	pthread_create(&t2, NULL, route, "thread 2");
	pthread_create(&t3, NULL, route, "thread 3");
	pthread_create(&t4, NULL, route, "thread 4");
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	pthread_mutex_destroy(&mutex);
}

📖2.2 如何理解锁

锁是用来保护共享资源,但是锁又是所有线程都可以申请,那锁是不是共享资源呢?它要不要保护呢?你的思虑是对的,但是由于锁本身就是原子的,因而不必为它担心!它是怎么实现原子的?


加锁流程

  • 线程1执行movb之后将0值存⼊⾃⼰的寄存器上下⽂
  • 执⾏xchgb指令原⼦交换锁变量和寄存器值

检查交换后寄存器中的值:

  • 若为1:表示成功获取锁
  • 若为0:表示锁已被占⽤,线程挂起等待

安全性保证

  • 成功获取锁的线程将锁值(1)保存在⾃⼰的上下⽂中
  • 其他线程交换时只能得到0值,⽆法进⼊临界区
  • 直到持有锁的线程释放锁(将1写回锁变量)

📚三、死锁

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

形成死锁的必要条件:

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

要避免死锁就是破坏上述四个必要条件

📚四、线程安全和可重入

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

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

所以说,重入实际上比线程安全更加严格

下面是常见的不可重入的情况:

可重入和线程安全的联系和区别:

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

📚五、线程同步

在多线程下并发的跑加锁的代码,确实不会出现数据一致性问题,但是也不代表这种方案就没有缺点,比如: 几个线程并发的执行一段代码,假如不做人为的干涉,这个线程进入临界区可能临界区资源并没有就绪,因此该线程什么事都没干,当它释放锁之后,下一个拿到锁的线程仍然是随机的,并且在加解锁这里,有一个可以称为就近原则的东西,就是说1号线程释放锁后,它此时距离锁最近,下一次获取锁可能还是1号线程,这就会导致其他线程虽然被创建出来了,但是并没有被调用,浪费的资源!

📖5.1 条件变量

在多线程编程中,线程之间经常需要协调执行顺序或等待某个共享资源的状态发生变化。条件变量是 POSIX 线程提供的一种同步机制,它允许一个或多个线程阻塞等待某个条件成立(例如队列非空、计数器达到阈值等),并在条件满足时被其他线程唤醒。

为什么需要条件变量?

仅使用互斥锁可以保证共享数据的互斥访问,但无法解决"等待某个条件发生"的问题。例如:

  • 线程 A 需要等待队列中有数据才能继续工作。

  • 如果线程 A 循环检查队列是否为空,会浪费 CPU 资源(忙等待)。

  • 如果线程 A 在检查到队列为空时释放锁并 sleep 一段时间,又可能导致响应延迟。

条件变量提供了一种高效的方式:当条件不满足时,线程可以原子地释放互斥锁并进入休眠,直到被其他线程唤醒。这样既避免了忙等待,又保证了线程安全

📖5.2 条件变量的接口使用

条件变量的核心操作

POSIX 条件变量类型为 pthread_cond_t,常用函数:

函数 作用
pthread_cond_init 初始化条件变量
pthread_cond_destroy 销毁条件变量
pthread_cond_wait 等待条件变量(阻塞)
pthread_cond_timedwait 限时等待条件变量
pthread_cond_signal 唤醒至少一个等待该条件变量的线程
pthread_cond_broadcast 唤醒所有等待该条件变量的线程

使用条件变量通常分为四步:

  1. 初始化条件变量
  2. 利用条件变量等待资源就绪
  3. 资源就绪后唤醒线程来访问
  4. 使用完后销毁条件变量

第一步:条件变量函数 初始化

cpp 复制代码
pthread_cond_t cond;//定义变量后再初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

第二步:等待条件满足

第三步:唤醒等待

  • pthread_cond_signal:唤醒至少一个等待该条件变量的线程(通常唤醒一个)。适用于多个线程在等待同一资源,但只需唤醒一个来处理。

  • pthread_cond_broadcast:唤醒所有等待该条件变量的线程。适用于资源状态变化需要所有等待线程重新评估条件(例如多个生产者消费者中的"全局停止"事件)。

第四步:销毁

代码示例:

cpp 复制代码
int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* start_routine(void* args)
{
    std::string name = static_cast<const char*>(args);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex); // 为什么要有mutex,后面就说
        // 判断暂时省略
        std::cout << name << " -> " << tickets << std::endl;
        tickets--;
        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    // 通过条件变量控制线程的执行
    pthread_t t[5];
    for (int i = 0; i < 5; i++)
    {
        char* name = new char[64];
        snprintf(name, 64, "thread %d", i + 1);
        pthread_create(t + i, nullptr, start_routine, name);
    }
    while (true)
    {
        sleep(1);
        // pthread_cond_signal(&cond);
        pthread_cond_broadcast(&cond);
        std::cout << "main thread wakeup one thread..." << std::endl;
    }
    for (int i = 0; i < 5; i++)
    {
        pthread_join(t[i], nullptr);
    }
    return 0;
}

但是上述代码是有bug的,需要注意下面问题:

必须与互斥锁配合使用

条件变量不能单独使用,必须与一个互斥锁配合。原因如下:

  • 避免竞态条件 :条件检查与进入等待必须是原子的。例如,线程 A 先检查队列为空,然后准备睡眠,但在睡眠前线程 B 插入了数据并发送了 signal。如果 A 没有持有锁,signal 就会丢失,导致 A 永远睡眠。(意思就是说,如果不配合使用,我睡眠之前你却放入了数据,并发送了signal,但是我已经做了检测认为没有数据即要进行睡眠了,你以为放入了数据发送了信号,唤醒了我,但实际上我却永远不知道)(人生何尝不是如此,你在等待我的回应,我在等待你的表达,却不知在错误的时间错误的地点,彼此都已经做过,但自此人生便擦肩而过!)

  • 保护共享条件变量本身pthread_cond_wait 内部会原子地释放锁并阻塞,被唤醒后会重新获取锁。

因此正确用法模式:

cpp 复制代码
pthread_mutex_lock(&mutex);
while (条件不成立) {
    pthread_cond_wait(&cond, &mutex);
}
// 此时条件成立,且持有锁,可以安全操作共享数据
pthread_mutex_unlock(&mutex);

还需要注意虚假唤醒问题:

条件变量可能在没有线程调用 signal/broadcast 时被唤醒,或者在只调用一次 signal 时唤醒多个线程。这是 POSIX 允许的行为(出于实现效率考虑)。因此,必须使用 while 循环而非 if 来重新检查条件

cpp 复制代码
// 错误:if 可能因虚假唤醒而错误地认为条件成立
if (条件不成立) pthread_cond_wait(...);

// 正确:循环检查,条件不成立则继续等待
while (条件不成立) pthread_cond_wait(...);

条件变量小结:

条件变量到底在解决什么问题?

条件变量的核心目的是为了让线程避免忙等待,让线程可以在条件不满足时高效地睡眠,同时释放锁让其它线程使用过,直到最终条件满足时再被唤醒。


📚六、总结

今天讲述了线程互斥及其附属概念,以及介绍了互斥锁是什么,为什么要加锁,怎样理解锁,以及死锁、线程安全和可重入函数相关概念,并详细介绍了线程同步中条件变量。

小结一下:

加锁确保线程互斥的原因:解决多线程数据一致性问题。(即有可能由于CPU调度的原因导致数据出现心理预期之外的结果,如两个线程同时对一个全局变量 count 执行 count++,在底层可能分解为"读-修改-写"三步,若交错执行,最终结果可能比预期少一次递增。

如何使用锁:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER//静态分配便不需要下述的初始化和销毁
//初始化
int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t* restrict attr); //mutex:要初始化的互斥量attr:NULL
//销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t* mutex);
//互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
//示例:
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);

{//需要加锁的某线程调用函数内部
    pthread_mutex_lock(&mutex);
    if () {
        pthread_mutex_unlock(&mutex);
    }
    else {
        pthread_mutex_unlock(&mutex);
    }
}
pthread_mutex_destroy(&mutex);

死锁:多个线程相互等待对方持有的锁,导致所有线程都陷入阻塞无法继续。

条件变量:当条件不满足时,线程可以原子地释放互斥锁并进入休眠,直到被其他线程唤醒。这样既避免了忙等待,又保证了线程安全

**为什么需要条件变量?**线程之间经常需要协调执行顺序或等待某个共享资源的状态发生变化。

条件变量的使用:

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//静态分配便不需要下述的初始化和销毁
//初始化,如果不静态分配就需要下述初始化
pthread_cond_t cond;
int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t* restrict attr);//cond:要初始化的条件变量 attr:NULL
//销毁
int pthread_cond_destroy(pthread_cond_t* cond)
//等待条件满足
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);//cond:要在这个条件变量上等待 mutex:互斥量
//唤醒等待
int pthread_cond_broadcast(pthread_cond_t* cond);
int pthread_cond_signal(pthread_cond_t* cond);
//示例:
pthread_cond_t cond;
pthread_cond_init(&cond, NULL);
{
    while (条件不满足)
    {
        pthread_cond_wait(&cond, &mutex);
    }
}
pthread_cond_signal(&cond);
pthread_cond_destroy(&cond);
相关推荐
Rabitebla2 小时前
归并排序(MergeSort)完全指南 —— 从原理到非递归实现
c语言·数据结构·c++·算法·排序算法
墨^O^2 小时前
进程与线程的核心区别及 Linux 启动全过程解析
linux·c++·笔记·学习
寒秋花开曾相惜2 小时前
(学习笔记)3.9 异质的数据结构(3.9.1 结构)
c语言·网络·数据结构·数据库·笔记·学习
福楠2 小时前
现代C++ | C++14甜点特性
linux·c语言·开发语言·c++
WBluuue2 小时前
Codeforces Educational 188(ABCDEF)
c++·算法
Lugas Luo2 小时前
Kernel 5.10 针对 eMMC 的 Detect、Power、Add 及深度优化解析
linux·嵌入式硬件
charlie1145141912 小时前
嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(4)从零构建 STM32 构建系统
linux·开发语言·c++·stm32·单片机·学习·嵌入式
crossaspeed2 小时前
Nginx配置文件详解
运维·nginx
LuminousCPP2 小时前
C语言自定义类型全解析
c语言·笔记·枚举·结构体·联合体