WDK tips (9.1) 同步机制与锁

这篇文章基本上就是把WDK文档复述了一下,算不上原创,各位将就着看吧。

在用户态的世界很多程序员(特别是*NIX界的人)不喜欢用多线程,认为这东西大大增加了程序的复杂度的同时带来的好处却不多,他们宁愿用进程来分割任务。当然这是一种很好的设计原则,我个人也持一模一样的观点。但是自从多核被炒热之后这部分内容越来越受关注,你假装问题不存在已经不可能了,借用冠希哥的日歪普歌词说就是:就算忘记你们不可能看不见。而在内核态的世界多线程的传统由来已久,因为内核部分的地址空间多半是共享的,即使是多进程架构在反映在内核部分也就是不同的线程,并且操作硬件的过程中不被重入也是基本要求。如果你开发过驱动或者玩过内核,那么同步机制和锁你一定已经看的很多了。

各种成熟的操作系统内核都会提供非常丰富的锁与同步机制来保证你的代码和资源不被重入,NT内核也不例外。但我发现驱动(或内核)开发人员常用的锁却很少,spin lock算一个,event算第二个,了不起加上个mutex就完了,其他锁的出镜率低的可怜。我看过比较离谱的例子是只要用到锁的地方就用spin lock,不管合适不合适。这么做想也知道是不对的,操作系统提供了不同类型的锁自然是希望你不同情况用不同的,全用spin lock虽然没风险但显然也很低效。本节列举了WDK中提供的各种锁机制,并试图指出何时该用哪种锁,以及更重要的,何时不该用那种。先从自旋锁说起。

自旋锁(spin lock)

自旋锁基本上算是最简单的同步机制了。我记得上学那会儿刚会写程序的时候碰到要同步的情况会写类似下面的代码:

    while( i == 0 );

    i = 0

    // do something

    i = 1

写完上面一段代码只需要几分钟,debug却debug了一天,弄的自己灰头土脸,被“高手”看了后免不了的又会被羞辱一通。没有一个靠谱的程序员会这么做同步,但是不管你信不信,自旋锁的核心机制就是上面那段while循环。所谓的自旋,就是CPU在某条指令上不停的回绕直到条件成立跳出,也就是我们常说的忙等。那上面那段代码也是不停自旋,它有什么问题?我们说它在多线程环境下会出问题。假设上面那两句编译完了后变成如下指令:

    WAIT:

    test if i equals 0

    jump to WAIT if true

    set i  into 0

    // do something

    set i into 1

又假设当前 i 为1,我们有两条线程同时执行,产生的指令流如下:

时间 线程1 线程2
0 test if i not 0  
1   test if i not 0
2 set i into 0  
3   set i into 0
4

第0条和第1条指令执行完后,两条线程都进入了临界区,很显然这是一个bug。这里的问题是test指令和set指令是分开的,中间会被重入,而我们不希望这种重入的发生。一个正确的实现要么需要及其复杂的算法保证,要么需要CPU提供set-and-test指令,在一条指令里面做完test和set两件事,保证不会被重入。NT内核用的是set-and-test指令,事实上这种指令在用户态也可以使用,就是InterlockedXXX那一堆API.

在多核CPU架构里set-and-test指令往往会把bus锁住导致并行效率降低,为了减少此类事件的发生NT内核实现自旋锁时用了两个循环,大循环用set-and-test检测,如果检测失败(也就是获得锁失败)立即进入小循环用普通的test指令检测,大致的代码如下:

    while( test i not 0 and then set i into 0 ){

        while( test i not 0 );

    }

另外考虑到检测值 i 不是cpu-local的值,两边的cpu cache需要不停的同步,每次i修改后参与排队的cpu的缓存就会变得无效,如果排队自旋锁竞争比较激烈的话,频繁的缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。又由于各cpu获得spin lock的时机是无序的,在竞争激烈的情况下很有可能会出现“饥饿”现象。基于这些理由,某些操作系统设计了 一种每个cpu都在自己的local变量上自旋,并能保证按先来先得顺序获得锁的的算法,此种自旋锁称为queued spinlock,具体信息请查看IBM Developerworks 上的文章,MSDN上也有简单的描述。

WDK里的自旋锁

