Windows核心编程【8】小结

第八章 用户模式下的线程同步

1、当所有的线程都能够独自运行而不需要相互通信的时候,MS将进入最佳运行状态。
2、通常创建线程是为了处理某些任务,当任务完成的时候,另一个线程可能想要得到通知。
3、如果一个线程独占了对某个资源的访问,那么其他线程就无法完成它们的工作。另一方面,也不能让任何线程在任何时刻都能访问任何资源。
4、在以下两种基本情况下,线程之间需要相互通信。需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性。或者一个线程需要通知其他线程某项任务已经完成。


一、原子访问:Interlocked系列函数

1、线程同步的一大部分与原子访问(atomic access)有关。所谓原子访问,指的是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问某一资源。
2、编译器如何生成代码,代码在什么CPU上运行,机器上装配了多少个CPU,所有浙西儿都可能会导致不同结果。这就是开发和运行环境,我们无法对它们进行控制。
3、一个简单的解决方案,需要一种方法能够保证对一个值的递增操作是原子操作——也就是说,不会被打断。Interlocked系列函数提供了我们需要的解决方案。
4、所有这些函数会以原子方式来操控一个值。
5、InterlockedExchangeAdd以及对LONGLONG类型进程操作的兄弟函数InterlockedExchangeAdd64。传一个长整型变量的地址和另一个递增值,函数就会保证递增操作是以原子方式进行的。
5、Interlocked函数是如何工作的取决于代码运行的CPU平台。如果是x86系列CPU,那么Interlocked函数会在总线上维持一个硬件信号,这个信号会阻止其他CPU访问同一个内存地址。
6、运用Interlocked函数只需要确保传给这些函数的变量地址是经过对齐的,否则这些函数可能会失败的。

