TCP/IP网络编程 第十章:多进程服务器端

在之前实现的服务器/客户端服务器中,基本上服务器是一个一个服务客户端的。就好像在银行柜台中,柜员一个一个的服务顾客。但这里会产生一个问题,来的晚的顾客会等的忍无可忍。之前章节描述的模型也是相似的。如果可以做到当有客户端连接到来时,服务器就能为其分配分配服务该多好啊!因此,接下来讨论如何提高客户端满意平均度。

并发服务器端的实现方法

下面列出的时具有代表性的并发服务器端实现模型和方法。

1.多进程服务器:通过创建多个进程提供服务

2.多路复用服务器:通过捆绑并统一管理IO对象提供服务

3.多线程服务器:通过生成与客户端等量的线程提供服务

上述并发服务器都有一个特点,虽然延长了平均服务时间,但是所有连接请求的受理时间会大大缩短,平均满意度得到提高。下面就来介绍第一种构建方法(注:是基于Linux的方法)。

理解进程(Process)

如果学过操作系统的同学可以跳过这几个基础内容讲解的小节了。进程简单理解,就是"正在运行的程序"。他和我们在硬盘里面的程序有很大区别。可以这么理解进程是程序在内存里面动态的表现形式,而程序(可执行文件)是在硬盘里面的静态表现形式。

举几个例子吧,当你打开微信的时候,程序的本体从硬盘中装载入内存,这时候一个进程就形成了。同时你可能打开网页,音乐播放器,文字处理软件等等应用,它们都会形成一个或者多个进程。接下来要创建的多进程服务器就是其中的代表。

进程ID

在每个进程创建的同时,所有进程都会从操作系统分配到ID。这个ID相当于是操作系统给所有进程的一个标签,便于操作系统管理这些进程。

调用fork函数创建进程

创建进程的方法有很多,此处只介绍用于创建多进程服务器端的fork函数。

#include<unistd.h>
pid_t fork(void);//成功时返回进程ID,失败时返回-1

这里创建子进程的方法主要是依靠写时复制的方法。怎么理解呢?就是在创建子进程后,大家先共享同一块内存,但是如果有谁要修改内存中的内容,那么此时操作系统会再分配出一块新的内存存放修改后的内容。这种写时复制的方法,可以非常快速的创建子进程,并且节约内存空间。

再说回到fork函数,其实fork函数的返回值对于子进程和父进程是不同的。

父进程:fork函数返回子进程的ID。

子进程:fork函数返回0。

接下来给出示例验证之前的内容。

#include<stdio.h>
#include<unistd.h>

int gval=10;
int main(int argc,char* argv[]){
    pid_t pid;
    int lval=20;
    gval++,lval+=5;

    pid=fork();
    if(pid==0)gval+=2,lval+=2;
    else gval-=2,lval-=2;

    if(pid==0)printf("Child Proc: [%d, %d] \n",gval,lval);
    else printf("Parent Proc: [%d, %d] \n",gval,lval);
    return 0;
}

进程和僵尸进程

产生僵尸进程的原因

1.传递参数并调用exit函数

2.main函数中执行return语句并返回值

向exit函数传递的参数值和main函数的retun语句返回的值都会传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。也就是说,将子进程变成僵尸进程的正是操作系统。既然如此,此僵尸进程何时被销毁呢?当父进程接收到子进程返回的这些参数时,僵尸进程会被销毁。如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母要负责收回自己生的孩子(也许这种描述有些不妥)。

销毁僵尸进程1:利用wait函数

如前面所述,为了销毁子进程,父进程应主动请求获取子进程的返回值。接下来讨论发起请求的具体方法,共有两种,其中之一就是调用如下函数。

#include<sys/wait.h>
pid_t wait(int*statloc);//成功时返回终止的子进程ID,失败时返回-1

调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离。

□ WIFEXITED子进程正常终止时返回“真”(true)。
□WEXITSTATUS返回子进程的返回值。

也就是说,向wait函数传递变量status的地址时,调用wait函数后应编写如下代码。

if(WIFEXITED(status)){//是正常终止的吗?
     puts("Normal termination!");
     printf("Child pass num: %d",WEXITSTATUS(status));//那么返回值是多少?
}

根据上述内容编写示例,此示例中不会再让子进程变为僵尸进程。

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>

int main(int argc,char *argv[]){
    int status;
    pid_t pid=fork();
  
    if(pid==0)return 3;
    else{
        printf("Child PID: %d \n",pid);
        pid=fork();
        if(pid==0)exit(7);
        else{
            printf("Child PID: %d \n",pid);
            wait(&status);
            if(WIFEXITED(status))
                  printf("Child send one: %d \n",WEXITSTATUS(status));

            wait(&status);
            if(WIFEXITED(status))
                  printf("Child send two: %d \n",WEXITSTATUS(status));
            sleep(30);
        }
    }
    return 0;
}

