个人上一篇博客的案例中,请求锁的线程若是发现锁已经被其余线程占用,它是经过自旋的方式来等待的,也就是不断地尝试直到成功。本篇就讨论一下另外一种方式,那就是挂起以等待唤醒。安全
注:相关代码都来自《Operating System: Three Easy Pieces》这本书。数据结构
先说明一下,自旋也有它的好处,不过这里先不讲,咱们先讲它可能存在哪些问题。多线程
咱们考虑一个极端的场景,某个电脑只有一个CPU,这时候有2个线程竞争锁,线程A得到了锁,进入临界区,开始执行临界区的代码(因为只有一个CPU,线程A在执行的时候,线程B只能在就绪队列中等待)。结果线程A还没执行完临界区的代码,时间片就用完了,因而发生上下文切换,线程A被换了出去,如今开始执行线程B,线程B就开始尝试获取锁。函数
这时候尴尬的事情就来了,拥有锁的线程没在运行,也就不能释放锁。而占据CPU的线程因为获取不到锁,就只能自旋直到用完它的时间片。spa
这还只是2个线程的状况,若是等待的线程有100多个呢,那在轮询调度器的场景下,线程A是否是要等到这100多个线程所有空转完才能运行,这浪费可就大了!线程
yield()方法是把调用线程之间切出,放回就绪队列。这个方法与前面的不一样就在于,当线程B恢复执行的时候,它只会尝试一次,若是失败,则直接退出,而不会用完它的整个时间片。也就是说被调度的线程最多只会尝试一次。这样虽然会比自旋好一点。可是开销仍是不小,对于100多个等待线程的状况,每一个都要进行一遍run-and-yield操做。上下文切换的开销也是不容小觑的。code
前面有之因此还会有过多的上下文切换,就是由于等待的线程仍是会不断尝试,只是没以前那么频繁罢了。对象
那不让这些等待线程执行不就行了?blog
能够啊,只须要将这些线程移出就绪队列,它们就不会被OS调度,也就不会被运行。队列
挂起是能够了,还得想一想谁来唤醒,怎么唤醒?
唤醒操做确定由释放锁的线程处理。另外一方面,咱们把线程挂起的时候,确定得用一个数据结构把这个线程的信息记录下来,否则要唤醒的时候都不知道该唤醒谁。而这个数据结构确定得跟锁对象关联起来,这样释放锁的线程也就知道该从哪里拿这些数据。
typedef struct __lock_t { int flag; //标识,锁是否被占用 int guard; //守护字段 queue_t *q; //等待队列,用于存储等待的线程信息 } lock_t; void lock_init(lock_t *m) { m->flag = 0; m->guard = 0; queue_init(m->q); } void lock(lock_t *m) { while(TestAndSet(&m->guard, 1) == 1) ;//经过自旋得到guard if (m->flag == 0) { m->flag = 1; m->guard = 0; } else { queue_add(m->q, gettid()); m->guard = 0; //注意:在park()以前调用 park(); //park()调用以前,线程已经成功加入队列 } } void unlock(lock_t *m) { while(TestAndSet(&m->guard, 1) == 1) ;//经过自旋获取guard if(queue_empty(m->q)) //若是没有等待的线程,则将锁标识为“空闲” m->flag = 0; else unpark(queue_remove(m->q)); //唤醒一个等待线程,此时锁标识仍为“已占用” m->guard = 0; }
park()与unpark(threadID)
park()与unpark(threadID)是Solaris系统提供的原语,用于挂起和恢复线程。其余系统通常也会提供,可是细节可能有所不一样。
park() => 将当前调用线程挂起
uppark(threadID) => 根据线程ID唤醒指定线程。
guard字段的用途
我在看这段代码的时候有一个疑问,那就是这个queue_t是在哪里定义的,它究竟是什么样子?这个队列内部是否是要作同步操做?不一样步的话, 多个线程同时访问,队列的数据结构就可能被破坏。实际上,仔细看代码就会发现,在操做队列的时候,线程须要先得到guard。也就是说,同一时刻只能有一个线程可以访问队列。因此这个队列是安全的,它自身并不须要提供同步。因此,书上才没有贴出源码。随便一个队列实现就能够了。
实际上guard字段用于控制多线程对lock对象的访问,同一时刻只能有一个线程可以对lock对象的其余信息(除guard字段外)进行修改。
上述代码存在的问题
由代码可知,当guard被释放的时候,其余线程就能访问Lock对象了。那就可能出现一种状况,即释放了guard,但还没来得及执行park()就发生了上下文切换。这个时候存在什么问题呢,咱们来看下图:
因为上下文切换的缘故,Thread A 已经加入了等待队列,但并无执行挂起操做。结果占有锁的线程释放的时候,恰好从队列中取出Thread A,Thread A被唤醒,放入就绪队列,等到下次调度的时候执行。Thread A恢复,继续向下执行,调用park()方法。结果就是Thead A被永久地挂起!!!。由于这个时候它已经从等待队列中移除了,谁也不知道它被挂起了。
OS提供的解决方法
OS提供一个setpark()函数来标识某个线程将要执行park()操做。若是在这个线程(好比Thread A)执行park()操做以前,其余线程(如Thread B)对其执行了unpark(threadID)方法,则该线程(Thread A)在执行park()会当即返回。更改以下:
... queue_add(m->q, gettid()); setpark(); m->guard=0; park(); ...
PS:实际上这个setpark()函数应该也只是在底层的Thread对象中设置了一个flag,park()函数内会查看一下这个flag。只不过这个底层的Thread对象咱们访问不到罢了。