Linux网络编程(八)——多进程服务器端

文章目录

8 多进程服务器端

8.1 进程概念及应用

8.1.1 并发服务器端实现方法

8.1.2 理解进程

8.1.3 进程ID

8.1.4 通过调用fork函数创建进程

8.2 进程和僵尸进程

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

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

8.3 信号处理

8.3.1 向操作系统求助

8.3.2 信号与 signal 函数

8.3.3 利用sigaction函数进行信号处理

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

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

8.5 分割TCP的I/O程序

8.5.1 分割 I/O 的优点

8.5.2 具体示例


8 多进程服务器端

8.1 进程概念及应用

8.1.1 并发服务器端实现方法

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

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

第一种方法:多进程服务器。这种方法不适合在 Windows 平台下(Windows不支持),因此重点放在 Linux 平台

8.1.2 理解进程

进程(Process)是正在运行的程序,是操作系统进行资源分配和调度的基本单位。程序是存储在硬盘或内存的一段二进制序列,是静态的,而进程是动态的。进程包括代码、数据以及分配给它的其他系统资料员(如文件描述符、网络连接等) 我们打开的VMWare、开启的浏览器都对应操作系统的一个进程。

8.1.3 进程ID

无论进程是如何创建的所有的进程都会被操作系统分配一个 ID。此 ID 被称为进程ID,其值为大于 2 的整数。1 是分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1。我们可以在终端执行 ps -ef 指令查看所有进程(ps -ef 展示的进程是按照 pid 升序排列的,通常越晚创建的进程 pid 越大。因此,从下往上找能快速定位刚刚启动的进程) 

8.1.4 通过调用fork函数创建进程

fork() 函数通过系统调用创建一个与原来进程几乎完全相同的进程(父子进程),也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

#include <unistd.h>
#include<sys/types.h>
typedef __pid_t pid_t	// pid_t是__pid_t的别名
    
__pid_t fork(void)
//返回值类型:pid_t(可看作int类型)
/**(1)在父进程中 返回子进程的PID
 * (2)在子进程中 返回0
 * (3)发生错误 返回-1
*/

一个进程调用 fork() 函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

int gval = 10;
int main(int argc, char const *argv[])
{
    pid_t pid;
    int lval = 20;
    pid = fork();
    gval++,lval+=5;
    if(pid<0) {
        //发生错误
        return 1;
    }
    else 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;
}

从运行结果来看,调用 fork 函数后分成了两个完全不同的进程,二者只是共享同一个代码而已,但是父子进程却拥有完全独立的内存结构。

8.2 进程和僵尸进程

Linux 中父进程除了可以启动子进程,还要负责回收子进程的状态。如果子进程结束后父进程没有正常回收,那么子进程就会变成僵尸进程——即程序执行完成,但是进程没有完全结束,其内核 PCB 结构体没有释放。 僵尸进程会占用系统内的重要资源,这也是给系统带来负担的原因之一。

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

为了销毁子进程,父进程应该主动请求获取子进程的返回值。下面是发起请求的具体方法。,有两种,下面的函数是其中一种。

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

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

  • WIFEXITED:子进程正常终止时返回 "真"
  • WEXITSTATUS:返回子进程的返回值

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

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

下列是示例代码

int main(int argc, char const *argv[])
{
    int status;
    pid_t pid = fork();
    if(pid < 0) return 1;
    else if(pid == 0) {
        printf("我是子进程1,我的pid号是:%d\n",getpid());  //子进程1
        return 3;     
    }
    else {
        //父进程
        printf("Child PID:%d\n",pid);   //此处打印的是子进程1的pid
        pid = fork();
        if(pid < 0) return 1;
        else if(pid == 0) {
            printf("我是子进程2,我的pid号是:%d\n",getpid());  //子进程2
            exit(7);
        }
        else {
            printf("Child PID:%d\n",pid);  //此处打印的是子进程2的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(5);  //阻塞程序,指导所有子进程终止
        }
    }
    return 0;
}

调用 wait 函数时,如果目前没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数。

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

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

 __pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options)   
/*
* __pid_t __pid:等待模式
*              (1)<-1:等待进程组ID等于给定值的任何子进程
*              (2)=-1:等待任何子进程 -> 儿孙都算
*              (3)= 0:等待同一进程组中任何子进程终止 -> 只算儿子
*              (4)> 0:仅等待指定进程ID的子进程终止
* int *__stat_loc:整数指针,子进程的返回值保存到该地址处
* int __options:传递头文件 sys/wait.h 声明的常量 WNOHANG ,
*                即使没有终止的子进程也不会进入阻塞状态,而是返回 0 退出函数。
* return: (1)返回pid(正整数):成功等到子进程停止 
*          (2)返回0:表示指定的子进程仍在运行,尚未结束
*          (3)返回-1:表示出现错误,无法等到子进程结束
*/

