MFC界面包装类(多线程时成员函数调用的断言失败)

类别:VC语言 点击:0 评论:0 推荐:
 

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 );
}


本文地址:http://com.8s8s.com/it/it200.htm