Windows下多線程編程(一)


前言

熟練掌握Windows下的多線程編程,能夠讓我們編寫出更規范多線程代碼,避免不要的異常。Windows下的多線程編程非常復雜,但是了解一些常用的特性,已經能夠滿足我們普通多線程對性能及其他要求。

進程與線程

1. 進程的概念

進程就是正在運行的程序。主要包括兩部分:

• 一個是操作系統用來管理進程的內核對象。內核對象也是系統用來存放關於進程的統計信息的地方。

• 另一個是地址空間,它包含所有可執行模塊或 D L L模塊的代碼和數據。它還包含動態內

2. 線程的概念

線程就是描述進程的一條執行路徑,進程內代碼的一條執行路徑。一個進程至少有一個主線程,且可以有多個線程。線程共享進程的所有資源。線程主要包括兩部分:

• 一個是線程的內核對象,操作系統用它來對線程實施管理。內核對象也是系統用來存放

線程統計信息的地方。

• 另一個是線程堆棧,它用於維護線程在執行代碼時需要的所有函數參數和局部變量。

3. 進程與線程的優劣

進程使用更多的系統資源,因為每個進程需要獨立的地址空間。而線程只有一個內核對象及一個堆棧。如果有空間資源和運行效率上的考慮,則優先使用多線程。正因為每個地址有自已獨立的進程空間,所以每個進程都是獨立互不影響的。而一個進程中所有線程是共用進程的地址空間的,這樣一個線程出問題可能影響到所有線程。像多標簽瀏覽器容易一個見面假死導致整個瀏覽無法使用。所以像360瀏覽器等每個標簽頁都是一個進程,這樣一個標簽頁面出問題並不會影響到其他標簽頁面。

4. 一個進程可以創建多少線程

32位windows中,0~4G線性內存空間。0~2G為應用程序內存空間(處於其中每個進程都有獨立的內存空間),2G~4G為系統內核空間(內核進程完全共享)。那么進程的最大可用內存就是2G,每個線程棧的默認大小是1MB,理論上最多創建2048個線程,實際進程中還有一些其他地方占用內存,所以一般情況下可創建的線程總數為2000個左右。當然,如果想創建更多線程,可以縮小線程的棧大小。

與線程有關的函數

1. 線程的創建與終止
線程創建API

HANDLE CreateThread(

LPSECURITY_ATTRIBUTES lpThreadAttributes,

SIZE_T dwStackSize,

LPTHREAD_START_ROUTINE lpStartAddress,

LPVOID lpParameter,

DWORD dwCreationFlags,

LPDWORD lpThreadId);

• lpThreadAttributes,描述線程安全的結構體,默認傳NULL.

• dwStackSize,堆棧大小,默認1MB.

• lpStartAddress,線程函數入口地址。

• lpParameter,線程函數參數。

• dwCreationFlags,線程創建時的狀態,0表示線程創建之后立即運行。CREATE_SUSPENDED表示線程創建完掛起,直到調用ResumeThread才運行。

• lpThreadId,指向1個變量接受線程ID,可為NULL。

線程終止API

void ExitThread(DWORD dwExitCode);

函數將強制終止線程的運行,並導致損傷系統清除該線程所使用的所有操作系統資源。但是C++對象可能由於析構函數沒有正常調用導致資源不能得到正確釋放。附加的退出碼,可以用GetExitCodeThread()函數可以獲取。不建議使用此線程終止函數,因為可能導致資源沒有正確的釋放,一般都讓線程正常退出。另外,即便要強制終止線程,也要使用_endThreadEx(不使用_endThread),因為它兼顧了多線程資源安全。

BOOL TerminateThread(HANDLE hThread, DWORD dwExitCode);

該函數也是強制退出線程的,只不過此函數是異步的,即它告訴系統去終止指定線程,但是不能保證函數返回時線程已經被終止了。因此調用者必須使用WaitForSingleObject函數來確定線程是否終止。因此此函數調用后終止的線程堆棧資源不會得到釋放。一般不建議使用此函數。

2. 線程安全

對線程安全沒有一個比較具體的說明,簡單來說線程函數的操作是安全的。這里的操作對象主要為:變量、函數、類對象。

線程安全變量

這里的變量指非自定義類型的全局變量/靜態變量,或者通過線程參數傳入的變量。

•所有線程只讀取該變量,那么該變量肯定線程安全的。

