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

动态进程间共享内存

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.92/5 (11投票s)

2006年9月11日

12分钟阅读

viewsIcon

71699

downloadIcon

969

一种在进程之间共享任意大小的多个条目的方法。

Screenshot

引言

这是一个简单的类,它允许多个进程共享同一块内存。这个类的不同之处在于内存可以增长/缩小,使您能够共享任意数量的数据。

背景

创建一个新程序时,内存管理是一个常见问题。当创建多线程应用程序时,这个问题会加剧,需要使用临界区、互斥体和/或信号量。但当涉及到多个进程时,没有简单的共享数据的方法。当我开始创建这个类时,我查找并阅读了许多关于进程间通信的教程。虽然它们写得很好,但没有一个解决了我的问题。我读过的每一篇文章都展示了如何在进程之间共享一个字符串。没有一篇展示了如何共享多个字符串、多种数据类型,甚至可变大小的数据。这正是我想要的。

我决定创建自己的方法,而不是浪费时间去寻找示例。这个类解决了我的两个担忧:多个字符串和可变大小的数据。

工作原理

在进程之间共享内存很简单。CreateFileMapping() 函数为我们完成了大部分工作。它可以创建一个文件,该文件可以位于硬盘驱动器上,也可以是系统页面文件中的临时文件。当两个或多个进程想要共享内存时,它们只需要使用相同的文件名调用此函数。但存在限制。首先,文件在关闭之前无法调整大小,其次,没有方便的方法将多个条目写入文件。

第一个问题可以通过使用磁盘上的物理文件来解决。通过这样做,您可以指定一个大小,打开文件时文件将增长到所需的大小。缺点是应用程序必须处理文件的创建和删除;此外,将私有数据存储在任何人都可以读取的地方存在安全风险。使用物理文件时,还可以使用 DeviceIoControl() 函数使文件可增长,但这仅适用于 NTFS5 分区,而 Win95/98 用户则无法使用。

多个条目

本质上,内存映射文件就是一个大的字节数组。要写入数据,我们只需要调用基本的内存函数 memset()memcpy()memmove()。我们也可以像操作任何其他数组一样写入数组,方法是获取元素的指针/位置并更改它。因此,要写入多个条目,我们只需要写入,增加指针,然后再次写入。但第二个进程呢?它怎么知道您将数据写到了哪里,数据的大小,甚至是否写入了数据?

第一个问题很简单,我们按顺序写入数据。读者只需要解析字节数组直到找到他们想要的数据。第二个问题可以通过将数据大小与数据一起写入来解决。第三个问题需要用户自己处理。对于添加到流中的每个条目,都需要一个唯一的 ID。我曾想,如果一个进程想要读取共享内存,那么它肯定知道一些关于它想要读取的内容。在我的例子中,我想读取可能存在也可能不存在的字符串。因此,对于我想要添加的每个条目,我都定义了一个包含唯一 ID 的 #define 语句。如果我想添加相同类型的条目,我只需使用 #define 作为基数,加上计数器来循环。

#define SMTP_BODY               0
#define SMTP_SUBJECT            1
...
#define SMTP_SENDERNAME         9
...
#define SMTP_RECIPIENT          20
#define SMTP_CCRECIPIENT        30
...
#define SMTP_ATTACHEDNAME       200
#define SMTP_ATTACHEDFILE       300
#define SMTP_ATTACHEDTYPE       400

正如您所见,对于可能存在多个实例的任何条目,例如 SMTP_ATTACHEDNAME,我可以通过一个循环,将 i 添加到 SMTP_ATTACHEDNAME 的值来创建我的唯一 ID。

因此,现在,对于写入流的每个条目,都会存储另外两个条目。这实际上对我们有利。读取流时,我们只需要读取 ID,读取大小,然后跳转到下一个 ID。此外,我们不需要按特定顺序存储任何内容。该类为添加的每个条目分配了额外的 8 字节:4 字节用于 ID,4 字节用于大小。这似乎是浪费,但它为我们提供了更多的 ID 空间,并允许添加更大的条目。

BOOL CMemMap::AddString(LPCTSTR szString, UINT uId)
{
    // Validate the ID
    if ( uId == 0xFFFFFFFF || uId == 0xFFFFFF00 )
        return FALSE;

    LPBYTE    lpBytePos    = 0;
    UINT    uPage        = 0;

    // Check if the id already exists
    if ( FindID(uId,&uPage,&lpBytePos) == TRUE )
        return FALSE;

    // Calc how many bytes we need
    UINT uStrlen = (_tcslen(szString) + 1 ) * sizeof(TCHAR);

    Write(&uPage, &lpBytePos, 4,       &uId);
    Write(&uPage, &lpBytePos, 4,       &uStrlen);
    Write(&uPage, &lpBytePos, uStrlen, (LPVOID)szString);
    Write(&uPage, &lpBytePos, 4,       DOUBLE_NULL);

    return TRUE;
}

