前言
本文是对于线程互斥的知识总结
一、线程互斥
问题
我们先看下面代码。
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
const int numbers = 3;
int ticket = 10000;
void *threadRoutine(void *args)
{
std::string name = static_cast<char *>(args);
while (true)
{
if (ticket > 0)
{
usleep(1000);
std::cout << name << "get a ticket: " << ticket << std::endl;
ticket--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
std::vector<pthread_t> tds;
for (int i = 0; i < numbers; ++i)
{
pthread_t td;
char buff[64];
snprintf(buff, sizeof(buff), "thread-%d", i);
pthread_create(&td, nullptr, threadRoutine, (void *)buff);
usleep(1000);
tds.push_back(td);
}
pthread_join(tds[0], nullptr);
pthread_join(tds[1], nullptr);
pthread_join(tds[2], nullptr);
return 0;
}
该代码创建三个线程-1,线程-2,线程-3,去抢夺ticket资源,当ticket从10000依次减到0时,三个线程退出。那该代码运行结果是什么呢?ticket最后会是0吗?
显而易见ticket最后是-1!这是为什么?三个线程在ticket为0时,不应该退出吗,ticket为什么会是-1?更奇怪的还是下图
ticket值尽然有相同的情况,ticket的值不应该依次递减吗?
解释
先不要着急,我们先来明确几个概念
-
临界资源:多线程执行流共享的资源叫做临界资源(如上面代码中的ticket全局变量)
-
临界区:每个线程内部,访问临界资源的代码,就叫做临界区(如下图红框部分的代码)
-
互斥:任何时候,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源其保护作用
-
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
在了解上面四个概念后,我们还需要了解,在C++/C中前置++ or 后置++,判断操作是原子的吗?
显然这些操作都不是原子的,判断在汇编指令是要先判断,再依据判断结果跳转执行流,后置–是先从内存中读取变量的值放到寄存器中,再对寄存器中的值-1, 最后将寄存器中的值放回变量中。
现在知道了上述知识,我们可以来理解为什么ticket会不变和变为-1。
我们现将这些汇编指令分别表明为步骤1,步骤2…
我们先来解释为什么ticket的值可能不变。
当线程thread-0执行步骤3,将内存中icket的值34,读取到寄存器中;线程thread-0被线程thread-1切换,寄存器中的34,会作为线程thread-0的上下文数据,被线程thread-0保留,此时线程thread-1执行步骤3,将内存中icket的值34,读取到寄存器中;线程thread-1被线程thread-2切换,同理寄存器中的34作为线程thread-1的上下文数据被保留;此时线程thread-2执行步骤3,将内存中icket的值34,读取到寄存器中,再执行步骤4,将寄存器中的34变为33,在执行步骤5,将寄存器中的33放回到内存中ticket处,此时ticket = 33;线程thread-2打印ticket的值;线程thread-2被线程thread-0切换,要恢复线程thread-0的上下文数据,寄存器中存储的是34,线程thread-0在执行步骤4,将寄存器中的34变为33,在执行步骤5,将寄存器中的33拷贝到内存中ticket处,ticket = 33,线程thread-0打印ticket的值为33;同理线程thread-2最后也会打印ticket的值为33。这就是线程0,1,2都会打印33的原因。
明白了为什么ticket会打印3次33。那ticket为什么会变为-1,就好理解了。
我们假定此时ticket = 1;线程thread-0执行步骤1( 1 > 0)判断为真,被线程thread-1切换,判断结果作为线程thread-0的上下文数据被保存;线程thread-1也指向步骤1( 1 > 0)判断为真,在执行步骤2,步骤3,步骤4,步骤5,从内存中读取ticket的值(ticket = 1),再在寄存器内将1 -> 0,再将0拷贝到内存ticket处(ticket= 0),线程tithread-1被线程thread-2切换,线程thread-2执行步骤1( 0 > 0)判断为假,结束循环;线程thread-2被线程thread-0切换,线程thread-0执行步骤2,步骤3,步骤4,步骤5,从内存中读取ticket的值(ticket = 0),再在寄存器内将0 -> -1,再将-1拷贝到内存中ticket处(ticket = -1)。这就是ticket为什么会是-1的原因。
这就是我们多线程访问共享数据而导致数据不一致问题,那如何解决呢?
要解决以上数据不一致问题,就要保证只能有一个执行流在临界区执行代码。而这就是锁,linux上提供的这把锁叫做互斥量。
互斥量的接口
初始化互斥量的两种方法:
-
静态分配
-
动态分配
mutex:要初始化的互斥量;
attr:用于设置互斥锁的属性(传递 NULL 作为 attr 参数的值,那么互斥锁会使用默认的属性进行初始化。)
销毁互斥量
如果成功销毁互斥锁,则返回0;
如果发生错误,则返回错误码(EBUSY:在尝试销毁一个正在使用的互斥锁时,通常会返回这个错误;EINVAL:传递个函数的mutex指针无效)
需要注意的是:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
加锁成功,返回0;加锁失败,返回错误码(如EDEADLK, EINVAL, EBUSY)
当互斥锁已经被其它线程锁定时,调用pthread_mutex_lock的线程通常会被阻塞,直到互斥锁被解锁。如果不想线程申请锁失败被阻塞,可以使用pthread_mutex_trylock函数。
加锁成功,返回0; 加锁失败,返回错误码(如EBUSY:当前互斥锁被其他线程锁定)
pthread_mutex_trylock不会让调用线程在互斥锁不可用时进入阻塞状态,这使得可以用轮询的方式来申请锁。
成功解除锁,返回0;解除锁失败,返回错误码(如传递给函数的mutex指针无效,解除一个未由当前线程锁定的锁)
现在我们对开始的代码进行改进
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
const int numbers = 3;
// 定义锁
pthread_mutex_t mutex = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
int ticket = 10000;
void *threadRoutine(void *args)
{
std::string name = static_cast<char *>(args);
while (true)
{
//加锁
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
std::cout << name << "get a ticket: " << ticket << std::endl;
ticket--;
// 解锁
pthread_mutex_unlock(&mutex);
}
else
{
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
std::vector<pthread_t> tds;
for (int i = 0; i < numbers; ++i)
{
pthread_t td;
char buff[64];
snprintf(buff, sizeof(buff), "thread-%d", i);
pthread_create(&td, nullptr, threadRoutine, (void *)buff);
usleep(1000);
tds.push_back(td);
}
pthread_join(tds[0], nullptr);
pthread_join(tds[1], nullptr);
pthread_join(tds[2], nullptr);
return 0;
}
现在ticket == 1时,程序退出。
二、加锁的原理
pthread_mutex_t的结构如下:
我们先简单的将mutex这个结构体理解为一个int,将mutex = 1视为锁资源空闲,将mutex = 0视为锁资源已经被占用。
当一个线程要加锁时,其要先执行movb指令,将0移动到%al寄存器中(表示未持有锁),再执行xchgb指令,将mutex中的值与%al寄存器交换,如果%al寄存器中的值大于0,表示加锁成功,可以执行临界区代码;%al寄存器的值小于等于0,表示加锁失败,要挂起等待其它持有该锁的线程释放锁,再执行goto语句,重新申请锁。
当一个线程释放锁时,其要执行movb指令,将1移动到mutex处(表示锁资源空闲),再唤醒挂起等待的线程;
看了上面内容,我们可能还有点疑惑,为什么pthread_mutex_lock函数就是原子的呢?下面让我们已两个线程1,2申请锁为列,来理解。
我们假定当前有一个空闲的锁mutex,线程1执行movb指令,将0移动到%al寄存器中,线程1被线程2切换,线程1保存%al寄存器中的内容0;线程2执行movb指令,将0移动到%al寄存器中,再执行xchgb指令,将mutex的值与%al寄存器中的值交换(mutex = 0, %al = 1),线程2被线程1切换,%al寄存器中的内容1作为线程2的上下文数据被保存,线程1回复上下文数据(%al = 0),再执行xchgb指令,交换mutex和%al寄存器的内容(%al = 0, mutex = 0),再执行if判断为假,线程1挂起等待;线程1被线程2切换,回复上下文数据(%al = 1),线程2执行if判断为真,线程2执行临界区代码。
现在我们就可以理解,为什么pthread_mutex_lock函数是原子的了。
三、 死锁
死锁是指在一组进程中各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
如下图所示:
线程A,B只有同时拥有锁1,锁2才能访问临界区代码,此时线程A拥有lock1,申请lock2,线程B拥有lock2,申请lock1,线程A,B都会因为所申请的资源被其它线程所占有而等待,这就是死锁。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获取资源保持不放
- 不剥夺条件:一个执行流已获取的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间现成一个头尾相接的循环等待资源的关系(如上图,线程A需要线程B持有的lock2,线程B需要线程A持有的lock1,线程A,B形成头尾相接的循环等待)
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致(如要求线程A,B都因先申请lock1,再申请lock2)
- 避免锁未释放的场景(如将pthread_mutex_unlock误写成pthread_mutex_lock,从而导致只有一个线程造成的死锁)
- 资源一次性分配
总结
以上就是我对于线程互斥的总结。