- 线程互斥
- 互斥相关背景概念
- 互斥量mutex
- 互斥量接口
- 初始化互斥量函数
- 销毁互斥量
- 互斥量加锁
- 互斥量解锁
- 代码模拟
- 互斥量实现的逻辑
- 常见锁的概念
- 死锁
- 什么叫做阻塞?
- 产生死锁的四个必要条件
- 如何避免死锁
- Linux线程同步
- 同步概念与竞态条件
- 条件变量
- 条件变量函数
- 代码练习
- 条件变量使用规范
线程互斥
互斥相关背景概念
- 临界资源:多线程执行流共享的资源叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码区叫做临界区。
- 互斥:任何时刻,互斥保证只有一个执行流进去临界区进行访问临界资源,通常对临界资源起到了保护作用。
- 原子性:在Linux中,原子性指的是一个操作的不可分割性。具体来说,原子性是指一个操作在执行期间不会被其他操作中断的特性。在并发编程中,原子性非常重要,它确保多个线程或进程同时访问同一资源时的数据一致性;该操作只有两态,要么完成,要么未完成。
模拟实现临界区和临界资源
其实在多线程中,几乎我们访问到的临界区和临界资源较多,所以我们通过多线程就可以很简单的构造这么一个环境。如下代码:
int count = 0;
void* func(void* args)
{while(1){count++;sleep(1);}pthread_exit((void*)6);
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,func,nullptr);while(1){printf("我和多线程访问的是同一个全局变量count:%d\n",count);sleep(1);}pthread_join(tid,nullptr);return 0;
}
此时我们相当如实现了主线程和新线程之间的通信,其中全局变量const称之为临界资源,因为他被多个执行流共享,而主线程中的printf和新线程中的const++称之为临界区,因为这些代码对临界资源进行了访问。
互斥和原子性
当多个线程同时多一个临界资源进行访问修改的时候,那么此时就可能会导致不一致的问题,解决该问题的方案叫做互斥,互斥的作用就是保证任何时候只有一个执行流通过临界区进行对临界资源访问。
我们可以自定义实现一个抢票系统来验证的观察以下
int tickets = 1000;//总票数
void* func(void* args)
{const char* name = (char*)args;printf("begin:%s\n",name);while (1){if (tickets > 0){//以微妙为单位usleep(2000);//1秒 = 1000毫秒 ,1毫秒 = 1000微秒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 tid[4];//char str[128];for(int i = 0;i < 4;i++){//sprintf(str,"thread: %d",i);char* str=new char[128];memset(str,0,128);snprintf(str,128,"thread: %d",i+1);printf("name:%s\n",str);//这一块首先我们是将str的首地址传入到func中,虽然说线程栈是私有的,但是每个都指向str数组本身,所以最后一个线程name覆盖pthread_create(tid + i, NULL, func, (void*)str);}for(int i = 0;i < 4;++i){pthread_join(tid[i],NULL);}printf("回收线程成功\n");return 0;
}
我们发现,在末尾结束的时候,票是已经成了负数了,这就是多个线程抢占临界资源导致的结果,出现这个结果的原因是:
- 主要原因还是usleep,当我们等待的时候,其他进程已经拿上进行抢票了,当我们的usleep等待成功后在抢票时,这时如果没有票数了在抢就是负数。
--tickte
操作本身不是一个原子操作
可以通过互斥来解决上述问题
互斥量mutex
要解决上述抢票系统并发的情况我们需要做到以下的方式:
- 代码必须是互斥的:当一个线程进入临界区进行执行时,不允许其他的线程进入。
- 如果线程不在临界区内执行,那么该线程不能阻止其他线程进入临界区
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
我们通俗的将这种方式称作为上锁。
互斥量接口
初始化互斥量函数
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)时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时对互斥量进行上锁,但是没有竞争到互斥量,那么此时的
pthread_mutex_lock
就会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量解锁
互斥量解锁函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功后返回0,解锁失败后返回失败错误码
代码模拟
接下来,我们来整体模拟一下以上所有的接口,就通过抢票系统来进行
int tickets = 1000;//总票数
//pthread_mutex_t mutex;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//静态锁
void* func(void* args)
{const char* name = (char*)args;while (1){pthread_mutex_lock(&mutex);if (tickets > 0){usleep(20);printf("[%s] get a ticket, left: %d\n", name, --tickets);pthread_mutex_unlock(&mutex);}else{pthread_mutex_unlock(&mutex);break;}usleep(1000); //充当抢完一张票,后续动作}printf("%s quit!\n", name);pthread_exit((void*)0);
}int main()
{//初始化锁//pthread_mutex_init(&mutex,NULL);//动态锁pthread_t tid[4];//char str[128];for(int i = 0;i < 4;i++){//sprintf(str,"thread: %d",i);char* str = new char[128];memset(str,0,128);snprintf(str,128,"thread: %d",i+1);//这一块首先我们是将str的首地址传入到func中,虽然说线程栈是私有的,但是每个都指向str数组本身,所以最后一个线程name覆盖pthread_create(tid + i, NULL, func, (void*)str);}for(int i = 0;i < 4;++i){pthread_join(tid[i],NULL);}printf("回收线程成功\n");//销毁锁//pthread_mutex_destroy(&mutex);return 0;
}
注意:
- 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。
互斥量实现的逻辑
加锁后是怎么实现原子性的
当我们使用互斥量后,其他线程看待我们的态度就变为了这个线程是否上锁和未上锁。
我们通过一个图来理解一下,当线程1进行上锁时来了好多线程,这时他们共同抢占一个资源,当这个资源处于别线程1进行上锁的状态时,其他线程是不可以进入的,至于线程 1 在里面干啥都可以,只要它一直处于上锁状态就行。
此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。
临界区内可以进行线程切换吗?
临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
锁是否需要保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际是一个原子性的,我们只需要保证申请锁的过程是原子的就行,它可以自己保护自己
为什么说申请锁的操作是原子的
下面我们来看看lock和unlock的伪代码:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
- 然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
- 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
- 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
常见锁的概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
例如上图所示:如果筷子的数量有限,这时,如果每个人手上分别拿一个筷子,谁也不让谁,此时就产生了死锁。
单执行流可能产生死锁码?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
#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;
}
此时程序一直处于挂起的状态
用ps命令查看该进程时可以看到,该进程当前的状态是Sl+,其中的l实际上就是lock的意思,表示该进程当前处于一种死锁的状态。
什么叫做阻塞?
进程运行时被CPU调度,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列,CPU在运行时就是从该队列中获取进程进行调度的。
在运行等待队列中的进程本质上就是在等待CPU资源,只是因为锁被申请后,所有在申请同一把锁的资源全部被放到了等待队列中,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。
例如,当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
- 那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
- 此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
- 直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。
总结一下:
- 站在系统的角度,进程等待某种资源就是将当前进程放入了进程的等待队列中,这种情况可以称之为当前进程被挂起等待了。
- 站在用户的角度,当某个进程等待某种资源时,用户认为是进程卡的不动了,其实我们称之为应用阻塞了。
- 这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。
产生死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求和保持条件:一个执行流因请求锁被阻塞,而另一个已获得锁的一直不放。
- 不剥夺条件:一个执行流在获得锁的时候,不可以强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
需要注意的是:如果要产生死锁必须这四个条件同时满足才能产生死锁。
如何避免死锁
- 破坏产生锁的四个必要条件之一
- 加锁顺序一致
- 避免未释放锁的场景
- 资源一次性分配
Linux线程同步
同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效的避免饥饿问题,这种做法就叫同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞太条件。
- 饥饿问题:首先我们要知道,单纯的只是加锁也会存在某些问题。当我们给一个竞争力较强的一个线程上锁后,当他申请锁又释放锁后,由于它的竞争力强,就会再次申请到锁,这样重复操作,就会导致其他的线程出现饥饿问题。
- 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
- 现在我们加一个条件,就是当一个线程释放锁后,需要重新排队,排到等待队列的最后。
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
例如:食堂打饭排队规则。
条件变量
条件变量是利用线程共享的全局变量进行同步的一种机制,条件变量用来描述一个线程资源是否处于就绪的状态。(数据化描述)
条件变量主要有两个动作:
- 一个线程等待的条件变量成立而被挂起
- 另一个线程条件成立后唤醒等待的线程
条件变量通常和互斥锁一起使用。
条件变量函数
初始化条件变量
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:
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;
销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
需要注意的是,当我们使用的是静态分配时,条件初始化变量不可以销毁。
等待条件变量满足
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
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 <iostream>
#include <unistd.h>
using namespace std;
const int nums = 5;pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;void *active(void *args)
{string name = static_cast<const char *>(args);while (1){pthread_mutex_lock(&mutex);// pthread_cond_wait,调用的时候,会自动释放锁。//起到阻塞作用pthread_cond_wait(&cond, &mutex);cout << name << " 活动" << endl;pthread_mutex_unlock(&mutex);}
}int main()
{pthread_t tids[nums];for (int i = 0; i < nums; ++i){char *name = new char[32];snprintf(name, 32, "thread -- %d", i + 1);pthread_create(tids + i, nullptr, active, name);}cout << "开始等待" << endl;sleep(3);cout << "等待结束" << endl;while(1){cout << "唤醒等待队列的第一个线程" << endl;pthread_cond_signal(&cond);sleep(3);}for(int i = 0;i < nums;i++){pthread_join(tids[i],nullptr);}pthread_mutex_destroy(&mutex);pthread_cond_destroy(&cond);return 0;
}
我们可以从图中看出,我们的等待条件变量满足的作用就是起到了阻塞作用和有序。
总结一下:
- 等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
- 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
- pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。
条件变量使用规范
等待条件变量的代码
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);