就像任何字符串一样,使用一个特殊标记 0xFFFFFF00 来标记数组的结尾。所有空闲空间都用 0xFFFFFFFF 标记为未分配。所有输入的 ID 和大小都将是无符号整数格式,因此从较高范围选择一个标记可以防止冲突,尽管这会阻止这两个十六进制值被用作 ID。

动态调整大小

如上所述,调整文件大小时涉及几个步骤,并且还需要考虑安全性。当使用系统的页面文件时,数据只是临时的。这意味着关闭文件句柄时,数据将丢失。我决定从另一个角度着手,将页面文件本身作为我想法的基础。我们不是创建一个单一文件,而是创建一本包含多个文件或页面的书。

这有优点也有缺点。我们不再处理一个简单的字节数组,而是处理多个字节数组。可以随时添加页面,但不能保证它们是连续的。读者还需要知道是否添加了页面以及在任何给定时间有多少页面。因此,第一页的前四个字节用作页面计数。任何时候读者/写入者想要执行一个操作,它都可以通过查看此值来快速调整其内部页面数组。

因此,在类作用域存在期间,第一页必须始终存在。另外,每页的大小必须完全相同。下一个问题出现在读写数据时。如果我们创建一个 56K 的页面并添加一个 238K 的位图图像,它将无法容纳。答案是跨页。读写需要更多的工作,但数据仍然可以保持连续。

这些条目序列将整个结构维系在一起。因此,任何时候删除一个条目,我们都不能仅仅擦除已使用的空间而留下一个空洞,否则读取器在跳转 ID 时会遇到麻烦。我们必须将所有后续条目向下移动以填补空洞。我们不是逐个条目地执行此操作(这将很慢),而是逐块内存地执行。

// uSize      == size of the void
// uRemaining == size of the data
// lpDestPos  == start of void
// lpBytePos  == start of data

// loop through remaining pages
while ( 1 ) {
    // move data into void
    memmove(lpDestPos,lpBytePos,uRemaining);

    // reset pointers
    if ( uPage < m_uPageCount-1 ) {
        uPage += 1;
        lpBytePos = (LPBYTE)m_pMappedViews[uPage];
        lpDestPos += uRemaining;
    }
    else {
        // no more pages
        break;
    }

    // move from next page into void
    memmove(lpDestPos,lpBytePos,uSize);

    // reset the pointers
    lpBytePos += uSize;
    lpDestPos = (LPBYTE)m_pMappedViews[uPage];
}

处理字符串也有助于提高性能。请记住,我们实际上处理的是一个字节数组,并且所有字符串都以 null 结尾。因此,要从文件中读取字符串,我们只需要找到起始位置。这个指针可以用于任何字符串函数,因为字节数组也会存储 null 值。唯一不能使用的情况是字符串跨越页面。在这种情况下,我们需要将每一半复制到一个单独的缓冲区。

LPCTSTR CMemMap::GetString(UINT uId)
{
    // Validate the ID
    if ( uId == 0xFFFFFFFF || uId == 0xFFFFFF00 )
        return NULL;

    LPTSTR lpString    = NULL;    // The string to return
    LPBYTE lpBytePos   = 0;        // a navigation pointer
    UINT   uPage       = 0;

    // Check if the id already exists
    if ( FindID(uId,&uPage,&lpBytePos) == FALSE )
        return NULL;

    UINT uLen = 0;
    Read(&uPage,&lpBytePos,4,NULL);
    Read(&uPage,&lpBytePos,4,&uLen);

    // Check if the string is spanned
    UINT uRemaining = ((UINT)m_pMappedViews[uPage] + 
                        MMF_PAGESIZE) - (UINT)lpBytePos;
    if ( uLen > uRemaining ) {

        // delete previous buffer if used
        if ( m_lpReturnBuffer )
            delete [] m_lpReturnBuffer;

        // allocate new buffer
        m_lpReturnBuffer = new BYTE [uLen];
        return (LPTSTR)Read(&uPage,&lpBytePos,uLen,m_lpReturnBuffer);
    }
    else
        return (LPTSTR)Read(&uPage,&lpBytePos,uLen,NULL);
}

