網絡安全編程:多線程編程基礎知識
線程是進程中的一個執行單位(每個進程都必須有一個主線程),一個進程可以有多個線程,而一個線程只存在于一個進程中。在數據關系上,進程與線程是一對多的關系。線程不擁有系統資源,線程所使用的資源全部由進程向系統申請,線程擁有的是CPU的時間片。
在單處理器上(或單核處理器上),同一個進程中的不同線程交替得到CPU的時間片。在多處理器上(或多核處理器上),不同的線程可以同時運行在不同的CPU上,這樣可以提高程序運行的效率。除此之外,在有些方面必須使用多線程。比如,如果在掃描磁盤并同時在程序界面上同步顯示當前掃描的位置時,必須使用多線程。因為在程序界面上顯示和磁盤的掃描工作在同一個線程中,而且界面也在不停進行重新顯示,這樣就會導致軟件看起來像是卡死一樣。在這種情況下,分為兩個線程就可以解決該問題,界面的顯示由主線程完成,而掃描磁盤的工作由另外一個線程完成,兩個線程協同工作,這樣就可以達到實時顯示當前掃描狀態的效果了。
首先了解一下線程的創建。線程的創建使用CreateThread()函數,該函數的原型如下:
- HANDLE CreateThread(
- LPSECURITY_ATTRIBUTES lpThreadAttributes, // SD
- DWORD dwStackSize, // initial stack size
- LPTHREAD_START_ROUTINE lpStartAddress, // thread function
- LPVOID lpParameter, // thread argument
- DWORD dwCreationFlags, // creation option
- LPDWORD lpThreadId // thread identifier
- );
參數說明如下。
lpThreadAttributes:指明創建線程的安全屬性,為指向 SECURITY_ATTRIBUTES 結構的指針,該參數一般設置為 NULL。
dwStackSize:指定線程使用缺省的堆棧大小,如果為 NULL,則與進程主線程棧相同。
lpStartAddress:指定線程函數,線程即從該函數的入口處開始運行,函數返回時就意味著線程終止運行,該函數屬于一個回調函數。線程函數的定義形式如下:
- DWORD WINAPI ThreadProc(
- LPVOID lpParameter // thread data
- );
線程函數的返回值為DWORD類型,線程函數只有一個參數,該參數在CreateThread()函數中給出。該函數的函數名稱可以任意給定。很多時候并不能保證執行了CreateThread()函數后線程就會立即啟動,線程的啟動需要等待CPU的調度,CPU將時間片給該線程時,該線程才會執行,當然這個時間短到可以忽略它。
lpParameter:該參數表示傳遞給線程函數的一個參數,可以是指向任意數據類型的指針。這里是一個指針,可以方便的將多個參數通過結構體等一次性傳到線程函數中。
dwCreationFlags:該參數指明創建線程后的線程狀態,在創建線程后可以讓線程立刻執行(這里的立即執行的意思是不會受人為的去讓它處于等待狀態),也可以讓線程處于暫停狀態。如果需要立刻執行,該參數設置為 0;如果要讓線程處于暫停狀態,那么該參數設置為 CREATE_SUSPENDED,待需要線程執行時調用ResumeThread()函數讓線程的狀態調整為等待運行的狀態,然后由 CPU 分配時間片后去執行。
lpThreadId:該參數用于返回新創建線程的線程 ID。
如果線程創建成功,該函數返回線程的句柄,否則返回NULL。創建新線程后,該線程就開始啟動執行了。但如果在dwCreationFlags中使用了CREATE_SUSPENDED參數,那么線程并不馬上執行,而是先掛起,等到調用ResumeThread后才開始啟動線程。線程的句柄需要通過CloseHandle()進行關閉,以便釋放資源。
寫一個簡單的多線程的例子,代碼如下:
- #include <windows.h>
- #include <stdio.h>
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- printf("ThreadProc \r\n");
- return 0;
- }
- int main()
- {
- HANDLE hThread = CreateThread(NULL,0,ThreadProc,NULL,0,NULL);
- printf("main \r\n");
- CloseHandle(hThread);
- return 0;
- }
代碼在主線程中打印一行“main”,在創建的新線程中會打印一行“ThreadProc”。編譯運行,查看其運行結果,如圖1所示。
圖1 多線程程序輸出結果
從圖1中看出,程序的輸出跟預期的結果并不相同。程序的問題出在了哪里呢?每個線程都有屬于自己的CPU時間片,當主線程創建新線程后,主線程的CPU時間片并未結束,它會向下繼續執行。由于主線程的代碼非常少,因此主線程在CPU分配的時間片中就執行完成并退出了。由于主線程的結束,意味著進程也就結束并退出了。因此,在代碼中創建的線程雖然被創建了,但是根本就沒有執行的機會。那么在這么短的代碼中,如何保證新創建的線程在主線程結束前就能得到執行呢?或者說,主線程的運行需要等待新線程的完成才得以執行。這里需要使用WaitForSingleObject()函數,該函數的原型如下:
- DWORD WaitForSingleObject(
- HANDLE hHandle, // handle to object
- DWORD dwMilliseconds // time-out interval
- );
參數說明如下。
hHandle:該參數為要等待的對象句柄。
dwMilliseconds:該參數指定等待超時的毫秒數,如果設為 0,則立即返回,如果設為 INFINITE,則表示一直等待線程函數的返回。INFINITE 是系統定義的一個宏,其定義如下。
- #define INFINITE 0xFFFFFFFF
如果該函數失敗,則返回WAIT_FAILED;如果等待的對象編程激發狀態,則返回WAIT_ OBJECT_0;如果等待對象變成激發狀態之前,等待時間結束了,將返回WAIT_TIMEOUT。
修改上面的代碼,在CreateThread()函數后面加入如下代碼:
- WaitForSingleObject(hThread, INFINITE);
添加WaitForSingleObject()函數以后,主線程會等待新創建的線程結束再繼續向下執行主線程后續的代碼。這樣在控制臺上的輸出如圖2所示。
圖2 主線程等待子線程的執行
WaitForSingleObject()只能等待一個線程,可是在程序中往往要創建多個線程來執行,那么如果需要等待若干個線程的完成狀態的話,WaitForSingleObject()函數就無能為力了。不過,系統除了提供WaitForSingleObject()函數外,還提供了另外一個可以等待多個線程的完成狀態的函數WaitForMultipleObjects(),該函數的定義如下:
- DWORD WaitForMultipleObjects(
- DWORD nCount, // number of handles in array
- CONST HANDLE *lpHandles, // object-handle array
- BOOL fWaitAll, // wait option
- DWORD dwMilliseconds // time-out interval
- );
該函數的參數比WaitForSingleObject()函數多2個參數,下面介紹這些參數。
nCount:該參數用于指明想要讓函數等待的線程的數量。該參數的取值范圍在 1 到 MAXIMUM_WAIT _OBJECTS 之間。
lpHandles:該參數是指向等待線程句柄的數組指針。
fWaitAll:該參數表示是否等待全部線程的狀態完成,如果設置為 TRUE,則等待全部。
dwMilliseconds:該參數與 WaitForSingleObject()函數中的 dwMilliseconds 用法相同。
WaitForSingleObject()和WaitForMultipleObjects()兩個函數除了可以等待線程外,還可以等待用于多線程同步和互斥的內核對象。
在使用多線程的時候常常需要考慮和注意的問題很多。比如多線程同時對一個共享資源進行操作,通過線程需要按照一定的順序執行等。看一個簡單的多線程例子:
- int g_Num_One = 0;
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- int nTmp = 0;
- for ( int i = 0; i < 10; i ++ )
- {
- nTmp = g_Num_One;
- nTmp ++;
- // Sleep(1)的作用是讓出 CPU
- // 使其他線程被調度運行
- Sleep(1);
- g_Num_One = nTmp;
- }
- return 0;
- }
每個線程都有一個CPU時間片,當自己的時間片運行完成后,CPU會停止該線程的運行,并切換到其他線程去運行。當多線程同時操作一個共享資源時,這樣的切換會帶來隱形的問題。這里的代碼比較短,在一個CPU時間片內肯定會完成,無法體現出因線程切換而產生的錯誤。為了達到能夠因線程切換導致的錯誤,在代碼中加入了Sleep(1),使得線程主動讓出CPU,讓CPU進行線程切換。在代碼中,線程處理的共享資源是全局變量g_Num_One變量。主函數創建線程的代碼如下:
- int main()
- {
- HANDLE hThread[10] = { 0 };
- int i;
- for ( i = 0; i < 10; i ++ )
- {
- hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
- }
- WaitForMultipleObjects(10, hThread, TRUE, INFINITE);
- for ( i = 0; i < 10; i ++ )
- {
- CloseHandle(hThread[i]);
- }
- printf("g_Num_One = %d \r\n", g_Num_One);
- return 0;
- }
在主函數中,通過CreateThread()創建了10個線程,每個線程都讓g_Num_One自增10次,每次的增量為1。那么10個線程會使得g_Num_One的結果變成100。編譯運行上面的代碼,查看輸出結果,如圖3所示。
圖3 多線程操作共享資源的錯誤結果
這個結果和預測的結果并不相同。為什么會產生這種不同呢?這里進行一次模擬分析。為了方便分析,把線程的數量縮小為兩個線程,分別是A線程和B線程。
① g_Num_One的初始值為0。
② 當A線程中執行nTmp = g_Num_One和nTmp++后(此時nTmp的值為1),因為Sleep(1)的原因發生了線程切換,此時g_Num_One的初始值仍然為0。
③ 當B線程中執行nTmp = g_Num_One和nTmp++后(此時nTmp的值也為1),因為Sleep(1)的原因又發生了線程切換。
④ A線程執行g_Num_One = nTmp,此時g_Num_One的值為1,接著執行下一次循環中的nTmp = g_Num_One和nTmp++的操作,又進行切換。
⑤ B線程執行g_Num_One = nTmp,此時g_Num_One的值為1。
到第⑤步時,不繼續往下分析了,已經可以看出原因。g_Num_One的值是最后一次nTmp進行賦值后的值(線程中的局部變量屬于線程內私有的,雖然是同一個線程函數,但是nTmp在每個線程中是私有的)。
解決該問題,這里使用的是臨界區。臨界區對象是一個CRITICAL_SECTION的數據結構,Windows操作系統使用該數據結構對關鍵代碼進行保護,以確保多線程下的共享資源。在同一時間內,Windows只允許一個線程進入臨界區。
臨界區的函數有4個,分別是初始化臨界區對象(InitializeCriticalSection())、進入臨界區(EnterCriticalSection())、離開臨界區(LeaveCriticalSection())和刪除臨界區對象(DeleteCriticalSection())。臨界區很好的保護了共享資源,臨界區在現實生活中有很多類似的例子。比如,在進行體檢的時候,一個體檢室內只有一個體檢醫生,體檢醫生會叫一個患者進去體檢,這時其他人是不能進入的,當這個患者離開后,下一個患者才可以進入。這里體檢醫生就是一個共享的資源,而每個體檢的患者是多個不同的線程。臨界區就是以類似的方式保護了共享資源不被破壞的。下面依次來看一下這四個函數關于臨界區的函數的定義,分別如下:
- VOID InitializeCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID EnterCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID LeaveCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID DeleteCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
這4個API函數的參數都是指向CRITICAL_SECTION結構體的指針。修改上面有問題的代碼,修改后的代碼如下:
- #include <windows.h>
- #include <stdio.h>
- int g_Num_One = 0;
- CRITICAL_SECTION g_cs;
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- int nTmp = 0;
- for ( int i = 0; i < 10; i ++ )
- {
- // 進入臨界區
- EnterCriticalSection(&g_cs);
- nTmp = g_Num_One;
- nTmp ++;
- Sleep(1);
- g_Num_One = nTmp;
- // 離開臨界區
- LeaveCriticalSection(&g_cs);
- }
- return 0;
- }
- int main()
- {
- InitializeCriticalSection(&g_cs);
- HANDLE hThread[10] = { 0 };
- int i;
- for ( i = 0; i < 10; i ++ )
- {
- hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
- }
- WaitForMultipleObjects(10, hThread, TRUE, INFINITE);
- printf("g_Num_One = %d \r\n", g_Num_One);
- for ( i = 0; i < 10; i ++ )
- {
- CloseHandle(hThread[i]);
- }
- DeleteCriticalSection(&g_cs);
- return 0;
- }
編譯以上代碼并運行,輸出結果為想要的正確結果,即g_Num_One的值為100。除了使用臨界區以外,對于線程的同步與互斥還有其他方法,這里就不一一進行介紹了。在開發多線程程序時,要注意多線程的同步與互斥問題。臨界區對象只能用于多線程的互斥。