2009年7月11日星期六

MFC多线程编程注意事项

转自:http://hi.baidu.com/jumbo/blog/item/fd5d70f0e25130a7a40f52cd.html
1. 表现——错误示例
关于启动线程时传输窗口对象(指针?句柄?)的问题:

在选择菜单中的开始线程后:
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,通常都是行为不可预测的。

2. 原因分析
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_CREATE和WM_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::SetBGColor 中SendMessage这个消息,并在其响应函数中修改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 );
}

3. 注意事项
“线程安全”是一个什么概念?
以前常听高手告诫MFC对象不要跨线程使用,因为MFC不是线程安全的。比如CWnd对象不要跨线程使用,可以用窗口句柄(HWND)代替。 CSocket/CAsyncSocket对象不要跨线程使用,用SOCKET句柄代替.那么到底什么是线程安全呢?什么时候需要考虑?如果程序涉及到多线程的话,就应该考虑线程安全问题。比如说设计的接口,将来需要在多线程环境中使用,或者需要跨线程使用某个对象时,这个就必须考虑了。关于线程安全也没什么权威定义。在这里我只说说我的理解:所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。
一般而言“线程安全”由多线程对共享资源的访问引起。如果调用某个接口时需要我们自己采取同步措施来保护该接口访问的共享资源,则这样的接口不是线程安全的.MFC和STL都不是线程安全的. 怎样才能设计出线程安全的类或者接口呢?如果接口中访问的数据都属于私有数据,那么这样的接口是线程安全的.或者几个接口对共享数据都是只读操作,那么这样的接口也是线程安全的.如果多个接口之间有共享数据,而且有读有写的话,如果设计者自己采取了同步措施,调用者不需要考虑数据同步问题,则这样的接口是线程安全的,否则不是线程安全的。


多线程的程序设计应该注意些什么呢
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语句有过之而无不及。好的多线程程序,应该是尽量少的使用线程。这句话怎么理解呐,就是说尽量统一一块数据共享区存放数据队列,工作子线程从队列中取数据,处理,再放回数据,这样才会模块化,对象化;而不是每个数据都起一个工作子线程处理,处理完了就关闭,写的时候虽然直接,等维护起来就累了。

没有评论: