首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一文读懂 Linux 互斥锁:小白也能看懂的临界区保护指南,手把手教你彻底告别多线程数据“打架”

一文读懂 Linux 互斥锁:小白也能看懂的临界区保护指南,手把手教你彻底告别多线程数据“打架”

作者头像
海棠蚀omo
发布2026-01-12 17:35:55
发布2026-01-12 17:35:55
1840
举报

前言: 在前面的章节中,我们了解了什么是线程,以及如何通过pthread库所提供的函数来对线程进行操作,但是我们要了解的不止有这些,我们创建多线程是为了让它们协作帮助我们去完成任务。 而今天及后面的章节我们就将目光聚焦在线程之间协作的方面,了解线程之间协作时会出现什么样的问题以及如何解决,下面就开始我们今天的内容。

一.线程间互斥相关背景概念

共享资源 临界资源:多线程执⾏流被保护的共享的资源就叫做临界资源 临界区:每个线程内部,访问临界资源的代码,就叫做临界区

我们今天要讲的内容是线程互斥,而在讲解线程互斥之前我们要先了解一些背景知识。

如上面所示,共享资源在前面就有所了解,在进程虚拟地址空间中的内容可以被所有的线程所看到,故而称之为共享资源。

而临界资源我们之前并没有讲过,上面的描述可能不够清晰,下面我用简单的例子来为大家讲解何为临界资源:

想必我们每个人都写过循环,在循环中也曾打印某种内容,相信我们都曾见过如上图所示的这种现象, 明明我们已经在执行的语句后面加上了换行符,为什么在打印时却会出现上图所示的这种现象呢? 我们知道, 显示器其实是一个文件,我们向显示器打印本质就是向显示器文件中写入内容,之前我们就说过,进程的文件描述符表是所有线程共享的,那么表中所对应的显示器文件也同样是共享的 。 那么 如果多个线程同时并发地向显示器文件中写入内容,就可能会发生上图所示的现象,这叫做数据不一致问题 ,而我们把 因多线程并发式的访问所产生数据不一致等问题的共享资源叫做临界资源 。 而我们 把每个线程内部,访问临界资源的代码,叫做:临界区

很明显,上面的这种访问共享资源的方式是会出问题的,所以我们就要解决这种问题,而解决这种问题的方式之一就是我们今天要讲的:线程互斥。

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

上面就是互斥的基本概念,对于互斥,我用一个生活中的例子来带大家去理解:

想必我们大家应该都去银行取过钱,就算没有取过,也应该见过在银行中有这种ATM机的独立小房间。

对于这个房间,每次只能进去一个人去取钱或存钱,而我们在进去后,就会把门给锁住,在我们出来之前,就算有人要取钱,也只能等在外面等我们出来。

这种现象就和互斥的思想极为相似,房间每次只能进去一个人,而互斥也只允许有且只有一个执行流进入临界区去执行代码

而因为互斥还会引出一种现象,这种现象我们叫做: 原子性

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

依旧拿ATM机的例子,原子性体现在要取钱的人身上时,ATM机对他展现的状态只有两种,分别为:正在使用中和欢迎使用,只有这两种状态

除此之外不会有其他的状态,比如:当他走到ATM机前时,发现上一个人的卡没取走,那取钱的人到底是取还是不取呢?

这种特殊的状态在原子性中是不会存在的,只会出现能取还是不能取这两种状态。

那么如何通过互斥来解决上面的问题呢?我们接着看。

二.互斥量mutex

我们要使用互斥的方式来解决问题,那么就要用到互斥量,而上面所讲的向显示器文件写入的例子力度太粗了,不能更好的带领我们去理解需要互斥量的场景,下面我们再看一个例子,通过这个例子我们就能更好地去理解了:

代码语言:javascript
复制
int ticket = 1000;

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

上面我利用上一篇所讲的知识写了一个简单的模拟售票系统的代码,由这四个线程一同去抢票,直到票数为0,抢票结束,代码中的共享资源ticket我们并没有使用互斥去保护它,那么会出现什么问题呢?下面我们来看程序执行的结果:

