《操作系统导论》第三部分 并发 P2 锁

C3 锁

对于并发编程的一个最基本的问题:程序员希望原子式执行一系列指令,但由于单处理器上的中断(或多线程在多处理器上并发执行),这变得不可实现,为了解决这个问题,在源代码中加锁,放在临界区周围,保证临界区能像单条原子指令一样执行

3.1 锁的基本思想

lock_t mutex;

lock(&mutex);
a = a + 1;
unlock(&mutex);

锁就是一个变量,因此像第一行代码那样需要先声明一个某种类型的锁才能使用,这个锁保存了锁在某一时刻的状态,它要么是可用的,表示没有线程持有锁,要么是被占用的,表示有一个线程持有锁,正处于临界区

(1) 线程获得锁和开锁的作用

lock()的作用为:某线程调用lock()尝试获取锁,如果没有其它线程持有锁,该线程会获得锁,进入临界区,这个线程被称为锁的持有者当锁的持有者在临界区内时,其它线程无法进入临界区

unlock()的作用为:锁的持有者调用unlock(),该锁就变成可用的了,如果有等待线程(锁的阻塞队列里),其中一个会获取锁,进入临界区执行代码

(2) 锁的意义

锁为程序员提供了最小程度的调度控制,线程被操作系统调度,但通过给临界区加锁,可以保证临界区内只有一个线程活跃,锁将原本操作系统调度的混乱状态变得更为可控

(3) 用更多的锁增加并发

POSIX库将锁称为互斥量 mutex,因为它被用来提供线程之间的互斥,即当一个线程在临界区内时,锁能阻止其它线程进入直到本线程离开临界区

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&lock);
a = a + 1;
pthread_mutex_unlock(&lock);

POSIX的lock()和unlock()会传入一个变量,因为可能用不同的锁保护不同的临界区,这样可以增加并发,用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区

接下来试着去实现一个锁

3.2 评价一个锁的几个标准

在实现一个锁之前,应该有一套对这个锁的效果的评价标准

1,该锁能否提供互斥,该锁是否有效,能阻止多个线程进入临界区
2,公平性,当锁可用时,是否每个竞争线程都有公平的机会获得锁,是否会有竞争锁的线程饿死
3,性能,即使用锁之后增加的时间开销

3.3 控制中断

最早提供的互斥解决方法之一就是在临界区内关闭中断,这个解决方案是为了单处理器系统开发的

void lock() {
    
    
	DisableInterrupts(); //关闭中断
}

void unlock() {
    
    
	EnableInterrupts(); //打开中断
}

假设在一个单处理器上,通过进入临界区之前关闭中断,可以保证临界区的代码不会被中断,从而原子地执行,结束之后,打开中断,程序正常运行

这个方案的主要优点就是简单,没有中断,线程可以确信它的代码会继续执行下去,不会被其它线程干扰

但是缺点很多:
1,这种方法要求所有的线程有打开或关闭中断的特权操作,如果一个贪婪的程序在一开始就调用lock(),关闭中断就独占了处理器,更糟的是,恶意程序调用lock()后一直死循环,就只能重启
2,这种方法不适合多处理器,如果多个线程运行在不同的CPU上,每个线程都尝试进入同一个临界区,关闭中断也没有用,线程可以运行在其它处理器上,也能进入临界区
3,关闭中断导致中断丢失,假如磁盘完成了I/O,但是CPU无法得到这个中断信息,那么操作系统就不知道何时去唤醒发起I/O的程序
4,效率低,与正常执行的指令相比,现代CPU对于关闭和打开中断的代码执行的较慢

基于以上原因,只在很有限的情况下用关闭中断来实现互斥原语,如某些情况下操作系统本身会采用屏蔽中断的方式,保证访问自己数据结构的原子性

3.4 测试并设置指令/原子交换

因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让硬件支持锁,最简单的硬件支持是测试并设置指令(test-and-set),也称原子交换

其工作方式很简单:用一个变量作为锁表示临界区是否被某些线程占用,当第一线程进入临界区,调用lock(),检查标志是否为1,如果不是1,则设置为1表明线程持有该锁,进入临界区,从临界区退出时,调用unlock(),清除标志,表示锁未被持有

void init() {
    
    
	//初始化将锁的标志设置为0
	mutex->flag = 0;
}

void lock(lock_t *mutex) {
    
    
	while(mutex->flag == 1) {
    
    
		;	//调用lock时发现标志为1,说明该锁已被持有,陷入循环中等待
	}
	mutex->flag = 1; //设置标志为1,持有锁
}

void unlock(lock_t *mutex) {
    
    
	mutex->flag = 0; //调用unlock时将标志置为0
}

