并发模型第叁讲-prefork
前言
ok,快到放假了,心里有点“燥”。 写个博客清醒些…
今天打算继续总结【1】中介绍的第三个并发模型-prefork。
一、prefork模型
1.1 介绍
所谓prefork简单来说就是提前 建立一批进程,留待后用。 按照我的理解这就是线程池。
根据这批进程的作用时机,这种prefork模型又可以分为两种子类型:
1.1.1 prefork 模型1
(1)、每个子进程都阻塞在accept上,建立连接后进行处理,处理完毕之后继续等待建立连接,然后处理。如下图所示。
就单个子进程来看,它的就像第零章所介绍的迭代型服务器一样。 就整个系统来看,多个子进程组成的迭代型服务器从某种程度上来说横向组成了并发服务器。
这种模型有个小问题,就是是多个进程共享监听同一个socket以及其所带来的的“惊群”问题。 这个我们在总结部分在详细介绍
1.1.2 prefork模型2
(2)、主进程阻塞在accept上,等待客户端建立连接,然后把accept来的connFd传递给子进程(需要选择一个当前正处在空闲状态的进程),子进程只进行具体的业务逻辑处理,处理完毕之后,告知主进程任务完毕。 这个模型图示如下。
是不是图画太简陋,色彩太浮夸,看的有点乱?
没关系,我来详细介绍一下。
- 图中的蓝色箭头代表主进程在需要事先建立一批子进程,也就是prefork.
- 图中的绿色线代表主进程负责和各个客户端建立连接。(其实,从底层原理上来说,真正负责三次握手和客户端建立连接的是内核,主进程阻塞在accpet上,只负责从建立连接的队列中取得连接)。
- 图中紫色箭头代表主进程在“取得”连接之后,把connFd传递给空闲的子进程(由于进程之间是隔离的,所以这里并不是仅仅传递一个描述符整型数, 而是需要传递一个真正的描述符,这个在后文中还有介绍)
- 黄色线代表子进程在接收到主进程传递来的连接之后,与客户端进行真正的通信(业务处理)。
图中还省略了一些通信过程,如子进程在任务处理完毕之后会通知主进程…
由上面的分析即可得出,第二种方式涉及到比较多的通信过程,相对来说比较复杂,后面会在代码中详细介绍。
1.2 代码部分
1.2.1 prefork1 模型代码部分
服务端代码:
#include "com.h"
#include "lib.h"
#include <pthread.h>
#include "str_echo.h"
#define LISTENQ 1024
//每个子进程的任务
void childProcess(int i,int listenFd){
struct sockaddr_in cliAddr;
printf("child %ld starting\n",(long)getpid());
int connFd = -1;
while(1){
int cliLen = sizeof(cliAddr);
if((connFd = accept(listenFd,(struct sockaddr*)&cliAddr,&cliLen))<0){ //阻塞等待连接
printf("accept error\n");
exit(-1);
}else{
str_echo(connFd); //echo服务
close(connFd); //关闭当前连接
}
}
}
//创建子进程
pid_t childMake(int i,int listenFd){
pid_t pid = -1;
if((pid = fork()) > 0){
return pid; //父进程返回
}else{
childProcess(i,listenFd); //子进程的任务函数
}
}
int main(int argc, char**argv){
int listenFd,connFd;
pid_t childPid;
socklen_t cliLen;
struct sockaddr_in cliAddr,servAddr;
//socket
if((listenFd = socket(AF_INET,SOCK_STREAM,0))<0){
printf("socket error\n");
exit(-1);
}
bzero(&servAddr,sizeof(servAddr));
servAddr.sin_family=AF_INET;
servAddr.sin_addr.s_addr=htonl(INADDR_ANY);
servAddr.sin_port=htons(SERV_PORT);
//bind
if(bind(listenFd,(struct sockaddr*)&servAddr,sizeof(servAddr))<0){
printf("bind error\n");
exit(-1);
}
//listen
if((listen(listenFd,LISTENQ)) <0){
printf("listen error\n");
exit(-1);
}
int numOfChild = atoi(argv[1]); //外部参数传递进来的进程数量
int childPids[numOfChild]; //子进程们的pid
for(int i = 0;i<numOfChild;++i){
childPids[i] = childMake(i,listenFd); //创建子进程
}
while(1){
pause();
}
return 0;
}
代码分析:整个代码的流程就是在主进程中建立一批子进程(childMake()),然后分别在子进程中进行连接和业务的处理(childProcess())。
客户端的代码和前面类似,这里就不贴了。
1.2.2 prefork2模型代码部分
看代码之前,先来点图发散一下思维。
这里对于主进程来说,涉及到两个方面的通信,一个是和客户端进行通信(为了建立连接),一个是和子进程进行通信(为了传递任务,或者具体点说传递建立的connFd套接字)。 对于前者不需要我们管,内核帮我们弄好了,直接accept中读取就可以;对于后者,就会出现一个问题.
如何在两个进程之间传递描述符呢?
这里直接公布一个做法( 具体的详细讨论在总结中会说到):在linux下,我们可以利用Unix域socket在进程间传递文件描述符。
具体的代码如下所示
pid_t childMake(int i,int listenFd,Child *childPtr){
int sockFd[2];
pid_t pid = -1;
//创建socket管道,用于和子进程进行通信
if(socketpair(AF_LOCAL,SOCK_STREAM,0,sockFd) < 0){
printf("create socketpair failed\n");
exit(-1);
}
if((pid = fork()) > 0){
close(sockFd[1]);
childPtr[i].childPid = pid;
childPtr[i].childPipeFd=sockFd[0]; //主进程的
childPtr[i].childStatus = 0;
return pid; //父进程返回
}else{
dup2(sockFd[1],STDERR_FILENO);
close(sockFd[0]);
close(sockFd[1]);
close(listenFd);
childProcess(i,listenFd); //子进程具体的处理流程
}
}
这里参考UNP中的做法,进行了描述符的重定向。 最后形成的父子进程通信的管道示意图如下:
标识Child的结构体
typedef struct
{
pid_t childPid; //子进程child的pid
int childPipeFd; //子进程和附近成分通信的socket管道
int childStatus; //子进程的状态,空闲还是否空闲
int childCount; //子进程服务的客户端数量,可以用作统计,这里没用到
}Child;
服务器端主函数的代码如下所示:
int main(int argc, char**argv){
int listenFd,connFd;
pid_t childPid;
socklen_t cliLen;
struct sockaddr_in cliAddr,servAddr;
//socket
if((listenFd = socket(AF_INET,SOCK_STREAM,0))<0){
printf("socket error\n");
exit(-1);
}
bzero(&servAddr,sizeof(servAddr));
servAddr.sin_family=AF_INET;
servAddr.sin_addr.s_addr=htonl(INADDR_ANY);
servAddr.sin_port=htons(SERV_PORT);
//bind
if(bind(listenFd,(struct sockaddr*)&servAddr,sizeof(servAddr))<0){
printf("bind error\n");
exit(-1);
}
//listen
if((listen(listenFd,LISTENQ)) <0){
printf("listen error\n");
exit(-1);
}
以下是重点部分//
//准备监控set
fd_set rset, masterSet;
int maxFd;
FD_ZERO(&masterSet);
FD_SET(listenFd,&masterSet);
maxFd=listenFd;
//prefork 子进程
int numOfChild = atoi(argv[1]);
Child childPtr[numOfChild];
int numOfAvail = numOfChild; //available child 初始化为numOfChild
//创建子进程
for(int i = 0;i<numOfChild;++i){
childMake(i,listenFd,childPtr);
FD_SET(childPtr[i].childPipeFd,&masterSet); //监控返回的父子进行套接字
maxFd = max(maxFd,childPtr[i].childPipeFd); //记录监控最大的fd
}
while(1){
rset = masterSet;
if(numOfAvail<=0){
FD_CLR(listenFd,&rset);
}
int nsel = select(maxFd+1,&rset,NULL,NULL,NULL); //阻塞等待客户端连接或者 子进程的写入请求
if(FD_ISSET(listenFd,&rset)){ //如果是有连接到来
int cliLen = sizeof(cliAddr);
connFd = accept(listenFd,(struct sockaddr*)&cliAddr,&cliLen);
printf("main process accept connect :%d\n",connFd);
if(connFd<0){
printf("accept error\n");
exit(-1);
}
//选择空闲的子进程
int i = 0;
for( i = 0;i<numOfChild;++i){
if(childPtr[i].childStatus == 0){
break;
}
}
if(i == numOfChild){
printf("no avaliable child\n");
exit(-1);
}
childPtr[i].childStatus=1; //标志位忙碌
childPtr[i].childCount++;
numOfAvail--; //可获得的进程数目减1
//向子进程中写入accept中的建立的连接
int n = Write_fd(childPtr[i].childPipeFd,"",1,connFd);
close(connFd); //父进程不处理连接,直接关闭connFd
if(--nsel == 0){
continue;
}
}
//处理子进程的写入请求:即子进程处理完毕
char rc;
for(int i = 0;i<numOfChild;++i){
if(FD_ISSET(childPtr[i].childPipeFd,&rset)){
int n = 0;
if((n = read(childPtr[i].childPipeFd,&rc,1)) == 0){
printf("child %d terminated unexpectedly\n",i);
exit(-1);
}
childPtr[i].childStatus = 0;
numOfAvail++;
if(--nsel == 0){
break;
}
}
}
}
return 0;
}
如上分析的,父进程因为要和多个子进程和客户端进行通信,所以使用了多路复用,示意图如下。
子进程的任务执行函数childProcess:
void childProcess(int i,int listenFd){
struct sockaddr_in cliAddr;
pid_t myPid = getpid();
printf("child %d starting\n",myPid);
int connFd = -1;
char c;
ssize_t n;
while(1){
if((n=Read_fd(STDERR_FILENO,&c,1,&connFd)) == 0 ){
printf("read return 0\n");
exit(-1);
}
printf("%d receive fd(%d)\n",myPid,connFd);
if(connFd<0){
printf("no descriptor from read_fd\n");
exit(-1);
}
str_echo(connFd);
close(connFd); //关闭当前链接
char c = 'e';
write(STDERR_FILENO,&c,1);
}
}
注意上面的Read_fd()和Write_fd()不是普通的read、write等IO函数,而是特殊的函数,里面包装了recvmsg()和sendmsg()。 可以参照【3】和【2】 中相关章节,这里就不赘述了。
二、总结
同样的,总结部分,先讨论遇到的几个小问题,然后在总结这种并发模型。
2.1 几个小问题
2.1.1 、关于几个或多个进程共享一个socket套接字进行 “通信”?
先讨论这样一种情况,在prefork模型1中,有没有深思这样一个问题:
模型中多个子进程都阻塞在accept上,它们都共用了一个 listenFd接口,难道它们都能和客户端通信(连接过程也是一种通信)? 这样客户端发来的数据难道不会产生混乱吗(比如说本该发给子进程1的最后发给了子进程2)?
这种情况下(指的是共享listenFd,然后所有子进程都阻塞在accept上)是不会的,因为从本质上说,连接的建立过程(三次握手)是内核帮我们完成的,accept的作用仅仅是从已建立连接的队列中获取已完成三次握手的连接(参考【4】)。这就像生产者-消费者模型一样,内核帮我们生产数据(建立连接),阻塞在accept上的各个子进程作为消费者等待获取数据。如下示意图所示。
所以当多个进程阻塞在accept上之时,如果已建立连接的队列里有一个连接的话,它们都会被内核唤醒,然后却只有一个进程获得这个连接,其他的进程“忙活了一下”,然后继续睡眠等待。
这就是所谓的“惊群现象”,具体来说就是“accept惊群”。需要说明的是,这种惊群现象在linux内核2.6版本之后已经解决了,解决方案也很简单,就是每当连接来的时候只唤醒一个对应的进程,使其得到资源,其他的原地不动。
关于惊群效应,还有一些问题可以讨论,比如说,
(1)、网络并发编程中还有哪些经典的惊群效应?
(2)、惊群效应更本质的探究?
(3)、锁可以带来惊群效应吗? 常见的避免惊群效应的方法有哪些?
…
我觉得这可以作为一个专题,下次单独探究一波,
这里还有一个附带的小问题,上图所示的已建立连接的缓冲区,按理来说应该算是个临界区,多个进程对其的访问应该要加锁的,所以我估计在accpet的内部具体实现上,应该是加了锁的。 如果有有小伙伴了解相关内容的可以和我说说啊,( ^▽ ^ )。
上面是说的共享listenFd连接的一种情况,这里还有另外一种情况:
请问,多个进程(线程)可以共享同一个已建立连接的套接字connFd吗? 也就是说多个进程可以使用相同的connFd和客户端进行通信吗(进行read和write)?
enenen…, 可以是可以,但是没必要,或者说这种实现方式不太好。
首先来说一下为什么可以?
本质上一个Fd就是内核的一个资源标识(在某些书上叫做句柄),我们需要真正操作的是不同的实体,对于connFd来说,其就是内核的一片缓冲区(就是read或者write时的缓冲区).多个进程如果对同一个connFd进行read(或者write) 相当于对同一片内存缓冲区进行操作,完全没问题。(这就像两个进程同时操作一个文件一样)。
然后说一下问什么不好?
并发编程有一个特点,就是进程或者线程太多,比较乱,按照设计模式中“单一职责”的原则,我们应该尽量降低程序之间的耦合性。按照我目前的认识来看,软件程序发展的一个趋势就是在不断降低程序间的耦合性,各个程序之间通过通信而不是共享资源来进行“交流”。
2.1.2 关于不同进程之间传递描述符的讨论
这个不是重点,而且在前文中也有论述,这里就简单介绍一点吧。
我们要了解的是在不同进程之间传递描述符并不是传递一个描述符号,而是在设计接受进程中创建一个新的描述符,而这个新的描述符和发送进程中的描述符指向内核中的相同表项。
如下图所示。A进程向B进程传递描述符,需要使得B进程在自己的描述符表中添加一项,使其指向系统打开表中和A进程共同的地方。
上面是理论部分,具体的实现有不同的方式,本文中使用的是利用Unix域套接字+sendmgs 传递一个特殊的消息,进而传递描述符。 详细的讨论可以参照【3】中的相关章节。
2.2 prefork模型小总结
从某种程度上说,prefork模型算是一种以空间换时间的一种实现。事先建立一批待工作的worker 进程,然后在其需要工作的时候给其分配必要的工作。
从另一方面说,这种模型并没有实质的改变并发模型中IO工作的方式,只是横向扩展了“worker”的数量。对于每个“worker”来说,其如果有多个IO需要处理, 还是每次只能阻塞在一个IO之上。 同时,这种方式把“程序性”、“事务性”的工作(即IO读取、分配空闲进程等等) 和业务性的工作杂糅在一起,不方便扩展。 后面介绍的reactor模型会消除这个问题。
参考
【1】、《Linux 多线程服务端编程》
【2】、《Linux高性能服务器编程》
【3】、《Unix 网络编程》 卷1
【4】、不可不知的socket和TCP连接过程