进程间通信(IPC)方式有:管道、消息队列、共享内存、信号量、套接字等。前面四种主要用于同一台机器上的进程通信,而套接字则主要用于不同机器间的网络通信。
1. 管道
1.1 匿名管道
父子进程间复制数据段和堆栈段而不共享,他们之间是通过管道进行通信的。
管道是一种两个进程间进行单向通信的机制,即为半双工管道。管道的局限性:
- 数据只能由一个进程流向另一个进程(其中给一个读管道,一个写管道);如果要进行双工通信,则需要建立两个管道。
- 管道只能用于父子进程或兄弟进程(实际指的是匿名进程)。
- 默认的管道没有名字。
- 管道的缓冲区大小受限制。
- 管道所传输的是无格式的字节流。(需要是输入方和输出方事先约定好数据格式)
使用管道进程通信时,两端的进程向管道读写数据是通过创建管道时,系统设置的文件描述符进行的。从本质上说,管道也是一种文件。通过管道的两个进程,一个进程向管道写数据,另一个从中读数据。写入的数据每次都添加到管道缓冲区的末尾,读数据时从缓冲区的头部读出。
创建管道的pipe()函数原型:
#include<unistd.h>
int pipe(int fd[2]);
描述字fd[0]是管道读端,fd[1]是管道写端。
一个进程在pipe创建管道后,一般再用fork建立一个子进程(使管道处于两个进程中间),然后通过管道实现父子进程的通信。
代码分析:
《后台开发:核心技术与应用实践》Page_353:例11.1“利用管道实现在父子进程间通信”
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define INPUT 0
#define OUTPUT 1
int main() {
int fd[2];
pid_t pid; //定义子进程号
char buf[256];
int returned_count;
pipe(fd); //创建无名管道
pid=fork(); //创建子进程
if(pid<0) {
printf("Error in fork\n");
exit(1);
}else if(pid == 0) { /*执行子进程*/
printf("in the child process...\n");
/*子进程向父进程写数据,关闭管道的读端*/
close(fd[INPUT]);
write(fd[OUTPUT], "hello world", strlen("hello world"));
exit(0);
}else { /*执行父进程*/
printf("in the parent process...\n");
/*父进程从管道读取子进程写的数据,关闭管道的写端*/
close(fd[OUTPUT]);
returned_count = read(fd[INPUT], buf, sizeof(buf));
printf("%d bytes of data received from child process: %s\n", returned_count, buf);
}
return 0;
}
1.2 命名管道
命名管道(FIFO)是对匿名管道的一种改进,它提供了一个路径名与之关联,以FIFO的形式存在于文件系统中,使得不具有亲缘关系的进程间也能完成通信。
有名管道的特点:
- 可以使互不相关的两个进程实现通信;
- 该管道通过路径名来指出,并且在文件系统中是可见的。即建立管道后,两个进程就可以把它当做普通文件来进行读写操作,使用非常方便;
- FIFO严格地遵循先进先出规则,读操作总是从开始处返回数据,写操作则是把数据添加到末尾。
创建有名管道的mkfifo()函数原型 :
#include<sys/types.h>
#include<sys/stat.h>
int mkfifo(const char* pathname, mode_t mode);
第一个参数pathname是一个路径名,也就是创建后FIFO的名字,第二个参数与打开普通文件的open()函数中的mode参数意义相同。
代码分析:
《后台开发:核心技术与应用实践》Page_355:例11.2“两个进程用有名管道进行通信”
两个进程用命名管道进行通信时,一共有两个程序,一个程序用于读管道,一个用于写管道。
- 读管道程序mkfifo_r.cpp中创建管道,并且读出用户写入到管道的内容:
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#define P_FIFO "/tmp/p_fifo" //路径名
int main(int argc, char** argv){
char cache[100];
int fd;
memset(cache,0, sizeof(cache)); /*初始化内存*/
if(access(P_FIFO,F_OK)==0){ /*管道文件存在*/
execlp("rm","-f", P_FIFO, NULL); /*删掉*/
printf("access.\n");
}
if(mkfifo(P_FIFO, 0777) < 0){
printf("createnamed pipe failed.\n");
}
fd= open(P_FIFO,O_RDONLY|O_NONBLOCK); /*非阻塞方式打开,只读*/
while(1){
memset(cache,0, sizeof(cache)); //读数据之前要将缓存清空
if((read(fd,cache, 100)) == 0 ){ /*没有读到数据*/
printf("nodata:\n");
}
else{
printf("getdata:%s\n", cache); /*读到数据,将其打印*/
}
sleep(1);/*休眠1s*/
}
close(fd);
return 0;
}
- 写管道程序mkfifo_w.cpp中,只要先以可写的方式打开命名管道,并往里写数据即可:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#define P_FIFO "/tmp/p_fifo"
int main(int argc, char **argv){
int fd;
if(argc< 2){
printf("please input the write data.\n");
}
fd= open(P_FIFO,O_WRONLY|O_NONBLOCK); /*非阻塞方式*/
write(fd,argv[1], 100); /*将argv[1]写道fd里面去*/
close(fd);
return 0;
}
mkfifo_r和mkfifo_w通过有名管道进行了通信,mkfifo_w发送什么内容,mkfifo_r就将收到什么内容。
- 一个服务器与多个客户端进行双向交流的过程:
每个客户端向服务器发送信息前都建立一个自己的读入管道,或让服务器在得到数据后再建立管道。使用客户端的进程号(PID)作为管道名是一种常用的方法。客户端可以先把自己的进程号告诉服务器,然后到那个以自己进程号命名的管道中读取数据。
1.3 匿名管道和命名管道总结:
pipe(匿名管道)与FIFO(命名管道)之间唯一的区别在它们创建与打开的方式不同。
- 匿名管道由pipe函数创建,打开方式则为普通的打开父子进程的方式,在父子进程中添加对管道的读写操作。
pipe(fd); //创建无名管道
pid=fork(); //创建子进程
if(pid<0) //fork error
{
...
}
else if(pid == 0) /*执行子进程*/
{
/*子进程向父进程写数据,关闭管道的读端*/
close(fd[INPUT]);
write(fd[OUTPUT], "hello world", strlen("hello world"));
exit(0);
}
else /*执行父进程*/
{
/*父进程从管道读取子进程写的数据,关闭管道的写端*/
close(fd[OUTPUT]);
returned_count = read(fd[INPUT], buf, sizeof(buf));
printf("%d bytes of data received from child process: %s\n", returned_count, buf);
}
- 命名管道由mkfifo函数创建,打开方式则用open函数(因为FIFO本质上就是文件,所以和普通的文件打开方式相同),然后就可以执行read或是write了
if(mkfifo("/tmp/p_fifo", 0777) < 0) /****"/tmp/p_fifo"为路径名****/
{
printf("createnamed pipe failed.\n");
}
fd= open("/tmp/p_fifo",O_RDONLY|O_NONBLOCK); /*非阻塞方式打开,只读*/
while(1)
{
memset(cache,0, sizeof(cache)); //读数据之前要将缓存清空
if((read(fd,cache, 100)) == 0 ){ /*没有读到数据*/
printf("nodata:\n");
}
else
{
printf("getdata:%s\n", cache); /*读到数据,将其打印*/
}
sleep(1);/*休眠1s*/
}
close(fd);
2. 消息队列
消息队列和管道很相似,是一个在系统内核中用来保存消息的队列,在内核中以消息链表的形式出现。不足之处在于每个消息有最大长度上限。
消息队列特点:
- 消息队列可以双向通信,在发送程序和接收程序中都需要建立消息队列,不像有名管道只需要一方建立有名管道即可;
- 消息队列的生命周期会一直存在,想要删除时需要显式地调用删除命令;
- 消息队列克服了管道只能传递无格式字节流的缺点,它可以定义自己的结构类型。
消息队列函数
1. msgget():
创建新消息队列或取得已有的消息队列,函数原型如下
int msgget(key_t key, int msgflg);
(1) 参数key可以认为是一个端口号;
(2) 参数msgflg如果等于IPC_CREAT,队列不存在时创建并返回新的标识符,队列已存在返回原标识符;如果IPC_CREAT配合IPC_EXCL使用,则队列不存在时返回0,队列已存在时返回错误(-1);
(3) 返回值:成功时返回非负整数,即消息队列标识符;失败时返回-1。
2. msgcrv()和msgsnd():
向队列读/写消息,msgrcv从列队中读取消息:
ssize_t msgrcv(int msgid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgsnd将数据写入消息队列:
int msgsnd(int msgid, const void* msgp, size_t msgsz, int msgflg);
参数:
(1) msgid是消息队列的标识码,为msgget()函数的返回值;
(2) msgp是指向消息缓冲区的指针,一般定义为以下结构:
struct msgstru
{
long mtype; //发送端为1,接收端为0
char mtext[512];
}
(3) msgsz指消息的大小,最大为BUFSIZ;
(4) msgflg用来指定核心程序在队列满或空时是否阻塞:msgflg为0时,msgrcv()及msgsnd()在队列空或满时阻塞等待;msgflg为IPC_NOWAIT在这种情况下则不会阻塞,立即返回-1。
3. msgctl():
用来设置消息队列属性,函数原型是:
int msgctl(int msgid, int cmd, struct msgid_ds *buf);
msgctl系统调用对msgid标识的消息队列执行cmd操作,系统定义了3种cmd操作:IPC_STAT、IPC_SET、IPC_RMID。
- IPC_STAT用来获取消息队列对应的msgid_ds数据结构,并将其保存到buf指定的地址空间;
- IPC_SET用来设置消息队列的属性,要设置的属性存储在buf中;
- IPC_RMID用来从内核中删除msgid标识的消息队列。
代码分析:
《后台开发:核心技术与应用实践》Page_359:例11.3“用消息队列来传输数据”
接收消息msgreceive.cpp的源码:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/msg.h>
struct msg_st{
long int msg_type;
char text[BUFSIZ];
};
int main(){
int running = 1;
int msgid = -1;
struct msg_st data;
long int msgtype = 0;
/*建立消息队列*/
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
if(msgid == -1){
fprintf(stderr, "msgget failed with error: %d\n", errno);
exit(EXIT_FAILURE);
}
/*从队列中获取消息,直到遇到end消息为止*/
while(running){
if(msgrcv(msgid, (void*)&data, BUFSIZ, msgtype, 0) == -1){
fprintf(stderr, "msgrcv failed with errno: %d\n", errno);
exit(EXIT_FAILURE);
}
printf("You wrote: %s\n",data.text);
/*遇到end结束*/
if(strncmp(data.text, "end", 3) == 0){
running = 0;
}
}
/*删除消息队列*/
if(msgctl(msgid, IPC_RMID, 0) == -1){
fprintf(stderr, "msgctl(IPC_RMID) failed\n");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
发送消息msgsend.cpp的源码:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/msg.h>
#include <errno.h>
#define MAX_TEXT 512
struct msg_st{
long int msg_type;
char text[MAX_TEXT];
};
int main(){
int running = 1;
struct msg_st data;
char buffer[BUFSIZ];
int msgid = -1;
/*建立消息队列*/
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
if(msgid == -1){
fprintf(stderr, "msgget failed with error: %d\n", errno);
exit(EXIT_FAILURE);
}
/*向消息队列中写消息,直到写入end*/
while(running){
/*输入数据*/
printf("Enter some text: ");
fgets(buffer, BUFSIZ, stdin);
data.msg_type = 1;
strcpy(data.text, buffer);
/*向队列发送数据*/
if(msgsnd(msgid, (void*)&data, MAX_TEXT, 0) == -1){
fprintf(stderr, "msgsnd failed\n");
exit(EXIT_FAILURE);
}
/*输入end结束输入*/
if(strncmp(buffer, "end", 3) == 0)
running = 0;
sleep(1);
}
exit(EXIT_SUCCESS);
}
msgreceive.cpp和msgsend.cpp中,都需要建立消息队列,不像有名管道,只需要一方建立有名管道即可。
消息队列与命名管道
- 在命名管道中,发送和接收数据分别用write函数和read函数;而在消息队列中,发送和接收数据分别用msgsnd函数和msgrcv函数。
- 消息队列可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难;
- 可以同时通过发送消息以避免命名管道的同步和阻塞问题,而不需要由进程自己来提供同步方法;
- 接收程序可以通过消息类型有选择地接收数据,而不是像命名管道那样只能默认的接收。
将结构体msgstru中的成员long mtype作为标志符,在发送端msgsnd的时候已经将msg_type写入,在接收端msgrcv的时候,有一个参数就是这个标志位,只有标志位符合的才会接收。
3. 共享内存
共享内存就是允许两个不相关的进程访问同一个逻辑内存,它是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。
不同进程之间共享的内存通常安排在同一段物理内存中。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
不过,共享内存并未提供同步机制,也就是说,在第一个进程对共享内存的写操作结束之前,并无自动机制可以阻止第二个进程对它进行读取。所以通常需要用其他的机制来同步对共享内存的访问。
做法:将共享内存映射到进程的地址空间分配给一个封装好的结构体,结构体中包含一个标志位,每次读写之前都检查标志位,读写后再将标志位复位。
但是这种做法也是不安全的,可以使用信号量来进行进程的同步。因为对信号量的操作都是原子性的。
共享内存函数
1. shmget():
用来创建共享内存:
#include<sys/shm.h>
int shmget(key_t key, int size, int flag);
第一个参数,程序需要提供一个参数key(非0整数),它有效地为共享内存段命名,shmget函数运行成功时会返回一个key相关的共享内存标识符(非负整数),用于后续的共享内存函数;调用失败是返回-1。
不相关的进程可以通过访问该函数的返回值访问同一共享内存,它代表程序可能要使用的某个资源,程序对所有共享内存的访问都是间接的。程序先通过调用shmget函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget函数的返回值)。
第二个参数,size以字节为单位指定需要共享的内存容量。
第三个参数,shmflg是权限标志。
2. shmat():
当共享内存创建后,其余进程可以通过调用shmat将其连接到自身的地址空间中:
void *shmat(int shmid, void *addr, int flag);
shmid为shmget函数返回的共享内存标识符,addr和flag参数决定了以什么方式来确定连接的地址,函数的返回值是该进程数据段所连接的实际地址,其它进程可以对此进程进行读写操作。
3. shmdt():
shmdt函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。它的原型:
int shmdt(const void *shmaddr);
参数shmaddr是shmat函数返回的地址指针,调用成功是返回0,失败时返回-1。
共享内存的优缺点:
- 优点:使用共享内存进行进程间通信非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。实际上,共享内存是进程间通信最快的方式。同时,它也不像无名管道那样要求通信的进程有一定的亲缘关系。
- 缺点:共享内存没有提供同步的机制,这使得在使用共享内存时往往要借助其他的手段来进行进程间的同步工作。
4. 信号量
共享内存的同步问题自身无法解决(即进程该何时去共享内存取得数据,而何时不能取),但用信号量即可轻易解决这个问题。 多线程同步方式中提到的信号量是POSIX信号量,进程通信的信号量是SYSTEM V信号量。
信号量本质是计数器,是衡量临界资源数量的,它的作用是保护临界资源。信号量只有两种操作,PV原语,P表示申请资源,V表示释放资源。
信号量函数
1. semget():
semget函数用于创建和打开信号量:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/sem.h>
int semget(key_t key, int nsems, int semflg);
参数:key信号量的名字,nsems信号量的个数。
返回值:该函数执行成功返回信号量标识符,失败则返回-1。
2. semop():
semop函数用于改变信号量的值:
int semop(int semid, struct sembuf *sops, unsigned nsops);
3. semctl():
semctl函数是信号量控制函数。
int semctl(int semid, int semnum, int cmd, ...);
参数:semid信号量的标识符,semnum信号量的序号,cmd和共享内存相同。
5. ipcs命令
ipcs是一个Linux命令,用于报告系统的消息队列、信号量、共享内存等。下面列举一些常用命令:
ipcs -a
用于列出本用户所有相关的ipcs参数。
ipcs -q
用于列出进程中的消息队列。
ipcs -s
用于列出所有的信号量。
ipcs -m
用于列出所有的共享内存信息。
ipcs -l
用于列出系统的限额。
ipcs -t
用于列出最后的访问时间。
ipcs -u
用于列出当前的使用情况。