----------------------------目录--------------------------
前言
我们知道,对于线程来说,多个同属一个进程的线程间是共享地址空间的,也就是很多资源是共享的–这就导致了它的优点是通信方便,不需要类似进程间通信需要建立管道(临界资源)来看到同一份资源;但是也造成了一个缺点,就是缺乏访问控制。
因为一个线程的操作问题,很可能给其他进程造成不可控,或者引起崩溃、异常、逻辑不正确等现象–即线程安全问题!
那么接下来我们就线程的安全问题来进行讲解~
相关概念理解
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
临界资源和临界区
进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区(在我们写的代码中,不是所有的代码都是进行访问临界资源的)。
互斥和原子性
在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么不执行,要么执行完毕。
同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥+原子性的),让访问资源具有一定的顺序性!
针对上述概念,我们举个相关例子:
下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建五个新线程,让这五个新线程进行抢票,当票被抢完后这五个线程自动退出。
#include<iostream>
#include<string>
#include<pthread.h>
#include<unistd.h>
#include<cstdio>
#include<ctime>
#include<cstdlib>
using namespace std;
//抢票逻辑;1000张,5个线程同时在抢
//tickets就是所谓的临界资源
//为了让多个线程进行切换,线程什么时候可能进行切换:
//1.时间片到了;2.检测的时间(从内核态返回用户态的时候)
int tickets=1000;
void* PthreadRun(void *args)
{
int id=*(int*)args;
delete (int*)args;
while(true)
{
if(tickets>0)
{
usleep(1000);//1s=1000ms=1000us
cout<<"我是["<<id<<"] 我要抢的票是:" <<tickets<<endl;
tickets--;
//抢票
}
else
{
//没有票
break;
}
// cout<<tname<<"is running..."<<endl;
// sleep(1);
}
}
int main()
{
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
int *id=new int(i);
pthread_create(tid+i,nullptr,PthreadRun,id);
}
for (int i = 0; i <5; i++)
{
pthread_join(tid[i],nullptr);//第二个参数为退出码
}
return 0;
}
我们看到,抢票的过程中,票数变成了负数, 为什么会这样?
该代码中记录剩余票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及–tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。
剩余票数出现负数的原因:
- if语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
- ticket- -操作本身不是一个原子操作。
为什么ticket- -不是原子操作?
我们对一个变量进行- -,我们实际需要进行以下三个步骤:
- load:将共享变量tickets从内存加载到寄存器中。
- update:更新寄存器里面的值,执行-1操作。
- store:将新值从寄存器写回共享变量tickets的内存地址。
既然减减操作需要三个步骤才能完成,那么就有可能当thread1刚把tickets的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了。
假设此时thread2被调度了,由于thread1只进行了–操作的第一步,因此thread2此时看到tickets的值还是1000,而系统给thread2的时间片可能较多,导致thread2一次性执行了100次–才被切走,最终tickets由1000减到了900。
此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行- -操作的第二步和第三步,最终将999写回内存。
在上述过程中,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。
那么在这种非原子操作的情形下,就很有可能票数变成负的, 因此对一个变量进行- -操作并不是原子的.
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,就会带来一些问题。
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁来保证临界区内一次只有一个线程进入并执行(也就是进行加锁操作),Linux上提供的这把锁叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量的函数叫做pthread_mutex_init
函数的函数原型如下:
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;
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy
该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁
互斥量加锁的函数叫做pthread_mutex_lock
该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量解锁
互斥量解锁的函数叫做pthread_mutex_unlock
该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
举例:
我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <mutex>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
// 抢票逻辑,10000票,5线程同时再抢
//tickets是不是就是所谓的临界资源! tickets-- 是原子的吗?(是安全的吗?)
//为了让多个线程进行切换,线程什么时候可能切换(1. 时间片到了 2. 检测的时间点:从内核态返回用户态的时候)
//对临界区进行加锁
class Ticket{
private:
int tickets;
int lock; //1 lock--(申请锁) lock++(释放锁) if(lock > 0){lock--}
//pthread_mutex_t vs std::mutex
pthread_mutex_t mtx; //原生线程库,系统级别
// std::mutex mymtx; //C++ 语言级别带的锁
public:
Ticket():tickets(1000)
{
pthread_mutex_init(&mtx, nullptr);
}
bool GetTicket()//抢票
{
//static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
//bool变量是否是被所有线程共享的呢??不是的!它是局部变量,在栈上开辟,被线程私有。
bool res = true;
//我要访问临界资源的时候tickets, 需要先访问mtx,前提是所有线程必须得先看到它!
//那么锁本身,是不是也是临界资源!!!!
//你如何保证锁本身是安全的!!!
//原理:lock,unlock-> 是原子的,所以是安全的!(为甚么?)
//什么情况下一行代码是原子的:只有一行汇编的情况
//pthread_mutex_lock(&mtx); //申请两次锁--死锁
pthread_mutex_lock(&mtx);
// mymtx.lock();
//执行这部分代码的执行流就是互斥的,串行执行的!
if(tickets > 0){
usleep(1000); //1s == 1000ms 1ms = 1000us
std::cout << "我是[" << pthread_self() << "] 我要抢的票是: " << tickets << std::endl;
tickets--; //这里看起来就是一行C、C++代码
printf("");
//抢票
}
else{
printf("票已经被抢空了\n");
res = false;
}
pthread_mutex_unlock(&mtx);
// mymtx.unlock();
return res;
}
~Ticket()
{
pthread_mutex_destroy(&mtx);//释放锁
}
};
void *ThreadRoutine(void *args)
{
Ticket *t = (Ticket*)args;
//购票的时候,不能出现负数的情况
srand((long)time(nullptr));
while(true)
{
if(!t->GetTicket())
{
break;
}
}
}
int main()
{
Ticket *t = new Ticket();
pthread_t tid[5];
for(int i = 0; i < 5; i++){
int *id = new int(i);
pthread_create(tid+i, nullptr, ThreadRoutine, (void*)t);
}
for(int i = 0 ; i < 5; i++){
pthread_join(tid[i], nullptr);
}
return 0;
}
我们看到在加锁之后,不存在抢票异常的情况了(例如票数为负数)
注意:
- 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
互斥量实现原理
加锁后的原子性体现在哪里?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
临界区内的线程可能进行线程切换吗?
临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,线程被切走的时候,要进行上下文保护,而锁数据也是在上下文之中的。锁没有被释放意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
锁是否需要被保护?
被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
如何保证申请锁的过程是原子的?
- 上面我们已经说明了–和++操作不是原子操作,可能会导致数据不一致问题。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
- 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
下面我们来分析一下lock和unlock的伪代码:
-
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
-
先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
-
然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换,因为是一条指令,所以申请锁的操作可以看作是原子的。
-
最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
- 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
- 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
可重入VS线程安全
概念
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数是否被重入。
常见的线程不安全的情况
-
不保护共享变量的函数。
-
函数状态随着被调用,状态发生变化的函数。
-
返回指向静态变量指针的函数。
-
调用线程不安全函数的函数。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
常见的可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
什么叫做阻塞?
进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。
在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。
例如,当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
- 那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
- 此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
- 直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。
总结:
- 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了。
- 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。
死锁的四个必要条件
- 互斥条件: 一个资源每次只能被一个执行流使用。
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。
这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
避免死锁
- 破坏死锁的四个必要条件。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
Linux线程同步
同步概念与竞态条件
- 同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
- 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
解释一下同步的必要性:
准确来说单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
单纯的加锁能够保证在同一时间只有一个线程进入临界区,但它没有高效且公平的让每一个线程使用这份临界资源。
现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有多个线程,我们就能够让这多个线程按照这种规则公平且高效的进行临界资源的访问。
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,此后该线程就一直在进行申请锁和释放锁,直到临界区被写满。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
条件变量
为何引入条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变其状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。
也就是说:之前我们介绍了多线程并发访问共享数据时遇到的数据竞争问题,我们通过互斥锁保护共享数据,保证多线程对共享数据的访问同步有序。但如果一个线程需要等待一个互斥锁的释放,该线程通常需要轮询该互斥锁是否已被释放,我们也很难找到适当的轮训周期,如果轮询周期太短则太浪费CPU资源,如果轮询周期太长则可能互斥锁已被释放而该线程还在睡眠导致发生延误。
因此,我们引入条件变量的概念,条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述,
条件变量主要包括两个动作:
- 一个线程等待“条件变量的条件成立”而挂起;
- 另一个线程使“条件成立”而发出信号。
条件变量的使用总是和一个互斥锁结合在一起
条件变量函数
初始化条件变量
初始化条件变量的函数叫做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_INITIALIZER
初始化的条件变量不需要销毁。
等待条件变量满足
等待条件变量满足的函数叫做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,失败返回错误码。
举例:
下面我们创建一个master线程作为控制线程,四个worker线程作为被控制线程,这四个新线程创建后都在条件变量下进行等待,master线程每隔4秒通过pthread_cond_signal
唤醒在条件变量下等待的一个线程(等待队列里等待的第一个线程)
#include<iostream>
#include<string>
#include<pthread.h>
#include<unistd.h>
using namespace std;
pthread_mutex_t mtx;//锁
pthread_cond_t cond;//条件变量
//ctrl thread 线程控制 work thread ,让它定期运行
void *ctrl(void *args)
{
string name=(char*)args;
while(true)
{
//pthread_cond_signal:唤醒在条件变量下等待的一个线程,哪一个??
//在cond 等待队列里等待的第一个线程
cout<<"master say: begin work!"<<endl;
pthread_cond_signal(&cond);
//唤醒所有线程
//pthread_cond_broadcast(&cond);
sleep(4);
}
}
void *work(void *args)
{
int number=*(int*)args;
delete (int*)args;
while(true)
{
//此处我们的mutex不用,暂时这样,后面解释
pthread_cond_wait(&cond,&mtx);
cout<<"work: "<<number<<" is working..."<<endl;
cout<<"-----------------------------------"<<endl;
}
}
int main()
{
#define NUM 4
//初始化
pthread_mutex_init(&mtx,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_t master;
pthread_t worker[NUM];
pthread_create(&master,nullptr,ctrl,(void*)"boss");
for(int i=0;i<NUM;i++)
{
int *number=new int(i);
pthread_create(worker+i,nullptr,work,(void*)number);
}
for(int i=0;i<NUM;i++)
{
pthread_join(worker[i],nullptr);
}
pthread_join(master,nullptr);
//释放
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
此时我们会发现唤醒的这四个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程(等待队列里等待的第一个线程),当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。
如果我们想一次性唤醒等待队列中的全部线程,可以将代码中的pthread_cond_signal函数改为pthread_cond_broadcast函数。
此时我们每一次唤醒都会将所有在该条件变量下等待的线程进行唤醒。
为什么pthread_cond_wait需要互斥量
我们下面讨论一下
为什么pthread_cond_wait的api被设计为
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
而不是被设计为
int pthread_cond_wait(pthread_cond_t *cond);
我们可以理解这两种的区别:
pthread_cond_wait(cond, mutex)的功能有3个:
- 调用者线程首先释放mutex
- 然后阻塞,等待被别的线程唤醒
- 当调用者线程被唤醒后,调用者线程会再次获取mutex
pthread_cond_wait(cond)的功能只有1个:
- 调用者线程阻塞,等待被别的线程唤醒。
通常的应用场景下,当前线程执行pthread_cond_wait时,处于临界区访问共享资源,因此存在一个mutex与该临界区相关联(也就是说pthread_cond_wait总和一个互斥锁结合使用。在调用pthread_cond_wait前要先获取锁。),这是理解pthread_cond_wait带有mutex参数的关键。
- 当前线程执行pthread_cond_wait前,必然已经获得了和临界区相关联的mutex;执行pthread_cond_wait会阻塞,但是在进入阻塞状态前,必须释放已经获得的mutex,让其它线程能够进入临界区
- 当前线程执行pthread_cond_wait后,阻塞等待的条件满足,条件满足时会被唤醒;被唤醒后,仍然处于临界区,因此被唤醒后必须再次获得和临界区相关联的mutex
综上,调用pthread_cond_wait时,线程总是位于某个临界区,该临界区与mutex相关,pthread_cond_wait需要带有一个参数mutex,用于释放和再次获取mutex。
想进一步理解,可以看为什么pthread_cond_wait需要互斥锁mutex作为参数
这篇文章的案例进行理解。
– the End –
以上就是我分享的Linux线程安全相关内容,感谢阅读!
本文收录于专栏:Linux
关注作者,持续阅读作者的文章,学习更多知识!
https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343