从结果中我们就能发现此时的售票系统就出问题了,票数怎么会是负数呢?

所以下面我们就要来探讨两个问题:

1.为什么会出现这个问题? 2.如何解决这个问题?

那么下面我们就针对这两个问题来深入探讨一下。

2.1为什么会出现问题?

既然是ticket出问题了,那么就与上面的两步操作有关,一个就是判断条件,另一个就是对ticket进行--操作

2.1.1判断条件

那么首先我们就从判断条件入手,我们对于判断条件的疑惑很明显,那就是:明明ticket已经为0了,为什么还会进入到判断条件中,导致ticket被减到负数?

下面我们就来剖析一下ticket被修改的过程:

我们知道,不管是什么数据,它最终都会保存在内存中,而我们的代码要想对数据进行修改,就要执行3步操作,分别是:

1.取指令or取数据 2.执行指令 3.返回指令执行的结果

一般就是上面的3步操作,取数据就是将数据拷贝到cpu的一些通用寄存器中,如:eax,ebx中,而后面的执行该指令就要通过算逻寄存器来执行,我们上面判断ticket的操作就属于逻辑操作

我们上面也说了,ticket这个全局变量是共享资源,但是当它在执行判断语句时,它被拷贝到了cpu中,而我们在上一篇中也讲了一些线程所" 私有 "的一些内容,里面就有寄存器中保存的上下文数据!!! 该数据当然也属于线程的上下文数据,所以当该数据被拷贝到cpu中时,它就从共享内容变为了当前线程的私有内容,当线程被切换时,这个数据就会被保存起来,在下次切换回来时接着使用虽然寄存器只有一套,但是寄存器内部的数据是多份的,每个线程各自一份!!!

所以不同的线程就可能会拿到相同的票号,但是这并不是直接导致ticket被减到负数的原因,因为即使拿到相同的票号也不影响后面的操作,我们来看:

我问大家一个问题:一个线程通过判断条件进入到相应的代码中,那么有没有可能它刚执行usleep,它的时间片就到了,进而被切换走了呢?

当然是有可能的,一个线程执行到程序的任何一个位置都有可能因时间片到了而被切换。

那么好,既然有了上面的共识,那我就列举一种极端情况来复述上面的过程:

这四个进程同时拿着1号票号通过了判断,进而都进入到了相应的代码中,此时我们姑且将这四个进程认为是串行执行这种极端情况,也就是线程1,2,3在此时依次时间片到了,被切走了,只有线程4来执行后面的内容。 而当线程4执行后面的ticket--时,会再一次执行上面的3步操作,将内存中的ticket拷贝到cpu中,进而完成运算,再将运算后的结果重新写入到内存中。 那么此时内存中的ticket已经变为0了,接着后面线程1,2,3又依次切换回来了,虽然此时内存中的ticket已经变为0了,但是这三个线程并不是从判断语句开始执行的,而是从它们被切走的位置接着执行的,也就是它们跳过了判断ticket的过程,直接执行的就是后面的ticket--的操作。 那结果就不言而喻了,线程1减一下,线程2减一下,线程3再减一下,那ticket可不就被减到负数了嘛

那么经过我们的分析后,ticket被减到负数的原因我们大概也就弄清楚了,那么下面我们再来看看ticket--会出现什么问题。

2.1.2ticket--

我们上面说了,对于ticket--这步操作,编译器会将其分为3步来进行,耳听为虚,眼见为实,下面我们就来看看是否真是如此:

代码语言:javascript
复制
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>

上面就是我将代码进行反汇编后截取的一部分代码,这部分汇编代码中我们就可以看到,确实要执行我们上面所说的那3步操作。

那么在上面我们说了,线程在执行代码的任何时刻都有可能被切换,那么我现在的问题是:那在执行上面的这3步操作时,会被切换吗?或者说ticket--的操作是安全的吗?

当然有可能被切换,那是否安全呢?我们下面来探讨一下:

现在有线程A和线程B两个线程,也执行的是上面售票系统的代码,我们现在假设一种极端情况,就是:此时线程A刚执行完第一步操作,也就是将数据拷贝到寄存器中,就被切换走了,切换为线程B来执行这3步操作。 但是此时系统资源分配极其不平衡,它只让线程B来执行,却不让线程A来执行,一直让线程A在等着,而在线程B的持续执行下,ticket很快就被减到了0,进而线程B就结束了。 线程B结束了,那么终于能轮到线程A来执行了,线程A会从上一次被切换的地方接着执行,那么线程A所保存的ticket值是多少呢? 1000啊,所以会导致什么结果想必大家就知道了,对1000进行--也就是999,再将计算后的结果重新写入到内存中,内存中的ticket就从0又变为了999,相当于线程B直接白干

虽然可能会出现上面的情况,但是我们从程序运行后的结果来看,ticket变为负数的主要原因还是if判断语句的问题

2.2如何解决问题

2.2.1解决方式

很明显,要解决这个问题,本质就是要对代码进行保护,不让线程并发式的去访问共享资源,形成临界区!!!

而要完成上面的操作,就要用到互斥量mutex了,而要使用这个互斥量,我们要进行三步操作:

1.初始化互斥量 2.销毁互斥量 3.互斥量加锁和解锁

对于对互斥量进行初始化,我们有两种方式:

1.静态分配

代码语言:javascript
复制
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

2.动态分配

代码语言:javascript
复制
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

静态变量就是直接将互斥量定义在全局即可,动态分配则是在函数内部通过pthread_mutex_init函数完成初始化

而对于销毁互斥量,我们则需要注意:

1.使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁 2.不要销毁⼀个已经加锁的互斥量 3.已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁

那该如何进行销毁呢?我们来看:

代码语言:javascript
复制
int pthread_mutex_destroy(pthread_mutex_t *mutex);

我们通过pthread_mutex_destory函数即可完成对互斥量的销毁

而最后的互斥量加锁和解锁,则需要这两个函数:

代码语言:javascript
复制
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

两个函数分别就是pthread_mutex_lock和pthread_mutex_unlock,作用分别就是:加锁和解锁

这上面出现的pthread_mutex_t是POSIX线程标准(pthreads) 中定义的互斥量数据类型,并且上面的这几个函数的返回值均是成功则返回0,失败则返回错误码

说了这么多,下面我们就利用互斥量来对上面售票系统的代码进行修改:

代码语言:javascript
复制
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

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--);
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return (void *)0;
}

在上面我是通过静态分配的方式来对互斥量进行初始化,因为我们要对访问临界资源的代码进行保护,所以要在判断语句前加锁,在执行完代码后要进行解锁

因为我们是在判断语句前进行了加锁,所以在每个分支中都要进行解锁的操作

说回这样操作的结果,从上图可以看到,此时就没有再出现ticket被减到负数的情况了,因为这里我们只能看到结果,其实我们自己下去执行一遍就能很明显地发现代码运行的效率要比之前慢,这也是互斥的缺点,有利就有弊

上面展示是静态分配的操作,下面我们再来看看通过动态分配来对互斥量进行初始化的代码:

代码语言:javascript
复制
struct Mutex
{
    string name;
    pthread_mutex_t *mutex;
};

void *route(void *arg)
{
    Mutex* id = (Mutex*)arg;
    while (1)
    {
        pthread_mutex_lock(id->mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id->name.c_str(), ticket--);
            pthread_mutex_unlock(id->mutex);
        }
        else
        {
            pthread_mutex_unlock(id->mutex);
            break;
        }
    }
    return (void *)0;
}
int main(void)
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);
    pthread_t t1, t2, t3, t4;

    Mutex m1 = {"thread 1", &mutex};
    pthread_create(&t1, NULL, route, (void *)&m1);

    Mutex m2 = {"thread 2", &mutex};
    pthread_create(&t2, NULL, route, (void *)&m2);

    Mutex m3 = {"thread 3", &mutex};
    pthread_create(&t3, NULL, route, (void *)&m3);

    Mutex m4 = {"thread 4", &mutex};
    pthread_create(&t4, NULL, route, (void *)&m4);

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

    pthread_mutex_destroy(&mutex);
}

