天天看點

【譯】剖析MFC多線程程式的同步機制

 原文連結:Synchronization in Multithreaded Applications with MFC

簡介

本文探讨基本的同步概念,并實際動手幫助新手掌握多線程程式設計。本文的重點在各種同步技巧。

基本概念

線上程執行過程中,或多或少都需要彼此互動,這種互動行為有多種形式和類型。例如,一個線程在執行完它被賦予的任務後,通知另一個線程任務已經完成。然後第二個線程做開始剩下的工作。

下述對象是用來支援同步的:

1)信号量

2)互斥鎖

3)關鍵區域

4)事件

每個對象都有不同的目的和用途,但基本目的都是支援同步。當然還有其他可以用來同步的對象,比如程序和線程對象。後兩者的使用由程式員決定,比如說判斷一個給定程序或線程是否執行完畢為了使用程序和線程對象來進行同步,我們一般使用Wait*函數,在使用這些函數時,你應當知道一個概念,任何被作為同步對象的核心對象(關鍵區域除外)都處于兩種狀态之一:通知狀态和未通知狀态。例如,程序和線程對象,當他們開始執行時處于未通知狀态,而當他們執行完畢時處于通知狀态,

為了判斷一個給定程序或線程是否已經結束,我們必須判斷表示其的對象是否處于通知狀态,而要達到這樣的目的,我們需要使用Wait*函數。

Wait*函數

      下面是最簡單的Wait*函數:

DWORD WaitForSingleObject

(

  HANDLE hHandle,

  DWORD dwMilliseconds

);

參數hHandle表示待檢查其狀态(通知或者未通知)的對象,dwMilliseconds表示調用線程在被檢查對象進入其通知狀态前應該等待的時間。若對象處于通知狀态或指定時間過去了,這個函數傳回控制權給調用線程。若dwMilliseconds設定為INIFINITE(值為-1),則調用線程會一直等待直到對象狀态變為通知,這有可能使得調用線程永遠等待下去,導緻“餓死”。

      例如,檢查指定線程是否正在執行, dwMilliseconds設定為0,是為了讓調用線程馬上傳回。

複制代碼

DWORD dw = WaitForSingleObject(hProcess, 0);

switch (dw)

{

   case WAIT_OBJECT_0:

      // the process has exited

      break;

   case WAIT_TIMEOUT:

      // the process is still executing

   case WAIT_FAILED:

      // failure

}

    下一個Wait類函數類似上面的,但它帶的是一系列句柄,并且等待其中之一或全部進入已通知狀态。

 複制代碼

DWORD WaitForMultipleObjects

  DWORD nCount,

  CONST HANDLE *lpHandles,

  BOOL fWaitAll,

  參數nCount表示待檢查的句柄個數,lpHandles指向句柄數組,若fWaitAll為TRUE,則等待所有的對象進入已通知狀态,若為FALSE,則當任何一個對象進入已通知狀态時,函數傳回。dwMilliseconds意義同上。

例如,下面代碼判斷哪個程序會先結束:

HANDLE h[3];

h[0] = hThread1;

h[1] = hThread2;

h[2] = hThread3;

DWORD dw = WaitForMultipleObjects(3, h, FALSE, 5000);//任何一個進入已通知就傳回

      // no processes exited during 5000ms

   case WAIT_OBJECT_0 + 0:

      // a process with h[0] descriptor has exited

   case WAIT_OBJECT_0 + 1:

      // a process with h[1] descriptor has exited

   case WAIT_OBJECT_0 + 2:

      // a process with h[2] descriptor has exited

  句柄數組中索引号為index的對象進入已通知狀态時,函數傳回WAIT_OBJECT_0 + 索引号。若fWaitAll為TRUE,則當所有對象進入已通知狀态時,函數傳回WAIT_OBJECT_0。

      一個線程若調用一個Wait*函數,則它從使用者模式切換為核心模式。這帶來的後果有好有壞。不好的是切換進入核心模式大概需要1000個時鐘周期,這消耗不算小。好的是當進入核心模式後,就不需要使用處理器,而是進入休眠态,不參與處理器的排程了。

      現在讓我們進入MFC,并看看它能為我們做些什麼。這裡有兩個類封裝了對Wait*函數的調用: CSingleLock和CMultiLock。

同步對象

等價的C++類

Events

CEvent

Critical sections

CCriticalSection

Mutexes

CMutex

Semaphores

CSemaphore

每個類都從一個類--CSyncObject繼承下來,此類最有用的成員是重載的HANDLE運算符,它傳回指定同步對象的内在句柄。所有這些類都定義在<AfxMt.h>頭檔案中。