下面介绍调用上述函数的示例,调用 waitpid 函数时,程序不会阻塞。

int main(int argc, char const *argv[])
{
    int status;
    pid_t pid = fork();
    if(pid < 0) return 1;
    else if(pid==0) {
        sleep(15);  //让子进程运行足够长时间
        return 24;
    }
    else {
        //父进程等待子进程,若子进程还在运行,父进程休眠3s
        while(!waitpid(-1,&status,WNOHANG)) {
            sleep(3);
            puts("Parent sleep 3sec");
        }
        if(WIFEXITED(status))
            printf("Child send:%d\n",WEXITSTATUS(status));
    }
    return 0;
}

8.3 信号处理

子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?父进程往往与子进程一样繁忙,因此不能只调用 waitpid 函数以等待子进程终止。

8.3.1 向操作系统求助

子进程终止的识别主题是操作系统,因此,若操作系统能把子进程结束时的信息告诉正忙于工作的父进程,让父进程暂时放下工作,处理子进程终止相关事宜,这是不是既合理又很酷的事?为了实现上述的功能,引入信号处理机制(Signal Handing)。此处 “信号” 是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为 “处理”或“信号处理”

8.3.2 信号与 signal 函数

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

操作系统:好的,如果你的子进程终止,我就帮你调用 zombie_handler 函数,你先把函数要执行的语句写好。

上述的对话,相当于 “注册信号” 的过程。即进程发现自己的子进程结束时,请求操作系统调用的特定函数。该请求可以通过如下函数调用完成:

#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);   
/**
* int signo:要处理的信号,可以使用kill -l查看更多的信号
* void(*func)(int):当收到对应的signum信号时,要调用的函数,
*                   该函数接收一个int参数(信号编号),返回void。
* return:成功时回之前的信号处理函数指针,类型为void (*)(int)。若出错,返回SIG_ERR。
*/

下面给出可以在 signal 函数中注册的部分特殊情况和对应的函数

  • SIGALRM:已到通过调用 alarm 函数注册时间
  • SIGINT:输入 CTRL+C
  • SIGCHLD:每一个子进程终止发送信号,多个子进程同时终止,可能会合并为一个送信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间

如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果注册信号后未指定对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。

void timeout(int sig) {
    if (sig == SIGALRM)
        puts("Time out!");
    //每间隔两秒发出此一次SIGALRM信号
    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 ( i = 0; i < 3; i++) {
        printf("wait...\n");
        sleep(100);
        printf("I'm awake. I'm gonna start the next cycle\n");
    }
    return 0;
}

从上面的结果可以看出,发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。调用函数的主体的确是操作系统,但是进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,会唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。

8.3.3 利用sigaction函数进行信号处理

因为 signal 函数在Unix系列的不同操作系统可能存在区别,但 sigaction 函数完全相同。sigaction 函数可以完全代替后者,也更稳定。在实际开发中也很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容。

#include <signal.h>

int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact);
/*
* signo:要处理的信号,可以使用kill -l查看更多的信号
* act: 对于第一个参数的信号处理函数信息。(结构体类型的信号处理函数)
* oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
* return:成功时返回 0 ,失败时返回 -1
*/

//sigaction结构体如下
struct sigaction
{
    void (*sa_handler)(int);
    sigset_t sa_mask;  //信号集
    int sa_flags;
};

//初始化一个自定义信号集,将其所有信号都清空,即将信号集中的所有的标志位置为0,
int sigemptyset(sigset_t *set);

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

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 = timeout;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGALRM, &act, 0);
    alarm(2);
    for ( i = 0; i < 3; i++) {
        puts("wait...");
        sleep(100);
    }
    return 0;
}

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

改进服务器端 echo_mpserv.c 与第三章客户端 echo_client.c 进行通信

#define BUF_SIZE 1024
#define handle_error(cmd,result)    \
    if(result < 0)                  \
    {                               \
        perror(cmd);                \
        return -1;                  \
    }                               \

void head_childproc(int sig) {
    pid_t pid;
    int status;
    pid = waitpid(-1,&status,WNOHANG);
    printf("removed proc id:%d\n",pid);
}