当第一个线程正处于临界区时,如果有一个线程调用lock(),它会在while循环中自旋等待,直到第一个线程退出临界区并调用unlock(),等待的线程才会退出while,争抢着设置标志为1,持有锁并执行临界区代码

但是这种方式实现锁,也有缺陷:
1,这种交替执行时,通过适时的中断,可以让多个线程都将标志设置为1,都能进入临界区,这就无法满足互斥
2,性能问题,当线程在等待已经被持有的锁时,采用了自旋等待的技术,即不停的检查标志的值,自旋等待的线程在等待其它线程释放锁时会浪费时间

3.5 自旋锁

(1) 实现一个可用的自旋锁

对于使用测试并设置指令实现一个锁,必须得有硬件的支持,测试并设置指令的内容必须原子执行

测试并设置指令它返回old ptr指向的旧值,同时更新new的新值,这些代码原子地执行,通过这段代码就可以简单实现一个自旋锁

int TestAndSet(int *old_ptr, int new) {
    
    
	int old = *old_ptr;
	*old_ptr = new;
	return old;
}

typedef struct lock_t {
    
    
	int flag;
}lock_t;


void init(lock_t *lock) {
    
    
	lock->flag = 0;	//初始化时设置标志为0
}

void lock(lock_t *lock) {
    
    
	//调用lock时调用测试并设置指令检查当前flag的值
	//如果flag为1则表明锁已被持有,调用lock的线程进入自旋等待
	while(TestAndSet(&lock->flag, 1) == 1) {
    
    
		;
	}
}


void unlock(lock_t *lock) {
    
    
	//调用unlock时,持有锁的线程将flag置为0,让锁等待竞争
	lock->flag = 0;
}

将测试和设置合并成一个原子操作后,就可以保证只有一个线程能获取锁,这就实现了以恶有效的互斥原语

这种锁被称为自旋锁的原因,就是让等待的线程一直自旋,利用CPU周期,直到锁可用,在单处理器上,需要抢占式的调度器(通过时钟中断一个线程,运行其它线程),否则自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU

(2) 评价自旋锁

对于自旋锁根据上面设定的几个标准进行评价

1,正确性:自旋锁一次只允许一个线程进入临界区,因此它是正确的锁
2,公平性:自旋锁不提供任何公平性保证,自旋的线程在竞争条件下可能会永远自旋,自旋锁没有公平性,可能会导致线程饿死
3,性能
在单CPU上,自旋锁的开销相当大,假设一个线程已经持有了锁,线程调度器可能会运行其它的所有线程,每个线程在得到锁之前,还要自旋一次(进行一次flag的判断),这会浪费CPU周期

在多CPU上,自旋锁性能尚可,假设线程A在CPU1,线程B在CPU2,它们竞争同一个锁,线程A持有锁时,线程B就会在CPU2上自旋,并没有浪费很多CPU周期

3.6 几种实现锁的硬件原语

(1) 比较并交换

比较并交换指令的C伪代码:

int CompareAndSwap(int *ptr, int expected, int new) {
    
    
	int actual = *ptr;
	if(actual == excepted) {
    
    
		*ptr = new;
	}
	return actual;
}

其基本思路就是检测ptr指向的值是否和expected相等,如果是,则更新ptr所指的值为新值,否则什么也不做,不论哪种情况,都返回该内存地址的实际值,让调用者知道执行是否成功

比较与交换指令实现的锁:

void lock(lock_t *lock) {
    
    
	//每次调用lock时,调用CompareAndSwap指令
	//传递的是flag和期望值及更新值
	//如果发现当前flag的值与比较值相同为0,则设置flag为1,持有锁
	//如果当前值是1与期望值0不同则线程进入自旋
	while(CompareAndSwap(&lock->flag, 0, 1) == 1) {
    
    
		;
	}
}

(2) 链接的加载和条件式存储指令

一些平台提供了实现临界区的一对指令,如链接的加载指令条件式存储指令,可以用来配合使用,实现其它并发结构

int LoadLinked(int *ptr) {
    
    
	return *ptr;
}

int  StoreConditional(int *ptr, int value) {
    
    
	//如果ptr指向的值在加载指令执行前没有被更改,则返回1
	if(no one has updated *ptr since the LoadLinked to this address) {
    
    
		*ptr = value;
		return 1;
	}
	return 0;
}

链接的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器,关键区别来自条件存储指令,只有上一次加载的地址在期间都没更新时,才会成功,成功时条件存储返回1,并将ptr指向的值更新为value,失败时,返回0,并且不会更新值

锁的实现:

void lock(lock_t *lock) {
    
    
	while(1) {
    
    
		while(LoadLinked(&lock->flag) == 1) {
    
    
			;
		}
		if(StoreConditional(&lock->flag, 1) == 1) {
    
    
			return;
		}
	}
}

