【Linux】二十七.线程篇四《Linux多线程编程:线程互斥(互斥量的底层到封装)、线程安全和冲入、死锁》---详解

一· 线程互斥

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

  • 共享资源
  • 临界资源:多线程执⾏流被保护的共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
  • 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

模拟抢票逻辑:

竞态条件不是因为 usleep 漫长 ,而是因为检查与修改分离 ,中间可能发生线程切换。即使没有 usleep,只要多线程并发访问共享变量且不加锁,就会导致数据不一致。解决方案是使用互斥锁原子操作 ,保证临界区的原子性

代码如下:

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

int ticket = 1000;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0) // 1. 判断
        {
            usleep(1000);                               // 模拟抢票花的时间
            printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了票
            ticket--;                                   // 3. 票数--
           
        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);
}

运行结果:

会发现结果出现了负数,如果真的是再买票的场景下不能出现负数的。原因如下:

多线程票数不一致的底层完整拆解

1.首先明确一个结论:单条 ticket-- 在 CPU 层面是"非原子"的

ticket-- 在 C 语言里是一行代码,但在底层汇编里被编译器拆解为严格有序的 3 条机器指令

Load(读)mov ticket, %eax (将内存地址里的数值 100 加载到 CPU 寄存器 %eax 中)。

Sub(减)sub $1, %eax (将寄存器里的数值减 1,变成 99)。

Store(写)mov %eax, ticket (将寄存器里的 99 写回内存地址)。

**注意:**这 3 条指令之间,CPU 随时可能被操作系统强行剥夺执行权(发生线程切换)。


2.发生上下文切换时的精确操作细节(重要)

a.线程 A 的初始动作

  • A 执行了第 1 条指令,把 100 读进寄存器;接着执行了第 2 条,寄存器变成 99

  • 关键截断点 :A 尚未 执行第 3 条(写回内存),此时时间片用完了(或者触发了 usleep 系统调用)。

b.线程 A 被剥离 CPU 时发生了什么(上下文保存)

  • 操作系统内核立即冻结 线程 A 的当前运行状态,把它的 %eax 寄存器值(此时是 99)程序计数器(PC指针) 等上下文数据,完整地存入线程 A 自己的内核栈或线程控制块(TCB)中。
  • 内存里的 ticket 此时依然是旧值 100

c.线程 B 疯狂执行(减了 50 次)

线程 B 获得 CPU,它也从内存读到了 100

因为 B 运气好(时间片较长),它顺利完成了 50 次完整的"读-减-写"循环。

最终结果:内存里的 ticket 被 B 从 100 精准减成了 50,并写回内存。

随后,B 的时间片也到了,B 被挂起。

d.线程 A 恢复执行(问题发生)

  • 操作系统调度到线程 A,内核原封不动地把之前保存的上下文(%eax = 99)恢复到 CPU 寄存器中。

  • 线程 A 的 PC 指针指回第 3 条指令(写回内存)。

  • A 执行 mov %eax, ticket,把 99 覆写到了内存中。


3.最终内存状态与逻辑错误

  • 内存现在的值是 99

  • 但实际上,票已经被卖掉 50 次了(从 100 变 50),线程 A 的 1 次减票操作本该基于 B 的结果(50)去减到 49,而不是把 B 的成果抹掉重新回到 99。

  • 这就产生了数据覆盖丢失(Write-after-Write竞态 ),导致最终票数完全错乱,不仅大于实际剩余票数,甚至在后续多轮叠加下直接变成负数。


4.内核态返回用户态是诱发高频切换的关键

