原子操作
原子操作的概念
原子操作就是不可中断的一个或者一系列操作。
原子操作如何实现
总线锁定
使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号的时候,其他处理器的请求将被阻塞住,那么该处理器可以独占内存。
缓存锁
总线锁开销比较大,因为把CPU和内存之间的通信锁住了,这使得锁住期间,其他处理器不能操作其他内存地址的数据。
频繁使用的内存会缓存在处理器的L1,L2,L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。
所谓的缓存锁定指的是**缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取。(缓存一致性机制就整体来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取)。
- 有两种情况不能用缓存锁:第一种情况是操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定;第二种情况是处理器不支持缓存锁定,对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
Linux下同步机制
- POSIX信号量:可用于进程同步,也可用于线程同步
- POSIX互斥锁+条件变量:只能用于线程同步。
进程同步的四种方法
临界区
对临界资源进行访问。
同步和互斥
- 同步:多个进程因为合作产生直接制约关系,使得进程有一定的先后执行关系。
- 互斥:多个进程在同一时刻只有一个进程能进入临界区。
信号量
信号量表示资源的数量,对应的变量是一个整型(sem)变量。另外,还有两个原子操作的系统调用函数来控制信号量,分别是:
- P操作:将sem-1,如果sem<0,则线程/进程阻塞,否则继续。
- V操作:将sem加1,如果sem<=0,唤醒一个等待中的进程/线程,表明V操作不会阻塞。
图片来源小林coding
使用信号量解决生产者—消费者问题
生产者在生成数据之后,放在一个缓冲区中,消费者从缓冲区中读数据进行数据处理,任何时刻,只能有一个生产者或者消费者可以访问缓冲区。
所以需要三个信号量:
- 互斥信号量mutex:互斥访问缓冲区
- 资源信号量full:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为0,表示缓冲区一开始为空
- 资源信号量empty:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为缓冲区大小
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer() {
while(TRUE) {
int item = produce_item();
down(&empty);
down(&mutex);
insert_item(item);
up(&mutex);
up(&full);
}
}
void consumer() {
while(TRUE) {
down(&full);
down(&mutex);
int item = remove_item();
consume_item(item);
up(&mutex);
up(&empty);
}
}
不能先down(mutex)再down(empty),不然生产者发现empty=0,就会等待,但是这时候消费者又无法对empty操作,就一直等下去了。
管程
管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。
经典同步问题
哲学家进餐
方案一
#define N 5
void philosopher(int i) {
while(TRUE) {
think();
take(i); // 拿起左边的筷子
take((i+1)%N); // 拿起右边的筷子
eat();
put(i);
put((i+1)%N);
}
}
问题:如果所有哲学家同时拿起左手筷子,那么就会一起等待右边的筷子,从而导致死锁
方案二
#define N 5
semaphore mutex;
void philosopher(int i) {
while(TRUE) {
think();
P(mutex);
take(i); // 拿起左边的筷子
take((i+1)%N); // 拿起右边的筷子
eat();
put(i);
put((i+1)%N);
V(mutex);
}
}
问题:可以解决死锁问题,但是每次进餐只有一个哲学家,效率上来说不是最好的解决方案
方案三
偶数编号的哲学家先拿左边的叉子后拿右边的叉子,奇数编号哲学家先拿右边叉子后拿左边叉子。
#define N 5
semaphore mutex;
void philosopher(int i) {
while(TRUE) {
think();
if(i%2==0){
take(i); // 拿起左边的筷子
take((i+1)%N); // 拿起右边的筷子
}
else if(i%2!=0){
take((i+1)%N); // 拿起左边的筷子
take(i); // 拿起右边的筷子
}
eat();
put(i);
put((i+1)%N);
}
}
方案四
使用一个数组state来记录每一位哲学家的状态,分别是进餐状态、思考状态、饥饿状态(正在试图拿叉子)
那么,一个哲学家只有在两个邻居没有进餐的时候,才可以进入进餐状态。
代码来源阿秀的学习笔记
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N // 右邻居
#define THINKING 0
#define HUNGRY 1
#define EATING 2
typedef int semaphore;
int state[N]; // 跟踪每个哲学家的状态
semaphore mutex = 1; // 临界区的互斥,临界区是 state 数组,对其修改需要互斥
semaphore s[N]; // 每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think(i);
take_two(i);
eat(i);
put_two(i);
}
}
void take_two(int i) {
down(&mutex);
state[i] = HUNGRY;
check(i);
up(&mutex);
down(&s[i]); // 只有收到通知之后才可以开始吃,否则会一直等下去
}
void put_two(i) {
down(&mutex);
state[i] = THINKING;
check(LEFT); // 尝试通知左右邻居,自己吃完了,你们可以开始吃了
check(RIGHT);
up(&mutex);
}
void eat(int i) {
down(&mutex);
state[i] = EATING;
up(&mutex);
}
// 检查两个邻居是否都没有用餐,如果是的话,就 up(&s[i]),使得 down(&s[i]) 能够得到通知并继续执行
void check(i) {
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]);
}
}
读者写者问题
代码来源阿秀的学习笔记
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
void reader() {
while(TRUE) {
down(&count_mutex);
count++;
if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
up(&count_mutex);
read();
down(&count_mutex);
count--;
if(count == 0) up(&data_mutex);//最后一个读者要对数据进行解锁,防止写进程无法访问
up(&count_mutex);
}
}
void writer() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
锁
读写锁
读写锁允许多个线程同时读取共享资源,但是只允许一个线程写入共享资源。读写锁分为共享锁(读锁)和独占锁(写锁)两种类型。写锁优先于读锁。
共享锁
允许多个线程同时获取锁并读取共享资源,但阻止其他线程获取独占锁。在读写锁中,如果某个线程已经获得了共享锁,则其他线程也可以获得共享锁,但不允许其他线程获得独占锁。
独占锁
只允许一个线程获取锁并修改共享资源,其他线程在获取写锁之前必须等待当前线程释放锁。在读写锁中,如果某个线程已经获得了独占锁,则其他线程无法获得任何锁,必须等待当前线程释放锁。
使用场景
一般适用于读取操作频繁,写入操作较少的情况,但是在写入操作频繁的情况下读写锁可能会出现饥饿现象。
示例
#include <iostream>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <chrono>
using namespace std;
shared_mutex rw_lock; // 读写锁
int shared_data = 0; // 共享数据
// 读取共享数据的线程函数
void read_thread(int id)
{
while (true) {
// 获取共享锁
shared_lock<shared_mutex> lock(rw_lock);
// 读取共享数据
cout << "thread " << id << " read shared data: " << shared_data << endl;
// 释放共享锁
lock.unlock();
// 等待一段时间
this_thread::sleep_for(chrono::milliseconds(100));
}
}
// 修改共享数据的线程函数
void write_thread()
{
while (true) {
// 获取独占锁
unique_lock<shared_mutex> lock(rw_lock);
// 修改共享数据
shared_data++;
// 输出修改后的共享数据
cout << "write thread write shared data: " << shared_data << endl;
// 释放独占锁
lock.unlock();
// 等待一段时间
this_thread::sleep_for(chrono::milliseconds(500));
}
}
int main()
{
// 创建多个读取共享数据的线程
thread t1(read_thread, 1);
thread t2(read_thread, 2);
thread t3(read_thread, 3);
// 创建一个修改共享数据的线程
thread t4(write_thread);
// 等待所有线程结束
t1.join();
t2.join();
t3.join();
t4.join();
return 0;
}
互斥锁
一次只能一个线程拥有互斥锁。互斥锁是在抢锁失败的情况下主动放弃CPU进入水面状态直到锁的状态改变时再换醒。
需要直接把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文切换。
互斥锁加锁时间大概在100ns左右,实际上一种可能的实现是先自旋一段时间,自旋时间超过阈值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁得效果可能不亚于使用自旋锁。
条件变量
互斥锁只有两个状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁不足,通常和互斥锁一起使用,以免出现竞态条件。
当条件不满足的时候,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化,一旦其它的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正彼此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
自旋锁
如果进程无法取得锁,不会立即放弃CPU,而是一直循环尝试获取锁,直到获取为止。自旋锁一般用于枷锁时间很短的场景,效率比较高。
- 自旋锁一直占用CPU,如果短时间没有获得锁就会一直自旋,降低CPU效率
- 递归调用有可能死锁
- 如果是单CPU并且不可抢占,自旋锁就是空操作
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
// 互斥锁实现
std::mutex mtx;
void increment_mutex(int &x) {
for (int i = 0; i < 1000000; ++i) {
mtx.lock();
++x;
mtx.unlock();
}
}
// 自旋锁实现
std::atomic_flag spin_lock = ATOMIC_FLAG_INIT;
void increment_spin(int &x) {
for (int i = 0; i < 1000000; ++i) {
while (spin_lock.test_and_set(std::memory_order_acquire)); // 自旋等待
++x;
spin_lock.clear(std::memory_order_release); // 释放自旋锁
}
}
int main() {
int x = 0;
// 使用互斥锁的线程
std::thread t1(increment_mutex, std::ref(x));
std::thread t2(increment_mutex, std::ref(x));
t1.join();
t2.join();
std::cout << "互斥锁实现的x的值: " << x << std::endl;//2000000
x = 0;
// 使用自旋锁的线程
std::thread t3(increment_spin, std::ref(x));
std::thread t4(increment_spin, std::ref(x));
t3.join();
t4.join();
std::cout << "自旋锁实现的x的值: " << x << std::endl;//2000000
return 0;
}
死锁
什么是死锁
两个或者多个线程相互等待对方数据,死锁会导致程序卡死,如果不解锁将会永远无法进行下去。
产生原因
- 互斥
- 不剥夺:进程在所获得的资源未释放之前,不能被其他进程抢走,只能自己释放
- 请求和保持:进程当前所拥有的资源在请求其它资源的时候,由该进程继续占有
- 循环等待:存在一种进程资源循环等待链,链中每个进程已获得的资源同时被链中下一个进程所请求。
举例
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mtx1, mtx2;
void ThreadA()
{
mtx1.lock(); // 线程A获取mtx1
cout << "Thread A obtained mutex 1" << endl;
// 在获取mtx2之前,先暂停一会儿,让线程B有机会获取mtx2
this_thread::sleep_for(chrono::milliseconds(100));
mtx2.lock(); // 尝试获取mtx2,但是已经被线程B获取了,因此线程A将被阻塞
cout << "Thread A obtained mutex 2" << endl;
// 完成任务后,释放互斥锁
mtx2.unlock();
mtx1.unlock();
}
void ThreadB()
{
mtx2.lock(); // 线程B获取mtx2
cout << "Thread B obtained mutex 2" << endl;
// 在获取mtx1之前,先暂停一会儿,让线程A有机会获取mtx1
this_thread::sleep_for(chrono::milliseconds(100));
mtx1.lock(); // 尝试获取mtx1,但是已经被线程A获取了,因此线程B将被阻塞
cout << "Thread B obtained mutex 1" << endl;
// 完成任务后,释放互斥锁
mtx1.unlock();
mtx2.unlock();
}
int main()
{
// 创建两个线程,分别运行ThreadA和ThreadB函数
thread t1(ThreadA);
thread t2(ThreadB);
// 等待两个线程执行完毕
t1.join();
t2.join();
return 0;
}
在这个例子中,线程A和线程B都试图获取互斥锁mtx1和mtx2。但是,当线程A获取了mtx1时,它会休眠一段时间,让线程B有机会获取mtx2。当线程B获取了mtx2时,它也会休眠一段时间,让线程A有机会获取mtx1。由于线程A和线程B都需要互斥锁,它们都被阻塞了,导致死锁。
死锁处理方法
鸵鸟策略
假装没有发生问题
死锁检测与死锁恢复
检索到死锁发生的时候就采取措施恢复。使用资源分配图法,包括每种类型一个资源和每种类型多个资源的死锁检测。
死锁恢复
- 抢占
- 回滚
- 杀死进程
死锁预防
- 破坏互斥条件
- 破坏请求和保持条件
- 破坏不剥夺条件
- 破坏循环等待
死锁避免
在程序运行的时候避免发生死锁。
安全状态
如果没有死锁发生,并且及时所有进程突然对请求对资源的最大需求,也仍然存在某种次序能够使得每一个进程运行完毕,则就是安全的。