預留內存攜帶附加信息的設計
有時候,將數據與一個對象的實例關聯起來是很有幫助的。這種設計要求預留一定的內存,一倍特定附加數據的存儲。
通過調用SetWindowWord或SetWindowLong函數將數據與一個指定的窗口關聯起來,數據保存在窗口附加內存塊中。窗口內存塊即是一種窗口對象(HWND)的附加數據(window extra bytes),參考WNDCLASS.cbWndExtra字段(Specifies the number of extra bytes to allocate following the window instance.)。
這種預留附加的設計,在MFC中處處可見。對於下拉選擇列表(CComboBox)、下拉列表框、列表視圖和樹控件,我們不光希望其能顯示條目內容(item text),還希望每個條目能夠攜帶附加信息,即存儲額外的關聯數據(item data),以備不時之需。這四個控件都提供了SetItemData/GetItemData接口,供用戶儲存關聯數據。存儲的數據爲DWORD值類型,可以是簡單的數值,也可以存儲指針。
線程消息隊列和_ptiddata
我們在編寫第一個SDK窗口程序時,就接觸到了消息這一重要概念。實際上,消息隊列是一種線程私有數據,每一個Windows程序的UI(CUI/GUI)線程都維持了一個消息隊列。GetMessage、TranslateMessage、DispatchMessage等對消息的操作都是與調用線程的消息隊列息息相關。PostThreadMessage是線程消息投遞函數,它向一個指定ID(idThread)的線程發送一條消息,然後不等處理立即返回。這個API在多線程架構程序中非常有用。PostQuitMessage是結束線程運行,相當於nExitCode作爲WM_QUIT消息參數調用PostThreadMessage。調用線程收到該消息後即ExitThread,故該函數一般用來響應WM_DESTROY消息。
儘管秉持封裝的原則,我們極力強調避免使用全局變量,但全局變量對於進程級和線程級的系統統籌管理卻是非常有用。除了消息隊列這種系統內置的線程私有數據外,Windows提供了線程局部存儲系統(TLS,Thread Local Storage),爲用戶提供了存儲與線程關聯數據的接口。前面提到的_beginthreadex中分配的_ptiddata(pointer to per-thread data),即使用了TLS。_ptiddata爲Windows平臺的多線程程序中,strtok、strerror(errno)等依賴全局變量或靜態變量的CRT函數的實現提供了有效的解決方案。
Win32線程局部存儲系統
用於管理 TLS 的數據結構是很簡單的,Windows僅爲系統中的每一個進程維護一個位數組,再爲該進程中的每一個線程申請一個同樣長度的數組空間,如下圖所示。
在Windbg中,可以窺探TEB中的TLS數據結構。
lkd> dt _teb
nt!_TEB
+0x02c ThreadLocalStoragePointer : Ptr32 Void
+0xe10 TlsSlots : [64] Ptr32 Void
+0xf10 TlsLinks : _LIST_ENTRY
+0xf94 TlsExpansionSlots : Ptr32 Ptr32 Void
typedef struct _TEB // 66 elements, 0xFB8 bytes (sizeof)
{
// ……
/*0x02C*/ VOID* ThreadLocalStoragePointer;
// ……
/*0xE10*/ VOID* TlsSlots[64];
/*0xF10*/ struct _LIST_ENTRY TlsLinks; // 2 elements, 0x8 bytes (sizeof)
// ……
/*0xF94*/ VOID** TlsExpansionSlots;
// ……
}TEB, *PTEB;
當一個線程被創建時,Windows就會在進程地址空間中爲該線程分配一個長度爲TLS_MINIMUM_AVAILABLE的數組,數組成員的值都被初始化爲 0。在內部,系統將此數組與該線程關聯起來,保證只能在該線程中訪問此數組中的數據。如上圖所示,每個線程都有它自己的數組,數組成員可以存儲任何數據。
運行在系統中的每一個進程都有上圖所示的一個位數組。位數組的成員是一個標誌,每個標誌的值被設爲FREE或INUSE,指示了此標誌對應的數組索引是否在使用中。Windows 保證至少有TLS_MINIMUM_AVAILABLE(定義在WinNT.h文件中)個標誌位可用。
動態使用TLS典型步驟如下。
(1)主線程調用TlsAlloc函數爲線程局部存儲分配索引,函數原型如下。
DWORD TlsAlloc(VOID);
TlsAlloc爲我們預訂了一個索引。如果TlsAlloc返回的索引爲3,那等於說索引3已經被我們預訂了,無論是進程中當前正在運行的線程,還是今後可能會創建的線程,都不能再使用索引3。
(2)每個線程調用TlsSetValue和TlsGetValue設置或讀取線程數組中的值,這兩個函數的原型如下。
BOOL TlsSetValue(
DWORD dwTlsIndex, // TLS index
LPVOID lpTlsValue // value to store
);
LPVOID TlsGetValue(
DWORD dwTlsIndex // TLS index
);
(3)主線程調用TlsFree釋放局部存儲索引。函數的惟一參數是TlsAlloc返回的索引。
BOOL TlsFree(
DWORD dwTlsIndex // TLS index
);
MFC中的線程局部存儲
如果你需要大量的數據貫穿一個線程,普通的TLS索引一個值就會變得不實用,Windows的TLS只允許用戶保存一個32位的指針。如果需要用戶保存任意類型的數據(包含整個類)。這個任意大小的數據所佔的內存通常是在進程的堆中分配,所以當用戶釋放全局索引時,系統必須將每個線程內此數據佔用的內存釋放掉,這就要求系統把爲各線程分配的內存都記錄下來。較好的方法是將各個私有數據的首地址用一個鏈表連在一起,釋放全局索引時只要遍歷此鏈表,就可以逐個釋放線程私有數據佔用的空間了。
例如,有下面一個存放線程私有數據的數據結構。
struct CThreadData
{
CThreadData* pNext; // 指向下一個線程的CThreadData結構的指針
LPVOID pData; // 指向真正的線程私有數據的指針
};
指針 pData指向爲線程分配的內存的首地址,指針pNext將各線程的數據連在了一起。這實際上是一種二級指針的分槽存儲。MFC的線程局部存儲類CThreadLocal即實現了二級指針的分槽存儲。
MFC框架的狀態信息也是理解的難點,包括模塊狀態AFX_MODULE_STATE、線程狀態_AFX_THREAD_STATE和模塊線程狀態AFX_MODULE_THREAD_STATE。這些線程級別的全局狀態維持即使用了線程局部存儲(TLS)。參考李久進著作的《MFC深入淺出》第九章《MFC的狀態》。
由於MFC廣泛地應用了線程局部存儲,故在MFC下,使用線程必須格外小心。許多MFC對象僅在創建它們的線程內運作。一般地,具有句柄映射的任何對象都不能從其他線程訪問該對象。例如,模塊線程狀態AFX_MODULE_THREAD_STATE中的CHandleMap* m_pmapHWND映射記錄了MFC線程中創建的CWnd對象實例與內核窗口句柄(HWND)之間的映射消息。內核窗口句柄是可以進程訪問級別,因此可跨線程訪問。但是試圖傳遞CWnd對象實例以期跨線程操作,往往失敗。因爲另一個引用線程並未像創建線程那樣維繫一個映射,所以當需要CWndàHWND以執行API操作時,往往找不到其所指窗口。
針對以上問題,通常優先傳送句柄,避免在線程之間傳送MFC對象。在引用線程中將其轉換爲臨時MFC對象。例如,假設線程 A創建一個CWnd對象。線程A並不將對象傳送給線程B,而將該對象的m_hWnd成員傳送給線程B。於是,線程B可以調用CWnd::FromHandle,以創建一個臨時的CWnd對象。如果線程B需要更持久的連接,就可以使用Attach方法,在窗口及其CWnd對象之間建立持久的關聯。
另外的一個常見問題是MFC對象訪存的線程安全性問題。MFC對象不會自動在不同的線程之間做出判斷。所以,如果兩個線程試圖同時訪問同一個CString類的對象,結果可能受到嚴重破壞。只有防止來自有衝突的MFC對象的線程。通常,這將需要使用前面提到的同步機制,以保證多線程數據交換的一致性。