usleep(1000)printf 都会触发系统调用 。一旦触发系统调用,线程会立刻从用户态 陷入内核态内核在完成系统调用处理、准备返回用户态的瞬间,一定会检查调度队列。 因为你的代码里有 usleep,这就意味着 线程 A 在 if 判断成立后、执行 ticket-- 前,必定会经历一次"内核返回用户"的上下文切换这种切换不是为了等,而是因为 usleep 本身属于系统调用,它插在了非原子的 3 条汇编指令中间,人为把竞态窗口拉大,从而让 B 更容易抢到 CPU 并执行 50 次,最终触发上述的覆盖问题
竞态(全称是竞态条件 )理解就是:两个或多个线程在"抢"同一个共享资源,因为抢的顺序乱了,导致最后的结果跟着乱了。


小结一下:

线程 A 将 tickets = 100 从内存读到 CPU 寄存器,紧接着执行 ticket-- 的减操作。但还没等线程 A 把减完的结果写回内存,就被 CPU 剥夺了执行权。在剥离之前,CPU 会将线程 A 当前的寄存器状态(即上下文数据)保存到它的内核栈或线程控制块中

随后 CPU 开始调度线程 B,线程 B 同样从内存读到 tickets = 100,然后运气较好,连续执行了 50 次 ticket--(因为它的时间片较长或抢占较少)。当线程 B 的时间片耗尽时,它将 tickets = 50 写回内存。

接着 CPU 再次调度线程 A,把之前保存的线程 A 的上下文数据恢复到 CPU 寄存器中,线程 A 接着执行刚才未完成的步骤------将寄存器中减过 1 的结果(也就是 99)写回物理内存。最终内存中的 tickets 被错误地改成了 99,原本的 50 次有效减票被覆盖丢失,导致数据严重不一致。


逻辑分析:

a.出现原子性问题(竞态条件)

核心问题在于 ticket-- 这一行 C 语言代码,在底层汇编层面被拆分为**"读内存"、"寄存器减 1"、"写回内存"** 三步,这三步并非原子操作

当线程 A 执行 if (ticket > 0) 判断结果为真后,在它真正执行 ticket--(写回内存)之前,如果发生了线程切换(无论是时间片耗尽,还是因为执行了 usleepprintf 等系统调用而陷入内核),线程 A 的上下文会被保存,CPU 转而执行线程 B。

此时,线程 B 重新从内存读取 ticket 时,读到的依然是线程 A 尚未修改的旧值 ,因此 B 也顺利通过了 if 判断,进入了临界区。

这种**"判断成立"与"实际修改"之间缺乏连贯性**的状态,会导致多个线程先后通过 if 检查,最终都在执行 ticket--。因为多个线程读取到的初始值相同,所以最终内存中的票数会被多减,甚至直接变成负数(如从 100 直接减成 -1、-2)。

解决方法: 必须引入线程互斥锁将 if 判断和 ticket-- 修改包裹成一个不可打断的原子临界区,保证同一时刻只有一个线程能操作该共享变量。


b. 未出现原子性问题


1.2互斥量 mutex

  • 大多数情况下,线程使用的数据都是局部变量 。这类变量的地址空间分配在线程独立的栈空间 内,因此变量归属单个线程,其他线程无法访问,天然具有线程安全性
  • 然而在实际应用中,许多变量需要在线程间共享,以便完成线程间的数据交互与协同工作,这类变量称为共享变量 (或全局变量 )。它们通常存储在全局数据区堆区,可以被所有线程共同读写。
  • 当多个线程并发地 操作同一个共享变量时,由于线程调度的随机性和操作的非原子性,会引发竞态条件 。具体表现为数据不一致、逻辑混乱甚至程序崩溃。核心原因在于共享变量的"读-改-写"操作在底层不是原子的 ,必须通过线程互斥(如互斥锁)原子操作来保证临界区的安全性。

为什么可能⽆法获得争取结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码段
  • --ticket 操作本⾝就不是⼀个原⼦操作
cpp 复制代码
// 地址:40064b
8b 05 e3 04 20 00      mov    0x2004e3(%rip), %eax    # 600b34 <ticket>
// 地址:400651
83 e8 01               sub    $0x1, %eax
// 地址:400654
89 05 da 04 20 00      mov    %eax, 0x2004da(%rip)    # 600b34 <ticket>

