- 我们需要保证不能有两个进程或线程同时进入critical section,来避免race condition. 也就是critical section need to be protected
- 也就是要实现互斥(mutual exclusive): 同一时间只能有一个任务进入critical section
- 所以执行critical section的进程或线程不能被打断,需要实现原子操作(atomic execution)
- 原子操作(atomic execution): 代码不被打断,一次执行完毕
- 在一个进程或线程申请进入临界区并被授权进入临界区之后,此进程或线程能进入临界区的次数是有限的(也就是不能永远占用临界区,要让别的进程或线程也能使用临界区)
- 当无进程或线程处于临界区,可允许一个请求进入临界区的进程或线程立即进入自己的临界区
- 对于进程/线程同步最直接的想法就是加一个shared variable来控制critical section
loop{
if not lock {
lock = true;
critical_section
lock = false;
}
}
- 以下是两个著名的解决race condition的算法 (通过改进intuitive solution)
wants_to_enter[0] = false;
wants_to_enter[1] = false;
turn = 0; //or 1
p0:
//当一个进程或线程想进入critical section时,首先将自己的wants_to_enter[0]变量设置为true
wants_to_enter[0] = true;
//查看对方是否想进入, 如果对方不想进入,则自己进入critical section
while(wants_to_enter[1]) {
//如果对方想进入,则查看是谁的turn
if (turn != 0) {
//如果是对方的turn,则表示自己不想进入
wants_to_enter[0] = false;
//然后等待
while (turn != 0) {
//busy wait;
}
//对方从critical section出来了,turn是自己了,将 wants_to_enter[0] = true
//表示自己想进入
wants_to_enter[0] = true;
}
}
//critical section
turn = 1;
wants_to_enter[0] = false;
//reminder section
- 比dekker算法简单一些
wants_to_enter[A] = true;
turn = B;
while (want_to_enter[B] == true && turn == B) {
//wait;
}
//critical section
wants_to_enter[A] = false;
优点:
- 在用户空间运行,不需要硬件的协助
缺点:
- 在现代计算机中,不一定能成功,因为software solution依赖于sequential consistency. 而现代计算机为了优化,在编译时会打乱sequential consistency
- sequential consistency: 简单来说就是按顺序执行代码
- 同时Software solution只能保证在单核计算机中成功,当多个任务在不同的核运行时,可能会同时进入critical section
- 所以以上的两种算法已经被淘汰,只有历史意义
loop{
if not lock {
lock = true;
critical_section
lock = false;
}
}
- 在上面这个代码中,因为对于lock的读和写不是atomic instruction所以会出现race condition
- 为了解决这个问题,现代计算机提供了atomic read and write指令(汇编指令). 也就是read和write连在一起是一个整体,是atomic的. 这样就在硬件的帮助下解决了这个问题
- 这个指令叫做 “Test and Set” (TSL)
//pseudo code
//如果lock == true,则继续lock,然后返回true
//如果lock == false,则将lock设为true,然后返回false
function Test_and_Set(lock) {
current_value = lock;
lock = true;
return current_value;
}
//使用test_and_set解决lock问题
//如果lock == true则返回true,一直while循环等待
//如果lock == false则返回false并将lock设置为true, 打破循环进入critical section
function Lock(*lock) {
while(Test_and_Set(lock));
}
function Unlock(*lock) {
lock = false;
}
- 如果系统没有提供Test_and_Set( ),则可以使用compare_and_swap( )
- compare_and_swap(ptr, old, new), 根据ptr查看所指向的内存里储存的值
- ptr: 内存地址, old: 备份旧数据, new: 基于旧数据构造新数据
- 如果 *ptr == old ,说明当前没有其它进程或线程在操作,所以,我们把new这个值写入ptr所指向的内存中
- 如果 *ptr != old,说明原来备份的旧数据已经被改动, 需要根据被改动的旧数据重新计算新数据
- 概括:CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值
- CPU会保证上述操作是atomic
使用compare_and_swap解决lock问题
//如果lock == fasle,则设置为true,然后返回true
//如果lock == true, 则直接return false
function Lock(*lock) {
while(!compare_and_swap(&lock, false, true));
return
}
function Unlock(*lock) {
lock = false
}
- 自旋锁
- 从上面代码可以看到,TS和CAS都是通过一个while loop不断查看lock的值,这种情况被叫做busy waiting也就是CPU一直在执行这个进程,开销很大。这种锁被叫叫做自旋锁(spinlock)
- 当然自旋锁也有好处,就是在进程等待时不会有context switch.而context switch的时间消耗比较大
- CAS的ABA问题
- CAS还有个问题就是ABA问题,比如第一次拿到内存里的值时是A,然后被其他线程修改为B, 然后又修改为A, 而此时去比较内存里的值会发现没有变,但是实际上还是有改动
- 举个通俗点的例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,还好 ; 但是假若你是一个比较讲卫生的人,那你肯定就不高兴了
- ABA问题的解决思路: 使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A了
- 为了解决spinlock CPU开销大的问题,我们可以让一个进程或线程进入阻塞状态如果这个进程或线程需要进入的critical section是locked,然后等待CPU调度唤醒再次查看critical section的状态
- 这种锁被叫做互斥锁(mutex lock)
data structure of mutex:
//pesudo code
struct {
int lock; //mutex value
PCB *PCB_block_q; //waitlist of process or threads, 放入阻塞的线程,等待CPU唤醒
}
常用的C library mutex函数
#include
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destory(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//以上所有函数成功时返回值为0,失败会返回error code
//pthread_mutex_t的定义
typedef struct {
struct pthread_queue queue; //waitlist of process or threads
char lock; //mutex value
struct pthread *owner;
int flags;
#ifdef _POSIX_THREADS_PRIO_PROTECT
int prioceiling;
pthread_protocol_t protocol;
int prev_max_ceiling_prio;
#endif
} pthread_mutex_t;
pthread mutex example
#include
#include
//假设我们有这样的一个数据结构可以被多个线程访问
struct foo {
int f_count; //记录有多少个线程访问了这个数据结构
pthread_mutex_t f_lock; //一个mutex lock用来保护这个数据结构
int f_id;
};
//初始化数据结构
strcut foo* foo_alloc(int id) {
struct foo *fp;
if ((fp = malloc(sizeof(struct foo))) != NULL) {
fp->f_count = 1; //初始值为1, 暂时没有其他线程访问
fp->f_id = id;
//初始化mutex,如果初始化失败则释放内存,返回
if (pthread_mutex_init(&fp->f_look, NULL) != 0) {
free(fp);
return(NULL);
}
}
return(fp);
}
//如果一个线程正在访问数据结构fp, foo_hold会increment f_count,用mutex锁保护f_count
void foo_hold(struct foo *fp) {
//先上锁
pthread_mutex_lock(&fp->f_lock);
fp->f_count++;
//然后解锁
pthread_mutex_unlock(&fp->f_lock);
}
//如果一个线程完成访问,foo_rele会decrement f_count
void foo_rele(struct foo *fp) {
//先上锁
pthread_mutex_lock(&fp->f_lock);
//如果f_count等于0,则说明没有线程在访问,所以先解锁, 然后销毁锁,然后释放fp
if (--fp->f_count == 0) {
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_destroy(&fp->f_lock);
free(fp);
}
else {
//其余情况直接解锁
pthread_mutex_unlock(&fp->f_lock);
}
}
- 死锁: 是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程
发生死锁的条件:
- 当前进程或线程拥有其他进程或线程需要的资源
- 当前进程或线程等待其他进程或线程已拥有的资源
- 都不放弃自己拥有的资源,也就是不能被其他进程或线程剥夺,只能在使用完以后由自己释放
Example 1:
mutex m
function {
lock(m) //成功拿到锁
lock(m) //拿不到锁,因为已经被自己拿了,所以会无限等待下去
//critical section
unlock(m)
unlock(m)
}
- task A成功拿到M1的锁,同时task B成功拿到M2的锁
- task A等待获取M2的锁,同时task B等待获取M1的锁
- task A只有获得M2的锁才能往下继续然后释放M1的锁
- task B只有获得M1的锁才能往下继续然后释放M2的锁
避免死锁:
- 在所有进程或线程中使用相同的加锁顺序
- 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
- 通过算法实现死锁检测机制
//Example: 在所有进程或线程中使用相同的加锁顺序
Thread A:
functionA {
//some code
lock(A)
lock(B)
// ....
}
Thread B:
functionB {
//some code
lock(A) //不能是lock(B) 然后lockA
lock(B)
// ....
}
- 是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。
- 是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况
- 乐观锁和悲观锁严格的说不是一种锁,而是一种策略
- 加锁是一种悲观的策略,它总是认为每次访问共享资源的时候,总会发生冲突,所以宁愿牺牲性能(时间)来保证数据安全。
- 无锁是一种乐观的策略,它假设线程访问共享资源不会发生冲突,所以不需要加锁,因此线程将不断执行,不需要停止。一旦碰到冲突,就重试当前操作直到没有冲突为止。无锁的策略之一就是使用CAS
- semaphore的本质是一个计数器
- semaphore主要用于调度进程, 维护对共享资源的顺序
- 所以利用semaphore可以解决很多进程同步的问题,如mutex,生产者消费者问题,read/writer问题等
- 数据结构semaphore包括一个 counter用来表示目前的资源数量,和一个queue用于存放等待中的线程
- semaphore的实现包括两个函数: post(signal), wait
- wait对counter进行递减操作,表示消费掉资源一个, 如果递减完counter为负说明目前没有资源可供消费,则需要将目前进程放进等待队列, 然后block掉
- post对counter进行递增操作,表示资源增加一个,如果递增完counter小于等于0则说明有线程在排队等待消费资源,所以需要队列中的资源移出,然后唤醒
//pseudo code
struct {
int counter;//表示目前的资源数量
queue q;//用于存放等待中的线程
} sem_t
//v operation
signal(sem_t *s) {
s.counter++;
//counter小于等于0则说明有线程在排队等待消费资源,所以需要队列中的资源移出,然后唤醒线程
if (s.counter <= 0) {
remove(s.q, p);
wakeup(q);
}
}
//p operation
wait(sem_t *s) {
s.counter--;
//counter为负说明目前没有资源可供消费,则需要将目前进程放进等待队列, 然后block掉
if (s.counter < 0) {
add this thread to s.q;
block();
}
}
- 当counter等于1时,semaphore实现了mutext的功能 (也就是mutex就是semaphore的value等于1的情况)
sem_t has_space = N;
sem_t has_item = 0;
sem_t mutex = 1;
producer() {
int item;
while (True) {
item = produce_item( );
//producer需要查看buffer里还有没有space
wait(has_space);
//has_item和has_space的作用是负责资源分配,
//但是不能保证不会有多个produce和conumer同时进入critical section所以需要加mutex
wait(mutex);
//critical section
buffer[in] = item;
in = (in + 1) % N;
signal(mutex);
//生产完成后,递增资源数量
signal(has_item);
}
}
consumer() {
int item;
while(True) {
//consumer需要查看buffer里有没有item
wait(has_item);
//has_item和has_space的作用是负责资源分配,
//但是不能保证不会有多个produce和consumer同时进入critical section所以需要加mutex
wait(mutex);
//critical section
item = buffer[out];
out = (out + 1) % N;
signal(mutex);
//消费完成后,递增space数量
signal(has_space);
}
}
- 注意上面wait(has_item)和wait(mutex)的顺序不能互换,否则l两个mutex会形成deadlock
- 多个reader和writer同时对资源进行读写,如何同步
- 第一种方法: reader preference
- 多个reader可以同时读取资源,reader之间不需要竞争
- reader和writer不能同时访问资源
- 即使有writer在等待,reader也可以访问资源
- 只有当所有reader访问完之后writer才能访问资源
- 只有第一个reader需要和writer竞争
- 也就是第一个reader代表所有reader和writer竞争资源,当reader成功竞争到资源之后,剩下的所有reader可以直接读取资源,而writer只能等待所有reader完成之后才能访问资源,所以最后一个reader访问完毕后需要解锁
int reader_count = 0; //用于记录reader的数量
sem_t resource_mutex = 1 //保护资源的mutex
sem_t reader_count_mutex = 1//保护变量reader_count的mutex,因为reader_count也是共享变量
reader() {
wait(reader_count_mutex);
/这部分也是critial section, 因为有count
reader_count++;
if (reader_count == 1) {
wait(resource_mutex);
}
///
signal(reader_count_mutex);
//critical section
//read;
wait(reader_count_mutex);
//这部分也是critial section, 因为有count
reader_count--
if (reader_count == 0) {
signal(resource_mutex);
}
/
signal(reader_count_mutex);
}
writer () {
wait(resource_mutex);
//critical section
//write
signal(resource_mutex);
}
- reader preference存在的问题是,如果不断的有reader进来,则writer会进入饥饿状态
- 所以reader preference需要改进为 ---- writer preference
- 为了解决reader preference下writer进入饥饿状态的情况
- 主要思路是降低reader访问资源的机率
- 假设资源放在会议室里,在之前reader preference的模式下会议室只有一道门(resource_mutex), 而现在需要在会议室外再加一道门
- 所有的reader需要互相竞争进入第一道门, 而除去第一个writer需要和reader竞争以外,其余所有的writer都可以直接进入第一道门
- 然后按照reader preference分配第二道门的权限
int reader_count = 0; //第二道门:需要记录reader的数量
int writer_count = 0;//第一道门: 需要记录writer的数量
sem_t first_door_mutex = 1 //第一道门的mutex
sem_t resource_mutex = 1 //保护资源的mutex(第二道门的mutex)
sem_t reader_count_mutex = 1//保护变量reader_count的mutex,因为reader_count也是共享变量
sem_t writer_count_mutex = 1//保护变量writer_count的mutex,因为writer_count也是共享变量
reader() {
wait(first_door_mutex);
wait(reader_count_mutex);
reader_count++;
if (reader_count == 1) {
wait(resource_mutex);
}
signal(reader_count_mutex);
signal(first_door_mutex);
//read critical section
reader_count--;
if (reader_count == 0)
signal(reource_mutex);
signal(reader_count_mutex);
}
writer() {
wait(writer_count_mutex);
writer_count++;
if (writer_count == 1)
wait(first_door_mutex);
signal(writer_count_mutex);
wait(reource_mutex);
//write --- critcial section
signal(reource_mutex);
wait(writer_count_mutex);
writer_count--;
if (writer_count == 0) {
signal(first_door_mutex);
}
signal(writer_count_mutex);
}