第8章 用户模式下的线程同步(1)_Interlocked系列函数

时间:2023-12-11 22:41:50

8.1 原子访问:Interlocked系列函数(Interlock英文为互锁的意思)

(1)原子访问的原理

  ①原子访问:指的是一线程在访问某个资源的同时,能够保证没有其他线程会在同一时刻访问该资源。

  ②从汇编的角度看,哪怕很简单的一条高级语言都可以被编译成多条的机器指令。在多线程环境下,这条语句的执行就可能被打断。而在打断期间,其中间结果可能己经被其他线程更改过,从而导致错误的结果。

  ③在Intelx86指令体系中,有些运算指令加上lock前缀就可以保证该指令操作的原子性。其原理是CPU执行该指令时发现其前面加lock前缀,就会在总线维持一个硬件信号以阻止其他CPU(或线程)访问与该指令相同的目标内存地址。(注意是指令目标操作数的内存地址而且这些地址是经过内存对齐过的!)

  ④以InterlockedIncrement函数为例分析原子访问的原理

LONG InterlockedIncrement(LPLONG volatile lpAddend)
{
_asm{   //xadd指令两个功能:①交换(opd)→(ops);②(opd)←(opd)+(ops)
   mov eax, //前缀lock表示线程在执行该指令时,会将[ecx]内存锁
  mov cx,Addend //定(实际上是在CPU总线上放一个信号)以标志该内存正在被使用,
  lock xadd [ecx],eax //从而阻止其他线程同时访问该内存。即其他线程要么在该指令之前,
  inc eax //要么在指令之后才能访问[ecx]指向的这块内存
}
}

(2)Windows内核支持的整数原子操作——Interlocked***互锁函数

函数

描述

InterlockedIncrement

InterlockedDecrement

对LONG变量加(减)1,如:InterlockedIncrement(&g_iX)

(内部使用lock xadd指令)

InterlockedExchangeAdd

将一个值加到一个LONG变量,返回变量原值,使用lock xadd指令,如:int g_iX = 0;   //

InterlockedExchangeAdd(&g_iX,-2); //g_iX -= 2;

InterlockedCompareExchange

InterlockedCompareExchange( plDest,lExchange,

lComperand)。比如*plDestination==lComperand,如果相等将*plDest修改为lExchange,如果不等,则*plDest不变。返回值为*plDest原来的值。 (使用lock cmpxchag指令)

InterlockedExchange

InterlockedExchangePointer

将第1个参数所指的内存里的当前值,以原子方式替换为第2个参数指定的值。函数返回值为原始值。后面那个函数是改变一个指针本身的值。(如果xchg指令,虽不加lock。但默认为原子操作)

InterlockedOr

对一个LONG变量做逻辑或运算,使用lock or指令

InterlockedAnd

对一个LONG变量做逻辑与运算,使用lock and指令

InterlockedXor

对一个LONG变量做逻辑异或运算,使用lock xor指令

(3)Interlocked单向链表函数

函数

描述

InitializeSListHead

创建一个空栈

InterlockedPushEntrySList

在栈顶添加一个元素

InterlockedPopentrySList

移除位于栈顶的元素并将它返回

InterlockedFlushSlist

清空栈

QueryDepthSlist

返回栈中元素的数量

【Interlocked单链表】演示程序

第8章 用户模式下的线程同步(1)_Interlocked系列函数

