多进程服务器

1、进程概念及应用

  我们知道,监听套接字会有一个等待队列,里面存放着不同客户端的连接请求,如果有一百个客户端,每个客户端的请求处理是0.5s,第一个客户端当然不会不满,但第一百个客户端就会有相当大的意见了。为了要使得所有客户端都尽可能的满意,我们应采用并发服务端,使其同时向所有发起请求的客户端提供服务。而且,网络程序中数据通信时间比CPU运算时间占比更大,因此,向多个客户端提供服务是一种有效利用CPU的方式。接下来讨论同时向多个客户端提供服务的并发服务端,下面提出具有代表性的并发服务端实现模型和方法:

  • 多进程服务器:通过创建多个进程提供服务
  • 多路复用服务器:通过捆绑并统一管理I/O对象提供服务
  • 多线程服务器:通过生成与客户端等量的线程提供服务

  先来简单理解下进程:我们打开电脑一般不会只做一件事,比方单纯的浏览网站,单纯的聊天。一般我们都是几件事轮流切换着做,我们会在浏览网页时打开音乐播放器播放音乐,还会时不时回复下QQ消息。那么这里就牵扯到三个进程了,一个是浏览器进程,一个是播放器进程,还有一个是QQ进程。从操作系统的角度看,进程是程序流的基本单位,若创建多个进程,则操作系统将同时运行。有时一个程序运行过程中也会产生多个进程,像谷歌浏览器,打开一个tab页,实际上就是产生一个新的进程。接下来要创建的多进程服务器就是其中的代表,编写服务端前,先了解一下通过程序创建进程的方法。

  CPU核的个数和进程数:拥有两个运算器的CPU称为双核CPU,拥有四个运算器的CPU称作四核CPU。也就是说,一个CPU可能包含多个运算器(核)。核的个数与可同时运行的进程数相同,相反,若进程数超过核数,进程将分时使用CPU资源。但因CPU运算速度极快,我们会感到所有进程同时运行,当然,核数越多,这种感觉越明显。

2、基于多任务的并发服务器

之前的回声服务端每次只能向一个客户端提供服务,现在,我们将扩展回声服务端,使其可以同时向多个客户端提供服务。图1-2给出了基于多进程的并发回声服务端的实现模型

 

  从图1-2可以看出,每当客户端请求时,回声服务端都创建子进程以提供服务,请求服务的客户端若有五个,则将创建五个子进程提供服务。为了完成这个任务,需要经过如下过程:

  • 第一阶段:回声服务端(父进程)通过调用accept函数受理连接请求
  • 第二阶段:此时获取的套接字文件描述符创建并传递给子进程
  • 第三阶段:子进程利用传递来的文件描述符提供服务

  此处容易引起困惑的是向子进程传递套接字文件描述符的方法,其实没什么大不了的,子进程会复制父进程拥有的所有资源,实际上根本不用另外经过传递文件描述符的过程

echo_mpserv.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 30
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;
 
    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");
 
    while (1)
    {
        adr_sz = sizeof(clnt_adr);
        clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_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);
}
  • 第29~32行:为防止产生僵尸进程而编写的代码
  • 第47、52行:第47行调用accept函数后,在第52行调用fork函数。因此,父子进程分别带有一个第47行生成的套接字(受理客户端连接请求时创建的)文件描述符
  • 第58~66行:子进程运行的区域,此部分向客户端提供回声服务,第60行关闭第33行创建的服务端套接字,这是因为服务端套接字文件描述符同样也传递到子进程,这一点稍后单独讨论
  • 第69行:第47行中通过accept函数创建的套接字文件描述符已复制给子进程,因此服务端需要销毁自己拥有的文件描述符,这一点稍后单独说明

编译echo_mpserv.c并运行

# gcc echo_mpserv.c -o echo_mpserv
# ./echo_mpserv 8500
new client connected...
new client connected...
client disconnected...
removed proc id: 7825
client disconnected...
removed proc id: 7823

通过echo_client程序连接服务端例1

# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Hello world!
Message from server:Hello world!
Input message(Q to quit):Hello Amy!
Message from server:Hello Amy!
Input message(Q to quit):Hello Tom!
Message from server:Hello Tom!
Input message(Q to quit):q

通过echo_client程序连接服务端例2 

# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Hello Java!
Message from server:Hello Java!
Input message(Q to quit):Hello Python!
Message from server:Hello Python!
Input message(Q to quit):Hello Golang!
Message from server:Hello Golang!
Input message(Q to quit):q