7、C运行库提供了一个_aligned_malloc函数(http://msdn.microsoft.com/en-us/library/8z34s9c6(v=vs.80).aspx),可以用这函数来分配一块对齐过的内存。原型如下


void * _aligned_malloc(
    size_t size, 
    size_t alignment
);



size表示要分配的字节数,参数alignment表示要对齐到的字节边界。传给alignment参数的值必须是2的整数幂次方。

8、Interlocked函数的另一个特点是执行得极快。调用一次Interlocked函数通常只占用几个CPU周期(通常小于50),而且也不需要在用户模式和内核模式之间切换(这个切换通常需要占用1000个周期以上)。
9、InterlockedExchange和InterlockedExchangePointer会把第一个参数所指向的内存地址的当前值,以原子方式替换为第二个参数指定的值。对32位应用程序来说,两函数替换的都是32位值,但对64位应用程序来说,前一个是32位值,后一个是64位值。返回值都是原来的值。在实现旋转锁(spinlock)的时候,InterlockedExchange极有作用。
10、旋转锁会耗费CPU时间,CPU必须不断地比较两个值。对用到旋转锁的线程来说,我们可能想调用SetProcessPriorityBoost或SetThreadPriorityBoost来禁用线程优先级提升(thread priority boosting)。
11、此外,必须确保锁变量和锁所保护的数据位于不同的高速缓存行。如果锁变量和数据共享同一高速缓存行,那么使用资源的CPU就会与任何试图访问资源的CPU发生争夺,而这会影响性能。
12、在单CPU的机器上应该避免使用旋转锁。调用Sleep在某种程度上改善了这一状况,如果使用Sleep,那么可以睡眠一段随机的时间。但取决于实际情况,把Sleep调用完全去掉可能会更好。
13、旋转锁假定被保护的资源始终只会被占用一小段时间。与切换到内核模式然后等待相比,在这种情况下以循环的方式进行等待的效率会更高。许多开发人员会循环指定的次数(比如4000),如果届时仍然无法访问资源,那么线程会切换到内核模式,并一直等待资源可供使用为止(此时它不消耗CPU时间)。这就是关键段(critical section)的实现方式。
14、最后两个Interlocked交换函数,InterlockedCompareExchange和InterlockedCompareExchangePointer函数。
15、没有哪个Interlocked函数可以仅用来读取一个值(但又不修改它),这是因为这样的功能没有必要。如果需要读取一个始终通过Interlocked函数修改的值,那么读到的值不会有任何问题。
16、还有InterlockedIncrement和InterlockedDecrement,还能使用一些列被称为Interlocked单向链表的栈进行操作。



二、高速缓存行

1、如果想为有多处理器的机器构建高性能应用程序,那么应该注意高速缓存行。(CPU寄存器和内存之间的cache)
2、高速缓存行可能包含32字节(老式CPU),64字节,甚至128字节(取决于CPU),它们始终对齐到32字节边界,64字节边界,或128字节边界。高速缓存行存在的目的是为了提高性能。
3、一般来说,应用程序会对一组相邻的字节进行操作。如果所有字节都在高速缓存中,那么CPU就不必访问内存总线,后者消耗的时间比前者耗费的时间要多得多。但是在多处理器环境中,高速缓存行使得对内存的更新变得更加困难。
4、当一个CPU修改了高速缓存行中的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。因此,可以看到,虽然高速缓存行能够提高性能,但在多处理器的机器上它们同样能够损伤性能。
5、这就意味着我们应该根据高速缓存行的大小来将应用程序的数据组织在一起,并将数据与缓存行的边界对齐。这样做的目的是为了确保不同的CPU能够各自访问不同的内存地址,而且这些地址不在同意高速缓存行中。此外,应该把只读数据(或不经常读的数据)与可读写数据分别存放。还应该把差不多会在同一时间的访问的数据组织在一起。
6、要想确定CPU高速缓存行的大小,最简单的方法是调用Win32的GetLogicalProcessorInformation函数,这个函数返回一个SYSTEM_LOGICAL_PROCESSOR_INFORMATION结构数组。可以检查每个结构的Cache字段,该成员是一个CACHE_DESCRIPTOR结构,其中的LineSize字段表示CPU的高速缓存行的大小。
7、可以使用C/C++编译器的__declspec(align(#))指示符来对字段对齐加以控制。如下实例:

8、最后是始终只让一个线程访问数据(函数参数和局部变量是确保这一点的最简单调方式),或者始终只让一个CPU访问数据(使用线程关联性,即thread affinity)。只要能做到其中任何一条,就可以完全避免高速缓存行的问题了。


三、高级线程同步

1、在配备单处理器的机器上,不应该使用旋转锁,即使在配备多处理器的机器上,在使用旋转锁的时候也应该谨慎。因为,浪费CPU时间是非常糟糕的事情。因此,需要一种机制,它既能让线程等待共享资源的访问权,又不会浪费CPU时间。
2、既是事件通知的方法,告诉系统需要等待的事件,然后休眠,然后等待响应时间触发,线程被唤醒。实际情况是,大多数线程在大部分情况下都处于等待状态。当系统检测到所有线程都已经在等待状态中度过了好几分钟,系统的电源管理器将会介入。
3、由于操作系统内建了对线程同步的支持,因此我们在任何时候都不应该使用如下方法:两个线程共享一个变量,其中一个线程不断地读取变量的值,直到另一个线程完成它的任务为止。但是,如果一个线程的优先级比另一个线程的优先级高,可能会早餐另一个线程一直无法运行。(一直调度到等待的高优先级线程,无法调度到要释放变量的低优先级线程)
4、轮循有时候还是非常方便的,毕竟旋转锁就是这么做的。但是也还有正确的方式和不正确的方式。一个通用的规则是,我们既不应该使用旋转锁,也不应该进行轮循,而应该调用函数把线程切换到等待状态,知道线程想要访问的资源可供使用为止。(下节对正确的方式进行介绍)
5、volatile关键字,这个限定符告诉编译器不要对这个变量进行任何形式的优化,而是始终从变量在内存中的位置读取变量的值。如果传一个变量的地址给函数,那么寒素必须从内存中读取它的值,编译器的优化程序不会对此产生影响。


四、关键段

1、关键段(critical section)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”来对资源进行操控。这里的原子方式,指的是代码知道除了当前线程之外,没有其他任何线程会同时访问该资源。系统仍然可以暂停当前线程去调度其他线程(可被中断),但是,在当前线程离开关键段之前,系统是不会去调度任何想要访问同意资源的其它线程的。
2、CRITICAL_SECTION结构,对一种资源进行保护。在访问该资源的时候,必须调用EnterCriticalSection,并传入该资源的CRITICAL_SECTION保护结构地址。用来检测资源的占用标志的函数,如果发现无人使用资源,则继续访问。否则,则会等待。访问资源后,应该调用LeaveCriticalSection。
3、只要有一个线程有粗暴(不调用Enter和Leave函数访问资源)的行为,资源就会被破坏。
4、关键段的最大好处在于它们非常容易使用,而且它们在内部也使用了Interlocked函数,因此执行速度非常快。关键段的最大缺点在于它们无法用在多个进程之间对线程进行同步。
5、CRITICAL_SECTION在WinBase.h被定义为RTL_CRITICAL_SECTION,该结构又在WinNT.h中被定义。
6、一般情况下,将CRITICAL_SECTION结构作为全局变量来分配。在使用的时候,只有两个必要条件。一个是所有想要访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结构的地址,我们可以通过自己喜欢的任何方式来把这个地址传给各个线程。二是在任何线程试图访问被保护的资源之前,必须对CRITICAL_SECTION结构的内部成员进行初始化。
7、InitializeCriticalSection(设置CRITICAL_SECTION结构的一些变量)和DeleteCriticalSection函数。
8、EnterCriticalSection会执行以下测试:
a、如果没有线程正在访问资源,那么会更新成员变量,以表示调用线程已经获准对资源的访问,并立即返回,这样线程就可以继续执行(访问资源)。
b、如果成员变量表示调用线程已经获准访问资源,那么会更新变量,以表示调用线程被获准访问的次数,并立即返回,这样线程就可以继续执行。这种情况非常少见,只有当线程在调用Leave之前连续调用Enter两次以上才会发生。(多次调用Enter作用何在?)
c、如果成员变量表示有一个(调用线程之外的其它)线程已经获准访问资源,那么Enter会使用一个事件内核对象(下章介绍)来把调用线程切换到等待状态。这样等待中的线程不会浪费任何CPU访问。系统会记住这个线程想要访问该资源,一旦当前正在访问资源的线程调用了Leave,系统会自动更新CRITICAL_SECTION的成员变量并将等待中的线程切换回可调度状态。
9、Enter的内部并不怎么复杂,就只是执行了一些简单的测试。这个函数的价值在于它能够以原子方式执行所有这些测试。在多处理器中也能正确地执行。
10、如果Enter把一个线程切换到等待状态,那么在很长一段时间内系统可能不会去调度这个线程。事实上,在一个编写得非常糟糕的应用程序中,系统可能再也不会给这个线程调度CPU时间了。这种情况,我们说线程在挨饿(starved)。实际的情况是,等待关键段的线程是绝对不会挨饿的,对Enter的调用最终会超时并引发异常。导致超时的时间长度由下面这个注册表子项中包含的CriticalSectionTimeout值决定:HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager,这个值以秒为单位,默认为2592000s,也即是大约为30天。
11、TryEnterCriticalSection从来不会让调用线程进入等待状态,它会通过返回值来表示调用线程是否获准访问资源。如果TryEnterCriticalSection返回TRUE,那么CRITICAL_SECTION的成员变量已经更新过了,以表示该线程正在访问资源。所以每个返回值为TRUE的TryEnterCriticalSection调用必须有一个对应的LeaveCriticalSection。
12、LeaveCriticalSection会检查结构内部的成员变量并将计数器减1,该计数器用来表示调用线程获准访问共享资源的次数。如果计数器大于0,LeaveCriticalSection会直接返回,不执行任何其他操作。如果计数器变为0,LeaveCriticalSection会更新成员变量,以表示没有任何线程正在访问被保护的资源。同时会检查有没有其他线程由于调用了EnterCriticalSection而处于等待状态。如果至少有一个线程处于等待状态,那么函数会更新成员变量,把其中一个处于等待状态的线程切换回可调度状态。
13、当线程试图进入一个关键段,但这个关键段正被另一个线程占用的时候,函数会立即把调用线程切换到等待状态。这意味着线程必须从用户模式切换到内核模式(大约1000个CPU周期),这个切换的开销非常大。事实上,在需要等待的线程完全切换到内核模式之前,占用资源的线程可能就已经释放了资源。为了提高关键段的性能,MS把旋转锁合并得到了关键段中。因此,当调用EnterCriticalSection的时候,它会用一个旋转锁不断地循环,尝试在一段时间内获得对资源的访问权。只有当尝试失败的时候,线程才会切换到内核模式并进入等待状态。
14、在使用关键段的同时使用旋转锁,需要调用下面的函数来初始化关键段,InitializeCriticalSectionAndSpinCount。第一个参数为关键段结构,第二个参数为希望旋转锁循环的次数。可以从0到0x00ffffff之间的任何一个值。在单处理器上调用这个函数,函数会忽略则合格参数,因此次数总是为0。(一般大约为4000)
15、有一种情况下InitializeCriticalSection函数会失败,不过可能性很小。失败的原因是它会分配一块内存,这样系统就可以提供一些内部调试信息。如果内存分配失败,那么函数会抛出STATUS_NO_MEMORY异常。


五、Slim读/写锁

1、SRWLock的目的和关键段相同:对一个资源进行保护,不让其他线程访问它。但是与关键段不同的是,SRWLock运行我们区分那些想要读取资源的值的线程(读者线程)和想要更新资源的值的线程(写者线程)。只有当写者进程想要对资源进行访问更新的时候才需要进行同步。这就是SRWLock提供的全部功能。
2、首先需要分配一个SRWLOCK结构并用InitializeSRWLock函数对它进程初始化。(SRWLOCK结构在WinBase.h中被定义为RTL_SRWLOCK,后者在WinNT.h中定义)。一旦SRWLock的初始化完成之后,写者线程可以调用AcquireSRWLockExclusive,以尝试获得对被保护的资源的独占访问权。完成对资源的更新之后,应该调用ReleaseSRWLockExclusive解除对资源的锁定。对读者线程来说,同样有两个步骤,AcquireSRWLockShared和ReleaseSRWLockShared。仅此而已,不存在用来删除或销毁SRWLOCK的函数,系统会自动执行清理工作。
3、与关键段相比,有两点不同:不存在Try之类的函数,如果锁已经被占用,那么调用会阻塞调用线程;不能递归地获得SRWLock。就是不能为了多次写入资源而多次锁定资源,然后再多次调用Release释放锁定。

4、




六、条件变量

1、有时候我们想让线程以原子方式把锁释放并将自己阻塞,知道某一个条件成立为止。要实现这样的线程同步比较复杂。Windows通过SleepConditonVariableCS或SleepConditionVariableSRWLock函数,提供了一种条件变量。
2、条件变量结构CONDITION_VARIBALE结构。
3、要以原子方式操作一组对象时使用一个锁。一种常见的情况是多个对象聚在一起会构成一个单独的“逻辑”资源。
4、为了减少死锁,必须在代码中的任何地方以完全相同的顺序来获取资源的锁。在调用LeaveCriticalSection的时候顺序无关紧要。
5、不要长时间占用锁。在只需要读取资源快照的时候,完全可以用个变量先存放。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值