Share via


TN058:MFC 模块状态实现

注意

以下技术说明在首次包括在联机文档中后未更新。 因此,某些过程和主题可能已过时或不正确。 要获得最新信息,建议你在联机文档索引中搜索热点话题。

本技术说明介绍 MFC“模块状态”结构的实现。 了解模块状态实现对于从 DLL(或 OLE 进程内服务器)使用 MFC 共享 DLL 至关重要。

在阅读本说明之前,请参阅创建新文档、窗口和视图中的“管理 MFC 模块的状态数据”。 本文包含有关此主题的重要用法信息和概述信息。

概述

MFC 状态信息有三种:模块状态、进程状态和线程状态。 有时可以组合这些状态类型。 例如,MFC 的句柄映射既是模块本地映射,也是线程本地映射。 这样,两个不同的模块就可以在其每个线程中使用不同的映射。

进程状态与线程状态相似。 这些数据项在传统上是全局变量,但需要特定于给定的进程或线程,才能获得适当的 Win32s 支持或多线程支持。 给定数据项适合的类别取决于该项及其与进程和线程边界相关的所需语义。

模块状态的独特之处在于它可以包含真正的全局状态或者进程本地或线程本地状态。 此外,它可以快速切换。

模块状态切换

每个线程包含指向“当前”或“活动”模块状态的指针(毫不奇怪,该指针是 MFC 线程本地状态的一部分)。 当执行线程越过模块边界时(例如,应用程序调用 OLE 控件或 DLL,或者 OLE 控件回调应用程序),此指针会更改。

通过调用 AfxSetModuleState 来切换当前模块状态。 在大多数情况下,你永远不会直接处理该 API。 在许多情况下,MFC 会为你调用该 API(在 WinMain、OLE 入口点、AfxWndProc 等处)。 可以在编写的任何组件中通过静态链接一个特殊的 WndProc 和一个知道哪种模块状态应是当前状态的特殊 WinMain(或 DllMain)来完成此操作。 可以通过查看 MFC\SRC 目录中的 DLLMODUL.CPP 或 APPMODUL.CPP 来查看此代码。

设置模块状态后不恢复设置的情况很少见。 大多数情况下,你会将自己的模块状态“推送”为当前状态,然后在完成后“弹回”原始上下文。 此操作由宏 AFX_MANAGE_STATE 和特殊类 AFX_MAINTAIN_STATE 完成。

CCmdTarget 提供特殊的功能用于支持模块状态切换。 具体而言,CCmdTarget 是用于 OLE 自动化和 OLE COM 入口点的根类。 与公开给系统的任何其他入口点一样,这些入口点必须设置正确的模块状态。 给定的 CCmdTarget 如何知道“正确的”模块状态是什么?答案是在构造它时,它会“记住”当前模块状态是什么,因此在以后调用它时,它可以将当前模块状态设置为“记住”的值。 因此,与给定 CCmdTarget 对象关联的模块状态是构造该对象时的当前模块状态。 举一个简单例子:我们要加载 INPROC 服务器、创建对象并调用其方法。

  1. DLL 由 OLE 使用 LoadLibrary 加载。

  2. 首先调用 RawDllMain。 它将模块状态设置为 DLL 的已知静态模块状态。 因此,RawDllMain 静态链接到 DLL。

  3. 调用与对象关联的类工厂的构造函数。 COleObjectFactory 派生自 CCmdTarget,因此它会记住它是在实例化时所处的模块状态。 这一点非常重要 – 要求类工厂创建对象时,它知道要将哪种模块状态设为当前状态。

  4. 调用 DllGetClassObject 以获取类工厂。 MFC 搜索与此模块关联的类工厂列表并返回它。

  5. 调用 COleObjectFactory::XClassFactory2::CreateInstance。 在创建并返回对象之前,此函数将模块状态设置为步骤 3 中的当前模块状态(实例化 COleObjectFactory 时的当前状态)。 此操作在 METHOD_PROLOGUE 内部完成。

  6. 创建对象时,它也是 CCmdTarget 的派生对象,COleObjectFactory 以同样的方式记住了哪种模块状态是活动状态,此新对象也是如此。 现在,每次调用该对象时,它都知道要切换到哪种模块状态。

  7. 客户端针对它从 CoCreateInstance 调用收到的 OLE COM 对象调用一个函数。 调用该对象时,它会使用 METHOD_PROLOGUE 来切换模块状态,就像 COleObjectFactory 一样。