启动服务端后,要创建多个客户端连接,可以验证通过服务端同时向大多数客户端提供服务

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

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

  示例echo_mpserv.c中的fork函数调用过程如图1-3所示,调用fork函数后,两个文件描述符指向同一套接字

 

 

                  图1-3   调用fork函数并复制文件描述符

  图1-3所示,一个套接字中存在两个文件描述符,只有两个文件描述符都销毁后,才能销毁套接字。如果维持图中的连接状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务端套接字同理)。因此,调用fork函数后,要将无关紧要的套接字文件描述符关掉,如图1-4所示

 

 

图1-4   整理复制的文件描述符

4、分割I/O程序的优点

  我们已经实现的回声客户端的数据回声方式为:向服务端传输数据,并等待服务端回复。无条件等待,直到接收完服务端的回声数据后,才能传输下一批数据。传输数据后需要等待服务端返回的数据,因为程序中重复调用了read和write函数,只能这么写的原因之一是:程序在一个进程运行,但现在可以创建多个进程,因此可以分割数据收发过程,默认分割模型如图1-5所示:

 

                       图1-5   回声客户端I/O分割模型

  从1-5可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务端接收完数据都可以进程传输。选择这种实现方式的原因很多,但最重要的一点是,程序的实现更加简单,也许有人质疑:既然多产生一个进程,怎么能算简化程序呢?其实,按照这种实现方式,父进程只需编写接收数据的代码,子进程只需编写发送数据的代码,所以会简化。实际上,在一个进程内同时实现数据收发逻辑要考虑更多细节,程序会更复杂

分割I/O程序的另一个好处是,可以提高频繁交换数据的程序性能,如图1-6

 

                               图1-6   数据交换方法比较

  图1-6左侧演示的是之前回声客户端数据交换方式,右侧演示的是分割I/O后的客户端数据传输方式。服务端相同,不同的是客户端区域。分割I/O后的客户端发送数据时不必考虑接收数据的情况,因此可以连续发送数据,由此提高同一时间内传输的数据量,这种差异在网速较慢时尤为明显

5、回声客户端的I/O程序分割

  既然我们知道I/O程序分割的意义,接下来通过实际代码进行实现,分割的对象是回声客户端,下面回声客户端可以结合之前的回声服务端echo_mpserv.c运行

echo_mpclient.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
#define BUF_SIZE 30
void error_handling(char *message);
void read_routine(int sock, char *buf);
void write_routine(int sock, char *buf);
 
int main(int argc, char *argv[])
{
    int sock;
    pid_t pid;
    char buf[BUF_SIZE];
    struct sockaddr_in serv_adr;
    if (argc != 3) {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
 
    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 = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));
 
    if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
 
    pid = fork();
    if (pid == 0)
        write_routine(sock, buf);
    else
        read_routine(sock, buf);
 
    close(sock);
    return 0;
}
 
void read_routine(int sock, char *buf)
{
    while (1)
    {
        int str_len = read(sock, buf, BUF_SIZE);
        if (str_len == 0)
            return;
 
        buf[str_len] = 0;
        printf("Message from server: %s", buf);
    }
}
void write_routine(int sock, char *buf)
{
    while (1)
    {
        fgets(buf, BUF_SIZE, stdin);
        if (!strcmp(buf, "q\n") || !strcmp(buf, "Q\n"))
        {
            shutdown(sock, SHUT_WR);
            return;
        }
        write(sock, buf, strlen(buf));
    }
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

 

  • 第34~37行:第35行调用的write_routine函数中只有数据传输相关代码,第37行调用的read_routine函数中只有数据输入相关代码。像这样分割I/O并分别在不同函数中定义,将有利于代码实现
  • 第62行:调用shutdown函数向服务端传输EOF,当然,执行第63行的return语句后,可以调用第39行的close函数传递EOF,但现在已通过第33行的fork函数调用复制了文件描述符,此时无法通过一次close函数调用传递EOF,因此需要通过shutdown函数调用另外传递

启动服务端

# ./echo_mpserv 8500
new client connected...
client disconnected...
removed proc id: 7941

编译echo_mpclient.c并运行

# gcc echo_client.c -o echo_client
# ./echo_mpclient 127.0.0.1 8500
Hello world!
Message from server: Hello world!
Hello Amy!
Message from server: Hello Amy!
Hello Tom!
Message from server: Hello Tom!
q

  为了简化输出过程,与之前示例不同,不会输出提示字符串:“Input message(Q to quit):”。无论是否接收消息,每次通过键盘输入字符串都会输出前面的提示字符串,可能会造成输出混乱,所以上面示例就没在输出提示字符串。

posted @ 2020-04-26 22:20  孤情剑客  阅读(567)  评论(0)    收藏  举报