(3) 获取并增加指令

获取并增加指令能原子地返回特定地址地旧值,并且让该值自增1

不是用一个值flag来做为标记,这个方案采用了ticket和turn变量来构建锁,如果线程希望获取锁,首先对一个ticket值执行一个获取并增加指令,这个值作为该线程的turn(顺位),根据全局共享的lock->turn,当某一个线程(myturn == turn)时,则轮到这个线程进入临界区,unlcok是增加turn,从而让下一个等待的线程进入临界区

int FetchAndAdd(int *ptr) {
    
    
	int old = *ptr;
	*ptr = old + 1;
	return old;
}

typedef struct lock_t {
    
    
	int ticket;
	int turn;
} lock_t;

void lock_init(lock_t *lock) {
    
    
	lock->ticket = 0;
	lock->turn = 0;
}


void lock(lock_t *lock) {
    
    
	//调用lock的线程先对它的ticket值加一作为myturn
	//直到myturn等于它的顺位才能持有锁
	int myturn = FetchAndAdd(&lock->ticket);
	while(lock->turn != myturn) {
    
    
		;
	}
}

void unlock(lock_t *lock) {
    
    
	//对当前线程的turn增加1,一个线程的turn越大,它的顺位myturn越大
	//他自旋的时间越长,顺位越低
	FetchAndAdd(&lock->turn);
}

不同于之前的方法,本方法能保证所有的线程都能得到锁,只要一个线程获得了ticket值,它最终都会被调度

3.7 自旋过多的解决方法

(1) 自旋过多引起的问题

基于硬件的锁简单且有效,但是在某些场景下,这些解决方案效率并不高

单处理器上运行两个线程,当线程A持有锁时,线程B尝试获取锁时,线程B会陷入自旋,当时钟中断产生时,CPU调度线程B,此时线程A释放锁,线程B获取锁,在类似情况下,一个线程会一直检查一个不会改变的值,浪费掉整个时间片,如果有N个线程在单处理器上,情况会更糟,会浪费N-1个时间片,它们只是自旋并等待持有锁的线程释放锁

只有硬件支持是不够的,还需要操作系统的支持

(2) 简单方法:让出CPU

通过ticket锁,可以实现有效,公平的锁,但问题依然存在,如果临界区的线程发生上下文切换,其它线程只能一直自旋,等待持有锁的线程重新运行,直到它释放锁,这对性能是很大的影响

最为简单的方法是在要自旋的时候,放弃CPU

void init() {
    
    
	flag = 0;
}

void lock() {
    
    
	while(TestAndSet(&flag ,1) == 1) {
    
    
		yield(); //放弃CPU
	}
}

void unlock() {
    
    
	flag = 0;
}

可以假定操作系统提供yield()原语,线程可以调用它主动放弃CPU,yield()让处于运行态的线程变为就绪态,从而允许其它线程运行,因此,让出CPU本质是取消调度自己

现在考虑100个线程竞争一把锁的情况,当一个线程持有锁时,其它99个线程调用lock()时,发现锁已经被持有,然后它们退出CPU,假定采用某种轮转调度程序,这99个线程会一直处于运行-让出CPU这种模式,直到持有锁的线程被调度,才可能有机会得到锁。虽然比原来的浪费99个时间片的自旋方案要好,但这种方式成本依然很高,上下文切换的成本是不可避免的

更糟糕的是,没有考虑到线程饿死的情况,一个线程可能一直处于让出CPU的循环,其它线程反复进出临界区,因此需要更好的方法

(3) 使用队列:休眠代替自旋

调度程序决定如何调度,如果调度不合理,线程或者一直自旋,或者让出CPU,这两种方法,都可能造成浪费,也不能防止饿死

因此需要更多的控制,决定释放锁时,谁能抢到锁,为了做到这一点,需要操作系统的更多支持,并需要一个队列来保存等待锁的线程

通过队列来控制谁会获得锁,避免饿死

(4) Linux的两阶段锁

Linux采用的两阶段锁,两阶段锁意识到自旋可能会有用,尤其是很快要释放锁的场景,因此两阶段锁的第一阶段会先自旋一段时间,希望它可以获取锁,但是如果第一个自旋阶段没有获得锁,第二阶段调用者会休眠,直到锁可用

两阶段锁又是一个杂合方案的例子,当然硬件环境,线程数,其它负载这些因素,都会影响锁的效果

(5) 小结

上述这些方案展示了真实的锁是如何是实现的:一些硬件支持和一些操作系统支持

猜你喜欢

转载自blog.csdn.net/weixin_43541094/article/details/110879905