可以看到,在创建对象时,模块状态会从一个对象传播到另一个对象。 正确设置模块状态非常重要。 如果未设置模块状态,DLL 或 COM 对象可能无法与调用它的 MFC 应用程序顺利交互、无法找到自身的资源,或者发生其他严重形式的失败。

请注意,某些类型的 DLL(具体而言,是“MFC 扩展”DLL)不会在其 RawDllMain 中切换模块状态(实际上,它们通常甚至没有 RawDllMain)。 这是因为,它们“看似”实际存在于使用它们的应用程序中。 它们在很大程度上是正在运行的应用程序的一部分,其目的是修改该应用程序的全局状态。

OLE 控件和其他 DLL 有很大的不同。 它们不会修改调用方应用程序的状态;调用它们的应用程序甚至可能不是 MFC 应用程序,因此可能不存在可修改的状态。 这就是开发模块状态切换功能的原因。

对于从 DLL 导出的函数(例如在 DLL 中启动对话框的函数),需要将以下代码添加到函数的开头:

AFX_MANAGE_STATE(AfxGetStaticModuleState())

这会将当前模块状态与从 AfxGetStaticModuleState 返回的状态交换,直至当前范围的末尾。

如果未使用 AFX_MODULE_STATE 宏,则 DLL 中将出现资源问题。 默认情况下,MFC 使用主应用程序的资源句柄来加载资源模板。 此模板实际存储在 DLL 中。 根本原因是 AFX_MODULE_STATE 宏尚未切换 MFC 的模块状态信息。 资源句柄将从 MFC 的模块状态中恢复。 不切换模块状态将导致使用错误的资源句柄。

不需要将 AFX_MODULE_STATE 放在 DLL 中的每个函数内。 例如,InitInstance 可由应用程序中的 MFC 代码调用,而无需 AFX_MODULE_STATE,因为 MFC 会在 InitInstance 之前自动切换模块状态,然后在 InitInstance 返回之后将其切换回来。 上述情况同样适用于所有消息映射处理程序。 常规 MFC DLL 实际上有一个特殊的主窗口过程,此过程将在路由任何消息之前自动切换模块状态。

进程本地数据

如果不是因为 Win32s DLL 模型的复杂性,进程本地数据也不会受到如此多的关注。 在 Win32s 中,所有 DLL 共享其全局数据,即使由多个应用程序加载也是如此。 这与“真正的”Win32 DLL 数据模型有很大的不同,后者的每个 DLL 在附加到 DLL 的每个进程中获得其数据空间的单独副本。 为了提高复杂性,在 Win32s DLL 中的堆上分配的数据实际上特定于进程(至少在所有权方面就是如此)。 考虑以下数据和代码:

static CString strGlobal; // at file scope

__declspec(dllexport)
void SetGlobalString(LPCTSTR lpsz)
{
    strGlobal = lpsz;
}

__declspec(dllexport)
void GetGlobalString(LPCTSTR lpsz, size_t cb)
{
    StringCbCopy(lpsz, cb, strGlobal);
}

考虑一下,如果上述代码位于 DLL 中,并且该 DLL 由进程 A 和 B 加载(事实上,它们可能是同一应用程序的两个实例),那么会发生什么情况。 A 调用 SetGlobalString("Hello from A")。 因此,在进程 A 的上下文中为 CString 数据分配了内存。请记住,CString 本身是全局性的,对 A 和 B 都可见。现在 B 调用 GetGlobalString(sz, sizeof(sz))。 B 可以看到 A 设置的数据。 这是因为,Win32s 不像 Win32 那样在进程之间提供保护。 这是第一个问题;在许多情况下,一个应用程序影响到被认为由其他应用程序拥有的全局数据是没有好处的。

另外还存在其他问题。 假设 A 现在退出。 当 A 退出时,“strGlobal”字符串使用的内存可供系统使用 – 也就是说,进程 A 分配的所有内存都由操作系统自动释放。 之所以未释放,是因为正在调用 CString 析构函数;但尚未调用该析构函数。 之所以释放,只是因为分配内存的应用程序已经离开了现场。 现在如果 B 调用 GetGlobalString(sz, sizeof(sz)),则它无法获取有效数据。 其他某个应用程序可能已将该内存用于其他目的。