读写二进制数据到文件也采用类似的方法,只是在读取数据时,它必须首先复制到一个缓冲区。该类为此提供了两种方法:一种是写入用户输入的缓冲区,另一种是写入内部缓冲区并返回一个指针。然后,这可以转换为您指定的数据类型。

读写锁定

互斥代码我无法归功于我,它来自于我研究期间找到的另一篇文章。该代码由 Alex Farber 编写,文章可以在这里找到 [^]。在我的应用程序中,我需要同时从多个进程读取多个条目。为每次调用使用互斥体是不可取的且效率低下。Alex Farber 的类允许多个进程读取数据,但只允许一个进程写入。它完美地满足了我的需求。为了方便起见,我在代码中保留了它,但您可能希望使用自己的方法。

使用代码

  • #define MMF_PAGESIZE 4096

    每个页面的字节大小。如果您希望将默认页面大小从 4K 更改为您自己的大小,请使用此 #define。在包含头文件之前将此语句添加到您的代码中,否则将使用默认值。如果您要向文件添加大项目,我建议将其设置为更高的值,因为它将减少跨页的数量并提高性能。

  • DWORD Create(LPCTSTR szMappedName, DWORD dwWaitTime, ULONG ulMappedSize);

    在任何读写操作之前都应调用此函数。必须将共享内存的唯一名称传递给 szMappedName,所有希望共享内存的进程都必须使用相同的名称。dwWaitTime 是互斥体的超时时间(以毫秒为单位),此参数可以是 INFINITEulMappedSize 是共享内存的初始大小(以字节为单位)。该值将向上舍入到 MMF_PAGESIZE 边界。如果此值小于 MMF_PAGESIZE,则改用 MMF_PAGESIZE 的值。

    如果共享内存已被创建,内存大小将是已创建文件的内存大小。如果成功创建文件,函数返回 ERROR_SUCCESS;如果文件是由另一个进程创建的,则返回 ERROR_ALREADY_EXISTS。失败时,返回 GetLastError() 的值。

  • BOOL Close();

    关闭映射文件的所有打开句柄。析构函数默认会调用此函数。

  • VOID Vacuum();

    当删除多个条目时,打开的句柄会保持打开状态。因此,共享文件的大小保持不变。调用此函数将关闭所有未使用的页面,释放用于管理它们的内存。

  • BOOL AddString(LPCTSTR szString, UINT uId);

    将字符串添加到文件。uId 参数必须是唯一值。如果 ID 已存在或函数失败,它将返回 FALSE

  • BOOL UpdateString(LPCTSTR szString, UINT uId);

    替换具有相同 uId 的存储项。如果 ID 不存在,则添加一个新项并返回 TRUE。如果函数失败,则返回 FALSE

  • UINT GetString(LPCTSTR szString, UINT uLen, UINT uId);

    uLen 字节读入 szString。如果 szString 参数为 NULL,则返回字符串长度(包括 null 终止符)以字节为单位。szString 必须是一个已分配的足够大的缓冲区来容纳 uLen 字节。

  • UINT GetStringLength(UINT uId);

    字节为单位返回 uId 的字符串长度,包括 null 终止符。

  • LPCTSTR GetString(UINT uId);

    返回指向 null 终止字符串的指针。建议您将此字符串复制到您自己的分配缓冲区中,因为文件的内部结构可能会发生变化,导致指针失效。

  • BOOL AddBinary(LPVOID lpBin, UINT uSize, UINT uId);

    将二进制数据(intlongstruct...)添加到文件。在 uSize 参数中指定数据类型的大小。如果函数失败,则返回 FALSE

  • BOOL UpdateBinary(LPVOID lpBin, UINT uSize, UINT uId);

    添加或替换存储在 uId 的数据。

  • UINT GetBinary(LPVOID lpBin, UINT uSize, UINT uId);

    uSize 的二进制数据读入 lpBin。如果 lpBin 参数为 NULL,则函数返回数据的大小。如果 uSize 参数大于存储的数据大小,则使用存储的数据大小。

  • UINT GetBinarySize(UINT uId);

    返回二进制数据的大小(以字节为单位)。

  • LPVOID GetBinary(UINT uId);

    返回指向二进制数据的指针。建议您复制数据,因为文件的内部结构可能会发生变化,导致指针失效。

  • BOOL DeleteID(UINT uId);

    从文件中删除指定的 uId。内部内存不会被释放。要释放任何已使用的内存,您必须调用 Vacuum()

  • UINT Count();

    返回当前存储的条目数。此函数作用不大,主要用于调试。

  • UINT64 UsedSize();

    返回内部文件的实际已用字节数。此函数作用不大,主要用于调试。

  • BOOL WaitToRead();

    尝试获取对共享文件的读取访问权限。读取可以与其他进程共享。读取完成后,您必须调用 Done(),否则您将阻止任何尝试写入的进程。

  • BOOL WaitToWrite();

    尝试获取对文件的写入访问权限。写入访问优先于任何和所有读取器,并且同一时间只有一个进程可以写入文件。写入完成后,您必须调用 Done()

  • BOOL Done();

    您必须在调用 WaitToRead()WaitToWrite() 以及完成任何读取或写入操作后调用此函数。这将释放锁,使另一个进程能够写入。

