windows核心编程---用户模式下的线程同步

时间:2021-08-21 17:41:54

-原子访问:Interlocked系列函数
原子访问:一个线程在访问某个资源的同时能保证没有其它线程会在同一时刻访问同一资源。

LONG InterlockedExchangeAdd(
PLONG volatile plAddend,
LONG lIncrement
)
;

LONGLONG InterlockedExchangeAdd64(
PLONGLONG volatile pllAddend,
LONGLONG llIncrement
)
;

LONG InterlockedExchange(
PLONG volatile plTarget,
LONG lValue
)
;

LONGLONG InterlockedExchange64(
PLONGLONG volatile plTarget,
LONGLONG lValue
)
;

PVOID InterlockedExchangePointer(
PVOID* volatile ppvTarget,
PVOID pvValue
)
;

PLONG InterlockedCompareExchange(
PLONG plDestination,
LONG lExchange,
LONG lComparand
)
;

LONG InterlockedCompareExchangePointer(
PVOID * ppvDestination,
PVOID pvExchange,
PVOID pvComparand
)
;

// 不足:
// 1.对于共享变量,要求所有线程都应调用这些函数来修改共享变量的值。
// 2.必须确保传给这些函数的变量地址是经过对齐的。否则,这些函数可能会失败。

// 分配一块对齐过的内存
// size:要分配的字节数
// alignment:要对齐到的字节边界
void * _aligned_malloc(size_t size, size_t alignment);

-高速缓存行
CPU从内存中读取一个字节时,它并不只是从内存中取回一个字节,而是取回一个高速缓存行。
高速缓存行有助于提高性能。一般,应用会对一组相邻的字节操作。如所有字节都在高速缓存,则,CPU就不必访问内存总线。但,多处理器中,高速缓存行让对内存的更新变得更困难。
1.CPU1读取一字节,导致该字节及其相邻字节被读到CPU1的高速缓存行。
2.CPU2读取同一字节,导致该字节及其相邻字节被读到CPU2的高速缓存行。
3.CPU1对内存中的这个字节进行修改,使该字节被写入CPU1的高速缓存中,这一信息还未写入内存。
4.CPU2再次读取同一字节。由于该字节已在CPU2的高速缓存行中,故,CPU2不再需访问内存。无法看到改变后的值。

说明:
只在多处理多线程间对共享资源操作时,会产生这种弊端。
对策:
一个CPU修改了高速缓存行的一个字节时,机器中其它CPU会收到通知,使自己的高速缓存行作废。

建议:
根据高速缓存行大小来组织应用的数据,并将数据与缓存行的边界对齐。

// 确定高速缓存行大小
BOOL WINAPI GetLogicalProcessorInformation(
_Out_ PSYSTEM_LOGICAL_PROCESSOR_INFORMATION Buffer,
_Inout_ PDWORD ReturnLength
)
;

// PSYSTEM_LOGICAL_PROCESSOR_INFORMATION结构的Cache字段,是一个CACHE_DESCRIPTOR,LineSize表示CPU的高速缓存行大小。

// C/C++编译器的__declspec(align(#))对字段对齐加以控制
#define CACHE_ALIGN 64

// 一个结构体成员组织示例:
struct __declspec(align(CACHE_ALIGN)) CUSTINFO
{
DWORD dwCustomerID;
wchar_t szName[100];

// 控制下一个成员 对齐到64的地址边界
__declspec(align(CACHE_ALIGN))
int nBalanceDue;
FILETIME ftLastOrderDate;
};

-高级线程同步
1.
需要一种机制,既能让线程等待共享资源的访问权,又不会浪费CPU时间。
线程想访问一个共享资源或想要得到一些“特殊事件”的通知时,线程须调用操作系统的一个函数,将线程正等待的东西作为参数传入。
如系统检测到资源可用了,或特殊事件发生了,则,这个函数立即返回,线程变为可调度的。
如无法取得对资源的访问权,或特殊事件尚未发生,则,系统将线程切换到等待状态,使线程变得不可调度。线程在等待时,系统会充当它的代理。当资源可供使用时,它会自动将线程唤醒。
2.
2.1.volatile:类型限定符,它告诉编译器这个变量可能会被应用程序之外的其它东西修改,如操作系统,硬件,一个并发线程。 从而使编译器不要对这个变量进行任何形式的优化,而是始终从变量在内存中的位置读取变量的值。

2.2.给一个结构加volatile限定符等于给结构中所有的成员都加volatile限定符。

2.3.如果传一个变量的地址给函数,则,函数必须从内存中读取它的值,编译器的优化程序不会对此产生影响。

-关键段
1.
关键段是一小段代码,它在执行前需独占对一些共享资源的访问权。

可以让多行代码以原子方式对资源进行操控。
系统仍然可以暂停当前线程去调度其它线程,但,在当前线程离开关键段之前,系统是不会去调度任何想要访问同一资源的其它线程的。

2.使用

// 1.定义CRITICAL_SECTION实例对象
CRITICAL_SECTION g_cs;

// 2.把任何需要访问共享资源的代码放在EnterCriticalSection和LeaveCriticalSection之间。
EnterCriticalSection(&g_cs);
// 资源访问代码
LeaveCriticalSection(&g_cs);

