3.Windows核心编程-线程及线程调度

时间:2022-03-15 19:28:12

线程

  • 与进程类似,线程也由两个组件组成:
    • 一个是线程的内核对象,操作系统用它管理线程。内核对象还是系统用来存放线程统计信息的地方;
    • 一个线程堆栈,用于维护线程执行时所需的所有函数参数和局部变量。
//线程函数

//创建线程
HANDLE CreateThread(
PSECURITY_ATTRIBUTES psa, //线程安全属性
DWORD cbStackSize, // 线程堆栈大小,默认是 1MB(Itanium芯片上,默认大小是4MB)
PTHREAD_START_ROUTINE pfnStartAddr, // 线程函数地址
PVOID pvParam, // 传入线程的参数
DWORD dwCreateFlags, // 控制线程的标志: CREATE_SUSPENDED: 创建之后挂起; 0: 创建之后立即执行
PDWORD pdwThreadID); // 返回值:线程ID



DWORD WINAPI ThreadFunc(PVOID pvParam)
{
DWORD dwResult = 0;

return dwResult;
}

线程可以通过以下4种方法来终止运行。
1. 线程函数返回(这是强烈推荐的)。
2. 线程通过调用ExitThread函数“杀死”自己
终止线程运行,并导致操作系统清理该线程的所有操作系统资源,但C/C++资源(如C++类对象)不会被销毁
3. 同一个进程或另一个进程中的线程调用TerminateThread函数
异步终止线程,线程终止时不会得到通知。ExitThread函数终止线程,线程的堆栈会被销毁,但是使用TerminateThread,除非拥有此线程的进程终止运行,否则系统不会销毁这个线程堆栈,Microsoft特意用这种方式来实现TerminateThread,其他还在运行的线程可引用被终止线程堆栈上的值。 动态链接库在线程终止运行时会收到通知,但被TerminateThread终止的线程,DLL不会收到这个通知,其结果不能执行正常的清理工作。
4. 包含线程的进程终止运行。

3.Windows核心编程-线程及线程调度
对CreateThread函数的一个调用系统创建一个线程内核对象,该对象最初的使用计数为2(只有线程终止同时从CreateThread返回的句柄关闭,否则线程内核对象不会被销毁)该线程内核对象的其他属性也被初始化:暂停计数被设为1,退出代码被设计为STILL_ACTIVE(0x103),内核对象被设为nonsignaled状态。一旦创建了内核对象,系统就分配内存,供线程的堆栈使用,此内存是从进程的地址空间内分配的,因为线程没有自己的地址空间。系统将CreateThread函数的pvParam参数压入堆栈,然后将传给CreateThread函数的pfnStartAddr值压入堆栈。

每个线程都有自己的一组CPU寄存器,称为线程的上下文(context)。上下文反映了当线程上一次执行时,线程的CPU寄存器的状态。线程的CPU寄存器全部保存在一个CONTEXT结构。CONTEXT结构本身保存在线程内核对象中。线程始终在进程的上下文中运行。 当线程内核对象被初始化的时候,CONTEXT结构的堆栈指针寄存器被设为 pfnStartAddr 在线程堆栈中的地址,而指令指针寄存器被设为 RtlUserThreadStart 函数:

VOID RtlUserThreadStart(PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam) {
__try {
ExitThread((pfnStartAddr)(pvParam));
}
__except(UnhandledExceptionFilter(GetExceptionInformation())) {
ExitProcess(GetExceptionCode());
}
// NOTE: We never get here.
}

线程完全初始化好之后,系统将检查CREATE_SUSPENDED标志是否传给CreateThread函数,如果没有传递,系统将线程的暂停计数递减至0,线程开始运行。

C/C++运行库

Microsoft Visual Studio附带的C/C++库:

库名称 描述
LibCMt.lib 库的静态链接Release版本
LibCMtD.lib 库的静态链接Debug版本
MSVCRt.lib 导入库,用于动态链接MSVCR80.dll库的Release版本
MSVCRtD.lib 导入库,用于动态链接MSVCR80D.dll库的Debug版本
MSVCMRt.lib 导入库,用于托管/原生代码混合
MSVCURt.lib 导入库,编译成百分之百纯MSIL代码

