第8章 用户方式中线程的同步

当所有的线程在互相之间不需要进行通信的情况下就能够顺利地运行时, M i c r o s o f t Wi n d o w s的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下,要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。

系统中的所有线程都必须拥有对各种系统资源的访问权,这些资源包括内存堆栈,串口,文件,窗口和许多其他资源。如果一个线程需要独占对资源的访问权,那么其他线程就无法完成它们的工作。反过来说,也不能让任何一个线程在任何时间都能访问所有的资源。如果在一个线程从内存块中读取数据时,另一个线程却想要将数据写入同一个内存块,那么这就像你在读一本书时另一个人却在修改书中的内容一样。这样,书中的内容就会被搞得乱七八糟,结果什么也看不清楚。

线程需要在下面两种情况下互相进行通信:

• 当有多个线程访问共享资源而不使资源被破坏时。

• 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。

线程的同步包括许多方面的内容,下面几章将分别对它们进行介绍。值得高兴的是,Wi n d o w s提供了许多方法,可以非常容易地实现线程的同步。但是,要想随时了解一连串的线程想要做什么,那是非常困难的。我们的头脑的工作不是异步的,我们希望以一种有序的方式来思考许多事情,每次前进一步。不过多线程环境不是这样运行的。

8.1 原子访问:互锁的函数家族

线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。让我们来看一看下面这个简单例子:

因为++会被翻译成多条对应的汇编语句,同时这多条语句又不是一个原子操作的集合,所以最终结果无法确定是多少。

为了解决上面的问题,需要某种比较简单的方法。我们需要一种手段来保证值的递增能够以原子操作方式来进行,也就是不中断地进行。互锁的函数家族提供了我们需要的解决方案。互锁的函数尽管用处很大,而且很容易理解,却有些让人望而生畏,大多数软件开发人员用得很少。所有的函数都能以原子操作方式对一个值进行操作。让我们看一看下面这个 I n t e r l o c k e dE x c
h a n g e A d d函数:

LONG InterlockedExchangeAdd(PLONG plAddend ,LONG lIncrement);

这是个最简单的函数了。只需调用这个函数,传递一个长变量地址,并指明将这个值递增多少即可。但是这个函数能够保证值的递增以原子操作方式来完成。因此可以将上面的代码重新编写为下面的形式:

互锁函数是如何运行的呢?答案取决于运行的是何种 C P U平台。对于x 8 6家族的C
P U来说,互锁函数会对总线发出一个硬件信号,防止另一个 C P U访问同一个内存地址。

对于互锁函数,需要了解的另一个重要问题是,它们运行的速度极快。调用一个互锁函数通常会导致执行几个C P U周期(通常小于5 0) ,并且不会从用户方式转换为内核方式(通常这需要执行1
0 0 0个C P U周期) 。

当然,可以使用I n t e r l o c k e d E x c h a n g e A d d减去一个值 — 只要为第二个参数传递一个负值。I n t e r l o c k e d E x c h a n g e A d
d将返回在* p l A d d e n d中的原始值。

下面是另外两个互锁函数:

I n t e r l o c k e d E x c h a n g e和I n t e r l o c k e d E x c h a n g e P o i n t e r能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。如果是
3 2位应用程序,两个函数都能用另一个3 2位值取代一个3 2位值。但是,如果是个6
4位应用程序,那么I n t e r l o c k e d E x c h a n g e能够取代一个3 2位值,而I
n t e r l o c k e d E x c h a n g e P o i n t e r则取代6 4位值。两个函数都返回原始值。当实现一个循环锁时,I n t e r l o c k e d E
x c h a n g e是非常有用的:

w h i l e循环是循环运行的,它将g _ f R e s o u r c e I n U s e中的值改为T R U E,并查看它的前一个值,以了解它是否是T
R U E。如果这个值原先是FA L S E,那么该资源并没有在使用,而是调用线程将它设置为在用状态并退出该循环。如果前一个值是T R U E,那么资源正在被另一个线程使用,w
h i l e循环将继续循环运行。

