充分利用共享内存并不总是容易的。在本文中,IBM 的 Sachin Agrawal 与我们共享了他的 C++ 专门技术,展示了面向对象如何去利用一个独特而实用的进程间通信通道的关键优势。
就时间和空间而言,共享内存可能是所有现代操作系统都具备的最高效的进程间通信通道。共享内存 同时将地址空间映射到多个进程:一个进程只需依附到共享内存并像使用普通内存一样使用它,就可以开始与其他进程进行通信。
不过,在面向对象编程领域中,进程更倾向于使用共享对象而不是原始的信息。通过对象,不需要再对对象中容纳的信息进行序列化、传输和反序列化。共享对象也驻留在共享内存中,尽管这种对象“属于”创建它们的进程,但是系统中的所有进程都可以访问它们。因此,共享对象中的所有信息都应该是严格与特定进程无关的。
这与当前所有流行的编译器所采用的 C++ 模型是直接矛盾的:C++ 对象中总是包含指向各种 Vee-Table 和子对象的指针,这些是 与特定进程相关的。要让这种对象可以共享,您需要确保在所有进程中这些指针的目标都驻留在相同的地址。
在一个小的示例的帮助下,本文展示了在哪些情况下 C++ 模型可以成功使用共享内存模型,哪些情况下不能,以及可能从哪里着手。讨论和示例程序都只限于非静态数据成员和虚函数。其他情形不像它们这样与 C++ 对象模型关系密切:静态的和非静态非虚拟的成员函数在共享环境中没有任何问题。每个进程的静态成员不驻留在共享内存中(因此没有问题),而共享的静态成员的问题与这里讨论到的问题类似。
环境假定
本文仅局限于用于 32 位 x86 Interl 体系结构的 Red Hat Linux 7.1,使用版本 2.95 的 GNU C++ 编译器及相关工具来编译和测试程序。不过,您同样可以将所有的思想应用到任意的机器体系结构、操作系统和编译器组合。
示例程序
示例程序由两个客户机构成:shm_client1 和 shm_client2,使用由共享对象库 shm_server 提供的共享对象服务。对象定义在 common.h 中:
清单 1. common.h 中的定义
#ifndef __COMMON_H__
#define __COMMON_H__
class A {
public:
int m_nA;
virtual void WhoAmI();
static void * m_sArena;
void * operator new (unsigned int);
};
class B : public A {
public:
int m_nB;
virtual void WhoAmI();
};
class C : virtual public A {
public:
int m_nC;
virtual void WhoAmI();
};
void GetObjects(A ** pA, B ** pB, C ** pC);
#endif //__COMMON_H__ |
清单 1 定义了三个类(A、B 和 C),它们有一个共同的虚函数 WhoAmI()。基类 A 有一个名为 m_nA 的成员。定义静态成员 m_sArena 和重载操作 new() 是为了可以在共享内存中构造对象。类 B 简单地从 A 继承,类 C 从 A 虚拟地继承。为了确保 A、B 和 C 的大小明显不同,定义了 B::m_nB 和 C::m_nC。这样就简化了 A::operator new() 的实现。GetObjects() 接口返回共享对象的指针。
共享库的实现在 shm_server.cpp 中:
清单 2. 库 - shm_server.cpp
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <errno.h>
#include <stdio.h>
#include <iostream>
#include "common.h"
void * A::m_sArena = NULL;
void * A::operator new (unsigned int size)
{
switch (size)
{
case sizeof(A):
return m_sArena;
case sizeof(B):
return (void *)((int)m_sArena + 1024);
case sizeof(C):
return (void *)((int)m_sArena + 2048);
default:
cerr << __FILE__ << ":" << __LINE__ << " Critical error" <<
endl;
}
}
void A::WhoAmI() {
cout << "Object type: A" << endl;
}
void B::WhoAmI() {
cout << "Object type: B" << endl;
}
void C::WhoAmI() {
cout << "Object type: C" << endl;
}
void GetObjects(A ** pA, B ** pB, C ** pC) {
*pA = (A *)A::m_sArena;
*pB = (B *)((int)A::m_sArena + 1024);
*pC = (C *)((int)A::m_sArena + 2048);
}
class Initializer {
public:
int m_shmid;
Initializer();
static Initializer m_sInitializer;
};
Initializer Initializer::m_sInitializer;
Initializer::Initializer()
{
key_t key = 1234;
bool bCreated = false;
m_shmid = shmget(key, 3*1024, 0666);
if (-1 == m_shmid) {
if (ENOENT != errno) {
cerr << __FILE__ << ":" << __LINE__ << " Critical error" <<
endl;
return;
}
m_shmid = shmget(key, 3*1024, IPC_CREAT | 0666);
if (-1 == m_shmid) {
cerr << __FILE__ << ":" << __LINE__ << " Critical error" <<
endl;
return;
}
cout << "Created the shared memory" << endl;
bCreated = true;
}
A::m_sArena = shmat(m_shmid, NULL, 0);
if (-1 == (int)A::m_sArena) {
cerr << __FILE__ << ":" << __LINE__ << " Critical error" <<
endl;
return;
}
if (bCreated) {
// Construct objects on the shared memory
A * pA;
pA = new A;
pA->m_nA = 1;
pA = new B;
pA->m_nA = 2;
pA = new C;
pA->m_nA = 3;
}
return;
} |
让我们更详细地研究清单 2:
第 9-25 行:operator new ()
同一个重载的操作符让您可以在共享内存中构造类 A、B 和 C 的对象。对象 A 直接从共享内存的起始处开始。对象 B 从偏移量 1024 处开始,C 从偏移量 2048 处开始。
第 26-34 行:虚函数
虚函数简单地向标准输出写一行文本。
第 35-39 行:GetObjects
GetObjects() 返回指向共享对象的指针。
第 40-46 行:初始化器(Initializer)
这个类存储共享内存标识符。它的构造函数创建共享内存及其中的对象。如果共享内存已经存在,它就只是依附到现有的共享内存。静态成员 m_sInitializer 确保在使用共享库的客户机模块的 main() 函数之前调用构造函数。
第 48-82 行:Initializer::Initializer()
如果原来没有共享内存,则创建,并在其中创建共享对象。如果共享内存已经存在,对象的构造就会被跳过。Initializer::m_shmid 记录标识符,A::m_sArena 记录共享内存地址。
即使所有进程都不再使用它了,共享内存也不会被销毁。这样就让您可以显式地使用 ipcs 命令来销毁它,或者使用 ipcs 命令进行一些速查。
客户机进程的实现在 shm_client.cpp 中:
清单 3. 客户机 - shm_client.cpp
#include "common.h"
#include <iostream>
#include <stdlib.h>
int main (int argc, char * argv[])
{
int jumpTo = 0;
if (1 < argc) {
jumpTo = strtol(argv[1], NULL, 10);
}
if ((1 > jumpTo) || (6 < jumpTo)) {
jumpTo = 1;
}
A * pA;
B * pB;
C * pC;
GetObjects(&pA, &pB, &pC);
cout << (int)pA << "\t";
cout << (int)pB << "\t";
cout << (int)pC << "\n";
switch (jumpTo) {
case 1:
cout << pA->m_nA << endl;
case 2:
pA->WhoAmI();
case 3:
cout << pB->m_nA << endl;
case 4:
pB->WhoAmI();
case 5:
cout << pC->m_nA << endl;
case 6:
pC->WhoAmI();
}
return 0;
}
#include <pthread.h>
void DoNothingCode() {
pthread_create(NULL, NULL, NULL, NULL);
} |
第 6-35 行
客户机进程获得指向三个共享对象的指针,建立对它们的数据成员的三个引用,并且 —— 依赖于命令行的输入 —— 调用三个虚函数。
第 36-39 行
没有被调用的 pthread_create() 函数用来强制链接到另一个共享库。来自所有共享库的任何方法都可以满足这一目的。
共享库和客户机可执行文件的两个实例的编译方法如下:
gcc shared g shm_server.cpp o libshm_server.so lstdc++
gcc -g shm_client.cpp -o shm_client1 -lpthread -lshm_server -L .
gcc -g shm_client.cpp -o shm_client2 -lshm_server -L . lpthread |
注意,交换了 shm_client1 和 shm_client2 中 shm_server 和 pthread 的链接顺序,以确保 shm_server 共享库在两个可执行文件中的基址不同。可以使用 ldd 命令进一步验证这一点。示例输出通常如下所示:
清单 4. shm_client1 的库映射
ldd shm_client1
libpthread.so.0 => (0x4002d000)
libshm_server.so => (0x40042000)
libc.so.6 => (0x4005b000)
ld-linux.so.2 => (0x40000000)
清单 5. shm_client2 的库映射
<ccid_nobr>
<table width="400" border="1" cellspacing="0" cellpadding="2"
bordercolorlight = "black" bordercolordark = "#FFFFFF" align="center">
<tr>
<td bgcolor="e6e6e6" class="code" style="font-size:9pt">
<pre><ccid_code> ldd shm_client2
libshm_server.so => (0x40018000)
libpthread.so.0 => (0x40046000)
libc.so.6 => (0x4005b000)
ld-linux.so.2 => (0x40000000) |
这里的主要目的是使构建的两个客户机二进制文件具有不同的服务器库基址。在这个示例程序的上下文中,使用不被调用的 pthread_create() 函数和不同的共享库链接顺序来达到这一目标。不过,没有具体规则或统一步骤可以作用于所有链接;需要根据不同的情况采取不同的方法。
例 1:shm_client1 与 shm_client1
在下面的输出中,首先在 shell 中调用 shm_client1。由于现在没有共享对象,于是 shm_client1 创建了它们,引用它们的数据成员,调用它们的虚函数,然后退出 —— 将对象留在了内存中。第二次,进程只是引用数据成员和虚函数。
清单 6. shm_client1 与 shm_client1 的输出日志
$ ./shm_client1
Created the shared memory
1073844224 1073845248 1073846272
1
Object type: A
2
Object type: B
3
Object type: C
$ ipcs
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x000004d2 2260997 sachin 666 3072 0
$ ./shm_client1
1073840128 1073841152 1073842176
1
Object type: A
2
Object type: B
-> 0
-> Segmentation fault (core dumped) |
当第二个进程试图通过类型 C * 的指针去引用数据成员 A::m_nA 时(您会记得 C 虚拟继承自 A),共享对象内的基子对象(base sub-object)指针会被读取。共享对象是在现在不存在的进程的上下文中构造的。因此,读取 A::m_nA 和 C::WhoAmI() 时读入的是内存垃圾。
因为 Vee-Table 和虚函数位于 shm_server 共享库内部,恰巧在同一虚拟地址被重新加载,所以,再次引用类型 A * 和 B * 的指针时不会观察到任何问题。
因此,GNU 所采用的 C++ 对象模型没有成功地处理虚拟继承。
例 2:shm_client1 与 shm_client2
在下一个示例输出中,在命令行中首先执行 shm_client1,然后执行 shm_client2:
清单 7. shm_client1 与 shm_client2 的输出日志
$ ./shm_client1
Created the shared memory
1073844224 1073845248 1073846272
1
Object type: A
2
Object type: B
3
Object type: C
$ ipcs
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x000004d2 2359301 sachin 666 3072 0
$ ./shm_client2
1073942528 1073943552 1073944576
1
-> Segmentation fault (core dumped)
$ ./shm_client2 3
1073942528 1073943552 1073944576
2
-> Segmentation fault (core dumped)
$ ./shm_client2 5
1073942528 1073943552 1073944576
-> 1048594
-> Segmentation fault (core dumped) |
然而,Vee-Table 位于 shm_server 共享库内部:在 shm_client1 和 shm_client2 中它被加载到不同的虚地址。这样,读取 A::WhoAmI() 和 B::WhoAmI() 时读入的都是内存垃圾。
运用共享内存
在共享内存中具体使用 C++ 对象时您应该考虑两个主要问题。首先,Vee-Table 指针用于访问虚函数,而对数据成员的访问直接使用编译时偏移量实现。因此,对于所有这种共享对象来说,所有进程中的 Vee-Table 和虚函数都应该具有相同的虚地址。关于这一点,没有一成不变的规则,不过,为相关的共享库采用适当的链接顺序大部分时候都会管用。
另外,永远不要忘记,虚拟继承的对象有指向基对象的基指针。基指针引用进程的数据段,而且永远是特定于进程的。难以确保所有的客户机进程中都有相同的数字值。因此,假如使用 C++ 对象模型,要避免在共享内存中构造虚拟继承的对象。但是,也不要忘记,不同的编译器采用的是不同的对象模型。例如,Microsoft Compiler 使用进程无关的偏移量来为虚拟继承类指定基对象,因而不存在这个问题。重要的是,确保所有客户机进程中的共享对象的地址相同。
关于作者
Sachin 五年来一直在从事 C++ 各方面的工作,包括为期三年的对各种编译器的 C++ 对象模型的研究。他当前在 IBM Global Services India 工作。您可以通过 sachin_agrawal@in.ibm.com 与他联系。