临界资源和临界区
进程之间如果要进行通信需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式又很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。
而多线程的大部分资源都是共享的,线程之间进行通信不需要非那么大的劲去创建第三方资源。
而多线程的共享资源就叫做临界资源。在每个线程内部,访问共享资源的代码,就叫做临界区。
互斥和原子性
在多线程情况下,如果多个执行流都自顾自地对临界资源进行操作,那么此时就可能导致数据不一致的情况。解决该问题的方案就叫做互斥,互斥的作用就是:保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
例如,下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完之后这四个线程自动退出。
#include
#include
#include
int tickets = 1000;
void* ticketGet(void* arg)
{
const char* name = (char*)arg;
while (1)
{
if (tickets > 0)
{
usleep(10000);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
}
else
{
break;
}
}
printf("%s quit!\n", name);
pthread_exit(NULL);
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, ticketGet, (void*)"thread 1");
pthread_create(&t2, NULL, ticketGet, (void*)"thread 2");
pthread_create(&t3, NULL, ticketGet, (void*)"thread 3");
pthread_create(&t4, NULL, ticketGet, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
运行结果如下:
结果竟然出现了票数剩余为负数的情况!
该代码中记录票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0,打印剩余票数及- -tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。
剩余票数出现负数的原因:
为什么==- -tickets==不是原子操作?
我们对一个变量进行减减实际需要以下三个步骤:
在这时,假设thread2被调度,由于thread1只进行了减减操作的第一步,因此thread2看到的值还是1000,而系统给thread2的时间片可能较多,导致htread2一次性执行了一百次减减操作才被切走,最终由1000减到了900。
此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行减减操作的第二步和第三步,却只是将999写回内存了。
所以,最终的结果了,thread1抢了一张票,thread2抢了100张票,而此时票数却未999,也就是多出了100张票。
因此对一个变量进行减减操作并不是原子性的,虽然- -tickets看起来就是一行代码,但这行代码编译之后本质上是三行汇编,想换,对一个变量进行加加操作也需要对应的三个步骤,即加加操作也不是原子操作。
为了解决模拟抢票系统出现的时候,我们引入互斥量mutex。
要解决上述抢票系统的问题,需要解决三点:
要解决这些问题,本质上就是需要一把锁,Linux称这个锁为互斥量。
互斥量的初始化接口
返回值说明:
调用pthread_mutex_init初始化互斥量为动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
互斥量的销毁接口
返回值说明:
销毁互斥量需要注意:
互斥量的加锁接口
返回值说明:
调用pthread_mutex_lock,可能会有以下情况:
互斥量的解锁接口
返回值说明:
下面尝试使用互斥量和以上的接口函数
我们在上述简易抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当下出临界区的时候需要进行解锁,这样才能让其余要进入临界区的线程继续竞争锁。
#include
#include
#include
int tickets = 1000;
pthread_mutex_t mutex;
void* ticketGet(void* arg)
{
const char* name = (char*)arg;
while (1)
{
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(100);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
printf("%s quit!\n", name);
pthread_exit(NULL);
}
int main()
{
pthread_mutex_init(&mutex, NULL);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, ticketGet, (void*)"thread 1");
pthread_create(&t2, NULL, ticketGet, (void*)"thread 2");
pthread_create(&t3, NULL, ticketGet, (void*)"thread 3");
pthread_create(&t4, NULL, ticketGet, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果如下,这样就不会出现剩余票数为负数的情况了!
注意:
加锁后的原子性体现在哪里?
引入互斥量之后,当一个线程申请到锁进入临界区后,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因此只有这两种状态对其他线程才是有意义的。
对于线程2、3、4来言,它们认为线程1的整个操作是具有原子性的。
临界区内的资源能进行进程切换吗?
临界区内的资源完全可能进行线程切换,但即便该下线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行访问了。
其他想进入临界区进行资源访问的线程,必须等待该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进去临界区。
锁是否需要被保护?
我们说所有的线程在进入临界区之前都必须竞争式地申请锁,因此锁也是被多个执行流所共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,我们只需保证申请锁的过程是原子的,那么锁就是安全的。
如何保证申请锁的过程是原子的?
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因相互申请被其他进程所占用不会的资源而处于的一种永久等待状态。
单执行流可能产生死锁吗?
单执行流也可能会产生死锁,如果某一执行流中连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁的时候,因为该锁已经被申请过了,于是申请失败,导致被挂起知道该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。
#include
#include
pthread_mutex_t mutex;
void* routine(void* arg)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
pthread_exit(NULL);
}
int main()
{
pthread_t tid;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, routine, NULL);
pthread_join(tid, NULL);
pthread_mutex_destroy(&tid);
return 0;
}
运行代码,此时程序就处于一个被阻塞的状态
用ps命令可以看到,该进程当前的状态为Sl+,其中的l实际上就是lock的意思,表示当前进程处于一种死锁的状态。
所以,当一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
进程处于阻塞状态时,等待的不一定就是硬件资源,也有可能是软件资源,比如互斥锁。
注意:这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
避免死锁有以下几种方法:
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件:因为时序问题而造成的程序运行结果不一样的问题,我们称之为竞态条件。
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好地解决。
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
条件变量通常需要配合互斥锁一起使用。
条件变量的初始化
初始化条件变量的函数pthread_cond_init,该函数的函数原型如下:
参数说明:
返回值说明:
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件变量的销毁
返回值说明:
注意:PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
等待条件变量满足
返回值说明:
唤醒等待
参数说明:
返回值说明:
下面用代码对上面的函数接口进行演示
例如,下面我们用主线程创建三个新线程,让主线程这三个新线程活动。这三个创建后都在条件变量进行等待,直到主线程检测到键盘输入时才唤醒一个等待进程,如此进行下去。
#include
#include
#include
pthread_mutex_t mutex;
pthread_cond_t cond;
void* routine(void* arg)
{
pthread_detach(pthread_self());
std::cout << (char*)arg << " run..." << std::endl;
while (true)
{
pthread_cond_wait(&cond, &mutex);
std::cout << (char*)arg << "活动..." << std::endl;
}
}
int main()
{
pthread_t t1, t2, t3;
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_create(&t1, nullptr, routine, (void*)"thread 1");
pthread_create(&t2, nullptr, routine, (void*)"thread 2");
pthread_create(&t3, nullptr, routine, (void*)"thread 3");
while (true)
{
getchar();
pthread_cond_signal(&cond);
}
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
此时我们会发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。
如果我们想每次唤醒都将在该条件下等待的所有线程进行唤醒,可以将代码中的pthread_cond_signal函数改为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);