7.4 单CPU下关键区段的实现
在单CPU环境下,通过上述分析可以看出有两个原因可能导致数据不一致:
(1)线程切换,即当一个线程正在试图修改全局数据的时候,切换到另外一个试图修改同一数据的线程;
(2)中断发生,即一个线程正在试图修改全局数据的时候,发生中断,而该中断的服务程序也在试图修改同一变量。
因此,在单CPU环境下,只要在修改关键数据结构的代码段的执行过程中避免上述两类事件发生,就可以实现关键区段。其中,在修改关键数据的时候,一般不会涉及系统调用,在这种情况下,线程切换也是由于中断导致(系统时钟中断)的,因此,在单CPU环境下,要实现关键区段,只要临时禁止中断即可。一种可能的实现就是,在关键代码段的开始禁止中断,在关键代码段执行完毕后重新开启中断,代码如下:
__asm{ cli } nKernelObject+=100; __asm{ sti }
这样做的一个弊端就是,在代码的最后又硬性地重新打开了中断(sti指令),考虑这样一种情况:
VOID IncreaseGlobal() { __asm{ cli } nKernelObject++; __asm{ sti } } VOID ModifyGlobal() { __asm{ cli } IncreaseGlobal(); AnotherRoutine(); __asm{ sti } }
在ModifyGlobal函数中,认为IncreaseGlobal和AnotherRoutine都是关键区段中的代码,因此调用这两个函数的时候,首先禁止了中断。但IncreaseGlobal函数,在实际执行关键代码(nKernelObject++)的时候,也首先禁止了中断,完成修改后,又恢复了中断(sti)。这样问题就产生了:从IncreaseGlobal函数返回后,中断已经打开,此时AnotherRoutine实际上已经不在关键区段里面了!
产生上述问题的原因,就是在每个函数中都硬性地关闭或打开了中断,而没有考虑调用自己的更上级函数的实际情况。由此可见,通过直接关闭或打开中断的方式来实现关键区段是不合理的。一个合理的实现是,首先在关键区段的开始处保存EFLAGS寄存器的值,然后再关闭中断,在关键代码段的结束处恢复先前保存的EFLAGS的值,这样就可以确保不影响原始EFLAGS寄存器的值了。因为开启中断或者关闭中断,不过是对EFLAGS寄存器的一个标志位的清除或设置而已。代码如下:
VOID IncreaseGlobal() { __asm{ pushfd //Save EFLAGS register. cli } nKernelObject++; __asm{ popfd //Restore EFLAGS register. } }
从IncreaseGlobal函数返回后,原来设置的中断标志得以保存,从而使得调用函数的关键区段使之连续。
Hello China的实现就是遵循了这个原则,不过是把所有上述汇编语言完成的功能,使用一个宏定义来代替,以增强代码的可移植性:
#define __ENTER_CRITICAL_SECTION(objptr,flags) \ __asm{ push eax \ pushfd \ pop eax \ mov flags,eax \ pop eax \ cli \ } #define __LEAVE_CRITICAL_SECTION(objptr,flags) \ __asm{ \ push flags \ popfd \ }
为了不破坏代码的堆栈框架,我们使用一个局部变量flags来保存EFLAGS寄存器的值(如果直接使用PUSHFD指令,可能会破坏调用上述两个宏的函数的堆栈框架)。因此,在调用上述两个宏的时候,必须首先声明一个局部变量,代码如下:
DWORD dwFlags; __ENTER_CRITICAL_SECTION(NULL,dwFlags); nKernelObject+=100; … … … //Other critical code here. __LEAVE_CRITICAL_SECTION(NULL,dwFlags);
__ENTER_CRITICAL_SECTION和__LEAVE_CRITICAL_SECTION宏,还有另外一个参数objptr,是对象指针(__COMMON_OBJECT指针)用来完成在多CPU下关键区段的实现,在目前版本的Hello China中,由于尚不支持多CPU,因此该参数可以设置为NULL。