1. 原子操作
1.1 InterLocked函数的工作原理取决于CPU平台,如果是x86系列CPU,那么InterLocked函数会在总线上维持一个硬件信号,这个信号会阻止其他CPU访问同一个地址。
1.2 我们必须保证传给InterLocked函数的变量地址是经过对齐的,否者这些函数可能会失败。可以使用_aligned_malloc函数分配一块对齐过的内存。
1.3 InterLocked函数执行极快,通常只占用几个CPU周期(通常<50),而且也不需要在用户模式和内核模式之间进行切换(这个切换通常需要占用1000个周期以上)。
2. 高速缓存行
2.1 当CPU从内存读取一个字节的时候,是取回一个高速缓存行,高速缓存行可能包含32/64/128字节(取决于CPU),也始终是对齐的。
2.2 在多CPU中,当一个CPU修改了高速缓存行中的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。为此:
- 应该根据高速缓存行的大小将应用程序的数据组织在一起。并将数据与缓存行的边界对齐,确保不同的CPU能够各自访问不同的内存地址,而且这些地址不在同一个高速缓冲行中;
- 应该把只读数据(或不经常访问的数据)与可读写的数据分别存放;
- 应该把差不多会在同一时间访问的数据组织在一起;
// terrible design struct CUSTINFO { DWORD dwCustomerID; // Mostly read-only int nBalanceDue; // Read-write wchar_t szName[100]; // Mostly read-only FILETIME ftLastOrderDate; // Read-write }; #define CACHE_ALIGN 64 // Force each structure to be in a different cache line struct __declspec(align(CACHE_ALIGN)) CUSTINFO { DWORD dwCustomerID; // Mostly read-only wchar_t szName[100]; // Mostly read-only // Force the following members to be in a different cache line __declspec(align(CACHE_ALIGN)) int nBalanceDue; // Read-write FILETIME ftLastOrderDate; // Read-write };
3. 高级线程同步
3.1 volatile 关键字告诉编译器这个变量可能会被应用程序之外的其他东西修改,比如操作系统、硬件或者一个并发执行的线程。确切的说 volatile 关键字告诉编译器不要对这个变量进行任何形式的优化,而是始终从变量所在的内存种读取值。
3.2 如果传一个变量的地址给函数,那么函数必须从内存中读取它的值,编译器的优化程序不会对此产生影响。
4. 关键段
4.1 CRITICAL_SECTION 在内部也使用了 Interlocked 函数,因此执行速度非常快,其最大的缺点在于它们无法用来在多个进程之间对线程进行同步。
4.2 EnterCriticalSection 会检查结构中的成员变量,这些变量表示是否有线程正在访问资源,以及哪个线程正在访问资源,会执行以下测试:
- 如果没有线程正在访问资源,EnterCriticalSection 会更新成员变量,表示调用线程已经获准对资源的访问,并立即返回。
- 如果成员变量表示调用线程已经获准访问资源,那么EnterCriticalSection会更新变量,并立即返回。
- 如果成员变量表示有一个其他线程已经获准访问资源,那么EnterCriticalSection会使用一个内核对象来把调用线程切换到等待状态。一旦当前正在访问资源的线程调用了LeaveCriticalSection,系统会自动更新CRITICAL_SECTION的成员变量并将等待中的线程切换回可调度状态。
4.3 等待关键段的线程是绝对不会 starved 的,对 EnterCriticalSection 的调用最终会超时并引发异常,导致超时的时间长度由注册表决定,默认值为 2592000 秒(约30天)。
4.4 LeaveCriticalSection 会检查结构内部的成员变量并将计数器减1,该计数器表示调用线程获准访问共享资源的次数。
- 如果计数器大于0,LeaveCriticalSection 会直接返回,不执行任何其他操作。
- 如果计数器变成了0,LeaveCriticalSection会更新成员变量,以表示没有任何线程正在访问被保护的资源。同时检查有没有其他线程由于调用了 EnterCriticalSection 而处于等待状态,那么函数会更新成员变量,把其中一个处于等待状态的线程切换回可调度状态。
LeaveCriticalSection总是立即返回的。
4.5 当线程试图进入一个正在被另一个线程占用的关键段时,函数回立即把调用线程切换到等待状态。这意味着线程必须从用户模式切换到内核模式(大约1000个CPU周期),为了提高关键段的性能,Microsoft 在调用 EnterCriticalSection 时,会用一个旋转锁不断地循环,尝试在一定时间内获得对资源的访问权。只有当尝试失败的时候,线程才会切换到内核模式并进入等待状态。使用旋转锁之前必须调用 InitializeCriticalSectionAndSpinCount 来初始化。在单处理器上,旋转次数始终为0。
4.6 只有当第一次要用到事件内核对象的时候,系统才会真正创建它,只有在调用 DeleteCriticalSection 的时候,系统才会释放这个事件内核对象。使用 InitializeCriticalSectionAndSpinCount 来创建关键段,并将dwSpinCount参数的最高位设为1,会在初始化时就创建一个与关键段相关联的事件内核对象,如果无法创建则返回FALSE,也避免了 EnterCriticalSection 抛出异常。
4.7 自从Windows XP开始,引入了新的有键事件(keyed event)类型的内核对象,用来帮助解决在资源不足的情况下创建事件的问题。当操作系统创建进程的时候,总是会创建一个有键事件,这个未公开的内核对象的行为与事件内核对象相同,唯一的不同之处在于它的一个实例能够同步不同的线程组,每组由一个指针大小的键值来标识和阻挡。
4.8 在关键段的情况中,当内存少到不足以创建一个事件内核对象的时候,可以将关键段的地址当作键值来使用,系统可以对试图进入这个关键段的线程进行同步,并在必要的情况下将它们阻挡在外。
5. Slim 读/写锁
5.1 SRWLock 允许我们区分那些想要读取资源的值的线程和想要更新资源的值的线程。和关键段相比,缺乏以下两个特性:
不存在TryEnter...之类的函数。
不能递归地获得 SRWLock。也就是说,一个线程不能为了多次写入资源而多次锁定资源,然后再多次释放对资源的锁定。
5.2 如果希望在应用程序中得到最佳性能,那么首先应该尝试不要共享数据,然后依次使用 volatile 读取,volatile 写入,Interlocked API,SRWLock 以及关键段。当且仅当所有这些都不能满足要求的时候,再使用内核对象。