一、进程线程间的互斥相关背景概念
临界资源:多线程执行流共享的资源就叫做临界资源。 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。 互斥:用来保护临界区,任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,进而对临界资源起作用。当多个执行流向同一个共享资源做写入并且没有加保护,数据会发生写入错乱、覆盖等问题时就叫做数据写入不一致。(这也是多线程代码一定会面临的问题,因为多线程大部分资源都是共享的) 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。(原子性在多线程场景中并不是一种特性,而是一种结果,正是有了互斥的存在,故而有了访问资源的原子性)
补充:
- 互斥保护资源的本质是把多线程访问资源由并发执行转化为串行执行,所以互斥是可能会降低程序运行效率的。
- 多线程共享资源不会发生数据不一致问题,多线程并发访问共享资源才会发生数据不一致问题。所以多线程并不是所有共享资源都需要被保护,被访问的共享资源才需要被保护。
二、互斥量 mutex
为了实现互斥,所以需要引入一种新的数据类型:互斥量(互斥锁),它是完成互斥保护的数据对象和配套的方法。
我们看下面这个多线程并发访问一个全局变量的代码,它是模拟的一个抢票逻辑,每一个线程代表一个用户:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void* route(void* arg) {
char* id = (char*)arg;
while(1) {
if(ticket > 0) {
usleep(1000); // 模拟漫长的业务的过程,期间可能有很多个线程会进入该代码段
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
return (void*)0;
}
int main() {
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);
}
运行结果:

我们可以看到结果最后成了负数,要理解这一现象发生的原理就需要先理解 CPU 硬件(计算机组成原理知识)和线程(操作系统知识)分别在这期间的行为:

ticket 减到负数现象分析
计算机组成原理视角:
首先我们看访问共享变量 ticket 的核心代码,如上图红框框起来的部分,我们称之为临界区,理解区中右两个访问共享资源的地方:ticket > 0 和 ticket–,无论是 ticket > 0 这样判断真假的逻辑运算还是 ticket–这样的算术运算都需要由 CPU 来完成。
(重要补充:同一进程的多个线程是共享进程地址空间和页表的,所以多线程访问 ticket 时本质都是访问的同一个在物理内存中的 tickct 变量)
所以对于代码中的逻辑运算和算术运算都需要经过三个步骤:
- 从物理内存中取数据、取汇编指令拷贝到 CPU 的寄存器中。
- 分析指令,是算术运算还是逻辑运算,是加运算还是减运算…
- CPU 中的算逻运算器执行指令。
- (部分情况有该步骤)将运算结果覆盖式拷贝回物理内存,针对要修改变量本身的指令,如++,–。
操作系统视角:
上面我们只是站在计算机组成原理的角度来分析问题,下面我们站在操作系统视角在分析一下整个过程,首先我们要知道 CPU 执行上面的取指令、分析指令、执行指令的过程都会以进程或线程为载体执行,也就是 CPU 取的指令,取的数据是线程的数据,CPU 执行的这些操作本质是线程的代码要求 CPU 这样做的。
所以当 CPU 把物理内存中的数据拷贝到 CPU 的寄存器,本质就是把共享资源拷贝到线程硬件上下文,我们知道,线程的硬件上下文是线程私有的,所以 CPU 拷贝数据本质就是把共享数据变成线程的私有数据,这是数据不一致的基础,也就是单个线程在 CPU 中对共享数据做修改时是不会影响共享数据本身的。
当 ticket 被减到 1 后,多个线程同时进入该临界区,判断 ticket > 0 条件成立,多个线程同时进入 if 条件判断,然后多个线程串行执行 if 内部代码逻辑,第一个线程把物理内存中 ticket 位置的变量 1 拷贝到 CPU 中,完成 ticket–后把结果 0 拷贝回物理内存的 ticket 位置,接着第一个线程被切走,然后把自己的硬件上下文带走,然后 OS 调度第二个线程执行 if 内部代码逻辑,首先它把物理内存中已经被改为 0 的 ticket 变量拷贝到 CPU 寄存器中,执行 ticket–后把结果 -1 拷贝回物理内存的 ticket 位置,然后调用 printf 就打印出 -1 了。
所以票数被减到负数本质原因 if 判断导致的,就是多线程并发访问临界区并且多个线程进入 if 判断条件中。
ticket–代码分析
现在我们把票数减到负的原理谈清楚了,现在还有一个问题,ticket–这个操作是线程安全的吗?答案是否定的,因为 ticket–并不是原子的,它经过汇编之后会转化成三条指令,如下图所示:
假设当线程 a 第一次执行 ticket–,刚刚执行到第二条汇编指令 sub 就因某种原因被切走了,这时操作系统会执行上下文保存,把线程 a 当前所有寄存器(包括存着 1000 的%eax)、程序计数器(PC/RIP)等状态,完整保存到该线程的线程控制块(TCB)中(实际是轻量级进程),然后 OS 调度线程 b 执行代码逻辑,假设又因为某种原因线程 b 一直执行代码把 ticket 减到 0 了,并把 0 写回了物理内存的 ticket 中,接着线程 b 被切走,线程 a 被切回,此时 OS 会将线程 a 被暂停时保存的寄存器状态恢复到 CPU 寄存器中,那么此时 CPU 不会去读取物理内存中 ticket 的最新值(0),而是直接恢复'线程 a 暂停时的旧值 1000',这时 CPU 在执行 ticket–就得到了 999,并把 999 写到物理内存的 ticket 中。
通过上面的现象我们能得出一个结论:类似 ticket 这样的全局变量式的计数器不是线程安全的,会有并发问题。
解决思路
所以要解决上述问题,本质就需要对红框代码进行保护,形成临界区,需要我们用代码的形式来保护,因为我们既然能写访问共享资源的代码,就一定也能写保护共享资源的代码。
为了解决这一问题 pthread 库为我们提供了互斥锁的概念,并且它一定是可编程的,下面我们先见一见它的接口,后面再讲原理。
互斥量的接口
- 使用互斥量相关接口需要包含 pthread 头文件,pthread_mutex_t 是 pthread 为我们提供的一种数据类型。
- 以 pthread 开头的函数大部分返回值都是整型,为 0 表示成功,其他整数表示出错原因。
初始化互斥量
定义的互斥量是静态的或全局的:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
在栈上、堆上动态开辟的互斥量:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
第二个参数是设置互斥锁属性,我们不关心,置为 nullptr 即可。
销毁互斥量
使用 PTHREAD_MUTEX_INITIALIZER 初始化的全局互斥量不需要销毁。不要销毁一个已经加锁的互斥量。已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回 0,失败返回错误号
利用互斥量解决问题
衍生出的 3 个问题
- gmutex 本身不就是全局变量吗?它要保证别人是线程安全的,它自己怎么保证自己是线程安全的呢? 答:让加锁和解锁操作本身具有原子性就可以保证互斥锁本身是线程安全的。
- 所有进入临界区线程都要加锁吗?可以有例外吗? 答:不能有例外,只要进入是临界区的线程必须全部加锁!!线程申请锁成功后能继续向后执行代码,若线程申请锁失败就会在申请锁的位置被阻塞。
- 如果一个线程在申请了互斥锁之后进入临界区,当它在临界区执行代码时可以被 OS 切走吗?会因为切换导致其他线程进入临界区造成并发问题吗? 答:线程可以被切走,但是切走后其他线程因申请该锁时会被阻塞进而无法进入临界区造成并发问题,因为线程是在持有锁的状态被切走的,它并没有释放锁,所以其他线程无法申请锁自然无法进入临界区。这也是为什么加锁后程序运行效率较慢的原因之一。
从软件层面的原子性理解互斥锁
对于一个申请了互斥锁的线程来讲,它对外本质就只有两种状态:未执行临界区代码和执行临界区代码完毕,因为在访问期间该线程是不可被打扰的,所以该线程访问临界区的过程就在逻辑上具有原子性。这里的原子性是在加锁之后产生的结果。这就是软件层面的原子性。
互斥锁使用的最佳实践
因为锁会降低程序效率,所以加锁和解锁时囊括的临界区尽量是最小集。
三、互斥的硬件/软件实现及原理
互斥本质就是要让线程的每个操作具有原子性,对于互斥的实现一般有硬件和软件两个层面的实现方案:
- 硬件实现: 互斥需要保证线程操作具有原子性,要解决这一问题就要在线程运行时避免外界因素中断/干扰线程的执行,除了线程调用如 read 系统调用阻塞等待时主动出让 CPU 资源以外,外界会频繁干扰线程的执行因素就是各种中断如时钟中断,外设中断,所以硬件实现互斥的方案之一就是关闭中断。
- 软件实现: 互斥锁的软件实现需要基于下面这个基本点:一条汇编语句本身是原子的。 为了实现互斥锁操作,大多数体系结构(如 X86,arm 等等)都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,下面是加锁和解锁操作的汇编指令:

虽然 pthread 库对互斥锁做了封装,我们先简单把互斥锁 mutex 当成一个整型变量,值为 1,加锁第一步线程会先将 cpu 中的 al 寄存器中的值置为 1,然后将 al 寄存器中的值和互斥锁 mutex 值做交换,这样 1 就换到了 al 寄存器中,内存中的 mutex 变量交换后就变成 0 了,我们要理解这里交换操作的本质:以非拷贝的形式把 1 这个数字由共享变为私有,因为这样 1 就会变成申请锁线程的硬件上下文。线程继续执行后面的 if 判断,判断成立返回 0 申请锁成功。
线程在申请锁的过程中是可能随时被切走的,但是不用担心会发生并发问题,因为如果一个线程已经将'1'交换进了 al 寄存器,当该线程被切走时会将'1'一并带走,其他线程被 OS 调度切回时也会执行申请锁操作,但此时的 mutex 值为 0,即使交换后 al 寄存器的内容仍为 0,if 判断不成立就会被 OS 挂起等待,所以线程是否申请锁成功就在于它是否成功执行了 exchange 指令把'1'换进自己的硬件上下文。
数据的交换而非拷贝保证了'1'只有一份,所以我们可以把这个'1'看作是访问临界区的钥匙或者令牌。
四、互斥锁的封装
下面我们来对 pthread 的互斥锁做一下面向对象化的封装,类似于 C++ 的互斥锁。
本文将实现一个 RAII 风格的互斥锁。
// Mutex.hpp
#pragma once
#include <pthread.h>
class Mutex {
public:
Mutex() {
pthread_mutex_init(&_lock, nullptr);
}
void Lock() {
pthread_mutex_lock(&_lock);
}
void Unlock() {
pthread_mutex_unlock(&_lock);
}
~Mutex() {
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard {
public:
LockGuard(Mutex *mutexp) : _mutexp(mutexp) {
// 构造的时候加锁
_mutexp->Lock();
}
~LockGuard() {
// 析构的时候解锁
_mutexp->Unlock();
}
private:
Mutex *_mutexp;
};
// testMutex.cc
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "mutex/Mutex.hpp"
int ticket = 100;
Mutex lock;
void* route(void* arg) {
char* id = (char*)arg;
while(1) {
{
// 临界区
LockGuard lockguard(&lock); // RAII 风格的加锁!!
if(ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
// other code....
}
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);
}
可以看到本文实现了一个 LockGuard 类,它的构造和析构的行为就是加锁和解锁,在测试代码中我们要加锁时只用定义一个 LockGuard 对象就完成了加锁操作,LockGuard 对象会自动在进入临界区时加锁,在出临界区时解锁。背后的原理就是利用了局部变量的生命周期,因为局部变量的生命周期只在花括号内部,如果我们想更直观体会 LockGuard 的作用原理,还可以在 while 循环里将整个临界区用花括号包起来,如果 while 循环里还有其他非临界区代码也互不影响,并且可以提高效率。
RAII 封装的核心优势是'资源获取即初始化',可避免忘记解锁的问题;即便临界区发生异常,局部对象 LockGuard 析构时也会自动解锁,保证锁的安全释放。


