MFC多線程編程注意事項

MFC多線程編程注意事項

PeterLee整理 2008-05-26


關於啓動線程時傳輸窗口對象(指針?句柄?)的問題:  

 在選擇菜單中的開始線程後:  
 void   cmainframe::onmenu_start()  
 {  
       ...  
       afxbeginthread(mythread,   this);  
       ...  
 }  

 線程函數如下:  
 uint   mythread(lpvoid   pparam)  
 {  
       cmainframe*   pmainfrm   =   (cmainframe   *)pparam;  
       ...  
 }  

 問題一:  
       這樣的代碼是不是有問題?  
       (文檔中說線程間不能直接傳輸mfc對象的指針,應該通過傳輸句柄實現)  

 問題二:  
       這樣使用開始好像沒有問題,直接通過pmainfrm訪問窗口中的view都正常。  
       但發現訪問狀態條時:  
             pmainfrm->m_wndstatusbar.setpanetext(2,   "test);  
       出現debug   assertion   failed!(在窗口線程中沒有問題)  
       位置是wincore.cpp中的  
             assert((p   =   pmap->lookuppermanent(m_hwnd))   !=   null   ||  
             (p   =   pmap->lookuptemporary(m_hwnd))   !=   null);  
       爲什麼訪問view能正常,但訪問狀態條時不可以呢?  

 問題三:  
       如果通過傳輸句柄實現,怎樣做呢?  
       我用下面的代碼執行時有問題:  
       void   cmainframe::onmenu_start()  
       {  
             ...  
             hwnd   hwnd   =   getsafehwnd();  
             afxbeginthread(mythread,   hwnd);  
             ...  
       }  

       uint   mythread(lpvoid   pparam)  
       {  
             cmainframe*   pmainfrm   =   (cmainframe   *)(cwnd::fromhandle((hwnd)pparam));  
             ...  
       }  
       執行時通過線程中得到pmainfrm,訪問其成員時不正常。
網友:hewwatt
大致原因解釋如下:
1.   mfc的大多數類不是線程安全的,cwnd及其消息路由是其中之最
2.   mfc界面類的大多數方法,最後都是通過sendmessage實現的,而消息處理的
過程中會引發其他消息的發送及處理。如果消息處理函數本身不是線程安全的
你從工作線程中調用這些方法遲早會同你界面線程的用戶消息響應發生衝突
3.   cxxxx::fromhandle會根據調用者所在線程查表,如果查不到用戶創建的cxxxx  
對應對象,它會創建一個臨時對象出來。由於你在工作線程中調用該方法,當然
不可能查到界面主線程中你所建立起來的那個對象了。這時mfc會你創建一個臨時
對象並返回給你,你根本不可能期望它的成員變量會是有意義的。所以要用
也只能用cwnd::fromhandle,因爲它只包含一個m_hwnd成員。不過,要記住

跨線程直接或間接地調用::sendmessage,通常都是行爲不可預測的。


MFC界面包裝類(多線程時成員函數調用的斷言失敗)
日期:2006-9-17 18:06:00     [Host01.Com]
MFC界面包裝類
——多線程時成員函數調用的斷言失敗
經常在論壇上看到如下的問題:
DWORD WINAPI ThreadProc( void *pData )
// 線程函數(比如用於從COM口獲取數據)
{
// 數據獲取循環
// 數據獲得後放在變量i中
CAbcDialog *pDialog = reinterpret_cast< CAbcDialog* >( pData );
ASSERT( pDialog ); // 此處如果ASSERT_VALID( pDialog )將斷言失敗
pDialog->m_Data = i;
pDialog->UpdateData( FALSE ); // UpdateData內部ASSERT_VALID( this )斷言失敗

}
BOOL CAbcDialog::OnInitDialog()
{
CDialog::OnInitDialog();
// 其他初始化代碼
CreateThread( NULL, 0, ThreadProc, this, 0, NULL ); // 創建線程
return TRUE;
}
注意上面註釋中的兩處斷言失敗,本文從MFC底層的實現來解釋爲什麼會斷言失敗,並說明MFC爲什麼要這樣實現及相應的處理辦法。
在說明MFC界面包裝類的底層實現之前,由於其和窗口有關,故先講解窗口類這個基礎知識以爲後面做鋪墊。

窗口類
窗口類是一個結構,其一個實例代表着一個窗口類型,與C++中的類的概念非常相近(雖然其表現形式完全不同,C++的類只不過是內存佈局和其上的操作這個概念的類型),故被稱作爲窗口類。
窗 口是具有設備操作能力的邏輯概念,即一種能操作設備(通常是顯示器)的東西。由於窗口是窗口類的實例,就象C++中的一個類的實例,是可以具有成員函數的 (雖然表現形式不同),但一定要明確窗口的目的——操作設備(這點也可以從Microsoft針對窗口所制訂的API的功能看出,主要出於對設備操作的方 便)。因此不應因爲其具有成員函數的功能而將窗口用於功能對象的創建,這雖然不錯,但是嚴重違反了語義的需要(關於語義,可參考我的另一篇文章——《語義 的需要》),是不提倡的,但卻由於MFC界面包裝類的加入導致大多數程序員經常將邏輯混入界面。
窗口類是個結構,其中的大部分成員都沒什麼重要意義,只是Microsoft一相情願制訂的,如果不想使用界面API(Windows User Interface API),可以不管那些成員。其中只有一個成員是重要的——lpfnWndProc,消息處理函數。
外 界(使用窗口的代碼)只能通過消息操作窗口,這就如同C++中編寫的具有良好的面向對象風格的類的實例只能通過其公共成員函數對其進行操作。因此消息處理 函數就代表了一個窗口的一切(忽略窗口類中其他成員的作用)。很容易發現,窗口這個實例只具有成員函數(消息處理函數),不具有成員變量,即沒有一塊特定 內存和一特定的窗口相關聯,則窗口將不能具有狀態(Windows還是提供了Window Properties API來緩和這種狀況)。這也正是上面問題發生的根源。
爲 了處理窗口不能具有狀態的問題(這其實正是Windows靈活的表現),可以有很多種方法,而MFC出於能夠很容易的對已有窗口類進行擴展,選擇了使用一 個映射將一個窗口句柄(窗口的唯一標示符)和一個內存塊進行綁定,而這塊內存塊就是我們熟知的MFC界面包裝類(從CWnd開始派生延續)的實例。

MFC狀態
狀 態就是實例通過某種手段使得信息可以跨時間段重現,C++的類的實例就是由外界通過公共成員函數改變實例的成員變量的值以實現具有狀態的效果。在MFC 中,具有三種狀態:模塊狀態、進程狀態、線程狀態。分別爲模塊、進程和線程這三種實例的狀態。由於代碼是由線程運行,且和另外兩個的關係也很密切,因此也 被稱作本地數據。
模塊本地數據
具 有模塊本地性的變量。模塊指一個加載到進程虛擬內存空間中的PE文件,即exe文件本身和其加載的dll文件。而模塊本地性即同樣的指針,根據代碼從不同 的模塊執行而訪問不同的內存空間。這其實只用每個模塊都聲明一個全局變量,而前面的“代碼”就在MFC庫文件中,然後通過一個切換的過程(將欲使用的模塊 的那個全局變量的地址賦給前述的指針)即可實現模塊本地性。MFC中,這個過程是通過調用AfxSetModuleState來切換的,而通常都使用 AFX_MANAGE_STATE這個宏來處理,因此下面常見的語句就是用於模塊狀態的切換的:
AFX_MANAGE_STATE( AfxGetStaticModuleState() );
MFC中定義了一個結構(AFX_MODULE_STATE),其實例具有模塊本地性,記錄了此模塊的全局應用程序對象指針、資源句柄等模塊級的全局變量。其中有一個成員變量是線程本地數據,類型爲AFX_MODULE_THREAD_STATE,其就是本文問題的關鍵。
進程本地數據
具 有進程本地性的變量。與模塊本地性相同,即同一個指針,在不同進程中指向不同的內存空間。這一點Windows本身的虛擬內存空間這個機制已經實現了,不 過在dll中定義的全局變量,如果dll支持Win32s,則其是共享其全局變量的,即不同的進程加載了同一dll將訪問同一內存。Win32s是爲了那 些基於Win32的應用程序能在Windows 3.1上運行,由於Windows 3.1是16位操作系統,早已被淘汰,而現行的dll模型其本身就已經實現了進程本地性(不過還是可以通過共享節來實現Win32s中的dll的效果), 因此進程狀態其實就是一全局變量。
MFC中作爲本地數據的結構有很多,如_AFX_WIN_STATE、_AFX_DEBUG_STATE、_AFX_DB_STATE等,都是MFC內部自己使用的具有進程本地性的全局變量。
線程本地數據
具有線程本地性的變量。如上,即同一個指針,不同的線程將會訪問不同的內存空間。這點MFC是通過線程本地存儲(TLS——Thread Local Storage,其使用方法由於與本文無關,在此不表)實現的。
MFC中定義了一個結構(_AFX_THREAD_STATE)以記錄某些線程級的全局變量,如最近一次的模塊狀態指針,最近一次的消息等。
模塊線程狀態
MFC中 定義的一個結構(AFX_MODULE_THREAD_STATE),其實例即具有線程本地性又具有模塊本地性。也就是說不同的線程從同一模塊中和同一線 程從不同模塊中訪問MFC庫函數都將導致操作不同的內存空間。其應用在AFX_MODULE_STATE中,記錄一些線程相關但又模塊級的數據,如本文的 重點——窗口句柄映射。

包裝類對象和句柄映射
句 柄映射——CHandleMap,MFC提供的一個底層輔助類,程序員是不應該直接使用它的。其有兩個重要的成員變量:CMapPtrToPtr m_permanentMap, m_temporaryMap;。分別記錄永久句柄綁定和臨時句柄綁定。前面說過,MFC使用一個映射將窗口句柄和其包裝類的實例綁定在一 起,m_permanentMap和m_temporaryMap就是這個映射,分別映射永久包裝類對象和臨時包裝類對象,而在前面提到過的 AFX_MODULE_THREAD_STATE中就有一個成員變量:CHandleMap* m_pmapHWND;(之所以是CHandleMap*是使用懶惰編程法,儘量節約資源)以專門完成HWND的綁定映射,除此以外還有如 m_pmapHDC、m_pmapHMENU等成員變量以分別實現HDC、HMENU的綁頂映射。而爲什麼這些映射要放在模塊線程狀態而不放在線程狀態或 模塊狀態是很明顯的——這些包裝類包裝的句柄都是和線程相關的(如HWND只有創建它的線程才能接收其消息)且這個模塊中的包裝類對象可能不同於另一個模 塊的(如包裝類是某個DLL中專門派生的一個類,如a.dll中定義的CAButton的實例和b.dll中定義的CBButton的實例如果同時在一個 線程中。此時線程卸載了a.dll,然後CAButton的實例得到消息並進行處理,將發生嚴重錯誤——類代碼已經被卸載掉了)。
包裝類存在的意義有二:包裝對HWND的操作以加速代碼的編寫和提供窗口子類化(不是超類化)的效果以派生窗口類。包裝類對象針對線程分爲兩種:永久包裝類對象(以後簡稱永久對象)和臨時包裝類對象(以後簡稱臨時對象)。臨時對象的意義僅僅只有包裝對HWND的操作以加速代碼編寫,不具有派生窗口類的功能。永久對象則具有前面說的包裝類的兩個意義。
在創建窗口時(即CWnd::CreateEx中),MFC通過鉤子提前(WM_CREATEWM_NCCREATE之前)處理了通知,用AfxWndProc子類化了創建的窗口並將對應的CWnd*加入當前線程的永久對象的映射中,而在AfxWndProc中,總是由CWnd::FromHandlePermanent(獲得對應HWND的永久對象)得到當前線程中當前消息所屬窗口句柄對應的永久對象,然後通過調用得到的CWnd*WindowProc成員函數來處理消息以實現派生窗口類的效果。這也就是說永久對象具有窗口子類化的意義,而不僅僅是封裝HWND的操作。
要將一個HWND和一個已有的包裝類對象相關聯,調用CWnd::Attach將此包裝類對象和HWND映射成永久對象(但這種方法得到的永久對象不一定具有子類化功能,很可能仍和臨時對象一樣,僅僅起封裝的目的)。如果想得到臨時對象,則通過CWnd::FromHandle這個靜態成員函數以獲得。臨時對象之所以叫臨時,就是其是由MFC內部(CHandleMap::FromHandle)生成,其內部(CHandleMap::DeleteTemp)銷燬(一般通過CWinThread::OnIdle中調用AfxUnlockTempMaps)。因此程序員是永遠不應該試圖銷燬臨時對象的(即使臨時對象所屬線程沒有消息循環,不能調用CwinThread::OnIdle,在線程結束時,CHandleMap的析構仍然會銷燬臨時對象)。

原因
爲什麼要分兩種包裝類對象?很好玩嗎?注意前面提過的窗口模型——只能通過消息機制和窗口交互。注意,也就是說窗口是線程安全的實例。窗口過程的編寫中不用考慮會有多個線程同時訪問窗口的狀態。如果不使用兩種包裝類對象,在窗口創建的鉤子中通過調用SetProp將創建的窗口句柄和對應的CWnd*綁定,不一樣也可以實現前面說的窗口句柄和內存塊的綁定?
   CWnd
的派生類CA,具有一個成員變量m_BGColor以決定使用什麼顏色填充底背景。線程1創建了CA的一個實例a,將其指針傳進線程2,線程2設置a.m_BGColor爲紅色。這已經很明顯了,CA::m_BGColor不是線程安全的,如果不止一個線程2,那麼a.m_BGColor將會出現線程訪問衝突。這嚴重違背窗口是線程安全的這個要求。因爲使用了非消息機制與窗口進行交互,所以失敗。
繼續,如果給CA一個公共成員函數SetBGColor,並在其中使用原子操作以保護m_BGColor,不就一切正常了?呵,在CA::OnPaint中,會兩次使用m_BGColor進行繪圖,如果在兩次繪圖之間另一線程調用CA::SetBGColor改變了CA::m_BGColor,問題嚴重了。也就是說不光是CA::m_BGColor的寫操作需要保護,讀操作亦需要保護,而這僅僅是一個成員變量。
那麼再繼續,完全按照窗口本身的定義,只使用消息與它交互,也就是說自定義一個消息,如AM_SETBGCOLOR,然後在CA::SetBGColorSendMessage這個消息,並在其響應函數中修改CA::m_BGColor。完美了,這是即符合窗口概念又很好的設計,不過它要求每一個程序員編寫每一個包裝類時都必須注意到這點,並且最重要的是,C++類的概念在這個設計中根本沒有發揮作用,嚴重地資源浪費。
因此,MFC決定要發揮C++類的概念的優勢,讓包裝類對象看起來就等同於窗口本身,因此使用了上面的兩種包裝類對象。讓包裝類對象隨線程的不同而不同可以對包裝類對象進行線程保護,也就是說一個線程不可以也不應該訪問另一個線程中的包裝類對象(因爲包裝類對象就相當於窗口,這是MFC的目標,並不是包裝類本身不能被跨線程訪問),不可以就是通過在包裝類成員函數中的斷言宏實現的(在CWnd::AssertValid中),而不應該前面已經解釋地很清楚了。因此本文開頭的斷言失敗的根本原因就是因爲違反了不可以不應該
雖然包裝類對象不能跨線程訪問,但是窗口句柄卻可以跨線程訪問。因爲包裝類對象不僅等同於窗口,還改變了窗口的交互方式(這也正是C++類的概念的應用),使得不用非得使用消息機制才能和窗口交互。注意前面提到的,如果跨線程訪問包裝類對象,而又使用C++類的概念操作它,則其必須進行線程保護,而不能跨線程訪問就消除了這個問題。因此臨時對象的產生就只是如前面所說,方便代碼的編寫而已,不提供子類化的效果,因爲窗口句柄可以跨線程訪問。

解決辦法
已經瞭解失敗的原因,因此做如下修改:
DWORD WINAPI ThreadProc( void *pData )  //
線程函數(比如用於從COM口獲取數據)
{
   //
數據獲取循環
   //
數據獲得後放在變量i
   CAbcDialog *pDialog = static_cast< CAbcDialog* >(
                             CWnd::FromHandle( reinterpret_cast< HWND >( pData ) ) );
   ASSERT_VALID( pDialog );  //
此處可能斷言失敗
   pDialog->m_Data = i;      //
這是不好的設計,詳情可參看我的另一篇文章:《語義的需要》
   pDialog->UpdateData( FALSE );  // UpdateData
內部ASSERT_VALID( this )可能斷言失敗
   …
}
BOOL CAbcDialog::OnInitDialog()
{
   CDialog::OnInitDialog();
   //
其他初始化代碼
   CreateThread( NULL, 0, ThreadProc, m_hWnd, 0, NULL );  //
創建線程
   return TRUE;
}
之所以是可能,因爲這裏有個重點就是臨時對象是HWND操作的封裝,不是窗口類的封裝。因此所有的HWND臨時對象都是CWnd的實例,即使上面強行轉換爲CAbcDialog*也依舊是CWnd*,所以在ASSERT_VALID裏調用CAbcDialog::AssertValid時,其定義了一些附加檢查,則可能發現這是一個CWnd的實例而非一個CAbcDialog實例,導致斷言失敗。因此應將CAbcDialog全部換成CWnd,這下雖然不斷言失敗了,但依舊錯誤(先不提pDialog->m_Data怎麼辦),因爲臨時對象是HWND操作的封裝,而不幸的是UpdateData只是MFC自己提供的一個對話框數據交換的機制(DDX)的操作,其不是通過向HWND發送消息來實現的,而是通過虛函數機制。因此在UpdateData中調用實例的DoDataExchange將不能調用CAbcDialog::DoDataExchange,而是調用CWnd::DoDataExchange,因此將不發生任何事。
因此合理(並不一定最好)的解決方法是向CAbcDialog的實例發送一個消息,而通過一箇中間變量(如一全局變量)來傳遞數據,而不是使用CAbcDialog::m_Data。當然,如果數據少,比如本例,就應該將數據作爲消息參數進行傳遞,減少代碼的複雜性;數據多則應該通過全局變量傳遞,減少了緩衝的管理費用。修改後如下:
#define AM_DATANOTIFY ( WM_USER + 1 )
static DWORD g_Data = 0;
DWORD WINAPI ThreadProc( void *pData )  //
線程函數(比如用於從COM口獲取數據)
{
   //
數據獲取循環
   //
數據獲得後放在變量i
   g_Data = i;
   CWnd *pWnd = CWnd::FromHandle( reinterpret_cast< HWND >( pData ) );
   ASSERT_VALID( pWnd );  //
本例應該直接調用平臺SendMessage而不調用包裝類的,這裏只是演示
   pWnd->SendMessage( AM_DATANOTIFY, 0, 0 );
   …
}
BEGIN_MESSAGE_MAP( CAbcDialog, CDialog )
   …
   ON_MESSAGE( AM_DATANOTIFY, OnDataNotify )
   …
END_MESSAGE_MAP()
BOOL CAbcDialog::OnInitDialog()
{
   CDialog::OnInitDialog();
   //
其他初始化代碼
   CreateThread( NULL, 0, ThreadProc, m_hWnd, 0, NULL );  //
創建線程
   return TRUE;
}
LRESULT CAbcDialog::OnDataNotify( WPARAM /* wParam */, LPARAM /* lParam */ )
{
   UpdateData( FALSE );
   return 0;
}
void CAbcDialog::DoDataExchange( CDataExchange *pDX )
{
   CDialog::DoDataExchange( pDX );
   DDX_Text( pDX, IDC_EDIT1, g_Data );
}
線程安全是一個什麼概念?
以前常聽高手告誡MFC對象不要跨線程使用,因爲MFC不是線程安全的。比如CWnd對象不要跨線程使用,可以用窗口句柄(HWND)代替。CSocket/CAsyncSocket對象不要跨線程使用,SOCKET句柄代替.那麼到底什麼是線程安全呢?什麼時候需要考慮?如 果程序涉及到多線程的話,就應該考慮線程安全問題。比如說設計的接口,將來需要在多線程環境中使用,或者需要跨線程使用某個對象時,這個就必須考慮了。關 於線程安全也沒什麼權威定義。在這裏我只說說我的理解:所提供的接口對於線程來說是原子操作或者多個線程之間的切換不會導致該接口的執行結果存在二義性,也就是說我們不用考慮同步的問題。
一般而言線程安全由多線程對共享資源的訪問引起。如果調用某個接口時需要我們自己採取同步措施來保護該接口訪問的共享資源,則這樣的接口不是線程安全的.MFCSTL都不是線程安全的. 怎樣才能設計出線程安全的類或者接口呢?如果接口中訪問的數據都屬於私有數據,那麼這樣的接口是線程安全的.或者幾個接口對共享數據都是隻讀操作,那麼這樣的接口也是線程安全的.如果多個接口之間有共享數據,而且有讀有寫的話,如果設計者自己採取了同步措施,調用者不需要考慮數據同步問題,則這樣的接口是線程安全的,否則不是線程安全的。
多線程的程序設計應該注意些什麼呢
1、儘量少的使用全局變量、static變量做共享數據,儘量使用參數傳遞對象。被參數傳遞的對象,應該只包括必需的成員變量。所謂必需的成員變量,就是必定會被多線程操作的。很多人圖省事,會把this指針(可能是任意一個對象指針)當作線程參數傳遞,致使線程內部有過多的操作權限,對this中的參數任意妄爲。整個程序由一個人完成,可能會非常注意,不會出錯,但只要一轉手,程序就會面目全非。當兩個線程同時操作一個成員變量的時候,程序就開始崩潰了,更糟的是,這種錯誤很難被重現。(我就在鬱悶這個問題,我們是幾個人,把程序編成debug版,經過數天使用,才找到錯誤。而找到錯誤只是開始,因爲你要證明這個bug被修改成功了,也非常困難。)其實,線程間數據交互大多是單向的,在線程回調函數入口處,儘可能的將傳入的數據備份到局部變量中(當然,用於線程間通訊的變量不能這麼處理),以後只對局部變量做處理,可以很好的解決這種問題。
2、在MFC中請慎用線程。因爲MFC的框架假定你的消息處理都是在主線程中完成的。首先窗口句柄是屬於線程的,如果擁有窗口句柄的線程退出了,如果另一個線程處理這個窗口句柄,系統就會出現問題。而MFC爲了避免這種情況的發生,使你在子線程中調用消息(窗口)處理函數時,就會不停的出Assert錯誤,煩都煩死你。典型的例子就時CSocket,因爲CSocket是使用了一個隱藏窗口實現了假阻塞,所以不可避免的使用了消息處理函數,如果你在子線程中使用CSocket,你就可能看到assert的彈出了。
3、不要在不同的線程中同時註冊COM組件。兩個線程,一個註冊1.ocx, 2.ocx, 3.ocx, 4.ocx; 而另一個則註冊5.ocx, 6.ocx, 7.ocx, 8.ocx,結果死鎖發生了,分別死在FreeLibrary和DllRegisterServer,因爲這8個ocx是用MFC中做的,也可能是MFC的Bug,但DllRegisterServer卻死在GetModuleFileName裏,而GetModuleFileName則是個API唉!如果有過客看到,恰巧又知道其原因,請不吝賜教。
4、不要把線程搞的那麼複雜。很多初學者,恨不能用上線程相關的所有的函數,這裏互斥,那裏等待,一會兒起線程,一會兒關線程的,比起goto語 句有過之而無不及。好的多線程程序,應該是儘量少的使用線程。這句話怎麼理解吶,就是說盡量統一一塊數據共享區存放數據隊列,工作子線程從隊列中取數據, 處理,再放回數據,這樣纔會模塊化,對象化;而不是每個數據都起一個工作子線程處理,處理完了就關閉,寫的時候雖然直接,等維護起來就累了。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章