共享内存概念
共享内存区是可用IPC形式中最快的。一旦这样的内存区映射到共享它的进程的地址空间,这些进程间数据的传递就不再涉及内核。
然而往该共享内存区存放信息或从中取走 信息的进程间通常需要某种形式的同步。如: 互斥锁、条件变量、读写锁、信号量。
其“不再涉及内核”的含义是:进程不再通过执行任何进入内核的系统调用来彼此传递数据。显然,内核必须建立允许各个进程共享该内存区的内存映射关系,然后一直管理该内存区(处理页面故障等)。
Posix 消息队列可使用内存映射 I/O(mmap 函数)实现。
假设 Posix 消息队列是在内核中实现的,这是另外一种可能实现。然而 PIPE、FIFO 和 SystemV 消息队列的 write 或 msgsnd 都涉及从进程到内核的数据复制,它们的 read 或 msgrcv 都涉及从内核到进程的数据复制。
如下实例:
服务器从输入文件读,文件数据从内核读入自己的进程空间,然后向一个 PIEP、FIFO 或消息队列中写入这些数据,这些 IPC 通常需要把这些数据从进程复制到内核。
客户端从该 IPC 中读出这些数据,通常需要将这些数据从内核复制到进程空间,然后调用 write 函数将进程空间中的数据复制到输出文件,数据又经历了从进程到内核。
假设 Posix 消息队列没有使用内存映射 I/O,那么 read、write 等相关函数都涉及从进程到内核或相反的数据复制。该过程示例图如下:
可以看到效率比较低,我们可以通过多个进程共享一个内存区,这种共享内存区的 IPC 形式提供了绕过上述问题的解决办法。当然必须做同步处理。
使用共享内存后的示例图如下所示:
现在只需要复制两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件。
注意:默认情况下,通过 fork 派生出的子进程并不和父进程共享内存区。看如下实例:
int count;
if (fork() == 0)
{
//... child
}
//... parent
mmap、munmap、msync 函数
- mmap
mmap 函数把一个文件或一个 Posix 共享内存区对象映射到调用进程的地址空间。使用该函数有三个目的:
(1) 使用普通文件以提供内存映射 I/O。
(2) 使用特殊文件以提供匿名内存映射。
(3) 使用 shm_open 以提供无亲缘关系进程间的 Posix 共享内存区。
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
返回值:成功返回被映射区的起始地址,出错返回 MAP_FAILED。
参数说明:
addr:可以指定描述符 fd 应被映射到的进程内空间的起始地址。通常被指定为一个 NULL,这样告诉内核自己去选择起始地址。但无论哪种情况,该函数的返回值都是描述符 fd 所映射到内存区的起始地址。
length:映射到调用进程地址空间中的字节数,它从被映射文件开头第 offser 个字节处开始算。
prot:内存映射区的保护。通常有这几种:PROT_READ 数据可读、PROT_WRITE 数据可写、PROT_EXEC 数据可执行、PROT_NONE 数据不可访问。
flags:常量值指定。MAP_SHARED(变动共享)或 MAP_PRIVATE(变动私自)这两个标志必须指 定一个,并可有选择地或上 MAP_FIXED(准确的解释 addr 参数)。如果指定了 MAP_PRIVATE,那么调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑对象(文件对 象,或者共享内存区对象),如果指定了 MAP_SHARED,那么调用进程对被映射数据所作的修改对于共享该对象的所有进程都可见,而且确实改变了其底层支撑对象。从移植性上考虑,MAP_FIXED 不应该指定。如果没有指定该标志,但是 addr 不是一 个空指针,那么 addr 如何处置取决于实现。不为空的 addr 值通常被当作有关该内存区应如何具体定位的线索。可移植的代码应把 addr 指定成一个空指针,并且不指定 MAP_FIXED。
fd:描述符。mmap 成功返回后,fd 参数可以关闭。该操作对于由 mmap 建立的映射关系没有影响。
offset:映射到调用进程地址空间的偏移数,通常为 0。
内存映射的关系图如下:
父子进程在共享内存区的方法之一是:父进程在调用 fork 前先指定 MAP_SHARED 调用 mmap。Posix 保证父进程中的内存映射关系存留到子进程中。而且父进程所作的修改子进程能看到,反过来也一样。
- munmap
从某个进程的地址空间删除一个映射关系,需要调用 munmap。
#include <sys/mman.h>
int munmap(void *addr, size_t length);
返回值:成功返回 0,出错返回 -1。
参数说明:
addr:由 mmap 函数返回的地址。
length:映射区的大小。
再次访问这些地址将导致向调用进程产生一个 SIGSEGV 信号(假设 mmap 调用并不重用这部分地址空间)。
如果被映射区是使用 MAP_PRIVATE 标志映射的,那么调用进程对它所作的变动都会被丢弃掉。
- msync
内核的虚拟内存算法保持内存映射文件(一般在硬盘上)与内存映射区 (在内存中)的同步,前提是它是一个 MAP_SHARED 内存区。所以,如果我们修改了处于内存映射到某个文件的内存区中某个位置的内容,那么内核将在稍后某个时刻相应地更新文件。然而有时候我们希望确信硬盘上的文件内容与内存映射区中的内容一致,可以调用 msync 来执行这种同步。
函数签名如下:
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);
返回值:成功返回 0,出错返回 -1。
参数说明:
addr:内存中的整个内存映射区。
length:映射区的大小。
flags:取值为:MS_ASYNC 执行异步写、MS_SYNC 执行同步写、MS_INVALIDATE 使高速缓存的数据失效。
MS_ASYNC 和 MS_SYNC 这两个常值中必须指定一个,但不能都指定。它们的差别是,一旦写操作已由内核排入队列,MS_ASYNC 即返回,而 MS_SYNC 则要等到写操作完成后才返回。如果还指定了 MS_INVALIDATE,那么与其最终副本不一致的文件数据的所有内存中副本都失效。后续的引用将从文件中取得数据。
mmap 的描述间接说明了内存映射文件:我们 open 它之后调用 mmap 把它映射到调用进程地址空间的某个文件。使用内存映射文件所得到的奇妙特性是,所有的 I/O 都在内核的掩盖下完成,我们只需编写存取内存映射区中各个值的代码。可以不调用 read、write 或 lseek,这样往往可以简化我们的代码。
但是不是所有的文件都能进程内存映射。例如,试图把一 个访问终端或套接字的描述符映射到内存将导致 mmap 返回一个错误。这些类型的描述符必须使用 read 和 write(或者它们的变体)来访问。
mmap 的另一个用途是在无亲缘关系的进程间提供共享的内存区。而且这些进程对该共享内存区所作的任何变动都复制回所映射的文件(以提供随文件系统的持续性)。这里假设指定了 MAP_SHARED 标志,它是进程间共享内存所需求的。
实例:在父子进程中使用内存映射文件中给计数器持续加 1。
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <pthread.h>
#include <string.h>
#include <sys/mman.h>
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
#define SEM_NAME "mysem"
struct shared
{
sem_t mutex;
int count;
} shared;
int main(int argc, char **argv)
{
int fd, i, nloop;
struct shared *ptr;
if (argc != 3)
{
printf("usage: incr3 <pathname> <#loops>");
exit(1);
}
nloop = atoi(argv[2]);
fd = open(argv[1], O_RDWR | O_CREAT, FILE_MODE);
write(fd, &shared, sizeof(struct shared));
ptr = mmap(NULL, sizeof(struct shared), PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
close(fd);
sem_init(&ptr->mutex, 1, 1);
setbuf(stdout, NULL);
if (fork() == 0) /* child */
{
for (i = 0; i < nloop; i++)
{
sem_wait(&ptr->mutex);
printf("child: %d\n", ptr->count++);
sem_post(&ptr->mutex);
}
exit(0);
}
/* parent */
for (i = 0; i < nloop; i++)
{
sem_wait(&ptr->mutex);
printf("parent: %d\n", ptr->count++);
sem_post(&ptr->mutex);
}
exit(0);
}
该程序中定义一个结构,其中含有整数计数器以及保护它的信号量。该结构将存放到共享内存区对象中。信号量是通过 sem_init 进行初始化的,使用了一个基于内存的信号量(也可以替换为有名信号量),调用 sem_init 把它的值初始化为 1。第二个参数不为 0,表示该信号量将在进程间共享。其实例图如下:
匿名内存映射
上面的例子正确并工作正常,但是必须在文件系统中创建一个文件,然后调用 open,再向文件中 write 一些 0 初始化它。如果调用 mmap 的目的是提供一个将穿越 frok 由父子进程共享的映射内存区,可以简化上述情况,具体方法取决于实现:
- BSD 提供匿名内存映射,它彻底避免了文件的创建和打开。其办法是把 mmap 的 flags 参数指定成 MAP_SHARED | MAP_ANON,把 fd 参数指定为 -1。offset 参数则被忽略。这样的内存区初始化为 0。
```cpp
include
include
include
include
include
include
include
include
include
define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
define SEM_NAME “mysem”
int main(int argc, char *argv) { int i, nloop; int ptr; sem_t *mutex;
if (argc != 2)
{
printf("usage: incr_map_anon <#loops>");
exit(1);
}
nloop = atoi(argv[1]);
ptr = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANON, -1, 0);
mutex = sem_open(px_ipc_name(SEM_NAME), O_CREAT | O_EXCL, FILE_MODE, 1);
sem_unlink(px_ipc_name(SEM_NAME));
setbuf(stdout, NULL);
if (fork() == 0) /* child */
{
for (i = 0; i < nloop; i++)
{
sem_wait(mutex);
printf("child: %d\n", (*ptr)++);
sem_post(mutex);
}
exit(0);
}
/* parent */
for (i = 0; i < nloop; i++)
{
sem_wait(mutex);
printf("parent: %d\n", (*ptr)++);
sem_post(mutex);
}
exit(0);
}
2. SVR4 提供 /dev/zero 设备文件,我们 open 它之后可在 mmap 调用中使用得到的描述符。从该设备读时返回的字节全为0,写往该设备的任何字节则被丢弃。
```cpp
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <pthread.h>
#include <string.h>
#include <sys/mman.h>
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
#define SEM_NAME "mysem"
int main(int argc, char **argv)
{
int fd, i, nloop;
int *ptr;
sem_t *mutex;
if (argc != 2)
{
printf("usage: incr_map_anon <#loops>");
exit(1);
}
nloop = atoi(argv[1]);
fd = open("/dev/zero", O_RDWR);
ptr = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
mutex = sem_open(px_ipc_name(SEM_NAME), O_CREAT | O_EXCL, FILE_MODE, 1);
sem_unlink(px_ipc_name(SEM_NAME));
setbuf(stdout, NULL);
if (fork() == 0) /* child */
{
for (i = 0; i < nloop; i++)
{
sem_wait(mutex);
printf("child: %d\n", (*ptr)++);
sem_post(mutex);
}
exit(0);
}
/* parent */
for (i = 0; i < nloop; i++)
{
sem_wait(mutex);
printf("parent: %d\n", (*ptr)++);
sem_post(mutex);
}
exit(0);
}
访问内存映射的对象
内存映射一个普通文件时,内存中映射区的大小(也就是 mmap 的第二个参数)通常等于该文件的大小。但是文件大小和内存映射区大小可以不同。看如下程序:
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <pthread.h>
#include <string.h>
#include <sys/mman.h>
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
#define max(a, b) ((a) > (b) ? (a) : (b))
#define SEM_NAME "mysem"
int main(int argc, char **argv)
{
int fd, i;
char *ptr;
size_t filesize, mmapsize, pagesize;
if (argc != 4)
{
printf("usage: test1 <pathname> <filesize> <mmapsize>");
exit(1);
}
filesize = atoi(argv[2]);
mmapsize = atoi(argv[3]);
//创建文件并设置其大小,方法是将文件读写指针移动到固定大小-1字节位置,再写一字节
fd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, FILE_MODE);
lseek(fd, filesize - 1, SEEK_SET);
write(fd, "", 1);
ptr = mmap(NULL, mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
//使用sysconf获取系统实现的页面大小并将其输出
pagesize = sysconf(_SC_PAGESIZE);
printf("PAGESIZE = %ld\n", (long)pagesize);
//读出内存映射区中每个页面的首字节和尾字节,并输出它们的值
//把每个页面的这两个字节设置为1
for (i = 0; i < max(filesize, mmapsize); i += pagesize)
{
printf("ptr[%d] = %d\n", i, ptr[i]);
ptr[i] = 1;
printf("ptr[%d] = %d\n", i + pagesize - 1, ptr[i + pagesize - 1]);
ptr[i + pagesize - 1] = 1;
}
printf("ptr[%d] = %d\n", i, ptr[i]);
exit(0);
}
第一种情况是:文件大小等于内存映射区大小,但是这个大小不是页面大小的倍数。
如:页面大小为 4096,但是文件 foo 大小和内存映射区为 5000,我们能够完整读完第 2 页,但访问第 3 页时(下标为 8192)引发 SIGEGV 信号,shell 中将输出 Segmentation Fault(段错误)。内核允许我们读写最后一页中映射区以远部分(内核的内存保护是以页面为单位 的)。但是我们写向这部分扩展区的任何内容都不会写到 foo 文件中。比如我们把 ptr[8191] 设置成 1,但它也不写到 foo 文件中,因而该文件的大小仍然是 5000。
第二种情况是:内存映射区大小指定成大于文件大小。
如:文件 foo 大小为 5000 ,而内存映射大小为 15000。结果与上一个例子类似,但是本例会引发 SIGBUS 信号,而上一个例子会引发 SIGSEGV 信号。差别是:SIGBUS 意味着我们是在内存映射区内访问,但是已超出了底层支撑对象的大小。SIGSEGV:意味着我们已在内存映射区以远访问。
可以看出,内核知道被映射的底层支撑对象(本例子中为文件 foo)的大小,即使该对象的描述符已经关闭也一样。内核允许我们给 mmap 指定一个大于该对象大小的大小参数,但是我们访问不了该对象以远的部分(最后一页上该对象以远的那些字节除外,其下标为5000~8191)。
下面给出一个程序,展示了处理一个持续增长的文件中的一种常用的技巧:指定一个大于该文件大小的内存映射区大小,跟踪该文件的当前大小(以确保不访问 当前文件尾以远的部分),然后就让该文件的大小随着往其中每次写入数据而增长。
#define FILE "test.data"
#define SIZE 32768
int main(int argc, char **argv)
{
int fd, i;
char *ptr;
fd = open(FILE, O_RDWR | O_CREAT | O_TRUNC, FILE_MODE);
ptr = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
for (i = 4096; i <= SIZE; i += 4096)
{
printf("setting file size to %d\n", i);
ftruncate(fd, i);
printf("ptr[%d] = %d\n", i - 1, ptr[i - 1]);
}
exit(0);
}
通过调用 ftruncate 把该文件的大小每次增长 4096 字节,然后取出现在是该文件最后一个字节的那个字节。内核跟踪着被内存映射的底层支撑对象(本例子中为文件 test.data)的大小,而且我们总是能访问在当前文件大小以内又在内存映射区以内的那些字节。
Posix 共享内存区
上面讨论了共享内存区以及 mmap 函数,并给出了使用 mmap 提供父子进程间的共享内存区的示例。现在把共享内存区的概念扩展到将无亲缘关系进程间共享的内存区包括在内。
Posix 提供了两种在无亲缘关系进程间共享内存区的方法:
(1)内存映射文件(memory-mapped file):由 open 函数打开,由 mmap 函数把得到的描述符映射到当前进程地址空间中的一个文件。上面已经描述了这种技术,并给出了用法。内存映射文件也可以在无亲缘关系的进程间共享。
(2)共享内存区对象(shared-memory object):由 shm_open 打开一个 Posix IPC名字(也许是在文件系统中的一个路径名),所返回的描述符由 mmap 函数映射到当前进程的地址空间。
这两种技术都需要调用 mmap,但差别在于作为 mmap 的参数之一的描述符的获取手段不同: 是通过 open 或通过 shm_open。Posix 把两者合称为内存区对象,如下图所示:
shm_open、shm_unlink 函数
- shm_open
Posix共享内存区涉及以下两个步骤要求。
(1)指定一个名字参数调用shm_open,以创建一个新的共享内存区对象或打开一个已 存在的共享内存区对象。
(2)调用 mmap 把这个共享内存区映射到调用进程的地址空间。
传递给 shm_open 的名字参数随后由希望共享该内存区的任何其他进程使用。 Posix共享内存区采用这样的两步过程而不是单个步骤,其原因在于当 Posix 发明自己的共享内存区形式时,mmap 已经存在。shm_open 返回一个描述符 (mq_open 返回一个 mqd_t 值,sem_open 返回一个指向某个 sem_t 值的指针)的原因是:mmap用于把一个内存区对象映射到调用进程地址空间的是该对象的一个已打开描述符。其函数签名如下:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
int shm_open(const char *name, int oflag, mode_t mode);
返回值:成功返回非负的描述符(将用作 mmap 的第五个参数),出错返回 -1。
参数描述:
name:IPC 名,上面已经提到过。
oflag:必须或者含有 O_RDONLY(只读)标志,或者含有 O_RDWR(读写)标志,还可以指定如下标志:O_CREAT、O_EXCL 或 O_TRUNC。如果随 O_RDWR 指定 O_TRUNC 标志,而且所需的共享内存区对象已经存在,那么它将被截短成 0 长度。
mode:指定权限位(之前已经提过),它在指定了 O_CREAT 标志的前提下使用。shm_open 的 mode 参数总是必须指定。如果没有指定 O_CREAT 标志,那么该参数可以指定为 0。
- shm_unlink
shm_unlink 函数删除一个共享内存区对象的名字。跟所有其他unlink函数(比如:删除文件系统中一个路径名的 unlink,删除一个 Posix 消息队列的mq_unlink 等)一样,删除一个名字不会影响对于其底层支撑对象的现有引用, 直到对于该对象的引用全部关闭为止。删除一个名字仅仅防止后续的 open、mq_open 或 sem_open调用取得成功。
函数签名如下:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
int shm_unlink(const char *name);
ftruncate、fstat 函数
- ftruncate
处理 mmap 的时候,普通文件或共享内存区对象的大小都可以通过调用 ftruncate 修改。其函数签名如下:
#include <unistd.h>
#include <sys/types.h>
int ftruncate(int fd, off_t length);
返回值:成功为 0,出错为 -1。
Posix 就该函数对普通文件和共享内存区对象的处理的定义稍有不同。
对于一个普通文件:如果该文件的大小大于 length 参数,额外的数据就被丢弃掉。如果该文件的大小小于 length,那么该文件是否修改以及其大小是否增长是未加说明的。实际上对于一个普通文件,把它的大小扩展到 length 字节的可移植方法是:先 lseek 到偏移为 length-1 处,然后 write 1 个字节的数据。所幸的是几乎所有 Unix 实现都支持使用 ftruncate 扩展一个文件。
对于一个共享内存区对象:ftruncate 把该对象的大小设置成 length 字节。调用 ftruncate 来指定新创建的共享内存区对象的大小,或者修改已存在的对象的大小。
- fstat
当打开一个已存在的共享内存区对象时,我们可调用 fstat 来获取有关该对象的信息。其函数签名如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int fstat(int fd, struct stat *statbuf);
返回值:成功返回 0,出错返回 -1。
stat 结构有 12 个或以上的成员,然而当 fd 指代一个共享内存区对象时,只有四个成员含有信息。
struct stat
{
//...
mode_t st_mode; /* mode: S_I{RW}{USR,GRP,OTH} */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
off_t st_size; /* size in bytes */
};
Posix 并没有指定一个新创建的共享内存区对象的初始内容。关于 shm_open 函数的说明只说:新创建的共享内存区对象的大小应该为 0。Posix 基本原理声称:如果一个内存区对象被扩展,那么扩展部分内容全为 0。
共享内存的读写实例如下:
//write
int main(int argc, char **argv)
{
int i, fd;
struct stat stat;
unsigned char *ptr;
if (argc != 2)
err_quit("usage: shmwrite <name>");
fd = shm_open(argv[1], O_RDWR, FILE_MODE);
fstat(fd, &stat);
ptr = mmap(NULL, stat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
for (i = 0; i < stat.st_size; i++)
*ptr++ = i % 256;
exit(0);
}
//read
int main(int argc, char **argv)
{
int i, fd;
struct stat stat;
unsigned char c, *ptr;
if (argc != 2)
err_quit("usage: shmread <name>");
fd = shm_open(argv[1], O_RDONLY, FILE_MODE);
fstat(fd, &stat);
ptr = mmap(NULL, stat.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
for (i = 0; i < stat.st_size; i++)
if ((c = *ptr++) != (i % 256))
err_ret("ptr[%d] = %d", i, c);
exit(0);
}
再一个简单的例子,说明同一共享内存区对象内存映射到不同进程的地址空间时,起始地址可以不一样。
int main(int argc, char **argv)
{
int fd1, fd2, *ptr1, *ptr2;
pid_t childpid;
struct stat stat;
if (argc != 2)
err_quit("usage: test3 <name>");
shm_unlink(px_ipc_name(argv[1]));
fd1 = shm_open(Px_ipc_name(argv[1]), O_RDWR | O_CREAT | O_EXCL, FILE_MODE);
ftruncate(fd1, sizeof(int));
fd2 = open("/etc/motd", O_RDONLY);
fstat(fd2, &stat);
if ((childpid = fork()) == 0) //child
{
ptr2 = mmap(NULL, stat.st_size, PROT_READ, MAP_SHARED, fd2, 0);
ptr1 = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd1, 0);
printf("child: shm ptr = %p, motd ptr = %p\n", ptr1, ptr2);
sleep(5);
printf("shared memory integer = %d\n", *ptr1);
exit(0);
}
//parent
ptr1 = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd1, 0);
ptr2 = mmap(NULL, stat.st_size, PROT_READ, MAP_SHARED, fd2, 0);
printf("parent: shm ptr = %p, motd ptr = %p\n", ptr1, ptr2);
*ptr1 = 777;
waitpid(childpid, NULL, 0);
exit(0);
}