显然存在问题。 MFC 3.x 使用了一种称为线程本地存储 (TLS) 的技术。 MFC 3.x 分配一个 TLS 索引,在 Win32s 下,该索引实际上用作进程本地存储索引(即使未调用);然后引用基于该 TLS 索引的所有数据。 这类似于用于在 Win32 上存储线程本地数据的 TLS 索引(有关该主题的详细信息,请参阅下文)。 这导致每个 MFC DLL 为每个进程利用至少两个 TLS 索引。 加载许多 OLE 控件 DLL (OCX) 时,很快就会用完 TLS 索引(只有 64 个可用)。 此外,MFC 必须将所有这些数据放在一个位置的单个结构中。 它的可扩展性不是很好,并且在使用 TLS 索引方面不理想。

MFC 4.x 使用一组类模板解决了此问题,这些模板可以“包装”进程本地数据。 例如,上面提到的问题可以通过编写以下代码来解决:

struct CMyGlobalData : public CNoTrackObject
{
    CString strGlobal;
};
CProcessLocal<CMyGlobalData> globalData;

__declspec(dllexport)
void SetGlobalString(LPCTSTR lpsz)
{
    globalData->strGlobal = lpsz;
}

__declspec(dllexport)
void GetGlobalString(LPCTSTR lpsz, size_t cb)
{
    StringCbCopy(lpsz, cb, globalData->strGlobal);
}

MFC 分两步实现此目的。 首先,在 Win32 Tls* API(TlsAlloc、TlsSetValue、TlsGetValue 等)之上有一个层,无论你有多少个 DLL,这些 API 都只为每个进程使用两个 TLS 索引。 其次,提供了 CProcessLocal 模板来访问这些数据。 它重写 operator->,它允许使用上面所示的直观语法。 由 CProcessLocal 包装的所有对象必须派生自 CNoTrackObjectCNoTrackObject 提供一个低级分配器 (LocalAlloc/LocalFree) 和一个虚拟析构函数,以便 MFC 可以在进程终止时自动销毁进程本地对象。 如果需要进行额外的清理,此类对象可以包含自定义析构函数。 上面的示例不需要自定义析构函数,因为编译器会生成一个默认的析构函数来销毁嵌入的 CString 对象。

这种方法还有其他有趣的优点。 不仅会自动销毁所有 CProcessLocal 对象,而且只有在需要这些对象时才构造它们。 首次调用 CProcessLocal::operator-> 时,它会实例化关联的对象,但不会提前实例化。 在上面的示例中,这意味着只有在首次调用 SetGlobalStringGetGlobalString 之后才会构造“strGlobal”字符串。 在某些情况下,这有助于缩短 DLL 启动时间。

线程本地数据

与进程本地数据类似,当数据必须是给定线程的本地数据时,将使用线程本地数据。 也就是说,对于访问该数据的每个线程,都需要一个单独的数据实例。 可以多次使用此技术来代替广泛同步机制。 如果数据不需要由多个线程共享,则这种机制可能既昂贵又不必要。 假设我们有一个 CString 对象(非常类似于上面的示例)。 我们可以使用 CThreadLocal 模板来包装它,使其成为线程本地对象:

struct CMyThreadData : public CNoTrackObject
{
    CString strThread;
};
CThreadLocal<CMyThreadData> threadData;

void MakeRandomString()
{
    // a kind of card shuffle (not a great one)
    CString& str = threadData->strThread;
    str.Empty();
    while (str.GetLength() != 52)
    {
        unsigned int randomNumber;
        errno_t randErr;
        randErr = rand_s(&randomNumber);

        if (randErr == 0)
        {
            TCHAR ch = randomNumber % 52 + 1;
            if (str.Find(ch) <0)
            str += ch; // not found, add it
        }
    }
}

如果从两个不同的线程调用 MakeRandomString,则每个线程将以不同的方式“打乱”字符串,而不会干扰另一个线程。 这是因为,每个线程实际上有一个 strThread 实例,而不仅仅是一个全局实例。

请注意如何使用引用来捕获 CString 地址一次,而不是在每次循环迭代中捕获一次。 在使用“str”的任何位置都可以使用 threadData->strThread 编写循环代码,但代码的执行速度要慢得多。 当此类引用出现在循环中时,最好是缓存对数据的引用。

CThreadLocal 类模板使用与 CProcessLocal 相同的机制和相同的实现技术。

另请参阅

按编号列出的技术说明
按类别列出的技术说明