不足:
2.1.任何要访问共享资源的代码,都必须包含在EnterCriticalSection和LeaveCriticalSection之间。
2.2.关键段无法用来在多个进程之间对线程进行同步。

3.细节
3.1.
CRITICAL_SECTION结构的成员对于我们是透明的,为了对CRITICAL_SECTION结构进行操控,我们须调用Windows函数,并传入结构的地址。
3.2.
一般,我们会将CRITICAL_SECTION结构作为全局变量来分配,这样,进程中的所有线程就能够非常方便地通过变量名来访问这些结构。
但,CRITICAL_SECTION也可作为局部变量来分配,或者从堆中动态地分配,也可将它们作为类的一个私有字段来分配。
3.3.在使用CRITICAL_SECTION的时候,有两个必要条件。
一个是,所有想要访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结构的地址。
一个是,在任何线程试图访问被保护的资源前,必须对CRITICAL_SECTION结构的内部成员进行初始化。

// 初始化。在调用EnterCriticalSection之前必须调用此函数。
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);

// 当知道线程将不再需要访问共享资源的时候,应该用下面来清理CRITICAL_SECTION结构。
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs);

// 对共享资源进行访问前,须在代码中先调用下面函数
VOID EnterCriticalSection(PCRITICAL_SECTION pcs);

// 完成对共享资源访问后
// 会检查内部的成员变量并将计数器减1,计数器表示调用线程获准访问共享资源的次数。
// 如计数器大于0,LeaveCriticalSection会直接返回
// 如计数器变成了0,LeaveCriticalSection会更新成员变量,表示没有线程正在访问被保护的资源,同时会检查有没有其它线程由于调用了EnterCriticalSection而处于等待状态。如存在,则,更新成员变量,把其中一个处于等待状态的线程切换回可调度状态。
VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);

3.3.EnterCriticalSection分析
-如果没有线程正在访问资源,则,EnterCriticalSection会更新成员变量,以表示调用线程已获准对资源的访问,并立即返回。
-如果成员变量表示线程已经获准访问资源,则,EnteriCriticalSection会更新变量,以表示调用线程被获准访问的次数,并立即返回。比较少见,只在当线程调用LeaveCriticalSection前连续调用EnterCriticalSection两次以上才会发生。
-如果成员变量表示有一个(调用线程之外的其它)线程已经获准访问资源,则,EnterCriticalSection会使用一个事件内核对象来把调用线程切换到等待状态。等待中的线程不会浪费任何CPU时间。系统会记住这个线程想要访问这个资源,一旦当前正在访问资源的线程调用了LeaveCriticalSection,系统会自动更新CRITICAL_SECTION的成员变量并将等待中的线程切换回可调度状态。

对EnterCriticalSection的调用最终会超时并引发异常。
导致超时的时间长度由下面注册表子项中包含的CriticalSectionTimeout值决定:
HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager。

EnterCriticalSection和LeaveCriticalSection以原子方式执行所有的测试和更新操作。

3.4.

// 从来不会让调用线程进入等待状态,
// 它通过返回值来表示调用线程是否获准访问资源,
// 如TryEnterCriticalSection发现资源正在被其它线程访问,它会返回FALSE
// 其它情况下,它会返回TRUE
BOOL TryEnterCriticalSection(PCRITICAL_SECTION pcs);

使用此函数,线程可快速地检查它是否能够访问某个共享资源。如不能访问共享资源,那么它可继续做其它的事情,而不用等待。如TryEnterCriticalSection返回TRUE,则,CRITICAL_SECTION的成员变量已经更新过了,以表示该线程正在访问资源。故,每个返回值为TRUE的TryEnterCriticalSection须有一对应的LeaveCriticalSection。

-关键段和旋转锁
当线程试图进入一个关键段,但这个关键段正被另一个线程占用的时候,函数立即把调用线程切换到等待状态。这意味着线程须从用户模式切换到内核模式(约1000个CPU周期),这个切换的开销非常大。在配有多处理器的机器上,当前占用资源的线程可能在另一个处理器上运行,而且可能很快就会结束对资源的访问。

为提高关键段的性能,Microsoft把旋转锁合并到了关键段中。当调用EnterCriticalSection时,会用一个旋转锁不断地循环,尝试在一段时间内获得对资源的访问权。只有当尝试失败时,才会切换到内核模式并进入等待状态。

// 为了在使用关键段的时候同时使用旋转锁,须调用下面的函数来初始化关键段
BOOL InitializeCriticalSectionAndSpinCount(
PCRITICAL_SECTION pcs,
// 希望旋转锁循环的次数
DWORD dwSpinCount
);

// 改变关键段的旋转次数
DWORD SetCriticalSectionSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount
);

在把线程切换到等待状态之前,函数会先尝试用旋转锁来获得对资源的访问权。这个值可是从0到0x00FFFFFF之间的任何一个值。如在单处理器的机器上使用此函数,忽略传入的次数,次数总是0。

用来保护进程堆的关键段所使用的旋转次数大约是4000,可作为我们的一个参考值。

