OS:Three Easy PiecesLockpages.cs.wisc.edu
导语
锁Lock,正如现实中的锁一样,决定了对于资源的访问权。在并发编程中,由于资源共享的缘故,一个线程中的write操作有可能影响到另一个线程的read操作。
部分严格的程序员为了杜绝这种side effect,选择了Functional Programming,以确保完全的Thread Safety。而在正常的结构化编程中,程序员倾向于使用锁,防止意料之外的Side Effect。
锁控制了一个资源只能被一个线程同时访问,因此有效避免了多线程情况下的读写导致的异常输出。
自旋Spinning锁
typedef
这是一个最基本的版本,当flag置1时,锁被获得,而flag置0时,锁被释放。而当锁没有被释放时,程序将会不断检测flag,而不做任何实际事情,因此被称作自旋。
原子性Atomicity
这个版本存在着一个致命的bug,因为CPU调度并不保证Line a与Line b之间不会插入其他线程的代码。如果在line a之后其他线程已经获得了锁,那么line ba仍然会被执行,也就是说flag的检测和设置被分开了,导致同时有两个线程持有这把锁。
我们一般使用atomic exchange来保证获取锁会是原子性操作,要么同时完成flag的检测和设置,要么什么都不做。这里C代码形式如下(TestAndSet):
int
而另一种形式为(CompareAndSwap),作用类似,但比TestAndSet更为泛用
int
饥饿Starvation
由于CPU调度并不保证先试图获取锁的必定能先获得,可能出现某个线程很久无法获得锁的情况。一个简单的想法就是使用队列,保证FIFO。
我们先以FetchAndAdd作为原子性的后置++操作。
int
正如同餐厅叫号,turn表示当前的号码,ticket表示手中的号码,每一个顾客(线程)用完之后就呼叫下一个号码。
Sleeping锁
由于自旋锁导致每个线程都在执行while操作,空转造成了极大浪费,因此一种改进思路是:在没有获得锁之前,令线程直接沉睡。而当释放锁时,再唤醒下一个线程。同理,我们使用queue作为数据结构,但是维护一个显式的链表。
typedef
lock_t:
这里的flag表示锁有没有被线程需求,锁可以同时被多个线程所等候,仅当没有线程等候时才会置0。
而guard是lock和unlock过程的一个自旋锁。在过程结束后自动释放。(basically as a spin-lock around the flag and queue manipulations the lock is using)
void
lock:
当锁中队列为空时: 置flag为1,即flag锁被占用
当锁中队列不为空时: 入队,使用park操作令线程休眠等待唤醒。
void
unlock:
当锁中队列为空时:置flag为0,即此锁闲置
当锁中队列不为空时:出队,使用unpark操作唤醒下一个线程并释放锁
Buggy
queue_add
假如在line a和line b之间正好有一个线程unlock了,那么将会唤醒当前正在加锁的线程,然后再运行line b使得当前线程进入休眠,而队列中当前线程却已经出队。这样一来,陷入休眠的当前线程就不再可以被唤醒了。
为了解决这个问题,如果能直接让ab原子性就好了,然而实际情况却很难做到。
我们可以特异性针对上面的问题处理,例如某种实现中,setpark函数可以令程序进入准备park的状态,如果在park之前进程已经被unpark,那么park将直接返回。
queue_add
Two-phase锁
实际操作系统中,互斥锁的实现综合了以上两种锁的实现。以下是Linux的Mutex实现机制。
膜这段代码!!!
void
Two-phase 锁意识到对于那些将会马上被释放的锁,使用自旋锁更有益处。而唤醒等操作需要使用更多的sys-call,因此会增大开销。
futex_wait和futex_wake会在内核态维护一个mutex对应的队列。
在第一阶段,线程将会自旋若干次,试图获取锁。
一旦第一阶段没有完成,则会进入第二阶段,线程沉睡,直到锁被释放后将线程唤醒。
上述linux的实现只自旋了一次,但是也可以使用有固定自旋次数的循环。
注意:
这里setpark的原因和上面不同,因为这里不会出现先入队列再沉睡的情况。
Special case: Queue Empty
假如没有v>=0的判断,
假如B lock中间插入C unlock,由于队列为空,lock位变为0,不wake下一个线程。此时B wait,则无法被唤醒。
但是如果continue,B就能直接拿锁,而不会wait。
联想
我个人其实是把这个和轮询/中断类比的,
以IO为例,轮询需要CPU不断访问IO,而中断则是仅当IO发生改变时CPU才进行访问。
同理,Spin其实就是CPU不断判断锁,而Sleep则是仅当锁被释放时才唤醒下一个线程。
尽管所处的抽象层次不同,但是这种思想确实有共通之处。
再往上,其实观察者模式也类似,查询其他对象是否发生改变很困难,因为在不知情的情况下,每时每刻都有可能发生改变。但改变的当事人想通知查询者就很容易,只需要在改变的同时发出消息即可。
猜想或者归纳一下,或许这种现象是由于信息隔离导致的。正是因为信息交流的双方信息的不对等,才导致传递这种信息所需要的代价截然不同。