如果另一个线程要执行类似的代码,它将在 w h i l e循环中运行,直到g _ f R e s o u r c e I n U s e重新改为FA
L S E。调用函数结尾处的I n t e r l o c k e d E x c h a n g e,可显示应该如何将g _ f R e s o u r c e I n U s e重新设置为FA
L S E。

当使用这个方法时必须格外小心,因为循环锁会浪费 C P U时间。C P U必须不断地比较两个值,直到一个值由于另一个线程而“奇妙地”改变为止。另外,该代码假定使用循环锁的所有线程都以相同的优先级等级运行。也可以把执行循环锁的线程的优先级提高功能禁用(通过调用S
e t P r o c e s s P r i o r i t y B o o s t或s e t T h r e a d P r i o r i t y B o o s t函数来实现之) 。

此外,应该保证将循环锁变量和循环锁保护的数据维护在不同的高速缓存行中(本章后面部分介绍)
。如果循环锁变量与数据共享相同的高速缓存行,那么使用该资源的 C P U将与试图访问该资源的任何C P U争用高速缓存行。

应该避免在单个C P U计算机上使用循环锁。如果一个线程正在循环运行,它就会浪费前一个C P U时间,这将防止另一个线程修改该值。我在上面的
w h i l e循环中使用了S l e e p ,从而在某种程度上解决了浪费C P U时间的问题。如果使用
S l e e p,你可能想睡眠一个随机时间量;每次请求访问该资源均被拒绝时,你可能想进一步延长睡眠时间。这可以防止线程浪费
C P U时间。根据情况,最好是全部删除对S l e e p的调用。或者使用对S w i t c h To T h r e
a d(Windows 98中没有这个函数)的调用来取代它。勇于试验和不断纠正错误,是学习的最好方法。

循环锁假定,受保护的资源总是被访问较短的时间。这使它能够更加有效地循环运行,然后转为内核方式并进入等待状态。许多编程人员循环运行一定的次数(比如 4 0 0次) ,如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗
C P U时间) ,直到该资源变为可供使用为止。这就是关键部分实现的方法。

循环锁在多处理器计算机上非常有用,因为当一个线程循环运行的时候,另一个线程可以在另一个C P U上运行。但是,即使在这种情况下,也必须小心。不应该让线程循环运行太长的时间,也不能浪费更多的C P U时间。本章后面将进一步介绍循环锁。第1
0章将介绍如何使用循环锁。

下面是最后两个互锁函数:

这两个函数负责执行一个原子测试和设置操作。如果是 3 2位应用程序,那么两个函数都在3 2位值上运行,但是,如果是6
4位应用程序,I n t e r l o c k e d C o m p a r e E x c h a n g e函数在3 2位值上运行,而I
n t e r l o c k e d C o m p a r e E x c h a n g e P o i n t e r函数则在6 4位值上运行。在伪代码中,它的运行情况如下面所示:

该函数对当前值(p l D e s t i n a t i o n参数指向的值)与l C o m p a r a n d参数中传递的值进行比较。如果两个值相同,那么
* p l D e s t i n a t i o n改为l E x c h a n g e参数的值。如果
* p l D e s t i n a t i o n中的值与l E x c h a n g e的值不匹配,* p l D
e s t i n a t i o n保持不变。该函数返回* p l D e s t i n a t i o n中的原始值。记住,所有这些操作都是作为一个原子执行单位来进行的。

没有任何互锁函数仅仅负责对值进行读取操作(而不改变这个值)
,因为这样的函数根本是不需要的。如果线程只是试图读取值的内容,而这个值始终都由互锁函数来修改,那么被读取的值总是一个很好的值。虽然你不知道你读取的是原始值还是更新值,但是你知道它是这两个值中的一个。对于大多数应用程序来说,这一点很重要。此外,当要对共享内存区域(比如内存映象文件)中的值的访问进行同步时,互锁函数也可以供多进程中的线程使用(第 9章中包含了几个示例应用程序,以显示如何正确地使用互锁函数)

虽然Wi n d o w s还提供了另外几个互锁函数,但是上面介绍的这些函数能够实现其他函数能做的一切功能,甚至更多。下面是两个其他的函数:

I n t e r l o c k e d E x c h a n g e A d d函数能够取代这些较老的函数。新函数能够递增或递减任何值,老的函数只能加1或减1。

8.2 高速缓存行

如果想创建一个能够在多处理器计算机上运行的高性能应用程序,必须懂得 C P U的高速缓存行。当一个C P U从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行由
3 2或6 4个字节组成(视C P U而定) ,并且始终在第3
2个字节或第6 4个字节的边界上对齐。高速缓存行的作用是为了提高
C P U运行的性能。通常情况下,应用程序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么
C P U就不必访问内存总线,而访问内存总线需要多得多的时间。

但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,下面这个例子就说明了这一点:

1) CPU1读取一个字节,使该字节和它的相邻字节被读入C P U 1的高速缓存行。

2) CPU2读取同一个字节,使得第一步中的相同的各个字节读入C P U 2的高速缓存行。

3) CPU1修改内存中的该字节,使得该字节被写入C P U 1的高速缓存行。但是该信息尚未写入R A M。

4) CPU2再次读取同一个字节。由于该字节已经放入C P U 2的高速缓存行,因此它不必访问内存。但是C P U 2将看不到内存中该字节的新值。

这种情况会造成严重的后果。当然,芯片设计者非常清楚这个问题,并且设计它们的芯片来处理这个问题。尤其是,当一个C P U修改高速缓存行中的字节时,计算机中的其他
C P U会被告知这个情况,它们的高速缓存行将变为无效。因此,在上面的情况下,
C P U 2的高速缓存在C P U 1修改字节的值时变为无效。在第
4步中,C P U 1必须将它的高速缓存内容迅速转入内存,C P U 2必须再次访问内存,重新将数据填入它的高速缓存行。如你所见,高速缓存行能够帮助提高运行的速度,但是它们也可能是多处理器计算机上的一个不利因素。

这一切意味着你应该将高速缓存行存储块中的和高速缓存行边界上的应用程序数据组合在一起。
这样做的目的是确保不同的C P U能够访问至少由高速缓存行边界分开的不同的内存地址。还有,应该将只读数据(或不常读的数据)与读写数据分开。同时,应该将同一时间访问的数据组合在一起。

下面是设计得很差的数据结构的例子:

下面是该结构的改进版本:

上面定义的C A C H E _ A L I G N宏是不错的,但是并不很好。问题是必须手工将每个成员变量的字节值输入该宏。如果增加、移动或删除数据成员,也必须更新对
C A C H E _ PA D宏的调用。将来,M i c r o s o f t的C / C + +编译器将支持一种新句法,该句法可以更容易地调整数据成员。它的形式类似_
_ d e c l s p e c ( a l i g n ( 3 2 ) )。

注意
最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法) ,或者始终让单个C P U访问这些数据(使用线程亲缘性) 。如果采取其中的一种方法,就能够完全避免高速缓存行的各种问题。

8.3 高级线程同步

当必须以原子操作方式来修改单个值时,互锁函数家族是相当有用的。你肯定应该先试试它们。
但是大多数实际工作中的编程问题要解决的是比单个3 2位或6 4位值复杂得多的数据结构。为了以原子操作方式使用更加复杂的数据结构,必须将互锁函数放在一边,使用
Wi n d o w s提供的其他某些特性。

前面强调了不应该在单处理器计算机上使用循环锁,甚至在多处理器计算机上,也应该小心地使用它们。原因是C P U时间非常宝贵,决不应该浪费。因此需要一种机制,使线程在等待访问共享资源时不浪费C P U时间。

当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态(该线程可以不必立即执行,它处于可调度状态,可以使用前一章介绍的原则将它分配给一个
C P U) 。

如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该线程无法调度。这可以防止线程浪费 C P U时间。当线程处于等待状态时,系统作为一个代理,代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动使该线程退出等待状态,该线程的运行将与特殊事件实现同步。

从实际情况来看,大多数线程几乎总是处于等待状态。当系统发现所有线程有若干分钟均处于等待状态时,系统的强大的管理功能就会发挥作用。

要避免使用的一种方法

如果没有同步对象,并且操作系统不能发现各种特殊事件,那么线程就不得不使用下面要介绍的一种方法使自己与特殊事件保持同步。不过,由于操作系统具有支持线程同步的内置特性,因此决不应该使用这种方法。

