线程(三)【线程互斥(下)】
目录
- 4. 互斥锁
- 4.1 解决数据不一致问题
- 5. 锁的原理
- 5.1 加锁
- 5.2 解锁
- 6. 可重入 vs 线程安全
4. 互斥锁
NAME
pthread_mutex_destroy, pthread_mutex_init - destroy and initialize a mutex // 创建、释放锁
SYNOPSIS
#include <pthread.h>
// pthread_mutex_t: 线程库提供的一种数据类型
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 定义并初始化全局锁,即可不需要显式的释放锁
4.1 解决数据不一致问题
锁的作用,我们现在可以先感性的理解一下:就好比我们去住酒店,入住之前需要先到前台申请房卡,这个房卡就是锁,我们入住后,把房卡挂墙上。后面我们退房时,再拿着房卡到酒店办理退住,这就是释放锁。
而在我们申请到这张房卡后,保证了只有我们这一个人能入住,其它人进不来你的房间。而其它人想要申请你这房卡,只能等你退房了,即多线程并发访问某种资源时,其它线程首先得等当前线程释放锁过后才能申请锁,并且保证其它线程申请的是同一把锁,假设酒店有多张该房间的房卡,但是给客户的永远是同一张房卡,这样才能保证只有一个执行流。
NAME
pthread_mutex_lock, pthread_mutex_trylock, pthread_mutex_unlock - lock and unlock a mutex
SYNOPSIS
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
一个 tickets 全局变量,也称共享变量,所有线程共享式的访问这个全局变量,如果不想在并发访问该共享变量时出现问题,就需要在访问 tickets 的所有地方进行加锁。tickets 被加锁之后,就只能运行一个执行流同时访问,我们把共享的、任何时刻只允许一个执行流访问的资源,称为临界资源。而在代码中,并不是每一处都在访问临界资源,我们把访问临界资源的那一块代码,称为临界区。
再者,加锁的本质其实是用时间来换取安全, 加锁后,共享资源就只能被多线程串行访问,因此会降低多线程的并发度,那么访问效率肯定就没那么高了。所以,加锁的表现就在于线程对于临界区的代码是串行执行的!加锁原则为尽可能的保证临界区代码越少越好。
int tickets = 1000;
class threadData
{
public:
threadData(int number, pthread_mutex_t* mutex)
{
threadname = "thread[" + to_string(number) + "]";
lock = mutex;
}
string threadname;
pthread_mutex_t* lock;
};
void* getTicket(void* args)
{
while(true)
{
pthread_mutex_lock(td->lock); // 申请锁成功,才能往后执行,不成功则一直阻塞。
if(tickets > 0)
{
usleep(1000);
printf("who = %s, get a tickets = %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);
}
else
{
pthread_mutex_unlock(td->lock);
break;
}
}
...
}
int main()
{
pthread_mutex_t lock; // 定义锁
pthread_mutex_init(&lock, nullptr); //初始化锁
...
pthread_mutex_destroy(&lock);
return 0;
}
对共享资源加锁之后,我们确实解决了多线程并发访问一个资源造成的数据不一致的问题,但是我们看到了另一个想象,每个线程访问到的资源占比不太均衡,似乎都是个别线程在访问的。
因此我们在每次释放锁过后,加上一点点休眠,来让多执行流访问到的资源尽可能均衡一些。
void* getTicket(void* args)
{
while(true)
{
...
usleep(13);
}
...
}
现在我们就看到了,基本每一个线程都能较为均衡的访问到同一份资源了。
-
不加 sleep,导致其它线程无法访问到该资源,这种现象正常吗?
如果在每个线程释放锁后,不加以等待,那么这个线程即可立即重新申请到锁,当前线程重复申请到锁后,其它线程申请不到,只能阻塞在 pthread_mutex_lock 处,所以这个现象是正常的。
-
是什么导致的这种现象?
这主要是因为线程对于锁这种资源的竞争能力可能是不同的。我们说过,如果线程申请不到锁资源,那么就会一直阻塞在申请处,其线程的 PCB 就会从运行态转而变为非运行态,处于阻塞状态。在当前进程释放锁之后,其它线程要申请锁,需要先从非运行态 --> 运行态,即唤醒线程,那么这个过程肯定是要比当前释放锁的这个线程重新申请一次锁要费力的,即线程唤醒再到申请这个过程,要比释放后再次申请要慢!所以同一个线程释放了锁,马上又申请到了,其它线程就再次陷入阻塞,循环往复,就导致了线程访问到的资源不均衡的问题。
而当我们加了
usleep(13);
,本质就是在模拟抢票之后的后续动作,而不是立马进入下一轮抢票,加上了usleep(13);
才是现实中较为常见的场景。所以一个线程释放锁之后,因为处理后续动作,因此是无法做到立即再次申请锁的,那么其它线程就能够申请到锁资源,然后访问临界资源。
接下来,我们再次举个例子帮助理解。
一个线程释放锁后能够重新立刻申请到锁,就好比,你在一个非常火热的度假岛,你的那间房间是全岛观光最佳的海景房,因此一大批人争的头破血流。第二天你刚退了房,但是转头想到,这个房间住的这么舒服,算了,我还是继续续住吧,于是前台就在你面前,你就立刻跟前台再次续住,申请到了房卡,其它人抱着手机刷新着该房型的状态,还没来得及下单,你就已经申请到了房卡。
这就是纯互斥环境,如果锁资源分配的不够合理,就容易导致其他线程的饥饿问题(即其它线程申请不到锁资源导致无法访问临界资源)!但并不是只要有互斥,就会出现饥饿问题。
由于这种现象持续发生,该酒店的前台投诉电话就被打爆了。于是,上层领导就做出了改革,规定这间房型,同一个人不能连续申请!这就是当一个线程申请到锁资源,释放锁之后,就要重新进入锁资源的等待队列中排队申请,不能连续重复申请。但是当前线程把锁释放后,其它100个线程全部被唤醒,最终只有一个线程能够获得锁资源,等于其它 99 个线程的唤醒是无效行为,那这样也不合理。
即,为了防止上一个人退房后,前台涌入一大批人在抢购这间房型,于是再次规定,该房型的购买需要到线上进行挂号排队!先排队的人先入住。
让所有的线程(人) 获取锁 (房卡),按照一定的顺序性获取资源,我们把这种机制称为同步!
在多线程并发访问一个共享资源时,我们要保证每个线程申请的是同一个锁,那么每个线程在申请锁这个资源的时候,锁本身也是一种共享资源!而锁是为了保护其它共享资源的安全,但是在保证其它资源安全的前提下,总得先保证自己是安全的吧(自己也是共享资源),所以,申请锁和释放锁本身就被库设计成为了原子性操作的(原子性:即申请和释放锁这个行为要么做、要么不做,不会出现做一半的情况)!
-
线程在执行临界区的时候,可以被切换吗??
当然可以。
tickets--
这一条语句在执行时都也可能发生切换,临界区不就是一块代码吗,一条代码都可能发现切换,执行一块代码凭什么不能切换。如果线程申请到锁资源后,时间片到了发生了线程切换,线程切换时,是持有锁被切走的,等到线程重新被调度运行时,锁资源依旧属于该线程。该线程被切换期间,其它线程也无法进入临界区访问临界资源!因为它们没有锁资源,锁资源就一份!
这里可以理解为,办理了酒店入住,你出去吃个早餐,出去喝个奶茶、喝个咖啡,你肯定是把房卡带在自己身上离开的酒店,即便你离开了自己的房间,也无人能进入。
对于其他线程来讲,它们只关心两个事:某个线程申请到锁了,这个线程释放锁了。其它线程不关心当前申请到锁的这个线程,执行临界区访问临界资源的一切过程!因为关心了,它也无法访问临界资源。
所以通过对临界资源进行加锁,可以保证当前线程在访问临界资源时,对于其它线程而言,这个访问的过程是原子性的。即,一个线程要么没有申请锁,要么释放锁了。
截止目前,我们可以思考一下,我们为什么要加锁呢?因为多线程并发会有数据不一致的问题?那么为什么要有多线程呢?因为我们想提高代码的并发度,又不想创建进程这种体量的资源。那么我们反推回去,即,为了提高并发度,我们创建了多线程,创建了多线程,就导致了访问共享资源时的数据不一致问题,为了解决这个问题,我们引入了互斥锁的概念,因此又引入了临界资源、临界区以及原子性的概念,并且可能存在线程饥饿的问题等等。这个世界就是这样子的,为了解决问题,引入新的解决方案的同时往往伴随着另一个问题的出现。
5. 锁的原理
我们已经知道 tickets--
在底层会被转换为 3 条汇编,因此它并不是原子的。所以我们可以理解为一条汇编就是原子的。但并不是只有一条汇编可以被称为原子的,诸如我们上面所说,一个线程申请到锁后访问的临界区,对于其它线程而言,这段临界区就是原子的。
为了实现互斥锁操作,大多数体系结构(即 CPU 架构) 都提供了 swap 或 exchange 指令(CPU 内部是内置了一些基础指令的,即芯片指令集,比如数据从内存加载到 CPU,CPU 内的数据写回内存等操作,而上层编写的更加复杂的代码,最终都经过编译转换为芯片里的指令集,CPU 就能够识别并操作),该指令的作用是把寄存器和内存单元的数据相交换,而由于只有一条指令,保证了原子性,所以即使是多处理器平台(主板只有一块,因此访存总线也只有一套),访问内存时由仲裁器决定由哪个 CPU 去访问,因此访问内存总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期,所以在访存上依旧是串行的,只不过是可以多处理器并发运算数据。
// 加锁、解锁的伪代码:
lock:
movb $0, %al
xchgb %al, mutex
if (al 寄存器的内容 > 0) return 0;
else 挂起等待;
goto lock;
unlock :
movb $l, mutex
唤醒等待 Mutex 的线程;
return 0;
其中的 lock 加锁就等效于 pthread_mutex_lock,unlock 解锁等效于 pthread_mutex_unlock。
5.1 加锁
我们可以把 “锁” 理解为内存中的一个对象 int mutex,其内容默认为 1。第一条语句 movb $0, %al
即把 al 寄存器中的内容清空为 0,xchgb %al, mutex
把 al 寄存器的内容与内存中定义的 mutex 变量的内容做交换,这条语句就是申请锁的动作。接着判断 al 寄存器的值,即申请锁成功 or 失败。
当 thread[1] 被调度运行,执行第一条语句 movb $0, %al
把寄存器的内容置 0 后,thread[1] 就发生了线程切换,与此同时,thread[1] 需要把自己的硬件上下文数据全部保存带走,即 al 寄存器中置 0 了,那么 thread[1] 就把 0 这个数据带走,以及 EIP 寄存器下一条指令的地址。
接下来,thread[2] 被调度运行,也来申请锁,那么 thread[2] 也把寄存器的内容置 0(这个动作本质是把自己的硬件上下文对应的数据置 0),然后继续执行 xchgb %al, mutex
,把 al 和 mutex 的内容做交互。接着,thread[2] 正准备执行 if 做判断时,thread[2] 也发生了切换,因此它也带走自己的上下文数据,即 al 寄存器的内容 1 带走。
thread[2] 被切换后,thread[1] 重新被调度,然后恢复自己的上下文数据,即把上次带走的 al 寄存器的内容 0 重新恢复回 al 寄存器中,然后与内存中的 mutex 的内容做交换,接着做 if 判断时条件为假,即 thread[1] 申请锁失败!所以 thread[1] 被阻塞。
thread[1] 申请锁失败被阻塞后, thread[2] 回来了,同样的先把自己的上下文数据恢复到寄存器中,然后接着上次没完成的 if 语句判断,最终条件为真,thread[2] 成功申请到锁,然后返回。
所以,整个过程,最重要的就是这条交换语句 xchgb %al, mutex
,把内存中的数据,交换到 CPU 的寄出器中。而这个锁 mutex 本质也是一个共享资源,每个线程都能够读取到这个锁,而交换的本质一定是在多线程中,一定存在一个线程,把 锁 mutex(1) 交换到自己的硬件上下文中(属于线程私有的),然后把 0(al 寄存器的内容) 交换到内存中,而这个线程就是执行了 exchange 指令的那个线程。换言之,交换的本质就是把一个共享的锁,让一个线程以一条汇编的方式交换到自己的上下文中,成为自己私有的资源(因为内存中只有一个 mutex 锁,即只有一个 1),而就称为一个线程持有锁。
5.2 解锁
解锁相对就很简单了,movb $l, mutex
只是把 1 重新写回到内存中的 mutex,因为这是一条汇编,因此解锁也是原子的。
解锁的设计上,允许不是加锁的线程来做解锁这个动作。这样的好处是,如果申请锁的线程发生异常退出了,退出就退出呗,其它线程不关心,但是它退出时没有解锁啊,所以设计上允许其它线程解锁。
6. 可重入 vs 线程安全
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果,就是线程安全的。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题(一个线程中访问野指针,导致线程异常,最后整个进程崩溃了,这种也称为线程安全问题)。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
从理解上,好像线程安全跟重入非常接近,都是描述的多执行流情况下并发运行时会不会出现问题。但需要注意的是,线程安全与重入依旧是两个不同的概念,重入描述的是一个函数的特点,用于描述函数的,而线程安全描述的是线程并发的问题,两个概念的侧重对象不同。
如果一个函数是不可重入的,在多线程调用时,它可能是线程不安全的(因为线程并发出问题是概率性问题,不能保证百分百);如果一个函数是可重入的,那它一定是线程安全的。
常见的线程不安全的情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数(比如一边调用函数,一边统计该函数的调用次数)
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc / free函数,因为 malloc 函数是用全局链表来管理堆的
- 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!