标准C运行库是1970年左右发明的,并没有线程的概念,标准C运行库的发明者也没有考虑到为多线程应用程序使用C运行库的问题。例如,标准C运行库一部分函数在出错时设置的是全局变量 errno,多线程环境出出问题的C/C++运行库变量和函数有:errno,_doserrno, strtok, _wcstok, strerror, _strerror, tmpnam, tmpfile, asctime, _wasctime, gmtime, _ecvt和_fcvt等等。
为了保证这些函数的正确执行,C/C++库必须在每个线程中为这些函数维护一个数据结构,并且还要保证此数据结构不会被其他线程修改,或是无意识的修改了其他线程的这个数据结构,使用C/C++运行库函数时,创建多线程用的函数版本是:_beginthreadex、_endthreadex ,该函数会为每个线程创建一个与线程挂钩的线程局部变量,这样线程之间就不会相互影响。

unsigned long _beginthreadex(
void *security,
unsigned stack_size,
unsigned (*start_address)(void *),
void *arglist,
unsigned initflag,
unsigned *thrdaddr)
{
_ptiddata ptd; // 线程数据块指针
uintptr_t thdl; // 线程句柄
//分配线程数据块
if ((ptd = (_ptiddata)_calloc_crt(1, sizeof(struct _tiddata))) == NULL)
goto error_return;

// 初始化线程数据块
initptd(ptd);

//线程参数与线程函数
ptd->_initaddr = (void *) pfnStartAddr;
ptd->_initarg = pvParam;

ptd->_thandle = (uintptr_t)(-1);
// 调用Windows API创建线程
thdl = (uintptr_t) CreateThread((LPSECURITY_ATTRIBUTES)psa,
cbStackSize,
_threadstartex, //线程函数是 _threadstartex, 而非pfnStartAddr
(PVOID) ptd, // 线程参数是 tiddata, 而不是传入的线程参数
dwCreateFlags,
pdwThreadID);

if (thdl == 0) {
goto error_return;
}
//
return(thdl);

error_return:

_free_crt(ptd);
return((uintptr_t)0L);
}

static unsigned long WINAPI _threadstartex (void* ptd) { //新线程先执行 RtlUserThreadStart,然后再跳转到 _threadstartex.
// Note: ptd is the address of this thread's tiddata block.
// Associate the tiddata block with this thread so
// _getptd() will be able to find it in _callthreadstartex.
TlsSetValue(__tlsindex, ptd); //TlsSetValue Windows API 将一个值与线程关联起来,就是线程本地存储(Thread Local Storage, TLS)
// Save this thread ID in the _tiddata block.
((_ptiddata) ptd)->_tid = GetCurrentThreadId();
// Initialize floating-point support (code not shown).
// call helper function.
_callthreadstartex();
// We never get here; the thread dies in _callthreadstartex.
return(0L);
}
static void _callthreadstartex(void) {
_ptiddata ptd; /* pointer to thread's _tiddata struct */
// get the pointer to thread data from TLS
ptd = _getptd();
// Wrap desired thread function in SEH frame to
// handle run-time errors and signal support.
__try {
// Call desired thread function, passing it the desired parameter.
// Pass thread's exit code value to _endthreadex.
_endthreadex(
( (unsigned (WINAPI *)(void *))(((_ptiddata)ptd)->_initaddr) )
( ((_ptiddata)ptd)->_initarg ) ) ;
}
__except(_XcptFilter(GetExceptionCode(), GetExceptionInformation())){
// The C run-time's exception handler deals with run-time errors
// and signal support; we should never get it here.
_exit(GetExceptionCode());
}
}

void __cdecl _endthreadex (unsigned retcode) {
_ptiddata ptd; // Pointer to thread's data block
// Clean up floating-point support (code not shown).
// Get the address of this thread's tiddata block.
ptd = _getptd_noexit (); // _getptd_noexit函数在内部调用操作系统的 TlsGetValue函数,获取线程的tiddata内存块地址
// Free the tiddata block.
if (ptd != NULL)
_freeptd(ptd);
// Terminate the thread.
ExitThread(retcode);
}

