深入 Linux 线程机制(三):线程互斥——竞争条件与互斥锁的本质
在上一篇《线程控制》中,我们不仅学习了线程控制相关的接口,还从底层理解了线程是如何在 POSIX 线程库(pthread) 与 Linux 内核 的共同配合下完成创建、管理以及回收的。
然而,仅仅能够创建多个线程还远远不够。当多个线程开始并发执行,并访问同一份共享资源时,就可能产生各种各样的问题。例如,多个线程同时向同一个显示器文件(标准输出)写入数据时,输出内容可能会交叉混杂;多个线程同时修改同一个全局变量时,由于执行顺序不可预知,最终结果也可能与我们的预期不符。
这些问题的本质,都是多个线程并发访问共享资源所导致的竞争问题。如果不对共享资源进行合理的保护,程序的运行结果将具有不确定性,甚至可能引发严重的逻辑错误。
因此,从本章开始,我们将围绕线程互斥展开学习,理解为什么会发生线程竞争、互斥机制是如何保证共享资源安全访问的,以及 Linux 与 pthread 又是如何实现线程互斥的。
三、线程互斥
3.1为什么需要线程互斥
代码示例:模拟抢票过程
#include <iostream> #include <pthread.h> #include <unistd.h> int ticket = 1000; // 总票数 void *routinue(void* arg) { std::string name = static_cast<const char*>(arg); while(true) { if(ticket > 0) { usleep(1000); // 增大线程切换的概率 std::cout << name << "抢到了" << ticket << "号票" << std::endl; ticket--; } else { break; } } return nullptr; } int main() { pthread_t tid1, tid2, tid3, tid4; pthread_create(&tid1, nullptr, routinue, (char*)"thread-1"); pthread_create(&tid2, nullptr, routinue, (char*)"thread-2"); pthread_create(&tid3, nullptr, routinue, (char*)"thread-3"); pthread_create(&tid4, nullptr, routinue, (char*)"thread-4"); pthread_join(tid1, nullptr); pthread_join(tid2, nullptr); pthread_join(tid3, nullptr); pthread_join(tid4, nullptr); return 0; }thread-2抢到了thread-1抢到了139号票139 thread-3抢到了139号票号票 thread-4抢到了136号票 ... thread-2抢到了3号票 thread-3抢到了2号票 thread-1抢到了1号票 thread-4抢到了0号票 thread-2抢到了-1号票 thread-3抢到了-2号票按照正常逻辑,一张票只能被一个线程抢到,当所有票售完后,程序也应该停止运行。然后在运行程序时,我们却经常看到如下现象:
- 同一张票被多个线程重复抢到
- 剩余票数被抢成负数
这些结果显然与我们预期不同。
那么,为什么会出现这些问题呢?
原因在于,多个线程同时访问并修改同一份共享资源时,各个线程的执行顺序是不可预知的。当多个线程对共享资源进行读写操作时,如果缺少对共享资源的保护,那么它们的执行过程就可能相互交叉运行,从而导致程序产生错误的运行结果。
这种由于多个线程并发访问共享资源而引发的问题,称为竞争条件。为了避免竞争条件带来的各种错误,我们需要一种机制来保证同一时刻只有一个线程能够访问共享资源,这种机制就是线程互斥。
3.2 共享资源
共享资源:在多线程程序中,能够被多个线程访问的资源被称为共享资源。例如:虚拟地址空间空间中的全局变量、静态变量、堆区数据、函数、以及文件、网络等操作系统资源。
需要注意的是,并不是所有共享资源都会产生线程安全问题。如果多个线程只是读取共享资源,而没有修改它,那么通常不会发生数据错误;真正需要重点保护的是那些可能被多个线程同时修改的共享资源。
3.3 临界资源
临界资源:在多线程程序中,并不是所有共享资源都会产生线程安全问题。对于那些多个线程不能同时访问,否则可能导致数据不一致或程序运行结果错误的共享资源,称为临界资源。
共享资源是否属于临界资源,并不取决于资源本身,而是取决于线程的访问方式。例如,同一个全局变量在多个线程仅读取时不是临界资源,而当多个线程同时修改它时,就成为临界资源。
3.4 临界区
临界区:在多线程程序中,多线程访问临界资源的代码块被称为临界区。
示例:
int ticket = 1000; void *routinue(void* arg) { std::string name = static_cast<const char*>(arg); while(true) { if(ticket > 0) { usleep(1000); std::cout << name << "抢到了" << ticket << "号票" << std::endl; ticket--; } else { break; } } return nullptr; }这里真正属于临界区的是:
if(ticket > 0) { usleep(1000); std::cout << name << "抢到了" << ticket << "号票" << std::endl; ticket--; } else { break; }因为这一段代码访问了 ticket(临界资源)。
注:临界资源是数据(或资源),临界区是访问这些数据(或资源)的代码。二者一个是"对象",一个是"代码区域",不要混淆。
3.5 竞争条件
例如:简化版模拟抢票过程 ---- 两个线程 Thread1 和 Thread2 :票数剩余量为 1
if (tickets > 0) { usleep(100); tickets--; }运行得到:
Thread1 抢到 1 Thread2 抢到 1 剩余 -1为什么两个线程能抢到了同一张票?
Thread1 读取 tickets = 1 ------------------ Thread1 被切换 ------------------ Thread2 读取 tickets = 1 tickets-- 0 ------------------ Thread2 被切换 ------------------ Thread1 tickets-- -1竞争条件:多个线程同时执行临界区代码,并且程序运行结果依赖于线程的执行顺序时,就发生了竞争条件。
思考
如果 Thread1 判断票数为1,进入了抢票流程,还没开始抢票就被切换走,Thread2 判断票数也为1,也进入抢票流程,抢完票之后,票数为 0,Thread2 被切换走,Thread1 切换回来,这就会导致 Thread1 抢到了0号票,然后对票数减一,票数为负的问题。如果是多个类似 Thread1的线程呢?那么就会导致其他线程抢到了票数为负数的问题。
3.6 为什么会发生竞争条件
在上述抢票过程中,最终出现同一张票被多个线程抢到、剩余票数为负数等问题,其根本原因有两种:判断票数 和 票数--
我们先从理解 票数-- 的产生的问题,理解原子性的概念,再去理解判断票数产生的问题。
讲 C/C++ 代码翻译成汇编语言后,ticket--; 一条语句是被翻译成三条汇编语句的。
这三条汇编分别对应 CPU 会对其执行三个步骤
① 从内存读取 tickets ↓ ② CPU 完成减一运算 ↓ ③ 将结果写回内存例如:
ticket = 100 线程A 读取100 ----------------- 线程B 读取100 ----------------- 线程A 减1 99 写回 ----------------- 线程B 减1 99 写回 最后得到:ticket = 99 实际上应该:ticket = 98两个线程都完成了一次 ticket--,但最终票数却只减少了一次。
原子性:一个操作在执行过程中不会被打断,要么全部执行完成,要么完全不执行。
很显然,ticket-- 在执行过程中可以被其他线程切换,因此它不具有原子性。
补充:一条汇编语句的执行过程是不会被切换的,但一条语言语句翻译成汇编可能对应多条汇编语句,它的执行过程可以被切换。
为什么判断票数仍然会出问题?
例如: if (tickets > 0) { tickets--; } 假设:ticket = 1 然后: 线程A if 看到1 ---------A切换------- 线程B if 看到1 ---------B切换------- 线程A ticket-- 0 ---------A切换------- 线程B ticket-- -1 于是: 线程 A 和 线程 B 都认为还有票,最后票数为负数即使 ticket-- 能够保证原子性, if 与 ticket-- 仍然是两个独立的操作,中间仍然可能发生线程切换,因此整个抢票过程依旧不是原子的。
因此,我们真正需要保证的,并不是某一条语句具有原子性,而是整个临界区在同一时刻只能由一个线程执行。这正是线程互斥要解决的问题。
3.7 线程互斥
通过上一节的分析可以发现,竞争条件产生的根本原因在于:多个线程能够同时进入临界区,并发访问临界资源。由于线程的调度顺序不可预知,临界区中的操作可能发生交叉执行,从而导致程序运行结果出现错误。
那么,如何避免竞争条件呢?
最直接的办法就是:同一时刻,只允许一个线程进入临界区访问临界资源,其余线程必须等待当前线程离开临界区后才能进入。
这种机制称为线程互斥(Thread Mutual Exclusion)。
线程互斥:在多线程程序中,通过某种机制,保证同一时刻只有一个线程能够进入临界区访问临界资源,其余线程必须等待,从而避免多个线程并发访问临界资源而产生竞争条件。
例如,假设线程 A 和线程 B 都需要执行同一段临界区代码:
线程A │ ├── 进入临界区 ├── 访问临界资源 └── 离开临界区 线程B │ └── 等待…… 线程A 离开临界区 ↓ 线程B │ ├── 进入临界区 ├── 访问临界资源 └── 离开临界区可以看到,线程互斥并不会限制多个线程的存在,也不会阻止线程并发执行。它只是保证访问临界资源时具有互斥性,即任意时刻只能有一个线程执行临界区代码,而临界区之外的代码仍然可以并发执行。
3.8 互斥锁的基本使用
互斥锁的基本思想:同一时刻只允许一个线程进入临界区访问临界资源。然后线程互斥只是一个概念,要真正将这一思想应用到程序中,还需要借助具体的同步机制。
在 POSIX 线程库中,实现线程互斥最常用的同步工具就是互斥锁。
互斥锁可以理解为一把"锁"。当线程进入临界区之前,需要先获取这把锁;如果锁已经被其他线程获取,那么当前线程将无法进入临界区,只能等待。当持有锁的线程执行完临界区代码后,再释放锁,此时其他等待的线程才有机会获取锁并进入临界区。
3.8.1 pthread_mutex_t
在 pthread 库中,互斥锁的数据类型为:
pthread_mutex_t它表示一个互斥锁对象。
多个线程需要共同使用同一把互斥锁来保护共享资源,因此,互斥锁本身通常也是一个共享对象,一般定义为全局变量,或者放在多个线程都能够访问的位置。
例如:
pthread_mutex_t mutex;这里定义了一把名为 mutex 的互斥锁。
以后所有线程访问临界资源之前,都需要先获取这把锁。
3.8.2 初始化互斥锁
互斥锁在使用之前必须先进行初始化。
pthread 提供了两种初始化方式。
方法一:静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;这种方式通常用于全局变量或者静态变量。
程序启动时,互斥锁便已经完成初始化,使用起来最为简单。
方法二:动态初始化
pthread_mutex_init(&mutex, nullptr);函数原型如下:
int pthread_mutex_init( pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);参数说明:
- mutex:需要初始化的互斥锁。
- attr:互斥锁属性,一般传
nullptr,表示使用默认属性。
初始化成功返回0,失败返回错误码。
3.8.3 加锁
线程进入临界区之前,需要先获得互斥锁。
对应接口:
int pthread_mutex_lock(pthread_mutex_t* mutex);例如:
pthread_mutex_lock(&mutex); /* 临界区 */ pthread_mutex_unlock(&mutex);如果当前互斥锁没有被其他线程持有,那么调用 pthread_mutex_lock() 后,线程立即获得锁,并继续向下执行。
如果锁已经被其他线程持有,那么当前线程将无法继续执行,而是进入等待状态,直到锁被释放。
因此:
获得锁,是进入临界区的前提。
3.8.4 解锁
线程执行完临界区代码之后,需要主动释放锁。
对应接口:
int pthread_mutex_unlock(pthread_mutex_t* mutex);例如:
pthread_mutex_lock(&mutex); tickets--; pthread_mutex_unlock(&mutex);释放锁之后,等待该锁的其他线程便有机会继续竞争这把锁。
需要注意的是:
加锁和解锁必须成对出现。
否则,很容易造成其他线程一直等待,从而导致程序无法继续运行。
3.8.5 销毁互斥锁
互斥锁使用结束之后,应及时释放相关资源。
对应接口:
int pthread_mutex_destroy(pthread_mutex_t* mutex);例如:
pthread_mutex_destroy(&mutex);需要注意:
- 必须保证没有线程正在使用该互斥锁;
- 已经销毁的互斥锁不能继续使用,否则行为未定义。
对于使用PTHREAD_MUTEX_INITIALIZER静态初始化的全局互斥锁,程序结束时通常会由操作系统统一回收资源;而对于动态初始化的互斥锁,仍建议在不再使用时主动调用 pthread_mutex_destory()。
3.8.6 互斥锁的使用 ---- 抢票示例
#include <iostream> #include <pthread.h> #include <unistd.h> int ticket = 1000; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void *routinue(void *arg) { std::string name = static_cast<const char *>(arg); while (true) { pthread_mutex_lock(&mutex); if (ticket > 0) { std::cout << name << "抢到了" << ticket << "号票" << std::endl; ticket--; pthread_mutex_unlock(&mutex); } else { pthread_mutex_unlock(&mutex); break; } } return nullptr; } int main() { pthread_t tid1, tid2, tid3, tid4; pthread_create(&tid1, nullptr, routinue, (char *)"thread-1"); pthread_create(&tid2, nullptr, routinue, (char *)"thread-2"); pthread_create(&tid3, nullptr, routinue, (char *)"thread-3"); pthread_create(&tid4, nullptr, routinue, (char *)"thread-4"); pthread_join(tid1, nullptr); pthread_join(tid2, nullptr); pthread_join(tid3, nullptr); pthread_join(tid4, nullptr); return 0; }thread-4抢到了9号票 thread-4抢到了8号票 thread-4抢到了7号票 thread-4抢到了6号票 thread-4抢到了5号票 thread-4抢到了4号票 thread-4抢到了3号票 thread-4抢到了2号票 thread-4抢到了1号票从运行结果可以看出,互斥锁成功解决了前面出现的同一张票被多个线程抢到、票数被抢成负数等问题。然而,新的现象又出现了:在很多次运行中,大部分票几乎都是由同一个线程抢到的。
为什么会出现这种现象呢?
当某个线程成功调用 pthread_mutex_lock() 获取互斥锁后,进入临界区访问临界资源。在此期间,其他线程如果也尝试获取这把锁,由于锁已经被占用,它们将无法继续进入临界区,而是在 pthread_mutex_lock() 处阻塞等待。
当持有锁的线程执行完临界区代码并调用 pthread_mutex_unlock() 释放锁后,等待的线程会被唤醒,重新参与锁的竞争。但是,刚刚释放锁的线程通常仍然处于运行状态,它可以立即再次调用 pthread_mutex_lock() 申请这把锁;而其他线程虽然已经被唤醒,却还需要经历被调度、恢复运行等过程,因此在竞争锁时往往处于劣势。
正因为如此,我们经常会观察到同一个线程连续多次获得互斥锁,从而连续抢到大量票,而其他线程获得锁的机会相对较少。在极端情况下,这种现象甚至可能导致某些线程长期无法获得锁,即所谓的线程饥饿。
需要说明的是,POSIX 互斥锁的设计目标是保证互斥访问,而不是保证线程之间的公平性。如何让多个线程更加公平地访问临界资源,是线程同步需要进一步解决的问题,我们将在后续章节中进行深入分析。
3.9 互斥锁的底层原理
经过前面的学习,我们已经知道,互斥锁能够保证同一时刻只有一个线程进入临界区,从而避免多个线程并发访问临界资源而产生竞争条件。
那么,新的问题来了:
一把普通的互斥锁,为什么能够做到同一时刻只允许一个线程进入临界区?
要回答这个问题,我们需要从互斥锁本身开始分析。
3.9.1 互斥锁本身也是一种共享资源
在抢票程序中,所有线程都会执行下面这段代码:
pthread_mutex_lock(&mutex); // 临界区 pthread_mutex_unlock(&mutex);可以发现,无论哪个线程调用 pthread_mutex_lock(),传入的都是同一个 mutex 对象。也就是说,所有线程共同访问同一把互斥锁。所以互斥锁也是一种共享资源。
那么问题来了:
既然互斥锁本身也是共享资源,多个线程同时访问它,为什么不会发生竞争条件呢?
答案是:互斥锁内部对自身状态的修改必须保证原子性。
3.9.2 互斥锁内部维护了什么
虽然不同平台对 pthread_mutex_t 的实现细节不同,但本质上一把互斥锁至少需要维护以下几类信息:
pthread_mutex_t:锁的状态(是否已经被占用);当前持有锁的线程;等待锁的线程队列
锁空闲:允许线程进入临界区;
锁已占用:其他线程不能进入临界区,只能等待。
整个互斥锁的工作过程,实际上就是不断修改和检查锁的状态。
3.9.3 pthread_mutex_lock() 做了什么
当线程调用:pthread_mutex_lock() 时,会完成以下几个步骤:
检查当前锁是否被其他线程占用
如果锁空闲,则立即讲锁设置为“已占用”,当前进程获得锁
如果锁已经被其他线程占用,则当前线程不能进入临界区,而是进入阻塞挂起状态进行等待,当锁被释放时被操作系统唤醒
因此,同一时刻最多只有一个线程能够继续执行临界区代码。
3.9.4 为什么不会两个线程同时获得锁
假设线程 A 和线程 B 同时调用 pthread_mutex_lock(),它们都发现锁是空闲的,那么为什么不会同时获得锁?
原因:修改锁状态并不是普通读写操作,而是原子操作。
这里最常见的就是CAS(Compare And Swap,比较并交换)。
CAS可以保证:
检查锁状态和修改锁状态这两个动作作为一个不可分割的整体完成。
这里为了便于理解互斥锁的工作过程,下面以x86 原子交换指令xchg为例说明锁状态的修改过程。实际工程中,glibc 的 pthread_mutex 会根据平台采用CAS 或其他 CPU 提供的原子读-改-写指令实现快速加锁,但核心思想是一致的:利用 CPU 保证对锁状态的修改具有原子性,从而保证同一时刻只有一个线程能够成功获得锁。
// lock lock: movb $0, %al // AL = 0 xchgb %al, mutex // 原子交换 AL 与 mutex if (al > 0) { // 获取锁成功 return 0; } else { // 获取锁失败 挂起等待; goto lock; } // unlock unlock: movb $1, mutex; // 释放锁 唤醒等待 Mutex 的线程; return 0;xchg 指令会原子地交换寄存器和内存中的数据。
整个交换过程中,不会被其他 CPU 或线程打断,因此它保证了:
同一时刻只有一个线程能够成功修改锁状态。
例如:
初始状态: mutex = 1 线程A: xchg mutex 1 → 0 成功获得锁 线程B: xchg mutex 已经是0 交换失败 获取锁失败因此,无论有多少线程同时竞争这把锁,最终都只有一个线程能够成功获得锁,其余线程只能等待锁被释放后再次竞争。
这就是互斥锁能够保证同一时刻只有一个线程进入临界区的根本原因。
总结:
互斥锁内部维护着唯一的一份锁状态。CPU 利用原子交换(如
xchg)保证对这份锁状态的检查和修改不可分割,因此任意时刻只能有一个线程成功将锁从"空闲"修改为"占用",从而保证了锁只能被一个线程持有。
3.9.5 为什么竞争失败的线程会阻塞
如果获取锁失败,线程并不会一直循环占用CPU。
实际上,当发现锁已经被其他线程持有时,pthread 库会借助 Linux 提供的futex(Fast Userspace Mutex)机制,将当前线程挂起。
整个过程可以理解为:
pthread_mutex_lock() │ ▼ 尝试获取锁 │ ┌────┴────┐ │ │ 成功 失败 │ │ 进入临界区 futex 挂起线程此时,线程进入阻塞状态,不再占用 CPU 资源,直到锁被释放后再重新参与竞争。
因此,真正负责阻塞线程和唤醒线程的是 Linux 内核,而 pthread 库负责完成用户态与内核态之间的协作。
3.9.6 pthread_mutex_unlock() 做了什么
当线程完成临界区代码后,会调用:
pthread_mutex_unlock(&mutex);它主要完成两件事情:
- 将锁状态修改为空闲;
- 如果存在等待锁的线程,则通知内核唤醒其中一个(或多个)等待线程。
需要注意的是:
线程被唤醒并不意味着已经获得锁。
Running... 申请锁 Running... 申请锁 卡住线程只是重新进入运行状态,之后仍然需要再次竞争互斥锁,只有竞争成功才能进入临界区。
因此,我们经常会观察到同一个线程连续多次获得锁,这也是前面抢票程序中一个线程连续抢到大量车票的重要原因。
3.10 死锁
3.10.1 什么是死锁
例如:
pthread_mutex_lock(&mutex); pthread_mutex_lock(&mutex);程序:
Running... 申请锁 Running... 申请锁 卡住为什么程序卡住不动了?
是因为一个线程持有了锁,又再次申请同一把锁,因此线程会一直阻塞等待自己释放锁,最终导致程序永久阻塞。
死锁:多个执行流因为互相等待资源而永久阻塞,谁也无法继续执行的现象。
3.10.2 死锁产生的原因
例如:
自己锁自己
lock(A) lock(A)ABBA --- 循环等待
Thread1 lock(A) lock(B) ------------------- Thread2 lock(B) lock(A)避免死锁的方法:固定加锁顺序;减少持锁时间;尽量避免嵌套加锁