概述
从最初的写并发服务器程序接触多线程的概念,到如今写一个远程注入程序,越来越多的和线程打起交道。我们通常熟悉也容易理解进程,而线程则作为对于一个具体执行流程的抽象出现。在同样一个程序“容器”中,可以同时存在多个执行流程,这些流程的状态完全由他们各自的Context决定的。一般的小程序只需要一个主线程就足够了,但是很多时候我们会需要多线程的协作处理。
其实,如果所有的线程都能够独自运行而不需要相互通信,Microsoft Windows将进入最佳运行状态。然而,同时存在的多个线程经常会发生各种各样的联系,需要进行“同步”才可以合作运行。一般来说有两种情况:
1. 需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性
2. 一个线程需要通知其他线程某项任务已经完成
《Windows核心编程》从用户模式和内核模式两个层面来讨论线程的同步问题,由于是第一遍学习,理解的程度有限,所以没有涉及条件变量部分,只就自己感觉学有所获的几个知识点做一个小结。这篇笔记的结构如下:
F 原子访问:Interlocked系列函数
F 关键段
F Slim读写锁
原子访问
线程同步的一大部分与原子访问(atomic access)有关。所谓原子访问,就是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。Windows是一个多线程抢占式系统,这也就意味着系统可以随时挂起一个线程而去执行另一个线程,这中间由于CPU和线程本身的关系就会出现各种各样的同步逻辑错误,比如下面我们要举的例子。原子访问的“原子性”就在于一个原子访问是不能被中途打断的,只有在一个原子访问结束之后才可以接受另一个线程的访问。Windows为我们提供了Interlocked系列函数来实现这一功能。
LONG InterlockedExchangeAdd(
PLONG volatile plAddend, //要计算的长整型变量的地址
LONG lIncrement //指定的长整型增量
);
这个函数对传入的变量(*plAddend)增加lIncrement的值,可正可负。volatile要求系统不要进行代码优化,每次CPU都从内存读取值,而不是优化偷懒从寄存器中读取。当然我们还有类似于赋值的函数:
LONG InterlockedExchange(
PLONG volatile plTarget, //要赋值的变量地址
LONG lValue //要赋的值
);
我们看一个例子:
//本程序用来实验学习原子操作函数Interlocked系列函数
#include
#include
#include
#include //使用C++输入输出流对象需要包含此头文件,且指定命名空间
using namespace std;
longdwGlobal = 0; //用来测试的全局变量
DWORD WINAPI Thread1(LPVOID lpParam);
DWORD WINAPI Thread2(LPVOID lpParam);
intmain(int argc, char*argv[], char *env[])
{
HANDLEhThread1, hThread2;
cout<
hThread1= CreateThread(NULL, 0, Thread1, NULL, 0, NULL);
cout<" after Thread1"<
hThread2= CreateThread(NULL, 0, Thread2, NULL, 0, NULL);
CloseHandle(hThread1);
CloseHandle(hThread2);
//这里其实应当添加一条Wait语句来确保子线程执行退出
//Sleep(1000); //如果没有这句,主线程会在子线程运行结束前结束,结果仍然不会是2
cout<" after Thread 1&2"<
cout<<"主线程结束"<
system("pause");
return 0;
}
DWORD WINAPI Thread1(LPVOID lpParam)
{
cout<"线程1开始运行..."<
dwGlobal++;
cout<"线程结束"<
return 1;
}
DWORD WINAPI Thread2(LPVOID lpParam)
{
cout<"线程2开始运行..."<
dwGlobal++;
Sleep(1000);
cout<"线程结束"<
cout<"dwGlobal at last:\t"<
return 2;
}
为了更好的理解Windows多线程抢占式系统意味着什么,特意加了一些输出标识用来帮助我们判断各个线程的执行退出情况,结果如下:
仔细观察分析这个输出结果,我们会看到线程1在主线程执行到cout<" afterThread1"<
时被临时挂起了,出现的换行符应该是线程1中的
cout<"线程1开始运行..."<
接下来又是交替执行输出指令。同时我们也会注意到主线程退出之后,线程2仍然继续执行,修改dwGlobal值后退出。所以,主线程结束并不会结束其生成的子线程。
如果在单CPU上运行该程序,线程函数中不是用InterlockedExchangeAdd()而是直接使用C++语句修改全局变量的值,如”dwGlobal++;”则结果可能为0,1和2.因为在两个子线程执行的过程中可能发生线程调度。如果从汇编的角度来看,两个线程的计算过程如下:
a. MOV EAX, [dwGlobal]
b. INCEAX
c. MOV [dwGlobal], EAX
可以看到变量的值增加是在寄存器EAX中变化的。可以想象,如果线程1在执行到b时切换到线程2,执行完后切换回线程1,那么结果就不是2而是1.这当然是我们不希望看到的。使用Interlocked系列函数后可以利用原子访问的特性有效避免这样的情况。但是由于自己的机子是多核CPU,因而在运行多线程时不存在调度挂起,结果运行始终是2.但是对于任何一个共享变量的访问都不应该直接使用C++语句,应该尽可能使用原子访问实现。
当然,这里还有一个需要注意的问题,就是如果主线程中没有Sleep()语句,主线程会比子线程先结束,这样两个子线程可能不能正常执行完退出,因而不能输出正确的结果。这部分分析线程的流程调度比较麻烦,有兴趣的同学可以参考《windows核心编程》第八章。
关键段
关键段(Critical Section)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”来对资源进行操控。这里的原子方式指的是代码除了当前线程之外,没有其他任何线程会同时访问该资源。当然,系统仍然可以暂停当前线程去调度其他线程。但是,在当前线程离开关键段之前,系统是不会去调度任何想要访问同一资源的其他线程的。我们对上面的例子稍加修改:
//本程序用来实验学习关键段的使用
#include
#include
#include
#include //使用C++输入输出流对象需要包含此头文件,且指定命名空间
using namespace std;
DWORD dwGlobal = 0; //用来测试的全局变量
CRITICAL_SECTIONCS; //使用关键段前必须先定义关键段数据结构
DWORD WINAPI Thread1(LPVOID lpParam);
DWORD WINAPI Thread2(LPVOID lpParam);
intmain(int argc, char*argv[], char *env[])
{
HANDLEhThread1, hThread2;
cout<
InitializeCriticalSection(&CS); //使用关键段前必须初始化设置成员变量值,否则Enter时会出错
hThread1= CreateThread(NULL, 0, Thread1, NULL, 0, NULL);
cout<<"dwGlobal after Thread1:\t"<
hThread2= CreateThread(NULL, 0, Thread2, NULL, 0, NULL);
CloseHandle(hThread1); //关闭主线程中的子线程句柄
CloseHandle(hThread2);
//这里其实应当添加一条Wait语句来确保子线程执行退出
//如果没有这句,主线程会在子线程运行结束前结束,结果仍然不会是
Sleep(1000);
cout<" after Thread 1&2"<
cout<<"主线程结束"<
DeleteCriticalSection(&CS); //不再访问共享资源时应当释放关键段
system("pause");
return 0;
}
DWORD WINAPI Thread1(LPVOID lpParam)
{
cout<<"\n线程开始运行...\n";
EnterCriticalSection(&CS); //进入关键段
dwGlobal++;
LeaveCriticalSection(&CS); //退出关键段
cout<"dwGlobal in Thread1:\t"<
cout<"线程结束"<
return 1;
}
DWORD WINAPI Thread2(LPVOID lpParam)
{
cout<"线程开始运行..."<
EnterCriticalSection(&CS); //进入关键段
dwGlobal++;
LeaveCriticalSection(&CS); //退出关键段
cout<<"\ndwGlobal in Thread2:\t"<
cout<"线程结束"<
return 2;
}
关键段涉及的部分都用黑体进行了加注,特别注意InitializeCriticalSection()函数,如果没有此函数直接使用EnterCriticalSection()会导致错误:
程序运行成功后结果为:
这里我们令主线程等待了1秒钟之后继续执行。所以,使用关键段访问共享资源的基本框架为:
1. 定义关键段结构体:CRITICAL_SECTIONCS;
2. 初始化关键段成员变量:InitializeCriticalSection(&CS);
3. 进入关键段:EnterCriticalSection(&CS);
退出关键段:LeaveCriticalSection(&CS);
4. 释放关键段:DeleteCriticalSection(&CS);
如何理解关键段呢?使用关键段前必须定义一个关键段结构体CRITICAL_SECTION,这其实相当于一个封闭电话亭,关键段所填入的数据则是电话亭中的电话机,当我们调用EnterCriticalSection()函数时就是向系统查看此时电话亭是否可用,LeaveCriticalSection()表示离开“电话亭”。如果“电话亭”空闲,则该线程可以直接进入;否则要等待“电话亭”空闲为止。
从具体的细节实现来看,有几个是我们需要重点理解的:
1. 使用CRITICAL_SECTION有两个必要条件,一个是所有要访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结构的地址,我们可以通过我们喜欢的任何方式将这个地址传给线程;另一个是在任何线程试图访问被保护的资源之前,必须对CRITICAL_SECTION结构的内部成员进行初始化,利用函数InitializeCriticalSection()进行,由于这个函数只不过是设置一些成员变量,不可能失败,因此它的返回值是VOID,上述涉及的关键段操作函数的返回值均为void。
2. 在对共享资源访问前,必须在代码中调用
void EnterCriticalSection(PCRITICAL_SECTION pcs);
该函数会检查关键段结构中的成员变量,这些变量表示是否有线程正在访问资源,以及哪个线程正在访问资源。当资源被占用不能访问时,该函数会使用一个事件内核对象来把调用线程切换到等待状态,系统会帮我们记住这个线程想要访问的资源,一旦当前正在访问的资源的线程调用了LeaveCriticalSection(),系统会自动更新CRITICAL_SECTION中的成员变量并将等待中的线程切换成可调度状态。
3. 关键段与旋转锁。由于线程试图访问一个被占用的关键段时,会被切换到等待状态,这意味着线程必须从用户模式切换到内核模式(大约1000个CPU周期),为了节省这个开销,我们结合旋转锁,先进行一定时间的旋转锁等待,之后再转入内核模式等待。主要利用函数:
BOOL InitializeCriticalSectionAndSpinCount(
PCRITICAL_SECTION pcs;
DWORD dwSpinCount //旋转锁循环次数,单CPU机上没有意义,忽略此选项
);
Slim读写锁
关键段通过保证一个线程对共享资源的独占访问来避免多线程同时访问时可能发生的错误冲突,然而实际中我们往往要求多线程不能同时写或者修改共享资源,却允许多个线程可以同时读取共享资源。比如去电影院买票,多个售票窗口会同时显示当前售票情况,但是购买时却会出现一定延时。我们利用Slim读写锁来实现这种机制。
Slim读写锁的使用方法和关键段大同小异,也必须遵循以下几点:
1. 使用前必须事先定义分配一个SRWLOCK结构并用InitialSRWLock()对它进行初始化
VOID InitiallizeSRWLock(PSRWLOCK SRWLock);
2. 对于写入线程而言,需要将SRWLOCK对象的地址作为参数传入给调用函数以尝试获得对被保护对象的独占访问权
VOID AcquireSRWLockExclusive(PSRWLOCK SRWLock);
完成对资源的更新后,应该调用函数解除对资源的锁定
VOID ReleaseSRWLockExclusive(PSRWLOCK SRWLock);
3. 对于读取线程来说,同样有两个函数类似的使用:
VOID AcquireSRWLockShared(PSRWLOCK SRWLock);
VOID ReleaseSRWLockShared(PSRWLOCK SRWLock);
当然不同的是,不存在用来删除或者销毁SRWLOCK结构的函数,系统会自动执行清理工作。
小结
如果希望在应用中得到最佳性能,那么首先应当尝试不要共享数据,然后依次使用volatile读取,volatile写入,Interlocked API, SRWLock以及关键段,当且仅当所有这些都不恩能够满足要求的时候,才应当考虑内核对象。