这就是通过调用wait函数消灭僵尸进程的方法。但是调用wait函数时,如果没有已终止的子进程,那么程序将堵塞直到有子进程终止,因此需谨慎调用该函数。

销毁僵尸进程2:调用waitpid函数

wait函数会引起程序阻塞,还可以考虑到调用waitpid函数。这就是防止僵尸进程的第二种方法,也是防止阻塞的方法。

#include<sys/wait.h>
pid_t waitpid(pid_t pid,int *statloc,int options);
//成功是返回终止的子进程ID(或0),失败时返回-1.
       pid     //等待终止的目标子进程的ID,如果传递-1,则与wait函数相同,可以等待任意子进程终止
       statloc //与wait函数对应参数有着相同的含义
       options //传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状 
               //态,而是返回0并退出函数

信号处理

只是通过waitpid函数等待子进程终止是不现实的。还好,操作系统给我们提供了另外的机制——"信号处理"机制。此处的信号是在特定事件发生时由操作系统向进程发送的消息。同时,为了相应该消息,执行与消息相关的自定义操作的过程称为"处理"或"信号处理"。

信号与signal函数

下列进程和操作系统间的对话是帮助大家理解信号处理而编写的,其中包含了所有信号处
相关内容。

进程:“嘿,操作系统!如果我之前创建的子进程终止,就帮我调用zombie_handler函数。“

操作系统:“好的!如果你的子进程终止,我会帮你调用zombie_handler函数,你先把该函数要执行的语句编好!”

上述对话中进程所讲的相当于“注册信号”过程,即进程发现自己的子进程结束时,请求模
作系统调用特定函数。该请求通过如下函数调用完成(因此称此函数为信号注册函数)。

#include<signal.h>
void (*signal(int signo,void (*func)(int)))(int);

解释一下上述的函数声明。

函数名:signal

参数:int signo,void (*func)(int)

返回类型:参数类型为int型,返回void类型函数指针

调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在signal函数中注册的部分特殊情况和对应的常数。
□SIGALRM:已到通过调用alarm函数注册的时间。
□ SIGINT:输入CTRL+C。
□ SIGCHLD:子进程终止。

接下来编写调用signal函数的语句完成如下请求:
“子进程终止则调用mychild函数。”
此时mychild函数的参数应为int,返回值类型应为void。只有这样才能成为signal函数的第二个参数。另外,常数SIGCHLD定义了子进程终止的情况,应成为signal函数的第一个参数。也就是说,signal函数调用语句如下。

signal(SIGCHLD,mychild);


接下来编写signal函数的调用语句,分别完成如下2个请求。“已到通过alarm函数注册的时间,请调用timeout函数。”“输入CTRL+C时调用keycontrol函数。”
代表这2种情况的常数分别为SIGALRM和SIGINT,因此按如下方式调用signal函数。

signal(SIGALRM, timeout);
signal(SIGINT,keycontrol);


以上就是信号注册过程。注册好信号后,发生注册信号时(注册的情况发生时),操作系统
将调用该信号对应的函数。下面通过示例验证,先介绍alarm函数。

#include <unistd.h>
unsigned int alarm(unsigned int seconds);//返回0或以秒为单位的距SIGALRM信号发生所剩时间。

如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理。
接下来给出信号处理相关示例,希望各位通过该示例彻底掌握之前的内容。

#include<stdio.h>
#include<unistd.h>
#include<signal.h>

void timeout(int sig){
     if(sig==SIGALRM)
          puts("Time out!");
     alarm(2);
}

void keycontrol(int sig){
     if(sig==SIGINT)
          puts("CTRL+C pressed");
}

int main(int argc,char *argv[]){
     int i;
     signal(SIGALRM,timeout);
     signal(SIGINT,keycontrol);
     alarm(2);

     for(int i=0;i<3;i++){
        puts("wait...");
        sleep(100);
     }
     return 0;
}

对于上述代码有一点必须要进行说明:"发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程"。调用函数的主体确实是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,将唤醒由于调用sleep函数而进入阻塞状态的进程。

利用sigaction函数进行信号处理

为什么在介绍了signal函数后又介绍了sigaction函数呢?是因为如下原因:"signal函数在UNIX系列的不同操作系统中可能存在区别,但sigaction函数完全相同。"实际上现在很少使用signal函数编写程序,它只是为了保持对旧程序的兼容。

#include<signal.h>
int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact);
//成功时返回0,失败时返回-1
    signo  //与signal函数相同,传递信号信息
    act    //对于第一个参数的信号处理函数信息
    oldact //通过此参数获得之前注册信号处理函数指针,如果不需要可以传递0

sigaction结构体定义如下。

struct sigaction{
   void (*sa_handler)(int);
   sigset_t sa_mask;
   int sa_flags;
}

