- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
比如下面这段代码中,g_val作为一个全局变量,主线程和新创建出的线程都能访问到它,被多个执行流所共享,所以是临界资源。
新线程中g_val- -和主线程中打印了g_val ,都访问了临界资源的代码,所以就叫做临界区。
//code1
#include
#include
#include
using namespace std;
int g_val = 10000;//临界资源
void* fun(void* args)
{
while(1)
{
g_val--;//临界区
sleep(1);
}
pthread_exit((void*) 0);
}
int main()
{
pthread_t t1;
pthread_create(&t1,nullptr,fun,nullptr);
while(1)
{
cout << "g_val = " << g_val << endl;//临界区
sleep(1);
}
pthread_join(t1,nullptr);
return 0;
}
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
在多线程的情况下,如果任由各个线程并发对临界资源进行修改的操作,就有可能导致临界资源不能达到我们预期的要求。
比如下面这段代码演示:
//code2
#include
#include
#include
using namespace std;
int g_val = 10;
void* fun(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
if(g_val > 0)
{
sleep(1);
g_val--;
cout << name << ": " << g_val << endl;
}
else
{
break;
}
}
pthread_exit((void*) 0);
}
int main()
{
pthread_t t1;
pthread_t t2;
pthread_create(&t1,nullptr,fun,(void*)"thread-1");
pthread_create(&t2,nullptr,fun,(void*)"thread-2");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
return 0;
}
code2 演示了两个线程对一个全局变量g_val进行 - - 操作,每次打印g_val的值。按照我们的预期,它应该是打印到0程序就结束了。然而并不是。g_val的值出现的负数的情况。
出现负数的原因:
前面我们讲到,原子性是不会被操作系统的任何调度打断的,也就说原子操作只有两种状态,要么完成了,要么未完成。
为什么g_val不是原子操作?
g_val - - 在C++语言层面上虽然只是一条语句,但是汇编语言才是计算机执行的语言,而对变量进行 - - ,实际需要以下三个步骤
- load:将共享变量tickets从内存加载到寄存器中。
- update:更新寄存器里面的值,执行-1操作。
- store:将新值从寄存器写回共享变量tickets的内存地址
**- -**操作的汇编代码如下:
既然- - 操作实际需要三个步骤才能完成,那么就有可能在thread-1把g_val的值读进CPU寄存器然后进行 - 1操作的时候就被CPU调度切走了,并没有把值写回到内存。假设此时thread-1读取到g_val的值是1,-1操作后,当thread-1被切走时,寄存器中的数据就叫做thread-1的上下文信息,thread-1需要将它保存起来,之后就挂起了,等待下一次调度。
假设此时CPU调度了thread-2,thread-2 判断此时g_val还是 1 ,所以进入if语句代码块里面,当还没开始进行 - - 操作时,如果因为时间片比较短,此时又切换到了thread-1,此时thread-1恢复上下文信息到CPU寄存器,会接着执行上一次还没完成的指令,于是就把0写回到了内存。
此时thread-1如果还未被切走,因为判断都g_val == 0 不满足 if 条件了,所以进入else,thread-1 结束。
重要的地方来了,此时CPU调度切回thread-2,由于thread是上次是已经满了if条件的,但还没开始- - 操作,所以才正式开始- - 操作,从内存中读数据到寄存器,是0,然后-1,最后把-1写回到内存。所以就会出现g_val为-1的情况。
出现负数这种情况只是线程不安全的情况之一,由于- - 操作不是原子的,在三步汇编指令中任意一步都可能被切走,也会出现其他的情况。
比如当thread-1的g_val的值为10时,执行完- - 操作汇编指令第二步把g_val变为9后,就被切走了,并没有写回到内存,thread-2被调度了,thread-2一直将g_val的值减到1,并写回到内存。此时thread-1回来了,接着执行上次的步骤,把9写回到内存,此时g_val又变为9了,thread-2做的工作白费了。如果应用到现实业务中,会出现很严重的问题。
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
要解决以上问题,需要做到三点:
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
初始化互斥量
初始化互斥量有两种方法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
互斥量初始化成功返回0,失败返回错误码
销毁互斥量
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
mutex:需要销毁的互斥量
返回值说明:
互斥量销毁成功返回0,失败返回错误码。
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
参数说明:
mutex:需要加锁的互斥量。
返回值说明:
互斥量加锁/解锁成功返回0,失败返回错误码。
调用 pthread_ mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
使用示例:
//code3
#include
#include
#include
using namespace std;
int g_val = 10;
pthread_mutex_t mutex;//定义一把全局的锁
void* fun(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
pthread_mutex_lock(&mutex);//加锁
if(g_val > 0)
{
usleep(1000);
g_val--;
cout << name << ": " << g_val << endl;
pthread_mutex_unlock(&mutex);//解锁
}
else
{
pthread_mutex_unlock(&mutex);//解锁
break;
}
}
pthread_exit((void*) 0);
}
int main()
{
pthread_mutex_init(&mutex,nullptr);
pthread_t t1;
pthread_t t2;
pthread_create(&t1,nullptr,fun,(void*)"thread-1");
pthread_create(&t2,nullptr,fun,(void*)"thread-2");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
为什么加了锁就能体现出原子性?
引入互斥量后,当一个线程申请锁进入到临界区时,在其他线程看来,要么没有申请锁,要么锁已经释放了。只有这两种状态对其他线程才是有意义的,只关心什么时候自己才能拿到锁。
例如,图中线程1进入临界区后,在线程2,3,4看来,线程1要么没有申请锁,要么锁已经释放了,只关心自己什么时候才能拿到锁,如果检测到其他状态(如该锁已经被线程1拿到了),自己只能处于阻塞状态,等待下一次竞争锁。
此时对于线程2,3,4而言,它们就认为线程1的操作是原子的。
临界区内的线程可能进行线程切换吗?如果切换了会影响到当前锁吗?
临界区的线程是可能线程切换去执行其他任务的,但是即使该线程被切走,其他线程也无法进入临界区进行资源访问,我们可以看做该线程是拿着锁被切走的,锁没用释放,也就意外着其他线程没用机会申请到锁,也就无法进入临界区进行资源访问了。
上面定义的锁也是一个全局对象,意味着它也是一个临界资源,它需要被保护吗?
锁既然是临界资源,那么它就必须被保护。可是锁的创造初心就是为了保护临界资源,那么谁来保护锁?
锁实际上是自己保护自己的,因为申请锁的过程本身就是原子的,所以锁是线程安全的。
申请锁如何保证原子性?
下面我们来看看lock和unlock的伪代码:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
注意:
以下四个必要条件必须同时存在,才会造成死锁。
避免死锁,破坏死锁的四个必要条件之一即可。