Linux系统:线程互斥
- 线程互斥
- 互斥锁 mutex
- 互斥锁原理
- 常见的锁
- 死锁
- 自旋锁 spinlock
- 其它锁
线程互斥
讲解线程互斥
前,先看到一个抢票案例:
class customer
{
public:
int _ticket_num = 0;
pthread_t _tid;
string _name;
};
int g_ticket = 10000;
void* buyTicket(void* args)
{
customer* cust = (customer*)args;
while(true)
{
if(g_ticket > 0)
{
usleep(1000);
cout << cust->_name << " get ticket: " << g_ticket << endl;
g_ticket--;
cust->_ticket_num++;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
vector<customer> custs(5);
for(int i = 0; i < 5; i++)
{
custs[i]._name= "customer-" + to_string(i + 1);
pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
}
for(int i = 0; i < 5; i++)
{
pthread_join(custs[i]._tid, nullptr);
}
for(int i = 0; i < 5; i++)
{
cout << custs[i]._name << " get tickets: " << custs[i]._ticket_num << endl;
}
return 0;
}
这个案例比较复杂,我们的目标是:设定一个全局变量g_ticket
,然后派出五个线程来模拟顾客,进行抢票,每次抢票的时候g_ticket--
,直到g_ticket <= 0
,也就是票被抢光了,就停止抢票。
首先我封装了一个类class customer
:
class customer
{
public:
int _ticket_num = 0;
pthread_t _tid;
string _name;
};
其代表一个顾客,本质来说是线程模拟的顾客,_ticket_num
表示该顾客抢到的票数,_tid
表示这个线程的TID
,_name
则为该顾客的名字。
随后让线程去执行buyTicket
函数:
void* buyTicket(void* args)
{
customer* cust = (customer*)args;
while(true)
{
if(g_ticket > 0)
{
usleep(1000);
cout << cust->_name << " get ticket: " << g_ticket << endl;
g_ticket--;
cust->_ticket_num++;
}
else
{
break;
}
}
return nullptr;
}
一开始线程就进入while
循环,只要g_ticket > 0
就抢票,让g_ticket--
,cust->_ticket_num++;
,表示总票数减少,自己的票数加一。再输出cust->_name << " get ticket: " << g_ticket
,含义为:xxx 抢到了第 xxx 张票
。
主函数中:
int main()
{
vector<customer> custs(5);
for(int i = 0; i < 5; i++)
{
custs[i]._name= "customer-" + to_string(i + 1);
pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
}
for(int i = 0; i < 5; i++)
{
pthread_join(custs[i]._tid, nullptr);
}
for(int i = 0; i < 5; i++)
{
cout << custs[i]._name << " get tickets: " << custs[i]._ticket_num << endl;
}
return 0;
}
一开始用vector
创建了五个customer
,第一个for
循环将这些customer
进行初始化,给他们命名,并创建线程。此时线程就已经开始进行抢票了,随后主线程第二个for
循环等待这五个线程。最后一个for
循环输出每个线程抢到的票的数目。
输出结果:
奇怪的事情发生了,我们只有10000
张票,最后却抢出了10005
张票!最后几个线程,抢到了不存在的0,-1,-2,-3
号的票,为什么会多出五张票?
我简化一下模型:
如图所示,现在有两个线程customer-1
和customer-2
,它们共同争夺g_ticket
,g_ticket = 1
,也就是说只有一个人可以抢到票。它们都执行左侧的代码,只要g_ticket > 0
,就g_ticket--
减少一个票,然后cust->_ticket_num++
,表示自己的票数增加。
现在假设线程customer-1
先调度:
该线程先判断,发现g_ticket > 0
,于是进入第一个if
语句,进行g_ticket--
。但是g_ticket--
本质上是多条汇编语句,比如下面这样:
MOV eax, [0x1000] ; 读取 g_ticket 的值
DEC eax ; 减 1
MOV [0x1000], eax ; 将值写回 g_ticket
也许你看不懂这个指令,我简单讲解一下:第一行MOVE
的作用,是把内存中g_ticket
的数据拷贝到CPU
中,第二行是将g_ticket - 1
,第三行是减法后的结果拷贝回内存中的g_ticket
。
那么假设我们现在执行到汇编的第二条指令:
现在突然线程customer-1
的时间片结束了,要结束调度当前线程,去调度customer-2
了。请问当前内存中的g_ticket
被修改了吗?不还没有,这是下一条汇编的作用,于是CPU
保存当前线程customer-1
的上下文,切换调度customer-2
:
此时线程customer-2
也通过if (g_ticket > 0)
检测还有没有票,结果发现还有一张票,于是custome-2
也去抢这张票,执行g_ticket--
。
这下出问题了,刚刚我们的customer-1
已经抢了这张票,但是还没来得及把g_ticket
变成0
,此时customer-2
又进来抢了一次票。最后就会出现一张票被两个人抢到的问题!
也就是说,为什么最开始的案例中,会出现10005
张票,就是因为最后一张票,被五个线程同时抢到了!当g_ticket
已经被抢走时,由于没来得及g_ticket = 0
,导致后来的线程以为还有票。
我先引入一部分概念,方便大家理解后续知识:
临界资源
:以上案例中,g_ticket
是共享资源,多个线程共享。我们把这种资源称为临界资源
临界区
:访问临界资源的代码,叫做临界区。比如g_ticket--
就是临界区
代码,以为其访问了临界资源g_ticket
原子性
:表示一个操作对外表现只有两种状态:还没开始
和已经结束
我用刚才的案例帮助大家理解这个原子性
的概念:我们说g_ticket--
本质上会变成多条汇编语句,也就是说g_ticket--
是有过程的,而不是一瞬间完成的。
这就导致在我还没有完成g_ticket--
的时候,也就是在g_ticket--
过程中,被其他线程打断了。导致其它线程收到错误的信息,抢到不存在的票。
如果说一个线程抢到票后,g_ticket--
会立马执行完毕,下一个线程在访问这个g_ticket > 0
的时候,一定是在别人已经g_ticket--
完毕,而不是在g_ticket--
过程中,就可以避免这个问题。这就要求访问g_ticket
是原子性
的,也就是说在别的线程眼中,根本就不存在g_ticket--
的过程,要么你没有执行g_ticket--
,要么已经执行完毕。
临界区代码只要保证是原子性的,就可以避免这样线程之间错误的抢占资源相同资源的问题
那么要如何保证临界区
代码是原子性
的呢?此时就需要线程互斥
了!
线程互斥
指的是在多线程环境中,多个线程访问同一个共享资源时,只允许一个线程访问,其他线程必须等待,直到当前线程访问完成才能继续访问。
线程互斥,是通过锁
来实现的。
锁的规则如下:
- 代码必须要有互斥行为:当代码进入
临界区
时,不允许其它线程进入临界区
- 如果多个线程都想执行
临界区
代码,并且当前临界区
没有线程在执行代码,只允许一个线程进入临界区
- 线程不能阻止其他线程进入
临界区
简单来说就是:任何时候临界区都只能有一个线程执行!
互斥锁 mutex
互斥锁
是pthread
库提供的,英文名为mutex(互斥)
,需要头文件<pthread.h>
,先讲解互斥锁
的基本创建和销毁方法。
互斥锁的类型是pthread_mutex_t
,分为全局互斥锁
和局部互斥锁
,它们的创建方式不同。
全局mutex:
想要创建一个全局的互斥锁很简单,直接定义即可:
pthread_mutex_t xxx = PTHREAD_MUTEX_INITIALIZER;
这样就创建了一个名为xxx
的变量,类型是pthread_mutex_t
,即这个变量是一个互斥锁
,全局的互斥锁必须用宏PTHREAD_MUTEX_INITIALIZER
进行初始化!
另外,全局的互斥锁不需要手动销毁。
局部mutex:
局部的互斥锁是需要通过接口来初始化与销毁的,接口如下:
pthread_mutex_init:
pthread_mutex_init
函数用于初始化一个互斥锁
,函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
restrict mutex
:类型为pthread_mutex_t *
的指针,指向一个互斥锁变量,对其初始化restrict attr
:用于设定该互斥锁的属性,一般不用,设为空指针
即可
返回值:成功返回0
;失败返回错误码
pthread_mutex_destroy:
pthread_mutex_destroy
函数用于销毁一个互斥锁
,函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:类型为pthread_mutex_t *
的指针,指向一个互斥锁变量,销毁该锁
返回值:成功返回0
;失败返回错误码
创建好互斥锁后,就要使用这个锁,主要是两个操作:申请锁
和释放锁
。
三个函数的原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock
:用于申请锁,如果申请失败,就阻塞等待,直到申请到锁;如果申请成功,就执行临界区代码pthread_mutex_trylock
:用于申请锁,如果申请失败,直接返回,而不是等待;如果申请成功,就执行临界区代码pthread_mutex_unlock
:用于释放锁,表明自己已经访问完毕临界区,其他线程可以来访问了
这三个函数的参数都是pthread_mutex_t *mutex
,即指向互斥锁变量的指针,表示要操作哪一个互斥锁。
接下来我们修改一下最初的抢票代码,给它加锁,保证抢票g_ticket--
的原子性:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //全局互斥锁
void *buyTicket(void *args)
{
customer *cust = (customer *)args;
while (true)
{
pthread_mutex_lock(&mutex); // 加锁
if (g_ticket > 0)
{
usleep(1000);
cout << cust->_name << " get ticket: " << g_ticket << endl;
g_ticket--;
pthread_mutex_unlock(&mutex); // 解锁
cust->_ticket_num++;
}
else
{
pthread_mutex_unlock(&mutex); // 解锁
break;
}
}
return nullptr;
}
我在此使用的是全局的互斥锁,第一行pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
就是定义了一个全局的互斥锁,并对其初始化。
在访问临界区前,对mutex
加锁,在此我在if (g_ticket > 0)
前加锁,因为不仅仅是g_ticket--
是临界区,if (g_ticket > 0)
也是临界区,它们都访问了临界资源g_ticket
。
在if
的第一个分支中,当g_ticket--
完毕,此时当前线程就不会再访问g_ticket
了,于是离开临界区,并对mutex
解锁。在第二个分支else
中,线程马上要break
出循环了,并且退出,此时也要解锁,不然别的线程永远处于阻塞状态了。
可以想象一下,当第一个线程被调度,它要进行抢票,现在先对mutex
加锁,然后再去if
中访问g_ticket
。假如在某个访问临界资源的过程中,CPU
调度了其它线程,此时第二个线程进入。第二个线程也想访问g_ticket
,于是也对mutex
加锁,但是由于锁已经被第一个线程申请走了,此时第二个线程pthread_mutex_lock
就会失败,然后阻塞等待。
等到第一个线程再次被调度,访问完临界区后,对mutex
解锁,此时锁又可以被申请了。于是线程二申请到锁,再去访问g_ticket
。加锁可以保证,任何时候都只有一个线程访问临界区。当第二个线程访问临界区时,一定是其他线程访问完毕了临界区,或者其它线程还没有访问临界区。这就保证了临界区的原子性,从而维护线程的安全!
输出结果:
最后的结果中,2158 + 2026 + 1690 + 2018 + 2108 = 10000
,不多不少。
互斥锁原理
那么互斥锁是如何做到的呢?
互斥锁的汇编伪代码如下:
加锁lock
:
moveb $0, %al
xchgb %al, mutex
if (al寄存器的内容 > 0){
return 0;
}else
挂起等待;
goto lock
接下来我讲解一下这个过程:
如图所示,现在有两个线程thread-1
和thread-2
,它们共同征用内存中的锁mutex
。在CPU
中有一个寄存器%al
,用于存储和锁的值。
现在假设thread-1
进行调度执行pthread_mutex_lock
:
首先执行指令moveb $0, %al
,你可以理解为,就是把%al寄存器
内部的值变成0
:
随后执行xchgb %al, mutex
,该过程是让内存中的mutex
与%al寄存器
的值进行交换:
此时%al寄存器
的值变成1
,mutex
的值变成0
。随后执行:
if (al寄存器的内容 > 0){
return 0;
}else
挂起等待;
也就是说判断当前%al
内部的值是0
还是大于0
,如果大于0
那么说明争夺到了锁,此时函数pthread_mutex_lock
返回0
,表示加锁成功。否则执行else
进行挂起等待。
这样一个线程就征用到了一把锁。
现在假设thread-1
执行到第一条汇编语句后,%al
的值还是0
,thread-2
调度了:
现在thread-1
保存自己的硬件上下文,包括%al = 0
在内,随后therad-2
进入:
现在thread-2
执行了两行汇编语句,成功把内存中的mutex
与自己的%al
交换,申请到了锁,此时thread-1
再次调度,thread-2
拷贝走自己的硬件上下文:
恢复硬件上下文后,thread-1
的%al
等于0
,执行第二条语句后,%al
和mutex
依然是0
,这表明锁已经别的线程拿走了,此时在执行if
内部的内容,thread-1
挂起等待。
可以看到,其实锁的本质,就是保证mutex变量
中以及所有访问锁的线程的%al寄存器
中,只会有一个非零值
。只有拿到非零值
的线程才有资格去访问临界资源。其它线程如果要再次申请锁,由于自己的%al
和mutex
都是0
,就算交换后还是0
,也申请不到锁。
并不是谁先调用ptherad_mutex_lock
,谁就先抢到锁,而是谁先执行该函数内部的xchgb %al, mutex
语句,把非零值
放到自己的%al
中,谁才抢到锁。
再简单看看解锁:
unlock
:
moveb $1, mutex
唤醒等待mutex的线程;
return 0;
解锁就很简单了,moveb $1, mutex
就是把自己的%al
中的1
还给mutex
,然后唤醒所有等待该锁的线程,让它们再次争夺这把锁。最后return 0
,也就是pthread_mutex_unlock
函数返回0
。
常见的锁
Linux
中不仅仅存在互斥锁
这一种锁,还有非常多的锁,接下来我们看看其它的锁。
死锁
死锁:指在一组进程中的各个进程均占有不会释放的资源,但因互相申请其它进程不会释放的资源而处于的一种永久等待状态
我简单举一个例子:
现在有两个线程thread-1
和thread-2
,以及两把互斥锁mutex-1
,mutex-2
:
现在要求:一个线程想要访问临界资源,必须同时持有mutex-1
和mutex-2
。随后therad-1
去申请了mutex-1
,thread-2
去申请了mutex-2
:
thread-1
再去申请mutex-2
,结果mutex-2
已经被therad-2
占用了,thread-1
陷入阻塞:
thread-2
再去申请mutex-1
,结果mutex-1
已经被therad-1
占用了,thread-2
陷入阻塞:
现在therad-1
等待therad-2
解锁mutex-2
,thread-2
等待thread-1
解锁mutex-1
,双方互相等待。由于唤醒thread-2
需要therad-1
,唤醒therad-1
又需要therad-2
,此时陷入永远的等待状态,这就是死锁
。
想要造成死锁,有四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流获得资源后,其它执行流不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
以上是比较正式的说法,接下来我从线程角度简单翻译翻译:
- 互斥条件:临界资源同时只能被一个线程访问=
- 请求与保持条件:请求是指:申请对方的锁,保持是指:占着自己有的锁不放;
- 不剥夺条件:一个线程如果申请锁失败,强行抢走他人的锁
- 循环等待条件:以刚刚的死锁为例,
therad-1
等thread-2
,而thread-2
等待thread-1
,形成一个头尾相接的循环
这四个条件都是必要条件
,也就是说:
解决死锁,本质就是破坏一个或多个必要条件
主要有以下方式避免死锁:
- 破坏互斥条件:不要用锁
- 破坏请求与保持条件:如果发现没有申请到锁,立刻释放自己的全部锁
- 破坏不剥夺条件:如果发现没有申请到锁,强行释放对方的锁,将其占为己有
- 破坏循环等待条件:如果申请多把锁,所有线程都必须按照相同的顺序申请(最简单的方式)
另外的,还有一些死锁的相关算法:死锁检测算法
和银行家算法
,本博客就不做解释了。
自旋锁 spinlock
我们先前讲的锁,其机制是这样的:
当线程申请一个锁失败,就会阻塞等待
,当锁被使用完毕,唤醒所有等待该锁的线程。
其实锁还有一种不用阻塞等待
的策略,而是反复检测的策略,就像这样:
当线程没有申请到锁,一段时间后再次检测这个锁有没有被释放,一直反复申请这个锁,这个过程叫做自旋
。基于这个策略来申请的锁,叫做自旋锁
。
Linux
自带了自旋锁spinlock
,类型为pthread_spinlock_t
,接口如下:
创建与销毁
:
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
加锁与解锁
:
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
你会发现,这和mutex
几乎一摸一样,所以接口也就不讲解了。
不过我这里要强调一点,pthread_spin_lock
并不是申请失败就返回,而是在pthread_spin_lock
内部以自旋的方式申请锁,我们无需手动模拟自旋的过程。
其它锁
悲观锁
:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁
,写锁
,行锁
等),当其他线程想要访问数据时,被阻塞挂起。乐观锁
:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。CAS操作
:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。公平锁
,非公平锁
等
以上出现的所有概念,本博客都不讲解。