对于 errno 之类的 C/C++库全局变量,其在标准库C headers中定义如下:

_CRTIMP extern int * __cdecl _errno(void);
#define errno (*_errno());

int* __cdecl _errno(void) {
_ptiddata ptd = _getptd_noexit(); //获得线程本地存储变量
if (!ptd) {
return &ErrnoNoMem;
} else {
return (&ptd->_terrno);
}
}

对于创建线程,早期C/C++运行库还包括一对函数:

unsigned long _beginthread(
void (__cdecl *start_address)(void *),
unsigned stack_size,
void *arglist);

void _endthread(void);

与新版本_beginthreadex和_endthreadex函数相比,_beginthread函数的参数较少,局限性比较大,不能创建具有安全属性的线程,不能创建可以挂起的线程,也不能获取线程ID值。

_endthread也不能设置退出代码,其退出码被硬编码位0,而且_endthread函数会在调用ExitThread前,会调用CloseHandle,向其传入新线程的句柄:

DWORD dwExitCode;
HANDLE hThread = _beginthread(...);
GetExitCodeThread(hThread, &dwExitCode); //如果新建线程已经运行结束,因为_endthread已关闭了线程的句柄,所以hThread是无效的,后面再调用CloseHandle函数时就会出错。
CloseHandle(hThread);

线程调度

  • 线程的调度是指对已经创建的线程内核对象进行控制,并有序分配资源的一种行为
  • 线程的调度包括线程及进程的挂起与恢复,线程的切换与执行时间的控制,线程与进程的优先级控制等等
  • 线程的调度是多线程编程的基础,也是程序性能调优必不可少的一种技术手段,例如著名的Process Lasso就是利用线程调度完成对系统性能的提升

获取当前进程(线程)

HANDLE hProc        =    GetCurrentProcess();    //进程句柄
HANDLE hThread = GetCurrentThread(); //线程句柄
UINT unPID = GetCurrentProcessId();//进程ID
UINT unTID = GetCurrentThreadId(); //线程ID

虽然在很多情况下获得本地的进程/线程的句柄比获取本地的PID/TID要有用得多,但是需要注意的是,使用GetCurrentXX获取的句柄并不是真正的句柄,而是伪句柄,这个伪句柄仅能在本地使用而不能在其他进程/线程中使用,每个线程/进程都有一个自己的伪句柄,这个伪句柄类似一个指针,它会指向真正句柄所在的位置,因此如果试图将本线程的伪句柄传递给其他线程使用,它指向的内容将会变成其他线程的句柄,这会导致句柄传递失败(因为伪句柄是一个指向句柄的指针,它相对于句柄的偏移都一样,所以当伪句柄传递给另一个线程时,会指向另一个线程本身)。

一段有问题的代码:

DWORD WINAPI ChildThread(PVOID pParam)
{
HANDLE hThreadParent = (HANDLE)pParam;
FILETIME stcCreationTime,stcExitTime;
FILETIME stcKernelTime, stcUserTime;
GetThreadTimes(hThreadParent, &stcCreationTime,
&stcExitTime, &stcKernelTime, &stcUserTime);
}

DWORD WINAPI ShowParentTime()
{
HANDLE hThreadParent = GetCurrentThread();

CreateThread(NULL, 0, ChildThread, (PVOID)hThreadParent, 0, NULL);
}

我们可以用DuplicateHandle()函数先将伪句柄转为真正的句柄后,然后在传入线程函数中使用:

HANDLE    hThreadParent    =    GetCurrentThread();

