深入 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_castconst 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_castconst 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 票数剩余量为 1if (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也进入抢票流程抢完票之后票数为 0Thread2 被切换走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_castconst 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()它们都发现锁是空闲的那么为什么不会同时获得锁原因修改锁状态并不是普通读写操作而是原子操作。这里最常见的就是CASCompare 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 提供的futexFast 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)避免死锁的方法固定加锁顺序减少持锁时间尽量避免嵌套加锁