进程间通信
进程间通信
进程间通信 (Inter-Process Communication, IPC),进程之间通信的方式有很多种,下图是一个总结:
下图中给出了 IPC 工具之间的比较:
管道
管道,从一端写入数据,可以从另一端读出数据,就像水管一样。管道是使用起来非常方便的一种进程间通信的方式。
下面是一个使用管道的例子:
#include <string.h>
#include <stdio.h>
#include <unistd.h>
int main(){
int fds[2];
if(pipe(fds) < 0){
perror("pipe");
}
char buffer[10];
const char* str = "hello";
// 写入
write(fds[1], str, strlen(str)+1);
// 读取
int n = read(fds[0], buffer, sizeof(buffer));
printf("%s\n", buffer);
return 0;
}
使用 pipe
创建管道,创建完成后,两个文件描述符分别管道的两端。向 fds[1]
写入的数据,可以从 fds[0]
中读取出来。
一个使用 pipe
的场景是,创建管道后,使用 fork
创建子进程,由于父子进程共享文件表,父子进程可以使用管道进行通信。下面是一个例子,在子进程中先把管道的写端口定向到标准输出,然后执行 ls
命令。在父进程中,把管道的读端口定向到标准输入,然后执行 wc
命令。这样子进程中 ls
命令的结果就会传给 wc
命令,最终的效果就等同于在终端上执行 ls | wc
。
#include <string.h>
#include <stdio.h>
#include <unistd.h>
int main(){
int fds[2];
if(pipe(fds) < 0){
perror("pipe");
}
switch (fork()) {
case -1:
perror("fork");
break;
case 0:
close(fds[0]);
dup2(fds[1], STDOUT_FILENO);
execlp("ls", "ls", nullptr);
default:
close(fds[1]);
dup2(fds[0], STDIN_FILENO);
execlp("wc", "wc", nullptr);
}
return 0;
}
在父进程和子进程中,可以关闭掉不需要使用的文件描述符。
在 C 标准库中提供了一个 popen
函数,该函数的第一个参数就是由字符串表示的命令,新建一个进程执行该命令,并使用管道连接两个进程。调用者不用自己创建进程,只需要调用此函数传入参数,就可以打开一个管道。
如下代码,启动 ls
命令,调用方可以在打开的管道上读取到执行结果。
char buf[BUFSIZ];
FILE* fin = popen("ls", "r");
while(fgets(buf, BUFSIZ, fin) != nullptr){
fputs(buf, stdout);
}
pclose(fin);
FIFO
FIFO (First In, First Out) 就像是一个管道。和 pipe
不同,FIFO 是和文件关联起来的,不同的进程通过读或写的方式打开 FIFO 文件,就可以使用该管道了。pipe
只能在同一个进程,或者父子进程之间使用管道,但是 FIFO 可以把毫不相关的进程中使用管道联系起来。
FIFO 文件可以使用命令 mkfifo <filename>
来创建,使用 ls -l
可以看到,管道文件是使用 p 字母来标识的。
$ mkfifo www
$ ls -l
prw-r--r-- 1 wangyu staff 0B Aug 16 22:27 www
在代码中也可以使用 mknod
来创建管道文件:
#include <unistd.h>
#include <fcntl.h>
mknod("www", S_IFIFO | 0666, 0);
管道文件创建好之后,可以使用 open
打开该文件,然后使用 write
写入数据,使用 read
读取。因为可以使用文件名打开 FIFO 文件,因此可以在两个不相关的进程间使用 FIFO 文件来传输信息。一个进程打开 FIFO 的一端,如果对端还没有被打开,那就会被阻塞,除非指定非阻塞式打开。
下面是一个使用管道的例子,有两个程序,一个是发送端一个是接收端。
发送端从标准输入接收用户的键入,然后把内容发送到 FIFO 管道中。
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#define FIFO_NAME "www"
int main(){
if(access(FIFO_NAME, F_OK) == -1){
mknod(FIFO_NAME, S_IFIFO | 0666, 0);
}
char buffer[100];
int fd = open(FIFO_NAME, O_WRONLY);
int n;
do{
if((n = read(STDIN_FILENO, buffer, sizeof(buffer))) == -1){
perror("read");
}else{
buffer[n] = '\0';
write(fd, buffer, n);
}
}while(n > 0);
return 0;
}
接收端,有多个进程,每个进程都尝试中管道中接收数据。接收到数据后,如果内容是 "quit"
就退出,否则打印出接收到的内容。
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <sys/wait.h>
#define FIFO_NAME "www"
int main(){
if(access(FIFO_NAME, F_OK) == -1){
mknod(FIFO_NAME, S_IFIFO | 0666, 0);
}
char buffer[100];
for(int i = 0; i < 5; i++){
if(fork() == 0){
int fd = open(FIFO_NAME, O_RDONLY);
int n;
do{
if((n = read(fd, buffer, sizeof(buffer))) == -1){
perror("read");
}else{
buffer[n] = '\0';
if(strncmp(buffer, "quit", 4) == 0){
close(fd);
exit(0);
}
printf("PID: %d recv: %s\n", (int)getpid(), buffer);
}
}while(n > 0);
exit(1);
}
}
while (wait(nullptr) != -1){}
return 0;
}
FIFO 可以有多个发送方和多个接收方,整个模型是一个多生产者多消费者的模型。当管道中没有内容时,尝试读取数据会阻塞。当没有接收方存在的时,如果写数据,就会接收到信号 SIGPIPE
,默认会结束进程。
FIFO 比起 pipe 强大了很多,在系统中创建一个 FIFO 文件,然后多个进程就可以进行通信了。FIFO 提供的字节流的传输,使用者需要自己设计分包的策略。
文件锁
对文件的某个部门加锁,如果加锁的区域已经部分或全部被锁定了,那么加锁操作就会阻塞,直到加锁的进程解锁为止。如果某个进程对文件加了锁,但是没有释放锁,在进程退出的时候锁会自动释放。
加锁和解锁操作都使用 flock
结构和 fcntl
函数。
struct flock {
off_t l_start; /* 加锁的起始位置 */
off_t l_len; /* 加锁长度,len = 0 表示从起始到文件结束 */
pid_t l_pid; /* 锁的拥有进程 */
short l_type; /* 锁的类型:读、写、清除,可取值为:F_RDLCK, F_WRLCK, F_UNLCK */
short l_whence; /* 和 lseek 中类似,用来指示 start 的类型,SEEK_SET, SEEK_CUR, SEEK_END */
};
fcntl
用来对文件加锁和解锁,其中第二个参数用来配置 fcntl
的行为:
F_SETLKW
:设置文件锁,如果当前文件制定的区域已经加锁,就等到F_SETLK
:设置文件锁,如果已经加锁就返回 -1F_GETLK
:检查当前文件上是否存在一个文件锁和传入的文件锁冲突
下面是一个使用文件锁的例子,把下面这段代码编译后,运行两个进程,你就会发现第二个进程在尝试加锁的时候会被阻塞起来,直到第一个进程释放文件锁。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main(){
struct flock fl;
fl.l_type = F_WRLCK;
fl.l_whence = SEEK_SET;
fl.l_start = 0;
fl.l_len = 0;
fl.l_pid = getpid();
printf("try to get lock\n");
int fd = open("test.txt", O_WRONLY);
fcntl(fd, F_SETLKW, &fl);
printf("got lock\n");
printf("input to release lock\n");
getchar();
printf("release lock");
fl.l_type = F_ULOCK;
fcntl(fd, F_SETLKW, &fl);
return 0;
}
消息队列
消息队列可以提供单向的数据传输服务,发送的内容是一个 struct
,接收方需要使用同样的 struct
来接收。发送的 struct
的第一个字段必须是 mtype
,之后的字段可以随机添加。
struct Message{
long mtype;
char name[20];
int age;
};
在发送和接收的时候,只需要使用相同的结构体,因为相同的结构体才能对数据做相同的解释。
int msgget (key_t __key, int __msgflg); // 获得消息队列
ssize_t msgrcv (int __msqid, void *__msgp, size_t __msgsz, long int __msgtyp, int __msgflg); // 接收
int msgsnd (int __msqid, const void *__msgp, size_t __msgsz, int __msgflg); // 发送
int msgctl (int __msqid, int __cmd, struct msqid_ds *__buf); // 控制消息队列
下面是一个例子,这里在同一个进程中使用同一个消息队列发送和接收数据。
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/msg.h>
#include <sys/ipc.h>
struct message{
long mtype;
char name[30];
};
int main(int argc, char *argv[]){
key_t key = ftok("/home/wy/file", 'a');
int message_queue_id = msgget(key, IPC_CREAT | 0666);
message msg = { 1, "wangyu123"};
msgsnd(message_queue_id, &msg, sizeof(message), 0);
message recv_msg;
msgrcv(message_queue_id, &recv_msg, sizeof(message), 1, 0);
printf("%s\n", recv_msg.name);
msgctl(message_queue_id, IPC_RMID, nullptr);
}
其他进程可以使用相同的 key 来获取到已经创建好的消息队列,并使用它发送数据。不同的消息类型,可以使用不同的结构体,但是因为结构体中第一个字段是固定的,操作系统可以基于此字段得知消息的类型。在接收消息时,需要使用正确的结构体接收,并且指明消息类型。
- 消息队列是使用标识符引用的,而不是文件描述符,没办法和 IO 多路复用的机制联系起来。
- 使用键来标识消息队列会增加程序设计的复杂性。
- 消息队列是无连接的,不知道存在多少个进程引用了该消息队列,不知道何时能安全地删除掉队列
- 消息队列的总数是有限制的
信号量
TODO
共享内存
多个进程共享内存,其中一个进程修改了内存,其他进程都可以看到,这是不是很棒啊。和共享内存相关的 API 有以下几个:
int shmget(key_t, size_t, int);
void *shmat(int, const void *, int);
int shmdt(const void *);
int shmctl(int, int, struct shmid_ds *);
首先使用 shmget
得到共享内存的 id,然后使用 shmat
得到与该 id 关联的共享内存,这里 at 是 attach 的意思。这一步我想是操作系统会进行页表的操作,把共享内存映射到进程空间中。共享内存使用完成后,可以使用 shmdt
来 detach 这块内存。
下面的代码中,我把共享内存强制转换为某个对象的指针,然后在此对象上修改数据。在其他进程可以在此内存上读取到修改后的内容。
#include <string.h>
#include <stdio.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#define SHM_SIZE 1024
struct User{
char name[10];
int age;
};
void writer_fn(){
key_t key = ftok("/home/wy/file", 'a');
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
void* data = shmat(shmid, nullptr, 0);
struct User* w = static_cast<User*>(data);
strcpy(w->name, "wangyu");
w->age = 18;
getchar(); // 阻塞住当前进程
shmdt(data);
}
void reader_fn(){
key_t key = ftok("/home/wy/file", 'a');
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
void* data = shmat(shmid, nullptr, 0);
User* w = static_cast<User*>(data);
printf("name: %s age: %d\n", w->name, w->age);
shmdt(data);
}
int main(int argc, char *argv[]){
// 运行程序的时候传入不同数量的参数,分别执行 write 和 read
if(argc == 2){
writer_fn();
}else if(argc == 3){
reader_fn();
}
}
内存映射
把一个文件的全部或者部分映射到内存中,然后对内存进行读写,而后文件也跟着变化了,这是不是很爽啊。使用 mmap
就可以实现了,其原型如下:
void* mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset)
- addr: 指定想要把文件映射到的地址,通常由操作系统自己选择一块合适的地址,这里填 0 即可
- len: 想要映射多少字节
- prot: 即 “protection”,指定对内存的保护措施,可选项为: PROT_READ, PROT_WRITE, PROT_EXEC,表示读、写、执行。prot 的配置需要和 open 的参数匹配,比如你这里想要能够读写,那么 open 文件的时候,自然也要设置可读
- flags: 可选值有两个 MAP_SHARED, MAP_PRIVATE,如果设置为 share 那么对这块内存的修改会反映到文件上,如果多个进程都映射了同一个文件,那么其他进程能够看到本进程的修改。如果设置为 private,每个进程都有自己的一份拷贝。
- fd: 需要映射的文件的文件描述符,如果设置此值为 0,操作系统也会返回一块内存,只是这块内存没有映射任何内容。可以使用这种机制来得到内存,malloc 就是使用这种方式得到大片内存,然后再精细化管理的。即,使用 mmap 从操作系统批发内存,然后零售给用户。
- offset: 文件偏移位置,指定要从文件的那个位置开始映射。
下面是一个具体的例子,把一个文件映射到内存中,然后把文件中的小写字母全部转为大写。程序运行完毕后,文件的内容也就改变了。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <ctype.h>
#include <sys/mman.h>
#include <sys/stat.h>
int main(int argc, char** argv){
if(argc < 2){
fprintf(stderr, "usage: %s <file>\n", argv[0]);
_exit(1);
}
int fd = open(argv[1], O_RDWR);
struct stat st{};
fstat(fd, &st);
char* data = (char*)mmap(0, st.st_size, PROT_WRITE | PROT_READ, MAP_SHARED, fd, 0);
for(int i = 0; i < st.st_size; i++){
if(islower(data[i])){
data[i] = toupper(data[i]);
}
}
munmap(data, st.st_size);
close(fd);
return 0;
}
socket
socket 是最具有灵活性的进程间通信方式了。相互通信的两个进程不必在同一台机器上,这一点前面的任何 IPC 方式都不能办到。因为 socket 非常常用,而且功能强大,我打算使用额外的一篇文章来写 socket。
TODO: 总结 socket 的用法
参考资料: