1.Linux线程同步
1.1.同步概念与线程饥饿问题
先来理解同步的概念
- 什么是线程同步
在一般情况下,创建一个线程是不能提高程序的执行效率的,所以要创建多个线程。但是多个线程同时运行的时候可能调用线程函数,在多个线程同时对同一个内存地址进行写入,由于CPU时间调度上的问题,写入数据会被多次的覆盖,所以就要使线程同步。
同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。
“同”字从字面上容易理解为一起动作,但其实不是,“同”字应是指协同、协助、互相配合。
如进程、线程同步,可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其它线程也不能调用这个方法。按照这个定义,其实绝大多数函数都是同步调用(例如sin, isdigit等)。但是一般而言,我们在说同步、异步的时候,特指那些需要其他部件协作或者需要一定时间完成的任务。例如Window API函数SendMessage。该函数发送一个消息给某个窗口,在对方处理完消息之前,这个函数不返回。当对方处理完毕以后,该函数才把消息处理函数所返回的LRESULT值返回给调用者。
- 同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
- 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
- 线程饥饿问题
首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
1.2.条件变量
我们怎么实现线程同步呢?这需要学习Linux的条件变量。
- 什么是条件变量?该不会真就是1个变量吧!!!
千万不要被误导了,条件变量可不是变量, 条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量通常需要配合互斥锁一起使用。
互斥量可以防止多个线程同时访问临界资源,而条件变量允许一个线程将某个临界资源的状态变化通知其他线程,在共享资源设定一个条件变量,如果共享资源条件不满足,则让线程到该条件变量下阻塞等待,当条件满足时,其他线程可以唤醒条件变量阻塞等待的线程。
在线程之间有一种情况:线程A需要某个条件才能继续往下执行,如果该条件不成立,此时线程A进行阻塞等待,当线程B运行后使该条件成立后,则唤醒该线程A继续往下执行。
在pthread库中,可以通过条件变量中,可以设定一个阻塞等待的条件,或者唤醒等待条件的线程。
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量是一种等待机制,每一个条件变量对应一个等待原因与等待队列。一般对于条件变量会有两种操作:
每个条件变量都有属于自己的一个等待队列
- wait操作 : 将自己阻塞在等待队列里,唤醒一个等待者或者开放锁的互斥访问
- singal 操作 : 唤醒一个等待的线程(等待队列为空的话什么也不做)
1.3.条件变量函数
1.3.1.初始化条件变量
POSIX提供了两种初始化条件变量的方法。
- 第一种方法
初始化条件变量的函数叫做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_init()初始化,并且参数attr为NULL。
1.3.2.销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
销毁条件变量需要注意:
- 使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
1.3.3.等待条件变量满足
条件变量就是为了与某个条件关联起来使用的,如果条件不满足,就等待(pthread_cond_wait
) ,或者等待一段有限的时间(pthread_cond_timedwait
) 。
POSIX提供了如下条件变量的等待接口:
函数描述:这两个函数都是让指定的条件变量进入等待状态,其工作机制是先解锁传入的互斥量,再让条件变量等待,从而使所在线程处于阻塞状态。这两个函数返回时,系统会确保该线程再次持有互斥量(加锁)。
两个函数的区别:
- pthread_cond_wait函数调用成功后,会一直阻塞等待,直到条件变量被唤醒。
- 而 pthread_cond_timedwait 函数只会等待指定的时间,时间到了之后,条件变量仍未被唤醒的话,会返回一个错误码ETIMEDOUT,该错误码定义在<errno.h>头文件。
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
函数调用成功返回0,失败返回错误码。
1.3.4.唤醒等待
上面说完了条件等待,接下来介绍条件变量的唤醒。
调用完条件变量等待函数的线程处于阻塞状态,若要被唤醒,必须是其他线程来唤醒。
唤醒等待的函数有以下两个:
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
必须在互斥锁的保护下使用相应的条件变量。否则对条件变量的解锁有可能发生在锁定条件变量之前,从而造成死锁。
pthread_cond_signal 负责唤醒等待在条件变量上的一个线程,如果有多个线程等待,是唤醒哪一个呢?Linux内核会为每个条件变量维护一个等待队列,调用了 pthread_cond_wait 或 pthread_cond_timedwait 的线程会按照调用时间先后添加到该队列中。pthread_cond_signal会唤醒该队列的第一个。
如果没有线程被阻塞在条件变量上,那么调用
pthread_cond_signal()
将没有作用。
pthread_cond_broadcast,就是同时唤醒等待在条件变量上的所有线程。前面说过,条件等待的两个函数返回时,系统会确保该线程再次持有互斥量(加锁),所有,这里被唤醒的所有线程都会去争夺互斥锁,没抢到的线程会继续等待,拿到锁后同样会从条件等待函数返回。所以,被唤醒的线程第一件事就是再次判断条件是否满足!
由于pthread_cond_broadcast函数唤醒所有阻塞在某个条件变量上的线程,这些线程被唤醒后将再次竞争相应的互斥锁,所以必须小心使用
pthread_cond_broadcast
函数。
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
使用示例:
我们先下面这样子的
#include <stdio.h>
#include <pthread.h>
#include<iostream>
#include<unistd.h>
using namespace std;
int cnt=0;
void* Count(void*args)
{
pthread_detach(pthread_self());//分离线程
long long number=(long long)args;
while(1)
{
cout<<"pthread: "<<number<<endl;
sleep(3);
}
}
int main()
{
for(long long i=0;i<5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
}
while(1)
sleep(1);
}
特别注意:64位平台下面,int类型是4字节,不能和void*指针类型(8字节)进行相互转换 ,所以这里使用long long
多个执行流向显示器打印,就是往文件里写入,多线程或多进程往同一个文件写入,这个文件就是一种临界资源,不加保护的话,非常容易出现信息干扰。
#include <stdio.h>
#include <pthread.h>
#include<iostream>
#include<unistd.h>
using namespace std;
int cnt=0;//全局变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁
void* Count(void*args)
{
pthread_detach(pthread_self());//分离线程
long long number=(long long)args;
while(1)
{
pthread_mutex_lock(&mutex);//加锁
//先不管临界资源的情况
cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;
pthread_mutex_unlock(&mutex);//解锁
sleep(1);
}
}
int main()
{
for(long long i=0;i<5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
}
while(1)
sleep(1);
}
我们给打印这条语句加了锁,打印出来的结果也自然不会混乱了
好了,我今天想说的主角可不是屏幕,而是我们的++操作
我们接下来用上我们的条件变量
#include <stdio.h>
#include <pthread.h>
#include<iostream>
#include<unistd.h>
using namespace std;
int cnt=0;//全局变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁
void* Count(void*args)
{
pthread_detach(pthread_self());//分离线程
long long number=(long long)args;
cout<<"pthread: "<<number<<" creat success !"<<endl;
while(1)
{
pthread_mutex_lock(&mutex);//加锁
pthread_cond_wait(&cond,&mutex);//先让自己这个线程去条件变量cond的等待队列
//为什么填在这里?pthread_cond_wait让线程等待的时候会自动释放掉
//先不管临界资源的情况
cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;
pthread_mutex_unlock(&mutex);//解锁
sleep(1);
}
}
int main()
{
for(long long i=0;i<5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
usleep(1000);
}
sleep(3);
cout<<"main thread ctrl begin:"<<endl;
while(1)
{
sleep(1);//每过1秒就唤醒1次
pthread_cond_signal(&cond);//唤醒在cond的等待队列的1个线程,默认都是第1个
cout<<"signal one thread..."<<endl;
}
}
此时我们会发现唤醒这4个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。
我们可以唤醒所有线程
#include <stdio.h>
#include <pthread.h>
#include<iostream>
#include<unistd.h>
using namespace std;
int cnt=0;//全局变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//互斥锁,不需要初始化和销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,不需要初始化和销毁
void* Count(void*args)
{
pthread_detach(pthread_self());//分离线程
long long number=(long long)args;
cout<<"pthread: "<<number<<" creat success !"<<endl;
while(1)
{
pthread_mutex_lock(&mutex);//加锁
pthread_cond_wait(&cond,&mutex);//先让别的线程去等待队列 //为什么填在这里?pthread_cond_wait让线程等待的时候会自动释放掉
//先不管临界资源的情况
cout<<"pthread: "<<number<<" ,cnt :"<<cnt++<<endl;
pthread_mutex_unlock(&mutex);//解锁
sleep(1);
}
}
int main()
{
for(long long i=0;i<5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
usleep(1000);
}
sleep(3);
cout<<"main thread ctrl begin:"<<endl;
while(1)
{
sleep(1);
pthread_cond_broadcast(&cond);//唤醒在cond的等待队列的1个线程,默认都是第1个
cout<<"signal one thread..."<<endl;
}
}
我为什么要让一个线程去休眠?
一定是临界资源没有就绪,没错,临界资源也是有状态的
你怎么知道临界资源是就绪还是不就绪的?你判断出来的!那判断是访问临界资源吗? 是的,必须是的
我们需要判断临界资源状态,就得访问临界资源,而我们的线程对临界资源是会修改的,这就注定了这个判断一定要在加锁和解锁之间,这样子别的线程就不能修改我们的临界资源,我们的判断结果也会是正确的
也就是必须是下面这种结构
void* Count(void*args) { while(1) { pthread_mutex_lock(&mutex);//加锁 pthread_cond_wait(&cond,&mutex);//判断资源情况, pthread_mutex_unlock(&mutex);//解锁 } }
这也是我们为什么需要互斥量的原因