什么是互斥
在任意时刻只允许一个执行流访问某段代码就可以叫作互斥。在本篇将介绍互斥锁(mutex)
抢票模型
让我们以抢票模型来开始互斥锁的学习,对于抢票这件事有两个原则,一是大家都会尽量抢更多的票,二是一旦票没了就不能再抢了。所以接下来以这段代码为例,看看这样的抢票方式会不会引起问题
int tickets = 1000;
void* route(void* args)
{
int id = *(int*)args;
delete((int*)args);
while (true)
{
if (tickets > 0)
{
usleep(1000);
cout << id << " 抢到 " << tickets << endl;
tickets--;
}
else { break; }
}
}
int main()
{
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
int *id = new int(i);
pthread_create(tid + i, nullptr, route, id);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
我创建了五个进程,分别让他们进行抢票,其中每次对tickets进行–就代表一次抢票的完成,当票数小于等于0时抢票应该结束,那么运行结果是什么呢?
我们发现问题在于当票数小于0时仍有线程在进行抢票操作,这显然是不符合逻辑的,那么原因是什么呢?
我们知道运算操作是由CPU进行的,也就是说我们每次对tickets进行–操作都要把tickets的数据从内存到CPU,再由CPU进行运算,然后再把数据从CPU拷回内存。由此我们也可以知道- -操作并不具备原子性。因此不能让我们获得预期结果的主要原因就是- -操作实际上是由三个操作组成的:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
而在上面的代码中usleep模拟的是实际操作中的漫长业务过程,在这个过程中可能会有大量的线程进入该代码段。而if判断为真时代码可以并发地切换到其它进程。举个例子吧,现在有两个线程希望来抢这些票,
首先线程一开始抢票,它刚刚把数据加载到CPU上计算完后就被切到线程二了,这时线程一还来不及将内存中的tickets进行更新,也就是说线程二仍认为有1000张票,而线程二抢票比较厉害,将1000张票抢到只剩2张,之后被切回线程一,而这时线程一就会接着进行之前没完成的store操作,将内存中的2又改成了1000,那么在这种情况下线程二所做的操作不就白费了吗?
那么应该如何解决这样的问题呢?我们需要做到三点: - 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这样的操作本质上就需要一把锁,在Linux中提供的这把锁就叫作互斥量。
mutex的接口
初始化的两种方法
- 方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法二:动态分配
// 参数: mutex:要初始化的互斥量 attr:nullptr
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
销毁互斥量
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其它线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
使用mutex
让我们看看用mutex改进上面抢票例子的代码
class Ticket
{
private:
int tickets;
pthread_mutex_t mtx;
public:
Ticket()
: tickets(1000)
{
// 初始化
pthread_mutex_init(&mtx, nullptr);
}
bool GetTicket()
{
// 加锁
pthread_mutex_lock(&mtx);
// 锁定临界区,每次只允许一个线程访问
bool res = false;
if (tickets > 0)
{
usleep(1000);
res = true;
cout << pthread_self() << " 抢到票 : " << tickets << endl;
tickets--;
}
else{cout << "票抢完了" << endl;}
// 解锁
pthread_mutex_unlock(&mtx);
return res;
}
~Ticket()
{
// 销毁
pthread_mutex_destroy(&mtx);
}
};
void* route(void* args)
{
Ticket *t = (Ticket*)args;
while (true) { if (!t->GetTicket()) { break; } }
}
int main()
{
Ticket *t = new Ticket();
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
int *id = new int(i);
pthread_create(tid + i, nullptr, route, (void*)t);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
此时就不会再出现票数小于0的情况了
mutex实现原理
因为mutex本身也是一个临界资源,所以我们必须得先保证mutex本身的安全,而保证他安全的方式就是使mutex具有原子性,也就是分别用一条汇编代码来实现lock和unlock。
mutex的原理大致可以这么理解:mutex的初始值为1,当一个线程进入临界区并加锁后,mutex的变为0,解锁后变回1。伪码如下
lock()
{
if (mutex == 1)
{
mutex--;
return 0;
}
else
{
return -1;
}
}
但我们知道–其实不是原子的,因此mutex实际上绝不可能以这种方式实现。为了实现互斥锁操作,大多数体系结构提供了swap和exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理平台,访问内存的周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。我们再来看一段伪码,
// 假设有A,B两线程同时访问这段代码
// CPU在执行线程A的代码的时候,CPU内寄存器的数据是不是线程A私有的?
// 是的,这部分数据在线程A被切换后就会保存到线程A的上下文中
lock:
move $0, %al // 把0赋给寄存器al,设置各自的上下文数据
xchgb %al, mutex // 交换寄存器和内存中的数据
if (al寄存器的内容 > 0) {
return 0;
} else
挂起内容;
goto lock;
unlock:
movb $1, mutex
唤醒等待mutex的线程;
return 0;
}
mutex的本质其实是通过一条汇编,将锁数据交换到自己的上下文中!
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待的状态。
死锁四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流以获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
一个简单的死锁例子就是线程连续进行了两次上锁操作。在第一次上锁操作后互斥锁的值被改为0,因此在第二次上锁时这个线程就会被挂起等待至被锁上的线程被解锁,而这个线程就是他自己,因此就会一直被卡在第二个上锁操作挂起等待这一步。