-- 操作并不是原⼦操作,⽽是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器⾥⾯的值,执⾏-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

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

  • 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。
  • 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程进⼊该临界区。
  • 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。

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


1.3互斥量的接口

参数:

  • mutex:初始化或释放的锁。
  • attr:锁的属性,设置nullptr,即不管

1.初始化互斥量

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

  • ⽅法1,静态分配:(初始化不用 destroy)
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);

2.销毁互斥量

销毁互斥量需要注意:

  • 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁⼀个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

3.互斥量加锁和解锁
a. 互斥量加锁
cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);

返回值:成功返回0,失败返回错误号


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

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

b. 互斥量解锁
cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);

线程确实是绝对可以被随时切换的,但关键在于,线程是"拿着锁"走的。只要它手里攥着锁不撒手,哪怕 CPU 中途把它切走干别的事去了,这把锁也不会自己跑掉。别的线程过来申请这把锁,也只能被堵在门外,根本进不去抢票那一步。直到拿锁的线程把票卖完,自己主动把锁释放了,后面的线程才有机会拿到锁进去抢票。

所以,对于其他线程来说,它们面对临界区的时候,要么因为没抢到锁而压根没进去(不执行),要么等拿到锁再进去并执行完(释放锁)。正是因为这种"互斥排队的机制",才从逻辑上把一串本该被打断的操作变成了一气呵成的整体,这就算间接完成了"原子性"。

不过细想一下,这把全局的锁,所有线程都要先能看到它才能去抢。那这把锁本身也是一种被大家共享的临界资源啊! 要是两个线程同时来抢这把锁的控制权,难道又要给这把锁再加一层锁吗?那岂不是没完没了、无限套娃了?

其实不用。真正的解法是:不需要用另一把锁来保护这把锁。我们只要保证,在硬件底层层面上,lock(抢锁)和 unlock(放锁)这两个动作本身是绝对原子的就行。也就是说,底层的 CPU 会保证,同一时刻只会有一个线程能成功修改这把锁的状态。只要锁本身是安全的,它就能去保护后面抢票那段代码的安全,这就够了。

代码如下:

但这里运行速度变慢了,同时因为互斥,没有出现原子性问题,但是我开了4个线程,为什么只有1个线程在干活?

原因如下:

原因1: 我把,usleep(1000) 放在了锁里面

cpp 复制代码
pthread_mutex_lock(&lock);
if (ticket > 0) {
    usleep(1000);  // 模拟抢票耗时
    printf(...);
    ticket--;
    pthread_mutex_unlock(&lock);
}

thread 3 抢到锁并进入 if 后,它执行了 usleep(1000)此时它并没有解锁,而是死死攥着锁在睡大觉。 这 1 毫秒(也是1000us)对现代 CPU 来说是非常漫长的时间。在 thread 3 睡觉的这段时间里,其他 3 个线程全都卡死在了 pthread_mutex_lock(&lock) 这一行,眼巴巴地等待锁被释放,根本没法进入抢票流程。等你睡醒了,它打印、减票、解锁,紧接着下一轮循环,它立刻又抢到了锁。

结论usleep 在锁内导致其他线程根本抢不到锁,最终 thread 3 一个人包揽了全部 100 张票。
上面的现象通常被称为调度运气问题,在并发编程中有一个专门的学术名称------线程饥饿

虽然例子中,usleep 被锁在临界区内是加剧"饥饿"的原因,但即便没有 usleep,这种现象依然可能出现。例如:当 CPU 的调度器因为线程的优先级、亲和性或是历史权重等原因,持续将时间片分配给同一个线程时,其他线程就会长期得不到 CPU 资源,从而发生饥饿。


错误代码