事件

      一般來說,事件用于這樣的情形下:當指定的動作發生後,一個線程(或多個線程)才開始執行其任務。例如,一個線程可能等待必需的資料收集完後才開始将其儲存到硬碟上。有兩種事件:手動重置型和自動重置型。通過使用事件,我們可以輕松地通知另一個線程特定的動作已經發生了。對于手動重置型事件,線程使用它通知多個線程特定動作已經發生,而對于自動重置型事件,線程使用它隻可以通知一個線程。在MFC中,CEvent類封裝了事件對象(若在win32中,它是用一個HANDLE來表示的)。CEvent的構造函數運作我們選擇建立手動重置型和自動重置型事件。預設的建立類型是自動重置型事件。為了通知正在等待的線程,我們可以調用CEvent::SetEvent方法,這個方法将會讓事件進入已通知狀态。若事件是手動重置型,則事件會保持已通知狀态,直到對應的CEvent::ResetEvent被調用,這個方法将使得事件進入未通知狀态。這個特性使得一個線程可以通過一個SetEvent調用去通知多個線程。若事件是自動重置型,則所有正在等待的線程中隻有一個線程會接收到通知。當那個線程接收到通知後,事件會自動進入未通知狀态。

      下面兩個例子将展示上述特性:

// create an auto-reset event

CEvent g_eventStart;

UINT ThreadProc1(LPVOID pParam)

    ::WaitForSingleObject(g_eventStart, INFINITE);

    return 0;

UINT ThreadProc2(LPVOID pParam)

        在這個例子中,一個全局的CEvent對象被建立,當然它是自動重置型的。除此以外,有兩個工作線程在等待這個事件對象以便開始其工作。隻要第三個線程調用那個事件對象的SetEvent方法,則兩個線程中之一(當然沒人知道會是哪個)會接收到通知,然後事件會進入未通知狀态,這就防止了第二個線程也得到事件的通知。

      下面來看第二個例子:

// create a manual-reset event

CEvent g_eventStart(FALSE, TRUE);

    這段代碼和上面的稍有不同,CEvent對象構造函數的參數不一樣了,但意義上就大不同了,這是一個手動重置型事件對象。若第三個線程調用事件對象的SetEvent方法,則可以確定兩個工作線程都會同時(幾乎是同時)開始工作。這是因為手動重置型事件在進入已通知狀态後,會保持此狀态直到對應的ResetEvent被調用。

      除此以外事件對象還有一個方法:CEvent::PulseEvent。這個方法首先使得事件對象進入已通知狀态,然後使其退回到未通知狀态。若事件是手動重置型,事件進入已通知狀态會讓所有正在等待的線程得到通知,然後事件進入未通知狀态。若事件是自動重置型,事件進入已通知狀态時隻會讓所有等待的線程之一得到通知。若沒有線程在等待,則調用ResetEvent什麼也不幹。

執行個體---工作者線程

       本文所帶的例子中,作者将展示如何建立工作者線程以及如何合理地銷毀它們。作者定義了一個被所有線程使用的控制函數。當點選視圖區域時,就建立一個線程。所有被建立的線程使用上述控制函數在視圖客戶區繪制一個運動的圓形。這裡作者使用了一個手動重置型事件,它被用來通知所有工作線程其“死訊”。除此以外,我們将看到如何使得主線程等待直到所有工作者線程銷毀掉。

作者将線程函數定義為全局的:

struct THREADINFO

    HWND hWnd;//主視圖區

    POINT point;//起始點

};

UINT ThreadDraw(PVOID pParam);

extern CEvent g_eventEnd;

UINT ThreadDraw(PVOID pParam)

    static int snCount = 0;//線程計數器

    snCount ++;//計數器遞增

    TRACE(TEXT("- ThreadDraw %d: started\n"), snCount);

    //取出傳入的參數

    THREADINFO *pInfo = reinterpret_cast<THREADINFO *> (pParam);

    CWnd *pWnd = CWnd::FromHandle(pInfo->hWnd);//主視圖區

    CClientDC dc(pWnd);

    int x = pInfo->point.x;

    int y = pInfo->point.y;

    srand((UINT)time(NULL));

    CRect rectEllipse(x - 25, y - 25, x + 25, y + 25);

    CSize sizeOffset(1, 1);

    //刷子顔色随機

    CBrush brush(RGB(rand()% 256, rand()% 256, rand()% 256));

    CBrush *pOld = dc.SelectObject(&brush);

    while (WAIT_TIMEOUT == ::WaitForSingleObject(g_eventEnd, 0))

    {//隻要主線程還未通知我自殺,繼續工作!(注意時間設定為)

        CRect rectClient;

        pWnd->GetClientRect(rectClient);

        if (rectEllipse.left < rectClient.left || rectEllipse.right > rectClient.right)

            sizeOffset.cx *= -1;

        if (rectEllipse.top < rectClient.top || rectEllipse.bottom > rectClient.bottom)

            sizeOffset.cy *= -1;

        dc.FillRect(rectEllipse, CBrush::FromHandle((HBRUSH)GetStockObject(WHITE_BRUSH)));

        rectEllipse.OffsetRect(sizeOffset);

        dc.Ellipse(rectEllipse);

        Sleep(25);//休眠下,給其他繪制子線程運作的機會

    }

    dc.SelectObject(pOld);    

    delete pInfo;//删除參數,防止記憶體洩露

    TRACE(TEXT("- ThreadDraw %d: exiting.\n"), snCount --);//歸還計數器

        注意作者傳入的是一個安全句柄,而不是一個CWnd指針,并且線上程函數中通過傳入的句柄建立一個臨時的C++對象并使用。這樣就避免了在多線程程式設計中多個對象引用單個C++對象的危險。

    CArray<CWinThread *, CWinThread *> m_ThreadArray;//儲存CWinThread對象指針

// manual-reset event

CEvent g_eventEnd(FALSE, TRUE);

void CWorkerThreadsView::OnLButtonDown(UINT nFlags, CPoint point)

    THREADINFO *pInfo = new THREADINFO;//線程參數

    pInfo->hWnd = GetSafeHwnd();//視圖視窗

    pInfo->point = point;//目前點

    //将界面作為參數傳入線程中,就可以線上程中自己更新主界面,而不用去通知主線程更新界面

    CWinThread *pThread = AfxBeginThread(ThreadDraw, (PVOID) pInfo, THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED);//建立線程,初始狀态為挂起

    pThread->m_bAutoDelete = FALSE;//線程執行完畢後不自動銷毀

    pThread->ResumeThread();//線程開始執行

    m_ThreadArray.Add(pThread);//儲存建立的線程

    為了合理地銷毀所有線程,首先使得事件進入已通知狀态,這會通知工作線程“死期已至”,然後調用WaitForSingleObject讓主線程等待所有的工作者線程完全銷毀掉。注意每次疊代時調用WaitForSingleObject會導緻從使用者模式進入核心模式。例如,10此疊代會浪費掉大約10000次時鐘周期。為了避免這個問題,我們可以使用WaitForMultipleObjects。這就是第二種方法。

void CWorkerThreadsView::OnDestroy()

    g_eventEnd.SetEvent();

/*    // 第一種方式

    for (int j = 0; j < m_ThreadArray.GetSize(); j ++)

    {

        ::WaitForSingleObject(m_ThreadArray[j]->m_hThread, INFINITE);

        delete m_ThreadArray[j];

*/

    //第二種方式

    int nSize = m_ThreadArray.GetSize();

    HANDLE *p = new HANDLE[nSize];

    for (int j = 0; j < nSize; j ++)

        p[j] = m_ThreadArray[j]->m_hThread;

    ::WaitForMultipleObjects(nSize, p, TRUE, INFINITE);

    delete [] p;

    TRACE("- CWorkerThreadsView::OnDestroy: finished!\n");

 關鍵區域

      和其他同步對象不同,除非有需要以外,關鍵區域工作在使用者模式下。若一個線程想運作一個封裝在關鍵區域中的代碼,它首先做一個旋轉封鎖,然後等待特定的時間,它進入核心模式去等待關鍵區域。實際上,關鍵區域持有一個旋轉計數器和一個信号量,前者用于使用者模式的等待,後者用于核心模式的等待(休眠态)。在Win32API中,有一個CRITICAL_SECTION結構體表示關鍵區域對象。在MFC中,有一個類CCriticalSection。關鍵區域是這樣一段代碼,當它被一個線程執行時,必須確定不會被另一個線程中斷。

一個簡單的例子是多個線程共用一個全局變量:

int g_nVariable = 0;

UINT Thread_First(LPVOID pParam)

    if (g_nVariable < 100)

UINT Thread_Second(LPVOID pParam)

    g_nVariable += 50;

      這段代碼不是線程安全的,因為沒有線程對變量g_nVariable是獨占使用的。為了解決這個問題,可以如下使用:

CCriticalSection g_cs;

    g_cs.Lock();

    g_cs.Unlock();

    g_nVariable += 20;

    這裡使用了CCriticalSection類的兩個方法,調用Lock函數通知系統下面代碼的執行不能被中斷,直到相同的線程調用Unlock方法。系統會首先檢查被系統關鍵區域封鎖的代碼是否被另一個線程捕獲。若是,則線程等待直到捕獲線程釋放掉關鍵區域。

      若有多個共享資源需要保護,則最好為每個資源使用一個單獨的關鍵區域。記得要配對使用UnLock和Lock。還有一點是需要防止“死鎖”。

class CSomeClass

    CCriticalSection m_cs;

    int m_nData1;

    int m_nData2;

public:

    void SetData(int nData1, int nData2)

        m_cs.Lock();

        m_nData1 = Function(nData1);

        m_nData2 = Function(nData2);

        m_cs.Unlock();

    int GetResult()

        int nResult = Function(m_nData1, m_nData2);

        return nResult;

互斥鎖

      和關鍵區域類似,互斥鎖設計為對同步通路共享資源進行保護。互斥鎖在核心中實作,是以需要進入核心模式操縱它們。互斥鎖不僅能在不同線程之間,也可以在不同程序之間程序同步。要跨程序使用,則互斥鎖應該是有名的。MFC中使用CMutex類來操縱互斥鎖。可以如下方式使用:

CSingleLock singleLock(&m_Mutex);

singleLock.Lock();  // try to capture the shared resource

if (singleLock.IsLocked())  // we did it

    // use the shared resource 

    // After we done, let other threads use the resource

    singleLock.Unlock();

或者通過Win32函數:

// try to capture the shared resource

::WaitForSingleObject(m_Mutex, INFINITE);

// use the shared resource 

// After we done, let other threads use the resource

::ReleaseMutex(m_Mutex);

        我們可以使用互斥鎖來限制應用程式的運作執行個體為一個。可以将如下代碼放置到InitInstance函數(或WinMain)中:

HANDLE h = CreateMutex(NULL, FALSE, "MutexUniqueName");

if (GetLastError() == ERROR_ALREADY_EXISTS)

{//互斥鎖已經存在

    AfxMessageBox("An instance is already running.");

    return(0);

信号量

      為了限制使用共享資源的線程數目,我們應該使用信号量。信号量是一個核心對象。它存儲了一個計數器變量來跟蹤使用共享資源的線程數目。例如,下面代碼使用CSemaphore類建立了一個信号量對象,它確定在給定的時間間隔内(由構造函數第一個參數指定)最多隻有5個線程能使用共享資源。還假定初始時沒有線程獲得資源:

CSemaphore g_Sem(5, 5);

一旦線程通路共享資源,信号量的計數器就減1.若變為0,則接下來對資源的通路會被拒絕,直到有一個持有資源的線程離開(也就是說釋放了信号量)。我們可以如下使用:

// Try to use the shared resource

::WaitForSingleObject(g_Sem, INFINITE);

// Now the user's counter of the semaphore has decremented by one

// Use the shared resource 

::ReleaseSemaphore(g_Sem, 1, NULL);

// Now the user's counter of the semaphore has incremented by one

 主從線程之間的通信

      若主線程想通知從線程一些動作的發生,使用事件對象是很友善的。但反過來卻是低效,不友善的。因為這會讓主線程停下來等待事件,進而降低了應用程式的響應速度。作者提出的方法是讓從線程發自定義消息給父線程。

    #define WM_MYMSG WM_USER + 1

    這隻能保證視窗類中唯一,但為了確定整個應用程式中唯一,更為安全的方式是:

#define WM_MYMSG WM_APP + 1

afx_msg LRESULT OnMyMessage(WPARAM , LPARAM );

LRESULT CMyWnd::OnMyMessage(WPARAM wParam, LPARAM lParam)

    // A notification got

    // Do something 

BEGIN_MESSAGE_MAP(CMyWnd, CWnd)

    ON_MESSAGE(WM_MYMSG, OnMyMessage)

END_MESSAGE_MAP()

UINT ThreadProc(LPVOID pParam)

    HWND hWnd = (HWND) pParam;

    // notify the primary thread's window

    ::PostMessage(hWnd, WM_MYMSG, 0, 0);

    但這個方法有個很大的缺陷--記憶體洩露,作者沒有深入研究,可以參考我這篇文章《淺談一個線程通信代碼的記憶體洩露及解決方案 》

本文轉自Phinecos(洞庭散人)部落格園部落格,原文連結:http://www.cnblogs.com/phinecos/archive/2008/06/27/1231223.html,如需轉載請自行聯系原作者

繼續閱讀