之前我们说过NT内核里有IRQL的概念,在APC 及以上的level线程调度是停止的,每个cpu都只可能有一条线程在运行。基于这条原则我们可以很容易的看出,在单核系统里自旋锁完全没必要真去自旋,它只需要把IRQL提高到没有线程切换的等级即可,这就是NT内核做的。每次获得自旋锁的时候,内核会把当前的IRQL提高到DISPATCH_LEVEL(或以上)并保存之前的level,然后根据处理器数量做不同的事情:如果是单核,则啥也不做;如果是多核,进入自旋的逻辑。释放锁之后则作相反的事:把IRQL设置成之前的level。

WDK提供了三种类型的自旋锁:普通自旋锁,普通ISR自旋锁,以及ISR同步自旋锁。普通自旋锁会把IRQL设成DISPATCH_LEVEL,而ISR的IRQL一定会大于DISPATCH_LEVEL,所以如果你在ISR里用普通自旋锁那么一定会发生BSOD。普通自旋锁使用前必须要先调用KeInitializeSpinLock进行初始化。普通自旋锁的获取用KeAcquireSpinLock函数,释放用KeReleaseSpinLock。这两个函数都会修改IRQL,如果你很确认当前的IRQL是DISPATCH_LEVEL,那么可以使用KeAcquireSpinLockAtDpcLevel和KeReleaseSpinLockFromDpcLevel函数,这两个函数不会修改IRQL。普通ISR自旋锁允许运行在DIRQL中的ISR和运行在DISPATCH_LEVEL的DPC之间进行同步,ISR同步自旋锁则是允许不同的ISR之间进行同步。当你调用IoConnectInterrupt时有一个optional parameter叫做SpinLock,如果你传入的值是NULL,那么IoManager会自动给你生成一个普通自旋锁,ISR调用前自动加锁,调用后自动解锁;如果传入的值非0(也就是由你自己制定spin lock),那么该spin lock为ISR同步自旋锁,它可以为不同DIRQL上的ISR提供同步机制。值得注意的是,不管是普通ISR自旋锁还是ISR同步自旋锁你都不能用KeAcquireSpinLock加锁,因为这个函数会把IRQL置为DISPATCH_LEVEL。你应该使用KeSynchronizeExecution进行同步。

与Linux内核里的MCS spin lock类似的,NT内核里也有排队自旋锁,而且据我所知,NT内核比Linux更早实现排队自旋锁。排队自旋锁的工作方式如下:每个处理器上的执行线程都有一个本地的标志,通过该标志,所有使用该锁的处理器(锁拥有者和等待者)被组织成一个单向队列。当一个处理器想要获得一个已被其它处理器持有的排队自旋锁时,它把自己的标志放在该 Queued Spinlock 的单向队列的末尾。如果当前锁持有者释放了自旋锁,则它将该锁移交到队列中位于自己之后的第一个处理器。同时,如果一个处理器正在忙等待排队自旋锁,它并不是检查该锁自身的状态,而是检查针对自己的标志;在队列中位于该处理器之前的处理器释放自旋锁时会设置这一标志,以表明轮到这个正在等待的处理器了。获取排队自旋锁的函数是KeAcquireInStackQueuedSpinLockAtDpcLevel,释放锁用KeReleaseInStackQueuedSpinLockFromDpcLevel。照着WDK文档的说法,给xp以后的操作系统写驱动都应该用排队自旋锁而不是旧的自旋锁。

死锁

死锁发生的条件想必大家都很清楚,避免方法我也没必要多说,之所以在这里还要提这个话题是因为自旋锁有一个很奇葩的特性:即使只有一个线程一把锁,它照样会死锁。为方便起见我们假设自旋锁的实现是这样的:

    void lock(){

    //……

    // (1)

    while( test i not 0 and then set i into 0 ){

    while( test i not 0 );

    }

    // (2)

    }

另有一个递归函数会调用lock()函数,函数返回后释放锁,这时会发生什么事?我们看到第一次lock()函数运行到(2)时 i 是一定是0,接着执行第二次lock(),那么那段set-and-test代码一定没法跳出,函数不会返回,锁也就永远不会释放。虽然只有一个线程一把锁,却也出现了典型的死锁症状。这是使用自旋锁时特有的问题,其他锁都不会有这种情况。

posted @ 2012-06-11 13:35  gussing  阅读(1476)  评论(0编辑  收藏  举报