cpp 复制代码
while(1)
{
    pthread_mutex_lock(&mtx);
    if(tickets > 0)
    {
        usleep(1000);
        printf(...);
        tickets--;
    }
    else
    {
        break;  // 直接 break,没解锁
    }
    pthread_mutex_unlock(&mtx); // 解锁代码在 else 外面,永远跑不到
}

elsebreak 直接跳出了循环,pthread_mutex_unlock 在循环底部,根本没机会执行。

出现(死锁)退出的线程手里还死死攥着锁,导致其他所有正在 lock 处等待的线程永久阻塞,程序卡死结束不了。所以,else 里必须先 pthread_mutex_unlock(&mtx);,然后再 break;


4.改进上⾯的售票系统完整版

代码如下:

cpp 复制代码
#include <stdio.h>
#include <stdlib.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);  // 1.加锁,互斥进入

        if ( ticket > 0 )            // 2.在锁保护下判断
        {
            // 把 usleep 移出锁外  原因:模拟抢票耗时(如网络IO、打印)应放在锁外,否则会导致其它线程长时间排队,并发变串行,拖慢整体速度。
            pthread_mutex_unlock(&mutex); // 先解锁,让别的线程有机会抢
            usleep(1000);                 // 在锁外模拟耗时
            pthread_mutex_lock(&mutex);   // 再次加锁,准备修改数据

            // ticket 可能在刚才的等待中被其他线程减光了,
            // 所以必须要再判断一次! 
            if (ticket > 0) {
                printf("%s sells ticket:%d\n", id, ticket);
                ticket--;                // 3.在锁内安全减票
            }
            pthread_mutex_unlock(&mutex); // 4.解锁
        }
        else 
        {
            pthread_mutex_unlock(&mutex); // 5.票卖完,必须解锁再退出
            break;
        }
    }
    return NULL;
}

int main( void )
{
    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;
}

问题补充

1:加锁就代表多线程完全变成串行执行了吗?

是的,但仅限于临界区。 被互斥锁包裹的临界区代码,同一时刻绝对只能有一个线程在执行。从宏观上看,多线程并发抢票变成了严格的"排队串行"。这虽然牺牲了并发度,但换来的是数据绝对安全。

2:线程在临界区中被 CPU 切换走,会不会出问题?

绝对不会。 线程被切走时,是**"带着锁"**离开的。只要它手里还攥着锁不释放,其他线程在申请锁时就会被阻塞住,根本进不来。所以,哪怕 CPU 切来切去,临界区内的数据也绝对不会被其他人破坏。

3:如果线程不申请锁,直接去读/写共享变量呢?

这是不被允许的错误编码方式,也是引起脏读/脏写的根源。 多线程编程的底层规律是:所有访问共享资源的线程,必须自觉遵守"先抢锁,再操作,后解锁"的规则。不遵守规则的线程进入临界区,相当于不排队直接插队,会导致锁的保护跟没有一样。

进阶核心问题:

4:锁本身也是所有线程都能看到的"共享内存",那谁来保证锁在抢的过程中不被"抢坏"?

对于没有持有锁的线程,最有意义的情况只有两种:

1.当前没有线程持有锁,它可以去申请;

2.持有锁的线程释放了锁,它可以去申请。

既然所有线程都得"看到"同一把锁去抢,那锁本身必然也是一种共享资源。为了不陷入"给锁加锁"的无限套娃,底层用:pthread_mutex_lockpthread_mutex_unlock 这两个操作本身必须被设计为"绝对原子的" (直接依赖 CPU 硬件提供的 xchgcmpxchg 等单条指令)。因为只有这样,才能保证同一时刻只会有一个线程能成功更改锁的状态,以此保护上层的数据安全。


1.4互斥量实现原理探究

  • 经过上⾯的例⼦,⼤家已经意识到单纯的 i++ 或者 ++i 都不是原⼦的,有可能会有数据⼀致性问题
  • 为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,即使是多处理器平台,访问内存的 总线周期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改⼀下