我很抱歉没有提供演示应用程序,我只是想不出一个合适的演示来展示这个类能做什么。如果您有任何想法,请告诉我,或者如果您想创建一个演示,我很乐意将其包含在文章中。

该类使用起来非常简单,如下面的示例所示。在调用任何函数之前,必须先调用 Create() 方法。大多数错误由函数返回,但在极少数情况下,可能会抛出异常,因此将代码包装在 try...catch 块中是一个好习惯。如果您决定使用内部锁定机制,请务必调用 Done() 来释放锁以便其他进程使用。不这样做不会阻止其他进程读取,但会阻止其他进程写入。

int main()
{
    CMemMap mmp;
    unsigned int i;
    double j = -123.456;

    try 
    {
        mmp.Create(_T("594855C7-9888-465a-8BC8-D9797874EB9F"),INFINITE,2048);

        if ( mmp.WaitToWrite() ) {

            for (i=0; i<3; i++,j*=7.23) {
                wcout << _T("Adding Binary: ") << j << endl;
                mmp.AddBinary(&j,sizeof(double),i);
            }
            
            for (i=0,j=0; i<3; i++) {
                mmp.GetBinary(&j,sizeof(double),i);
                wcout << _T("GetBinary Returned: ") << j << endl;
            }
            
            for (i=0; i<3; i++,j*=7.23) {
                wcout << _T("Updating binary to: ") << j << endl;
                mmp.UpdateBinary(&j,sizeof(double),i);
            }
            
            for (i=0,j=0; i<3; i++) {
                mmp.GetBinary(&j,sizeof(double),i);
                wcout << _T("GetBinary Returned: ") << j << endl;
            }

            for (i=0; i<3; i++) {
                wcout << _T("Deleting ID: ") << i << endl;
                mmp.DeleteID(i);
            }

            for (i=0; i<3; i++) {
                wcout << _T("Adding string \"Hello World!\"") << endl;
                mmp.AddString(_T("Hello World!"),i);
            }

            for (i=0; i<3; i++) {
                wcout << _T("GetString Size Returned: ");
                wcout << (UINT)mmp.GetString(0,0,i) << endl;
            }

            for (i=0; i<3; i++) {
                wcout << _T("GetString returned: ");
                wcout << (LPCTSTR)mmp.GetString(i) << endl;
            }

            for (i=0; i<3; i++) {
                wcout << _T("Deleting ID: ") << i << endl;
                mmp.DeleteID(i);
            }

            wcout << _T("Freeing the memory") << endl;
            mmp.Vacuum();

            wcout << _T("Releasing lock") << endl;
            mmp.Done();
        }
    }
    catch (LPCTSTR sz)
    {
        wcout << sz << endl;
    }

    char c(' ');

    while (c != 'q' && c != 'Q')
    {
        cout << "Press q then enter to quit: ";
        cin >> c;
    }
    return 0;
}

我的最新项目需要多个进程读取/写入/存储许多字符串。其中一些字符串的大小高达 10 MB(base64 编码的文件)。当我开始这个项目时,我做的第一件事是创建一个处理这些字符串的类。当时,所有数据都存储在该类中。当出现多进程的概念时,我意识到我无法以这种方式存储字符串。因此,在创建这个类之后,我就不再需要这样做了。与其调用 new 分配一个缓冲区然后将字符串复制进去,不如直接将字符串存储到共享文件中。任何时候类成员想要使用该字符串,我只需使用 GetString() 返回的指针。

待办事项列表

  • 创建一个演示应用程序。
  • 所有读取和写入的进程必须使用相同的字符串格式。如果 ANSI 版本写入一个字符串,它将以 ANSI 格式存储,但如果 UNICODE 版本尝试读取相同的字符串,则会出现问题。
  • 可能实现一种枚举所有存储条目的方法。

历史

  • 9月10日(教师节)- 发布 1.0 版本。
© . All rights reserved.