65.9K
CodeProject 正在变化。 阅读更多。
Home

DevGlobalCache——进程间缓存和共享数据的方法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (60投票s)

2003年6月1日

55分钟阅读

viewsIcon

221160

downloadIcon

3024

进程间缓存和共享数据的方法。

引言

在 COM 架构的全盛时期,在进程间共享数据的最常用方法是使用 COM EXE 类。COM 对象运行在自己的内存空间中,使得数据能够与进程代码一起保留。这些数据可以在所有从 COM EXE 类创建的 COM 对象之间共享。

当 .NET 发布时,我和我的客户也在寻找一种方法来实现像以前那样缓存未更改数据的方式。我们的直觉是寻找一个 .Net EXE 类,但不幸的是,没有找到。我们发现的是,我们可以创建一个 EXE 类并通过远程处理连接它。然而,当我们测试这种方法时,遇到了性能瓶颈。深入研究这个问题后发现,在客户端和服务器之间打开 TCP 端口非常慢。此外,远程处理过程还有一个额外的问题,即需要保留一个持续的监听器(监听服务器端口)来接收客户端请求。这使得任务更加复杂。

牢记这一点,很明显,基础设施工作变得至关重要。我们需要创建一个机制,使我们能够及时地在进程间共享数据。

有两种方法可以构建这个机制。我们将讨论其中的两种。

  1. 我们可以将一个对象序列化到磁盘上的一个文件,然后从任何其他进程反序列化它。没有完美的方法,文件读写速度不可能比远程处理快。
  2. 另一种解决方案基于 Win32 内存映射文件(MMF)。MMF 允许我们读写文件,但所有实际的 IO 交互都发生在 RAM 中,以标准内存寻址的形式。这具有性能优势。MMF 的另一个好处是,它还可以通过使用与创建 MMF 时指定的相同全局名称来跨进程共享,从不同的应用程序访问 MMF。

哪种选择是最优的,不言而喻。

使用 MMF,我们将构建一个机制,使我们能够在几乎没有性能损失的情况下在进程间共享数据。挑战在于,即使 .NET 的托管代码框架没有处理 MMF 的类,我们也要能够使用 MMF。为此,我们将回到使用非托管代码(WIN 32 API)通过 P/Invoke(互操作)进行工作。本文将演示如何在 .NET 中使用 MMF,为 .NET 应用程序创建一个全局缓存。我们通过创建一组类来实现这一点,这些类封装了使用 MMF 功能的 P/Invoke 代码,并封装了使用 MMF 的逻辑。在读写 MMF 时,我们将在托管和非托管内存之间收集数据。

定义问题

在编写应用程序时,进程间共享数据是我们面临的常见问题之一。程序员可以使用两种主要方式在进程间进行内存共享:

  1. 缓存数据:在开发多个 Web 应用程序和需要从数据库获取相同未更改数据集的对象时,我们可以防止每个应用程序或对象访问数据库,以提高服务器性能。通过在首次访问数据库时缓存数据,我们可以使用缓存数据并避免访问数据库。如果所有应用程序都是 Web 应用程序,我们可以使用 `Application` 对象在 `aspnet_wp.exe` 中缓存数据。但是,如果我们需要在 Web 应用程序与其他进程中的对象之间共享数据,我们就需要找到一种方法来在所有这些进程之间共享缓存数据。这种处理缓存的方式的特点是数据只输入一次,很少更改。如果缓存被更改,通常是由创建它的同一个进程更改的。
  2. 维护状态:在开发无状态应用程序(如 Web 应用程序)时,开发人员需要一种方法来在调用之间保存应用程序的状态。状态数据实际上是一组数据,我们需要在应用程序的每次调用之间保留这些数据,以维护应用程序流程。这些数据通常由一小部分数据组成(例如用户 ID 和用户密码,或用户在应用程序中执行的最后一个操作)。如果开发应用程序基于网页,`Session` 对象能够提供此功能。在两种情况下,`Session` 对象将失败:
    1. 第一种情况发生在正在开发的对象作为 COM+ 中的服务器应用程序运行时。在这种情况下,`Session` 对象中的数据无法从正在开发的对象中获取。在 .NET 中,无法从 COM+ 上下文访问 Web 上下文。
    2. 第二种情况发生在应用程序托管在 Web 场中时。在这种情况下,来自客户端的一次调用可能会到达一台服务器,而下一次客户端调用可能会到达另一台服务器。你可能已经猜到,如果你在第一台服务器上设置了状态数据,那么在另一台服务器上将无法获得它。状态数据与缓存数据之间的主要区别在于,状态数据通常是一小部分快速变化的数据。

在不支持一个进程访问另一个进程内存的系统中,进程间共享数据是一个已公认的问题,Win32 提供了解决方案(管道、邮件槽、内存映射文件等)。COM 提供了一个更简单的解决方案。如果我们创建一个 COM EXE 服务器,所有由其他进程创建的实例都存在于分配给 COM EXE 进程的相同内存区域中。在这种情况下,如果我们保存在进程中的数据,这些数据就可以在所有实例之间共享。这很容易实现,正如你将在演示缓存 `Recordset`s 的代码中看到的。所有需要做的就是在 EXE 模块类中声明候选缓存数据,然后使用 `extern_Module` 从实例代码访问数据。

typedef map<_bstr_t,_RecordsetPtr> DATAMAP;
typedef DATAMAP::value_type vtData;
typedef vector<DATA, allocator<Data> > DATAVECTOR;
{
public:
    LONG Unlock();
    DWORD dwThreadID;
    HANDLE hEventShutdown;
    void MonitorShutdown();
    bool StartMonitor();
    bool bActivity;
    DATAMAP DataMap;
    DATAMAP::iterator MapIterator;
    DATAVECTOR CachDataVector;
    DATAVECTOR::iterator CachDataIterator;
    HANDLE  hMutex;
};
extern CExeModule _Module;

正如你可能已经猜到的,如果我们不编写必要的代码来防止多线程访问共享数据,我们将遇到问题。

在编写 COM 程序时,我使用 COM EXE 方法来缓存数据库中很少更改的 Recordset,以及在客户端或服务器端的无状态应用程序之间维护状态数据的机制。当我开始为 .NET 编写新基础设施时,我寻找这些问题的解决方案。乍一看,使用相同的机制编写 EXE 类似乎是可以接受的。然而,当我试图给该机制施加压力时,我很快就发现了一个性能问题。`aspnet_wp.exe` 和持有缓存数据的 EXE 之间的远程连接速度不够快。我们需要一种方法,在对系统性能影响最小的情况下,在进程间共享数据。

定义解决方案

问题的解决方案已在问题描述中提供。我们在 COM EXE 代码中放置的数据会在所有 COM EXE 类的实例之间共享,因为 Win32 实际上在物理内存中保留了 COM EXE 代码的一个副本。从 EXE 创建的每个进程都被映射到这个物理内存空间。利用托管代码的这一特性,我们可以开发一个令人满意的解决方案。

共享内存是 Win32 提供的用于处理进程间通信(IPC)的选项之一,但也有其他选项。因此,我们首先应该检查 IPC 类型,看看是否有其他选项可以用于获得解决方案。

为了创建能够提供所需功能的可用程序集,我们首先需要将工作划分为三个主要块:

  • 构建一个类,其中包含与内存映射文件 Win32 功能通信所需的所有互操作声明。Microsoft 在 CLR 中构建了许多类,但 MMF 功能不是 CLR 类的一部分。
  • 构建一个类,它将封装 MMF。而不是直接与 API 交互,我们将创建一个类来管理 MMF 的工作。这个类将保存解决方案所需的内部数据和功能。
  • 维护逻辑。解决方案不仅仅是读写 MMF 文件。解决方案还通过使用 MMF 在进程间维护共享数据的逻辑。该解决方案允许程序员将对象插入、检索和删除 MMF。为了提供此功能,需要解决一些问题。例如,我们需要维护程序员添加的对象名称和物理位置。这些信息对于检索和/或删除这些对象是必需的。

我们将这三个代码块放入一个 DLL 中。这样,每个将 DLL 附加到其内存区域的进程都可以使用其功能来存储和检索内存映射文件中的数据。

起初,我想在这篇文章中展示如何共享 Web 场服务器之间的缓存数据。此功能对于跨 Web 场服务器维护状态至关重要。由于这方面需要冗长的讨论,因此可以作为未来文章的主题。

由于此解决方案的复杂性,我们首先将检查所有要创建的类及其在该解决方案中的主要任务。

  • Win32 API 包含了 P/Invoke 的所有声明,以实现对非托管代码的调用。
  • MemoryMapFile 封装了 MMF 功能。此类可以独立用于访问 MMF。
  • MapViewStream 封装了可以读写 MMF 内存地址的流。此功能是此解决方案的核心。它使我们能够从非托管内存中读写托管代码中的对象。
  • DevCache 包含了我们为允许程序员简单激活机制所需的逻辑。
  • FileMapIOException 自定义了在处理 MMF 时引发 Win32 错误的异常类。
  • OFSTRUCT 显示了将结构发送到 Win32 API 以返回数据。

组件设计与编码

什么是 MMF,它们如何帮助我们?

在 COM 解决方案中,我们使用了 Win32 中可用的进程间通信(IPC)之一,即内存映射文件。如上所述,我们的解决方案将使用 MMF。我们需要找到一种方法来在进程间共享数据,而 MMF 似乎很合适。但是,我们首先要检查所有 IPC 类型,以确定这是正确的决定,以及是否有其他 IPC 类型可用于此解决方案。

IPC 类型

IPC 类型可分为两大类:本地和网络。本地 IPC 用于机器内部的通信。网络用于跨机器的 IPC。本地 IPC 包括:

  • 原子是字符串或整数。它们由句柄标识,并且可以从每个进程访问。它们最初是为 DDE 创建的。Win32 将它们限制为仅 37 个字符串和整数。
  • 共享内存(内存映射文件 - MMF)是 Win32 的一种机制,它允许多个进程共享相同的物理内存区域。Win32 的构建方式是,一个进程无法访问其他进程的内存区域。这是通过为每个进程分配 2GB 的虚拟内存来实现的,该内存私有于该进程。Win32 能够通过使用进程虚拟内存来共享进程间的内存。共享数据放置在单个物理内存区域中,每个进程都有指向其虚拟内存的指针,该指针指向物理内存。此机制可以控制进程对物理内存区域的读写访问。Win32 允许我们将物理文件或文件的一部分映射到虚拟内存。这就是为什么此机制也称为内存映射文件。这个独特的功能使我们和操作系统能够以与访问内存相同的方式访问文件。
  • 互斥锁(Mutex)是一个一次只能由一个线程拥有的对象。尝试获取互斥锁所有权的线程将被阻塞在队列中,直到当前互斥锁所有者释放它为止。互斥锁是控制线程对资源的访问的一个好选项,因为一次只有一个线程可以访问该资源。我们将使用互斥锁来控制进程对共享内存区域的访问。
  • 信号量(Semaphore)类似于互斥锁,但可以设置可以访问资源的线程数量。所有其他进程将被阻塞并排队。
  • 临界区(Critical sections)的行为与互斥锁相同。它们的限制是不能在不同进程的线程之间共享。它们的优点是比互斥锁更轻量级、速度更快。
  • 事件(Events)是可用于在线程更改状态时通知线程的对象。事件可以表示已完成资源、已完成初始化,或线程已准备好获取数据。
  • 其他类型的本地 IPC 包括窗口消息、DDE、剪贴板以及如上所述的 COM。

网络 IPC 可以是:

  • 网络协议(NetBEUI、TCP/IP)是在多台机器上在进程间传输数据的灵活选项。问题在于它们需要大量的编程(监听、接收多请求等)。它们的优点是,使用的每种解决方案实际上都依赖于一种网络协议。最好的协议是 TCP/IP。它是操作系统之间最灵活、最广泛的协议。
  • 邮件槽(Mailslots)类似于 WIN32 消息,但可以发送到网络上的其他机器。邮件槽发送的数据量有限,就像 Win32 消息一样。它们也可以作为数据报(UDP)发送到域中的所有机器。这意味着接收者不一定能收到数据。
  • 管道(Pipes)。如果邮件槽类似于 UDP,那么管道就类似于 TCP。它们通过两个端点进行通信,一旦设置好,就可以通过管道在两个端点之间发送数据。
  • 另一个选项是 RPC(远程过程调用),它允许调用远程机器上的过程,以及 DCOM(用于激活远程 COM 对象)。

检查了所有 IPC 选项后,很明显,内存映射文件是存储可在多台机器上的多个进程之间共享的数据的最佳选择。这是唯一允许我们共享大量数据的选项。除了使用 MMF,我们还将使用互斥锁来同步进程对该解决方案中内存的写访问。我们的下一步是理解 MMF 的工作原理以及如何从非托管代码中使用它。这些知识将用于构建一个类,该类将包含激活托管代码中的非托管代码所需的所有 P/Invoke 代码。

将 Win32 API 转换为 .NET

在本文中,我们将仅使用 MMF 来映射文件。但是,Win32 API 也允许我们映射系统页面文件。在达到创建文件映射的声明时,将显示系统页面文件的映射。映射文件的主要原因是,每次我们写入 MMF 时,Windows 实际上都会更新底层文件。这是由操作系统执行的,这意味着我们不知道更新或性能损失。在过程结束时,我们得到一个包含我们数据的文件的。即使计算机关闭,我们也可以使用此文件来维护状态。

这里的第一个步骤是创建一个文件,该文件最终将被映射到内存,以便我们可以读写它。要创建此文件,我们将使用 Win32 API `CreateFile`。尽管 `System.IO.FileStream` 也可以使用,但本文涉及 P/Invoke,此函数将演示 P/Invoke。此函数的 API 声明如下:

HANDLE CreateFile(
  LPCTSTR lpFileName,                        // file name
  DWORD dwDesiredAccess,                     // access mode
  DWORD dwShareMode,                         // share mode
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,// SD
  DWORD dwCreationDisposition,               // how to create
  DWORD dwFlagsAndAttributes,                // file attributes
  HANDLE hTemplateFile                       // handle to template file
);

要在 .NET 中使用此函数,我们需要将所有 C 类型转换为 .NET CLS 类型。为此,我们将创建一个新类 `WIN32MapApis`。此类将包含互操作 Win32 API 所需的全部代码。要处理从 DLL 导入的非托管函数,我们将使用 `System.Runtime.InteropServices` 中的一些类。我们可以使用 `Using` 关键字导入命名空间。导入允许我们通过类名引用类,否则需要完整的类限定符。我们将导入命名空间以简化代码(但请注意,导入命名空间会增大 DLL 的大小。这可能会对性能产生负面影响)。

在编写 P/Invoke 代码时,使用属性非常普遍。属性是我们可以应用于任何目标元素(程序集、类、构造函数、委托、枚举、事件、字段、接口、方法、模块、注释、参数、属性、返回值和结构)的类。通过将属性应用于类,我们可以声明我们的意图。声明式编程一直让我着迷。从 DOS 时代开始,我们就能够使用 DumpBin 来查看程序员对其代码的意图。这一趋势在 COM 中继续,它允许我们使用 `TypeLibrary` 元数据输入更多关于我们意图的数据。COM+ 通过使用属性更进一步,但其实现是一项繁琐的任务。.NET 使实现更容易,并允许使用属性来声明我们的意图。当我们声明意图时,我们实际上是在声明已发布并具有预定义含义的符号(属性)。当程序员(CLR 代码或程序员)将这些符号应用于元素时,处理代码可以考虑此符号的预定义含义。然后,属性可以指示启动事务、对元素应用序列化,或其他任何预定义含义。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Auto ) ];

`DLLImport` 属性告诉我们正在应用的函数的运行时,并且该函数是从包含非托管代码的外部 DLL 导入的。对于此类函数,可以设置属性的属性以帮助运行时处理此函数。通过使用属性,我们可以告诉:

  1. 包含函数的 DLL 名称。
  2. 函数的调用约定。
  3. 字符串如何在托管和非托管代码之间编组(`CharSet`)。
  4. 包含函数实际名称的入口点。
  5. 指示调用的 API 函数是否会在返回前调用 API `SetLastError` 以及其他选项。
public static extern IntPtr CreateFile ( 
    String lpFileName,
    int dwDesiredAccess,
    int dwShareMode,
    IntPtr lpSecurityAttributes,
    int dwCreationDisposition,
    int dwFlagsAndAttributes,
    IntPtr hTemplateFile )

在编译了具有所需属性的 `DLLImport` 属性后,我们可以开始声明函数。`extern` 关键字告诉编译器此函数是在另一个 DLL 中实现的,并且是用不支持 CLS 的语言实现的。由于这只是一个没有实现功能的函数声明,所以函数声明没有函数体,并且以分号结尾。`static extern` 指示运行时自动执行 `LoadLibrary` 和 `GetProcAddress`。

`IntPtr` 结构表示指针或句柄(平台无关)。每当我们需要在 C# 代码中表示指针或句柄时,我们都使用此结构。你可能知道 Win32 API 包含许多指针(指向字符串、指向结构等)和句柄(文件、窗口等)。因此,该结构在 P/Invoke 中被广泛使用。如果我们被要求将 `null` 传递给声明为 `IntPtr` 的参数,正确的方法是使用 `IntPtr.Zero` 而不是 `null`。在此声明中,我们使用 `IntPtr` 来表示将从操作系统接收的文件句柄以及安全属性结构的指针。随着文章的进展,我将演示如何表示一个结构。这项任务需要代码和时间。因此,如果你知道你要传递 `null` 给指针,你可以通过将指向 `struct` 的指针声明为 `IntPtr` 来节省工作和时间。

下面显示了 `CreateFile` 函数的声明。如你所见,讨论的细节出现在声明中。所有在 CLS 和 API 中具有相同定义的(如 `int`、`long` 等)数据类型将无需任何工作即可进行编组。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Auto  ) ]
public static extern IntPtr CreateFile ( 
    String lpFileName,
    int dwDesiredAccess,
    int dwShareMode,
    IntPtr lpSecurityAttributes,
    int dwCreationDisposition,
    int dwFlagsAndAttributes,
    IntPtr hTemplateFile )