cpp 复制代码
lock:
	movb $0, %al
	xchgb %al, mutex
	if(a1寄存器的内容 > 0){
		return 0;
	}
	else{
		挂起等待;	
	}	
	goto lock;
unlock:
	movb $1, mutex
	唤醒等待mutex的线程;
	return 0;

这个是我的一个原理图,来说明问题

  1. 蓝色箭头 :代表 xchgb(或类似的原子交换)的过程。它把 %al(CPU寄存器)和 mutex(内存)中的值进行了互换

  2. 红色打叉 :图上画出了"把 1 置 0"的动作(可能是 mov),并用红叉划掉了。单纯的写值(如 mov)是不管用的,必须用原子交换。

  3. 状态展示 :寄存器拿到了 mutex 原来的值(如果是 1 就代表抢锁成功),而内存被写入了 0(代表锁已被占用)。

mutex 本质上就是一个内存中的共享变量,初始值设为 1。 为了理解它,我们看底层汇编的实现。线程在执行时,寄存器和堆栈数据保存在线程的 TCB(线程控制块)中,属于线程私有。而 mutex 变量在内存中,所有线程都能读取。


1. 加锁过程(对应图中发生的交换):

线程 X 执行 movb $0, %al(将寄存器清零),紧接着执行 xchgb %al, mutex

如图所示,这条 xchgb 是一条 硬件级别的原子指令。它会瞬间将 CPU 寄存器 %al 中的 0 与内存 mutex 中的 1 进行互换。互换后(对应图中虚线箭头结果):

  • 内存中的 mutex 变成了 0(表示锁被占用了)。

  • 线程 X 的寄存器 %al 变成了 1

接着线程 X 判断 %al 的值,发现大于 0,说明它成功抢到了锁,于是进入临界区。哪怕它在执行 if 前被 CPU 切走,操作系统也会保存它 %al:1 的上下文,这把锁依然在它手里。


2. 为什么 xchgb 是原子的?

因为 xchgb 在底层被翻译成 CPU 机器码时就是单条指令 。CPU 在执行这条指令时,会在硬件层面锁住总线(如图中交换过程是一条整体),保证其它 CPU 核心或其它线程在这个周期内无法访问 mutex 内存地址。这就确保了"交换"这一下绝对不被干扰。


3. 如果线程没抢到锁(%al 为 0):

比如线程 Y 被切进来,它也执行 xchgb,但此时内存 mutex 已经被 X 改成了 0。Y 交换后,内存 mutex 还是 0,Y 的 %al 也是 0。它判断 %al 不大于 0,于是进入挂起等待,直到被唤醒后跳回 lock 标签重新尝试。


4. 解锁过程:

线程 X 临界区执行完毕,调用 unlock:执行 movb $1, mutex 将互斥锁重新置为 1,并唤醒所有等待该锁的线程。由于只有拥有锁的线程才能执行 unlock,所以 unlock 不需要像 lock 那样用 xchgb,普通的 mov 写回即可。


5. 为什么不能只用 movb $0, mutex 来代替 xchgb

因为 mov 指令在 CPU 硬件层面不具备锁总线的原子属性。如果两个线程同时执行 movmutex 写成 0,就会产生竞争,导致两个线程都以为自己抢到了锁。必须借助 xchgb(或 cmpxchg 等硬件原子指令)硬锁总线,才能确保一把锁只被一个线程拿走。


1.5互斥量的封装

编译规则文件:Makefile
cpp 复制代码
testMutex:testMutex.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f testMutex

2. 互斥锁封装头文件:Mutex.hpp
cpp 复制代码
#pragma once
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex):_mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex &_mutex;
    };
}

3. 主程序测试文件:testMutex.cpp
cpp 复制代码
#include <iostream>
#include <mutex>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

int ticket = 100;

class ThreadData
{
public:
    ThreadData(const std::string &n, Mutex &lock)
        : name(n),
          lockp(&lock)
    {
    }
    ~ThreadData() {}
    std::string name;
    Mutex *lockp;
};

