内核态线程同步(2)利用内核对象同步
最后更新于:2022-04-01 21:44:56
## 事件对象
event对象常用来多个线程间进行工作的同步,如线程A先执行一些初始化工作,触发evnet,通知线程B初始化工作已经完成,可以进行接下来的工作。
### 创建event对象
~~~
HANDLE WINAPI CreateEvent(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes, // 设置安全属性
_In_ BOOL bManualReset, // 是否人工重置状态(人工重置则不会自动改变事件状态, 自动重置则会自动将事件恢复为未触发)
_In_ BOOL bInitialState, // 事件初始状态(触发/未触发)
_In_opt_ LPCTSTR lpName // 事件名称
);
~~~
值得注意的是
~~~
BOOL bManualReset,
~~~
若 为人工重置,那么当事件触发时,所有等待线程均能够获得事件对象,且不会自动重置事件状态。若为自动重置,则仅有一个线程wait获得该事件,同时置事件为未触发状态。
另外一点,其他线程若想获得该事件对象句柄,可以也调用CreateEvent函数,并传入事件名称。若该事件已经存在,则直接返回句柄,若未存在则会创建该事件并返回句柄。
注意,若事件已经存在,再调用CreateEvent只会获取其句柄,但该函数的其他参数会忽略。
对于自动重置事件,若multiplewait函数为全部等待状态,则对于仅自动重置事件触发时,multiplewait函数会忽略该event,同时不会重置事件,只有当所有的等待对象都触发时,multiplewait才会获取自动重置事件并自动重置为未触发状态。
**若想在创建事件时指定的可以访问事件的权限,可以用**
~~~
HANDLE WINAPI CreateEventEx(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
_In_opt_ LPCTSTR lpName,
_In_ DWORD dwFlags, // 可以是两种flags的任意组合CREATE_EVENT_INITIAL_SET、CREATE_EVENT_MANUAL_RESET
_In_ DWORD dwDesiredAccess // 设置事件权限
);
~~~
### 获取事件句柄函数
~~~
HANDLE WINAPI OpenEvent(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCTSTR lpName
);
~~~
### 改变事件触发状态
### 设置事件为触发状态
~~~
BOOL WINAPI SetEvent(
_In_ HANDLE hEvent
);
~~~
### 设置事件未触发状态
~~~
BOOL WINAPI ResetEvent(
_In_ HANDLE hEvent
);
~~~
## 可等待计时器内核对象
可等待计时器对象会在一定时后或每间隔一段时间触发,可用在某个时间的操作。
### 创建或获取可等待计时器
~~~
HANDLE WINAPI CreateWaitableTimer(
_In_opt_ LPSECURITY_ATTRIBUTES lpTimerAttributes,
_In_ BOOL bManualReset, // 是否人工重置
_In_opt_ LPCTSTR lpTimerName
);
~~~
### 获取可等待计时器句柄
~~~
HANDLE WINAPI OpenWaitableTimer(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCTSTR lpTimerName
);
~~~
不像事件对象,可等待计时器创建后总是未触发的。
需要调用函数 SetWaitableTimer
~~~
BOOL WINAPI SetWaitableTimer(
_In_ HANDLE hTimer, // 计时等待对象
_In_ const LARGE_INTEGER *pDueTime, // 何时触发对象(用负值表示相对于调用SetWaitableTimer后的时间 100纳秒为单位)
_In_ LONG lPeriod, // 触发后间隔的触发频率(0 表示仅触发一次)
_In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine, // APC调用函数
_In_opt_ LPVOID lpArgToCompletionRoutine, // APC调用参数
_In_ BOOL fResume // 在可挂起的计算机系统中,是否恢复计算机来使等待线程执行CPU时间。
// 若传入FALSE,则会触发对象,但等待线程不会执行,除非直到计算机系统重新执行
);
~~~
### 取消计时等待对象的时间设置
~~~
该函数会取消一切的SetWaitableTimer的计时设置。
但是该函数不会更改timer对象的触发状态,若已经触发,则该对象仍会处于触发状态。
BOOL WINAPI CancelWaitableTimer(
_In_ HANDLE hTimer
);
~~~
### 计时等待对象 VS 用户计时器(SetTimer)
1、内核对象,用户对象
2、用户计时器会产生WM_TIMER消息,该消息会被送到调用SetTimer线程或创建窗口线程,同一时间仅有一个线程得到通知。
计时等待对象可多个线程同时被通知。
## 信号量内核对象
信号能够灵活的限制可被激活的线程数目,并确保线程数目不会超过设定的最大值。
具体使用流程为:
1、创建信号量对象,并指定最大资源数目与当前可用数目(常为0)。
2、创建多个资源请求线程,因为当前可用数目为0,线程等待。
3、当符合某种条件时,调用[**ReleaseSemaphore**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms685071%28v=vs.85%29.aspx)函数释放资源,这时候可用资源数目递增。
4、可以资源数目不再为0,等待线程获得资源,同时可以资源数目递减。
windows系统会确保当前可用资源数目大于等于0,同时不会超过最大值。
### 创建(或获取)信号量对象
~~~
HANDLE WINAPI CreateSemaphore(
_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
_In_ LONG lInitialCount, // 初始当前可用资源数目
_In_ LONG lMaximumCount, // 最大可用资源数目
_In_opt_ LPCTSTR lpName
);
~~~
[**CreateSemaphoreEx**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms682446%28v=vs.85%29.aspx)
### 获取信号量对象句柄
~~~
HANDLE WINAPI OpenSemaphore(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCTSTR lpName
);
~~~
### 递增信号量可用资源
~~~
BOOL WINAPI ReleaseSemaphore(
_In_ HANDLE hSemaphore,
_In_ LONG lReleaseCount,
_Out_opt_ LPLONG lpPreviousCount
);
~~~
## 互斥量内核对象
互斥量内核对象用于确保资源被唯一的线程访问,即互斥访问。
### 创建(获取)互斥量对象
~~~
HANDLE WINAPI CreateMutex(
_In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
_In_ BOOL bInitialOwner,
_In_opt_ LPCTSTR lpName
);
~~~
互斥量内核对象 有 引用计数器,线程ID已经递归计数器组成。
线程ID用来记录当前获取互斥量对象的线程ID,0表示没人获取,互斥量处于触发状态。一旦,有个线程wait到互斥量,其内核对象线程ID为该线程ID,同时内核对象变为未触发状态,其他线程只能继续等待。但对于已经获得互斥量线程,其仍可以等待成功,这时候内核对象会递增其递归计数器。
调用[**ReleaseMutex**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms685066%28v=vs.85%29.aspx) 释放互斥量。对于多次递归进入的互斥量,要相应的多次调用release函数。
注意,当线程在获取了互斥量对象,而在调用[**ReleaseMutex**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms685066%28v=vs.85%29.aspx)之前结束的话,会产生遗弃问题。
## 关于内核态同步对象的一些事项
1、一般的,通过内核对象来进行同步,其获取的内核对象句柄都是具有所有权限的(访问,改变触发状态等),但是我们可以在Create内核对象时,通过扩展函数ex函数设置可访问权限,那么当访问该内核对象句柄时,仅能够进行指定的权限访问。
2、内核对象命名与多用户系统
我们有多种方法可以在多个进程空间访问同一个内核对象(继承,dumplicatehandle,命名的内核对象)。
在使用命名内核对象访问时,需要注意在多用户系统中内核对象名称的前缀。
MSDN原话是:
The name can have a "Global\" or "Local\" prefix to explicitly create the object in the global or session namespace.
即加上“Global\”前缀,可以在多个用户间通过名称访问该内核对象,而"Local\"前缀仅能够当前用户通过名称访问内核对象。
';
内核态线程同步(1)概述
最后更新于:2022-04-01 21:44:54
除了用户态的线程同步,我们可以使用内核对象进行线程的同步。与用户态同步相比,内核态同步耗时要多(用户态内核态切换),但内核同步能够跨进程同步,并使用灵活,以及配套的安全机制。
触发未触发状态
对于内核对象来说,均有触发及未触发状态,其状态转换规则因不同内核对象而异。利用内核对象同步,我们就利用了内核对象的这种状态转换。
等待函数
要进行内核态同步,需要使用等待函数来使为获得等待对象触发状态的线程处于等待状态。常用的等待函数有两个:
等待单个内核对象:
~~~
DWORD WINAPI WaitForSingleObject(
_In_ HANDLE hHandle, // 等待的内核对象句柄
_In_ DWORD dwMilliseconds // 以毫秒计数的等待超时,INFINITE为无限等待
);
~~~
该函数的返回值有以下4种
~~~
WAIT_ABANDONED
0x00000080L
The specified object is a mutex object that was not released by the thread that owned the mutex object before the owning thread terminated. Ownership of the mutex object is granted to the calling thread and the mutex state is set to nonsignaled.
If the mutex was protecting persistent state information, you should check it for consistency.
WAIT_OBJECT_0
0x00000000L
The state of the specified object is signaled.
WAIT_TIMEOUT
0x00000102L
The time-out interval elapsed, and the object's state is nonsignaled.
WAIT_FAILED
(DWORD)0xFFFFFFFF
The function has failed. To get extended error information, call GetLastError.
~~~
等待多个内核对象:
~~~
DWORD WINAPI WaitForMultipleObjects(
_In_ DWORD nCount, // 等待内核对象数目
_In_ const HANDLE *lpHandles, // 等待内核对象数组
_In_ BOOL bWaitAll, // 是否等待所有的内核对象才唤醒线程
_In_ DWORD dwMilliseconds // 等待超时时间
);
~~~
其可能返回的结果
~~~
WAIT_OBJECT_0 to (WAIT_OBJECT_0 + nCount– 1)
If bWaitAll is TRUE, the return value indicates that the state of all specified objects is signaled.
If bWaitAll is FALSE, the return value minus WAIT_OBJECT_0 indicates the lpHandles array index of the object that satisfied the wait. If more than one object became signaled during the call, this is the array index of the signaled object with the smallest index value of all the signaled objects.
WAIT_ABANDONED_0 to (WAIT_ABANDONED_0 + nCount– 1)
If bWaitAll is TRUE, the return value indicates that the state of all specified objects is signaled and at least one of the objects is an abandoned mutex object.
If bWaitAll is FALSE, the return value minus WAIT_ABANDONED_0 indicates the lpHandles array index of an abandoned mutex object that satisfied the wait. Ownership of the mutex object is granted to the calling thread, and the mutex is set to nonsignaled.
If a mutex was protecting persistent state information, you should check it for consistency.
WAIT_TIMEOUT
0x00000102L
The time-out interval elapsed and the conditions specified by the bWaitAll parameter are not satisfied.
WAIT_FAILED
(DWORD)0xFFFFFFFF
The function has failed. To get extended error information, call GetLastError.
~~~
关于等待函数有两点应该知道的地方
1、对于一种特殊的内核对象,即mutex对象,其存在互斥量遗弃现象。即,若获得mutex的线程终止,却没有显示的释放mutex,那么系统自动会默认原线程遗弃了mutex,并自动在等待该mutex对象的剩余线程中,挑选一个进行唤醒。而这时,wait函数的返回值不是WAIT_OBJECT(_0),而是WAIT_ABANDONED(_0)。
2、我们可以运用wait函数的扩展
[**WaitForSingleObjectEx**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687036%28v=vs.85%29.aspx)
[**WaitForMultipleObjectsEx**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687028%28v=vs.85%29.aspx)
将等待线程设置为可提醒状态,这样除了线程等待的内核对象外,我们可以在用户模式下线程的异步调用队列(APC)中,加入信号来唤醒线程(此时不要求等待内核对象是触发的,wait函数会返回**WAIT_IO_COMPLETION**)。
类似的有[**SleepEx**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms686307%28v=vs.85%29.aspx)函数。
等待成功的副作用
如果wait函数等待成功了内核对象后,会改变内核对象的状态,这就称作为等待成功的副作用。并不是所有的内核对象都有副作用,如进程和线程内核对象没有任何副作用。而对于event内核对象,如果我们将event事件的重置状态设置为自动重置,那么在一个线程获得内核对象之前,系统会将该event自动重置为未触发状态(等待成功的副作用)。这样,在多个等待该事件的线程中,仅有一个会被唤醒。
';
(5)用户态线程同步
最后更新于:2022-04-01 21:44:51
在多线程环境下,线程同步是不可避免的话题。Windows环境下的线程同步分为:用户态同步 与 内核态同步。
下面我们先了解用户态同步的一些方法。
- 使用Interlocked系列函数。简单易用的函数。
- 关键段。用来对关键资源实现独享访问。
- Slim读写锁。灵活的进行写独享读共享操作。
- 条件变量。当线程要进行较为复杂的条件进行同步时,可以实现。
Interlocked系列函数。
Windows提供了Interlocked系列函数,用来原子的对数据进行增减或交换。如自旋转锁,就可以通过InterlockedExchange函数实现。
~~~
BOOL g_fResourceInUse = FALSE;
void Func1()
{
// InterlockedExchange会一直原子设置g_fResourceInUse为TRUE,同时返回g_fResourceInUse
// 上一次的值。当g_fResourceInUse为初始值FALSE或被另一线程设置为FALSE后,该循环才会结束。
// 以此自循环(自旋锁)方式来实现对资源的独占访问。
while (InterlockedExchange(&g_fResourceInUse,TRUE) == TRUE)
Sleep(0);
// Access the resource, do something
...
// Do not need the resource anymore, release it
InterlockedExchange(&g_fResourceInUse,FALSE)
}
~~~
注意,这种循环方式会占用CPU大量时间,不建议在单CPU机器上运行。(可以用关键段代替或用C++11标准中的atom系列函数代替)
关键段
上面使用自旋锁的方式进行同步显然是低效的。因为等待线程依然处于可调度状态,仍然会占用CPU时间。Windows提供了一系列函数,让线程同步。这一系列函数保证了在线程获得想要的资源之前,不被CPU调度,直到其要求的资源可被线程访问为止。关键段就是其一,其实关键段的实现是通过事件内核对象的。
运用关键段五个步骤:
1、声明一个可以被多个线程访问到其地址的关键段变量。
2、在使用关键段前,调用InitializeCriticalSection函数初始化关键段。
2、在进入资源前调用EnterCriticalSection,请求进入关键段(若进入不了,则线程等待)
3、在离开资源时,调用LeaveCriticalSection,离开关键段。
若确定了关键段已经不被任何线程再使用,则要销毁关键段对象。
4、在不再使用关键段时,调用DeleteCriticalSection销毁关键段。
~~~
CRITICAL_SECTION g_cs;
int g_sum = 0;
//初始化关键段,注意不要多次初始化,否则后果是未定义的
';
InitializeCriticalSection(&g_cs); ~~~ ~~~ void ThreadFunc1() { EnterCriticalSection(&g_cs); g_sum++; LeaveCriticalSection(&g_cs); } void ThreadFunc2() { EnterCriticalSection(&g_cs); g_sum++; LeaveCriticalSection(&g_cs); } ... ... // 不再使用critical section,显示销毁 DeleteCriticalSection(&g_cs); ~~~ 关键段最容易忘记 ~~~ LeaveCriticalSection ~~~ ,这时候可以用RAII技巧来进行简单的封装。 关于关键段的细节 1、若一个线程已经成功进入关键段,则可以多次调用EnterCriticalSection,相应的,要调用多次LeaveCriticalSection来离开临界区。 2、对于跨进程的线程同步,可以使用mutex对象。 可以使用TryEnterCriticalSection进入关键段,他不会使线程进入等待,而是返回布尔值表示是否获得了关键段。对于返回TRUE,需要调用LeaveCriticalSection。 关键段与旋转锁 当线程由于得不到关键段而进入等待状态时,会进行用户态和内核态切换,这会占用大量的CPU时间。在多处理器的环境下,可能的一种情况是,用户/内核态的切换还未结束,占用关键段的线程可能已经释放了关键段。 在多处理器的情况下,可以使用 [**InitializeCriticalSectionAndSpinCount**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms683476%28v=vs.85%29.aspx) 函数来初始化关键段。其函数原型如下 ~~~ BOOL WINAPI InitializeCriticalSectionAndSpinCount( _Out_ LPCRITICAL_SECTION lpCriticalSection, _In_ DWORD dwSpinCount ); ~~~ 其中参数dwSpinCout用来设置旋转锁循环次数。[**SetCriticalSectionSpinCount**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms686197%28v=vs.85%29.aspx) 可以重设自旋转锁次数。 该函数会在进入内核态前,旋转设置的循环次数来获取关键段。若在旋转锁阶段获取关键段,则不会进入内核态。 注意,在单CPU模式下,dwSpinCout是被忽略的,总是为0。因为在单CPU下, ~~~ InitializeCriticalSectionAndSpinCount ~~~ 是没有意义的:CPU在旋转锁阶段被线程占用,其他线程根本没有时机来释放关键段。但我们仍可以这样初始化关键段,以应对未来可能的多CPU环境。 Slim读写锁 一般的,对于线程的同步,读是可以共享的,而写则是互斥的。因此Windows提供了读写锁机制。 与关键段类似,在使用读写锁之前,要调用[**InitializeSRWLock**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms683483%28v=vs.85%29.aspx)函数初始化读写锁。 利用读写锁要分清读者和写者。 读写锁使用步骤 1、声明SRWLOCK对象。 2、用InitializeSRWLock函数初始化SRWLOCK对象。 3.1、对于读者,调用 [**AcquireSRWLockShared**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms681934%28v=vs.85%29.aspx) [**ReleaseSRWLockShared**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms685080%28v=vs.85%29.aspx) 以共享的方式获取,释放的读写锁。若该锁没被占用或被其他线程读,则立即获得锁,否则等待。 3.2、对于写者,调用 [**AcquireSRWLockExclusive**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms681930%28v=vs.85%29.aspx) [**ReleaseSRWLockExclusive**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms685076%28v=vs.85%29.aspx) 以独占的方式获取,释放的读写锁。若该锁未被占用,则立即获得锁,否则等待。 Slim与关键段的对比 Slim锁与关键段主要有以下两点区别 1、Slim锁不能够递归获取,即当一个线程Acquire并获得Slim锁之后,不能够再次Acquire同一把锁。 2、不存在TryEnter类似函数获取Slim锁。 3、Slim锁不用显示销毁,系统会自动释放。 4、总体上说,Slim锁的效率优于关键段。 多种同步方法的效率对比 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/82c440c7466c6449356a9b8660ba37f6_617x136.jpg) 条件变量同步 有时候需要线程原子方式释放获得的锁同时阻塞自身,直到某一条件成立为止。这时候可以通过条件变量进行同步。 等待条件变量函数。当条件被满足,线程被唤醒后,会自动得到锁。 [**SleepConditionVariableCS**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms686301%28v=vs.85%29.aspx) [**SleepConditionVariableSRW**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms686304%28v=vs.85%29.aspx) 唤醒等待条件的线程函数 [**WakeAllConditionVariable**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687076%28v=vs.85%29.aspx) [**WakeConditionVariable**](https://msdn.microsoft.com/en-us/library/windows/desktop/ms687080%28v=vs.85%29.aspx)
(4)当前线程句柄与伪句柄
最后更新于:2022-04-01 21:44:49
在windows编程中,有时候我们会遇到需要传入作用对象句柄的函数,如GetThreadTimes(HANDLE hThread, ...)函数,允许我们获得句柄指定线程的运行时间。如果我们需要本线程的运行时间,那么只需要简单调用函数
GetCurrentThread()函数即可,其会返回当前线程的“伪”句柄。类似的,我们也有函数可以获得当前进程的句柄。
~~~
HANDLE GetCurrentThread(); //获得当前线程伪句柄
HANDLE GetCurrentProcess(); //获取当前进程伪句柄
~~~
伪句柄与真实句柄
注意,之前我们说的通过GetCurrentThread与GetCurrentProcess函数获取的句柄,都是伪句柄。众所周知,每个进程都会有一个句柄表,来保存当前进程获取的内核对象句柄及其他信息。当进程获取一个内核对象时,操作系统会自动将该对象信息插入当前进程的句柄表,并返回类似于索引的句柄。因此每个内核对象的句柄在不同的进程中基本是不一样的。
但当我们调用GetCurrentThread()与GetCurrentProcess()时,其总是会返回值0xfffffffe(-2),0xffffffff(-1)。这就是所谓的伪句柄,它们并不反映真实的句柄表信息,仅用来作用于当前线程\进程本身。关于伪句柄,有以下几点要注意:
1、伪句柄仅限作用于当前线程\进程。超出了当前线程\进程便没有任何意义。
~~~
DWORD WINAPI childThread(PVOID pvParam)
{
HANDLE hThread = (HANDLE)pvParam;
...
GetThreadTimes(hThread, ...);
}
int _tmain(int argc, _TCHAR* argv[])
{
...
HANDLE hThread = GetCurrentThread();
CreateThread(nullptr, 0, childThread, (PVOID)hThread, 0, nullptr);
...
return 0;
}
~~~
上面代码本意是通过子线程来获取主线程的运行时间,但由于传递的是伪句柄0xfffffffe(-2),因此在子线程中,其实会获取子线程的运行时间而非主线程。
2、伪句柄不用调用CloseHandle函数关闭
因为伪句柄不是真正的句柄,因此不需要CloseHandle来关闭。(即使调用了也没有任何影响,CloseHandle会返回errorcode ERROR_INVALID_HANDLE)。
伪句柄转换为真实句柄
像上面这种情况,有时候我们需要获取线程或进程的真实句柄,那么我们可以利用函数[**DuplicateHandle**](https://msdn.microsoft.com/en-us/library/ms724251%28v=vs.85%29.aspx)来获取。
~~~
BOOL WINAPI DuplicateHandle(
_In_ HANDLE hSourceProcessHandle,
_In_ HANDLE hSourceHandle,
_In_ HANDLE hTargetProcessHandle,
_Out_ LPHANDLE lpTargetHandle,
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ DWORD dwOptions
);
~~~
该函数常用来从进程A中来复制一份内核对象句柄并使B进程可用。但我们可以灵活运用一下。我们可以将上面代码修改为
~~~
DWORD WINAPI childThread(PVOID pvParam)
{
HANDLE hThreadParent = (HANDLE)pvParam;
...
GetThreadTimes(hThreadParent, ...);
CloseHandle(hThreadParent); // 由于DuplicateHandle会增加句柄计数,因此不要忘记CloseHandle
}
int _tmain(int argc, _TCHAR* argv[])
{
...
HANDLE hThreadParent = nullptr;
// 通过DuplicateHandle获得线程的真实句柄。
DuplicateHandle(
GetCurrentProcess(),
GetCurrentThread(),
GetCurrentProcess(),
hThreadParent,
0,
FALSE,
DUPLICATE_SAME_ACCESS);
CreateThread(nullptr, 0, childThread, (PVOID)hThreadParent, 0, nullptr);
...;
return 0;
}
~~~
需要注意的是,通过DuplicateHandle获取的真实句柄,需要CloseHandle进行关闭。同理,我们也可以获取进程的真实句柄。
';
(3) 正确编写C/C++运行库下的线程
最后更新于:2022-04-01 21:44:47
## 如何在C/C++运行库下正确的启用新线程
由于C/C++的历史早于线程的出现,因此C/C++的函数并不都是线程安全的。如全局变量errno等。
这就需要一种解决方案。一种方法是利用属于每个线程的数据块,该数据块不会被线程共享,而只能够用于线程自己,这样类似errno的情况便迎刃而解。
此外,C/C++运行库针对特定函数做了改写,使其能够进行线程同步。如malloc函数,由于不能够多线程同时执行内存堆分配操作,因此多线程版本的运行库进行了线程同步处理。
那么,如何让windows系统知道当我们创造新线程时,为我们分配属于线程的存储区呢?利用CreateThread函数并不行(C/C++运行库若获取不到存储器,会自动请求分配对应存储区,因此CreateThread函数实际也可以支持线程安全,但还有其他问题下面再说),因为他只是一个系统API,他不会知道你所写的是C\C++代码。
## _beginthreadex函数
_beginthreadex是C/C++运行库创建线程函数,因此可以完美支持C/C++代码的线程安全。其声明如下:
~~~
uintptr_t _beginthreadex(
void *security,
unsigned stack_size,
unsigned ( __stdcall *start_address )( void * ),
void *arglist,
unsigned initflag,
unsigned *thrdaddr
);
~~~
其参数意义与CreateThread函数完全相同。
重点是要理解该函数为C/C++线程安全做了那些事情。我们可以看到其函数定义。(VS2012路径为C:\Program Files (x86)\Microsoft Visual Studio 11.0\VC\crt\src\threadex.c)
~~~
_CRTIMP uintptr_t __cdecl _beginthreadex (
void *security,
unsigned stacksize,
unsigned (__stdcall * initialcode) (void *),
void * argument,
unsigned createflag,
unsigned *thrdaddr
)
{
_ptiddata ptd; /* pointer to per-thread data */
uintptr_t thdl; /* thread handle */
unsigned long err = 0L; /* Return from GetLastError() */
unsigned dummyid; /* dummy returned thread ID */
/* validation section */
_VALIDATE_RETURN(initialcode != NULL, EINVAL, 0);
/*
* Allocate and initialize a per-thread data structure for the to-
* be-created thread.
*/
if ( (ptd = (_ptiddata)_calloc_crt(1, sizeof(struct _tiddata))) == NULL )
goto error_return;
/*
* Initialize the per-thread data
*/
_initptd(ptd, _getptd()->ptlocinfo);
ptd->_initaddr = (void *) initialcode;
ptd->_initarg = argument;
ptd->_thandle = (uintptr_t)(-1);
#if defined (_M_CEE) || defined (MRTDLL)
if(!_getdomain(&(ptd->__initDomain)))
{
goto error_return;
}
#endif /* defined (_M_CEE) || defined (MRTDLL) */
/*
* Make sure non-NULL thrdaddr is passed to CreateThread
*/
if ( thrdaddr == NULL )
thrdaddr = &dummyid;
/*
* Create the new thread using the parameters supplied by the caller.
*/
if ( (thdl = (uintptr_t)
CreateThread( (LPSECURITY_ATTRIBUTES)security,
stacksize,
_threadstartex,
(LPVOID)ptd,
createflag,
(LPDWORD)thrdaddr))
== (uintptr_t)0 )
{
err = GetLastError();
goto error_return;
}
/*
* Good return
*/
return(thdl);
/*
* Error return
*/
error_return:
/*
* Either ptd is NULL, or it points to the no-longer-necessary block
* calloc-ed for the _tiddata struct which should now be freed up.
*/
_free_crt(ptd);
/*
* Map the error, if necessary.
*
* Note: this routine returns 0 for failure, just like the Win32
* API CreateThread, but _beginthread() returns -1 for failure.
*/
if ( err != 0L )
_dosmaperr(err);
return( (uintptr_t)0 );
}
~~~
**可以看到_beginthreadex函数做了以下事项:**
1、在函数开始处,在C/C++运行库堆上分配并初始化每个线程的私有内存ptd。
2、我们初始传入的线程函数与线程参数被存储到ptd中。
3、_beginthreade最终调用CreateThread函数运行线程(毕竟windows系统只认识其API)。
4、注意在CreateThread函数中,线程函数替换为另一函数_threadstartex,同时线程参数传入了ptd。
## _threadstartex函数
由_beginthreadex函数定义可以知道,我们的线程函数,其实首先执行的都是_threadstartex。那么我们看看该函数都做了什么。
~~~
static unsigned long WINAPI _threadstartex (
void * ptd
)
{
_ptiddata _ptd; /* pointer to per-thread data */
/*
* Check if ptd is initialised during THREAD_ATTACH call to dll mains
*/
if ( ( _ptd = (_ptiddata)__crtFlsGetValue(__get_flsindex())) == NULL)
{
/*
* Stash the pointer to the per-thread data stucture in TLS
*/
if ( !__crtFlsSetValue(__get_flsindex(), ptd) )
ExitThread(GetLastError());
/*
* Set the thread ID field -- parent thread cannot set it after
* CreateThread() returns since the child thread might have run
* to completion and already freed its per-thread data block!
*/
((_ptiddata) ptd)->_tid = GetCurrentThreadId();
_ptd = ptd;
}
else
{
_ptd->_initaddr = ((_ptiddata) ptd)->_initaddr;
_ptd->_initarg = ((_ptiddata) ptd)->_initarg;
_ptd->_thandle = ((_ptiddata) ptd)->_thandle;
#if defined (_M_CEE) || defined (MRTDLL)
_ptd->__initDomain=((_ptiddata) ptd)->__initDomain;
#endif /* defined (_M_CEE) || defined (MRTDLL) */
_freefls(ptd);
ptd = _ptd;
}
#if defined (_M_CEE) || defined (MRTDLL)
DWORD domain=0;
if(!_getdomain(&domain))
{
ExitThread(0);
}
if(domain!=_ptd->__initDomain)
{
/* need to transition to caller's domain and startup there*/
::msclr::call_in_appdomain(_ptd->__initDomain, _callthreadstartex);
return 0L;
}
#endif /* defined (_M_CEE) || defined (MRTDLL) */
_ptd->_initapartment = __crtIsPackagedApp();
if (_ptd->_initapartment)
{
_ptd->_initapartment = _initMTAoncurrentthread();
}
_callthreadstartex();
/*
* Never executed!
*/
return(0L);
}
~~~
**上面代码很多,大体看下就好。要了解的是:**
1、和往常一样,CreateThread后,系统会先调用RtlUserThreadStart,然后由其调用_threadstartex。
2、在_threadstartex中,调用了系统API [TlsSetValue](https://msdn.microsoft.com/en-us/library/aa908728.aspx) 来讲ptd与调用线程关联起来(TLS 线程本地存储)。
3、_threadstartex调用 _callthreadstartex() 来运行我们最初传入的线程函数。
## _callthreadstartex函数
经历了上面种种,最终我们传入的线程函数,会被 _callthreadstartex函数调用。其定义如下:
~~~
static void _callthreadstartex(void)
{
_ptiddata ptd; /* pointer to thread's _tiddata struct */
/* must always exist at this point */
ptd = _getptd();
/*
* Guard call to user code with a _try - _except statement to
* implement runtime errors and signal support
*/
__try {
_endthreadex (
( (unsigned (__CLR_OR_STD_CALL *)(void *))(((_ptiddata)ptd)->_initaddr) )
( ((_ptiddata)ptd)->_initarg ) ) ;
}
__except ( _XcptFilter(GetExceptionCode(), GetExceptionInformation()) )
{
/*
* Should never reach here
*/
_exit( GetExceptionCode() );
} /* end of _try - _except */
}
~~~
该函数很简单,就是拿出ptd的值,同时,调用了_endthreadex函数来执行并返回我们的线程函数。
## _endthreadex函数
与_beginthreadex函数对应,_endthreadex是C/C++运行库终止线程运行的函数,其定义如下
~~~
/***
*_endthreadex() - Terminate the calling thread
*
*Purpose:
*
*Entry:
* Thread exit code
*
*Exit:
* Never returns!
*
*Exceptions:
*
*****************************************/
void __cdecl _endthreadex (
unsigned retcode
)
{
_ptiddata ptd; /* pointer to thread's _tiddata struct */
ptd = _getptd_noexit();
if (ptd) {
if (ptd->_initapartment)
_uninitMTAoncurrentthread();
/*
* Free up the _tiddata structure & its subordinate buffers
* _freeptd() will also clear the value for this thread
* of the FLS variable __flsindex.
*/
_freeptd(ptd);
}
/*
* Terminate the thread
*/
ExitThread(retcode);
}
~~~
**与_beginthreadex函数对应,**
1、_endthreadex销毁了在_beginthreadex分配的堆内存(保证了没有内存泄露)。
2、其调用了系统API ExitThread退出线程。
## ExitThread VS _endthreadex
在编写C\C++程序时,要调用_endthreadex来结束线程。基于如下两个理由:
1、ExitThread函数非C++函数,线程创建的C++对象不会得到析构。
2、若线程中使用了ptd,ExitThread不会释放内存,造成内存泄露。
## CreateThread VS _beginthreadex
一般的理由是,CreateThread有可能照成内存泄露。(如果使用了ptd内存,而CreateThread并不会在内部自动调用释放内存函数,但若链接的是C/C++运行库的dll版本,则其会在线程退出的DLL_THREAD_DETCH通知中释放内存)。
## 不要调用的C/C++函数
_beginthreadex和_endthreadex分别有两个比较老的版本:
~~~
uintptr_t _beginthread(
void( __cdecl *start_address )( void * ),
unsigned stack_size,
void *arglist
);
void _endthread( void );
~~~
我们应该忘记这两个函数,不要调用它们。
对于_beginthread函数,可以看出其函数参数是较少的,例如其中不包括安全属性,让我们对线程的控制力没有其增强版本多。
同时,由于在_beginthread内部会调用_endthread函数,而该函数多此一举的会调用一次CloseHandle,来帮我们关闭线程句柄。似乎有友好的操作,但实际会照成问题。例如下边代码
~~~
HANDLE hThread = _beginthread(...);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
~~~
在真正调用WaitForSingleObject之前,_beginthread函数里的线程可能已经执行完毕,系统会释放一次线程handle句柄,同时,_endthread又会释放一次handle句柄(此时handle计数为0)。那么再调用WaitForSingleObject时,可能这时的hThread已经是一个无效句柄,导致函数调用失败,同理,对CloseHandle也是一样。
';
(2)线程内幕
最后更新于:2022-04-01 21:44:45
## 线程结构
如上一篇文章所述,系统创建线程时,会分配一个内核对象与线程栈。如下图
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/10c78ca2c0bb6ff71b24c89438335509_616x298.jpg)
**线程内核对象如图左侧,其初始为**
1、引用计数为2
2、挂起计数为1(此时线程无法运行,当线程初始化好后,若未设置CREATE_SUSPENDED标志,则系统会自动将挂起计数减至0,线程为可调度状态)
3、退出代码为STILL_ACTIVE状态
4、内核对象未触发状态
5、记录线程上下文的CONTEXT结构为初始值(所谓线程切换,其实就是根据CONTEXT结构数据更新CPU寄存器内容)。注意其中的SP(栈指针寄存器)与IP(指令指针寄存器)。
SP指向pfnStartAddr而IP指向NTDLL.dll导出的
RtlUserThreadStart函数。这说明,其实每个新建的线程,其运行入口并不是我们传入的线程函数,而是统一会由系统调用RtlUserThreadStart。
RtlUserThreadStart函数定义如下
~~~
VOID RtlUserStartThread(PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParm)
{
__try
{
ExitThread((pfnStartAddr)(pvParm));
}
__except(UnhandledExceptionFilter(GetExceptionInformation()))
{
ExitProcess(GetExceptionCode());
}
// Note: We never get here
}
~~~
**观察RtlUserThreadStart函数,可得到如下事实:**
1、RtlUserThreadStart函数最终调用ExitThread函数退出线程并设置退出码。
2、若线程运行期由任何异常,则会被捕获并结束整个进程。
3、RtlUserThreadStart只会被操作系统调用来开启线程。
4、RtlUserThreadStart会为线程的返回地址压栈,让线程可返回。但RtlUserThreadStart本身永远不会返回,因为在函数返回前,其线程已经结束(如代码中注释一样)。
5、当进程运行主线程时,RtlUserThreadStart会调用C/C++的运行库启动代码,并有启动代码调用main函数,当线程由main返回时,C/C++启动代码会调用ExitProcess退出进程。
**线程栈如图右侧所示。**
1、线程栈空间来自进程空间。
2、线程栈空间由高向低扩展。
3、线程栈系统会默认写入两个值,分别是CreateThread时传入的线程参数与线程函数地址。
';
(1)线程基础
最后更新于:2022-04-01 21:44:42
在Windows系统中,进程更像一个容器,其功能的实现是靠线程完成的,即进程具有惰性。一个进程,至少拥有一个线程来执行任务。(进程第一个被创建的线程叫做主线程,其他的进程中的线程均为其子线程)
## 线程数据结构
对进程来说,一个进程包括 一个地址空间 和 一个内核对象。
对于线程,类似的 拥有一个内核对象 和 一个线程栈(用于维护线程执行时 所需要的函数参数及变量)。
同时,对于线程来说,他们还有一个进程层面的存储空间TLS(线程本地存储),这部分存储空间用于存储线程的私有全局变量用于支持非线程安全的C/C++函数。
## 进程与线程的资源开销
进程拥有独立的地址空间,线程在进程内共享进程空间,因此线程间可以直接处理相同的数据及共享句柄对象(句柄表是属于进程的,可有线程共享)。
操作系统对进程切换的处理需要有大量的记录(虚拟空间的切换),以及exe和dll加载入地址空间的消耗,因此进程的切换远大于线程。
线程切换仅仅是线程上下文的切换(即运行时寄存器的少量记录与转换)。
## 线程的创建
线程的创建需要 一个线程函数(用于线程执行任务) 及 线程创建线程的函数。
Windows中线程函数原型如下
~~~
DWORD WINAPI ThreadFunc(PVOID pvParam)
{
....
return 0;
}
~~~
对应的创建Windows线程的函数为
~~~
HANDLE WINAPI CreateThread(
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ SIZE_T dwStackSize,
_In_ LPTHREAD_START_ROUTINE lpStartAddress,
_In_opt_ LPVOID lpParameter,
_In_ DWORD dwCreationFlags,
_Out_opt_ LPDWORD lpThreadId
);
~~~
但是在实际中,如果我们是编写的C/C++代码,那么我们不要使用这个函数,因为其并不完整的支持非线程安全的C/C++函数(可能会导致内存泄露)。这里,应该使用Microsoft C
/C++编译器提供的_beginthreadex函数(或使用C++ 11标准中的thread类)
这里需要记住的是,一个线程调用CreateThread函数后,其返会的线程内核对象句柄(Handle)的引用计数是2,(创建该线程1的线程一个,线程自己一个,线程自己的会在线程函数执行完毕后自动close其句柄)。而内核对象销毁的必要条件是句柄引用为0。为了防止资源泄露,在创建了线程后主动掉一下CloseHandle是一个不错的习惯。比如
~~~
DOWD WINAPI ThreadFunc(PVOID pvParam)
{
....
return 0; // will close thread handle automatly
}
...
HANDLE hThread = CreateThread(.., ThreadFunc, ...);
CloseHandle(hThread); // Good!
~~~
## Windows线程的创建过程
当用户调用函数创建线程后,windows系统会做两件事:
1、创建一个代表新线程的内核对象。
2、在当前进程空间中分配内存空间给新线程的线程栈。
这里还要强调下,因为新线程是与其他线程在同一个进程地址空间中运行,因此新线程可以直接访问进程的所有句柄,内存空间以及其他线程的线程栈。因此同一进程的线程通信非常简单,但要注意线程间同步的问题。
## CreateThread函数参数意义
psa
线程安全属性
*dwStackSize*
默认线程栈的大小(以字节为单位)。线程栈的大小同时可以在编译器的/STACK指定,默认为1M。线程栈会取其中较大的一个值。限制线程栈的大小可以检测到无穷递归的bug。
*lpStartAddress*和*lpParameter*
分别指向线程函数地址与线程参数地址。
*dwCreationFlags*
线程创建后执行标志。
若为0,则线程立即执行。
若为**CREATE_SUSPENDED**,则线程创建后不会立即执行,而是等待[**ResumeThread**](https://msdn.microsoft.com/en-us/library/ms685086%28v=vs.85%29.aspx)函数手动执行。
**STACK_SIZE_PARAM_IS_A_RESERVATION**若设置该flag,则线程栈空间只会预定而不会实际分配。若没有设置,则默认直接分配线程栈空间。
*lpThreadId* 传出函数,获取新建线程的id。传入null则不关心该thread id。
## 终止线程的运行
如果我们编写的是C++的windows程序,那么,在线程终止时,我们应该期待如下四点的清理工作能够执行:
1、属于该线程栈上的所有C++对象的析构函数得以调用。(C++要求)
2、该线程的线程栈使用的内存能够正确的释放。(windows要求)
3、线程内核对象引用计数减一(注意这里是减一而不是销毁。线程内核对象生命可能会长于线程函数本身)。(windows要求)
4、操作系统将线程退出代码作为线程函数的返回值。(windows要求)
基于上述4点要求,依次比较线程结束的四种方法。
### 线程函数结束
这是唯一能够完全执行线程清理方法的结束方式。
### ExitThread函数
由被终止线程自身调用,可传入线程返回代码作为线程返回值。该函数属于windows系统API,因此只会执行2——3的windows清理工作,而C++对象不会析构。
(注意:若使用的是C/C++代码,必须得调用ExitThread函数时,应该使用_endthreadex函数代替,原因见后面)
### TerminateThread函数
该函数可以杀死任意线程(取决于传入该函数的线程句柄)。应该知道:
该函数是异步的。
由于该函数的突然性,被杀死的线程资源不会得到清理(但内核对象计数会减一)。
数据不会写回磁盘。
C++对象不会析构(由于是windows API函数)。
在dll的main函数中不会得到被TerminateThread函数杀死线程的退出通知。
### 包含线程的进程退出
这种情况下,类似于对每个进程内线程调用TerminateThread函数,但操作系统会保证进程的资源全部释放(即内核对象、线程栈资源都会释放 )。但C++对象不会得到析构,同时数据不会写回到磁盘……
因此,在进程返回前,我们通常会调用join或wait函数等待其内线程函数的结束。
这里要注意的是dll main函数中多线程死锁的问题,详细可见:
http://blog.csdn.net/guke1978_123/article/details/625773
## 线程结束后
现在讨论线程结束后,操作系统肯定会执行的操作。
1、线程所拥有的用户对象句柄被释放(窗口和挂钩)
2、线程退出代码由STILL_ACTIVE变为Exitthread或TerminateThread的传入值。
3、线程内核对象变为触发状态。
4、线程内核对象计数减一。
5、若该线程为进程最后的活动线程,则进程终止。
注意,当线程结束时,其内核对象并不会立即销毁,只有当说有拥有该线程内核对象的线程关闭其句柄时,句柄对象才会真正销毁。因此,在线程结束时,其他线程仍可以调用
GetExitCodeThread函数获取线程的退出代码。
';
前言
最后更新于:2022-04-01 21:44:40
> 原文出处:[Windows核心编程](http://blog.csdn.net/column/details/shilunlun-wincore5th.html)
作者:[u013378438](http://blog.csdn.net/u013378438)
**本系列文章经作者授权在看云整理发布,未经作者允许,请勿转载!**
# Windows核心编程
> Windows核心编程第五版读书笔记以及心得
';