创建文件后,下一步是创建一个实际将文件映射到内存区域的 MMF 对象。通常,`CreateFileMapping` 接收文件句柄,并带有访问权限属性,然后创建 MMF 对象。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Auto) ]
public static extern IntPtr CreateFileMapping ( 
   IntPtr hFile,
   IntPtr lpAttributes,
   int flProtect, 
   int dwMaximumSizeLow,
   int dwMaximumSizeHigh,
   String lpName );

关于 P/Invoke 的声明没有新内容,但我们需要理解函数参数。第一个是我们在上一个函数中接收到的文件句柄。如果我们将此参数传递 `0xFFFFFFFF`,我们将映射系统页面文件。第二个是安全属性。我们将传递 `null` 并放弃安全设置。第三个参数表示文件视图映射时的保护级别。为了使代码更具可读性,创建一个包含所有保护选项的枚举。`Flags` 属性表示此枚举可以被视为位字段。因此,我们可以对位字段(枚举字段)执行按位 OR 操作。

[Flags]
public enum MapProtection 
{
   PageNone       = 0x00000000,
   // protection
   PageReadOnly   = 0x00000002,
   PageReadWrite  = 0x00000004,
   PageWriteCopy  = 0x00000008,
   // attributes
   SecImage       = 0x01000000,
   SecReserve     = 0x04000000,
   SecCommit      = 0x08000000,
   SecNoCache     = 0x10000000,
}

第四和第五个参数负责设置文件映射的大小。为了解决 Windows 32 位寻址限制,API 使用这些参数。可能需要更大的映射大小,在这种情况下,我们可以用 32 位地址空间设置它。使用两个参数,我们有 64 位来设置映射大小。自然,对于小于 4 GB 的文件,我们不需要使用第四个参数,因此我们将它设置为 0。如果我们把两个参数都设置为 0,那么映射文件的长度将是文件本身的长度。如果我们设置了文件映射长度,我们必须注意映射文件的大小,它不应该小于文件的大小。最后一个参数非常重要。此参数设置文件名称,该名称将是操作系统中映射文件的唯一标识符。这样,如果多个进程使用此名称打开文件,映射将映射到同一个文件。

当映射到系统页面时,我们需要绕过 Microsoft 的设计。第一个参数是 `IntPtr` 结构。该结构表示 Win32 的 `int32` 和 Win64 操作系统的 `int64`。我们在 Win32 操作系统上工作,因此当我们尝试将 `0xFFFFFFFF` 作为参数发送时,由于溢出,CLR 不会允许我们编译。为了防止这种情况,我们将重载 `CreateFileMapping`,添加另一个接收 `uint` 作为第一个参数的 `hFile` 参数。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Auto) ]
public static extern IntPtr CreateFileMapping ( 
   uint hFile,
   IntPtr lpAttributes,
   int flProtect, 
   int dwMaximumSizeLow,
   int dwMaximumSizeHigh,
   String lpName );

一个进程可以创建具有唯一名称的内存文件映射。多个进程可以创建多个文件映射并获取它们的 MMF HANDLE。但在本例中情况并非如此。为了在进程之间共享数据,它们需要共享同一个 MMF。这可以通过允许除打开 MMF 的进程之外的任何其他进程使用唯一名称来打开现有的文件映射来实现。通过使用 `OpenFileMapping` 函数,任何进程都可以打开现有的 MMF。此函数声明很简单。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Auto) ]
public static extern IntPtr OpenFileMapping (
   int dwDesiredAccess, 
   bool bInheritHandle,
   String lpName );

这里的第一个参数设置文件映射的访问权限。与保护级别一样,我们创建一个包含所有选项的枚举。第二个参数指示子进程是否将继承此文件映射句柄。最后一个参数是在 `CreateFileMapping` 函数中使用的唯一名称。

在获得内存映射文件句柄后的下一步是查看进程内的全部或部分内存映射文件。此操作在进程内存区域中创建一个内存区域,该区域指向映射文件对象。为此,我们需要声明 `MapViewOfFile` 函数。

[ DllImport("kernel32", SetLastError=true) ]
public static extern IntPtr MapViewOfFile (
   IntPtr hFileMappingObject, 
   int dwDesiredAccess, 
   int dwFileOffsetHigh,
   int dwFileOffsetLow, 
   int dwNumBytesToMap );

第一个参数是 MMF 的句柄,我们通过 `CreateFileMapping` 或 `OpenFileMapping` 获得。第二个参数是文件访问模式,使用上述枚举。第三个和第四个参数一起用作 64 位偏移量来设置映射的开始。最后一个参数设置要查看的文件长度。最后一个参数可以是 0,表示查看整个文件(在这种情况下,第三个和第四个参数无关紧要)。该函数返回一个指向进程中文件正在映射的内存地址的指针。`MapViewOfFileEx` 提供了将文件映射到特定地址集的能力。

当我们在视图取消映射或文件映射对象被移除时,对进程映射视图内存区域所做的更改将由操作系统更新到物理内存和文件。如果需要立即进行更新,则需要调用 `FlushViewOfFile` 函数。

[ DllImport("kernel32", SetLastError=true) ]
public static extern bool FlushViewOfFile ( 
    IntPtr lpBaseAddress,
    int dwNumBytesToFlush );

此函数将内存视图的基地址作为第一个参数,将需要写入的文件视图映射长度作为第二个参数。要取消映射视图,您需要调用

[ DllImport("kernel32", SetLastError=true) ]
public static extern bool UnmapViewOfFile ( IntPtr lpBaseAddress );

这里的唯一参数是文件视图的基地址。最后,要释放内存映射文件对象,我们将使用 `CloseHandle` 函数。

[ DllImport("kernel32", SetLastError=true) ]
public static extern bool <CODE>CloseHandle ( IntPtr handle );

如本节开头所述,我们创建了一个包装类,它封装了使用 MMF 功能所需的所有 API 函数。

将 MMF 封装到类中

到目前为止,我们已经创建了一个静态包装类,它封装了与 Win32 函数交互所需的所有 P/Invoke 工作。解决方案是使用 MMF 作为存储数据的机制,以便在进程之间共享。此解决方案基于映射文件(尽管也可以使用系统页面文件)。在本节中,我们将构建包含我们需要在此解决方案中使用内存映射文件的功能和数据。事实上,我们将构建两个主要类。第一个类将包含所有必需的功能和数据,用于:打开文件、创建和打开内存映射文件、映射和取消映射文件视图,以及关闭所有已打开的句柄。第二个类将处理从文件映射视图(托管进程的虚拟内存地址)读写数据以及刷新内存映射文件。

内存映射文件类

`MemoryMappedFile` 类将包含操作 MMF 所需的所有功能和数据。此类实现了 `IDisposable` 接口。允许用户随时释放类使用的所有句柄是一个好主意。作为私有数据,我们将持有 MMF 对象的句柄,该句柄通过 `OpenFileMamming` 和 `CreateFileMapping` 获得。我们需要这些数据来 `MapViewOfFile` 和关闭 MMF 对象。我们还将保留映射视图的大小,以便在托管和非托管代码之间编组数据时使用。

当我们要创建类的实例时,可能存在两种情况。第一种是创建对象并使用其功能稍后获取映射视图的基地址。第二种是使用所有必需的参数创建对象,以便在构造函数结束时获得 MMF 句柄。参数化构造函数背后的逻辑很简单:我们首先尝试通过其唯一名称获取现有 MMF 的句柄。如果我们没有获得有效的句柄,我们将为给定的文件名创建一个文件,然后使用给定的参数创建到该文件的文件映射。当构造函数成功完成时,我们的类将持有 MMF 对象的句柄。如果有人已经用给定的名称打开了一个 MMF 对象,那么此函数的执行时间会很快。如果没有,我们需要执行额外的工作(打开并映射文件),这将导致更长的执行时间。

public MemoryMappedFile( String fileName, MapProtection protection,
    MapAccess access, long maxSize, String name)
{
    IntPtr hFile = IntPtr.Zero;
    try
    {

通过其唯一名称查找已打开的 MMF 对象。如果返回的句柄为 `null`,我们需要创建 MMF 对象。

        m_hMap = Win32MapApis.OpenFileMapping((int)access,false,name);
        if (m_hMap == NULL_HANDLE )
        {
            int desiredAccess = GENERIC_READ;
            if  ( (protection == MapProtection.PageReadWrite) ||
                (protection == MapProtection.PageWriteCopy) )
            {
                desiredAccess |= GENERIC_WRITE; 
            }

首先,我们将尝试使用给定的参数打开后备文件。如果我们成功,我们将使用文件句柄来创建 MMF 对象。如果 MMF 对象的句柄为 `null`,我们将抛出异常。我们使用 `Marshal` 类从 Win32 获取最后一条错误。

            hFile = Win32MapApis.CreateFile ( 
                GetMMFDir() + fileName, desiredAccess, 0, 
                IntPtr.Zero, OPEN_ALWAYS, 0, IntPtr.Zero);
            if (hFile != NULL_HANDLE)
            {
                m_hMap = Win32MapApis.CreateFileMapping (
                    hFile, IntPtr.Zero, (int)protection, 
                    0,(int)(maxSize & 0xFFFFFFFF), name );
                if (m_hMap != NULL_HANDLE)
                    m_maxSize = maxSize;
                else
                    throw new FileMapIOException 
                        ( Marshal.GetHRForLastWin32Error() );
            }
            else
                throw new FileMapIOException 
                    ( Marshal.GetHRForLastWin32Error() );
        }
    }
    catch (Exception Err)
    {
        throw Err;
    }
    finally
    {

如果文件句柄正在使用中,我们需要释放它。

        if ( (hFile != NULL_HANDLE) && (hFile != INVALID_HANDLE_VALUE) )
            Win32MapApis.CloseHandle(hFile);
    }
}

在创建物理文件时,使用 `GetMMFDir` 函数返回用于创建文件的驱动器。最终,我们将应用程序部署到集成、测试和生产服务器。这些服务器拥有驱动器来将系统文件与应用程序文件分开。在这些服务器上,例如,E 驱动器用于应用程序文件,C 驱动器仅包含系统文件。在开发机器上,C 驱动器包含应用程序和系统数据。这只是返回 C 驱动器,但你可以从注册表、`initialize` 文件或 `Config` 文件中读取驱动器。

另一种方法是创建一个空的 `MMF` 类,然后使用 `Create` 函数来创建文件和内存映射文件。`Create` 函数始终用于创建新文件和内存映射文件。此函数用于使类更通用,但不在代码中使用。

`Open` 函数完成两项任务。第一项是通过名称获取已打开 MMF 对象的句柄。第二项是返回一个指示符,指示具有该名称的 MMF 是否已打开。

public bool Open ( MapAccess access, String name )
{
    bool RV = true;
    try
    {
        m_hMap = Win32MapApis.OpenFileMapping ( (int)access, false, name );
        if ( m_hMap == NULL_HANDLE )
            RV=false;
        return RV;
    }
    catch
    {
        return RV;
    }
}

在此解决方案中,获取 MMF 对象句柄最常用的函数是 `OpenEx`。此函数还使用 `OpenFileMapping` 函数打开一个已创建的 MMF 对象。但是,如果 `OpenFileMapping` 返回无效句柄,我们将尝试另一种方式打开 MMF 对象。我们的解决方案基于将文件映射到内存。这意味着对于每个 MMF 对象,都有一个物理文件。如前所述,此文件会随着操作系统写入内存的数据而更新。我们可以使用此文件及其中的数据,方法是尝试打开文件并获取其句柄。然后,我们可以使用此句柄作为打开 MMF 对象的参数之一。如果成功,则文件包含数据的映射将映射到内存,函数的返回值将为 `true`。如果未能打开 MMF 对象,函数将返回 `false`。此函数与前面提到的函数的主要区别在于,此函数尝试从现有的物理文件打开 MMF 对象,而其他函数则尝试打开映射文件,如果失败,则创建新文件。

public bool OpenEx (int size,string FileName, MapProtection protection,
             string name,MapAccess access)
{
    bool RV = false;
    IntPtr hFile = INVALID_HANDLE_VALUE;
    try
    {

尝试打开一个已创建的 MMF 对象。如果不存在,则检查是否存在后备文件。如果存在,我们将获取其大小和文件句柄,方法是使用 `OpenFile`。然后,我们将使用文件句柄来创建新的 MMF 对象。

        m_hMap = Win32MapApis.OpenFileMapping ( (int)access, true, name );
        if ( m_hMap == NULL_HANDLE)
        {

检查磁盘上是否存在后备物理文件。

            if (  System.IO.File.Exists (GetMMFDir() + FileName) )
            {
                long maxSize = size;
                OFSTRUCT ipStruct = new OFSTRUCT ();
                string MMFName = GetMMFDir() + FileName;

打开物理文件。

                hFile = Win32MapApis.OpenFile (MMFName, ipStruct ,2);

                // determine file access needed
                // we'll always need generic read access
                int desiredAccess = GENERIC_READ;
                if  ( (protection == MapProtection.PageReadWrite) ||
                  (protection == MapProtection.PageWriteCopy) )
                {
                    desiredAccess |= GENERIC_WRITE; 
                }

                // open or create the file
                // if it doesn't exist, it is created

创建一个文件映射对象。

                m_hMap = Win32MapApis.CreateFileMapping (
                    hFile, IntPtr.Zero, (int)protection, 
                    (int)((maxSize >> 32) & 0xFFFFFFFF),
                    (int)(maxSize & 0xFFFFFFFF), name );
                RV = true;
            }
            else
                RV = false;
        }
        else
            RV = true;
        return RV;
    }
    catch
    {
        return false;
    }

最后,关闭物理文件句柄。

    finally
    {
        if ( (hFile != NULL_HANDLE) && (hFile != INVALID_HANDLE_VALUE) ) 
            Win32MapApis.CloseHandle(hFile);
    }
}

在编写 `OpenFileEx` 函数时,我们使用新的 Win32 API 函数 `OpenFile` 来打开现有文件。将该函数 P/Invoke 代码添加到我们的 `WIN32MapApis` 类中,以便在托管代码中使用此函数。我们将使用此新 API 声明,并演示如何声明 API 结构。Win32 `OpenFile` 函数需要接收一个指向结构的指针,该结构包含有关打开文件的返回信息。

HFILE OpenFile(
  LPCSTR lpFileName,        // file name
  LPOFSTRUCT lpReOpenBuff,  // file information
  UINT uStyle               // action and attributes
);

typedef struct _OFSTRUCT { 
  BYTE cBytes; 
  BYTE fFixedDisk; 
  WORD nErrCode; 
  WORD Reserved1; 
  WORD Reserved2; 
  CHAR szPathName[OFS_MAXPATHNAME]; 
} OFSTRUCT, *POFSTRUCT; 

结构包含一个新的关注点。其成员之一是字符数组,需要引起注意。要在托管代码中使用此结构,我们将创建一个新类来表示 Win32 结构中存在的所有数据。

[StructLayout (LayoutKind.Sequential )]
public class OFSTRUCT
{
    public const int OFS_MAXPATHNAME = 128;
    public byte cBytes;
    public byte fFixedDisc;
    public UInt16 nErrCode;
    public UInt16 Reserved1;
    public UInt16 Reserved2;
    [MarshalAs (UnmanagedType.ByValTStr,SizeConst=OFS_MAXPATHNAME)] 
    public string szPathName;
}

在此类声明中,我们使用 `StructLayout` 属性和 `Sequential`,指示 CLR 按声明顺序在内存中排序字段。字符串 `szPathName` 成员的 `MarshalAs` 属性告诉 CLR 如何将类型编组到非托管区域。`MarshalAs` 的 `ByValTStr` 参数表示结构内部的一个固定字符串,而 `SizeConst` 保存设置字符串大小的 `Const`。这样,我们就可以将固定字符数组编组到非托管代码。

关于 `OpenFile` 声明中的 P/Invoke 有两个新问题。首先,我们将 `CharSet` 属性更改为 ANSI。API 函数以 ASCII 代码获取文件名,而 CLR 使用 Unicode。`CharSet` 将托管 Unicode 转换为非托管 ANSI。其次,我们使用 `MarshalAs` 属性告诉 CLR 将模拟结构(`struct`)的类编组为指向结构的 `long` 指针。

[ DllImport("kernel32", SetLastError=true, CharSet=CharSet.Ansi ) ]
public static extern IntPtr OpenFile (String lpFileName,
[Out,MarshalAs (UnmanagedType.LPStruct )]
    OFSTRUCT lpReOpenBuff,
    int uStyle);

获得 MMF 对象句柄后,下一步是映射 MMF 对象的视图。`MapView` 函数负责此任务。通过使用 `MapViewOfFile` 函数,我们将获得托管进程中 MMF 对象视图开始的内存基地址。然而,在托管代码中,我们无法读写非托管堆。为了解决这个问题,我们将创建一个新的 `MemoryStream` 类,该类通过使用 `Marshal` 类来读写非托管代码。如果 MMF 对象包含数据,我们需要将这些数据从非托管代码复制到我们新的 `MemoryStream` 对象中。为此,我们使用 `Marshal` 类的 `Copy` 函数。此函数将指定数量的字节从非托管堆复制到托管字节数组,从给定地址开始。我们将在下一步创建的流类中使用此字节数组。

public MapViewStream  MapView ( MapAccess access, long offset, int size,
 string path )
{
    IntPtr baseAddress = IntPtr.Zero;
    bool iSWritable=true;
    MapProtection protection=MapProtection.PageReadOnly;
    try
    {

使用 WIN32 函数获取映射对象视图在托管进程中的基地址。

        baseAddress = Win32MapApis.MapViewOfFile (
            m_hMap, (int)access, 
            (int)((offset >> 32) & 0xFFFFFFFF),
            (int)(offset & 0xFFFFFFFF), 0 );

        if ( baseAddress != IntPtr.Zero )
        {
            if ( access == MapAccess.FileMapRead )
                protection = MapProtection.PageReadOnly;
            else
                protection = MapProtection.PageReadWrite;
            m_maxSize = size;

将字节从非托管内存堆复制到字节数组。

            byte[] bytes = new byte[m_maxSize];
            Marshal.Copy(baseAddress,bytes,0,(int)m_maxSize);
            if ( access == MapAccess.FileMapRead )
                iSWritable = false;
            else
                iSWritable = true;

通过发送基地址和字节数组,返回我们实现的 `MemoryStream` 的新实例。

            return new MapViewStream(baseAddress, bytes,iSWritable); 
        }
        return null;
    }
    catch
    {
        throw new FileMapIOException ( Marshal.GetHRForLastWin32Error() );
    }
}