void *route(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        // RAII风格的自动加解锁:构造即加锁,析构即解锁
        LockGuard guard(*td->lockp); 
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
            ticket--;
        }
        else
        {
            break;
        }

        usleep(123);
    }

    return nullptr;
}

int main(void)
{
    Mutex lock;
    pthread_t t1, t2, t3, t4;
    
    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

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

    return 0;
}

在 C 语言里,加锁和解锁是分开的两个步骤,你必须写两行代码:一行 lock,一行 unlock。但实际写业务代码时,逻辑里会有很多 ifelsereturnbreak等。一旦逻辑复杂了,非常容易漏写 unlock,导致线程卡死(死锁)。为了解决这个问题,我借用了 C++ 里一个非常有名的思想------RAII(资源获取即初始化) 。它的核心思路是:把锁当成一个普通的对象来管理。

具体的做法是设计两个类:

1.Mutex :把底层的 pthread_mutex_t 包起来,让锁的创建和销毁自动完成,不需要手动调用 initdestroy

2.LockGuard :这个才是灵魂。它在构造(生成) 的时候自动调用加锁,在**析构(销毁)**的时候自动调用解锁。

只要使用了这个 LockGuard,我们就不需要手动去写 unlock 了。只要代码走出了当前的花括号 {},这个临时对象就自动销毁,锁就自动被释放了。


二.线程安全和重⼊问题

2.1 概念

线程安全:

就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结
果。
⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。

重⼊:

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

学到现在,其实我们已经能理解重⼊其实可以分为两种情况

  • 多线程重⼊函数
  • 信号导致⼀个执⾏流重复进⼊函数

2.2 常见的线程不安全的情况

  • 不保护共享变量的函数。
  • 函数状态随着被调用,状态发生变化的函数。
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数。

2.3 常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

2.4 常见不可重入的情况

  • 调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的。
  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
  • 可重入函数体内使用了静态的数据结构。

2.5 常见可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用用 malloc 或者 new 开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都有函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

2.6 结论

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

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

三.常见锁概念

死锁

3.1概念

死锁 是指在一组进程或线程中,每个执行流都持有了对方所需的资源,同时又在等待对方释放自己所需的资源,导致所有执行流均陷入永久阻塞无法自行解开的一种状态。

死锁发生的核心特征:

  1. 线程之间相互持有对方需要的锁。

  2. 每个线程都不愿意放弃自己当前持有的锁(互不相让)

举个例子: 假设有两个人(线程 A 和线程 B)要开车出门。线程 A 手里握着车钥匙 ,正等着线程 B 给他递方向盘 才能开车;线程 B 手里握着方向盘 ,正等着线程 A 给他递车钥匙 才能开车。结果就是 因为 A 不肯先给钥匙(怕没方向盘),B 也不肯先给方向盘(怕没钥匙),两人手里攥着对方要的东西,谁也不肯先松手,最后谁的车也开不走,陷入了无限僵持


3.2死锁的四个必要条件

只要产生了死锁,这四个条件必须同时满足,缺一不可

a. 互斥条件

定义:一个资源每次只能被一个执行流使用。即资源是独占的,不能共享。

(引入互斥锁的本质是为了保护临界资源,将并发访问变成串行,保证原子性。因为有了互斥,线程在申请资源时,如果资源已被占用,就必定会产生阻塞等待。没有互斥,也就没有死锁。)

注意互斥本身不一定会导致死锁,但死锁绝对建立在互斥的基础之上。


b.请求与保持条件

定义 :一个执行流在因请求新资源而阻塞时,对自己已经获得的资源保持不放(占着茅坑不拉屎)。(我手里已经抓着一个资源了,现在想申请你手里的新资源。但在我苦苦等你释放新资源的同时,我绝不放开我已经抓在手里的资源。)


c.不剥夺条件

