线程
- 与进程类似,线程也由两个组件组成:
- 一个是线程的内核对象,操作系统用它管理线程。内核对象还是系统用来存放线程统计信息的地方;
- 一个线程堆栈,用于维护线程执行时所需的所有函数参数和局部变量。
//线程函数
//创建线程
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. 包含线程的进程终止运行。
对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 |