`MemoryMappedFile` 类还包含使用 API 函数 `CloseHandle` 关闭 MMF 对象以及处置对象(释放 MMF 对象句柄)的函数。此类提供了创建、打开和映射 MMF 对象视图所需的所有功能。`MapView` 函数返回 `MapViewStream` 类的实例。此类将处理从非托管内存读写数据所需的所有函数。我们将在下一节中介绍此类。

读写数据

这项任务的这一部分是最重要、最复杂、也最有趣的部分。我将尽量清晰地介绍。到目前为止,我们已经设法从 C# 中使用 MMF WIN32 API 获取了 DLL 托管进程中 MMF 映射的基地址。从现在开始,我们需要在存储映射数据的内存区域和托管代码之间移动数据。由于托管代码的特性,这并不容易。在托管代码中工作时,CLR 负责处理内存。这一事实阻止我们直接访问内存。映射的 MMF 的基地址需要直接访问内存,以便从中读写数据。

在 C# 中,有解决此问题的方案。第一个也是最知名的是不安全代码。我们可以在解决方案中编写不安全代码块。从这些块中,我们可以直接操作内存。第二个选项是使用 `Marshal` 类。此类提供了一组方法,用于分配非托管内存、复制非托管内存块以及转换托管类型为非托管类型。我们在 `MapView` 函数中已经使用了 `Marshal` 类的 `Copy` 函数,将数据从非托管代码复制到托管字节数组。

此时,将我们已经整合的所有部分组合起来。使用 MMF,我们可以共享进程间的数据。MMF 为我们提供了进程中映射了数据内存区域的基地址。要操作这些数据,我们将使用 `Marshal` 类。只剩下一个问题仍然不清楚——如何将对象写入缓存?我们需要找到一种方法将数据从托管对象移动到非托管内存堆。乍一看,使用序列化似乎是最简单的方法。我们可以将类的私有和公共成员序列化到 `MemoryStream` 对象。`MemoryStream` 类实现了从中读写数据。我们可以重载 `MemoryStream` 类的 `Read` 和 `Write` 函数,以便读写操作将来自非托管堆,使用 `Marshal` 类。

我尝试了上述方法。我编写了自己的 `stream` 类,它继承自抽象 `Stream` 类。我通过使用 `Marshal.Copy` 实现 `read` 和 `write` 函数。我检查了这个类的性能,发现它确实不令人满意。所以,我尝试了另一种方法。我没有使用序列化,而是将对象按原样写入非托管内存(我用字符串进行了测试)。性能有了显著提高。这种方法的缺点是,大多数 CLR 对象不告诉我们它们在内存中的长度,CLR 也不提供返回此信息的函数。`Copy` 方法需要对象的字节长度。我们在构建 `MemoryStream` 类时将使用此信息。

在编写我们的 `MemoryStream` 类之前,让我们看一下 `Marshal` 提供的一些选项。

  • ReadByte 允许我们一次从非托管内存读取一个字节。要读取字节,我们需要设置要从中读取的内存地址。我们可以设置从给定地址开始的字节偏移量来读取字节。我们可以使用此函数逐字节从非托管堆读取到托管数组。
  • ReadInt16(32,64) 允许我们一次从非托管内存读取(16、32、64)位 `Integer`。要读取 `Integer`,我们需要设置要从中读取的内存地址。我们可以设置从给定地址开始的读取 `Integer` 的字节偏移量。我们可以使用此函数从非托管堆读取整数。
  • WriteByte 允许我们一次向非托管内存写入一个字节。要写入字节,我们需要设置要写入的内存地址。我们可以设置从给定地址开始的写入字节的整数偏移量。我们可以使用此函数将字节从流逐字节写入非托管堆。
  • WriteInt16(32,64) 允许我们一次向非托管内存写入(16、32、64)位 `Integer`。要写入 `Integer`,我们需要设置要写入的内存地址。我们可以设置从给定地址开始的写入整数的字节偏移量。我们可以使用此函数将整数写入非托管堆。
  • StringToHGlobalUni(Ansi,auto) 允许我们将字符串(Unicode 或 ANSI 格式)从托管堆复制到非托管堆。此函数在堆中分配内存空间,复制字符串并返回地址。但是,我们无法使用此函数,因为我们无法设置复制字符串的地址。如果我们无法将复制位置设置为 MMF 视图的基地址,我们就无法使用此值。
  • PtrToStringUni(Ansi,Auto) 将字符串(Unicode 或 ANSI 格式)从给定地址复制到托管字符串。我们可以使用此函数从非托管堆复制字符串。
  • Copy 是最实用的方法。它可以复制 `byte`、`char`、`double`、`short`、`int` 和 `long` 数组在托管和非托管堆之间。要执行复制功能,需要一个内存地址作为操作的起始点。还需要数组、设置操作起始数组元素的索引以及复制字节的长度。只要我们知道复制对象的字节大小,就可以使用此函数在托管和非托管之间复制任何数据。

在熟悉了 `Marshal` 函数和序列化限制后,我们将创建一个 `MemoryStream` 对象,该对象可以使用序列化提供所需的功能,并通过直接写入非托管堆来写入数据,而无需序列化。如前所述,我们不知道对象的大小。在这种情况下,我们将使用序列化来获取一个具有已知大小的流(这是格式化器内置功能的一部分)。如果我们知道对象的大小,这意味着字符串、整数类型或数组(字符串除外),我们将直接将数据写入非托管堆,而无需使用序列化。新类将被命名为 `MapViewStream`。

CLR `MemoryStream` 中的大部分功能都能满足我们的需求,因此通过继承它,我们可以节省编写代码的时间。作为私有数据,我们将保存 `MapView` 函数返回的基地址。我们将在我们编写的几乎所有函数中使用此数据。

public class MapViewStream : MemoryStream //, IDisposable
{
    private IntPtr m_baseaddress = IntPtr.Zero;

当用户使用 `MapView` 函数返回一个可以跨非托管代码传输数据的 `MemoryStream` 时,将创建此对象。要处理此类流,我们需要 `MapViewOfFile` 函数在 `MapView` 函数中返回的基地址,以及包含 `MemoryStream` 数据的字节数组。我们通过将数据从非托管代码复制到字节数组来获取包含数据的字节数组。最后一个参数表示流是否可写。在构造函数中,我们调用基类,传入字节数组和可写指示。基地址存储在类的私有成员中。

    public MapViewStream(IntPtr baseaddress, byte[] bytes,bool iSWritable) :
     base(bytes,iSWritable)
    {
        m_baseaddress = baseaddress;
    }

存储数据并创建基类后,我们无需重载常规的 `Read` 函数。我们已经在流中有字节数组,因此无需执行特殊操作即可读取它。常规的 `Read` 用于反序列化类型。对于序列化字节的写入,我们需要创建一个新的 `Write` 函数。此函数以字节数组和流长度作为参数。在此函数中,我们使用 `Marshal.Copy` 函数将字节数组写入非托管堆。

    public void Write ( byte[] buffer, int count ) 
    {
        try
        {
            Marshal.Copy(buffer,0,m_baseaddress,count); 
        }
        catch(Exception Err)
        {
            throw Err;
        }
    }

我们将添加一个接收字符串作为参数的 `Write` 函数。此函数将字符串写入非托管堆。为了复制字符串,我们将使用 `Marshal.Copy` 方法,并向其发送一个字符数组。我们能够不使用序列化而写入字符串,因为字符串保存了其字节长度。

    public void Write (string str) 
    {
        try
        {
            Marshal.Copy(str.ToCharArray (),0,m_baseaddress,str.Length);
        }
        catch(Exception Err)
        {
            throw Err;
        }
    }

不带参数的 `Read` 函数被添加到将字符串从非托管堆读取到托管字符串。每个字符串都以 null 终止。`PtrToStringUni` 查找此项以了解字符串何时结束。我们必须向 `PtrToStringUni` 函数提供要开始查找字符串终止符的地址。这是我们在 `Write` 函数(映射视图基地址)中提供的基地址。有了字符串的起始地址,函数会收集字节直到遇到 null 终止符,然后将收集到的字节作为字符串返回。

    public string Read () 
    {
        try
        {
            return Marshal.PtrToStringUni(m_baseaddress);
        }
        catch(Exception Err)
        {
            throw Err;
        }
    }

`Flush` 函数用作一种方法,将流中所做的更改反映到基媒体。在我们的例子中,我们需要使用 `FlushViewOfFile` 并以基地址作为参数,将我们在映射内存中所做的更改反映到我们映射的文件中。

    public override void Flush()
    {
        base.Flush();
        Win32MapApis.FlushViewOfFile(m_baseaddress,0);  
    }

当我们关闭 `MemoryStream` 时,我们也希望确保更改得到反映。我们通过调用 `Flush` 函数来实现这一点。关闭 `MemoryStream` 后,我们无法读写映射文件视图,因此最好取消映射 MMF 对象视图。

    public override void Close()
    {
        Flush();
        base.Close(); 
        Win32MapApis.UnmapViewOfFile(m_baseaddress);
    }
}

