动态进程间共享内存






3.92/5 (11投票s)
2006年9月11日
12分钟阅读

71699

969
一种在进程之间共享任意大小的多个条目的方法。
引言
这是一个简单的类,它允许多个进程共享同一块内存。这个类的不同之处在于内存可以增长/缩小,使您能够共享任意数量的数据。
背景
创建一个新程序时,内存管理是一个常见问题。当创建多线程应用程序时,这个问题会加剧,需要使用临界区、互斥体和/或信号量。但当涉及到多个进程时,没有简单的共享数据的方法。当我开始创建这个类时,我查找并阅读了许多关于进程间通信的教程。虽然它们写得很好,但没有一个解决了我的问题。我读过的每一篇文章都展示了如何在进程之间共享一个字符串。没有一篇展示了如何共享多个字符串、多种数据类型,甚至可变大小的数据。这正是我想要的。
我决定创建自己的方法,而不是浪费时间去寻找示例。这个类解决了我的两个担忧:多个字符串和可变大小的数据。
工作原理
在进程之间共享内存很简单。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
是互斥体的超时时间(以毫秒为单位),此参数可以是INFINITE
。ulMappedSize
是共享内存的初始大小(以字节为单位)。该值将向上舍入到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);
将二进制数据(
int
、long
、struct
...)添加到文件。在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 版本。