-关键段和错误处理
1.
InitializeCriticalSection会分配一块内存,来让系统提供一些内部调试信息。如果内存分配失败,那么函数会抛出STATUS_NO_MEMORY异常。我们可使用结构化异常来捕获这个异常。

2.
InitializeCriticalSectionAndSpinCount,也会为调试信息分配一块内存,但如果分配不成功,它会返回FALSE。

3.
如果两个或两个以上线程在同一时刻争夺同一个关键段,那么关键段会在内部使用一个事件内核对象。因为,争夺现象很少发生,因此只有当第一次要用到事件内核对象的时候,系统才会真正创建它。
只有在调用DeleteCriticalSection时,系统才会释放这个事件内核对象。
如创建事件内核对象失败,
EnterCriticalSection函数会抛出EXCEPTION_INVALID_HANDLE异常。
由于这种错误是极其罕见的,大部分开发人员会在代码中忽略这个潜在的错误。但是,如打算对这种情况进行处理,有两种选择。
一种是,使用结构化异常来捕获错误。
一种是,使用InitializeCriticalSectionAndSpinCount来初始化关键段,并将dwSpinCount参数的最高位设为1。当函数看到参数的最高位为1时,会在初始化时就创建一个与关键段相关联的事件内核对象。如无法创建事件内核对象,那么函数会返回FALSE。如果成功创建了事件内核对象,那么我们就知道EnterCriticalSection将总是可以正常工作,绝不会抛出异常。

4.有键事件类型的内核对象
用来帮助解决在资源不足的情况下创建事件的问题。

当操作系统创建进程的时候,总是会创建一个有键事件。
这个未公开的内核对象的行为与事件内核对象相同,唯一的不同之处在于它的一个实例能够同步不同的线程组,每组由一个指针大小的键值来标识和阻挡。
在关键段的情况中,当内存少到不足以创建一个事件内核对象时,可将关键段的地址当作键值使用。系统可对试图进入这个关键段的线程进行同步,并在必要情况下,将它们阻挡在外。

-Slim读/写锁
1.
SRWLock的目的和关键段相同:对一个资源进行保护,不让其它线程访问它。但,与关键段不同的是,SRWLock允许我们区分那些想要读取资源的值的线程和想要更新资源的值的线程。
让所有的读取着线程在同一时刻访问共享资源是可行的。
只有当写入者线程想要对资源进行更新的时候才需要进行同步。这时,写入者线程应独占对资源的访问权:任何其它线程,无论是读取者线程还是写入者线程,都不允许访问资源。

2.使用

// 1.分配一个SRWLock结构

// 2.初始化
VOID InitializeSRWLock(PSRWLOCK pSRWLock);

// 3.尝试获得对被保护资源的独占访问权

// 写入者版
VOID ReleaseSRWLockExclusive(PSRWLOCK pSRWLock);
// 读取者版
VOID AcquireSRWLockShared(PSRWLOCK pSRWLock);

// 4.完成对资源的更新后,解除对资源的锁定

// 写入者版
VOID ReleaseSRWLockExclusive(PSRWLOCK pSRWLock);
// 读取者版
VOID ReleaseSRWLockShared(PSRWLOCK pSRWLock);

// 系统自动执行清理工作

与关键段相比缺少的两个特性:
-不存在TryxxSRWLock之类的函数。
-不存在计数值。即,一个线程不能为了多次写入资源而多次锁定资源,再多次释放锁定。

-条件变量
1.有时想让线程以原子方式:
把锁释放
将自己阻塞,直到某一个条件成立为止。

// Windows提供条件变量来帮助我们简化这种情形下的工作
BOOL SleepConditionVariableCS(
// 指向一个已初始化的条件变量,调用线程正在等待该条件变量
PCONDITION_VARIABLE pConditionVariable,
PCRITICAL_SECTION pCriticalSection,
// 标识希望线程发多少时间来等待条件变量被触发
DWORD dwMilliseconds
);

BOOL SleepConditionVariableSRW(
// 指向一个已初始化的条件变量,调用线程正在等待该条件变量
PCONDITION_VARIABLE pConditionVariable,
PSRWLOCK pSRWLock,
DWORD dwMilliseconds,
// 一旦条件变量被触发,希望线程以何种方式得到锁
// 0 写入式
// CONDITION_VARIABLE_LOCKMODE_SHARED 读取式
ULONG Flags
);

// 使一个在SleepConditionVariable*函数中等待同一个条件变量被触发的线程得到锁并返回。
VOID WakeConditionVariable(
PCONDITION_VARIABLE pConditionVariable
);

// 使一个或几个在SleepConditionVariable*中等待这个条件变量被触发的线程得到对资源的访问权并返回。
VOID WakeAllConditionVariable(
PCONDITION_VARIABLE pConditionVariable
);

当指定的时间用完的时候,如果条件变量尚未被触发,函数会返回FALSE,否则,函数会返回TRUE。
另一个线程检测到相应的条件已经满足的时候,会调用WakeConditionVariable或WakeAllConditionVariable,这样阻塞在Sleep*函数中的线程就会被唤醒。