11.进程间通信
前言
第10章节讲解了如何创建进程,本章将讨论创建的2个进程之间交换数据的方法。这与构建服务器端并无直接关系,但可能有助于构建多种类型服务器端,以及更好地理解操作系统。
一、进程间通信的基本概念
进程间通信(InterProcessCommunication)意味着两个不同进程间可以交换数据,为了完成这一点,操作系统中应提供两个进程可以同时访问的内存空间。
1.进程间通信的基本基本理解
理解好进程间通信并没有想象中那么难,进程A和B之间的如下谈话内容就是一种进程间通信规则。
“如果我有1个面包,变量bread的值就变为1。如果吃掉这个面包,bread的值又变回0。因此,你可以通过变量bread值判断我的状态。”
也就是说,进程A通过变量bread将自已的状态通知给了进程B,进程B通过变量bread听到了进程A的话。因此,只要有两个进程可以同时访问的内存空间,就可以通过此空间交换数据。但正如第10章所讲,进程具有完全独立的内存结构。就连通过fork函数创建的子进程也不会与父进程共享内存空间。因此,进程间通信只能通过其他特殊方法完成。
大家应该已经明白进程间通信的含义及其无法简单实现的原因,下面正式介绍进程间通信方法。
2.通过管道实现进程间通信
从图中可以看到,为了完成进程间通信,需要创建管道。管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是fork函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。下面介绍创建管道的函数:
#include <unistd.h>
int pipe(int filedes[2]);
// 成功时返回0,失败时返回-1。
// filedes[0]通过管道接收数据时使用的文件描述符,即管道出口。
// filedes[1]通过管道传输数据时使用的文件描述符,即管道入口。
以长度为2的int数组地址值作为参数调用上述函数时,数组中存有两个文件描述符,它们将被用作管道的出口和人口。父进程调用该函数时将创建管道,同时获取对应于出人口的文件描述符,此时父进程可以读写同一管道(相信大家也做过这样的实验)。但父进程的目的是与子进程进行数据交换,因此需要将人口或出口中的1个文件描述符传递给子进程。如何完成传递呢?答案就是调用fork函数。通过下面的示例进行演示。
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30int main(int argc, char *argv[])
{int fds[2];char str[]="WHO ARE YOU?";char buf[BUF_SIZE];pid_t pid;pipe(fds);pid=fork();if(pid==0){write(fds[1], str, sizeof(str));}else{read(fds[0],buf,BUF_SIZE);puts(buf);}return 0;
}
第12行:调用pipe函数创建管道,fds数组中保存用于I/O的文件描述符。
第13行:接着调用fork函数。子进程将同时拥有通过第12行函数调用获取的2个文件描述符。注意!复制的并非管道,而是用于管道I/O的文件描述符。至此,父子进程同时拥有I/O文件描述符。
第16、20行:子进程通过第16行代码向管道传递字符串。父进程通过第20行代码从管道接收字符串。
重点在于,父子进程都可以访问管道的I/O路径,但子进程仅用输人路径,父进程仅用输出路径。
3.通过管道进行进程间双向通信
下面创建2个进程通过1个管道进行双向数据交换的示例:
从图可看出,通过1个管道可以进行双向通信。但采用这种模型时需格外注意。先给出示例,稍后我们再讨论。
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30int main(int argc, char *argv[])
{int fds[2];char str1[]="WHO ARE YOU?";char str2[]="Thank you for your message";char buf[BUF_SIZE];pid_t pid;pipe(fds);pid=fork();if(pid==0){write(fds[1], str1, sizeof(str1));sleep(2);read(fds[0],buf, BUF_SIZE);printf("Child proc output: %s \n", buf);}else{read(fds[0], buf, BUF_SIZE);printf("Parent proc output: %s \n", buf);write(fds[1], str2, sizeof(str2));sleep(3);}return 0;
}
第17~20行:子进程运行区域。通过第17行传输数据,通过第19行接收数据。需要特别关注第18行的sleep函数。关于这一点稍后再讨论,希望各位自己思考其含义。
第24~26行:父进程的运行区域。通过第24行接收数据,这是为了接收第17行子进程传输的数据。另外,通过第26行传输数据,这些数据被第19行的子进程接收。
第27行:父进程先终止时会弹出命令提示符。这时子进程仍在工作,故不会产生问题。这条语句主要是为了防止子进程终止前弹出命令提示符(故可删除)。注释这条代码后再运行程序,大家就会明白我的意思。
运行结果应该和大家的预想一致。这次注释第18行代码后再运行(务必亲自动手操作)。虽然这行代码只将运行时间延迟了2秒,但已引发运行错误。产生原因是什么呢?
“向管道传递数据时,先读的进程会把数据取走。”
简言之,数据进人管道后成为无主数据。也就是通过read函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因此,注释第18行将产生问题。在第19行,子进程将读回自己在第17行向管道发送的数据。结果,父进程调用read函数后将无限期等待数据进人管道。
从上面示例中可以看到,只用1个管道进行双向通信并非易事。为了实现这一点,程序需要预测并控制运行流程,这在每种系统中都不同,可以视为不可能完成的任务。既然如此,该如何进行双向通信呢?
“创建2个管道。”
非常简单,1个管道无法完成双向通信任务,因此需要创建2个管道,各自负责不同的数据流动即可。其过程如下图所示。
由上图可知,使用2个管道可以避免程序流程的预测或控制。下面采用上述模型改进pipe2.c:
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30int main(int argc, char *argv[])
{int fds1[2], fds2[2];char str1[]="WHO ARE YOU?";char str2[]="Thank you for your message";char buf[BUF_SIZE];pid_t pid;pipe(fds1), pipe(fds2);pid=fork();if(pid==0){write(fds1[1], str1, sizeof(str1));read(fds2[0], buf, BUF_SIZE);printf("Child proc output: %s \n", buf);}else{read(fds1[0], buf, BUF_SIZE);printf("parent proc output: %s \n", buf);write(fds2[1], str2, sizeof(str2));sleep(3);}return 0;
}
第13行:创建两个管道。
第17、23行:子进程可以通过数组fds1指向的管道向父进程传输数据。
第18、25行:父进程可以通过数组fds2指向的管道向子进程发送数据。
第26行:没有太大的意义,只是为了延迟父进程终止而插入的代码。
二、运用进程间通信
已经学习了基于管道的进程间通信方法,接下来将其运用到网络代码中。
1.保存消息的回声服务器端
下面扩展第10章的echo_mpserv.c,添加如下功能:
“将回声客户端传输的字符串按序保存到文件中。”
我希望将该任务委托给另外的进程。换言之,另行创建进程,从向客户端提供服务的进程读取字符串信息。当然,该过程中需要创建用于接收数据的管道。
下面给出示例。该示例可以与任意回声客户端配合运行,但我们将用第10章介绍过的echo_mpclient.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define BUF_SIZE 100
void error_handling(char *message);
void read_childproc(int sig);int main(int argc, char *argv[])
{int serv_sock, clnt_sock;struct sockaddr_in serv_adr, clnt_adr;int fds[2];pid_t pid;struct sigaction act;socklen_t adr_sz;int str_len, state;char buf[BUF_SIZE];if(argc!=2) {printf("Usage : %s <port> \n", argv[0]);exit(1);}act.sa_handler=read_childproc;sigemptyset(&act.sa_mask);act.sa_flags=0;state=sigaction(SIGCHLD, &act,0);serv_sock=socket(PF_INET, SOCK_STREAM, 0);memset(&serv_adr,0,sizeof(serv_adr));serv_adr.sin_family=AF_INET;serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);serv_adr.sin_port=htons(atoi(argv[1]));if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)error_handling("bind() error");if(listen(serv_sock, 5)==-1)error_handling("listen() error");pipe(fds);pid=fork();if(pid==0){FILE * fp=fopen("echomsg.txt", "wt");char msgbuf[BUF_SIZE];int i, len;for(i=0; i<10; i++){len=read(fds[0],msgbuf, BUF_SIZE);fwrite((void*)msgbuf, 1, len, fp);}fclose(fp);return 0;}while(1){adr_sz=sizeof(clnt_adr);clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);if(clnt_sock==-1)continue;elseputs("new client connected...");pid=fork();if(pid==0){close(serv_sock);while((str_len=read(clnt_sock, buf, BUF_SIZE))!=0){write(clnt_sock, buf, str_len);write(fds[1], buf, str_len);}close(clnt_sock);puts("client disconnected...");return 0;}elseclose(clnt_sock);}close(serv_sock);return 0;
}void read_childproc(int sig)
{pid_t pid;int status;pid=waitpid(-1,&status,WNOHANG);printf("Removed proc id: %d \n", pid);
}void error_handling(char * message)
{fputs(message, stderr);fputc('\n', stderr);exit(1);
}
第46、47行:46行创建管道,47行创建负责保存文件的进程。
第49~60行:第47行创建的子进程运行区域。该区域从管道出口fds[0]读取数据并保存到文件中。另外,上述服务器端并不终止运行,而是不断向客户端提供服务。因此,数据在文件中累计到一定程度即关闭文件,该过程通过第53行的循环完成。
第76行:第69行通过fork函数创建的所有子进程将复制第46行创建的管道的文件描述符。因此,可以通过管道入口fds[1]传递字符串信息。
运行结果,希望大家也启动多个客户端向服务器端传输字符串。文件中累计一定数量的字符串后(共10次的fwrite函数调用完成后),可以打开echomsg.txt验证保存的字符串哦。
总结
前面已经讲解了并发服务器的第一种实现模型,但各位或许有如下想法:
“我想利用进程和管道编写聊天室程序,使多个客户端进行对话,应该从哪着手呢?”
若想仅用进程和管道构建具有复杂功能的服务器端,程序员需要具备熟练的编程技能和经验。因此,初学者应用该模型扩展程序并非易事,希望大家不要过于拘泥。以后要说明的另外两种模型在功能上更加强大,同时更容易实现我们的想法。
“那前面讲的内容算什么啊?是不是没用呢?”
大家都会有这样的疑问。通过第10章节和本章内容理解了操作系统的基本内容,同时也是学习线程必备的前期知识—进程。而且掌握了多进程代码的基本分析能力。即使大家不会亲自利用多进程构建服务器端,但这些都值得学习。可以将我的个人经验告诉所有朋友:
“即使开始时只想学习必要部分,最后也会需要掌握所有内容。”