进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。而多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。
我们用一个简单的例子来进行解释第三方资源(临界资源),我们定义一个全局变量count,新线程用来++操作,主线程用来读取操作,如下代码:
#include
#include
#include
//using namespace std;
int count = 0;
void* Routine(void* arg)
{
while(1)
{
count++;
sleep(1);
}
pthread_exit((void*)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while(1)
{
std::cout << "my count#" << count << std::endl;
sleep(1);
}
pthread_join(tid, NULL);
return 0;
}
我们可以知道,上述代码的count是临界资源,因为它被多个线程进行共享资源,而我们主线程当中的printf和新线程当中的count++两者都是临界区,是对临界资源进行访问。
在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
互斥和原子性最好的佐证就是春节抢票系统,这不是正好临近春节了吗,提前祝大家新年快乐!
其中的参数说明为:
记录票的剩余张数的变量定义为全局变量(临界资源)。
主线程main,创建4个新线程。
这四个新线程进行抢票,当票被抢完后这四个线程自动退出。
#include
#include
#include
using namespace std;
// 临界资源1000张票
int tickets = 1000;
void* Routine(void* arg)
{
const char* name = (char*)arg;
// 抢票
while(1)
{
if(tickets > 0)
{
// 抢票
usleep(10000);
printf("[%s] get a ticket...left tickets#%d\n", name, --tickets);
}
else
{
break;
}
}
cout << name << "exit..." << endl;
pthread_exit((void*)0);
}
int main()
{
pthread_t tid1, tid2, tid3, tid4;
// 创建四个新线程
pthread_create(&tid1, NULL, Routine, (void*)"thread 1");
pthread_create(&tid2, NULL, Routine, (void*)"thread 2");
pthread_create(&tid3, NULL, Routine, (void*)"thread 3");
pthread_create(&tid4, NULL, Routine, (void*)"thread 4");
// 退出
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
pthread_join(tid4, NULL);
return 0;
}
这打印结果与我们预期是不符合的,怎么线程2都已经抢完最后一张票了,怎么其他进程还要抢?抢出来了负数。
该代码中记录剩余票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及–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张票。
因此对一个变量进行–操作并不是原子的,虽然–tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编,相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作。
那么如果想要解决上面的抢票问题,我们就需要满足至少是互斥的条件,我们有如下的要求:
我们要想完成上面的要求,那么就需要一个锁,我们称这个锁为互斥量。
初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
返回值说明:
调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量的函数是pthread_mutex_destroy,函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
返回值说明:
特别注意:
互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
返回值说明:
调用pthread_mutex_lock时,可能会遇到以下情况:
互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
返回值说明:
我们既然有了这个互斥量和加锁解锁的概念,那么我们就可以进行下一步代码的书写,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。
代码如下:
#include
#include
#include
using namespace std;
// 锁
pthread_mutex_t mutex;
// 临界资源1000张票
int tickets = 1000;
void* Routine(void* arg)
{
const char* name = (char*)arg;
// 抢票
while(1)
{
// 加锁
pthread_mutex_lock(&mutex);
if(tickets > 0)
{
// 抢票
usleep(10000);
printf("[%s] get a ticket...left tickets#%d\n", name, --tickets);
// 解锁
pthread_mutex_unlock(&mutex);
}
else
{
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
cout << name << "exit..." << endl;
pthread_exit((void*)0);
}
int main()
{
// 锁的初始化
pthread_mutex_init(&mutex, NULL);
pthread_t tid1, tid2, tid3, tid4;
// 创建四个新线程
pthread_create(&tid1, NULL, Routine, (void*)"thread 1");
pthread_create(&tid2, NULL, Routine, (void*)"thread 2");
pthread_create(&tid3, NULL, Routine, (void*)"thread 3");
pthread_create(&tid4, NULL, Routine, (void*)"thread 4");
// 退出
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
pthread_join(tid4, NULL);
// 销毁锁
pthread_mutex_destroy(&mutex);
return 0;
}
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
我们看下面的图片,只有thread2线程加锁了,其他线程都没有加锁,那么只有等着thread2线程解锁以后才能对其他线程进行加锁。那么只有两种情况thread1/3/4能够进行加锁,thread2被释放解锁了或者thread2没有申请锁。
当线程1、3、4检测到其他状态时也就被阻塞了。
此时对于线程1、3、4而言,它们就认为线程2的整个操作过程是原子的。
临界区内的线程完全可以进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
操作系统的工作原理:
lock和unlock的伪代码:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
注意:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
问题:单执行流会有死锁情况吗?
当然了,假如我们这个单执行流申请锁申请了两次,第一次是申请成功了,而第二次申请锁的时候该锁已经被申请过了,是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。
在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。
当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
总结一下:
只有同时满足下面四种条件才会出现死锁:
首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
举个例子:
现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
返回值说明:
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
返回值说明:
销毁条件变量需要注意:
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
返回值说明:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
区别:
参数说明:
返回值说明:
#include
#include
#include
using namespace std;
pthread_mutex_t mutex;
pthread_cond_t cond;
void* Routine(void* arg)
{
// 进行阻塞,只有键盘输入才会被唤醒
pthread_detach(pthread_self());
const char* msg = (char*)arg;
cout << msg << "run###" << endl;
while(1)
{
// 进行等待阻塞
pthread_cond_wait(&cond, &mutex);
cout << msg << "end###gogogo" << endl;
}
}
int main()
{
pthread_t tid1, tid2, tid3;
// 初始化锁
pthread_mutex_init(&mutex, nullptr);
// 初始化条件变量
pthread_cond_init(&cond, nullptr);
// 主线程创建三个新线程
pthread_create(&tid1, nullptr, Routine, (void*)"thread 1");
pthread_create(&tid2, nullptr, Routine, (void*)"thread 2");
pthread_create(&tid3, nullptr, Routine, (void*)"thread 3");
// 当识别到有键盘输入的时候,才进行等待条件变量
while(1)
{
getchar();
pthread_cond_signal(&cond);
}
// 销毁锁
pthread_mutex_destroy(&mutex);
// 销毁初始化条件变量
pthread_cond_destroy(&cond);
return 0;
}
此时我们会发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。
当唤醒等待的接口函数换成pthread_cond_broadcast
,我们看下会出现什么情况:
此时我们每一次唤醒都会将所有在该条件变量下等待的线程进行唤醒,也就是每次都将这三个线程唤醒。
总结:
当我们进入临界区上锁后,如果发现条件不满足,那我们先解锁,然后在该条件变量下进行等待不就行了。
//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
但这是不可行的,因为解锁和等待不是原子操作,调用解锁之后,在调用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);