这个结构体的sa_handler成员保存信号处理函数的指针值。sa_mask和sa_flags的所有位均初始化为0即可。这两个成员用于指定信号相关的选项和特性,而我们的主要是防止产生僵尸进程,故省略。

下面是该函数的演示示例:

#include<stdio.h>
#include<unistd.h>
#include<signal.h>

void timeout(int sig){
    if(sig==SIGALRM)
       puts("Time out!");
    alarm(2);
}

int main(int argc,char* argv[]){
    int i;
    struct sigaction act;
    act.sa_handler=time;
    sigemptyset(&act.sa_mask);
    act.sa_flags=0;
    sigaction(SIGALRM,&act,0);

    alarm(2);
    for(int i=0;i<3;i++){
       puts("wait....");
       sleep(100);
    }
    return 0;
}
  

以上就是信号处理的相关理论。

利用信号处理技术消灭僵尸进程

接下来利用sigaction函数编写演示。

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>

void read_childproc(int sig){
     int status;
     pid_t id=waitpid(-1,&status,WNOHANG);
     if(WIFEXITED(status)){
          printf("Removed proc id: %d \n",id);
          printf("Child send: %d \n",WEXITSTATUS);
     }
}

int main(int argc,char *message){
     pid_t pid;
     struct sigaction act;
     act.sa_handler=read_childproc;
     sigemptyset(&act.sa_mask);
     act.sa.flags=0;
     sigaction(SIGCHLD,&act,0);

     pid=fork();
     if(pid==0){
        puts("Hi! I'm child process");
        sleep(10);
        return 12;
     }
     else{
         printf("Child proc id: %d \n",pid);
         pid=fork();
         if(pid==0){
            puts("Hi! I'm child process");
            sleep(10);
            exit(24);
         }
         else{
            int i;
            printf("Child proc id: %d \n",pid);
            for(int i=0;i<5;i++){
                 puts("wait...");
                 sleep(5);
            }
         }
     }
     return 0;
}

接下来的小节会介绍利用进程相关知识编写服务器端。

基于多任务的并发服务器

每当有客户端请求服务(连接请求)时,回声服务器端都创建子进程以提供服务。请求服务的客户端若有5个,则将创建5个子进程提供服务。为了完成这些任务,需要经过如下过程,这是与之前的回声服务器端的区别所在。
□第一阶段:回声服务器端(父进程)通过调用accept函数受理连接请求。

□第二阶段:此时获取的套接字文件描述符创建并传递给子进程。

□第三阶段:子进程利用传递来的文件描述符提供服务。
此处容易引起困惑的是向子进程传递套接字文件描述符的方法。但各位读完代码后会发现这其实没什么大不了的,因为子进程会复制父进程拥有的所有资源。实际上根本不用另外经过传递文件描述符的过程。

以下是实现并发服务器的代码

#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>

#defind BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);
  
int main(int argc,char *agcv[]){
    int serv_sock,clnt_sock;
    struct sockaddr_in serv_addr,clnt_addr;

    pid_t pid;
    struct sigaction act;
    socklen_t addr_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_addr,0,sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_addr.sin_port=htons(atoi(argv[1]));

    if(bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr))==-1)
        error_handling("bind() error");

    if(listen(serv_sock,5)==-1)
        error_handling("listen() error");

    while(1){
      addr_sz=sizeof(clnt_addr);
      clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_addr,&addr_sz);
      if(clnt_sock==-1)
          continue;
      else
          puts("new client connected...");
      pid=fork();
      if(pid==-1){
         close(clnt_sock);
         continue;
      }
      if(pid==0){  //子进程运行区域
         close(serv_sock);
         while((str_len=read(clnt_sock,buf,BUF_SIZE))!=0)
               write(clnt_sock,buf,str_len);

         close(clnt_sock);
         puts("client disconnected...");
         return 0;
      }
      else close(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);
}

通过fork函数复制文件描述符

上述示例给出了通过fork函数复制文件描述符的过程。父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。文件描述符的实际复制多少有些难以理解。调用fork函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字。但套接字并非进程所有——从严格意义上说,套接字属于操作系统——只是进程拥有代表相应套接字的文件描述符。也不一定非要这样理解,仅因为如下原因,复制套接字也并不合理。

“复制套接字后,同一端口将对应多个套接字。”
对于上述代码可以通过Java的垃圾回收机制理解一下。套接字的文件描述符相当于是套接字的引用,在父进程调用fork函数创建子进程后这个引用也复制给了子进程。此时一个套接字相当于有了两个引用,所以只关闭一个文件描述符套接字是不会被垃圾系统回收的。所以为了每个套接字都能得到回收,因此我们只对每个套接字保留一个引用(文件描述符),最后只用调用一次关闭文件描述符,我们就可以关闭一个套接字。

猜你喜欢

转载自blog.csdn.net/Reol99999/article/details/131737564