让我们与星光探索者一起,探索Windows吧!

通常,线程之间需要进行通信,而通信时很可能就会导致数据混乱的现象。就好比一个人正在读书时,另一个人正在修改书中文字。这样书的内容将乱七八糟。对于多个线程同时访问一个共享资源,或一个线程需要通知其他线程某任务已经完成时,需要用到线程同步。Microsoft已提供了很多基础设施,使得线程同步很容易。

用户模式下的线程同步

我们可以有多种方式实现线程同步

使用原子锁

线程同步的一大部分与原子访问有关(atomic access)有关。看如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
long g_x = 0;


DWORD WINAPI Work1(PVOID)
{
for (int i = 0; i < 100; ++i)
{
g_x++;
}
return 0;
}

DWORD WINAPI Work2(PVOID)
{
for (int i = 0; i < 100; ++i)
{
g_x++;
}
return 0;
}

假设我们创建了两个线程去分别执行Work1, Work2这些函数,且只执行一次。那么当这两个线程都执行完毕后,g_x的值是200吗?答案是有可能,结果取决于编译器。例如如果在Visual Studio编译运行,结果可能是1。因为g_x在被修改时总是被打断,导致对g_x的修改总是不成功,因此我们需要使用一个技术,使得一个线程操作一个变量时其他线程无法操作。在这里我们可以使用原子操作来进行这个问题。原子操作的原理取决于代码运行的CPU平台。

Microsoft提供了Interlocked系列函数来实现原子操作。如InterlockedExchangeAdd函数,这个函数就可以保证对long类型变量进行加号运算时正确的递增。因此,上面的代码就可以用InterlockedExchangeAdd(&g_x, 1)来代替g_x++,这样就可以保证变量进行操作时不互相影响,因而正确的操作。

此外还有InterlockedExchange 函数InterlockedExchangePointer 函数 ,以及一种称为Interlocked的单向链表的栈等。对于InterlockedExchange函数,我们可用于实现旋转锁(也称为自旋锁),旋转锁和原子操作一样,可以保证同一时间只有一个线程访问资源。简单的旋转锁实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BOOL g_locked = FALSE;

void MyFunc(PVOID)
{
// 旋转锁部分代码
// 不断尝试修改g_locked的值为TRUE,
// 如果之前的值为FALSE,表明无线程访问资源,本线程访问资源并上锁,执行访问资源的代码
// 否则线程不断睡眠,直到其他线程结束访问资源为止
while (InterlockedExchange(&g_locked, TRUE), TRUE) == TRUE)
{
Sleep(0);
}
// 访问资源的代码 BEGIN
// 访问资源的代码 END

// 访问资源结束了,记得解锁
InterlockedExchange(&g_locked, FLASE);
}

旋转锁很耗费CPU时间,因此使用时极其小心。CPU必须不断这两个值,直到改变为止。在单CPU的机器上应该避免使用旋转锁。旋转锁假定被保护的资源只会占用一小段的时间,与切换到内核模式然后等待相比,以循环的方式等待效率更高。许多开发者会循环指定次数如4000,如果还不能访问资源,线程会切换到内核模式,并一直等到资源可供使用为止,此时它不消耗CPU时间,这就是关键段(critical section)的实现方式。

高速缓存行

如果想为装配有多处理器的机器构建高性能应用程序,那么应该注意高速缓存行。当CPU从内存中读取一个字节的时候,它并不只是从内存中取回一个字节,而是取回一个高速缓存行。高速缓存行可能包含32字节(老式CPU),64字节,甚至是128字节(取决于CPU),它们始终都对齐到32字节边界,64字节边界,或128字节边界。高速缓存行存在的目的是为了提高性能。一般来说,应用程序会对一组相邻的字节进行操作。如果所有字节都在高速缓存中,那么CPU就不必访问内存总线,后者耗费的时间比前者耗费的时间要多得多。

但是,在多处理器环境中,高速缓存线使得对内存的更新变得更加困难。我们可以从下面的例子中体会到这一点。

(1)CPU1读取一个字节,这使得该字节以及与它相邻的字节被读到CPU1的高速缓存行中。
(2)CPU2读取同一个字节,这使得该字节被读到CPU2的高速缓存行中。
(3)CPU1对内存中的这个字节进行修改,这使得该字节被写入到CPU1的高速缓存行中。
但这一信息还没有写回到内存。
(4)CPU2再次读取同一个字节。由于该字节已经在CPU2的高速缓存行中,因此CPU2不需要再访问内存。但CPU2将无法看到该字节在内存中新的值。

这种情形非常糟糕。当然,CPU芯片的设计者非常清楚这个问题,并做了专门的设计来对它进行处理。明确地说,当一个CPU修改了高速缓存行中的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。因此在刚才的情形中,当CPU1修改该字节的值时,CPU2的高速缓存就作废了。在第4步中,CPU1必须将它的高速缓存写回到内存中,CPU2必须重新访问内存来填满它的高速缓存行。我们可以看到,虽然高速缓存行能够提高性能,但在多处理器的机器上它们同样能够损伤性能。

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

高级线程同步

大多数实际的编程问题需要处理的数据结构往往要比一个简单的32位值或64位值复杂得多。为了能够以“原子”方式来访问复杂数据结构,我们必须超越Interlocked系列函数,转而使用Windows提供的一些其他特性。

浪费CPU时间是件非常糟糕的事情。因此,我们需要一种机制,它既能让线程等待共享资源的访问权,又不会浪费CPU时间。当线程想要访问一个共享资源或者想要得到一些“特殊事件”的通知时,线程必须调用操作系统的一个函数,并将线程正在等待的东西作为参数传入。如果操作系统检测到资源已经可供使用了,或者特殊事件已经发生了,那么这个函数会立即返回,这样线程将仍然保持可调度状态。

如果无法取得对资源的访问权,或者特殊事件尚未发生,那么系统会将线程切换到等待状态,使线程变得不可调度,从而避免了让线程浪费CPU时间。当线程在等待的时候,系统会充当它的代理。系统会记住线程想要访问什么资源,当资源可供使用的时候,它会自动将线程唤醒——线程的执行与特殊事件是同步的。

实际情况是,大多数线程在大部分情况下都处于等待状态。当系统检测到所有线程都已经在等待状态中度过了好几分钟的时候,系统的电源管理器将会介入。

如果没有同步对象,如果操作系统不能对特殊事件进行监测,那么线程将不得不使用下面介绍的技术来在自己和特殊事件之间进行同步。但是,由于操作系统内建了对线程同步的支持,因此我们在任何时候都不应该使用这种方法。在这种方法中,两个线程共享一个变量,其中一个线程不断地读取变量的值,直到另一个线程完成它的任务为止。

关键段(临界区)

关键段(或称临界区)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”对资源进行操控。这里的原子方式,指的是代码知道除了当前线程之外,没有其他任何线程会同时访问该资源。当然,系统仍然可以暂停当前线程去调度其他线程。但是,在当前线程离开关键段之前,系统是不会去调度任何想要访问同一资源的其他线程的。

看下面使用关键段的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct MyResource
{
int m_rsc1;
char m_rsc2;
}g_resource{0, 0};

const int COUNT = 100;
CRITICAL_SECTION g_cs; // 创建关键段结构体


DWORD WINAPI MyWork(PVOID)
{
// 把任何需要访问共享资源(这里是g_resource)放在
// EnterCriticalSection,LeaveCriticalSection之间
// 这样就可以保证进行线程同步
EnterCriticalSection(&g_cs);
// 操作资源
g_resource.m_rsc1 += m_rsc2++;
// 记得调用LeaveCriticalSection
LeaveCriticalSection(&g_cs);
return 0;
}

关键段里面可以保护多个共享资源。我们可以把关键段比喻成一个卫生间,EnterCriticalSection,LeaveCriticalSection之间操作的共享资源可以比喻成卫生间里的用品。无论那里的代码要访问一个资源,我们调用EnterCriticalSection,并传入关键段的地址,就好比线程访问一个资源时,先检查卫生间门上的占用标志。CRITICAL_SECTION即用来表示线程想要进入的卫生间,EnterCriticalSection是线程用来检查占用标志的函数,如果发现没有其他线程在卫生间中,它将允许线程进入卫生间,否则线程必须在卫生间门外等待直到卫生间不被占用。当线程不再需要使用卫生间的资源时,调用LeaveCriticalSection来告诉系统它已经离开资源,否则系统会认为线程仍在卫生间中,导致其他线程无法访问资源,导致错误。因而EnterCriticalSection,LeaveCriticalSection要配对调用。

轻型读写锁Slim Read/Write Locks

轻型读写锁能够使单一进程的线程们访问共享资源;轻型读写锁速度优化和占据非常少的内存。轻型读写锁不能跨线程共享;

读取线程从共享资源读取数据而写入线程写数据到共享资源。当多线程正在使用共享资源进行读写时,如果这个读取线程持续运行但是写操作却几乎不运行,互斥锁(比如,临界区或者互斥锁)存在性能问题。

轻型读写锁提供多线程访问共享资源的两个模式:

  • 共享模式  该模式授权多个读取线程共享只读权限,使它们能够从当前共享的资源中并发读取数据。如果读取操作多于写操作,与临界区相比,这种并发提高了性能和吞吐量。
  • 互斥模式  该模式一次授权给一个写入线程读/写权限。当这个锁在互斥模式被获取时,没有其他线程能够访问这个共享资源直到这个写入线程释放这个锁。

注意:互斥模式请读写锁不能被递归请求。如果一个线程已经拥有读写锁,再尝试获取读写锁时,这种尝试将失败或者死锁。

在任意一个模式下均可获取一个轻型读写锁:在共享模式下读取线程能够获取锁而写入线程能在互斥模式下获取锁。无法保证请求所有权的线程被授予所有权的顺序。轻型读写锁既不公平也不是先进先出(FIFO)。

一个轻型读写锁只有指针的大小。优点是,它能快速更新锁的状态。缺点是,只有非常少的状态信息被存储, 所以轻型读写锁不能被递归获取。此外,一个拥有共享模式轻型读写锁的线程不能升级它的锁的所有权到互斥模式。

这个调用者必须分配一个SRWLOCK结构体内存并初始化它,通过调用 InitializeSRWLock(动态初始化结构体)或者赋值给这个结构体变量SRWLOCK_INIT常量(静态初始化结构体)。

条件变量

有时我们想让线程以原子方式吧锁释放并将自己阻塞,直到某个条件成立位置。Windows提供了一种条件变量来简化相关工作。

内核对象进行线程同步

在用户模式下的线程同步有非常好的性能,但存在一些局限性。我们将逐步探索使用内核对象进行线程同步的方式。内核对象唯一的缺点是他们的性能,这会占用很多CPU周期。几乎所有内核对象都可以进行同步,包括进程线程作业等。内核对象要么处于触发(signaled)状态,要么处于未触发(nosignaled)状态。进程,线程,作业,文件以及控制台标准输入输出错误流,事件,可等待计时器,信号量,互斥量可以处于触发状态,也可以处于未触发状态。

等待函数

等待函数使一个线程自愿进入等待状态,直到指定的内核对象被触发为止。如果线程在调用一个等待函数的时候,相应的内核对象已经处于触发状态,那么线程是不会进入等待状态的。常用的等待函数有WaitForSingleObject函数 ,此函数用于等待目标内核对象触发。

1
2
3
4
DWORD WaitForSingleObject(
HANDLE hHandle, // 目标内核对象的句柄
DWORD dwMilliseconds // 线程愿意花的等待时间,传INFINITE表示一直等
);

此函数的返回值表示为什么调用线程又可以继续执行了。WAIT_OBJECT_0表示线程等待的对象被触发,WAIT_TIMEOUT表示等待超时等待。

WaitForMultipleObjects函数可以同时等待多个对象触发,以原子方式执行。

1
2
3
4
5
6
DWORD WaitForMultipleObjects(
DWORD nCount, // 等待数量,1~MAXIMUM_WAIT_OBJECT个
const HANDLE *lpHandles, // 一个内核对象句柄数组
BOOL bWaitAll, // 是否等待全部都被触发才返回
DWORD dwMilliseconds // 最长等待时间
);

WaitForMultipleObjects函数的返回值为为什么它已继续运行。如果给bWaitAll传的TRUE,并且每个对象都触发了,返回值是WAIT_OBJECT_0;如果bWaitAll传的FALSE,只要任何一个对象被触发,函数就会立即返回,这时的返回值是[WAIT_OBJECT_0, WAIT_OBJECT_0 + nCount - 1]范围的一个值。换句话说,如果返回值既不是WAIT_TIMEOUT,也不是WAIT_FAILED,我们应该将返回值减去WAIT_OBJECT_0,得到的结果即是lpHandles的触发对象的数组索引。

如果被等待的对象在等待之后发生了变化,我们称为等待成功所引起的副作用(successful wait side effects)。

事件内核对象

事件内核对象比其他对象布局简单的多,事件包含一个引用计数,一个用于表示事件是自动重置事件(auto-reset event)还是手动重置事件(manual-reset event)的布尔值,以及一个表示事件有没有触发的布尔值。事件通常用来进行线程同步。

CreateEvent函数可以创建一个事件内核对象,调用SetEvent函数我们可使事件进入触发状态,ResetEvent 函数可修改事件为未触发状态。可调用OpenEvent访问已创建的事件内核对象。没错,事件就是这么简单

1
2
3
4
5
6
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全描述符
BOOL bManualReset, // 是否是手动重置事件
BOOL bInitialState, // 初始触发状态
LPCTSTR lpName // 内核对象名
);

可等待计时器内核对象

可等待计时器是一种内核对象,它们在某个指定的时间触发或每隔一段时间触发一次。它们通常在某个时间执行一个操作。调用CreateWaitableTimer函数可创建可等待计时器,参数和CreateEvent有异曲同工之处,这里就不多介绍。

1
2
3
4
5
HANDLE CreateWaitableTimer(
LPSECURITY_ATTRIBUTES lpTimerAttributes,
BOOL bManualReset,
LPCTSTR lpTimerName
);

我们想要触发可等待计时器时,调用SetWaitableTimer函数

1
2
3
4
5
6
7
8
BOOL SetWaitableTimer(
HANDLE hTimer, // 计时器句柄
const LARGE_INTEGER *lpDueTime, // 计时器第一次触发时间
LONG lPeriod, // 第一次出发之后的计时器触发周期,传0仅触发一次
PTIMERAPCROUTINE pfnCompletionRoutine, // 触发时回调函数
LPVOID lpArgToCompletionRoutine, // 回调函数参数
BOOL fResume // 如果为TRUE,对于支持电源管理的系统,将退出省电模式
);

lpDueTime的值需要使用一个FILETIME结构,但是我们比较习惯使用SYSTEMTIME结构后调用SystemTimeToFileTimeLocalFileTimeToFileTime转换成FILETIME结构。

当可等待计时器被触发时,Microsoft允许计时器把一个APC(Asynchronous Procedure Call,异步过程调用)放入调用SetWaitableTimer的线程APC队列中,每个线程都有一个APC队列。

信号量内核对象

信号量(Semaphore)内核对象用来给资源进行计数。信号量包含一个引用计数,一个最大资源计数和一个当前资源计数。信号量有如下规则:

  • 如果当前资源计数大于0,信号量处于触发状态

  • 当前资源计数等于0,信号量处于未触发状态

  • 系统绝不会让当前资源计数变为负数

  • 当前资源计数绝不会大于最大资源计数

可调用CreateSemaphore函数创建信号量,调用ReleaseSemaphore 函数增加信号量的资源计数。

互斥量内核对象

互斥量(mutex,即mutual exclusive的缩写)内核对象用于确保一个线程独占对一个资源的访问。互斥量包含一个引用计数,线程ID,递归计数。互斥量与关键段的行为完全相同,但是互斥量是内核对象。

线程同步对象速查表

下表总结了各种内核对象与线程同步有关的行为

内核对象 何时处于未触发状态 何时处于触发状态 成功等待的副作用
进程 进程仍在运行时 进程终止时
线程 线程仍在运行时 线程终止时
作业 作业没有超时 作业已超时
文件 有待处理的I/O请求时 I/O请求完成时
控制台输入 无输入时 有输入时
文件变更通知 文件没有变更时 文件系统检测到变更时 重置通知
自动重置事件 ResetEventPulseEvent,或等待成功时 SetEventPulseEvent被调用时 重置事件
手动重置事件 ResetEventPulseEvent SetEventPulseEvent被调用时
自动重置可等待计时器 CancelWaitableTimer或等待成功时 时间到的时候 重置计时器
手动重置可等待计时器 CancelWaitableTimer 时间到的时候
信号量 等待成功的时候 计数大于0时 计数减1
互斥量 等待成功的时候 不为线程占用时 把所有权交给线程
关键段 等待成功的时候 不为线程占用时 把所有权交给线程
SRWLock 等待成功的时候 不为线程占用时 把所有权交给线程
条件变量 等待成功的时候 被唤醒时