文章目录
- 1.线程互斥
- 1.1.线程间互斥的相关概念
- 1.2互斥量
- 1.3互斥量接口
- 1.4互斥量实现原理
- 2.可重入VS线程安全
- 3.常见锁概念
- 3.1死锁
- 3.2常见死锁情况
- 3.2.1情况一:忘记释放锁
- 3.2.2情况二:线程重复申请锁
- 3.2.3情况三:双线程多锁申请
- 3.3锁的相关概念
- 4.Linux线程同步
- 4.1同步概念与竞态条件
- 4.2条件变量
- 4.3条件变量接口
- 4.4为什么pthread_cond_wait需要互斥量
1.线程互斥
1.1.线程间互斥的相关概念
- 临界资源:多线程执行流共享的资源叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码段,叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:一个操作要么执行完,要么不执行,不会被任何调度机制打断的操作
多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。但是如果不对资源进行保护,那么就可能出现意料之外的逻辑错误。
比如下面的抢票程序
#define NUM 5
int tickets=200; //总票数
void* routine(void* arg){
while(1){
if(tickets>0){
usleep(30000); //模拟抢票的过程
printf("线程:%d抢票成功,票的序号是%d\n",pthread_self(),tickets);
tickets--;
}
else{
break;
}
}
return nullptr;
}
int main(){
pthread_t tid[NUM];
for(int i=0;i<NUM;i++){
pthread_create(&tid[i],nullptr,routine,nullptr);
}
for(int i=0;i<NUM;i++){
pthread_join(tid[i],nullptr);
}
}
逻辑上似乎没用错误,但是代码的结果却出现了票数为负数的情况。
为什么会出现票数为负数的情况?
tickets本身是一个全局变量,是被所有线程所共享的,也就是一个临界资源。在代码的运行过程中,出现了以下的情况:
- if语句判断条件为真以后,代码可能被切换到其他线程。
- usleep模拟抢票的过程,在这个过程中,可能其他线程也进入该代码段。
- tickets–不是一个原子操作
进行 - - 操作的时候,不是原子的(安全的)。它对应了三条汇编指令:
- load:将共享变量ticket从内存加载到寄存器中
- update: 更新寄存器里面的值,执行-1操作
- store:将新值,从寄存器写回共享变量ticket的内存地址
既然–操作需要三个步骤才能完成,那么thread1可能在任何一个步骤被切走。假设此时thread1读取到的值为1000,而当thread1被切走时,寄存器中的1000被保存到了thread1的上下文数据中。
假设此时thread2被调度了,由于thread1只进行了--
操作的第一步,因此thread2此时看到tickets的值还是1000,而系统给thread2的时间片可能较多,导致thread2一次性执行了100次--
才被切走,最终tickets由1000减到了900。
此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行--
操作的第二步和第三步,最终将999写回内存。
最后的结果是”凭空多出了100张票"
所以我们可以总结:从内核态返回用户态的时候,OS中线程间进行切换,极有可能出现数据交叉操作,而导致数据不一致的问题。
1.2互斥量
要解决上面的问题,就需要保证访问临界资源的过程是原子操作。多个线程访问临界区时需要做到下面三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
1.3互斥量接口
初始化互斥量
函数原型:
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);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
**改进上面的抢票系统:**为临界区加上锁
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
#define NUM 5
int tickets=200; //总票数
pthread_mutex_t mutex;
void* routine(void* arg){
while(1){
pthread_mutex_lock(&mutex);
if(tickets>0){
usleep(30000); //模拟抢票的过程
printf("线程:%d抢票成功,票的序号是%d\n",pthread_self(),tickets);
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main(){
pthread_t tid[NUM];
for(int i=0;i<NUM;i++){
pthread_create(&tid[i],nullptr,routine,nullptr);
}
for(int i=0;i<NUM;i++){
pthread_join(tid[i],nullptr);
}
}
1.4互斥量实现原理
首先的一个问题:在加锁的临界区,线程可以被切换吗?
需要理解的是:加锁!=不能被切换;
在加锁的临界区,线程可以被切换。加锁解锁等操作对应的也是代码。线程在任意代码段都可能被切换。
但是线程加锁是原子的,即使线程被切走后,锁也保证了绝对不会有其他线程进入临界区。想要访问临界区,线程就必须要抱锁。一旦一个线程抱锁,该线程就不会担心该锁对应的资源因线程切换而出问题。
**总结:**加锁的线程可以被切换,但是其他线程无法进入临界区。
锁是否需要被保护?
被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。锁需要被多个线程访问,因此锁也是临界资源。
锁是临界资源,那么锁也需要被保护,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际是自己保护自己,有关锁的操作都是原子的,那么锁就是安全的。
加锁和解锁具有原子性,如何实现?
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换。
- 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,
一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
下面是lock和unlock的伪代码
lock的执行过程
mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的数据设置为0
- 交换内存mutex和al中的数据,xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断寄存器al的值是否大于0,如果大于0表示申请锁成功;否则就挂起等待。
如果当前thread1申请锁。xchb交换内存mutex和al寄存器的数据后,寄存器的数据为1,mutex的数据为0。此时thread1申请锁成功。
如果thread2申请锁。由于当前内存mutex的值是0,和al寄存器交换后,两者的数据都是0。因此thread2申请锁失败,线程挂起。
当在申请锁的过程中,线程被切换,如何保证锁的原子性?
首先需要有以下的认识:
- 凡是在寄存器中的数据,都是线程的上下文数据。
- 当某个线程需要使用寄存器时,必须等待正式使用寄存器的线程被CPU剥离,并保存寄存器中的上下文数据到线程栈中。
比如:如果thread1刚刚交换完内存mutex和寄存器al的数据,但是还没有来得及判断是否申请锁成功就被切走。
此时thread2再去申请锁失败。
当thread1被切回来时,上下文数据恢复到寄存器中,此时al寄存器的数据为1。thread1申请锁成功。
解锁的过程和加锁的过程相同,不同的是初始向al寄存器填充的数据是1
2.可重入VS线程安全
基本概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题 。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没用写入的权限
- 类或者接口对于线程来说都是原子操作
- 多线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
常见的可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
- 函数是可重入的,那么就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
3.常见锁概念
3.1死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态 。
常见死锁情况
3.2常见死锁情况
定义:死锁是值两个或者两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象。 若无外力作用,它们都将无法推进下去,此时称系统处于死锁状态或系统产生了死锁,而这些永远在互相等待的进程称为死锁进程。
例如,如果线程A锁住了记录1并等待记录2,而线程B锁住了记录2并等待记录1,这样两个线程就发生了死锁现象。
3.2.1情况一:忘记释放锁
函数调用先于释放锁,锁还没来得及释放进程就退出。
std::mutex m;
void func()
{
//进程报锁
m.lock();
if(1)
{
return ;
}
m.unlock();
}
3.2.2情况二:线程重复申请锁
mutex _mutex;
void func()
{
_mutex.lock();
//do somrthing....
//重复申请锁,第二次申请处于阻塞等待状态
_mutex.lock();
_mutex.unlock();
}
3.2.3情况三:双线程多锁申请
下面的例子中,process1和process2先对_mutex1和_mutex2上锁。
然后由于 _ mutex2已经上锁,process1会一直阻塞等待 _ mutex2;同样,由于 _ mutex1上锁,process2会一直阻塞等待 _mutex1
mutex _mutex1;
mutex _mutex2;
void process1() {
_mutex1.lock();
_mutex2.lock();
//do something1...
_mutex2.unlock();
_mutex1.unlock();
}
void process2() {
_mutex2.lock();
_mutex1.lock();
//do something2...
_mutex1.unlock();
_mutex2.unlock();
}
3.3锁的相关概念
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
注意:四个条件都要满足才会出现死锁的情况
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致(比如上面的情况三)
- 避免锁未释放的情况
- 资源一次性分配,减少锁的使用。
避免死锁的算法
- 死锁检测算法
- 银行家算法
4.Linux线程同步
4.1同步概念与竞态条件
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
- 首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
- 为了让每个线程都有机会申请到锁,现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程
线程互斥,在一定的场景下是不合理的。可能出现饥饿现象:一个执行流长时间得不到某种资源。
例如:在互斥量的时候,我们解决了共享变量的问题,但是我们在创建锁的过程中,可能会存在一个优先级高的线程,每次都是它优先申请到锁。该线程一直在申请锁、检测(进行抢票)、释放锁,导致其他线程没有机会得到这把锁(俗称饥饿问题)。这样错了嘛?显然没有,但是这样安排是不合理的。
排队的本质:让线程在获取锁安全的前提下,按照某种顺序进行申请和释放锁,让每个线程都有机会申请到锁,这就叫做同步。
4.2条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要有两个动作
- 一个线程等待条件变量的就绪而被挂起
- 另一个线程使条件成立后唤醒等待的线程。
4.3条件变量接口
初始化条件变量
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)
注意:使用PTHREAD_COND_INITIALIZER
初始化的条件变量不需要销毁。
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
- cond:要在这个条件变量上等待
- mutex:需要释放的锁
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
两个接口的区别:
- pthread_cond_signal函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast函数用于唤醒等待队列中的全部线程
实例:使用pthread_cond_signal依次唤醒线程
#include <functional>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond;
pthread_mutex_t mutex;
void* waitcommand(void* arg){
char* s=(char*)arg;
while(1){
pthread_cond_wait(&cond,&mutex);
int cnt=3;
while (cnt--)
{
printf("%s is running...\n",s);
sleep(1);
}
}
return nullptr;
}
int main(){
pthread_cond_init(&cond,nullptr);
pthread_mutex_init(&mutex,nullptr);
pthread_t tid[3];
for(int i=0;i<3;i++)
{
char* s=(char*)malloc(32);
sprintf(s,"thread %d",i);
pthread_create(&tid[i],nullptr,waitcommand,(void*)s);
}
//循环的唤醒执行线程
while(1){
pthread_cond_signal(&cond);
sleep(3);
}
for(int i=0;i<3;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
使用pthread_cond_broadcast唤醒所有线程
#include <functional>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond;
pthread_mutex_t mutex;
void* waitcommand(void* arg){
char* s=(char*)arg;
while(1){
pthread_cond_wait(&cond,&mutex);
int cnt=3;
while (cnt--)
{
printf("%s is running...\n",s);
sleep(1);
}
}
return nullptr;
}
int main(){
pthread_cond_init(&cond,nullptr);
pthread_mutex_init(&mutex,nullptr);
pthread_t tid[3];
for(int i=0;i<3;i++)
{
char* s=(char*)malloc(32);
sprintf(s,"thread %d",i);
pthread_create(&tid[i],nullptr,waitcommand,(void*)s);
}
//循环的唤醒执行线程
while(1){
pthread_cond_broadcast(&cond);
sleep(3);
}
for(int i=0;i<3;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
4.4为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以,调用pthread_cond_wait需要传递互斥量。当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
- 当该线程被唤醒时,该线程会接着执行临界区内的代码,pthread_cond_wait在接收到条件变量时,被唤醒时,会自动的获得锁。
而实际进入pthread_cond_wait
函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait
函数返回时再将条件变量改为1,并将对应的互斥锁加锁。
条件变量的使用规范
等待条件
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);