这是关于 Linux 中进程间通信 (IPC) 系列文章的第一篇。本系列使用 C 代码示例来阐明以下 IPC 机制
- 共享文件
- 共享内存(带信号量)
- 管道(命名和未命名)
- 消息队列
- 套接字
- 信号
本文回顾了一些核心概念,然后继续介绍前两个机制:共享文件和共享内存。
核心概念
进程是正在执行的程序,每个进程都有自己的地址空间,其中包括进程允许访问的内存位置。一个进程有一个或多个执行线程,它们是可执行指令的序列:单线程进程只有一个线程,而多线程进程有多个线程。进程内的线程共享各种资源,特别是地址空间。因此,进程内的线程可以通过共享内存直接通信,尽管一些现代语言(例如,Go)鼓励更规范的方法,例如使用线程安全的通道。这里感兴趣的是,默认情况下,不同的进程不共享内存。
启动随后进行通信的进程有多种方法,以下示例中主要使用两种方法
- 使用一个终端启动一个进程,也许使用另一个不同的终端启动另一个进程。
- 在一个进程(父进程)中调用系统函数 fork 以派生另一个进程(子进程)。
第一个示例采用终端方法。代码示例可以在我的网站上的 ZIP 文件中找到。
共享文件
程序员都非常熟悉文件访问,包括程序中使用文件时遇到的许多陷阱(文件不存在、文件权限错误等等)。尽管如此,共享文件可能是最基本的 IPC 机制。考虑一个相对简单的情况,其中一个进程(生产者)创建文件并写入文件,另一个进程(消费者)从同一个文件读取
writes +-----------+ reads
producer-------->| disk file |<-------consumer
+-----------+
使用此 IPC 机制的明显挑战是可能会出现竞争条件:生产者和消费者可能会在完全相同的时间访问文件,从而使结果不确定。为了避免竞争条件,必须以防止写入操作与任何其他操作(无论是读取还是写入)之间发生冲突的方式锁定文件。标准系统库中的锁定 API 可以总结如下
- 生产者应在写入文件之前获得文件的独占锁。一个独占锁最多只能由一个进程持有,这排除了竞争条件,因为在锁释放之前,没有其他进程可以访问该文件。
- 消费者应在从文件读取之前至少获得文件的共享锁。多个读取者可以同时持有共享锁,但即使只有一个读取者持有共享锁,也没有写入者可以访问文件。
共享锁提高了效率。如果一个进程只是读取文件而不更改其内容,则没有理由阻止其他进程执行相同的操作。但是,写入显然需要对文件的独占访问。
标准 I/O 库包含一个名为 fcntl 的实用函数,该函数可用于检查和操作文件上的独占锁和共享锁。该函数通过文件描述符工作,文件描述符是一个非负整数值,在进程中标识一个文件。(不同进程中的不同文件描述符可能标识同一个物理文件。)对于文件锁定,Linux 提供了库函数 flock,它是 fcntl 的一个薄封装。第一个示例使用 fcntl 函数来公开 API 细节。
示例 1. 生产者程序
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define FileName "data.dat"
#define DataString "Now is the winter of our discontent\nMade glorious summer by this sun of York\n"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1); /* EXIT_FAILURE */
}
int main() {
struct flock lock;
lock.l_type = F_WRLCK; /* read/write (exclusive versus shared) lock */
lock.l_whence = SEEK_SET; /* base for seek offsets */
lock.l_start = 0; /* 1st byte in file */
lock.l_len = 0; /* 0 here means 'until EOF' */
lock.l_pid = getpid(); /* process id */
int fd; /* file descriptor to identify a file within a process */
if ((fd = open(FileName, O_RDWR | O_CREAT, 0666)) < 0) /* -1 signals an error */
report_and_exit("open failed...");
if (fcntl(fd, F_SETLK, &lock) < 0) /** F_SETLK doesn't block, F_SETLKW does **/
report_and_exit("fcntl failed to get lock...");
else {
write(fd, DataString, strlen(DataString)); /* populate data file */
fprintf(stderr, "Process %d has written to data file...\n", lock.l_pid);
}
/* Now release the lock explicitly. */
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("explicit unlocking failed...");
close(fd); /* close the file: would unlock if needed */
return 0; /* terminating the process would unlock as well */
}
上面生产者程序中的主要步骤可以总结如下
- 程序声明了一个 struct flock 类型的变量,它表示一个锁,并初始化了结构的五个字段。第一个初始化
lock.l_type = F_WRLCK; /* exclusive lock */
使锁成为独占(读写)锁,而不是共享(只读)锁。如果生产者获得锁,那么在生产者显式调用 fcntl 释放锁或隐式关闭文件之前,没有其他进程能够写入或读取文件。(当进程终止时,任何打开的文件都将自动关闭,从而释放锁。)
- 然后,程序初始化其余字段。主要效果是要锁定整个文件。但是,锁定 API 仅允许锁定指定的字节。例如,如果文件包含多个文本记录,则可以锁定单个记录(甚至记录的一部分),而其余部分保持未锁定状态。
- 对 fcntl 的第一次调用
if (fcntl(fd, F_SETLK, &lock) < 0)
尝试独占锁定文件,检查调用是否成功。通常,fcntl 函数返回 -1(因此,小于零)表示失败。第二个参数 F_SETLK 表示对 fcntl 的调用不阻塞:该函数立即返回,要么授予锁,要么指示失败。如果改为使用标志 F_SETLKW(末尾的 W 表示 wait),则对 fcntl 的调用将阻塞,直到可以获得锁为止。在对 fcntl 的调用中,第一个参数 fd 是文件描述符,第二个参数指定要执行的操作(在本例中,是用于设置锁的 F_SETLK),第三个参数是锁结构的地址(在本例中,是 &lock)。
- 如果生产者获得锁,则程序将两个文本记录写入文件。
- 在写入文件后,生产者将锁结构的 l_type 字段更改为 解锁 值
lock.l_type = F_UNLCK;
并调用 fcntl 执行解锁操作。程序最后关闭文件并退出。
示例 2. 消费者程序
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#define FileName "data.dat"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1); /* EXIT_FAILURE */
}
int main() {
struct flock lock;
lock.l_type = F_WRLCK; /* read/write (exclusive) lock */
lock.l_whence = SEEK_SET; /* base for seek offsets */
lock.l_start = 0; /* 1st byte in file */
lock.l_len = 0; /* 0 here means 'until EOF' */
lock.l_pid = getpid(); /* process id */
int fd; /* file descriptor to identify a file within a process */
if ((fd = open(FileName, O_RDONLY)) < 0) /* -1 signals an error */
report_and_exit("open to read failed...");
/* If the file is write-locked, we can't continue. */
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
report_and_exit("file is still write locked...");
lock.l_type = F_RDLCK; /* prevents any writing during the reading */
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("can't get a read-only lock...");
/* Read the bytes (they happen to be ASCII codes) one at a time. */
int c; /* buffer for read bytes */
while (read(fd, &c, 1) > 0) /* 0 signals EOF */
write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */
/* Release the lock explicitly. */
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("explicit unlocking failed...");
close(fd);
return 0;
}
消费者程序比必要的更复杂,目的是突出显示锁定 API 的功能。特别是,消费者程序首先检查文件是否被独占锁定,然后才尝试获得共享锁。相关代码是
lock.l_type = F_WRLCK;
...
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
report_and_exit("file is still write locked...");
在 fcntl 调用中指定的 F_GETLK 操作检查锁,在本例中,是上面第一个语句中给出的独占锁 F_WRLCK。如果指定的锁不存在,则 fcntl 调用会自动将锁类型字段更改为 F_UNLCK 以指示此事实。如果文件被独占锁定,则消费者终止。(更健壮的程序版本可能会让消费者 sleep 一会儿,然后重试几次。)
如果文件当前未锁定,则消费者尝试获得共享(只读)锁(F_RDLCK)。为了缩短程序,可以删除对 fcntl 的 F_GETLK 调用,因为如果某个其他进程已经持有读写锁,则 F_RDLCK 调用将失败。回想一下,只读锁确实会阻止任何其他进程写入文件,但允许其他进程从文件读取。简而言之,共享锁可以由多个进程持有。在获得共享锁后,消费者程序从文件中一次读取一个字节,将字节打印到标准输出,释放锁,关闭文件并终止。
以下是从同一终端启动的两个程序的输出,其中 % 作为命令行提示符
% ./producer
Process 29255 has written to data file...
% ./consumer
Now is the winter of our discontent
Made glorious summer by this sun of York
在第一个代码示例中,通过 IPC 共享的数据是文本:莎士比亚戏剧《理查三世》中的两行。然而,共享文件的内容可能是大量的任意字节(例如,数字电影),这使得文件共享成为一种非常灵活的 IPC 机制。缺点是文件访问相对较慢,无论访问涉及读取还是写入。与往常一样,编程伴随着权衡。下一个示例的优点是通过共享内存(而不是共享文件)进行 IPC,从而提高了性能。
共享内存
Linux 系统为共享内存提供了两个独立的 API:传统的 System V API 和更新的 POSIX API。但是,不应在单个应用程序中混合使用这些 API。POSIX 方法的一个缺点是功能仍在开发中,并且依赖于已安装的内核版本,这会影响代码的可移植性。例如,默认情况下,POSIX API 将共享内存实现为内存映射文件:对于共享内存段,系统维护一个具有相应内容的后备文件。POSIX 下的共享内存可以配置为没有后备文件,但这可能会影响可移植性。我的示例使用带有后备文件的 POSIX API,它结合了内存访问(速度)和文件存储(持久性)的优点。
共享内存示例有两个程序,名为 memwriter 和 memreader,并使用信号量来协调它们对共享内存的访问。每当共享内存与写入者一起出现时,无论是在多进程还是多线程中,都会存在基于内存的竞争条件的风险;因此,信号量用于协调(同步)对共享内存的访问。
memwriter 程序应首先在其自己的终端中启动。然后可以在其自己的终端中启动 memreader 程序(在十几秒钟内)。memreader 的输出是
This is the way the world ends...
每个源文件顶部都有文档,解释了编译期间要包含的链接标志。
让我们首先回顾一下信号量作为同步机制的工作原理。通用信号量也称为计数信号量,因为它有一个值(通常初始化为零),可以递增。考虑一家出租自行车的商店,库存有一百辆自行车,并有一个店员用来进行租赁的程序。每次租出一辆自行车,信号量就递增 1;当自行车归还时,信号量就递减 1。租赁可以继续,直到值达到 100,但然后必须停止,直到至少归还一辆自行车,从而将信号量递减到 99。
二元信号量是一种特殊情况,只需要两个值:0 和 1。在这种情况下,信号量充当 mutex:互斥构造。共享内存示例使用信号量作为互斥锁。当信号量的值为 0 时,只有 memwriter 可以访问共享内存。写入后,此进程将信号量的值递增,从而允许 memreader 读取共享内存。
示例 3. memwriter 进程的源代码
/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1);
}
int main() {
int fd = shm_open(BackingFile, /* name from smem.h */
O_RDWR | O_CREAT, /* read/write, create if needed */
AccessPerms); /* access permissions (0644) */
if (fd < 0) report_and_exit("Can't open shared mem segment...");
ftruncate(fd, ByteSize); /* get the bytes */
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
ByteSize, /* how many bytes */
PROT_READ | PROT_WRITE, /* access protections */
MAP_SHARED, /* mapping visible to other processes */
fd, /* file descriptor */
0); /* offset: start at 1st byte */
if ((caddr_t) -1 == memptr) report_and_exit("Can't get segment...");
fprintf(stderr, "shared mem address: %p [0..%d]\n", memptr, ByteSize - 1);
fprintf(stderr, "backing file: /dev/shm%s\n", BackingFile );
/* semaphore code to lock the shared mem */
sem_t* semptr = sem_open(SemaphoreName, /* name */
O_CREAT, /* create the semaphore */
AccessPerms, /* protection perms */
0); /* initial value */
if (semptr == (void*) -1) report_and_exit("sem_open");
strcpy(memptr, MemContents); /* copy some ASCII bytes to the segment */
/* increment the semaphore so that memreader can read */
if (sem_post(semptr) < 0) report_and_exit("sem_post");
sleep(12); /* give reader a chance */
/* clean up */
munmap(memptr, ByteSize); /* unmap the storage */
close(fd);
sem_close(semptr);
shm_unlink(BackingFile); /* unlink from the backing file */
return 0;
}
以下概述了 memwriter 和 memreader 程序如何通过共享内存进行通信
- 上面显示的 memwriter 程序调用 shm_open 函数以获取系统与共享内存协调的后备文件的文件描述符。此时,尚未分配任何内存。随后调用名称具有误导性的函数 ftruncate
ftruncate(fd, ByteSize); /* get the bytes */
分配 ByteSize 字节,在本例中,是适度的 512 字节。memwriter 和 memreader 程序仅访问共享内存,而不访问后备文件。系统负责同步共享内存和后备文件。
- 然后 memwriter 调用 mmap 函数
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */ ByteSize, /* how many bytes */ PROT_READ | PROT_WRITE, /* access protections */ MAP_SHARED, /* mapping visible to other processes */ fd, /* file descriptor */ 0); /* offset: start at 1st byte */
以获取指向共享内存的指针。(memreader 进行类似的调用。)指针类型 caddr_t 以 c 开头,表示 calloc,这是一个将动态分配的存储初始化为零的系统函数。memwriter 将 memptr 用于后续的写入操作,使用库 strcpy(字符串复制)函数。
- 此时,memwriter 已准备好写入,但它首先创建一个信号量以确保对共享内存的独占访问。如果在 memreader 正在读取时 memwriter 正在写入,则会发生竞争条件。如果对 sem_open 的调用成功
sem_t* semptr = sem_open(SemaphoreName, /* name */ O_CREAT, /* create the semaphore */ AccessPerms, /* protection perms */ 0); /* initial value */
则可以继续写入。SemaphoreName(任何唯一的非空名称都可以)在 memwriter 和 memreader 中标识信号量。零的初始值使信号量的创建者(在本例中为 memwriter)有权继续,在本例中,是写入操作。
- 写入后,memwriter 将信号量值递增到 1
if (sem_post(semptr) < 0) ..
通过调用 sem_post 函数。递增信号量会释放互斥锁,并使 memreader 能够执行其读取操作。为了安全起见,memwriter 还从 memwriter 地址空间取消映射共享内存
munmap(memptr, ByteSize); /* unmap the storage *
这阻止 memwriter 进一步访问共享内存。
示例 4. memreader 进程的源代码
/** Compilation: gcc -o memreader memreader.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1);
}
int main() {
int fd = shm_open(BackingFile, O_RDWR, AccessPerms); /* empty to begin */
if (fd < 0) report_and_exit("Can't get file descriptor...");
/* get a pointer to memory */
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
ByteSize, /* how many bytes */
PROT_READ | PROT_WRITE, /* access protections */
MAP_SHARED, /* mapping visible to other processes */
fd, /* file descriptor */
0); /* offset: start at 1st byte */
if ((caddr_t) -1 == memptr) report_and_exit("Can't access segment...");
/* create a semaphore for mutual exclusion */
sem_t* semptr = sem_open(SemaphoreName, /* name */
O_CREAT, /* create the semaphore */
AccessPerms, /* protection perms */
0); /* initial value */
if (semptr == (void*) -1) report_and_exit("sem_open");
/* use semaphore as a mutex (lock) by waiting for writer to increment it */
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
int i;
for (i = 0; i < strlen(MemContents); i++)
write(STDOUT_FILENO, memptr + i, 1); /* one byte at a time */
sem_post(semptr);
}
/* cleanup */
munmap(memptr, ByteSize);
close(fd);
sem_close(semptr);
unlink(BackingFile);
return 0;
}
在 memwriter 和 memreader 程序中,主要关注的共享内存函数是 shm_open 和 mmap:成功后,第一次调用返回后备文件的文件描述符,第二次调用然后使用该文件描述符来获取指向共享内存段的指针。对 shm_open 的调用在两个程序中类似,除了 memwriter 程序创建共享内存,而 memreader 仅访问此已创建的内存
int fd = shm_open(BackingFile, O_RDWR | O_CREAT, AccessPerms); /* memwriter */
int fd = shm_open(BackingFile, O_RDWR, AccessPerms); /* memreader */
有了文件描述符后,对 mmap 的调用是相同的
caddr_t memptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
mmap 的第一个参数是 NULL,这意味着系统确定在虚拟地址空间中分配内存的位置。也可以(但很棘手)指定地址。MAP_SHARED 标志指示分配的内存在进程之间是可共享的,最后一个参数(在本例中为零)表示共享内存的偏移量应为第一个字节。size 参数指定要分配的字节数(在本例中为 512),保护参数指示共享内存可以写入和读取。
当 memwriter 程序成功执行时,系统会创建并维护后备文件;在我的系统上,该文件是 /dev/shm/shMemEx,其中 shMemEx 是我在共享存储的头文件 shmem.h 中给出的名称。在当前版本的 memwriter 和 memreader 程序中,语句
shm_unlink(BackingFile); /* removes backing file */
删除后备文件。如果省略 unlink 语句,则后备文件会在程序终止后持续存在。
memreader 与 memwriter 一样,通过其名称在对 sem_open 的调用中访问信号量。但是 memreader 然后进入等待状态,直到 memwriter 递增信号量,其初始值为 0
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
一旦等待结束,memreader 从共享内存读取 ASCII 字节,清理并终止。
共享内存 API 包括显式同步共享内存段和后备文件的操作。这些操作已从示例中省略,以减少混乱,并将重点放在内存共享和信号量代码上。
即使删除了信号量代码,memwriter 和 memreader 程序也可能在不引起竞争条件的情况下执行:memwriter 创建共享内存段并立即写入;memreader 甚至在创建共享内存之前都无法访问它。但是,最佳实践要求每当混合使用写入操作时,都应同步共享内存访问,并且信号量 API 非常重要,因此需要在代码示例中突出显示。
总结
共享文件和共享内存示例展示了进程如何通过共享存储进行通信,一种情况是文件,另一种情况是内存段。这两种方法的 API 都相对简单。这些方法是否有共同的缺点?现代应用程序通常处理流数据,实际上是处理海量数据流。共享文件和共享内存方法都不太适合海量数据流。一种或另一种类型的通道更适合。因此,第 2 部分介绍了通道和消息队列,同样使用 C 代码示例。
[下载 Linux 进程间通信完整指南]
1 条评论