进程通信( InterProcess Communication,IPC)就是指进程之间的信息交换。实际上,进程的同步与互斥本质上也是一种进程通信(这也就是待会我们会在进程通信机制中看见信号量和 PV 操作的原因了),只不过它传输的仅仅是信号量,通过修改信号量,使得进程之间建立联系,相互协调和协同工作,但是它缺乏传递数据的能力。
虽然存在某些情况,进程之间交换的信息量很少,比如仅仅交换某个状态信息,这样进程的同步与互斥机制完全可以胜任这项工作。但是大多数情况下,进程之间需要交换大批数据,比如传送一批信息或整个文件,这就需要通过一种新的通信机制来完成,也就是所谓的进程通信。
再来从操作系统层面直观的看一些进程通信:我们知道,为了保证安全,每个进程的用户地址空间都是独立的,一般而言一个进程不能直接访问另一个进程的地址空间,不过内核空间是每个进程都共享的,所以进程之间想要进行信息交换就必须通过内核。
下面就来我们来列举一下 Linux 内核提供的常见的进程通信机制:
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。如果想实现相互通信(全双工通信),我们需要创建两个管道才行。另外,通过管道符 | 创建的管道是匿名管道,用完了就会被自动销毁。并且,匿名管道只能在具有亲缘关系(父子进程)的进程间使用。也就是说,匿名管道只能用于父子进程之间的通信。
在 Linux 的实际编码中,是通过 pipe 函数来创建匿名管道的,若创建成功则返回 0,创建失败就返回 -1:
#include
#include
#include
#include
#include
#include
int main(void)
{
pid_t pid1;
int fields[2];
char buffer[80];
char s[100];
char ss[100];
if(pipe(fields)!=0){
fprintf(stderr,"Createpipe error:%s\n\a",strerror(errno));
exit(1);
}
if((pid1=fork())<0)printf("fork child error!\n");
/* 子进程写入数据 */
if(pid1==0){
printf("fork child,child is sending a message !\n");
char s[]="hello!\n";
write(fields[1],s,sizeof(s));
exit(0)
}
/* 父进程读取数据 */
else
{
printf("parent read start !\n");
read(fields[0],buffer,80);
printf("parent receive the message:%s",buffer);
}
exit (0);
}
编译运行
cc -o unamepipe unamepipe.c -g
./unamepipe
运行结果
有名管道fifo解决了pipe只能有关系的进程才能通信的问题,实现一个有名管道实际上就是实现一个FIFO文件,有名管道一旦建立,之后它的读,以及关闭操作都与普通管道完全相同。虽然FIFO文件的inode节点在磁盘上,但仅是一个节点而已,文件的数据还是存在内核缓冲页面上,和普通管道相同。
使用 Linux 命令 mkfifo 来创建有名管道:
$ mkfifo myPipe
myPipe 就是这个管道的名称,接下来,我们往 myPipe 这个有名管道中写入数据:
$ echo "hello" > myPipe
执行这行命令后,你会发现它就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。于是,我们执行另外一个命令来读取这个有名管道里的数据:
$ cat < myPipe
hello
程序源码示例如下:
//读进程
#include
#include
#include
#include
#include
#include
#include
#include
#define FIFO_PATH "myfifofile"
int main()
{
int fd;
char cont_r[255];
#创建命名管道
if(mkfifo(FIFO_PATH,0666)<0 && errno!=EEXIST)
{
perror("create fifo failed");
return -1;
}else
{
printf("create fifo success\n");
#打开文件进行读操作
fd =open(FIFO_PATH,O_CREAT|O_RDONLY,0666);
if(fd>0)
{
while(1){
read(fd,cont_r,255);
printf("read:%s\n",cont_r);
}
close(fd);
}else
perror("open failed");
}
return 0;
}
//写进程
#include
#include
#include
#include
#include
#include
#include
#include
#define FIFO_PATH "myfifofile"
int main()
{
int fd;
char cont_w[] = "hello sundy";
if(mkfifo(FIFO_PATH,0666)<0 && errno!=EEXIST)
{
perror("create fifo failed");
return -1;
}else
{
printf("create fifo success\n");
fd =open(FIFO_PATH,O_CREAT|O_WRONLY,0666);
if(fd>0)
{
while(1){
write(fd,cont_w,strlen(cont_w));
printf("write success\n");
sleep(2);
}
close(fd);
}else
perror("open failed");
}
return 0;
}
编译运行
打开两个终端执行
可以看出,管道这种进程通信方式虽然使用简单,但是效率比较低,不适合进程间频繁地交换数据,并且管道只能传输无格式的字节流。为此,消息传递机制(Linux 中称消息队列)应用而生。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程在需要的时候自行去消息队列中读取数据就可以了。同样的,B 进程要给 A 进程发送消息也是如此。
消息队列的本质就是存放在内存中的消息的链表,而消息本质上是用户自定义的数据结构。如果进程从消息队列中读取了某个消息,这个消息就会被从消息队列中删除。对比一下管道机制:
需要注意的是,消息队列对于交换较少数量的数据很有用,因为无需避免冲突。但是,由于用户进程写入数据到内存中的消息队列时,会发生从用户态拷贝数据到内核态的过程;同样的,另一个用户进程读取内存中的消息数据时,会发生从内核态拷贝数据到用户态的过程。因此,如果数据量较大,使用消息队列就会造成频繁的系统调用,也就是需要消耗更多的时间以便内核介入。
相关函数
消息数据格式如下
源程序示例如下:
// 写进程
#include
#include
#include
// 消息队列数据结构
struct mesg_buffer {
long mesg_type;
char mesg_text[100];
} message;
int main()
{
key_t key;
int msgid;
// ftok to generate unique key
key = ftok("progfile", 65);
// msgget creates a message queue
// and returns identifier
msgid = msgget(key, 0666 | IPC_CREAT);
message.mesg_type = 1;
printf("Write Data : ");
gets(message.mesg_text);
// msgsnd to send message
msgsnd(msgid, &message, sizeof(message), 0);
// display the message
printf("Data send is : %s \n", message.mesg_text);
return 0;
}
// 读进程
#include
#include
#include
// structure for message queue
struct mesg_buffer {
long mesg_type;
char mesg_text[100];
} message;
int main()
{
key_t key;
int msgid;
// ftok to generate unique key
key = ftok("progfile", 65);
// msgget creates a message queue
// and returns identifier
msgid = msgget(key, 0666 | IPC_CREAT);
// msgrcv to receive message
msgrcv(msgid, &message, sizeof(message), 1, 0);
// display the message
printf("Data Received is : %s \n",
message.mesg_text);
// to destroy the message queue
msgctl(msgid, IPC_RMID, NULL);
return 0;
}
运行结果
为了避免像消息队列那样频繁的拷贝消息、进行系统调用,共享内存机制出现了。顾名思义,共享内存就是允许不相干的进程将同一段物理内存连接到它们各自的地址空间中,使得这些进程可以访问同一个物理内存,这个物理内存就成为共享内存。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
集合内存管理的内容,我们来深入理解下共享内存的原理。首先,每个进程都有属于自己的进程控制块(PCB)和逻辑地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的逻辑地址(虚拟地址)与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同进程的逻辑地址通过页表映射到物理空间的同一区域,它们所共同指向的这块区域就是共享内存。
不同于消息队列频繁的系统调用,对于共享内存机制来说,仅在建立共享内存区域时需要系统调用,一旦建立共享内存,所有的访问都可作为常规内存访问,无需借助内核。这样,数据就不需要在进程之间来回拷贝,所以这是最快的一种进程通信方式。
相关函数
shmget:申请共享内存
shmat:建立用户进程空间到共享内存的映射
shmdt:解除映射关系
shmctl:回收共享内存空间
程序源码示例
//写进程
#include
#include
#include
int main()
{
// ftok to generate unique key
key_t key = ftok("shmfile",65);
// shmget returns an identifier in shmid
int shmid = shmget(key,1024,0666|IPC_CREAT);
// shmat to attach to shared memory
char *str = (char*) shmat(shmid,(void*)0,0);
gets(str);
printf("Data written in memory: %s\n",str);
//detach from shared memory
shmdt(str);
return 0;
}
//读进程
#include
#include
#include
int main()
{
// ftok to generate unique key
key_t key = ftok("shmfile",65);
// shmget returns an identifier in shmid
int shmid = shmget(key,1024,0666|IPC_CREAT);
// shmat to attach to shared memory
char *str = (char*) shmat(shmid,(void*)0,0);
printf("Data read from memory: %s\n",str);
//detach from shared memory
shmdt(str);
// destroy the shared memory
shmctl(shmid,IPC_RMID,NULL);
return 0;
}
编译运行
实际上,对具有多 CPU 系统的最新研究表明,在这类系统上,消息传递的性能其实是要优于共享内存的,因为消息队列无需避免冲突,而共享内存机制可能会发生冲突。也就是说如果多个进程同时修改同一个共享内存,先来的那个进程写的内容就会被后来的覆盖。并且,在多道批处理系统中,多个进程是可以并发执行的,但由于系统的资源有限,进程的执行不是一贯到底的, 而是走走停停,以不可预知的速度向前推进(异步性)。但有时候我们又希望多个进程能密切合作,按照某个特定的顺序依次执行,以实现一个共同的任务。
举个例子,如果有 A、B 两个进程分别负责读和写数据的操作,这两个线程是相互合作、相互依赖的。那么写数据应该发生在读数据之前。而实际上,由于异步性的存在,可能会发生先读后写的情况,而此时由于缓冲区还没有被写入数据,读进程 A 没有数据可读,因此读进程 A 被阻塞。
因此,为了解决上述这两个问题,保证共享内存在任何时刻只有一个进程在访问(互斥),并且使得进程们能够按照某个特定顺序访问共享内存(同步),我们就可以使用进程的同步与互斥机制,常见的比如信号量与 PV 操作。
信号量就是具有原子性的计数器,就相当于一把锁,在每个进程要访问临界资源时,必须要向信号量拿个锁”,它才能进去临界资源这个“房间”,并锁上门,不让其他进程进来,此时信号量执行P()操作,锁的数目减少了一个,所以计数器减1,;当它访问完成时,它出来,将锁还给信号量,执行V()操作,计数器加1;
程序源码示例:
让两个进程分别向显示器(linux下一切皆文件,临界资源)打印AA和BB,当没有信号量进行保护时,会出现数据混乱,例如:“AABBABAAAB…”,为了解决这一问题,我们创建信号量进行保护。打印“AA”或“BB”
#include
#include
#include
#include
union semun{
int val;
struct semid_ds *buf;
unsigned short *array;
//struct seminfo *buff;
};
static int set_semvalue(void);
static void del_semvalue(void);
static int semaphore_p(void);
static int semaphore_v(void);
static int sem_id;
int main(int argc, char *argv[])
{
int i;
int pause_time;
char op_char = 'O';
srand((unsigned int)getpid());
sem_id =semget((key_t)1234, 1, 0666 | IPC_CREAT);
/* 如果程序第一个被调用,也就是调用时含有一个参数,使得argc>1,此时就调用set_semvalue初始化信号量,并将op_char设置为x*/
if (argc > 1){
if(!set_semvalue()){
fprintf(stderr, "Failed to initialize semaphore\n");
exit(EXIT_FAILURE);
}
op_char = 'X';
sleep(2);
}
/*进入和离开临界区10次,每次循环开始的时候首先调用semaphore_p函数,它在程序将进入临界区域时设置信号量以等待进入*/
for(i=0; i<10; i++){
if(!semaphore_p()) exit(EXIT_FAILURE);
printf("%c", op_char);fflush(stdout);
pause_time = rand() % 3;
sleep(pause_time);
printf("%c", op_char);fflush(stdout);
/*进入临界区域后,调用semaphore_v将信号量设置为可用,然后等待一段随机的时间,再进入下一次循环*/
if(!semaphore_v()) exit(EXIT_FAILURE);
pause_time = rand() % 2;
sleep(pause_time);
}
printf("\n%d - finished\n", getpid());
if (argc > 1)
{
sleep(10);
del_semvalue();
}
exit(EXIT_SUCCESS);
}
/*该函数用来将semctl调用的command参数设置为SETVAL来初始化信号量*/
static int set_semvalue(void)
{
union semun sem_union;
sem_union.val = 1;
if (semctl(sem_id, 0, SETVAL, sem_union)==-1) return 0;
return (1);
}
/*通过调用semctl调用的command设置为IPC_RMID来删除信号量ID*/
static void del_semvalue(void)
{
union semun sem_union;
if (semctl(sem_id, 0, IPC_RMID, sem_union)==-1)
fprintf(stderr, "Failed to delete semaphore");
}
/*对信号量执行减1操作*/
static int semaphore_p(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flg = SEM_UNDO;
if (semop(sem_id, &sem_b, 1) == -1){
fprintf(stderr,"semaphore_p failed\n");
return (0);
}
return(1);
}
/*对信号量执行加1操作*/
static int semaphore_v(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flg = SEM_UNDO;
if (semop(sem_id, &sem_b, 1) == -1){
fprintf(stderr,"semaphore_v failed\n");
return (0);
}
return(1);
}
编译运行
源代码示例2,与上述代码几乎相同,只不过变为了父子进程间的信号量机制。
#include
#include
#include
#include
#include
#include
static int set_semvalue(void);
static void del_semvalue(void);
static int semaphore_p(void);
static int semaphore_v(void);
static int sem_id;
union semun{
int val;
struct semid_ds *buf;
unsigned short *array;
//struct seminfo *buff;
};
int main(int argc, char *argv[])
{
int i;
int pause_time;
char op_char = 'O';
srand((unsigned int)getpid());
sem_id =semget((key_t)1234, 1, 0666 | IPC_CREAT);
int id = fork();
if(id<0)
{
perror("fork failed\n");
return -1;
}
else if (id>0){
if(!set_semvalue()){
fprintf(stderr, "Failed to initialize semaphore\n");
exit(EXIT_FAILURE);
}
op_char = 'X';
sleep(2);
}
for(i=0; i<10; i++){
if(!semaphore_p()) exit(EXIT_FAILURE);
printf("%c", op_char);fflush(stdout);
pause_time = rand() % 3;
sleep(pause_time);
printf("%c", op_char);fflush(stdout);
if(!semaphore_v()) exit(EXIT_FAILURE);
pause_time = rand() % 2;
sleep(pause_time);
}
printf("\n%d - finished\n", getpid());
if (id> 0)
{
sleep(10);
del_semvalue();
}
exit(EXIT_SUCCESS);
}
static int set_semvalue(void)
{
union semun sem_union;
sem_union.val = 1;
if (semctl(sem_id, 0, SETVAL, sem_union)==-1) return 0;
return (1);
}
static void del_semvalue(void)
{
union semun sem_union;
if (semctl(sem_id, 0, IPC_RMID, sem_union)==-1)
fprintf(stderr, "Failed to delete semaphore");
}
static int semaphore_p(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flg = SEM_UNDO;
if (semop(sem_id, &sem_b, 1) == -1){
fprintf(stderr,"semaphore_p failed\n");
return (0);
}
return(1);
}
static int semaphore_v(void)
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flg = SEM_UNDO;
if (semop(sem_id, &sem_b, 1) == -1){
fprintf(stderr,"semaphore_v failed\n");
return (0);
}
return(1);
}
编译运行
在这里我们可以看到子进程的pid为父进程的pid+1,注意的是fork创建子进程的返回值为0,和这里的pid是不同的。
注意!信号和信号量是完全不同的两个概念!
信号是进程通信机制中唯一的异步通信机制,它可以在任何时候发送信号给某个进程。通过发送指定信号来通知进程某个异步事件的发送,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行。用户、内核和进程都能生成和发送信号。
信号事件的来源主要有硬件来源和软件来源。所谓硬件来源就是说我们可以通过键盘输入某些组合键给进程发送信号,比如常见的组合键 Ctrl+C 产生 SIGINT 信号,表示终止该进程;而软件来源就是通过 kill 系列的命令给进程发送信号,比如 kill -9 1111 ,表示给 PID 为 1111 的进程发送 SIGKILL 信号,让其立即结束。
程序源码示例
以模拟闹钟的形式,通过一个进程向另一个进程发送SIGALRM信号来表现进程间的通信。
#include
#include
#include
#include
#include
static int alarm_fired = 0;
/*该函数用来模拟闹钟*/
void ding(int sig)
{
alarm_fired =1;
}
/*main函数中告诉子进程在等待5秒后发送SIGALRM信号给它的父进程*/
int main()
{
pid_t pid;
printf("alarm start\n");
pid = fork(); /*创建子进程*/
switch (pid)
{
case -1:
perror("fork failed");
exit(1);
case 0:
sleep(5); /*子进程休眠5秒*/
kill(getppid(), SIGALRM); /*子进程在5秒后将SIGALRM信号传递给父进程*/
exit(0);
}
/*父进程通过一个signal调用捕获SIGALRM信号的工作,等待该信号的到来*/
printf("waitting for alarm to go on\n");
(void) signal(SIGALRM, ding);
pause();
if (alarm_fired)
printf("ding!\n");
printf("done\n");
exit(0);
}
编译运行
至此,上面介绍的 5 种方法都是用于同一台主机上的进程之间进行通信的,如果想要跨网络与不同主机上的进程进行通信,那该怎么做呢?这就是 Socket 通信做的事情了(当然,Socket 也能完成同主机上的进程通信)。
socket即套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。也因为这样,套接字明确地将客户端和服务器区分开来。
程序源码示例
//服务器
#include
#include
#include
#include
#include
#include
#include
int main()
{
int server_sockfd = -1;
int client_sockfd = -1;
int client_len = 0;
struct sockaddr_in server_addr;
struct sockaddr_in client_addr;
//创建流套接字
server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
//设置服务器接收的连接地址和监听的端口
server_addr.sin_family = AF_INET;//指定网络套接字
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);//接受所有IP地址的连接
server_addr.sin_port = htons(9736);//绑定到9736端口
//绑定(命名)套接字
bind(server_sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
//创建套接字队列,监听套接字
listen(server_sockfd, 5);
//忽略子进程停止或退出信号
signal(SIGCHLD, SIG_IGN);
while(1)
{
char ch = '\0';
client_len = sizeof(client_addr);
printf("Server waiting\n");
//接受连接,创建新的套接字
client_sockfd = accept(server_sockfd, (struct sockaddr*)&client_addr, &client_len);
if(fork() == 0)
{
//子进程中,读取客户端发过来的信息,处理信息,再发送给客户端
read(client_sockfd, &ch, 1);
sleep(5);
ch++;
write(client_sockfd, &ch, 1);
close(client_sockfd);
exit(0);
}
else
{
//父进程中,关闭套接字
close(client_sockfd);
}
}
}
//客户端
#include
#include
#include
#include
#include
#include
#include
int main()
{
int sockfd = -1;
int len = 0;
struct sockaddr_in address;
int result;
char ch = 'A';
//创建流套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
//设置要连接的服务器的信息
address.sin_family = AF_INET;//使用网络套接字
address.sin_addr.s_addr = inet_addr("127.0.0.1");//服务器地址
address.sin_port = htons(9736);//服务器所监听的端口
len = sizeof(address);
//连接到服务器
result = connect(sockfd, (struct sockaddr*)&address, len);
if(result == -1)
{
perror("ops:client\n");
exit(1);
}
//发送请求给服务器
write(sockfd, &ch, 1);
//从服务器获取数据
read(sockfd, &ch, 1);
printf("char form server = %c\n", ch);
close(sockfd);
exit(0);
}
编译运行
简单总结一下上面六种 Linux 内核提供的进程通信机制:
1)首先,最简单的方式就是管道,管道的本质是存放在内存中的特殊的文件。也就是说,内核在内存中开辟了一个缓冲区,这个缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区的操作。管道分为匿名管道和有名管道,匿名管道只能在父子进程之间进行通信,而有名管道没有限制。
2)虽然管道使用简单,但是效率比较低,不适合进程间频繁地交换数据,并且管道只能传输无格式的字节流。为此消息队列应用而生。消息队列的本质就是存放在内存中的消息的链表,而消息本质上是用户自定义的数据结构。如果进程从消息队列中读取了某个消息,这个消息就会被从消息队列中删除。
3)消息队列的速度比较慢,因为每次数据的写入和读取都需要经过用户态与内核态之间数据的拷贝过程,共享内存可以解决这个问题。所谓共享内存就是:两个不同进程的逻辑地址通过页表映射到物理空间的同一区域,它们所共同指向的这块区域就是共享内存。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
对于共享内存机制来说,仅在建立共享内存区域时需要系统调用,一旦建立共享内存,所有的访问都可作为常规内存访问,无需借助内核。这样,数据就不需要在进程之间来回拷贝,所以这是最快的一种进程通信方式。
4)共享内存速度虽然非常快,但是存在冲突问题,为此,我们可以使用信号量和 PV 操作来实现对共享内存的互斥访问,并且还可以实现进程同步。
5)信号和信号量是完全不同的两个概念!信号是进程通信机制中唯一的异步通信机制,它可以在任何时候发送信号给某个进程。通过发送指定信号来通知进程某个异步事件的发送,以迫使进程执行信号处理程序。信号处理完毕后,被中断进程将恢复执行。用户、内核和进程都能生成和发送信号。
6)上面介绍的 5 种方法都是用于同一台主机上的进程之间进行通信的,如果想要跨网络与不同主机上的进程进行通信,就需要使用 Socket 通信。另外,Socket 也能完成同主机上的进程通信。