【Windows核心编程学习笔记】用户模式上的线程同步之一-Interlocked系列函数及高速缓存行

【Windows核心编程学习笔记】用户模式下的线程同步之一---Interlocked系列函数及高速缓存行

在下面两种基本情况下,线程之间需要相互通信:

(1)需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性。

(2)一个线程需要通知其他线程某项任务已经完成。


一、下面介绍线程同步内容之一---原子访问:Interlocked系列函数


线程同步的一大部分与原子访问(atomic access)有关。所谓原子访问,指的是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。


下面从一段简单的代码说起。

#include <Windows.h>
#include <iostream>

using namespace std;

const int NUM_THREADS = 2;
//全局变量
long g_x = 0;

//线程函数1
DWORD WINAPI ThreadFUnc1(PVOID pvParam)
{
	++g_x;

	return 0;
}

//线程函数2
DWORD WINAPI ThreadFUnc2(PVOID pvParam)
{
	++g_x;

	return 0;
}

DWORD WaitForAllThread(HANDLE hThread[],DWORD dwNumThread)
{
	//等待所有线程结束
	DWORD deWaitResult = WaitForMultipleObjects(
		dwNumThread,
		hThread,
		TRUE,
		INFINITE);

	switch(deWaitResult)
	{
	case WAIT_OBJECT_0:
		cout << "All threads exit" << endl;
		break;
	default:
		cout << "Wait error" << GetLastError << endl;
	}

	return 0;
}

void revokeThreads()
{
	//线程句柄
	HANDLE hThread[NUM_THREADS];

	//定义函数指针类型
	//typedef DWORD (WINAPI *ptrThreadFunc) (PVOID pvParam);
	//ptrThreadFunc threadFuncs[NUM_THREADS] = {
	//	ThreadFUnc1,ThreadFUnc2
	//};

	PTHREAD_START_ROUTINE threadFuncs[NUM_THREADS] = {
		ThreadFUnc1,ThreadFUnc2
	};

	for (int i = 0;i < NUM_THREADS;++i)
	{
		hThread[i] = CreateThread(NULL,0,
			threadFuncs[i],NULL,0,NULL);
	}

	WaitForAllThread(hThread,NUM_THREADS);
}

int main()
{
	revokeThreads();

	cout << g_x << endl;
}

代码中声明了一个全局变量并将其初始化为0.现在假设我们创建2个线程,一个线程执行函数ThreadFunc1,ThreadFunc1另一个线程执行函数ThreadFunc2.当两个线程都停止运行的时候,g_x的值一定是2吗?答案是:否。

这是因为Windows是一个抢占式的多线程环境,因此系统会在任意时刻暂停执行一个线程,切换到另一个线程并让新线程继续执行。因此程序的执行顺序可能不是先执行完一个线程的函数里面的代码,然后继续执行另一个线程的函数代码,然后结束得到结果是2.


为了解决这个问题,我们可以采用一些简单的方案,我们需要有一种方法可以保证对一个值的递增操作是原子操作---即不会被打断。Interlocked系列函数提供了我们需要的解决方案。


下面看看两个函数InterlockedExchangeAdd以及InterlockedExchangeAdd64:

// 传一个长整形变量的地址和另一个增量值,函数就会保证递增操作是以原子方式进行的。
LONG InterlockedExchangeAdd(PLONG volatile plAddend,LONG lIncrement);

LONGLONG InterlockedExchangeAdd64(PLONGLONG volatile pllAddend,LONGLONG llIncrement);
因此,我们可以对前面的代码进行修改,解决问题:

//定义一个全局变量
long g_x = 0;

//线程函数1
DWORD WINAPI ThreadFunc1(PVOID pvParam)
{
	InterlockedExchangeAdd(&g_x,1)
}

//线程函数2
DWORD WINAPI ThreadFunc2(PVOID pvParam)
{
	InterlockedExchangeAdd(&g_x,1)
}
需要注意的是,所有线程都应该调用这些函数来修改共享变量的值,任何一个线程都不应该使用简单的C++语句来修改共享变量。


那么Interlocked系列函数是如何工作的呢

这取决于代码运行的CPU平台。如果是x86系列CPU,那么Interlocked函数会在总线上维持一个硬件信号,这个信号会阻止其他CPU访问同一个内存地址。


Interlocked系列函数的优点是:执行的很快,通常只占用几个CPU周期(通常小于50),而且不需要在用户模式和内核模式之间切换(通常需要耗费1000个周期以上)。

其他三个Interlocked函数如下:

//InterlockedExchange和InterlockedExchangePointer会把第一个参数所指向的内存地址的当前值,以原子方式替换为第二个参数指定的值。
LONG InterlockedExchange(OUT PLONG Target,IN LONG Value);

LONGLONG InterlockedExchange64(PLONGLONG volatile plTarget,LONGLONG lValue);

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

在实现旋转锁(spinlock)的时候,函数InterlockedExchang及其有用:

