目录
前言
一、进程线程间的互斥相关背景概念
二、互斥量(互斥锁)
三、互斥锁的使用
1.互斥锁的初始化
2.加锁与解锁
3.锁的使用
4.锁的封装
四、线程饥饿
五、互斥锁的原理
六、死锁
前言
我们学习过线程概念与线程控制,知道了线程的原理以及如何控制线程,由于线程可以创建多个,也就是操作系统中存在多个由某一个进程创建的执行流。那么他们在访问并修改共享资源时,可能会发生数据不一致的问题,进而我们需要让线程互斥来保护公共资源。
一、进程线程间的互斥相关背景概念
- 临界资源:一次仅允许一个进程使用的资源称为临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么不做,要么做完成
二、互斥量(互斥锁)
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
学习了这么多概念,不如我们直接来上代码,看看为何需要进程进行互斥。
现在我们写一个模拟抢票的代码,总共只有10000张票,创建5个进程,让他们一起去访问调用这个抢票函数,访问ticket资源。
#include<iostream>
#include<thread>
#include<unistd.h>
#include<vector>
#include<functional>
using namespace std;
string Getname()
{
static int num = 1;
string name("thread_");
name += to_string(num);
num++;
return name;
}
int ticket = 10000;
void _GetTicket(string name)
{
while(1)
{
if(ticket>0)
{
usleep(1000);
cout<<"我是: "<<name<<",剩余票数: "<<ticket<<endl;
ticket--;
}
else
{
break;
}
}
}
void GetTicket()
{
_GetTicket(Getname());
}
int main()
{
int n = 5;
vector<thread> threads;
while(n--)
{
threads.push_back(thread(GetTicket));
}
for(auto& t : threads)
{
t.join();
}
}
运行发现剩余票数竟然会发生负数,也就是抢票抢到了不存在的票,这肯定是不对的。
我们代码中判断明明是ticket大于0,才能够继续抢票,为啥会发生这种情况?
因为ticket是共享资源,当多线程访问同一共享资源时,我们需要加以互斥保护。
- 如果不保护,那么多线程执行时,遇到的ticket共享资源,会进行抢票并对ticket一直--,当ticket被减到1时,此时某一线程进来了,发现ticket是1,就会进入内部继续访问。在抢票过程进行中(也就是if进入后,ticket--之前)
- 多核情况下,其他进程也能在ticket是1时判断,并进入内部。
- 单核情况下,时钟中断到来,进程会进行切换,其他进程依然能在ticket是1是判断,也进入内部
- 因此进入了if判断内部,后续做--操作,又会重新从内存中读取数据,此时可能数据变为了0或者负数,就会让值减到负数。
由此,我们需要对共享资源进行保护,让线程互斥起来,也就是让资源变为临界资源——任何一个时刻,只允许一个线程正在访问公共资源 。我们把进程中访问临界资源的代码称之为临界区。
此时我们发现,我们在对ticket的访问过程并不是原子性的——不会被任何调度机制打断的操作,该操作只有两态,要么不做,要么做完成。因为访问过程可能随时被时钟中断,进程发生切换。
为了深刻理解原子性,我们写了如下这么简单的代码,我们知道C语言代码会被编译成汇编代码,再转化为二进制由CPU进行执行处理。那么一句简单的a++代码,就会有三句汇编代码。
先读取,再处理,再返回。 是有可能在第1步或第2部被中断。那并没有对内存进行修改,其他进程此时进来运行,查看到的内存数据还是1。那a++的操作,肯定不是原子性的。
那么什么样的操作是原子性的呢?
- 要么是只需要一步操作就完成的
- 要么就给进程加上互斥锁,就是只能让单个进程访问,再当前进程访问结束之前,其他进程无法进行访问。
三、互斥锁的使用
1.互斥锁的初始化
互斥锁的使用很简单,如下
定义全局锁:PTHREAD_MUTEX_INITIALIZER ,不需要销毁、
定义局部锁:pthread_mutex_init,需要销毁
- 参数:mutex,pthread_mutex_t 是锁的类型,可以定义锁,然后传入&mutex即可。
- 参数:atrr,定义锁的属性,nullptr代表默认属性
销毁锁:pthread_mutex_destory
- 参数:mutex,传入&mutex即可。
2.加锁与解锁
定义了锁之后,还需要给代码加锁,加锁与解锁代码如下。
加锁阻塞:pthread_mutex_lock
加锁不阻塞:pthread_mutex_trylock
解锁:pthread_mutex_unlock
- 参数都为锁的地址。
3.锁的使用
我们首先定义锁,然后初始化锁,对临界区进行加锁,临界区结束进行解锁。最后销毁锁。
并且我们需要尽可能的给少的代码加锁,因为有了锁之后,多线程就只有一个线程正在临界区中运行。效率并不高,如果给很多代码甚至全部代码加锁,那么创建多线程也就没有了意义。
那么现在,我们对之前的抢票代码做如下修改,定义全局锁并初始化,对访问ticket的代码进行加锁,if中的代码之前完前进行解锁。
注意因为ticket==0不会进入if判断,但是在此之前你锁已经加上了,所以else中也需要解锁,
加锁之后再运行,发现剩余票数不会再出现0以及负数了。因为同一时刻,只有一个进程在访问临界资源。
同时,申请锁一定是原子性的,要么申请失败,要么申请成功。当某一个进程申请锁成功后,再他没有释放锁之前,其他进程不可能申请成功。其他进程会在申请锁这里进行阻塞,也就是等待。
- 如果使用的是pthread_mutex_trylock 。那么申请锁成功返回0继续执行,申请失败返回错误值也继续执行,需要用户自行去判断。
- 也就是说 trylock 不会阻塞,用户通过if判断返回值为不为0来判断线程是否申请锁成功来进行代码编写。
虽然我们已经加锁,但是线程切换是不管你有没有持有锁,因此你会将你持有锁的信息一起保存在你的硬件上下文之中,等待其他线程调度运行,你再回来继续执行。因为你还没有解锁,你是持有锁的状态,那么其他线程无法获取到这把锁,也就会一直阻塞。
小总结:
- 申请锁是原子的,同一时刻只有一个线程申请成功。
- 使用 pthread_mute_lock 申请锁失败的线程会等待,pthread_mute_trylock会返回错误值。
- 持有锁的线程在访问临界区资源时,也会被切换,但是没关系,只要他没释放锁,其他线程也不会申请锁成功。
全局锁使用比较简单,定义锁使用即可。
如果是局部锁,需要从外部传参,因为要让进程去获取同一把锁,不然就没办法互斥。
4.锁的封装
每一次都需要加锁再解锁,是非常麻烦的事情,如果我们将加锁封装成一个类,构造的时候加锁,出了作用域自己会调用析构解锁,就会很方便。
如下,封装了一个LockGuard(守护者),构造函数加锁,析构函数解锁
#pragma once
#include <pthread.h>
// 不定义锁,外部会传递锁
class Mutex
{
public:
Mutex(pthread_mutex_t *lock)
: _lock(lock)
{
}
void Lock()
{
pthread_mutex_lock(_lock);
}
void UnLock()
{
pthread_mutex_unlock(_lock);
}
~Mutex()
{
}
private:
pthread_mutex_t *_lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock)
: _mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.UnLock();
}
private:
Mutex _mutex;
};
那么我们现在使用就使用LockGurad构造一下就可以了。出了作用域会自动析构
四、线程饥饿
刚才的代码,我们在centos 7系统下(ubuntu可能结果不同),会发现thread_1一直能够运行,也就是该线程竞争锁的能力非常强,导致其他进程无法拥有锁。这是由于线程优先级不一致导致的问题。
解决饥饿问题只用互斥是不行的,要让线程执行的时候,具备一定的顺序性,这就是同步!!!(后续写完同步,链接会放在这里)
五、互斥锁的原理
我们一直再说申请互斥锁是原子的。这确实是必须的,必须保护好自己,才能更好的保护其他人。那么为何说他这个行为是原子的呢?
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,该指令是原子的。
- mutex锁可以当做一个整形变量,只不过他还有其他数据,比如持有锁的线程是谁,等待队列等,我们姑且他是一个整形变量,1代表锁还未被使用,0代表锁已被使用
有了这两点知识,我们来看互斥锁的汇编伪码。
如下是执行过程
- 给al寄存器置0,此时锁的内容为1(还没有人申请锁)
- 使用exchange指令让mutex锁和al寄存器中的数据进行交换,
- 判断寄存器里面的内容是否大于0来让线程做不同的处理,大于0证明申请到锁,返回执行后续代码,等于0证明没申请到锁,就在等待队列进行等待。
这里有好几句指令,为何说他是原子性的呢?
如果线程① movb $0, %al 运行完被切换,保存好自己硬件上下文离开,线程②进来,执行到xchgb %al , mutex成功,那么锁就被线程②拿走了,进程①切换回来,加载自己的上下文继续执行,发现 xchgb %al , mutex 之后,当前 al 为0,于是被等待。
线程①xchgb %al , mutex成功,那么锁就被线程①拿走了,线程②也不会申请到锁,会被挂起等待。
也就是说,通过这样的代码,使得任何一个时刻,只有一个线程能够成功申请锁, 这样就保证了加锁的原子性。
解锁的时候,就将锁的数据置为1,呼叫其他进程从等待队列中出来继续争抢锁就可以了。
关于加锁的原则:谁加锁,谁解锁
六、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁其实并不常见,大部分情况下是复杂的代码,创建了很多锁,由于逻辑问题出现了互相申请锁的情况发生,才会造成死锁。
比如线程A,在持有锁1的情况下,想要申请锁2,线程B,在持有锁2的情况下,想要申请锁1。他们都不让步,都想等待对方将锁释放出来。于是都卡在申请锁的步骤里,发生了死锁。
只有一个锁也可能死锁,也就是持有锁的时候,想要再去申请这把锁,因为你还没有释放锁 ,mutex的值为0,于是一直申请不了,造成了死锁。
死锁的4个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配