在本节中,我们构建了一个继承自 `MemoryStream` 的类,它可以从非托管代码读写数据,并允许我们操作数据。此类是本解决方案的关键。我们使用它一方面从 MMF 视图读写数据,另一方面序列化/反序列化对象或直接向 MMF 视图读写对象。在下一节中,我们将研究如何实现此解决方案的逻辑。

缓存数据

我们的目标是实现进程间数据共享。由于 CLR 缺少对象大小信息,我们缓存的大多数对象将使用序列化。基本上,我们可以将对象序列化到文件并同步对文件的访问。这样,每个进程都可以通过反序列化来获取对象。但是,此解决方案的问题在于它很慢,因为每次我们想从文件中反序列化对象时都需要进行 IO 操作,并且还有线程同步(访问资源)的开销。或者,使用 MMF 我们可以更有效。我们只需要创建 MMF 对象一次。然后每个进程将 MMF 对象映射到其内存中的一个空间。这样,我们可以通过 MMF 对象的名称在进程间通信,并提高速度,因为读取值仅仅是读取内存中的数据而不是文件。

在大多数情况下,我们将对象序列化/反序列化到/从文件,然后通过 MMF 访问文件,就像访问内存一样。好消息是序列化功能大部分内置于 CLR 中,所以我们可以直接使用它。只有一个例外。如果我们想序列化我们的类型,我们需要实现 `Iserializable`。实现将迫使我们创建一个特殊构造函数来读取公共和私有数据(反序列化),然后调用 `GetObjectData` 函数来写入数据(序列化)。另一种更简单的方法是使用 `serializable` 属性。此属性表示该类是可序列化的;因此,类型中的每个成员(私有或公共)都将被 CLR 序列化。这两种方法之间的区别在于,第一种方法更灵活,并且在序列化过程中给我们更多的控制。(例如,它可以用于将数据序列化为可以直接写入内存的长字符串)。请记住,如果有人想使用我们的机制来存储他的类型,他将不得不负责序列化。我们稍后将看到如何使用序列化。

在此解决方案中,我们维护缓存。这意味着我们需要在我们的机制中保留大量对象。我们需要监控缓存的对象,以便当我们从缓存中获取特定对象时,我们知道对象“是谁”,它有多长,以及它存储在哪里。知道对象“是谁”的简单方法是给它一个名称,后面可以跟着对象长度。这样,每个进程都可以通过使用其名称从缓存中获取相同的对象。哈希表对象看起来是最适合快速存储和检索数据的对象,但使用此对象会导致性能问题。每次调用和更改(添加、获取、更改、删除对象)机制时都会使用哈希表。每次我们需要更改哈希表时,都需要使用序列化,因为我们无法知道其长度。你可能还记得,序列化比直接处理内存更耗时。因此,为了更具动态性,我们将缓存的对象保存起来,并在字符串中管理数据,我们可以直接将这些字符串读/写到非托管代码。

我们还需要考虑的另一个问题是,我们将缓存的数据保存在哪里。基本上,有两种方法可以解决这个问题。第一种是将所有数据保存在同一个 MMF 对象(文件)中。这种方法要求我们知道托管缓存数据字符串的动态大小,以便提取字符串,并知道请求对象的位置及其长度。在这种情况下,我们将保存缓存对象在内存中的地址偏移量。除了缓存对象偏移量,我们还需要将托管缓存数据字符串的长度保存在前 4 个字节中。通过这种方法,我们将只有一个文件可以映射。使用另一种方法,我们将为每个对象创建一个文件。我们将对象序列化或直接写入内存,并将它们反映到文件中。这样,我们只需要保留每个对象映射的文件的名称。这里的优点是我们不需要保存托管缓存数据字符串的大小和每个对象的位置,因为每个对象都将被分配一个特殊文件来保存其数据。此外,这种方法允许我们保留存在于唯一文件中的对象数据,这些文件可以在计算机关机后保留。使用文件更容易维护快速改变大小的数据。第二种选择主要是为了便于存储快速变化的对象。为了简化,我们将每个后备文件命名为对象存储名称,以便我们可以通过名称知道对象的位置。

为了在同一个地方维护我们创建的所有后备文件,请设置特殊文件夹 `MMFfiles` 来保存这些文件。此文件夹中的每个文件都将具有用户为对象指定的名称,并加上 `.nat` 扩展名。缓存对象管理数据字符串将始终命名为 `ObjectNamesMMF`。

`DevCache` 类的目的是封装缓存机制逻辑,并为最终用户提供一个简单直观的界面。我们将允许用户缓存对象,但如果发现请求的缓存对象是字符串,我们将不使用序列化对其进行缓存。这种方法可以提高性能;因此,`DevCache` 接口包含三个函数:

  • `AddObject` 负责向缓存添加新对象或更新现有对象的内容。该函数将添加或更新缓存中的条目,管理字符串,并创建或更新文件和 MMF 对象。
  • `GetObject` 将在缓存管理字符串中查找对象名称,并从 MMF 对象中反序列化或直接获取对象并返回给调用者。
  • `RemoveObject` 通过从缓存管理字符串中删除其名称,关闭其 MMF 对象,并删除文件来删除对象。

除了公共函数外,还有一些私有函数负责整个过程中的特殊任务。我们在处理此类时会遍历这些函数。

`DevCache` 包含四个私有成员:`m_StringMMFs` 存储所有未经序列化添加的对象。我们需要此列表来知道在用户调用时如何从内存中获取它们。`oMutex` 是一个命名 `Mutex` 的实例。我们将使用 `Mutex` 作为不同进程中试图同时访问共享内存的线程之间的访问同步。`oStringMMF` 存储非序列化对象的列表,以便我们可以跨进程和计算机关机来保留这些数据。`ObjectNamesMMF` 是一个 `const`,保存缓存管理数据 MMF 对象的名称。

public class DevCache  
{
    int m_StringMMFs="";
    private System.Threading.Mutex oMutex =
         new System.Threading.Mutex(false,"MmfUpdater");
    MemoryMappedFile oStringMMF = new MemoryMappedFile();
    private const string ObjectNamesMMF = "ObjectNamesMMF";

写入 MMF

在本节中,我们将介绍实际将缓存对象写入 MMF 内存的私有函数。我们支持通过序列化和不使用序列化来写入对象。`WriteString2MMF` 将字符串写入 MMF 内存。正如在检查请求缓存对象的类型后所看到的,如果我们发现对象是字符串,我们就会调用此函数。此函数始终用于写入缓存对象管理数据字符串。

    private int WriteString2MMF(string InObject, string obectName)
    {
        MemoryMappedFile map = new MemoryMappedFile();

将大小变体设置为字符串对象长度。我们将使用它来打开 MMF 对象并映射 MMF 视图。

        int iSize = InObject.Length;
        oMutex.WaitOne ();

我们使用 `OpenEx` 来发现是否有已打开的 MMF 对象或持有 MMF 数据的文件可用于打开 MMF 对象。如果我们未能打开 MMF 对象,我们将创建一个新的。

        if (!map.OpenEx (iSize,obectName + ".nat",MapProtection.PageReadWrite,
             obectName,MapAccess.FileMapAllAccess))
        map = new MemoryMappedFile (obectName + ".nat",
         MapProtection.PageReadWrite,
         MapAccess.FileMapAllAccess,iSize,obectName);

调用 `MapView` 来获取 `MapViewStream` 对象。

        MapViewStream  stream = map.MapView(MapAccess.FileMapAllAccess, 0, 
            (int)iSize,obectName + ".nat" );

将字符串传递给 `MapViewStream.Write` 以将字符串写入内存。

        stream.Write(InObject);
        stream.Close();
        oMutex.ReleaseMutex();
        return iSize;
    }

`WriteObjectToMMF` 接收缓存对象、其名称和大小。通过这些参数,该函数尝试在序列化的辅助下写入 MMF 内存。在使用序列化之前,函数会检查 `InObject` 参数的类型。如果 `InObject` 是字符串,我们调用 `WriteString2MMF` 函数以提高性能。

    private int WriteObjectToMMF(object InObject, string obectName,int ObjectSize)
    {

检查 `InObject` 类型以查看它是否为字符串。

        if (InObject.GetType()  == typeof(String) )
        {

将 `objectName` 添加到持有缓存中所有对象的 MMF,不使用序列化。然后将字符串写入 MMF 内存区域。

            this.StringMMFs = obectName;
            return WriteString2MMF(InObject.ToString(), obectName); 
        }
        MemoryMappedFile map = new MemoryMappedFile();
        MemoryStream ms = new MemoryStream ();
        BinaryFormatter bf= new BinaryFormatter();
        int iSize = 0;

使用二进制格式化器将对象序列化到流并获取其大小。

        bf.Serialize (ms,InObject);
        iSize = (int)ms.GetBuffer().Length;
        oMutex.WaitOne ();

使用对象名称打开 MMF 对象。

        if (!map.OpenEx (iSize,obectName + ".nat",MapProtection.PageReadWrite,
         obectName,MapAccess.FileMapAllAccess))
            map = new MemoryMappedFile (obectName + ".nat",
             MapProtection.PageReadWrite,MapAccess.FileMapAllAccess,
             iSize,obectName);

从 MMF 视图获取 `MapviewStrem` 对象,并将其发送到流字节数组以写入内存。

        MapViewStream  stream = map.MapView(MapAccess.FileMapAllAccess, 0,
         (int)iSize,obectName + ".nat" );
        stream.Write(ms.GetBuffer(),iSize);

更新 MMF 对象并取消映射 MMF 对象视图。

        stream.Close();
        oMutex.ReleaseMutex();
        return iSize;
    }

`StringMMFs` 属性可以获取和设置未序列化存储在缓存中的对象名称列表。我们需要此列表来从缓存中调用对象。如果我们尝试检索未经序列化存储的对象,将导致错误。要从 MMF 对象检索字符串,我们尝试打开 MMF。如果失败,将返回空字符串。如果成功,我们将使用 `MapViewStream.Read` 从 MMF 内存中获取字符串。