#include <windows.h>
#include <malloc.h>
#include <stdio.h>
#include <tchar.h> typedef struct _tag_PROGRAM_ITEM
{
SLIST_ENTRY ItemEntry;
ULONG Signature;
}PROGRAM_ITEM,*PPROGRAM_ITEM; int _tmain()
{
ULONG Count;
PSLIST_ENTRY pFirstEntry, pListEntry;
PSLIST_HEADER pListHead;
PPROGRAM_ITEM pProgramItem; //初始化链表头部
//C库函数_aigned_malloc用来分配一个块对齐过的内存,其中第1个参数表示要分配
//的字节数,第2个参数表示要对齐到的字节边界(必须是2的整数幂次方)
pListHead = (PSLIST_HEADER)_aligned_malloc(sizeof(SLIST_HEADER),
MEMORY_ALLOCATION_ALIGNMENT);
if (NULL == pListHead){
_tprintf(_T("内存分配失败!"));
return -;
} //初始化链表头,创建一个空栈
InitializeSListHead(pListHead); //插入10个元素
for (Count = ; Count <= ;Count++){
pProgramItem = (PPROGRAM_ITEM)_aligned_malloc(sizeof(PROGRAM_ITEM),
MEMORY_ALLOCATION_ALIGNMENT);
if (NULL == pProgramItem){
_tprintf(_T("内存分配失败!"));
return -;
}
pProgramItem->Signature = Count; //返回值为链表中以上的第1个元素,如果以前是空链表,则这里返回NULL
pFirstEntry = InterlockedPushEntrySList(pListHead,
&(pProgramItem->ItemEntry)); //也可以pProgramItem
} //删除10个元素,并显示其signature字段
for (Count = ; Count >= ;Count-=){
pListEntry = InterlockedPopEntrySList(pListHead);
if (NULL == pListEntry){
_tprintf(_T("链表是空的!"));
return -;
}
pProgramItem = (PPROGRAM_ITEM)pListEntry;
_tprintf(_T("Signature = %d\n"), pProgramItem->Signature); //释放该元素的内存
_aligned_free(pListEntry);
} //清空链表,并验证是否所有元素都己释放
pListEntry = InterlockedFlushSList(pListHead);
pFirstEntry = InterlockedPopEntrySList(pListHead);
if (pFirstEntry != NULL){
_tprintf(_T("错误:链表非空!\n"));
return -;
} _aligned_free(pListHead); _tsystem(_T("PAUSE"));
return ;
}

(4)利用InterlockedExchange实现自旋锁(spinlock)

 //全局变量用来表明“共享资源”是否正在被使用
BOOl g_fResourceInUse = FALSE; void Func()
{
//等待资源的访问权——注意InterlockedExchange返回旧的值
while(InterlockedExchange(&g_fResourceInUse,TRUE)==TRUE)
Sleep(); //如果等不到锁,就休眼一下,以防止循环,浪费CPU。 //访问资源
…… //不再使用资源时
InterlockedExchange(&g_fResource,FALSE); //交出锁
}

  ①使用这项技术要极其小心,因为旋转锁是通过循环实现的,较耗费CPU时间,所以在while中加Sleep,可以改善这种状况,以避免浪费CPU。当然也可以用SwitchToThread代替,以便让低优先级的线程也有被调度的机会。

  ②特别要注意的是,在单CPU的机器上要避免使用旋转锁,因为如果这个线程一直在不停循环,对CPU浪费大,也影响了其他线程改变锁的值,造成恶性循环。

  ③使用旋转锁的线程优先级要相同,否则如果等待锁的线程优先级高,则使用资源的线程可能会因分配不到CPU时间而无法释放锁。所以使用这种锁的线程要通过SetProcessPrirityBoost(或SetThreadPriortyBoost)来禁用系统动态提升线程优先级

  ④要确保锁变量和锁所保护的数据位于不同的高速缓存行(cache line),如果在同一高速缓存行,当使用资源的CPU更改了被保护的数据,会也会其他CPU相应的高速缓存行失效,这里等待锁的CPU还要从内存中(注意:不是CPU高速缓存行)读入锁的状态,这浪费了CPU时间。

  ⑤旋转锁是假定被保护资源始终只会占用一小段时间。与切换到内核模式的等待相比,这种通过循环方式的等待效率更高

8.2 高速缓存行

8.2.1 CPU、CPU高速缓存、内存的关系——见《深入理解计算机系统(第2版)P408》

第8章 用户模式下的线程同步(1)_Interlocked系列函数

(1)CPU是基于程序代码和数据在时间和空间上的局部性原理预测接下来将要用来的数据,并把这个数据(含指令)装载到高速缓存。

(2)每个CPU都有自己的高速缓存,包括指令Cache和数据Cache(其中的数据Cache一般包含多级)

(3)CPU不直接访问主存,而是通过Cache间接的访问内存。

(4)每次都从内存中读取的数据不是1个字节,而是一个Cache Line(高速缓存行,可能是32字节、64字节或128字节,取决于CPU)。

8.2.2 多处理器下的读写问题

(1)举例分析

  ①CPU1读取一个字节,这使得该字节及其相邻的若干字节被读到CPU1的高速缓存行

  ②CPU2读取同一个字节,同①一样,将那串字节的数据读到CPU2的高速缓存行

  ③设CPU1对内存的这个字节修改,被写进CPU1的高速缓存行,但未真正写回主存

  ④CPU2再次读取这个字节,由于该字节己经在CPU2的高速缓存行中,因些CPU2不需再访问内存。但CPU2将无法知道这个值已经在CPU1中得到更新的值。

(2)解决方案:

  ①当一个CPU修改了高速缓存行中的1个字节,其他CPU会收到通知,并使自己的缓存行作废。如CPU1修改了自己的缓存行数据,CPU2中如果相关的缓存行就作废。

  ②CPU2读取自己缓存行的该字时,发现己经作废,则系统会调度CPU1把新值写入主存,然后CPU2重新访问内存来填充它的高速缓存行。可见高速缓存行提高了性能,但在多CPU的机器上同样会损失性能。

(3)编程启示

  ①应根据数据缓存行的大小来组织应用程序的数据,使数据与缓存行边界对齐。目的是确保不同的CPU能够各自访问不同的内存地址,而这些内存地址不在同一高速缓存行中。(★获取缓存行大小:通过GetLogicalProcessInformation,传入SYSTEM_LOGICAL_PROCESSOR_INFORMATION结构体,从这个结构体的CACHE_DESCRIPTOR字段中的LineSize获得。) 

  ②利用LineSize,通过__declspec(align(XXX))来对字段对齐加以控制

糟糕的数据结构设计

改进后的版本

Struct CUSTINFO{

DWORD dwCustomerID;//只读,经常访问

int  nBalanceDue;  //读写

wchar_t szName[100]; //只读,经常访问

FILETIME ftLstOrderDate; //读写

}

【说明】避免高速缓存行可能出现问题的方法

①使用局部变量或函数参数时,因为他们只让一个线程访问数据,就不会受其他线程影响

②设置线程亲缘性,始终只让一个CPU访问数据。

#define CACHE_ALIGN 64 //64是从LineSize中获取

//强迫每个结构体放在不同的缓存行中

struct __declspec(align(CACHE_ALIGN)) CUSTINFO{

DWORD dwCustomerID;  //只读,经常访问

wchar_t szName[100]; //只读,经常访问

//将以下两个字段放在不同的缓存行中

  __declspec(align(CACHE_ALIGN))

int  nBalanceDue;  //读写

FILETIME ftLstOrderDate; //读写

}

8.3 高级线程同步

(1)Interlocked是以原子方式修改一个值,但如果要以原子方式修改“复杂数据结构时”,需要用其他同步手段,如Critical Section、互斥锁等。

(2)旋转锁的使用应谨慎,原因是浪费CPU时间比较严重,特别是在单CPU的机器上不应使用它。

(3)当线程无法取得对资源的访问权或特殊事件尚未发生时,线程应进入不可调度的等待状态,从而避免了浪费CPU的现象。

(4)volatile限定符会告诉编译器不要对变量进行优化,而是每次读取该变量时都从内存中获取(有时为了提高效率,编译器会变量放到某个寄存器,以便快速访问。这在单线程可能没有问题,但多线程中,这个内存中的变量可能被修改,而出现寄存器的那个变量与内存变量值的不一致,volatile会强迫总是从内存中读取而不优化)。但注意以下区别

//变量前须加volatile

volatile int g_iX = 0;

DWORD WINAPI ThreadProc(…)

{

//g_iX可能被其他线程修改,

//这里强迫每次从内存读取

int xOrg = g_iX; //以值的形式访问g_iX

……

}

//变量前无须加volatile

int g_iX = 0;

DWORD WINAPI ThreadProc(…)

{

//以下函数是以地址方式访

//问g_iX,自然就要从内存中

//中取,所以无须加volatile

InterlockedIncrement(&g_iX);

……

}

(5)volatile修饰结构体时,等于结构体中所有成员都加volatile限定符。