•有1個線程寫操作該變量,其他線程讀取該變量。這時就需要考慮volatile。當一段線程代碼多次讀取變量的值時,編譯器默認會優化代碼只第1次會從內存上讀取值,其他時候直接是從寄存器上讀取的。這樣如果其他線程更新了變量的值,讀取的線程可能依然是從寄存器上讀取的。這個時候就需要告訴編譯器該變量不要優化,永遠是從內存上讀取。效率可能低一點,但是保證線程中變量的安全更重要。

•有多個線程同時寫操作該變量,那么就必須考慮臨界區讀寫鎖等方法。

線程安全函數

多線程出現之前就已經有C/C++運行時庫,所以C/C++運行時庫不一定是線程安全的。例如GetLastError()獲取的就是一個全局的變量值,針對多線程可能就會出錯。針對這個問題,MS提供了C/C++多線程運行時庫,並且需要配合相應的多線程創建函數。

_beginthreadex

不建議使用_beginthread,因為它是早期不成熟的函數,因為它創建完成線程之后立即結束了句柄,導致不能有效控制線程。C/C++運行時庫函數_beginthreadex是對操作系統函數CreateThread的封裝,並且這里使用了線程局存儲(TLS)來保證每個線程都有自已的單獨的一些共用變量,例如像GetLastError()使用的變量。這樣每個線程就能夠保證所有的API函數都是線程安全的。

AfxBeginThread

如果當前代碼環境是基於MFC庫的,那么多線程創建函數必須使用MFC庫函數AfxBeginThread。這是因為MFC庫是對C/C++運行庫的再封裝,同樣會面臨MFC庫本身存在的一些線程不安全變量的操作。AfxBeginThread其實是對_beginthreadex函數的再封裝,在調用_beginthreadex之前完成一些安全載入MFC DLL庫的的操作。這樣基於MFC的庫函數的調用才是安全的。

線程安全類

除了C/C++運行時庫、MFC庫因為已經有處理線程安全外,其他第三方庫,甚至包括STL都不是線程安全的。這些自定義的類庫,都需要自已去考慮線程安全。 這里可以利用鎖、同步及異步等內核對象來解決,當然也可以使用TLS來解決。

3. 線程的暫停與恢復

在線程內核對象的內部有一個值,用於指明線程的暫停計數。當調用CreateThread函數時,就創建了線程的內核對象,並且內核對象里的暫停計數被初始化為 1,這樣操作系統就不會再分配時間片給線程。當創建的線程指定CREATE_SUSPENED標志時,那么線程就處於暫停狀,這個時候可以給線程進行一些優先級設置等其他初始化。當初始化完成之后,可以調用ResumeThread來恢復。單個線程可以暫時多次,如果暫停了3次,則需要ResumeThread恢復3次才能重新讓線程獲得時間片。

除了創建線程指定CREATE_SUSPENED來暫停線程外,還可以調用SuspendThread來暫時線程。調用SuspendThread時,因為不知道當前線程正在做什么,如果是正在進行內存分配或者正在一個鎖操作當中,可能導致其他線程鎖死之類的。所以使用SuspendThread時一定要加強措施來避免可能出現的問題。

用戶模式與內核模式

運行 Windows 的計算機中的處理器有兩個不同模式:“用戶模式”和“內核模式”。根據處理器上運行的代碼的類型,處理器在兩個模式之間切換。應用程序在用戶模式下運行,核心操作系統組件在內核模式下運行。多個驅動程序在內核模式下運行,但某些驅動程序在用戶模式下運行。

1. 用戶模式

當啟動用戶模式的應用程序時,Windows 會為該應用程序創建“進程”。進程為應用程序提供專用的“虛擬地址空間”和專用的“句柄表格”。由於應用程序的虛擬地址空間為專用空間,一個應用程序無法更改屬於其他應用程序的數據。每個應用程序都孤立運行,如果一個應用程序損壞,則損壞會限制到該應用程序。其他應用程序和操作系統不會受該損壞的影響。

用戶模式應用程序的虛擬地址空間除了為專用空間以外,還會受到限制。在用戶模式下運行的處理器無法訪問為該操作系統保留的虛擬地址。限制用戶模式應用程序的虛擬地址空間可防止應用程序更改並且可能損壞關鍵的操作系統數據。

2. 內核模式

實現操作系統的一些底層服務,比如線程調度,多處理器的同步,中斷/異常處理等。

3. 內核對象