上面就是通过动态分配的方式来对互斥量进行初始化,从结果我们也可以看到和上面一样,不再出现ticket被减到复数的情况。

不过对比这两种初始化的方式和使用方式,明显通过静态分配的方式初始化用起来要比动态分配的方式初始化更加方便

2.2.2所产生的疑问

相信在了解完上面的例子后,我们心中也会产生一些疑惑,这里我用三个问题来总结:

1.mutex自己就是全局变量,那么它是如何保证自己的安全的? 2.所有的线程访问临界资源都要申请锁吗? 3.如果线程进入了临界区,线程会被切换吗?如果会被切换,那么会因为切换而导致并发问题吗?

下面我们就针对这三个问题来进行探讨。

问题一:

答案就是互斥量本身是具有原子性的,虽然在代码的角度来看确实是全局变量,或者共享资源,但是互斥量的底层实现就是原子性的。

问题二:

答案是的,这是所有的线程都要遵守的" 黄金法则 ",我们规定所有访问(读/写)临界资源的代码路径,都必须以原子方式(即在持有锁的情况下)执行,这就意味着在进入临界区前要申请锁,退出后要释放锁!!! 并且申请成功的话,线程会继续向下执行,但是申请失败的话,线程就会被阻塞住

问题三:

答案是可以被切换的,加锁不影响线程被切换,但并不会引发并发问题,这点下面我通过一个例子来帮助大家更好的去理解:

比如,你的学校现在有一个自习室,这个自习室的环境很好,导致很多人都想用这个自习室,但是这个自习室一次只能供一个人去使用,而这个自习室的钥匙就挂在外面的墙上,当有人进去学习时就会将钥匙取下来带在身上。 而只要这间自习室有人正在学习,那么后面来的人就只能等待,等里面的人学习完了,把钥匙从重新挂在墙上,下个人才能进去学习。 那么这个时候就有一个问题:你并没有学习完,但是中途你想上厕所,你会怎么办呢? 答案很明显,即使我出去了,也不会将钥匙挂在墙上,因为我还没学完,只是上个厕所,那么自习室外面的人就只能接着等。

在上面的例子中自习室就相当于临界区,自习室中的人就相当于线程,钥匙就相当于锁,在自习室外面等待的人也是线程

所以通过上面的例子我们就知道了一个线程在进入临界区后即使被切换了,只要它没有执行完临界区的代码,就不会被打扰,也就是不会出现并发问题。

当然,严格地执行这种方式同样也有缺点,那就是会导致代码运行效率的下降。

那么讲到这里,我们就可以来看这张图了,这张图很好地将我们上面所写的代码给划分了出来,能够使大家更好的理解上面我们所讲的知识。

三.互斥实现原理探究

互斥量本身就是原子性的,但是我们关心的是如何通过加锁和解锁操作来实现互斥的,那么下面我们就来探讨一下。

其实对于实现互斥有很多种方案,可以从硬件方面出发, 也可以从软件方面出发。

那么首先我先简单介绍一下硬件实现方案: 一个线程执行的过程不是原子性的原因在硬件方面就是因为cpu在不断的接收时钟中断,而接收了时钟中断就会检测当前线程的时间片到了没有,一旦到了,就会将线程切换走,自然就会导致线程并发的问题。 所以硬件的实现方案就是关闭中断,只要线程在执行代码的过程中不被切换不就做到了互斥吗?

下面我再说软件实现方案: 我们知道,我们的代码在被编译的过程中,一条代码可能会被编译成多条汇编语句,所以我们看似只有几十行的代码,可能在被编译后就会变成几百行的汇编语句。 而这一条汇编语句是具有原子的,就像我们上面说的取指令,执行指令,返回结果这三条汇编语句合在一起是一个完整的动作,这个不是原子的,但是里面单个的汇编语句它是原子的

而只有上面的了解还不够,为了实现互斥锁的操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,下面我们结合lock和unlock的伪代码来看:

上面就是lock和unlock的伪代码,我们从lock来开始看:

首先就是前两步操作,movb操作就是将%al寄存器中的内容置为0,其实也就是清空操作,这里就不解释了,而我们可以认为互斥锁中的数据就是1。 接着就是通过exchange指令交换而这里面的内容,这是完成互斥的核心代码,注意不是拷贝,而是交换,最终结果就是上图所示的:%al中的数据变为1,而内存中的metux变为了0。 后面的if判断语句就是来判断%al寄存器中的内容,如果大于0,那么就相当于锁申请成功了,就可以接着执行后面的代码了,但是如果寄存器中的内容一旦小于等于0,那么就会被挂起等待

下面我通过一个例子来带大家去理解发生互斥的过程:

现在有线程A和线程B两个线程,目前是线程A在执行代码,而当它刚执行完第二行的exchange指令就被切换走了,而此时mutex中的1已经被交换到了%al寄存器中,那么此时这个数据就是线程A的私有数据了,当它被切走时,自然也会将该数据给带走

那么当轮到线程B时,也同样会去执行上面的代码,但是此时mutex中的数据为0,所以即使切换了,二者里面的数据也皆为0,那么当执行到if判断语句时自然就会进入到else中,此时线程B就会被挂起等待了,因为它没有拿到锁资源

再有更多的线程也同样如此,他们会和线程B一样被挂起等待,通过这种方式我们就做到了只有一个执行流去执行代码。

所以交换的本质,就是:把1这个数字,也就是锁资源,以非拷贝的形式,从共享变成私有!!!

而谁先执行,谁就能拿到锁资源,进而去执行代码。

而有了上面lock的介绍,下面的unlock就简单许多了:

线程A在执行完代码后,执行unlock函数首先会通过movb指令将1写入到mutex中,这就相当于把锁资源给回收了接着就会唤醒被挂起等待的线程B,线程B就会接着执行goto lock指令,也就是从头开始执行lock的指令,剩下的过程就和线程A是一样的了,过程很简单。

所以软件的实现方案就是通过一条汇编指令是原子的和通过swap,exchange交换来实现互斥

四.由线程互斥的缺点引出线程同步

拿我们上面自习室的例子来讲解,我们上面说了在这个例子中的每一个人都相当于一个线程,打个比方:在这些线程中,目前在自习室中的那个线程竞争力特别强,即使它被切换走了,但是因为它的竞争力太强,下次也还是它拿到了锁资源,进而接着进入到自习室中接着学习。 映射到人身上,就是里面的人虽然出来了,把钥匙重更新挂到了墙上,但是它突然又感觉还没学够,因为他离钥匙最近,所以他又把钥匙拿走了,又进入到了自习室去学习。 就这样,周而复始,自始至终都只有他一个人能够在自习室学习,其他人没机会拿到钥匙,就只能在外面等着。

那么我的问题是:上面重复进入自习室的那个人有错吗?

理论上讲,他没错,因为规则就是谁拿到钥匙,谁就能进入到自习室学习,也就是谁拿到了锁资源,谁就能进入到临界区执行代码,但是这种行为不合理!!!

正是因为这种不合理的行为,其他线程申请不到锁,导致其无法执行,进而导致饥饿问题,也就是效率低下的问题,所以该如何解决呢?

此次过后,学校就针对这件自习室重新制定了规则,那就是:

1.互斥进入 2.凡是从自习室出来的人,归还钥匙后,不能立即申请 3.外部的人,必须排好队,出来的人,要想接着学习,必须排到队列尾部

在原来互斥的基础上又新添了两条规则,而这两条规则在保证临界资源安全的情况下,能够让不同的进程访问临界资源具有一定的顺序性!!!

而上面的这种操作,就叫做:线程同步!!!

以上就是一文读懂 Linux 互斥锁:小白也能看懂的临界区保护指南,手把手教你彻底告别多线程数据“打架”的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-12-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.线程间互斥相关背景概念
  • 二.互斥量mutex
    • 2.1为什么会出现问题?
      • 2.1.1判断条件
      • 2.1.2ticket--
    • 2.2如何解决问题
      • 2.2.1解决方式
      • 2.2.2所产生的疑问
  • 三.互斥实现原理探究
  • 四.由线程互斥的缺点引出线程同步
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档