本篇目标:
1.深刻理解线程互斥的原理和操作
2.理解锁的概念
一.线程互斥
1.相关概念
1.1. 共享资源
概念:多个执行流(线程/进程)都可以访问的资源。
比如:
cpp
int count = 0;
如果多个线程都可以修改 count:
线程A:
cpp
count++;
线程B:
count++;
那么 count 就是一个共享资源。
常见共享资源:
- 全局变量
- 堆上的数据
- 文件
- 数据库中的数据
- 硬件设备(打印机等)
1.2. 临界资源
概念:被多个执行流访问,并且访问过程需要保护的共享资源。
注意:不是所有共享资源都是临界资源。
例如:
cpp
int ticket = 100;
多个线程卖票:
线程1:
ticket--;
线程2:
ticket--;
ticket 就是临界资源。
注:当然为什么这个票会被减为负数?以及是否真的会被减为负数?,我将在下面的代码里面演示的
1.3. 临界区
概念:访问临界资源的代码区。
注意:
临界资源是"东西"
临界区是"代码"
例如:
cpp
int ticket = 100;
void sell()
{
ticket--; // 临界区
}
这里:
ticket
↓
临界资源
ticket--
↓
临界区
1.4. 互斥
概念:任何时刻,只允许一个执行流进入临界区访问临界资源。
目的:保护临界资源。
为什么要互斥?
以一个例子来稍微解释一下:
没有互斥时:
cpp
线程A 线程B
进入临界区 进入临界区
修改数据 修改数据
后果:两个线程同时操作,这样会造成数据混乱。
有互斥时:
cpp
线程A
获得锁
进入临界区
修改数据
释放锁
//如果线程A先获得了锁,那么线程B就无法进入这个临界区,进行修改数据了
线程B
获得锁
进入临界区
修改数据
有了锁以后,就可以同一时间,仅有一个线程访问
1.5. 原子性
概念:不会被任何调度机制打断的操作,该操作只有两态,要么完成, 要么未完类似数据库中的事务。
例如:
ticket--;
看起来是一条语句:
但是CPU执行:
实际上:
cpp
1.读取ticket
2.ticket-1
3.写回ticket
三个步骤。
如果执行到:
cpp
2.读取
被切换:
线程A:
读取 ticket=100
线程切走
线程B:
读取 ticket=100
就出现问题。
所以:
真正的原子操作:
cpp
开始
↓
执行
↓
完成
中间不能被打断。
当然这个原子性也会在下面的代码演示中体现出来。
2.互斥量mutex
2.1.代码演示
在Test.cpp中,
cpp
#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); //休眠1秒
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
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);
return 0;
}
makefile中,
cpp
Test:Test.cpp
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -rf Test
运行结果:
当我们运行这个代码时,就会惊讶的发现:居然会有负数的抢票结果,可是这不对啊?代码中我们是判断了:当ticket<=0时就停止了啊?
那么这个就牵扯到关于上面的概念了:
实际上,这里的 ticket 是一个共享资源,因为它被所有线程共同访问。然而,这个共享资源需要被保护起来,使其成为临界资源 。相应地,if 判断及其内部的代码段应被视为临界区,而其他代码区域则可以是非临界区。
2.2.为什么会减到负数?
首先,ticket--并不是原子的,因为这是个C语言代码,但是在底层是汇编语言,如代码所示:
cpp
//objdump -d a.out > test.objdump 反汇编
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax #
600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) #
600b34 <ticket>
对应三条汇编指令理解:
• load :将共享变量ticket从内存加载到寄存器中
• up date :更新寄存器里面的值,执行-1操作
• store :将新值从寄存器写回共享变量ticket的内存地址
目前,我们可以暂时的将一条汇编认为就是原子的。
假设线程 A 在执行这三条指令时,在即将执行 store 操作前被切换,线程 B 开始执行。此时,线程 A 的上下文(包括寄存器中已计算出的新值 99)会被保存。线程 B 执行这三条指令时若未受任何阻拦,可能会将 ticket 减至 0。若此时再次切换回线程 A,线程 A 会继续执行 store 操作,将寄存器中的值 99 写回 ticket 的内存地址。这样一来,线程 B 操作后变为 0 的 ticket 又被改回 99,从而导致数据不一致的问题。
流程图:

其实上面的解释是为了让我们知道ticket并不是让ticket减为负数的主要原因,其实是这个判断ticket>0导致的。
解释:ticket>0也是个计算,但是是逻辑计算,如果有一个线程A执行这个后,但是突然被切换了,而另外一个线程B直接while将ticket减为了0,然后又切换到了线程A,那么线程A再读取这个ticket时不就是0了吗?然后再--,不就变为负数了吗?
要解决以上问题,需要做到三点:
• 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
• 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许⼀个线程 进⼊该临界区。
• 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要⼀把锁,Linux上将提供的这把锁叫互斥量。
2.3.互斥量接口
我们在终端里面输入man pthread_mutex_init就可以查看关于锁的初始化,如图:

cpp
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量attr:互斥量属性,通常传 NULL 表示使用默认属性
返回值:成功返回 0,失败返回错误码。
如果互斥量是全局变量或静态变量,也可以直接写:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
我们初始化锁后,最后还要销毁这把锁:
cpp
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数mutex就是要销毁的那把锁。
注意:
• 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要我们自动销毁,程序运行结束后,会自动释放
• 不要销毁⼀个已经加锁的互斥量。
• 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
但是目前我们仅是初始化锁,并没有加锁,还需要一下的操作:
我们在终端里面输入man pthread_mutex_lock就可以查看关于加锁的操作,如图:

cpp
int pthread_mutex_lock(pthread_mutex_t *mutex);
解释:
- 如果互斥量当前没有被其他线程占用,当前线程立即获得锁。
- 如果互斥量已经被其他线程占用,当前线程会阻塞等待。
- 直到其他线程释放锁,当前线程才有机会继续执行。
cpp
int pthread_mutex_trylock(pthread_mutex_t *mutex);
这个是尝试加锁 ,但是如果锁被占用,不会阻塞等待,而是直接返回错误码,一般不咋使用。
cpp
int pthread_mutex_unlock(pthread_mutex_t *mutex);
这个是解锁 ,解锁后,其他正在等待该锁的线程才有机会进入临界区。
注意:获得锁后一定要记得解锁,否则其他线程可能永远阻塞。
有了以上的操作了,我们就可以修改之前的代码了,如代码所示:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_mutex_t mutex;
int ticket = 100;
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);
ticket--;
pthread_mutex_unlock(&mutex); //解锁
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main(void)
{
pthread_mutex_init(&mutex,NULL); //初始化锁
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);
pthread_mutex_destroy(&mutex); //销毁锁
return 0;
}
运行结果:

2.4.细节补充
<1>.锁初始化后,每个线程都可以申请加锁,锁本身就是临界资源,所以申请锁的过程也必须是原子的,所以锁提供的能力本质:执行临界区代码由并行转换为串行,在线程执行期间,不会被其他的线程打扰,也是一种变相的原子性的体现
<2>.加锁之后,在临界区内部允许线程切换吗?切换了会咋样?
答案:可以切换,但是当前的线程并没有释放锁,持有锁被切换了,即使不在了,其他的线程也必须得等这个线程回来后执行完代码了,释放锁后,其他的锁才可以进入锁得竞争,进入临界区,所以切换了也不会对当前线程造成影响,所有得线程必须得等我跑完
<3>.下面让我来举个例子加深一下印象吧:
假设一个县城里面得自习室里面有个VIP单人自习室+开门得钥匙,然后小李最早到达这个自习室,拿走了钥匙,那么其余后来的人是无法进入这个自习室得,因为小李将门给锁了,即使小李中途出门上厕所了,但是将门给锁了,其他的人也是进不来的,所以外边得人对小李的自习要么不用这个自习室,要么就等待小李用完,所以小李的自习对外边的人是原子的。
3.锁的原理实现
3.1.原理探究
• 经过上⾯的例⼦,⼤家已经意识到单纯的 问题 i++ 或者 ++i 都不是原⼦的,有可能会有数据⼀致性
• 为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令, 该指令的作⽤是把寄存器和 内存单元的数据相交换, 由于**只有⼀条指令,保证了原⼦性,**即使是多处理器平台,访问内存的总线周期也有先后,⼀个处理器上的交换指令执行时另⼀个处理器的交换指令只能等待总线周期,现在我们把lock和unlock的伪代码展示一下,如图:

解释:首行的movb $0 ,%al其实就是将寄存器%al里面的数据为0,然后我们可以简单的将mutex理解成一个为1的整数,而xchgb %al,mutex其实就是将内存里面的mutex和寄存器%al里的1交换,所以我们就知道了谁交换成功了这个1谁就持有这个锁,因为后面的线程再次执行xchgb %al,mutex,mutex里面的数据不是1而是0了。
再往深理解:
进程/线程切换:CPU里面的寄存器硬件仅有一套,但是CPU寄存器里面的数据可以有多份,各自一份,存储着当前执行流的上下文 ,换句话说:把一个变量的内容交换到寄存器的内部本质是将该变量内容获取到当前执行流的硬件上下文。所以当前进程/线程的硬件上下文(各个寄存器的内容)属于进程/线程私有的 ,所以我们用swap/exchange将内存里的内容交换到CPU的寄存器中 ,本质是当前进程/线程在获取锁 ,因为是交换而不是拷贝 ,所以1仅有一份,所以谁申请到,谁持有锁。
当我们解锁时,需要将这个1重新放到mutex中,唤醒等待mutex的线程,其他的线程就可以申请锁了
3.2.锁的简单封装
cpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace LockModule
{
// 对锁进行封装,可以独立使用
class Mutex
{
public:
// 删除不要的拷贝和赋值
Mutex(const Mutex&) = delete;
const Mutex& operator=(const Mutex&) = delete;
Mutex()
{
int n = pthread_mutex_init(&_mutex, nullptr);
(void)n;
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
// 获取原始互斥锁指针
pthread_mutex_t* GetMutexOriginal()
{
return &_mutex;
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
(void)n;
}
private:
pthread_mutex_t _mutex;
};
// 采用 RAII 风格,进行锁管理
class LockGuard
{
public:
LockGuard(Mutex& mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex& _mutex;
};
}
RAII风格的互斥锁,C++11也有,例如: std::mutex mtx;std::lock_guard guard(mtx); 此处我们
仅做封装,⽅便后续使用,后边我会写C++的有关线程的使用
总结:
多线程访问共享资源时,如果资源存在竞争,就成为临界资源。访问临界资源的代码叫临界区。为了保证临界区安全,需要使用互斥锁。互斥锁通过原子性的加锁操作保证同一时刻只有一个线程进入临界区,将并行访问转换为串行访问,从而避免数据不一致问题。其底层依靠CPU提供的原子交换指令实现,而C++中通常使用RAII方式封装锁,自动管理锁的生命周期。