顧名思義,內核對象即內核創建的對象。由於內核對象的數據結構只能被內核訪問,所以應用程序無法在內存中找到這些數據內容。因為要用內核來創建對象,所以必從用戶模式切換到內核模式,而從用戶模式切換到內核模式是需要耗費幾百個時鍾 周期的。建和操作若干類型的內核對象,比如存取符號對象、事件對象、文件對象、文件映射對象、I / O完成端口對象、作業對象、信箱對象、互斥對象、管道對象、進程對象、信標對象、線程對象和等待計時器對象等。內核對象是跨進程的,所以跨進程可以使用內核對象進行通信。

時間片和原子操作

1. 時間片

早期CPU是單核單線程,所以不可能做到真正的多線程。時間片即是操作將CPU運行的時間划分成長短基本一致的時間區,即是時間片。多線程主要是通過操作系統不停地切換時間給不同的線程,來讓線程快速交替運行,因為時間相隔很短,用戶看起來像是幾個線程同時在運行。當然現在CPU有多核多線程,可以做到真正的多線程了。可以使用SetThreadAffinityMask來指定線程運行在不同CPU上。

sleep(0),當1個線程有大量計算量,容易導致CPU使用很高,而其他進程線程得不到時間片。這個時候調用sleep(0),相當告訴操作系統重新來分配時間片,這個時候同優先級的線程就可能分配得時間片,減緩計算線程大量占用時間片。

2. 原子操作

線程同步問題在很大程度上與原子訪問有關,所謂原子訪問,是指線程在訪問資源時能夠確保所有其他線程都不在同一時間內訪問相同的資源。

例如:

int g_nVal = 0;

DWORD WINAPI ThreadFun1(PLOVE pParam)

{

g_nVal++;

return 0;

}

DWORD WINAPI ThreadFun2(PLOVE pParam)

{

g_nVal++;

return 0;

}

因為g_nVal++是先從內存上取值放寄存器上再來進行計算,因為線程調度的不可控性,導致可能兩個線程先后都是從內存上取到的0,這樣自加后的結果都是1。這與我們實際想要的結果2並不一致。為了避免這種情況,就需要原子操作InterlockedExchangeAdd(g_nVal, 1)來達到效果。互鎖函數操作一個內存地址時,會防止另一個CPU訪問內一個內存地址。

InterlockedExchanged/InterlockedExchangePointer,前者是交換一個值,后者是交換一組值。其作用是原子交換指定的值,並返回原來的值。因此它可以有如下的應用。

void Fun()

{

while (InterlockedExchange(&g_bVal, TRUE) == TRUE)

Sleep(0);

// do something

InterlockedExchange(&g_bVal, FALSE);

}

上面的代碼能夠達到一個鎖的效果。原子操作不用切換到內核模式,所以速度比較快。但是上面的代碼依然需要不停地循環來達到等待的效果。臨界區與原子操作一樣,都可以直接在用戶模式下操作,並且臨界區則是直接等待完全不用給當前線程分配CPU時間片。所以效率上還是臨界區更優一點。

線程池

當線程頻繁創建時,大量線程的創建銷毀會占用大量的資源,導致效率低下。這個時候就可以考慮使用線程池。線程池的主要原理,即創建的線程暫時不銷毀,加入空閑線程列表。當需要創建新線程時,優先去空閑線程列表中查詢是否有空閑線程,有就直接用,如果沒有再創建新的線程。這樣就能夠達到減少線程的頻繁創建與銷毀。

協程

像Python、Lua都提供了協程,尤其是Lua,因為它沒有多線程,所以非常依賴協程,Lua也是將協程發揮得比較好的腳本語言。像其他語言也都有第三方實現的協程庫可用。Windows多線程是由內核提供的,所以創建多線程需要切換到內核模式,因為從用戶模式切換到內核模式分花費幾百個時鍾周期。而一種直接由用戶模式提供的輕量級類多線程,其實就是協程(Coroutine)。具體來講就是函數A調用協程函數B,然后B執行到第5行中斷返回函數A繼續執行其他函數C,然后下次再次調用到B時,這個時候是從B函數的第5行開始執行的。看起來就是先執行協程函數B,執行了一部分,中斷去執行C,執行完C接着從上次的位置執行B。看起來是簡陋的多線程,其實是利用同步達到異步的效果。C++的主要實現原理,是通過保存函數的寄存器上下文以及堆棧,下次執行協程函數時,首先恢復寄存器上下文以及堆棧,然后跳轉到上次執行的函數。如果有大規模的並發,不希望頻繁調用多線程,可以考慮使用協程。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM