同步原语
原来我们都用的是单核的CPU,但是单核的性能现在已经很难有突破了,所以开始在一个CPU中添加多个物理核。
但是原来的应用程序都是为单核设计的,在多核运行无法体现多核的性能,为了更充分的使用多核,应用程序需要将待处理的数据进行划分,从而能够在同一时间分配任务到多个核心上并行处理,利用更短的时间完成计算。
然而并行处理同一任务意味着对共享资源的并发访问,为了保证共享资源状态的正确性,需要正确地在这些子任务之间进行同步。看下面这个例子
生产者不断生成数据放到共享缓冲区中,消费者从缓冲区拿数据,但是他需要满足两个前提条件:
1.当缓冲区满时,生产者应停止向缓冲区写入数据
2.当缓冲区为空时,消费者应停止从缓冲区拿数据
下面这个代码段展示了一个生产者和一个消费者时的方案
volatile int empty_slot = 5;
volatile int filled_slot = 0;
void producer(void)
{
int new_msg;
while(TRUE)
{
new_msg = produce_new();
while(empty_slot == 0)
;//没有空位可使用
empty_slot--;
buffer_add(new_msg);
filled_slot ++;
}
}
void consumer(void)
{
int cur_msg;
while(TRUE){
while(filled_slot == 0)
;//没有对象可消耗
filled_slot --;
cur_msg = buffer_remove();
empty_slot ++;
consume_msg(cur_msg);
}
}
这里设置了两个全局计数器:filled_slot和empty_slot,其中filled_slot用于记录缓冲区中可以被消耗的对象数量,empty_slot用于记录缓冲区中剩余的空位数量。
先看生产者,当要写入数据时,要看是否有空位,如果没有空位需要一直等待,直到有空位,放入数据后,要将空位数量减1,将可消耗数量加1.
消费者同理。
生产者消费者模型在并行编程中很常见,为了正确、高效地解决这些同步问题,前人抽象出了一系列同步原语供开发者使用。
互斥锁
临界区问题
像上图这样有两个生产者的情况,他们可能同时向3号缓存块进行写入,这样就产生了对该缓存块的竞争,这就叫做竞争冒险。
解决这个竞争冒险的方法就是在同一时刻,只允许一个生产者可以向3号缓冲区写入,这就叫做互斥访问。
保证互斥访问共享资源的代码区域被称为临界区。
图中这个循环就是用来解决临界区问题的,那么为了程序的正确运行,必须满足以下条件的算法:
(1)互斥访问:在同一时刻,最多只有一个线程可以执行临界区
(2)有限等待:当一个线程申请进入临界区之后,必须在有限的时间内获得许可并进入临界区,不能无限等待。
(3)空闲让进:当没有线程在执行临界区代码时,必须在申请进入临界区的线程中选择一个线程,允许其执行临界区代码,保证程序执行的进展。
硬件实现:关闭中断
我们可以通过关闭中断来解决单核的临界区问题,关中断意味着当前执行的线程不会被其他线程抢占,因此线程在进入临界区之前关闭中断,在离开临界区后开启中断,从而保证任意时刻只有一个线程执行临界区。关中断在执行在单核中满足了,互斥访问,有限等待,空闲让进。
但是在多核中,关闭中断无法阻止多个同时运行的线程需要执行临界区。
软件实现:皮特森算法
皮特森算法分可以用于多核的临界区问题
flag数组,0对应0号线程,1对应1号线程,TRUE代表申请进入临界区,turn是表示最终决定谁可以进临界区。如果0线程想进入临界区,必须满足下述两个条件之一:
1.flag[1] == FALSE
2.turn == 0
在检查线程0是否可以进入线程时,线程1可能处于以下三种情况:
1.线程1在准备进入临界区(空闲让进)
也就是执行完第二行代码,这里解释一下turn为什么取的是对方的值,因为如果取自己的话,他就可以直接进入临界区了,那么线程0和线程1就可以同时进入临界区了,而取对方的,可以保证有一个可以进入临界区。
2.线程1在临界区内部(互斥访问)
由于线程1在临界区内,他就不会更新flag和turn的值,而线程0还置turn为1,因此线程0就需要循环等待。
3.线程1在执行其他代码(有限等待)
线程0这会在进行循环等待,而线程1执行完了第六行代码,flag[1]为false,turn=0,并且turn不会被线程0和1继续更新了,满足了线程0进入临界区的条件。
软硬件协同:使用原子操作实现互斥锁
我们还可以利用硬件提供的原子操作,设计新的软件算法来解决临界区问题。
原子操作:不可被打断的一个或一系列操作,即要么这一系列指令都执行完成,要么这一系列指令一条都没有执行,不会出现执行到一半的状态。
互斥锁抽象
一把锁在一同一时刻只能被一个线程所拥有,一旦一个线程获取了一个锁,其他的线程均不能同时拥有该锁,只能等待该锁被释放。
只有拥有锁的线程才能允许执行临界区代码,在退出临界区后要释放锁,从而允许其他线程拥有锁并进入临界区。
代码展示了如何使用互斥锁保护生产者消费者的共享缓冲区。
volatile int buffer_write_cnt = 0;
volatile int buffer_read_cnt = 0;
lock_t buffer_lock;
int buffer[5];
void buffer_init(void)
{
lock_init(&buffer_lock);
}
void buffer_add_safe(int msg)
{
lock(&buffer_lock);
buffer[buffer_write_cnt] = msg;
buffer_write_cnt = (buffer_write_cnt + 1) % 5;
unlock(&buffer_lock);
}
int buffer_remove_safe(void)
{
lock(&buffer_lock);
int ret = buffer[buffer_read_cnt];
buffer_read_cnt = (buffer_read_cnt + 1) % 5;
unlock(&buffer_lock);
return ret;
}
互斥锁的种类繁多,不同的互斥锁被用于不同的场景,以达到最好的性能表现。
本节介绍分别为利用原子CAS实现的自旋锁,和利用原子FAA实现的排号自旋锁。
自旋锁
自旋锁利用一变量lock来表示锁的状态,lock为1表示已经有人拿锁,而为0表示该锁空闲。
void lock_init(int *lock)
{
//初始化自旋锁
*lock = 0;
}
void lock(int *lock)
{
while(atomic_CAS(lock, 0, 1) != 0)
; //循环忙等
}
void unlock(int *lock)
{
*lock = 0;
}
自旋锁满足了互斥访问和空闲让进,不满足有限等待,但是这不要紧,他还是很高效的,因此依然广泛的应用在各种软件之中。
排号自旋锁
顾名思义,排号锁就是要给用锁的线程进行排号,然后锁沿着这个号进行传递,因此可以说锁的竞争就变成了一个先进先出的等待队列。
struct lock
{
volatile int owner;
volatile int next;
};
void lock_init(struct lock *lock)
{
//初始化排号锁
lock->owner = 0;
lock->next = 0;
}
void lock(struct lock*lock)
{
//拿取自己的序号
volatile int my_ticket = atomic_FAA(&lock->next,1);
while(lock->owner != my_ticket)
; //循环忙等
}
void unlock(struct lock*lock)
{
//传递给下一位竞争者
lock->owner++;
}
owner表示当前的锁持有序号,next表示下一个需要分发的序号
第17行是拿取自己的序号,并累加,这样就不会拿取相同的序号
第18行是看当前锁的持有者是不是自己,不是就循环等待
第25行是释放锁,然后锁持有者向后传递。
条件变量
在生产者和消费者模型中,无剩余空位时,生产者会陷入循环等待,他可以不用循环等待的,这会浪费cpu资源,因此需要一种挂起/唤醒机制,条件变量就是为这个机制而设计的。
通过条件变量的接口,一个线程可以停止使用CPU并将自己挂起,当等待的条件满足时,其他线程会唤醒该挂起的线程让其继续执行。
int empty_slot = 5;
int filled_slot = 0;
struct cond empty_cond;
struct lock empty_cnt_lock;
struct cond filled_cond;
struct lock filled_cnt_lock;
void producer(void)
{
int new_msg;
while(TRUE)
{
new_msg = produce_new();
lock(&empty_cnt_lock);
while(empty_slot == 0)
{
cond_wait(&empty_cond, &empty_cnt_lock);
}
empty_slot --;
unlock(&empty_cnt_lock);
buffer_add_safe(new_msg);
lock(&filled_cnt_lock);
filled_slot ++;
cond_signal(&filled_cond);
unlock(&filled_cnt_lock);
}
}
empty_cnt_lock和filled_cnt_lock是来保护对共享计数器empty_slot与filled_slot的修改的锁,这个锁设计的目的是在使用条件变量时,必须要搭配互斥锁一起使用。
这里设置了两个条件,empty_cond 缓冲区无空位和filled_cond 缓冲区无数据。
当生产者要写数据时发现没有空位,则通过cond_wait函数挂起,条件是empty_cond无空位,搭配的互斥锁是empty_cnt_lock,后边那个cond_signal是唤醒线程,由于写入数据则缓冲区存在数据了,可以唤醒由于缓冲区无数据而挂起的消费者线程。
struct cond{
struct thread *wait_list;
};
void cond_wait(struct cond *cond, struct lock *mutex)
{
list_append(cond->wait_list, thread_self());//将线程加入等待队列
atomic_block_unlock(mutex); //原子挂起并放锁
lock(mutex); //重新获得互斥锁(被唤醒后)
}
void cond_signal(struct cond *cond)
{
if(!list_empty(cond->wait_list))//看是否有线程等待在条件变量上
wakeup(list_remove(cond->wait_list)); //操作系统提供的唤醒
}
void cond_broadcast(struct cond *cond) //广播操作,用于唤醒所有等待在条件变量上的线程
{
while(!list_empty(cond->wait_list))
wakeup(list_remove(cond->wait_list));
}
信号量
上述解决生产者和消费者问题的两个计时器,就是信号量,除了初始化之外,信号量只能通过两个操作来更新,P操作即检索,V操作即自增,因此信号量又称为PV原语,为了便于理解,一般会使用wait和signal来表示信号量的P和V操作。
wait操作用于用于等待,当信号量的值小于或等于0时进入循环等待。
signal操作则用于通知,其会增加信号量的值供wait的线程使用。
void wait (int *S)
{
while(*S <= 0)
; //循环忙等
*S = *S - 1;
}
void signal(int *S)
{
*S = *S + 1;
}
信号量的实现
struct sem{
int value; //可正可负。正代表可用资源,负代表等待获取资源的线程数量
int wakeup; //有线程等待时的可用资源数量
struct lock sem_lock;
struct cond sem_cond;
}
void wait(struct sem *S)
{
lock(&S->sem_lock);
S->value --;
if(S->value < 0){
do{
cond_wait(&S->sem_cond,&S->sem_lock);
}
while(S->wakeup == 0);
S->wakeup --;
}
unlock(&S->sem_lock);
}
void signal(struct sem *S)
{
lock(&S->sem_lock);
S->value ++;
if(S->value <= 0)
{
S->wakeup ++;
cond_signal(&S->sem_cond);
}
unlock(&S->sem_cond);
}
管程
线程安全是指某个函数,函数库在多线程环境中被调用时,能够正确地使用同步原语保护多个线程对共享变量的访问与修改。
管程:共享的数据,操作共享数据的函数。
管程保证在同一时刻最多只有一个操作者能够进入管程的保护区域访问共享数据。
死锁
必要条件:
- 互斥访问
- 持有并等待
- 资源非抢占
- 循环等待
死锁预防:
- 避免互斥访问
- 不允许持有并等待
- 允许资源被抢占
- 避免循环等待
死锁避免:
系统存在两种状态,安全状态和非安全状态,在安全状态下,一定存在一个安全序列,如果系统按照这个序列调度线程执行,即可避免资源不足的情况发生。
活锁:两个线程,1号线程获取锁a,2号线程获取锁b,然后1号获取锁b,2号获取锁a,发现锁被占有,然后1号放弃锁a,2号放弃锁b,但是两个线程执行速度相似,1号又获取了锁a,2号又获取了锁b,导致循环往复,这是活锁现象。
优先级反转
优先级反转的意思就是,当一个低优先级线程获取了一个锁并进入临界区执行,那么当一个高优先级线程也要获取该锁并执行时,需要等待低优先级执行完并放锁后才能执行,因此出现了低优先级先于高优先级执行的情况,这既是优先级反转。