Linux 下的进程间通信
2019-10-06
关键字:进程间通信、semaphore、进程间通信实例
进程间通信的几种方式:
1、早期 Unix 进程间通信方式
1、无名管道 pipe
2、有名管道 fifo
3、信号 signal
2、System V IPC
1、共享内存
2、消息队列
3、信号灯集
3、套接字 socket
前面六种是用于本地通信。第 3 种一般是用于主机之间的通信。
1、无名管道 pipe
由系统内核创建。管道创建好以后需要通信的两个进程可以分别从这个管道中读取或写入数据。
它只能用于有亲缘关系的进程之间的通信。
单工通信方式,通信双进程之间有固定的读端或写端。无名管道创建时会返回两个文件描述符,分别应用于读写操作。
若非要使用无名管道来实现双工通信,则可以通过使用两个无名管道的方式来间接实现。
创建无名管道
#include <unistd.h>
int pipe(int pfd[2]);
创建成功时返回值0,失败时返回 EOF。
参数 pfd 用于保存创建成功时的读写文件描述符的整型数组。pfd[0] 保存读管道描述符,pfd[1] 保存写管道描述符。
创建出的无名管道的缺少大小为 64K。
读无名管道
写端存在时:
写端存在是指无名管道通信过程中,至少有一个进程能够对该无名管道进行写操作。
当管道中有数据时,调用 read 函数就可以直接读取数据,返回值是实际读取的字节数。
当管道中无数据时则会阻塞。这个读操作与普通的文件IO是一致的。
写端不存在时:
当管道中有数据时,调用 read 函数就可以直接读取数据,返回值是实际读取的字节数。
当管道中无数据时则 read 时会直接返回值0,并不会阻塞。
写无名管道
读端存在时:
当有空间时调用 write 函数将与普通的文件IO一致,返回实际的写出字节数。
当管道中空间不足时,会先写一部分数据出去,然后进入到阻塞状态,直至将所有数据都写出去为止。
读端不存在时:
无论此时管道中有无空间,写操作都会失败。
程序会被一个类型值为 13 的信号所中止。
2、有名管道 fifo
与无名管道不同,有名管道会在文件系统中创建出实际的文件出来。因此,任意进程都可以通过这个实际的文件来进行通信。
进程打开有名管道时可以像打开普通文件一样指定读写方式,进而实现不同读写需求。
管道中的内容是存放在内存中的,因此有名管道文件的大小永远都是 0。
有名管道所创建的文件的类型标识符是 p 。
有名管道的创建:
#include <unistd.h>
#include <fcntl.h>
int mkfifo(const char *path, mode_t mode);
创建成功时返回值0, 失败时返回 EOF ,并设置 errno。
参数 path 表示要创建的有名管道的路径,可以填写绝对路径,也可以填写相对路径。
参数 mode 表示所创建的有名管道的权限。例如 0666。
有名管道的读写:
因为有名管道是会在文件系统中创建实际文件的,因此对有名管道的读写就直接像操作一个普通文件一样就可以了。
直接使用标准IO的几个操作文件的函数进行读写即可。open, close, read, write。
注意:当只有读端或只有写端的时候,打开有名管道时的 open 函数会阻塞,直至读写双端都存在时才能正常 open 有名管道。
3、信号 signal
信号是一种异步通信机制,可以简单地将它理解成是一种软件层次上的“中断”机制。
通过 kill -l 命令可以查看系统中所有的信号类型。
与信号相关的命令:
Linux 系统用来发送信号的命令是 kill,严格来讲 kill 命令的作用是向进程发送信号而不是将它杀死。这点需要加以明示。
1、kill [-signal] pid
默认情况下 kill 命令发送的是 15 号信号,即 SIGTERM ,表示用于结束进程。如 kill 1233, kill -9 1233, kill -11 1233
pid 还可以是一个“进程组”,当 pid 的值是负数时表示向一个进程组发送信号。
2、killall [ -u user | prog ]
prog 指定一个进程名。
user 指定一个用户名。
可以将系统中所有运行了指定 prog 名的进程发送信号。或者是向系统中所有以用户 user 运行的进程发送信号。当然执行命令前是会检查权限的。
同样默认发送的信号类型是 15 号,即 SIGTERM。
例: killall tst.out , killall -u chorm。
在程序中发送信号:
#include <unistd.h>
#include <signal.h>
int kill(pid_t pid, int sig);
int raise(int sig);
执行成功时返回值 0,失败时返回EOF,并设置 errno。
kill 函数表示向任意进程发送信号。
参数 pid 表示接收信号的进程号。这个值可以传 0,表示与当前程序的进程组相同的所有进程。还可以传值 -1,表示所有进程。同样执行信号前需要检查权限。
参数 sig 即表示要发送的信号类型。
raise 函数表示向自己发送信号。参数与 kill 函数一致。
定时函数:
int alarm(unsigned int seconds);
为当前进程设定一个定时器,当定时时间到时会产生一个 SIGALRM 信号。
一个进程同一时刻只允许设置一个定时器,执行该函数成功时会返回上一个定时器所剩余的时间,并重新以本次定时时间值进行定时任务。执行失败时返回 EOF。
参数 seconds 表示需要定时的秒钟数,若传入值 0 表示取消定时功能。
暂停函数:
int pause();
执行以后会让当前进程进入等待态,直到有信号将它唤醒。
执行了这个函数后进程会进行阻塞,当等待态被中断时会返回值 -1,并设置 errno 为 EINTR。
设置信号的响应方式:
#include <unistd.h>
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
这个函数的作用是将某个类型的信号与指定的处理函数关联起来,即起到自定义信号响应方式的目的。
这个函数执行成功时会返回原先该信号的处理函数指针,失败时返回 SIG_ERR。
参数 signo 表示要设置的信号类型。
参数 handler 表示处理 signo 的函数指针,也可以传入宏 SIG_DFL 表示使用缺少处理方式,或者传入 SIG_IGN 表示忽略该信号事件。
4、System V IPC
System V 的 IPC 对象包含三个:共享内存、消息队列以及信号灯集。
每一个 IPC 对象会有一个唯一的 ID。
IPC 对象被创建后就会一直存在,直到被显式地删除。
每一个 IPC 对象在创建的时候会分配一个 Key 与之关联。若 Key 值是 0 表示这个 IPC 对象是一个私有对象,私有对象只能被创建它的进程使用。
与 IPC 对象关联的命令有两个:
1、ipcs
查看 System V 的 IPC 对象的。
2、ipcrm
删除 IPC 对象用。
生成 Key 值的函数:
#include <sys/types.h>
#include <sys/icp.h>
key_t ftok(const char *path, int proj_id);
专门用于生成不会冲突的 IPC Key 值的函数。当执行成功时会返回一个合法的 Key 值,执行失败时则返回 EOF。
参数 path 是一个地址,没什么特殊要求。
参数 proj_id 是一个正整数,通常传一个字符常量。
通常每个进程都需要生成一个相同的 Key 值。
共享内存:
共享内存是效率最高的进程间通信方式。因为进程之间的数据都是直接在内存上进行的,不需要做任何的数据拷贝。
另外,由于共享内存是在内核空间创建的,所以用户态的程序不能直接使用。需要将共享内存映射到用户空间才行。
通常在使用共享内存时要使用同步或互斥机制来保证数据同步性。
共享内存的使用步骤主要有五步,如下所示:
1、创建/打开共享内存:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, int size, int shmflg);
执行成功时返回共享内存的 ID,失败时返回 EOF。
参数 key 就是要和这个共享内存关联的 Key 值了,简单来说可以理解成是 ftok 函数返回的值了。
参数 size 表示需要创建的共享内存的大小。
参数 shmflg 指定共享内存标志位。如 IPC_CREAT|0666,这个标志位表示当指定的共享内存不存在时就创建它,当已存在时就直接打开并返回。
2、映射共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);
执行成功后返回映射后的地址,失败时返回 (void *)-1。
参数 shmaddr 表示映射后的地址。可以传入 NULL,表示由系统自动映射。
参数 shmflg 表示标志位。值 0 表示映射后的地址空间可读可写,若想让映射后的空间只读,则可以传入参数 SHM_RDONLY。
3、读写共享内存
直接使用普通的内存读写函数就可以了。例如 fgets() 之类的,甚至直接从内存中拿数据都可以。
4、撤消共享内存映射
int shmdt(void *shmaddr);
执行成功时返回值 0,失败时返回 EOF,并设置 errno。
5、删除共享内存对象。
shmctl(shmid, IPC_RMID, NULL);
共享内存的控制接口:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
执行成功时返回值0,失败时返回 EOF,并设置对应的 errno。
参数 cmd 表示我们想对这个共享内存执行什么样的操作。如:IPC_STAT, IPC_SET, IPC_RMID。
参数 buf 就是用于保存查询共享内存属性时的结构体地址,或者是我们设置共享内存属性时的数据存储地址。
共享内存的大小是有上限的。默认情况下,共享内存最大的大小是 32KB。可以通过命令 ipcs -l 来查看上限值。共享内存的上限值是可以修改的,方法是将希望的大小写进 /proc/sys/kernel/shmmax 文件中去。
共享内存在调用删除命令时并不会立即删除,而是给要删除的共享内存打上一个标记,待到系统检测到不再有进程使用这一共享内存时才会真正去删除它。
消息队列:
消息队列本质上也是一段内存空间,不同进程可以往队列中写入、读取消息,进而达到进程间通信的目的。
不过消息队列所管理的内存空间是比共享内存空间要更复杂一些的内存空间。可以形象地将共享内存理解成是一个一维数组,消息队列则是一个二维数组。在共享内存中,所有的数据都会被一股脑地放在同一段内存空间里,而消息队列则多了一个“类型”加以区分。不同类型的数据会被放到不同的“第二维”中管理。消息队列相较于共享内存就是对数据多了一层管理而已。
消息队列可以按照不同的类型来发送、接收消息,每一个消息队列都有唯一的 ID 用以标识。
消息队列的使用步骤如下:
0、定义消息格式:
通常使用结构体来定义消息格式。
第一个成员的类型是固定的,是消息类型,它的数据类型为 long,它的值只能为正整数。
其它成员均属于消息正文,根据需要定义类型即可。
1、创建消息队列:
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
执行成功时返回消息队列的 ID,失败时返回 EOF 并设置 errno。
参数 key 和共享内存上的 key 是一样的概念。可以自定义也可以用 ftok() 函数来生成,还可以将类型设为私有的 IPC_PRIVATE。
参数 msgflg 也与共享内存中的是同样的概念。可以指定为 IPC_CREAT|0666。
2、向消息队列发送消息:
int msgsnd(int msgid, const void *msgp, size_t size, int msgflg);
执行成功时返回值 0,失败时返回 EOF。
参数 msgp 就是要发送的消息的地址。
参数 size 就是消息 msgp 所指的消息数据的长度。这里指的是消息正文的长度,不包含消息类型的长度。
参数 msgflg 是消息标志位。可传值 0 或 IPC_NOWAIT。值 0 表示发送消息时会阻塞,直至消息发送成功才返回。另一个就是异步发送消息了。
3、从消息队列中接收指定类型的消息:
int msgrcv(int msgid, void *msgp, size_t size, long msgtype, int msgflg);
执行成功时返回收到的消息的长度,失败时返回 EOF,并设置 errno。
参数 msgp 是自定义的消息结构体地址,就是用来存放消息的缓冲区。
参数 size 就是要接收的消息的长度。通常发送和接收的消息的长度都固定的。
参数 msgtype 就是消息类型了。这个参数可以填值 0,表示接收消息队列中最早的消息。
参数 msgflg 是一个标志位,填值 0 或 IPC_NOWAIT。这两个值的区别就是阻塞与否。
4、消息队列控制:
int msgctl(int msgid, int cmd, struct msqid_ds *buf);
执行成功时返回值 0,失败时返回 EOF 并设置 errno。
参数 cmd 就是要执行的命令,可填的值有 IPC_STAT, IPC_SET, IPC_RMID,分别对应获取属性、设置属性与删除消息队列。
参数 buf 就是用来存放消息队列属性的地址。
一般而言都是第一个进程负责创建消息队列,最后一个退出的消息队列负责删除消息队列。
与共享内存不同,消息队列的删除是立即执行的。当执行了删除消息队列的操作后仍有进程向消息队列读或写就会立即出错并返回。
信号灯集:
信号灯又称为信号量,即 semaphore。主要用于进程或线程之间的同步或互斥机制。
信号灯主要有三种:
1、Posix 无名信号灯;
2、Posix 有名信号灯;
3、System V 信号灯;
System V 的信号灯是一个包含了一个或多个计数信号灯的集合,因此它可以同时操作集合中的多个计数信号灯。
System V 下的信号灯的使用步骤如下:
1、创建信号灯:
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
函数执行成功时返回信号灯集合的 ID,失败时返回 EOF,并设置 errno。
参数 key 就是与共享内存、消息队列类似的 key。
参数 nsems 指示集合中包含的信号灯的数量。
参数 semflg 是标志位,常填的值有:IPC_CREAT|0666, IPC_EXCL。IPC_EXCL 通常和 IPC_CREAT 同用,使用这个标志位时,当创建的信号灯集已存在时会报错。
2、信号灯初始化:
int semctl(int semid, int semnum, int cmd, ...);
执行成功时返回值 0,失败时返回 EOF。
参数 semnum 表示要操作的集合中的信号灯编号。
参数 cmd 就是要执行的操作类型,主要有两个值:SETVAL, IPC_RMID。
最后一个可变参数只有在 cmd 是 SETVAL 时才需要用到。当需要 SETVAL 时,第四个参数可以填 union semun。但这个共用体在系统中没有定义,需要自行定义。具体的定义方法可以 man semctl 查阅用法。
3、P/V操作:
int semop(int semid, struct sembuf *sops, unsigned nsops);
执行成功时返回值 0,失败时返回 EOF。
参数 sops 这个结构体是已经在系统头文件中定义好的了。它用于描述对信号灯操作的信息的结构体,例如要操作哪一个信号灯,要如何操作等。一个信号灯对应一个该结构体,若有多个信号灯需要操作,则可以定义一个结构体数组。
参数 nsops 是要操作的信号灯的个数。
关于结构体 struct sembuf:
这个结构体的定义如下:
struct sembuf {
short semnum; // 信号灯编号,即哪一个信号灯。
short sem_op; // 值 -1 表示 P 操作,值 1 表示 V 操作。
short sem_flg; // 值 0 或 IPC_NOWAIT。
};
4、删除信号灯:
信号灯的删除所使用到的函数与初始化一样,都是 semctl ,具体参考上面第 2 点。
以下是一个结合了共享内存与 System V 信号灯集的小示例。这个实例的功能就是当父进程键入有数据时即在子进程中打印出来。示例代码如下所示:
#include <stdio.h> #include <string.h> #include <signal.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/sem.h> #define N 64 #define READ 0 #define WRITE 1 union semun{ int val; struct semid_ds *buf; unsigned short *array; struct seminfo *__buf; }; void init_sem(int semid, int s[], int n) { int i; union semun myun; for(i = 0; i < n; i++) { myun.val = s[i]; semctl(semid, i, SETVAL, myun); } } void pv(int semid, int num, int op) { struct sembuf buf; buf.sem_num = num; buf.sem_op = op; buf.sem_flg = 0; semop(semid, &buf, 1); } int main() { int shmid, semid; /* 0号用于读信号灯,初始时无资源。 1号用于写信号灯,初始时有 1 个资源。 */ int s[]={0,1}; pid_t pid; key_t key; char *shmaddr; // 为共享内存生成 Key。 if((key = ftok(".", 's')) == -1) { perror("ftok"); exit(-1); } // 创建共享内存。 if((shmid = shmget(key, N, IPC_CREAT|0666)) < 0) { perror(shmget); exit(-1); } // 创建信号灯集,这个集合里有两个计数信号灯。 if((semid = semget(key, 2, IPC_CREAT|0666)) < 0) { perror("semget"); goto error1; } init_sem(semid, s, 2); // 映射内核态的共享内存到用户态。 if((shmaddr = (char *)shmat(shmid, NULL, 0)) == (char *)-1) { perror("shmat"); goto error2; } if((pid = fork()) < 0) { perror("fork"); goto error2; } else if(pid == 0) { char *p, *q; while(1) { pv(semid, READ, -1); p = q = shmaddr; while(*q) { if(*q != ' ') { *p++ = *q; } q++; } *p = '\0'; printf("%s", shmaddr); pv(semid, WRITE, 1); } } else { while(1) { pv(semid, WRITE, -1); printf("input > "); fgets(shmaddr, N, stdin); if(strcmp(shmaddr, "quit\n") == 0) { break; } pv(semid, READ, 1); } kill(pid, SIGUSR1); } error2: semctl(semid, 0, IPC_RMID); error1: shmctl(shmid, IPC_RMID, NULL); return 0; }
5、Socket
Linux 下用 Socket 实现的进程间通信的机制被称为“域套接字(unix domain)”
域套接字的作用就是专用于本地进程间通信。
Linux 下创建域套接字的方式有两种:
socket(AF_LOCAL, SOCK_STREAM, 0);
socket(AF_LOCAL, SOCK_DGRAM, 0);
这两种协议分别用于创建 TCP 套接字和 UDP 套接字。
unix域套接字的通信也是以 C/S 模型实现的。
它的服务器端的 TCP 通信流程为:
1、socket(AF_LOCAL, SOCK_STREAM, 0);
2、bind();
3、listen();
4、accept();
5、recv() / send();
客户端的 TCP 通信流程为:
1、socket(AF_LOCAL, SOCK_STREAM, 0);
2、bind();
3、connect();
4、recv() / send();
服务器端的 UDP 通信流程为:
1、socket(AF_LOCAL, SOCK_DGRAM, 0);
2、bind();
3、recvfrom();
4、sendto();
客户端的 UDP 通信流程为:
1、socket(AF_LOCAL, SOCK_DGRAM, 0);
2、bind();
3、sendto();
4、recvfrom();
以下是一个通过域套接字实现的进程间通信的示例代码,该示例代码的功能是客户端传递一系列数字给服务器端,服务器端将求和结果返回给客户端:
服务端代码:
/* * File server.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <sys/un.h> #include <unistd.h> #define SOCKET_NAME "/tmp/lemontea.socket" #define BUFFER_SIZE 12 int main(int argc, char *argv[]) { struct sockaddr_un name; int down_flag = 0; int ret; int connection_socket; int data_socket; int result; char buffer[BUFFER_SIZE]; /* 删除文件。 unix domain 要求所创建的文件必须不存在。 */ unlink(SOCKET_NAME); connection_socket = socket(AF_UNIX, SOCK_STREAM, 0); if (connection_socket == -1) { perror("socket"); exit(EXIT_FAILURE); } memset(&name, 0, sizeof(struct sockaddr_un)); name.sun_family = AF_UNIX; strncpy(name.sun_path, SOCKET_NAME, strlen(SOCKET_NAME)); ret = bind(connection_socket, (const struct sockaddr *) &name, sizeof(struct sockaddr_un)); if (ret == -1) { perror("bind"); exit(EXIT_FAILURE); } /* * Prepare for accepting connections. The backlog size is set * to 20. So while one request is being processed other requests * can be waiting. */ ret = listen(connection_socket, 20); if (ret == -1) { perror("listen"); exit(EXIT_FAILURE); } /* This is the main loop for handling connections. */ for (;;) { /* Wait for incoming connection. */ data_socket = accept(connection_socket, NULL, NULL); if (data_socket == -1) { perror("accept"); exit(EXIT_FAILURE); } result = 0; for(;;) { //一次只跟一个客户端通信。 /* Wait for next data packet. */ ret = read(data_socket, buffer, BUFFER_SIZE); if (ret == -1) { perror("read"); exit(EXIT_FAILURE); } /* Ensure buffer is 0-terminated. */ buffer[BUFFER_SIZE - 1] = 0; /* Handle commands. */ if (!strncmp(buffer, "DOWN", BUFFER_SIZE)) { down_flag = 1; break; } if (!strncmp(buffer, "END", BUFFER_SIZE)) { break; } /* Add received summand. */ result += atoi(buffer); } /* Send result. */ sprintf(buffer, "%d", result); ret = write(data_socket, buffer, BUFFER_SIZE); if (ret == -1) { perror("write"); exit(EXIT_FAILURE); } /* Close socket. */ close(data_socket); /* Quit on DOWN command. */ if (down_flag) { break; } } close(connection_socket); /* Unlink the socket. */ unlink(SOCKET_NAME); exit(EXIT_SUCCESS); }
客户端端代码:
/* * File client.c */ #include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <sys/un.h> #include <unistd.h> #define SOCKET_NAME "/tmp/lemontea.socket" #define BUFFER_SIZE 12 int main(int argc, char *argv[]) { struct sockaddr_un addr; int i; int ret; int data_socket; char buffer[BUFFER_SIZE]; /* Create local socket. */ data_socket = socket(AF_UNIX, SOCK_STREAM, 0); if (data_socket == -1) { perror("socket"); exit(EXIT_FAILURE); } memset(&addr, 0, sizeof(struct sockaddr_un)); /* Connect socket to socket address */ addr.sun_family = AF_UNIX; strncpy(addr.sun_path, SOCKET_NAME, sizeof(addr.sun_path) - 1); ret = connect(data_socket, (const struct sockaddr *) &addr, sizeof(struct sockaddr_un)); if (ret == -1) { fprintf(stderr, "The server is down.\n"); exit(EXIT_FAILURE); } /* Send arguments. */ for (i = 1; i < argc; ++i) { ret = write(data_socket, argv[i], strlen(argv[i]) + 1); if (ret == -1) { perror("write"); break; } } /* Request result. */ strcpy (buffer, "END"); ret = write(data_socket, buffer, strlen(buffer) + 1); if (ret == -1) { perror("write"); exit(EXIT_FAILURE); } /* Receive result. */ ret = read(data_socket, buffer, BUFFER_SIZE); if (ret == -1) { perror("read"); exit(EXIT_FAILURE); } /* Ensure buffer is 0-terminated. */ buffer[BUFFER_SIZE - 1] = 0; printf("Result = %s\n", buffer); /* Close socket. */ close(data_socket); exit(EXIT_SUCCESS); }
在 Linux 进程间通信的几种方式中,按易用性及效率性排个序大致有如下顺序:
易用性:消息队列 > unix域套接字 > 管道 > 共享内存。
效率:共享内存 > unix域套接字 > 管道 > 消息队列。
因此,在进程间通信中,常用的就是共享内存和域套接字两种形式。