线程互斥
线程互斥概念
- 临界资源:多线程执行流共享的资源叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证只有一个执行流能进入临界区,访问临界资源,通信对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,一是完成,二是未完成。
临界资源和临界区
进程之间如果要进行通信,就需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式由很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码叫做临界区。
多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲取创建第三方资源。
例如,我们只需要在全局区定义一个count变量,让新线程每隔一秒对该变量加一操作,让主线程每隔一秒获取count变量的值进行打印:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int count = 0;
void* function(void* arg)
{
while (1){
count++;
sleep(1);
}
pthread_exit((void*)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, function, NULL);
while (1){
printf("coount: %d\n", count);
sleep(1);
}
pthread_join(tid, NULL);
return 0;
}
运行结果如下:
可以看到,全局变量count叫做临界资源,主线程和新线程都可以被读取,被共享,而主线程的printf和新线程中的count++叫做临界区,因为这些代码都对临界资源进行了访问。
互斥和原子性
在多线程的情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,一是完成,二是未完成。
例如下列代码,我们模拟实现一个抢票系统,我们将记录剩余的票数变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后,这四个线程会自动退出:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
void* function(void* arg)
{
const char* name = (char*)arg;
while (1){
if (tickets > 0){
usleep(10000);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
}
else{
break;
}
}
printf("%s quit!\n", name);
pthread_exit((void*)0);
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, function, "thread 1");
pthread_create(&t2, NULL, function, "thread 2");
pthread_create(&t3, NULL, function, "thread 3");
pthread_create(&t4, NULL, function, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
运行结果如下:
这里出现了负数,为什么呢?
我们要先知道:ticket是临界资源,被多个执行流同时访问,而判断ticket是否大于0,打印票数和--ticket都是临界区。
当if语句判断为真之后,代码可以并发的切换到其他线程,usleep用于模拟漫长业务的过程,在这个等待时间,有可能会有很多个线程进入该代码段,且--ticket并不是一个原子操作
为什么--ticket不是原子操作
我们对一个变量进行--,需要三个步骤:
- load:将共享变量tickets从内存加载到寄存器中。
- update:更新寄存器里面的值,执行-1操作。
- store:将新值从寄存器写回共享变量ticket的内存地址。
所以我们来看看发生意外的一个整个过程:
thread1把ticket的值读进CPU就被切走,从CPU上剥离下来,假设此时读到的值是1000.而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,任何thread1就被挂起了。
此时thread2被调度了,因为thread1只进行了--操作第一步,thread2看到ticket的值还是1000,但是系统给了thread2足够的时间,导致thread2执行了100次完整的--才被切走,最后ticket变成了900。
此时系统再把thread1恢复,任何继续执行thread1的代码,也就是--操作的第二步和第三步,此时寄存器中thread1的上下文信息的ticket值为999,最终将999写回内存。
所以导致了抢了101张票,最后还剩下999张票。
互斥量mutex
大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。但是有些时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。此时出现多个线程并发的操作共享变量,就会带来一些和上面相同的问题。
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
综上所述,如果要做到这三点,那么就是要一把锁,Linux上提供的这把所叫做互斥量。
互斥量接口
互斥量初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr)
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 初始化成功返回0,失败返回错误码。
调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们用另一种方式初始化互斥量,叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 销毁成功返回0,失败返回错误码。
注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 加锁成功返回0,失败返回错误码。
注意:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,如果其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但美哟竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞,也就是被挂起等待互斥量解锁。
互斥量解锁
int pthread_mutex_unlock(ptread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
示例:
我们在前面的抢票系统引入互斥量,每一个线程进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁,代码如下:
互斥量的实现原理
加锁后的原子性
当我们引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态:一是没有申请锁,二是锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
我们先看下图:线程1进入临界区后,在线程2、3、4眼中,线程1要么没有申请锁,要么已经释放锁了,因为这样才有意义,毕竟只有这样别的线程才能进行竞争,一旦检测到其他状态,那么这三个线程也就被阻塞了。
所以对于线程2、3、4而言,它们认为线程1的整个操作过程是原子的。
临界区内的线程进行进行线程切换
临界区内的线程是可以进行线程切换的,但就算线程被切走,如果锁还未释放,那么该线程就会带着锁一起被切走,导致别的线程无法申请到锁。
所以只有锁被释放了,别的线程才能拿到锁进如临界区。
锁的保护
锁是会被多个线程同时竞争,那么意味着锁也是临界资源,那么锁就需要被保护起来,但是锁就是用来保护临界资源的,这不就冲突了吗?
锁实际上是自己保护自己的,我们只需要保护申请锁的过程是原子的,那么锁就是安全的。
申请锁的过程的原子性
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
理解lock和unlock
我们可以认为mutex初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要三步:
- 先将al寄存器中的值清零。这个动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行动作的本质就是将自己的al寄存器清零。
- 然后交换al寄存器和mutex中的值。
- 最后判断al寄存器中的值是否大于0。是则申请锁成功,否则申请锁失败被挂起等到,等到锁释放后再次竞争申请锁。
举个例子:
- 此时内存中的mutex值为1,线程申请锁时先将al寄存器中的值清零,然后交换al和mutex的值。
- 交换后检测该线程的al寄存器中的值为1,那么线程申请锁成功。此时mutex为0。
- 此后的线程线程再次申请锁,无论怎么交换,al和mutex都是0,申请失败,挂起等待。
当线程释放锁时:
- 将mutex置为1,使得下一个线程交换后al可以为1。也就是将锁放回去。
- 唤醒等待mutex的线程,让他们继续竞争。
注意:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器的值为1了,而交换指令就只是一条汇编指令,一个线程要么执行了交换执行,要么没有执行交换质量,所以申请锁的过程时原子的。
- 线程释放锁时没有将自己的al寄存器清零,这不会影响,因为每次申请时都会先清零再交换。
- 每个线程都有自己的寄存器,而内存时是共享的。
可重入与线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全的问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现问题,那么称为可重入函数,否则成为不可重入函数。
注意:线程安全讨论的时线程执行代码时是否安全,重入讨论的是函数被重入进入。
常见的线程不安全情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发送变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全情况
- 线程对全局变量或静态百年来只有读的权限,没有写的权限,这些线程一般是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致接口的执行结果存在二义性
常见的不可重入情况
- 调用了malloc/free函数,因为malloc函数是全局链表来管理堆的
- 调用了I/O库函数,标准I/O很多实现都是以不可重入方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见的可重入情况
- 不使用全局变量或静态变量
- 不使用malloc或new开辟的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据由函数调用者来提高
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系
- 函数是可重入的,那么线程是安全的
- 函数是不可重入的,那么就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数不安全也不可重入
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,但是可重入函数一定是线程安全的
- 如果对临界资源访问上锁,那么函数是线程安全的,如果这个重入函数的所还释放就会产生死锁,那么就是不可重入的
锁的概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
单执行流的死锁
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么该执行流就会被挂起。因为第一次成功,第二次的话就肯定会失败,导致该线程被挂起,这样该进程也没机会释放锁,也不会被唤醒,已经是死锁了。
例如,在下面的代码,我们让主线程连续申请两次锁:
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex;
void* Routine(void* arg)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
pthread_exit((void*)0);
}
int main()
{
pthread_t tid;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, Routine, NULL);
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果如下:
可以看到,已经被挂起了。
阻塞是什么?
进程运行时时被CPU调度的,换句话说进程在调度时时需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueque),CPU在运行时就是从该队列中获取进程进行调度的。
在运行等待队列中的进程就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,都有各自对应的资源等待队列。
我们可以举一个例子:
- 此时某个进程被CPU调度,进程需要用到锁的资源,但是此时锁的资源被1其他进程使用。
- 此时该进程由R状态变为某种阻塞状态,并且该进程会被移除运行等待队列,被链接到等待锁的资源的等待队列,CPU继续调度运行等待队列中的下一个进程。
- 每一个需要用到锁的资源,都会被CPU转移到等待锁的队列中。
- 直到使用锁的进程运行完毕,放锁了,此时会CPU会从等待锁的队列中唤醒一个进程,将其重新链接到运行等待队列,等到CPU再次调度到该进程,就可以使用锁资源了。
死锁的必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
注意:这是四个必要条件,只有同时满足才可能产生死锁。
避免死锁
- 破坏死锁的四个必要条件任意一个。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
除此之外,还有一些避免死锁的算法,例如死锁检测算法和银行家算法。
线程同步
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
具体来说:
- 首先需要明确的是,单纯的加锁是会存在某写问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但是申请到锁之后啥也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
- 单纯的加锁是没错的,它能够保证一次只有一个线程进入临界区,但是没有高效的让每一个线程使用这份临界资源。
- 所以就需要增加一个规则,一个线程释放锁后,该线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列头部的线程,如果有十个线程,我们就能让十个线程按照某种次序进行临界资源的访问。
条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
条件变量通常需要配合互斥锁一起使用。
条件变量函数
条件变量的初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般为NULL即可。
返回值说明:
- 初始化成功返回0,失败返回错误码。
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用静态分配的方式初始化条件变量:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 销毁成功放回0,失败返回错误码。
注意:使用静态分配PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
等待条件变量满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 调用成功返回0,失败返回错误码。
唤醒等待
int pthread_cond_broadcast()pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
这两个函数区别如下:
- pthread_cond_signal函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 调用成功返回0,失败返回错误码。
现在来综合使用一下
我们让主线程创建三个新线程,让主线程控制这三个新线程活动。这三个新线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入时才唤醒一个等待线程,一直循环。
#include<pthread.h>
#include<stdio.h>
pthread_mutex_t mutex;
pthread_cond_t cond;
void* function(void* arg)
{
pthread_detach(pthread_self());
printf("%srun...\n",arg);
while(1)
{
pthread_cond_wait(&cond, &mutex);
printf("%s...running...\n",arg);
}
}
int main()
{
pthread_t t1,t2,t3;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond,NULL);
pthread_create(&t1,NULL,function,(void*)"thread 1");
pthread_create(&t2,NULL,function,(void*)"thread 2");
pthread_create(&t3,NULL,function,(void*)"thread 3");
while(1)
{
getchar();
pthread_cond_signal(&cond);
}
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
运行结果如下:
为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题
- 所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
- 当该线程被唤醒时,该线程就会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁
综上所述:pthread_cond_wait函数有两个功能:
- 让线程在特定的条件变量下等待
- 让线程释放对应的互斥锁
条件变量使用规范
等待条件变量:
pthread_mutex_lock(&mutex);
while(条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程:
pthread_mutex_lock(&mutex);
while(设置条件为真)
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);