    if (!oStringMMF.OpenEx(4,"stringMmf.nat", MapProtection.PageReadWrite,
         "StringMmf",MapAccess.FileMapAllAccess))
            return "";

    MapViewStream stream = oStringMMF.MapView(MapAccess.FileMapAllAccess,0,4,
         "stringMmf.nat");

    string str = stream.Read();
    stream.Close();
    return str; 

设置字符串中的值更复杂。在设置字符串时,我们希望在字符串不存在时添加新的对象名称,并在用户替换字符串中任何对象的对象类型时从列表中删除对象。如果我们无法打开 MMF 对象,我们将创建一个新的,使用现有的 `m_StringMMFs` 字符串(添加新值后)来设置其大小和映射视图大小。

    if(m_StringMMFs.IndexOf (value) == -1 || value == "")
    {
        m_StringMMFs += value;
        if (!oStringMMF.OpenEx(m_StringMMFs.Length ,"stringMmf.nat",
             MapProtection.PageReadWrite,"StringMmf",
             MapAccess.FileMapAllAccess))
            
            oStringMMF = new MemoryMappedFile( "stringMmf.nat",
             MapProtection.PageReadWrite,
             MapAccess.FileMapAllAccess,m_StringMMFs.Length,
             "StringMmf");
        MapViewStream stream = oStringMMF.MapView(MapAccess.FileMapAllAccess,
         0,m_StringMMFs.Length, "stringMmf.nat"); 

如果字符串中的最后一个对象名称被删除,我们需要清除最后一个对象名称。

            if (m_StringMMFs == "")
                stream.Write("      ");
        else
            stream.Write(m_StringMMFs & "*");
        stream.Close();
    }

AddObject

添加对象是一项复杂的任务。在某些情况下,我们可以完成此功能:

  • 第一次创建缓存对象管理字符串和给定对象。
  • 已存在缓存对象管理字符串,并且我们希望添加一个新对象。在这种情况下,我们需要在缓存对象管理字符串中添加一个新条目,并为给定对象创建一个文件和 MMF 对象。
  • 请求的缓存对象已存在于缓存对象管理字符串中,但用户请求用新数据替换缓存对象 MMF。

为了检查我们是否在第一次尝试时访问了该机制,我们将使用 `MemoryMapFile` 类的 `OpenEx` 函数。此方法将尝试按请求的对象名称打开 MMF 对象。如果打开失败,该函数将查找持有数据作为请求对象 MMF 的物理文件。如果找到物理文件,则函数将基于物理文件数据创建一个新的 MMF 对象。如果未找到物理文件,则函数返回 false,我们知道这是该对象首次添加。当请求的对象名称是缓存对象管理数据字符串的名称时,我们就知道这是请求首次到达该机制。当第一次到达机制时,我们将使用 `WriteObjectToMMF/ WriteString2MMF` 函数来创建物理文件、MMF 对象,并将它们加载缓存对象数据。首先,我们处理请求对象,然后使用其大小和名称,我们处理缓存对象管理数据。

当存在缓存对象管理字符串 MMF 时,我们需要从内存中调用该字符串并检查请求的对象名称是否存在于字符串中。如果对象名称不存在,我们将使用 `WriteObjectToMMF/ WriteString2MMF` 函数为给定缓存对象创建物理文件和 MMF 对象,然后将它们加载缓存对象数据。之后,我们需要将对象名称和大小添加到缓存对象管理数据字符串,并使用更改反射缓存对象管理数据字符串 MMF 对象。

如果请求的缓存对象名称存在于缓存对象管理数据字符串中,我们只需要用新值更新给定对象的 MMF 对象。

public void AddObject(string objName, object inObject, bool UpdateDomain)
{
   MemoryMappedFile map = new MemoryMappedFile();

创建持有缓存对象管理数据字符串的字符串生成器。

   System.Text.StringBuilder  oFilesMap= new System.Text.StringBuilder()
   int iSize = 0;
   oMutex.WaitOne ();
   try
   {

检查缓存对象管理数据字符串是否存在 MMF。

if (! map.OpenEx(0,ObjectNamesMMF + ".nat",MapProtection.PageReadWrite ,
 ObjectNamesMMF,MapAccess.FileMapAllAccess))
      {

如果它不存在,则创建 MMF 并用请求的缓存对象数据填充它。将新缓存对象及其大小添加到缓存对象管理数据字符串。为缓存对象管理数据字符串创建一个 MMF,并写入字符串内容。

         //Create MMF for the object and serialize it
         iSize = WriteObjectToMMF(inObject,objName,0);
         //add object name and mmf name to hash
         oFilesMap.Append(objName + "#" + System.Convert.ToString(iSize) +
              "@");
         //create main MMF
         WriteString2MMF(oFilesMap.ToString(),ObjectNamesMMF);
      }
      else
      {
         BinaryFormatter bf = new BinaryFormatter();

如果缓存对象管理数据字符串 MMF 存在,则调用其内容。

         MapViewStream mmfStream = map.MapView(MapAccess.FileMapAllAccess, 0,
             0,ObjectNamesMMF + ".nat");
         mmfStream.Position = 0;
         oFilesMap.Append (mmfStream.Read());
         long StartPosition = mmfStream.Position;
         mmfStream.Close ();

检查缓存对象管理数据字符串是否包含请求的缓存对象名称。

         if (oFilesMap.ToString().IndexOf(objName + "#") > -1 )
         {

如果请求的缓存对象存在,我们需要更改其内容。在这样做时,我们需要检查新数据是字符串还是对象,并按要求行事。

            MemoryMappedFile MemberMap = new MemoryMappedFile();
            bf = new BinaryFormatter();
            MemoryStream ms = new MemoryStream ();

检查请求的缓存对象数据类型是否为字符串。如果不是,我们需要使用序列化来获取对象大小。

            if (inObject.GetType() != typeof(String))
               bf.Serialize (ms,inObject);
            iSize = (int)ms.GetBuffer().Length;

打开请求的现有缓存对象 MMF 对象。

            MemberMap.OpenEx(iSize,objName + ".nat",
                MapProtection.PageReadWrite,
                objName,MapAccess.FileMapAllAccess);  
            MapViewStream stream = MemberMap.MapView
                (MapAccess.FileMapAllAccess, 0,iSize,objName + ".nat");
            stream.Position = 0;

再次检查类型。如果不是字符串,我们使用序列化字节数组写入数据,并从持有非序列化对象的字符串中删除请求的缓存对象。如果类型是字符串,我们只发送字符串进行写入,并将请求的缓存对象添加到非序列化字符串中。

            if (inObject.GetType() != typeof(String))
            {
              stream.Write(ms.GetBuffer(),iSize);
              m_StringMMFs = m_StringMMFs.Replace (objName,"");
              StringMMFs = "";
            }
            else
            {
              stream.Write (inObject.ToString());
              iSize = inObject.ToString().Length;
              StringMMFs = objName;
            }
            stream.Close();

更改并更新缓存对象管理数据字符串中对象的新大小。

            string[] str = oFilesMap.ToString().Split('@');
            for(int i = 0; i < str.Length; i++)
            {
              if (str[i].IndexOf (objName) > -1)
              {
                string strVal = str[i].Substring( str[i].IndexOf('#')+1);
                oFilesMap.Replace(str[i],objName + "#" + iSize);
                break;
              }
            }
            WriteString2MMF(oFilesMap.ToString() ,ObjectNamesMMF);
          }
          else
          {

如果请求的缓存对象名称不存在于缓存对象管理数据字符串中。我们将为新对象创建一个文件和 MMF,并用新对象数据加载它们。

            iSize = WriteObjectToMMF(inObject,objName,0);

然后,我们将更新缓存对象管理数据字符串及其 MMF 对象。

            MapViewStream stream = map.MapView (MapAccess.FileMapAllAccess,
               0,0,ObjectNamesMMF + ".nat"  );
            // update the main HashTable
            oFilesMap.Append(objName + "#" + System.Convert.ToString(iSize)
                + "@");
            // serialize new Hash
            stream.Write (oFilesMap.ToString());
            stream.Position = 0;
            stream.Close();
         }
      }
   }
   catch (Exception e)
   {
      throw new Exception("Cannot Open File "+objName,e);
   }
   finally
   {
      oMutex.ReleaseMutex ();
   }
}

GetObject

从缓存中获取对象非常简单。我们从缓存对象管理字符串中获取对象名称。如果对象名称存在,我们可以打开代表该对象的 MMF 对象,从 MMF 中反序列化或读取对象并将其返回给调用者。

public object GetObject(string objName)
{
    MemoryMappedFile map = new MemoryMappedFile();
    MemoryMappedFile mapOfName = new MemoryMappedFile();
    string oFilesMap = "";
    try
    {
        oMutex.WaitOne ();

检查缓存对象管理数据字符串是否存在。如果不存在,则返回 null。

        if (! map.OpenEx (0,ObjectNamesMMF + ".NAT",MapProtection.PageReadWrite
                 ,ObjectNamesMMF,MapAccess.FileMapAllAccess  ))
            throw new Exception ("No Desc FileFound");

从 MMF 对象获取字符串。

        BinaryFormatter bf = new BinaryFormatter();
        MapViewStream  mmfStream = map.MapView (MapAccess.FileMapAllAccess,
             0, 0,ObjectNamesMMF + ".NAT");
        mmfStream.Position = 0;
        oFilesMap = mmfStream.Read ();
        long StartPosition = mmfStream.Position;

检查请求的名称是否存在。如果不存在,则返回 null。

        if (oFilesMap.IndexOf(objName + "#") == -1)
            throw new Exception ("No Name Found");
        string strValSize = "";

从缓存对象管理数据字符串获取请求的文件大小。

        string[] str = oFilesMap.Split('@');
        for(int i = 0; i < str.Length; i++)
        {
            if (str[i].IndexOf (objName) > -1)
            {
                strValSize = str[i].Substring( str[i].IndexOf('#')+1);
                break;
            }
        }

打开请求的对象 MMF 对象。

        if(! mapOfName.OpenEx ( Convert.ToInt32(strValSize), objName +
                         ".NAT",MapProtection.PageReadWrite ,
                         objName,MapAccess.FileMapAllAccess ))
            throw new Exception ("No Name File Found");
        mmfStream.Close();
        mmfStream = null;
        MapViewStream ObjStream = mapOfName.MapView(MapAccess.FileMapAllAccess,
             0, Convert.ToInt32(strValSize) ,objName+".NAT");
        ObjStream.Position = 0;
        object oRV;

如果请求的对象名称存在于非序列化文件中,则读取数据。如果存在,则反序列化读取对象。

        if (this.StringMMFs.IndexOf(objName) > -1  )
            oRV = ObjStream.Read();
        else
            oRV = bf.Deserialize(ObjStream) as object;

        ObjStream.Close ();
        return oRV;
    }
    catch
    {
        return null;
    }
    finally
    {
        oMutex.ReleaseMutex ();
    }
}

RemoveObject

要删除对象,我们首先需要读取缓存对象管理数据字符串,从管理字符串中删除对象名称的条目,并将更新后的字符串写入 MMF 对象。然后,我们使用 `MemoryMapFile` 对象的 `Open` 方法来检查请求对象的 MMF 对象是否已打开。如果是,我们关闭 MMF 对象。现在只需要删除保存对象数据的物理文件。

public void RemoveObject(string ObjName)
{

如果我们成功打开缓存对象管理数据 MMF。

    MemoryMappedFile map = new MemoryMappedFile();
    if ( map.OpenEx(0,ObjectNamesMMF + ".nat",MapProtection.PageReadWrite, 
            ObjectNamesMMF,MapAccess.FileMapAllAccess))
    {

从缓存对象管理数据字符串和非序列化对象字符串中删除对象名称。

        BinaryFormatter bf = new BinaryFormatter();
        MapViewStream mmfStream = map.MapView(MapAccess.FileMapAllAccess, 0,
                                 0,"");
        mmfStream.Position = 0;
        string oFilesMap = mmfStream.Read();
        int iEntryStart = oFilesMap.IndexOf(ObjName);
        string Entry =  oFilesMap.Substring(iEntryStart, 
            oFilesMap.IndexOf("@",iEntryStart)+1 - iEntryStart);  
        oFilesMap = oFilesMap.Replace(Entry,"");
        mmfStream.Write(oFilesMap);
        mmfStream.Flush();
        mmfStream.Close();

删除对象的映射。

        MemoryMappedFile oMMf = new MemoryMappedFile ();
        if( oMMf.Open(MapAccess.FileMapAllAccess,ObjName))
        {
            oMMf.Close();
            oMMf.Dispose();
        }
        if (System.IO.File.Exists(map.GetMMFDir() + ObjName + ".nat"))
            System.IO.File.Delete(map.GetMMFDir() + ObjName + ".nat");
    }
}

更新和锁定机制。

在本节中,我们将讨论两个密切相关的议题。我们可以构建一个机制,当我们在缓存中添加或更新对象时,该机制可以更新域中的另一个机器。这里的问题在于,这个问题本身就是一个独立的课题,所以我将继续讨论第二个议题。在这里,我们将在尝试同时更改同一内存区域的不同线程和进程之间创建同步。我们将通过使用互斥锁(Mutex)来完成这项任务。我们将创建一个具有已知名称的互斥锁对象,以便附加此 DLL 的所有进程都将使用相同的互斥锁作为它们之间同步访问的方式。

现在让我们看看互斥锁是如何集成到我们的代码中的。我们已经看到了属于 DevCache 类私有成员的 System.Threading 中的互斥锁类的声明。我们为构造函数提供了两个参数。第二个参数是互斥锁的名称。通过这个名称,第一个调用构造函数的线程将创建互斥锁。另一个进程将通过名称获取对互斥锁的句柄。指示谁将获得互斥锁的初始所有权的第一个参数应设置为 false。

private System.Threading.Mutex oMutex = new
 System.Threading.Mutex(false,"MmfUpdater");

所有读写 MMF 的函数都实现了阻塞。我们所需要做的就是使用互斥锁的 WaitOne 方法来阻塞线程,如果另一个线程持有该互斥锁。然后,当我们想释放阻塞时,我们必须调用 ReleaseMutexReleaseMutex 将释放互斥锁并通知正在等待的其他线程,它们现在可以采取行动了。我们可以调用带有参数的 WaitOne 来设置等待超时时间,或者不带参数,表示无限等待。

MemoryStream ms = new MemoryStream ();
BinaryFormatter bf= new BinaryFormatter();

bf.Serialize (ms,InObject);
oMutex.WaitOne ();
MemoryMappedFile map = new MemoryMappedFile(obectName + ".nat",
 MapProtection.PageReadWrite,MapAccess.FileMapAllAccess,  ms.Length  ,
 obectName);
MapViewStream stream = map.MapView(MapAccess.FileMapAllAccess, 0,
 (int) ms.Length,"" );
stream.Write(ms.GetBuffer() , 0,(int)ms.Length);
stream.Flush();
stream.Close();
oMutex.ReleaseMutex();

示例应用程序

示例应用程序演示了如何使用缓存及其实现的性能优势。该示例允许您从英文单词列表中检查单词。如果单词不存在于列表中,应用程序会要求其拼写。该示例需要读取磁盘上存在的文件中的所有单词。正如您可能知道的,如果我们只从文件中读取一次,然后我们激活的每个示例实例直接从内存中读取数据,效率可能会更高。为此,我们将尝试从缓存中打开数据。如果我们从缓存中获取数据,我们将使用它。如果不是,我们需要从文件中读取单词,将其添加到排序列表中,然后将排序列表添加到缓存中。清除按钮会清除 MMF,以便从文件读取数据。该示例还显示了每项操作花费的毫秒数。

要激活示例,您需要解压缩文件 cacheDemo.zip。然后打开 cacheDemo 解决方案并激活 winApplication (cacheDemo)。

文章回顾

在本文中,我们仔细研究了内存映射文件作为进程间共享数据的方式。在此研究期间,我们考察了如何在托管代码和非托管代码之间封送数据。我们构建了一个封装 API 函数的类,以便我们可以从 .NET 访问它。为了使用 MMF,我们创建了一个使我们能够享受 MMF 所有功能的类。为了读写 MMF 对象,我们创建了一个继承自 MemoryStream 的类。这个类使我们能够读写托管内存和非托管内存之间的数据。该类可以直接读取字符串,也可以通过序列化读取对象。您可以在任何需要使用 MMF 的解决方案中使用这些类。在我们的解决方案中,我们决定使用序列化来存储和检索 MMF 中的对象。我们不知道对象的大小。我们发现使用序列化而不是直接写入对象会损害机器的性能。

在创建了能够读写 MMF 对象的类之后,我们构建了一个处理我们解决方案逻辑的类。该类负责我们在 MMF 中添加、更新、获取和删除对象的每种情况。该类还检查缓存中的对象及其位置。为了防止来自不同进程的线程同时访问 MMF,我们使用了 Mutex 类。

此解决方案对于多种任务非常有用。第一个也是最适合的任务是缓存很少更改但应用程序在服务器或客户端上频繁请求的数据。有了这个功能,我们可以从数据库加载数据列表,然后每个进程都可以轻松快速地找到数据并使用它。使用此机制的另一种可能性是共享进程间数据。这种情况在 Web 服务器中可以看到,如果 Web 服务器使用的是注册为 COM+ 服务器应用程序的 DLL。这些 DLL 在 Web 服务器(dllhost.exe)以外的进程中运行,因此存在管理它们之间状态数据的问题。使用此功能,网页可以将数据写入 MMF,并且任何 DLL,无论它在哪个进程中运行,都可以找到该数据。

© . All rights reserved.