int main(int argc, char const *argv[])
{
    struct sigaction act; 
    struct sockaddr_in serv_addr,clnt_addr;
    char* buf = malloc(sizeof(char)*BUF_SIZE);
    memset(buf,0,sizeof(buf));
    memset(&serv_addr,0,sizeof(serv_addr));
    memset(&clnt_addr,0,sizeof(serv_addr));
    if(argc != 2) {
        printf("Usage:%s <port>\n",argv[0]);
        exit(1);
    }

    //注册信号,子进程运行结束执行函数
    act.sa_handler = head_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD,&act,0);

    //套接字流程
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(atoi(argv[1]));
    inet_pton(AF_INET,"0.0.0.0",&serv_addr.sin_addr);
    int serv_sock = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",serv_sock);
    //设置地址在分配
    int option = 1;
    int optlen = sizeof(option);
    setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
    int tempt = bind(serv_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
    handle_error("bind",tempt);
    tempt = listen(serv_sock,128);
    handle_error("listen",tempt);

    while(1) {
        socklen_t clnt_len = sizeof(clnt_addr);
        int clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_addr,&clnt_len);
        puts("new client connected...");
        
        int str_len;
        pid_t pid = fork();
        if(pid<0) {
            close(clnt_sock);
            continue;
        }
        else if(pid==0) {
            //子进程  进行通信
            close(serv_sock);
            printf("与客户端%s %d建立连接,",inet_ntoa(clnt_addr.sin_addr),
                          ntohs(clnt_addr.sin_port));
            while((str_len=recv(clnt_sock,buf,BUF_SIZE,0))!=0) {
                send(clnt_sock,buf,str_len,0);
            }
            close(clnt_sock);
            puts("client disconnected...");
            return 0;
        }
        else {
            //父进程
            close(clnt_sock);
        }
    }
    close(serv_sock);
    free(buf);
    return 0;
}

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

上述代码子进程为什么要关闭服务端套接字,父进程为什么要关闭客户端套接字?父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。调用 fork 函数时会复制父进程的所有资源,但是套接字并非某一进程的“所有物”,而是归操作系统所有,只是进程拥有代表响应套接字的文件描述符。

如上图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止后(引用计数减至0),才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数后,要将无关紧要的套接字文件描述符关掉。

8.5 分割TCP的I/O程序

8.5.1 分割 I/O 的优点

上一章节的客户端在传输数据后要等待服务器端返回的数据,因此程序代码中重复调用了 sendrecv 函数。只能这么写的原因之一是,程序在1个进程中运行。而现在我们可以创建多个进程,因此可以分割数据收发过程。客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。

选择这种实现方式的原因有很多,但最重要的一点是,程序的实现更加简单。父进程中只需编写接
收数据的代码,子进程中只需编写发送数据的代码,所以会简化。

8.5.2 具体示例

改进客户端 echo_mpclient.c 与上一小节的 echo_mpserv.c 通信

void write_routine(int clnt_sock,char* buf) {
    while(1) {
        memset(buf,0,sizeof(buf));
        fgets(buf,BUF_SIZE,stdin);
        if(!strcmp(buf,"q\n") || !strcmp(buf,"Q\n")) {
            shutdown(clnt_sock,SHUT_WR);
            return;
        }
        send(clnt_sock,buf,strlen(buf),0);
    } 
}

void read_routine(int clnt_sock,char* buf) {
    while(1) {
        memset(buf,0,sizeof(buf));
        int str_len = recv(clnt_sock,buf,BUF_SIZE,0);
        if(str_len==0) return;
        printf("Message from server:%s",buf);
    }
}

int main(int argc, char const *argv[])
{
    struct sockaddr_in serv_addr,clnt_addr;
    char* buf = malloc(sizeof(char)*1024);
    memset(&serv_addr,0,sizeof(serv_addr));
    memset(&clnt_addr,0,sizeof(clnt_addr));
    if(argc != 3) {
        printf("Usage:%s <IP> <port>\n",argv[0]);
        exit(1);
    }
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(atoi(argv[2]));
    inet_pton(AF_INET,argv[1],&serv_addr.sin_addr);
    int clnt_sock = socket(AF_INET,SOCK_STREAM,0);
    int temp=connect(clnt_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
    printf("Connected......\n");

    pid_t pid = fork();
    if(pid<0) return 1;
    else if(pid==0) {
        write_routine(clnt_sock,buf);
    }
    else {
        read_routine(clnt_sock,buf);
    }
    close(clnt_sock);
    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值