定义 :一个执行流已获得的资源,在未使用完之前,不能被其他执行流强行剥夺。资源只能由持有者自己主动释放。

**(**我们互相使用"锁"作为保护机制时,锁是"私人专属"的。就算别的线程再急,只要我不解锁,别人抢不走我手里的锁。)


d. 循环等待条件

定义 :若干执行流之间形成一种头尾相接的循环等待 关系。(不是别人借我的,我借别人的就一定会死锁;而是**"我要你的,你也刚好要我的)**

例如:

线程 A 占着锁 1,想申请锁 2,线程 B 占着锁 2,想申请锁 1,A 等 B 释放,B 等 A 释放,这就会形成一条闭环,导致双方无限等待。

只要发生死锁,这四个条件一定同时成立;但反过来,即便四个条件同时成立,系统不一定立刻发生死锁。


3.3避免死锁

只要打破这四个条件里的任何一个就行。不过这四个条件里,有的好打破,有的不好打破:

第一个条件(互斥): 这东西不好破。因为加锁就是为了不让别人同时用资源,你要是破了互斥,那锁就等于白加了。所以一般不去碰它。

第二个条件(请求并保持): 这个好破。要求每个线程在干活之前,一次性把需要的资源全部拿到手,少一个都不开始干。要么全拿到,要么一个都不要,这样就不会出现"手里捏着你的,又等着要他的"这种情况。

第三个条件(不剥夺): 这个也好破。可以设计一个规则,允许资源被抢走。比如高优先级的线程可以强行拿走低优先级手里的资源,不用非得等低优先级自己慢慢用完。

第四个条件(循环等待): 这是工程上最常用、也最好破 的。只要给所有的锁规定好一个固定的申请顺序,大家都按一个顺序来,比如规定"先拿锁A,再拿锁B",谁都不能反过来。只要顺序一致,就不会形成你等我、我等你的死循环了。


3.4避免死锁算法

A. 死锁检测算法(了解)

死锁检测算法并不预防死锁,而是在死锁发生后发现它。系统会定时检查资源分配图,如果发现图中形成了一个闭环,就说明发生了死锁。一旦检测到死锁,系统会采取强制措施来解除死锁,比如强行剥夺某个进程的资源,或者直接终止某个进程。这个算法的特点是允许死锁发生,但会在发生后及时处理。

B. 银行家算法(了解)

银行家算法是一种提前预防死锁的算法。系统在给进程分配资源之前,会先模拟一下分配后的情况,判断系统是否处于安全状态。如果把资源分配出去后,还能保证至少有一个进程能顺利执行完毕并释放资源,那么就分配;如果分配后所有进程都可能卡住,就拒绝本次分配。这个算法的核心思想是宁可不分配,也不让系统进入死锁状态。

相关推荐
啦啦啦啦啦zzzz1 小时前
贪心算法和动态规划
c++·算法·贪心算法·动态规划
刘名喜1 小时前
第09篇-协程基础-Kotlin异步编程
开发语言·kotlin·springboot
其实防守也摸鱼1 小时前
HackBar 工具完全指南:信息探测、漏洞验证与安全测试实战
开发语言·人工智能·学习·安全·网络安全·安全威胁分析·安全性测试
我不是懒洋洋1 小时前
从零实现一个分布式数据集成:Airbyte的核心设计
c++
会博通·代码搬运工1 小时前
会博通API对接实战:工程企业文档分布式采集系统的技术实现与Python SDK详解
开发语言·分布式·python·线性代数·矩阵·架构·电子档案合规
用户0510122572961 小时前
DAY 5-智能指针与 C++ 内存管理
c++·面试
yunwei371 小时前
eBPF 入门实践教程第五十篇:使用 TCX Link 实现可组合的流量控制
linux·云原生·开源
(Charon)1 小时前
【C++】手写 MySQL 连接池(三):异步任务队列与工作线程
c语言·c++
2401_827501282 小时前
Linux系统移植
linux·arm开发·单片机