C++网络编程(四):多进程并发服务器

    科技2022-07-10  205

    部分内容转自Linux并发服务器编程之多进程并发服务器。

    目录

    前言创建进程Linux下的进程进程创建函数fork与vforkexec函数 使用fork函数实现多进程并发服务器分割I/O程序扩展-进程的终止

    前言

    服务器按处理方式可以分为迭代服务器和并发服务器两类。平常写的简单Socket客户端服务器通信,服务器每次只能处理一个客户的请求,它实现简单但效率很低,通常这种服务器被称为迭代服务器。 然而在实际应用中,不可能让一个服务器长时间地为一个客户服务,而需要其具有同时处理多个客户请求的能力,这种同时可以处理多个客户请求的服务器称为并发服务器,其效率很高却实现复杂。在实际应用中,并发服务器应用的最广泛。

    linux有3种实现并发服务器的方式:多进程并发服务器,多线程并发服务器,IO复用,先来看多进程并发服务器的实现。


    创建进程

    Linux下的进程

    在创建新进程时,要进行资源拷贝。Linux 有三种资源拷贝的方式:

    共享:新老进程共享通用的资源。当共享资源时,两个进程共同用一个数据结构,不需要为新进程另建。直接拷贝:将父进程的文件、文件系统、虚拟内存等结构直接拷贝到子进程中。子进程创建后,父子进程拥有相同的结构。写时复制(Copy on Write):拷贝虚拟内存页是相当困难和耗时的工作,所以能不拷贝就最好不要拷贝,如果必须拷贝,也要尽可能地少拷贝。为此,Linux 采用了 Copy on Write技术,把真正的虚拟内存拷贝推迟到两个进程中的任一个试图写虚拟页的时候。如果某虚拟内存页上没有出现写的动作,父子进程就一直共享该页而不用拷贝。

    进程创建函数fork与vfork

    下面介绍创建新进程的两个函数:fork()和 vfork()。

    其中,fork 用于普通进程的创建,采用的是 Copy on Write 方式;而 vfork 使用完全共享的创建,新老进程共享同样的资源,完全没有拷贝。

    fork函数原型如下:

    #include <unistd.h> pid_t fork (void)

    函数调用失败会返回-1。fork 函数调用失败的原因主要有两个:

    系统中已经有太多的进程;该实际用户 ID 的进程总数超过了系统限制。

    而如果调用成功,该函数调用会在父子进程中分别返回一次。在调用进程也就是父进程中,它的返回值是新派生的子进程的 ID 号,而在子进程中它的返回值为 0。因此可以通过返回值来区别当前进程是子进程还是父进程。

    为什么在 fork 的子进程中返回的是 0,而不是父进程 id 呢?

    原因在于:没有子进程都只有一个父进程,它可以通过调用 getppid 函数来得到父进程的 ID,而对于父进程,它有很多个子进程,他没有办法通过一个函数得到各子进程的ID。如果父进程想跟踪所有子进程的ID, 它必须记住 fork 的返回值。

    vfork函数原型如下:

    #include <unistd.h> pid_t vfork (void)

    vfork 是完全共享的创建,新老进程共享同样的资源,完全没有拷贝。当使用 vfork()创 建新进程时,父进程将被暂时阻塞,而子进程则可以借用父进程的地址空间运行。这个奇特 状态将持续直到子进程要么退出,要么调用 execve(),至此父进程才继续执行。   可以通过下面的程序来比较 fork 和 vfork 的不同。

    #include <sys/types.h> #include <unistd.h> int main(void) { pid_t pid; int status; if ((pid = vfork()) == 0) { sleep(2); printf("child running.\n"); printf("child sleeping.\n"); sleep(5); printf("child dead.\n"); exit(0); } else if ( pid > 0) { printf("parent running .\n"); printf("parent exit\n"); exit(0); 21. } else { printf("fork error.\n"); exit(0); } }

    程序运行结果如下:

    child running. child sleeping. child dead. parent running . parent exit

    如果将 vfork 函数换成 fork 函数,该程序运行的结果如下:

    parent running . parent exit [root@localhost test]# child running. child sleeping. child dead.

    fork与vfork区别:

    fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段

    fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。

    vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

    fork当需要改变共享数据段中变量的值,则拷贝父进程(写时复制);vfork()的子进程里不能访问/修改数据。

    exec函数

    fork函数是用于创建一个子进程,该子进程几乎是父进程的副本,而有时我们希望子进程去执行另外的程序,exec函数族就提供了一个在进程中启动另一个程序执行的方法。

    具体见exec函数详解。

    使用fork函数实现多进程并发服务器

    fork 调用后,父进程和子进程继续执行 fork 函数后的指令,是父进程先执行还是子进程先执行是不确定的,这取决于系统内核所使用的调度算法。    而在网络编程中,父进程中调用 fork 之前打开的所有套接字描述符在函数 fork 返回之后都是共享。如果父、子进程同时对同一个描述符进行操作, 而且没有任何形式的同步,那么它们的输出就会相互混合。

    fork函数在并发服务器中的应用:    父、子进程各自执行不同的程序段,这是非常典型的网络服务器。父进程等待客户的服务请求。当这种请求到达时,父进程调用 fork 函数,产生一个子进程,由子进程对该请求作处理。父进程则继续等待下一个客户的服务请求。并且这种情况下,在 fork 函数之后,父、子进程需要关闭各自不使用的描述符,即父进程将不需要的已连接描述符关闭,而子进程关闭不需要的监听描述符。这么做的原因有3个:

    节省系统资源防止上面提到的父、子进程同时对共享描述符进程操作最重要的一点,是确保close函数能够正确关闭套接字描述符

    我们在socket编程中调用 close 关闭已连接描述符时,其实只是将访问计数值减 1。而描述符只在访问计数为 0 时才真正关闭。所以为了正确的关闭连接,当调用 fork 函数后父进程将不需要的 已连接描述符关闭,而子进程关闭不需要的监听描述符。

    好了,有了上面的知识,我们现在可以总结出编写多进程并发服务器的基本思路:

    建立连接服务器调用fork()产生新的子进程父进程关闭连接套接字,子进程关闭监听套接字子进程处理客户请求,父进程等待另一个客户连接。

    服务器端代码示例(来源于网络):

    #include <stdlib.h> #include <stdio.h> #include <errno.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <sys/types.h> #include <netdb.h> #define SERV_PORT 1113 #define LISTENQ 32 #define MAXLINE 1024 /***连接处理函数***/ void str_echo(int fd); int main(int argc, char *argv[]){ int listenfd,connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in servaddr; struct sockaddr_in cliaddr; if((listenfd = socket(AF_INET, SOCK_STREAM,0))==-1){ fprintf(stderr,"Socket error:%s\n\a",strerror(errno)); exit(1); } /* 服务器端填充 sockaddr结构*/ bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl (INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); /* 绑定listenfd描述符 */ if(bind(listenfd,(struct sockaddr*)(&servaddr),sizeof(struct sockaddr))==-1){ fprintf(stderr,"Bind error:%s\n\a",strerror(errno)); exit(1); } /* 监听listenfd描述符*/ if(listen(listenfd,5)==-1){ fprintf(stderr,"Listen error:%s\n\a",strerror(errno)); exit(1); } for ( ; ; ) { clilen = sizeof(cliaddr); /* 服务器阻塞,直到客户程序建立连接 */ if((connfd=accept(listenfd,(struct sockaddr*)(&cliaddr),&clilen))==-1){ fprintf(stderr,"Accept error:%s\n\a",strerror(errno)); exit(1); } //有客户端建立了连接后 if ( (childpid = fork()) == 0) { /*子进程*/ close(listenfd); /* 关闭监听套接字*/ str_echo(connfd); /*处理该客户端的请求*/ exit (0); } close(connfd);/*父进程关闭连接套接字,继续等待其他连接的到来*/ } } void str_echo(int sockfd){ ssize_t n; char buf[MAXLINE]; again: while ( (n = read(sockfd, buf, MAXLINE)) > 0) write(sockfd, buf, n); if (n < 0 && errno == EINTR)//被中断,重入 goto again; else if (n < 0){//出错 fprintf(stderr,"read error:%s\n\a",strerror(errno)); exit(1); } }

    传统的网络服务器程序大都在新的连接到达时,fork一个子进程来处理。虽然这种模式很多年使用得很好,但fork有一些问题:

    fork是昂贵的。fork时需要复制父进程的所有资源,包括内存映象、描述字等;目前的实现使用了一种写时拷贝(copy-on-write)技术,可有效避免昂贵的复制问题,但fork仍然是昂贵的;fork子进程后,父子进程间、兄弟进程间的通信需要进程间通信IPC机制,给通信带来了困难;多进程在一定程度上仍然不能有效地利用系统资源;系统中进程个数也有限制。

    分割I/O程序

    如下图所示,客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入和输出,这样无论客户端是否从服务器端接收完数据都可以进行传输。

    按照这种实现方式,父进程中只需编写接收数据的代码,子进程中只需编写发送数据的代码,可以简化程序实现。

    分割I/O程序可以提高频繁交换数据的程序性能,因为分割了之后发送数据时不用考虑接收数据的情况,可以连续发送数据,提高同一时间内传输的数据量。

    部分代码实例:

    pid=fork() if(pid==0) write_routine(sock,buf); else read_routine(sock,buf);

    扩展-进程的终止

    进程终止存在两种可能:父进程先于子进程终止(孤儿进程);子进程先于父进程终止(僵尸进程)。

    如果父进程在子进程之前终止,则所有子进程的父进程被改为 init 进程,就是由 init 进程领养。在一个进程终止是,系统会逐个检查所有活动进程,判断 这些进程是否是正要终止 的进程的子进程。如果是,则该进程的父进程 ID 就更改为 1(init 的 ID)。这就保证了每个 进程都有一个父进程。

    如果子进程在父进程之前终止,系统内核会为每个终止子进程保存一些信息,这样父进 程就可以通过调用 wait()或 waitpid()函数,获得子进程的终止信息。终止子进程保存的信息 包括进程 ID、该进程的终止状态,以及该进程使用的 CPU 时间总量。当父进程调用 wait() 或 waitpid()函数时,系统内核可以释放终止进程所使用的所有存储空间,关闭其所有打开文 件。一个已经终止,但是其父进程尚未对其进行善后处理的进程称为僵尸进程。

    当子进程正常或异常终止时,系统内核向其父进程发送 SIGCHLD 信号,默认情况下, 父进程忽略该信号,或者提供一个该信号发生时即被调用的函数。    父进程可以通过调用 wait()或 waitpid()函数,获得子进程的终止信息。

    wait 函数原型如下:

    #include <sys/wait.h> pid_t wait(int *statloc);

    参数 statloc 返回子进程的终止状态(一个整数)。当调用该函数时,如果有一个子进程 已经终止,则该函数立即返回,并释放子进程所有资源,返回值是终止子进程的 ID 号。如果当前没有终止的子进程,但有正在执行的子进程,则 wait 将阻塞直到有子进程终止时才返 回。如果当前既没有终止的子进程,也没有正在执行的子进程,则返回错误-1。

    函数 waitpid 对等待哪个进程终止及是否采用阻塞操作方式方面给了更多的控制。

    waitpid函数原型如下:

    #include <sys/wait.h> waitpid(pid_t pid ,int *statloc, int option);

    当参数 pid 等于-1 而 option 等于 0 时,该函数等同于 wait()函数。 参数 pid 指定了父进程要求知道哪些子进程的状态,当 pid 取-1 时,要求知道任何一个子进程的终止状态。当 pid 取值大于 0 时,要求知道进程号为 pid 的子进程的终止状态。当 pid 取值小于-1 时,要求知道进程组号为 pid 的绝对值的子进程的终止状态。

    参数 option 让用户指定附加选项。最常用的选项是 WNO_HANG,它通知内核在没有已 终止子进程时不要阻塞。

    当前有终止的子进程时,返回值为子进程的 ID 号,同时参数 statloc 返回子进程的终止 状态。否则返回值为-1。

    和wait较大的不同是waitpid可以循环调用,等待所有任意进程结束,而wait只有一次机会。

    Processed: 0.019, SQL: 8