DuplicateHandle(
GetCurrentProcess(), //拥有源句柄的进程句柄
GetCurrentThread(), //指定对象的现有句柄(伪句柄)
GetCurrentProcess(), //拥有新对象句柄的进程句柄
&hThreadParent, //用于保存新句柄
0, //安全访问级别
false, //是否可以被子进程继承
DUPLICATE_SAME_ACCESS//转换选项);

线程的挂起与恢复

  • 当我们创建一个进程或线程时,系统会先将挂起计数初始化为1,此时系统将不会给此线程对象调度CPU时间片。当进程或线程初始化完毕后,系统会检查CreateProcess()函数或CreateThread()函数是否有CREATE_SUSPENDED标志传入,是的话函数将直接返回,使得线程保持挂起状态,否则系统会将此线程挂起计数置为0,以使得此线程可以正常运行
  • 当我们在对线程做完必要的操作后,如果需要使得线程跑起来,可以通过 ResumeThread() 函数实现,此函数的作用就是将线程的挂起计数减1后,并返回此线程减1前的挂起计数
  • 一个线程可以被挂起多次,当线程被挂起多次时,想要恢复此线程同样需要多次,我们除了在创建线程时使用CREATE_SUSPENDED标志将其挂起来外,还可以调用 SuspendThread() 函数将一个指定的线程挂起
    注意:在实际开发中,我们需要确保知道要挂起的线程正在做什么,否则会导致程序运行不稳定,例如当一个正在执行堆创建的线程被挂起时,此线程会将这个堆锁定,这会导致所有试图访问这个堆的线程被卡死,直到此线程恢复后并创建完这个堆为止。

进程的挂起与恢复

  • 本质上,进程是不能被挂起或恢复的,这基于一个最基本的事实—即进程本身并没有执行代码的能力
  • 即便如此,我们是可以通过技术手段中断一个进程,一个是通过调试函数 WaitForDebugEvent 中断进程内的所有线程(通过 ContinueDebugEvnet 恢复),另一个就是遍历系统中的所有线程,并中断属于此进程的所有线程, 但是这仍然会有很多问题,例如我们遍历线程时,仅仅能遍历线程在系统中某个时间点的状态,而我们执行挂起操作时却是在这个时间点之后。在这个时间点间隙中系统的线程状态可能发生较大的变化,例如会有新线程创建,也会有旧线程退出.
VOID SuspendProcess(DWORD dwProcessID, BOOL fSuspend) {
// 获取进程内线程的快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, dwProcessID);

if (hSnapshot != INVALID_HANDLE_VALUE) {
//遍历线程
THREADENTRY32 te = { sizeof(te) };
BOOL fOk = Thread32First(hSnapshot, &te);
for(; fOk; fOk = Thread32Next(hSnapshot, &te)) {
if (te.th32OwnerProcessID == dwProcessID) {
HANDLE hThread = OpenThread(THREAD_SUSPEND_RESUME, FALSE, te.th32ThreadID);
if (hThread != NULL) {
if (fSuspend)
SuspendThread(hThread);
else
ResumeThread(hThread);
}
CloseHandle(hThread);
}
}
CloseHandle(hSnapshot);
}
}

线程的睡眠

  • 如果线程在一段时间内不需要再获取CPU时间片,可以通过Sleep()函数实现
  • 在设置线程不可调度的具体时间只是一个近似值,Sleep()函数只能将精度控制在+-20ms左右,导致这个情况的主要原因是Windows是一个分时系统,而并非实时系统
  • 可以通过给 Sleep() 函数传入0而告诉系统,线程放弃了时间片的剩余部分。虽然这样,如果没有同优先级及更高优先级存在的线程时,系统仍然有可能马上重新调度这个线程。
  • Sleep()函数的不可调度时间在某些情况下可能误差较大,在极特殊情况下,即便是传入0值,这个线程也有可能会休眠数秒钟。

线程的切换

可以通过SwitchToThread()函数切换到另一个线程

BOOL bReturn = SwitchToThread();
  • 当我们调用这个函数时,系统会判断是否有其他饥饿线程的存在,如果没有的话此函数返回false,有的话则会调用那个线程
  • 调用SwitchToThread()函数与Sleep()函数类似,当Sleep()传入0时,也会放弃剩余时间片,但是系统并不会调用比其优先级低的饥饿线程,但是当我们调用SwitchToThread()函数则不存在这情况
  • 除了给予系统内核的线程切换外,我们还可以在支持超线程的CPU中执行物理核心切换操作,Windows SKD给我们准备了一个宏YieldProcessor用于完成此操作

线程执行时间

  • 在一般情况下,当我们需要获取一段代码的执行时间时,会尝试使用以下代码完成:

    UINT64 unStartTime = GetTickCount64();
    //Do Something...
    UINT64 unEndTime = GetTickCount64();

    但是这种方法测的的时间并不是真正执行的时间,如果在执行过程中,线程因为调度事件发生了切换,那么切换过程中消耗的时间也会被计算在内。从Windows XP开始,微软提供了一个专门用于统计线程执行时间的函数GetThreadTimes.其原型如下:
BOOL    WINAPI    GetThreadTimes(
HANDLE hThread, //线程句柄
LPFILETIME lpCreationTime, //创建时间( 格林威治)
LPFILETIME lpExitTime, //退出时间(格林威治)
LPFILETIME lpKernelTime, //内核时间(绝对时间)
LPFILETIME lpUserTime); //用户时间(绝对时间)
// 格林威治时间指的是以100ns为单位,从1601年1月1日零时到现在的一个时间计数

我们可以使用以下代码精准的测量代码执行时间

UINT64 FileTimeToUint64(PFILETIME pFileTime)
{
return Int64ShllMod32(pFileTime->dwHighDateTime, 32) | pFileTime->dwLowDateTime;
}

FILETIME stcKernelStartTime, stcKernelEndTime;
FILETIME stcUserStartTime, stcUserEndTime;
FILETIME stcTemp;

GetThreadTimes(GetCurrentThread(), &stcTemp, &stcTemp,
&stcKernelStartTime, &stcUserStartTime);
// Do Something...
GetThreadTimes( GetCurrentThread(), &stcTemp, &stcTemp,
&stcKernelEndTime, &stcUserEndFile);
UINT64 unKernelTime = FileTimeToUint64(&stcKernelEndTime)
- FileTimeToUint64(&stcKernelStartTime);
UINT64 unUserTime = FileTimeToUint64(&stcUserEndTime)
- FileTimeToUint64(&stcUserStartTime);
UINT64 unAllTime = UNkERNELtIME + unUserTime;

线程执行时间改进——–时间戳

  • 从Windows Vista开始, 微软为我们提供了一个精度更高的计时方法–时间戳计时器(TSC)
  • TSC的计时方式依赖于CPU的时钟周期,它主要记录计算机从开机开始到函数执行时的时钟周期数,1GHz的CPU的时间精确度可达十亿分之一秒,主频越高的CPU其TSC的精度也就越高
  • 我们可以通过 QueryPerformanceCounter() 函数获取当前时钟通过的周期数,然后再使用 QueryPerformanceFrequency() 获取当前CPU的主频,通过简单的除法计算可得到精确的时间差值 ,需要注意的是,由于现在的CPU都引入了变频技术,因此在执行两次QueryPerformanceCounter()函数期间,CPU的实际频率有可能发生变化,这可能会导致计算出现误差。
//1.获取当前CPU的频率
LARGE_INTECER stcFrenquency;
QueryPerformanceFrequency(&stcFrequency);
//2.获取起始时钟周期
LARGE_INTEGER stcStartTime;
QueryPerformanceCounter(&stcStartTime);
// Do Something...
//3. 获取结束时钟周期
LARGE_INTEGER stcEndTime;
QueryPerformanceCounter(&stcEndTime);
//4.计算时钟周期差值
LARGE_INTEGER stcTime;
stcTime.QuadPart = stcEndTime.QuadPart - stcStartTime.QuadPart;
//5. 换算经过的微妙数(1s=1000000um)
stcTime.QuadPart *= 1000000;
stcTime.QuadPart /= stcFrequency.QuadPart;

线程与CONTEXT

系统为每个线程都维护了一个上下文(CONTEXT),这个上下文中记录了当前线程中各种CPU状态的值,其中包括通用寄存器、标志寄存器等我们可以使用如下方法获取/设置线程上下文

//获取线程上下文
CONTEXT stcCxt = (CONTEXT_FULL);
stcCxt.ContextFlags = CONTEXT_CONTROL | CONTEXT_INTEGER; //指定要获取线程上下文中的那些信息
if (!GetThreadContext(hTargetThread, &stcCrt))
return false;

//通过修改stcCxt来修改寄存器
//设置进程上下文
if (!SetThreadContext(hTargetThread, &stcCxt))
return false;

线程优先级

  • 每个线程被赋予0~31的优先级数,优先级数越高,线程能获得的CPU时间就越长
  • 在系统对线程进行循环调度的时候,会首先从优先级为31的线程开始调度,一直到优先级为31的所有线程都执行完毕后,系统才会开始调度优先级为30的线程,依次类推
  • 系统中优先级最低的线程是一个名为” 页面清零”的线程,它的责任实在系统完全空闲时将已经释放的内存空间清零,此线程的优先级为0
  • Windows的NT内核并不是为处理今天的事物而生的,因此NT内核本质上就缺少对于游戏、多媒体及Internet的支持。这一点大卫.卡特勒在设计NT内核之初就非常清楚——NT内核必须能处理现在还没诞生的一些用户需求
  • 微软通过以下三种手段来确保调度优先级始终都是可用的:
    • A.微软并没有在文档中完整描述线程调度的行为;
    • B.微软限制的应用程序充分使用线程调度的特性;
    • C.微软明确告诉用户,调度算法会发生变化
  • 鉴于微软采取的以上策略,身为一名合格的程序员,我们应该在使用相应的特性做到有防御性的编程,以避免可能由此带来的兼容性问题。

Windows支持6个优先级类,他们分别是idle、below normal、normal、above normal、high、real-time
进程优先级

优先级类型 级别 解释
real-time 实时 此进程的所有计算请求会实时被响应。操作系统内核的时间也会被尽可能的分配给此线程使用
high 此进程内的事件必须被立即响应
above normal 高于标准 比标准高一些
normal 标准 无特殊调度需求(90%进程属于此情况)
below normal 低于标准 比标准低一点
idle 此进程的事件在系统空闲时会被响应

线程优先级

优先级类型 级别 解释
Time-critical 实时 此线程的所有计算请求会实时响应,操作系统内核的时间也会被尽可能分配给此线程使用
highest
above normal
normal
below normal
idle

优先级映射表:

线程优先级 Idle below normal normal above normal high real-time
Time-critical 15 15 15 15 15 31
highest 6 8 10 12 15 26
above normal 5 7 9 11 14 25
normal 4 6 8 10 13 24
below normal 3 5 7 9 12 23
lowest 2 4 6 8 11 22
Idle 1 1 1 1 1 16
  • 我们可以通过在创建进程时、或使用命令、调用API等方式来改变一个进程的优先级
  • 在调用CreateProcess()函数调用进程时,可以在fdwCreat参数中传入以下几个宏来指定进程的优先级:
优先级类型 级别
real-time 实时 REALTIME_PRIORITY_CLASS
high HIGH_PRIORITY_CLASS
above_normal 高于标准 ABOVE_NORMAL_PRIORITY_CLASS
normal 标准 NORMAL_PRIORITY_CLASS
below normal 低于标准 BELOW_NORMAL_PRIORITY_CLASS
idle IDLE_PRIORITY_CLASS
  • 如果程序已经编译完毕,我们可以通过START命令在进程启动时为其设定优先级例如:
C:\>START /LOW CALC.EXE
START 命令共有 "/BELOWNORMAL""/NORMAL""/ABOVENORMAL"
"/HIGH""/REALTIME"这几个优先级开关
// 如果程序已经运行起来了,那么我们最后还可以使用API修改其优先级,其API原型如下所示:
BOOL WINAPI SetPriorityClass(
_IN_ HANDLE hProcess, //进程句柄
_IN_ DWORD dwPriorityClass //进程优先级宏
);

除了进程以外,我们还可以为线程设定优先级,其API原型如下所示:

BOOL    WINAPI    SetThreadPriority(
_In_ HANDLE hThread, //线程句柄
_In_ DWORD dwPriority //线程优先级宏
);
优先级类型 级别
Time-critical 实时 THREAD_PRIORITY_TIME_CRITICAL
highest THREAD_PRIORITY_HIGHEST
above normal 高于标准 THREAD_PRIORITY_ABOVE_NORMAL
normal 标准 THREAD_PRIORITY_NORMAL
below normal 低于标准 THREAD_PRIORITY_BELOW_NORMAL
lowest THREAD_PRIORITY_LOWEST
idle 空闲 THREAD_PRIORITY_IDLE