用户方式中线程的同步
线程需要在两种情况下相互通信:
- 当有多个线程访问共享资源而不使资源被破坏时
- 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时
8.1 原子访问:互锁的函数家族
原子访问指:线程在访问资源时,能确保所有其他线程都不在同一时间内访问相同的资源。
【例】
假设编译器将 g_x 递增1的代码为:
解析描述:
全局变量 g_x
线程一执行ThreadFunc1
线程二执行ThreadFunc2
两个线程的代码相同,但不可能在相同的时间内执行代码,所以无法说明运行结果 g_x 是什么指
实际情况如下
综上所述,无法预测运行结果是什么,但是可以通过原子操作的方式来保证线程安全。互锁函数提供了很多解决方案。
1. 可用 InterlockedExchangeAdd 函数
LONG InterlockedExchangeAdd(
PLONG plAddend,
LONG lIncrement);
调用这个函数,可传递一个长变量地址,并指明将这个值递增多少即可。这个函数能保证值的递增,以原子操作的方式来完成,可以吧上述代码改成:
所有线程都应该保证变量在某一刻只能被一个操作修改,可通过调用这些函数来共享长变量,不能简单地操作共享的变量。
【错误情况】
互锁函数
运行的逻辑:
- x86的CPU:互锁函对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址
- Alpha平台:执行以下操作
1. 打开 CPU 中的一个特殊的位标志,并注明被访问的内存地址。
2. 将内存的值读入一个寄存器。
3. 修改该寄存器。
4. 如果 CPU 中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。(如果系统中的另一个 CPU 试图修改同一个内存地址,那它能够关闭 CPU 的特殊位标志,从而导致互锁函数返回第二步)
特点
- 无论编译器怎么改代码,无论计算机有几个 CPU ,都能保证原子操作修改一个值
- 必须保证传递给这些函数的变量地址正确地对齐,否则这些函数就会运行失败
- 互锁函数运行速度极快,调用一个互锁函数通常会执行几个 CPU 周期(<50),并且不会从用户方式转换为内核方式(需要执行1000个 CPU 周期)
2. 循环锁
两个互锁函数:
详解:
while 循环运行,将 g_fResourceInUse的值改为 TRUE,并检查上一个值是否为 TRUE
如果这个值原先是 FALSE,那么该资源并没有在使用,而是调用线程将它设置为在用状态并退出该循环
如果前一个值是 TRUE,那么资源正在被另一个线程使用,while循环将继续循环运行
如果另一个线程要执行类似代码,要在 while 循环中运行,直到 g_fResourceInUse 重新改为FALSE。调用函数结尾处的InterlockedExchange,可显示应该如何将g _fResourceInUse 重新设置为FALSE
综上所述,这个操作会浪费 CPU 时间,CPU 必须不断比较两个值,直到某个值由于另一个线程改变为止
使用场景:
- 使用循环锁的所有线程的运行优先级相同,也可以把执行循环锁的优先级提高功能禁用
- 应保证将循环锁变量和循环锁保护的数据维护在不同的高速缓存中
- 如果循环锁变量与数据共享相同的高速缓存行,那么使用该资源的 CPU 将与试图访问该资源的任何 CPU 争用高速缓存行
- 避免在单个 CPU 计算机上使用循环锁。
- 受保护的资源总是被访问较短的时间,这使它能更加有效地循环运行,然后转为内核方式并进入等待状态(许多编程人员循环运行一定的次数(比如 400次),如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗CPU 时间),直到该资源变为可供使用为止。)
- 在多处理器计算机上非常有用,当一个线程循环运行时,另一个线程可在另一个 CPU 上运行,但是不应该让线程循环运行太长时间,也不能浪费更多的 CPU 时间
3. 设值操作
解析:
8.2 高速缓存行
- 高速缓存行由 32 或 64个字节组成(视 CPU 而定),并且始终在第32个字节或第64个字节的边界上对齐。
- 高速缓存行的作用是为了提高 CPU 运行的性能。通常,应用程序只对一组相邻的字节处理,如果字节在高速缓存中,那么 CPU 就不必访问内存总线(访问内存总线需要更多时间)
- 但是在多处理器中,高速缓存使内存更新更难
【例】
- CPU1 读取一个字节,使该字节和它的相邻字节被读入 CPU1 的高速缓存行。
- CPU2 读取同一个字节,使得第一步中的相同的各个字节读入 CPU2 的高速缓存行。
- CPU1 修改内存中的该字节,使得该字节被写入 CPU1 的高速缓存行。但是该信息尚未写入 RAM。
- CPU2 再次读取同一个字节。由于该字节已经放入 CPU2 的高速缓存行,因此它不必访问内存。但是 CPU2 将看不到内存中该字节的新值。
综上所述,得:
【反面案例】
设计得很差的数据结构:
改进版本:
- 定义 CACHE_ALIGN 宏不是很好,必须手工将每个成员变量的子节值输入该宏
- 如果增加、移动或删除数据成员,也必须更新对 CACHE_PAD 宏的调用
注意:最好始终让单线程来访问数据,或者始终让单 CPU 访问这些数据,采取其中一种就能完全避免高速缓存行的各种问题。
8.3 高级线程同步
若实际工作要解决比单个32位、64位值复杂多的数据结构,为了以原子操作使用更加复杂的数据结构,必须要考虑 Windows 的其他某些特性。
复杂的同步机制
- 前提:CPU时间非常宝贵,不能浪费资源
- 解决方案:使线程在等待访问共享资源时,不浪费 CPU 时间
要避免的方法
volatile 关键字:变量可以被应用程序本身以外的某个东西修改(这些东西包括操作系统,硬件或同时执行的线程等)
8.4 关键代码段
关键代码段指:一个小代码段,在代码能执行前,必须独占某些共享资源的访问权,在线程退出关键代码段之前,系统将不给想要访问相同资源的,其他任何线程进行调度。
关键代码段,是能够 “以原子操作方式” 来使用资源的一种方法。
【例】
以上代码存在一个问题:g_dwTimes 不会被正确地填入数据,因为两个线程函数,要同时访问相同的全局变量。
可以通过关键代码段,来确保在各个线程之间协调对数据结构的访问。
用关键代码段来修正代码:
注意:
- 编写共享资源的任何代码,都必须放在 EnterCriticalSection 和 LeaveCriticalSection 函数中,如果忘记将代码封装在一个为止,共享资源可能遭到破坏。
- 如果忘记调用 EnterCriticalSection 和 LeaveCriticalSection 函数,可能会造成一直等待,或者资源被破坏
综上所述,当你当无法用互锁函数来解决同步问题时,可以试用关键代码段。
- 关键代码段的优点在于:它的使用非常容易,在内部使用互锁函数,这样它们能迅速运行。
- 关键代码段的缺点在于:无法用它们对多个进程中的各个线程进行同步。
CRITICAL_SECTION 数据结构
用法:
- 可以调用一个Windows函数,给它传递该结构的地址。
- 可以作为全局变量来分配,进程所有线程就能很容易地按照变量名来使用该结构,也可以作为局部变量,从堆栈动态分配。
用时有两个要求:
- 需要访问该资源的所有线程都必须知道负责保护资源的 CRITICAL_SECTION 结构的地址,你可以使用你喜欢的任何机制来获得这些线程的这个地址
- CRITICAL_SECTION 结构中的成员应该在任何线程试图访问被保护的资源之前初始化。初始化函数如下
关键代码段与循环锁
当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意味着该线程必须从用户方式转入内核方式(大约 1000 个 CPU周期)拥有资源的线程,可以在另一个线程完成转入内核方式之前,释放资源。会浪费许多 CPU 时间。
- 为了提高性能,当 EnterCriticalSection 函数被调用时,使用循环锁进行循环,以便设法多次取得该资源。只有当为了取得该资源的每次试图都失败时,该线程才转入内核方式,以便进入等待状态。
调用初始化函数,将循环锁用于关键代码段:
关键代码段与错误处理
- InitializaCriticalSection 函数可能运行失败,因为它分配了一个内存块以便系统得到一些内部调试信息。如果该内存的分配失败,就会出现一个 STATUS_NO_MEMORY 异常情况。
- 在内存不足的情况下,关键代码段可能被争用,同时系统可能无法创建必要的事件内核对象。这时 EnterCriticalSection 函数将会产生一个 EXCEPTION_INVALID_HANDLE 异常。
- 可以使用结构化异常处理方法来跟踪错误,当错误发生时,既可以不访问关键代码段保护的资源,也可以等待某些内存变成可用状态,然后再次调用相应的函数
有用的提示和技巧
适用于内核对象的同步
- 每个共享资源使用一个 CRITICAL_SECTION 变量 :若应用程序中有若干互不相干的数据结构,则该为每个数据结构创建一个 CRITICAL_SECTION 变量,比只有单个该结构来保护对所有共享资源的访问要好。
- 同时访问多个资源:必须按照完全相同的顺序请求对资源的访问,但当调用 LeaveCriticalSection 函数时,按照什么顺序访问资源是没有关系的,该函数不会使进程进入等待状态
- 不要长时间运行关键代码段:当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行
性能。