BOOL g_fResourceInUse = FALSE;

void func1()
{
	//等待访问资源
	while(InterlockedExchange(&g_fResourceInUse,TRUE) == TRUE)
		Sleep(0);

	//访问资源
	//....

	InterlockedExchange(&g_fResourceInUse,FALSE);
}

其工作原理:while循环不断运行,把g_fResourceInUse的值设为TRUE并检查原来的值是否为TRUE。如果原来的值为FALSE,那么说明资源尚未被使用,于是调用线程立即就能将它设为使用中,然后退出循环。如果原来的值为TRUE,那说明有其他的线程正在使用该资源,于是while循环会继续执行。

如果另外一个线程也执行类似的代码,那么它会一直执行while循环,直到g_fResourceInUs被改回FALSE。函数最后对InterlockedExchange的调用展示了如何将g_fResourceInUse改回FALSE。

需要注意的是,旋转锁会耗费CPU时间。CPU必须不断测试,知道另一个线程改变了这个变量的值为止。

此外,我们必须确保锁变量和锁所保护的数据位于不同的高速缓存行中。否则,使用资源的CPU就会与任何试图访问资源的CPU发生争夺。

在单CPU的机器上,应该避免使用旋转锁。如果一个线程不断循环,那么不仅会浪费宝贵的CPU时间,而且会阻止其他现场改变锁的值。前面的代码使用了Sleep函数改善了这一状况。

旋转锁假定被保护的资源只会被占用一小段时间。与切换到内核模式让后等待相比,在这种情况下以循环的方式进行等待的效率会更高。许多开发人员会指定循环的次数,如果届时仍然无法访问资源,那么线程会切换到内核模式,并一直等到资源可供使用位置,这就是关键段(critical section)的实现方式。


还有几个Interlocked函数,在这里列出来:

LONG InterlockedCompareExchange(
  LPLONG Destination, LONG Exchange, LONG Comperand );
如果第三个参数与第一个参数指向的值相同,那么用第二个参数取代第一个参数指向的值。函数返回值为原始值。
PVOID InterlockedCompareExchangePointer (
  PVOID *Destination, PVOID Exchange, PVOID Comperand );
如果第三个参数与第一个参数指向的值相同,那么用第二个参数取代第一个参数指向的值。函数返回值为原始值。


下面的两个函数可以用InterlockedExchangeAdd实现。但是这两个新函数可以加减任何值,而旧函数只能加减1.

LONG InterlockedIncrement(PLONG plAddend);

LONG InterlockedDecrement(PLONG plAddend);

除此之外,还有基于InterlockedCompareExchange64实现的用于OR、XOR和AND操作的Interlocked函数,比如InterlockedAnd64

LONGLONG InterlockedAnd64(LONGLONG* Destination, LONGLONG value) {
  LONGLONG old = *Destination;
  do {
    old = *Destination;
  } while(InterlockedCompareExchange64(Destination, old&value, old) != old);
  return old;
}


如果只需要以原子方式修改一个值,那么interlocked系列函数非常好用。我们当然应该优先使用它们。但大多数实际的编程问题需要处理的数据结构往往要比一个简单的32为值或64位值复杂得多。为了能够以原子方式访问复杂数据结构,我们必须超越interlocked函数,转而使用Windows提供的一些其他特性。

前面提到了旋转锁要谨慎使用,是因为它会浪费CPU时间。我们需要一种机制,既能够让线程等待共享资源的访问权,又不会浪费CPU时间。


二、高速缓存行


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

但是,在多处理器环境中,高速缓存行是的对内存的更新变得更加困难。

(1)CPU1读取一个字节,这使得该字节和与他相邻的字节被读到CPU1的高速缓存行中。

(2)CPU2读取一个字节,这使得该字节被读到CPU2的高速缓存行中。

(3)CPU1对该字节进行修改,是的该字节被写入到CPU1的高速缓存中,但这一信息还没被写回到内存中。

(4)CPU2再次读取同一个字节。由于该字节已经在CPU2的高速缓存行中,因此CPU2不需要再访问内存。但CPU2将无法看到该字节在内存中新的值。

这种情况十分糟糕。CPU芯片设计者做了专门的设计来处理这个问题:当一个CPU修改了高速缓存行中的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。于是在上述第四步中,CPU1必须将它的高速缓存行写回到内存中,CPU2必须重新访问内存来填满它的高速缓存行。可以看出,损失了性能。


因此,我们应该根据高速缓存行的大小将应用程序的数据组织在一起,并将数据与缓存行的边界对齐。这样做的目的是确保不同的CPU能够各自访问不同的内存地址,而且这些地址不在同一个高速缓存行中。此外,我们应该把只读数据与可读写数据分别存放。我们还应该把差不多会在同一时间访问的数据组织在一起

1楼yt2233444天前 10:59
给点建议
Re: xiajun070612254天前 19:04
回复yt223344n恩?