多線程程序設計(一)

應用程序被裝載到內存之後就形成了進程,這是上一章重點討論的話題。但是程序在內存中是如何執行的呢?這就涉及到了代碼的執行單元——線程。本章就線程的創建、多線程處理展開介紹。

本章首先介紹創建線程的方法和線程內核對象,接着詳細分析產生線程同步問題的根本原因,並提出一些解決辦法。爲了擴展多線程的應用和爲讀者提供更多的實際機會,本章還重點討論了線程局部存儲和CWinThread類的設計,這也是設計框架程序的一個前奏。

本書今後討論的程序實例很多都是基於多線程的,在實際的應用過程中,大部分程序也都會涉及到多線程,所以讀者應該深入掌握本章的內容。

3.1 多線程

CreateProcess函數創建了進程,同時也創建了進程的主線程。這也就是說,系統中的每個進程都至少有一個線程,這個線程從入口地址main處開始執行,直到return語句返回,主線程結束,該進程也就從內存中卸載了。

主線程在運行過程中還可以創建新的線程,即所謂的多線程。在同一進程中運行不同的線程的好處是這些線程可以共享進程的資源,如全局變量、句柄等。當然各個線程也可以有自己的私有堆棧用於保存私有數據。本節具體介紹線程的創建和線程內核對象對程序的影響。

3.1.1 線程的創建

線程描述了進程內代碼的執行路徑。進程中同時可以有多個線程在執行,爲了使它們能夠“同時”運行,操作系統爲每個線程輪流分配CPU時間片。爲了充分地利用CPU,提高軟件產品的性能,一般情況下,應用程序使用主線程接受用戶的輸入,顯示運行結果,而創建新的線程(稱爲輔助線程)來處理長時間的操作,比如讀寫文件、訪問網絡等。這樣,即便是在程序忙於繁重的工作時也可以由專門的線程響應用戶命令。

每個線程必須擁有一個進入點函數,線程從這個進入點開始運行。主線程的進入點是函數main,如果想在進程中創建一個輔助線程,則必須爲該輔助線程指定一個進入點函數,這個函數稱爲線程函數。線程函數的定義如下:

DWORD WINAPI ThreadProc(LPVOID lpParam);          // 線程函數名稱ThreadProc可以是任意的

WINAPI是一個宏名,在windef.h文件中有如下的聲明:

#define WINAPI __stdcall ;

__stdcall是新標準C/C++函數的調用方法。從底層上說,使用這種調用方法參數的進棧順序和標準C調用(_cdecl方法)是一樣的,都是從右到左,但是__stdcall採用自動清棧的方式,而_cdecl採用的是手工清棧方式。Windows規定,凡是由它來負責調用的函數都必須定義爲__stdcall類型。ThreadProc是一個回調函數,即由Windows系統來負責調用的函數,所以此函數應定義爲__stdcall類型。注意,如果沒有顯式說明的話,函數的調用方法是_cdecl。

可以看到這個函數有一個參數lpParam,它的值是由下面要講述的CreateTread函數的第四個參數lpParameter指定的。

創建新線程的函數是CreateThread,由這個函數創建的線程將在調用者的虛擬地址空間內執行。函數的用法如下:

HANDLE CreateThread (

    LPSECURITY_ATTRIBUTES lpThreadAttributes,          // 線程的安全屬性

    DWORD dwStackSize,                                                        // 指定線程堆棧的大小

    LPTHREAD_START_ROUTINE lpStartAddress,            // 線程函數的起始地址

    LPVOID lpParameter,           // 傳遞給線程函數的參數

    DWORD dwCreationFlags, // 指定創線程建後是否立即啓動

    DWORD* lpThreadId                  // 用於取得內核給新生成的線程分配的線程ID 號

    );

此函數執行成功後,將返回新建線程的線程句柄。lpStartAddress 參數指定了線程函數的地址,新建線程將從此地址開始執行,直到return語句返回,線程運行結束,把控制權交給操作系統。

下面是一個簡單的例子(03ThreadDemo工程下)。在這個的例子中,主線程首先創建了一個輔助線程,打印出輔助線程的ID號,然後等待輔助線程運行結束;輔助線程僅打印出幾行字符串,以模擬真正的工作。程序代碼如下:

#include <stdio.h>

#include <windows.h>

// 線程函數

DWORD WINAPI ThreadProc(LPVOID lpParam)

{       int i = 0;

         while(i < 20)

         {       printf(" I am from a thread, count = %d \n", i++);          }

         return 0;

}

int main(int argc, char* argv[])

{       HANDLE hThread;

         DWORD dwThreadId;

         // 創建一個線程

         hThread = ::CreateThread (

                   NULL,                // 默認安全屬性

                   NULL,                // 默認堆棧大小

                   ThreadProc,                  // 線程入口地址(執行線程的函數)

                   NULL,                // 傳給函數的參數

                   0,                         // 指定線程立即運行

                   &dwThreadId);   // 返回線程的ID號

         printf(" Now another thread has been created. ID = %d \n", dwThreadId);

         // 等待新線程運行結束

         ::WaitForSingleObject (hThread, INFINITE);

         ::CloseHandle (hThread);

         return 0;

}

程序執行後,CreateThread函數會創建一個新的線程,此線程的入口地址爲 ThreadProc。最後的輸出結果如圖3.1所示。

圖3.1 新線程的運行結果

上面的例子使用CreateThread 函數創建了一個新線程:

CreateThread ( NULL, NULL, ThreadProc, NULL,0, NULL);

創建新線程後CreateThread函數返回,新線程從ThreadProc函數的第一行執行。主線程繼續運行,打印出新線程的一些信息後,調用WaitForSingleObject函數等待新線程運行結束。

         // 等待新線程運行結束

         ::WaitForSingleObject (

                                               hThread,              // hHandle                             要等待的對象的句柄

                                               INFINITE );       // dwMilliseconds        要等待的時間(以毫秒爲單位)

WaitForSingleObject函數用於等待指定的對象(hHandle)變成受信狀態。參數dwMilliseconds給出了以毫秒爲單位的要等待的時間,其值指定爲INFINITE表示要等待無限長的時間。當有下列一種情況發生時函數就會返回:

(1)要等待的對象變成受信(signaled)狀態。

(2)參數dwMilliseconds指定的時間已過去。

一個可執行對象有兩種狀態,未受信(nonsignaled)和受信(signaled)狀態。線程對象只有當線程運行結束時才達到受信狀態,此時“WaitForSingleObject(hThread, INFINITE)”語句纔會返回。

CreateThread函數的lpThreadAttributes和dwCreationFlags參數的作用在本節的例子中沒有體現出來,下面詳細說明一下。

lpThreadAttributes——一個指向SECURITY_ATTRIBUTES結構的指針,如果需要默認的安全屬性,傳遞NULL就行了。如果希望此線程對象句柄可以被子進程繼承的話,必須設定一個SECURITY_ATTRIBUTES結構,將它的bInheritHandle成員初始化爲TRUE,如下面的代碼所示:

         SECURITY_ATTRIBUTES sa;

         sa.nLength = sizeof(sa);

         sa.lpSecurityDescriptor = NULL;

         sa.bInheritHandle = TRUE ;           // 使CreateThread返回的句柄可以被繼承

         // 句柄h可以被子進程繼承

         HANDLE h = ::CreateThread (&sa, ...... );

當創建新的線程時,如果傳遞NULL做爲lpThreadAttributes參數的值,那麼返回的句柄是不可繼承的;如果定義一個SECURITY_ATTRIBUTES類型的變量sa,並像上面一樣初始化sa變量的各成員,最後傳遞sa變量的地址做爲lpThreadAttributes參數的值,那麼CreateThread函數返回的句柄就是可繼承的。

這裏的繼承是相對於子進程來說的。當創建子進程時,如果爲CreateProcess函數的bInheritHandles參數傳遞TRUE,那麼子進程就可以繼承父進程的可繼承句柄。

dwCreationFlags——創建標誌。如果是0,表示線程被創建後立即開始運行;如果指定爲CREATE_SUSPENDED標誌,表示線程被創建以後處於掛起(暫停)狀態,直到使用ResumeThread函數(見下一小節)顯式地啓動線程爲止。

3.1.2 線程內核對象

線程內核對象就是一個包含了線程狀態信息的數據結構。每一次對CreateThread函數的成功調用,系統都會在內部爲新的線程分配一個內核對象。系統提供的管理線程的函數其實就是依靠訪問線程內核對象來實現管理的。下面列出了這個結構的基本成員:

線程內核對象(Thread Kernel Object)

CONTEXT (上下文,即寄存器的狀態)

EAX

EBX

其他CPU寄存器

Usage Count 使用計數(2)

Suspend Count 暫停次數(1)

Exit Code 退出代碼(STILL_ACTIVE)

Signaled 是否受信(FALSE)

………………

創建線程內核對象的時候,系統要對它的各個成員進行初始化,上表中每一項括號裏面的值就是該成員的初始值。本節主要討論內核對象各成員的作用,以及系統如何管理這些成員。

1.線程上下文CONTEXT

每個線程都有它自己的一組CPU寄存器,稱爲線程的上下文。這組寄存器的值保存在一個CONTEXT結構裏,反映了該線程上次運行時CPU寄存器的狀態。

2.使用計數Usage Count

Usage Count成員記錄了線程內核對象的使用計數,這個計數說明了此內核對象被打開的次數。線程內核對象的存在與Usage Count的值息息相關,當這個值是0的時候,系統就認爲已經沒有任何進程在引用此內核對象了,於是線程內核對象就要從內存中撤銷。

只要線程沒有結束運行,Usage Count的值就至少爲1。在創建一個新的線程時,CreateThread函數返回線程內核對象的句柄,相當於打開一次新創建的內核對象,這也會促使Usage Count的值加1。所以創建一個新的線程後,初始狀態下Usage Count的值是2。之後,只要有進程打開此內核對象,就會使Usage Count的值加1。比如當有一個進程調用OpenThread函數打開這個線程內核對象後,Usage Count的值會再次加1。

HANDLE OpenThread(

DWORD dwDesiredAccess,     // 想要的訪問權限,可以爲THREAD_ALL_ACCESS等

BOOL bInheritHandle,               // 指定此函數返回的句柄是否可以被子進程繼承

DWORD dwThreadId                     // 目標線程ID號

);                // 注意,OpenThread函數是Windows 2000及其以上產品的新特性,Windows 98並不支持它。

由於對這個函數的調用會使Usage Count的值加1,所以在使用完它們返回的句柄後一定要調用CloseHandle函數進行關閉。關閉內核對象句柄的操作就會使Usage Count的值減1。

還有一些函數僅僅返回內核對象的僞句柄,並不會創建新的句柄,當然也就不會影響Usage Count的值。如果對這些僞句柄調用CloseHandle函數,那麼CloseHandle就會忽略對自己的調用並返回FALSE。對進程和線程來說,這些函數有:

HANDLE GetCurrentProcess ();   // 返回當前進程句柄

HANDLE GetCurrentThread ();    // 返回當前線程句柄

前面提到,新創建的線程在初始狀態下Usage Count的值是2。此時如果立即調用CloseHandle函數來關閉CreateThread返回的句柄的話,Usage Count的值將減爲1,但新創建的線程是不會被終止的。

在上一小節那個簡單的例子中,Usage Count值的變化情況是這樣的:調用CreateThread函數後,系統創建一個新的線程,返回其句柄,並將Usage Count的值初始化爲2。線程函數一旦返回,線程的生命週期也就到此爲止了,系統會使Usage Count的值由2減爲1。接下來調用CloseHandle函數又會使Usage Count減1。這個時候系統檢查到Usage Count的值已經爲0,就會撤銷此內核對象,釋放它佔用的內存。如果不關閉句柄的話,Usage Count的值將永遠不會是0,系統將永遠不會撤銷它佔用的內存,這就會造成內存泄漏(當然,線程所在的進程結束後,該進程佔用的所有資源都要釋放)。

3.暫停次數Suspend Count

線程內核對象中的Suspend Count用於指明線程的暫停計數。當調用CreateProcess(創建進程的主線程)或CreateThread函數時,線程的內核對象就被創建了,它暫停計數被初始化爲1(即處於暫停狀態),這可以阻止新創建的線程被調度到CPU中。因爲線程的初始化需要時間,當線程完全初始化好了之後,CreateProcess或CreateThread檢查是否傳遞了CREATE_SUSPENDED 標誌。如果傳遞了這個標誌,那麼這些函數就返回,同時新線程處於暫停狀態。如果尚未傳遞該標誌,那麼線程的暫停計數將被遞減爲0。當線程的暫停計數是0的時候,該線程就處於可調度狀態。

創建線程的時候指定CREATE_SUSPENDED標誌,就可以在線程有機會在執行任何代碼之前改變線程的運行環境(如下面討論的優先級等)。一旦達到了目的,必須使線程處於可調度狀態。進行這項操作,可以使用ResumeThread函數。

DWORD ResumeThread (HANDLE hThread);       // 喚醒一個掛起的線程

該函數減少線程的暫停計數,當計數值減到0的時候,線程被恢復運行。如果調用成功,ResumeThread函數返回線程的前一個暫停計數,否則返回0xFFFFFFFF(-1)。

單個線程可以被暫停若干次。如果一個線程被暫停了3次,它必須被喚醒3次纔可以分配給一個CPU。暫停一個線程的運行可以用SuspendThread函數。

DWORD SuspendThread (HANDLE hThread);       // 掛起一個線程

任何線程都可以調用該函數來暫停另一個線程的運行。和ResumeThread相反,SuspendThread函數會增加線程的暫停計數。

大約每經20ms,Windows查看一次當前存在的所有線程內核對象。在這些對象中,只有一少部分是可調度的(沒有處於暫停狀態),Windows選擇其中的一個內核對象,將它的CONTEXT(上下文)裝入CPU的寄存器,這一過程稱爲上下文轉換。但是這樣做的前提是,所有的線程具有相同的優先級。在現實環境中,線程被賦予許多不同的優先級,這會影響到調度程序將哪個線程取出來做爲下一個要運行的線程。

發佈了24 篇原創文章 · 獲贊 18 · 訪問量 31萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章