运用这种方法时,一个线程能够自己与另一个线程中的任务的完成实现同步,方法是不断查询多个线程共享或可以访问的变量的状态。下面的代码段说明了这个情况:

如你所见,当主线程(执行Wi n M a i n)必须使自己与R e c a l c F u n c函数的完成运行实现同步时,它并没有使自己进入睡眠状态。由于主线程没有进入睡眠状态,因此操作系统继续为它调度C
P U时间,这就要占用其他线程的宝贵时间周期。

前面代码段中使用的查询方法存在的另一个问题是, B O O L变量g_f FinishedCalculation从来没有被设置为T
R U E。当主线程的优先级高于执行 R e c a l c F u n c函数的线程时,就会发生这种情况。在这种情况下,系统决不会将任何时间片分配给
R e c a l c F u n c线程。如果执行Wi n M a i n函数的线程被置于睡眠状态,而不是进行查询,那么这就不是已调度的时间。系统可以将时间调度给低优先级的线程,如R
e c a l c F u n c线程,使它们得以运行。

应该说,有时查询迟早都可以进行,毕竟是循环锁执行的操作。不过有些方法进行这项操作是恰当的,而有些方法是不恰当的。一般来说,应该调用一些函数,使线程进入睡眠状态,直到线程需要的资源可供使用为止。下一节将介绍一种正确的方法。

首先,在前面介绍的代码段的开头,你会发现它使用了 v o l a t i l e一词。为了使这个代码段更加接近工作状态,必须有一个v o l a t i l e类型的限定词。它告诉编译器,变量可以被应用程序本身以外的某个东西进行修改,这些东西包括操作系统,硬件或同时执行的线程等。尤其是,v
o l a t i l e限定词会告诉编译器,不要对该变量进行任何优化,并且总是重新加载来自该变量的内存单元的值。比如,编译器为前面的代码段中的w h i l e语句生成了下面的伪代码:

如果不使布尔变量具备易变性,编译器就能像上面所示的那样优化你的 C代码。为了实现这样的优化,编译器只需将B O O L变量的值装入一个C
P U寄存器一次。然后,它对该C P U寄存器反复进行测试。这样得出的性能当然要比不断地重复读取内存地址中的值并对它进行重复测试要好,因此,优化编译器能够编写上面所示的那种代码。但是,如果编译器进行这样的操作,线程就会进入一个无限循环,永远无法唤醒。另外,使一个结构具备易变性,可以确保它的所有成员都具有易变性,当它们被引用时,总是可以从内存中读取它们。

你也许会问,循环变量g _ f R e s o u r c e I n U s e是否应该声明为v o l a t i l e变量。答案是不必,因为我们将该变量的地址传递给各个不同的互锁函数,而不是传递变量值本身。当将一个变量地址传递给一个函数时,该函数必须从内存读取该值。优化程序不会对它产生任何影响。

Windows核心编程 第八章 用户方式中线程的同步(上)的更多相关文章

  1. Windows核心编程 第八章 用户方式中线程的同步(下)

    8.4 关键代码段 关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权.这是让若干行代码能够"以原子操作方式"来使用资源的一种方法.所谓原子操作方式,是 ...

  2. windows核心编程---第八章 使用内核对象进行线程同步

    使用内核对象进行线程同步. 前面我们介绍了用户模式下线程同步的几种方式.在用户模式下进行线程同步的最大好处就是速度非常快.因此当需要使用线程同步时用户模式下的线程同步是首选. 但是用户模式下的线程同步 ...

  3. 《windows核心编程》 在应用程序中使用虚拟内存

    Microsoft Windows 提供了以下三种机制来对内存进行操控: 虚拟内存 最适合用来管理大型对象数组或大型结构数组 内存映射文件 最适合用来管理大型数据流(通常是文件),以及在同一台机器上运 ...

  4. 【windows核心编程】 第八章 用户模式下的线程同步

    Windows核心编程 第八章 用户模式下的线程同步 1. 线程之间通信发生在以下两种情况: ①    需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性 ②    一个线程需要通知其他线程 ...

  5. 【Windows】windows核心编程整理(上)

    小续 这是我11年看<windows核心编程>时所作的一些笔记,现整理出来共享给大家 windows核心编程整理(上) windows核心编程整理(下) 线程的基础知识 进程是不活泼的,进 ...

  6. windows核心编程-互斥器(Mutexes)

    线程同步的方式主要有:临界区.互斥区.事件.信号量四种方式. 前边讲过了临界区线程同步-----windows核心编程-关键段(临界区)线程同步,这章我来介绍一下互斥器(Mutexes)在线程同步中的 ...

  7. 用户模式下的线程同步的分析(Windows核心编程)

    线程同步 同一进程或者同一线程可以生成许多不同的子线程来完成规定的任务,但是多个线程同时运行的情况下可能需要对某个资源进行读写访问,比如以下这个情况:创建两个线程对同一资源进行访问,最后打印出这个资源 ...

  8. 使用同步或异步的方式完成 I/O 访问和操作(Windows核心编程)

    0x01 Windows 中对文件的底层操作 Windows 为了方便开发人员操作 I/O 设备(这些设备包括套接字.管道.文件.串口.目录等),对这些设备的差异进行了隐藏,所以开发人员在使用这些设备 ...

  9. windows核心编程 - 线程同步机制

    线程同步机制 常用的线程同步机制有很多种,主要分为用户模式和内核对象两类:其中 用户模式包括:原子操作.关键代码段 内核对象包括:时间内核对象(Event).等待定时器内核对象(WaitableTim ...

随机推荐

  1. Tomcat后台爆破指南

          0x00 实验环境 攻击机:Win 10 0x01 爆破指南 针对某Tomcat默认管理页面: (1)这里主要是介绍一种比较好用的burp爆破方法: 点击Tomcat后台管理链接 Tomc ...

  2. CVE-2019-2618 任意文件上传

    漏洞描述:CVE-2019-2618漏洞主要是利用了WebLogic组件中的DeploymentService接口,该接口支持向服务器上传任意文件.攻击者突破了OAM(Oracle Access Ma ...

  3. SQL字符串传参

    technicianCode in('${techList.collect { it.technicianCode }.join("','")}')

  4. 如何选择 WebClient,HttpClient,HttpWebRequest

    当我们在用 .NET 调用 RestAPI 时通常有三种选择,分别为:WebClient, HttpWebRequest,HttpClient,这篇文章我们将会讨论如何使用这三种方式去调用 RestA ...

  5. VSCode 微信小程序扩展开发

    写在前面 为什么要开发这个扩展呢,是因为微信开发者工具自身不支持页面引入组件的跳转,人工根据引入组件路径查看对应代码的方式,效率偏低.就形如这样的json文件,引入了多个组件,比如要查看 " ...

  6. CSS篇-样式表、选择器、权重、伪类

    CSS定义 CSS:Cascading Style Sheet(层叠样式表) // 写法 选择器 { 属性名: 属性值; } CSS样式表 (1)三种样式表使用 // 内联样式 <div sty ...

  7. Nodejs学习笔记(5) 文件上传系统实例

    目录 2018.8.4更新:  MySQL可以存放几乎任何类型的数据(图片.文档.压缩包等),但这不是最好的解决方案,正常情况下都是在数据库中存放文件路径,图片.音乐.视频.压缩包.文档等文件存放在硬 ...

  8. Java 8 Stream API 详解

    Java 8 中的 Stream 是对集合(Collection)对象功能的增强,它专注于对集合对象进行各种非常便利.高效的聚合操作(aggregate operation),或者大批量数据操作 (b ...

  9. wrf模拟的domain图绘制

    wrf模拟的区域绘制,domain图,利用python的cartopy库绘制模拟区域 参考Liang Chen的draw_wrf_domian.py这个代码, 出处python画wrf模式的模拟区域 ...

  10. VSCode中插件Code Spell Checker

    说在前面 介绍 Code Spell Checker 是在VSCode中的一款插件,能够帮助我们检查单词拼写是否出现错误,检查的规则遵循 camelCase (驼峰拼写法). 安装方法 打开VSCod ...