TCP/IP网络编程_第10章多进程服务器端(上)

在这里插入图片描述

10.1 进程概念及应用

利用之前学习到的内容, 我们可以构建按序向第一个客户端到第一百个客户端提供服务的服务器端. 当然, 第一个客户端不会抱怨服务器端, 但如果每个客户端的平均服务器时间为0.5秒, 则第100个客户端会对服务器端产生相当大的不满.

两种类型的服务器端

如果真正为客户端着想, 应提高客户端满意度平均标准. 如果有下面这种类型的服务器端, 各位应该感到满意了吧.
在这里插入图片描述
如果排在前面的请求数能用一只手数清, 客户端当然会对服务器端感到满意. 但只要超过这个数, 客户端就会开始抱怨. 还不如下面这种方式提供服务.
在这里插入图片描述
大家无需过多考虑到底哪种服务器端好一些, 只需假设收看网络视频课程, 而且其顺序是第100位, 就能得出结论. 因此, 接下来讨论如何提高客户端满意度平均标准.

并发服务器端的实现方法

即便有可能延长服务时间, 也有必要改进服务器端, 使其同时向所有发起请求的客户端提供服务, 以提高平均满意度. 而且, 网络程序中数据通信时间比CPU运算时间占比更大, 因此, 向对多个客户端提供服务是一种有效利用CPU的方式. 接下来讨论同时向多个客户端提供服务的并发服务器端. 下面列出的是具有代表性并发服务器端实现模型和方法.
在这里插入图片描述
先讲解第一种方法: 多进程服务器端. 这种方法不适合在 Windows 平台下 (Windows不支持) 讲解, 因此将重点放在 Linux 平台. 若各位不关心基于 Linux 的实现, 可以直接跳过第12章. 不过还是希望尽可能浏览一下, 因为本章内容有助于理解服务器构造方法.

理解进程 (Process)
接下来了解多进程服务器实现的重点内容-进程, 其定义如下:
在这里插入图片描述
假如各位位下载了LBreakout 游戏并安装到硬盘. 此时的游戏并非进程, 而是程序. 因为游戏并非进入运行状态. 下面开始运行程序. 此时游戏被加载到主内存并进入运行状态, 这时才可称为程序. 如果同时运行多个LBreakout 程序, 则会生成相应数量的进程, 也会占用相应的进程数的空间.

再举个例子. 假设各位需要进行文档相关操作, 这时应打开文档编辑软件. 如果工作的同时还想听歌, 应打开MP3 播放器. 另外, 为了与朋友聊聊天, 再打开QQ软件. 此时共创建3个进程. 从操作系统的角度看, 进程是程序流的基本单位, 若创建多个进程, 则操作系统将同时运行. 有时一个程序运行过程也会产生多个进程. 接下来要创建的多进程服务器就是其中的代表. 编写服务器端前, 先了解一下通过程序创建进程的方法.
在这里插入图片描述

CPU核的个数与进程数

拥有2个运算设备的CPU称作双核(Daul)CPU, 拥有4个运算器的CPU称为4核(Quad)CPU. 也就是说, 1个CPU 中可能包含多个运算设备(核). 核的个数与可同时运行进程数相同. 相反, 若进程数超过核数, 进程将分时使用CPU资源. 但因为CPU运转速度极快, 我们会感到所有进程同时运行. 当然, 核数越多, 这种感觉越明显.

进程ID

讲解创建进程的方法前, 先简要说明进程ID. 无论进程是如何创建的, 所有进程都会从操作系统分配到ID, 此ID称为 “进程ID”, 其值为大于2的整数. 1要分配给操作系统启动后的(用于协助操作系统)首个进程. 因此用户进程无法得到ID值1. 接下来观察 Linux 中正在运行的进程.

运行结果: ps 命令语句

在这里插入图片描述
可以看出, 通过ps指令可以查看当前运行的所有进程. 特别需要注意的是, 该命令同时列出了PID(进程ID). 另外, 上述示例通过指定a和u参数列出了所有进程详细信息.

通过调用 fork 函数创建进程

创建进程的方法很多, 此处只介绍用于创建多进程服务器端的 fork 函数.
在这里插入图片描述
fork 函数将创建的进程副本(概念上略难). 也就是说, 并非根据完全不同的程序创建进程, 而是复制正在运行的, 调用的 fork 函数的进程. 另外, 两个进程都将执行 fork 函数调用后的语句(准确的说是在 fork 函数返回后). 但因为通过同一进程, 复制相同的内存空间, 之后的程序流要根据 fork 函数的返回值加以区分. 及利用 fork 函数的如下特点区分程序执行流程.
在这里插入图片描述
此处 “父进程(Parent Process)” 指原进程, 即调用 fork 函数的主体, 而 “子进程”(Child Process)是通过父进程调用 fork 函数复制出的进程. 接下来讲解调用 fork 函数后程序运行流程, 如图10-1 所示.
在这里插入图片描述
从图10-1中可以看到, 父进程调用 fork 函数的同时复制出子进程, 并分别得到 fork 函数的返回值. 但复制前, 父进程将全局变量gval 增加到11 , 将局部变量lval 的值增加到25, 因此在这种状态下完成进程复制. 复制完成后根据 fork 函数的返回类型区分父/子进程. 父进程将lval的值加1, 但这不会影响子进程的 lval 值. 同样, 子进程将 gval 的值加1 也不会影响到父进程的gval. 因为 fork 函数调用后分成了完全不同的进程, 只是二者共享同一端代码而已. 接下来给出示例验证之前的内容.

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

int gval = 10;

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

    pid = fork();
    if (pid == 0)
    {
        gval = gval + 2;
        lval = lval + 2;
    }
    else
    {
        gval = gval - 2;
        lval = 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 函数后, 父进程拥有完全独立的内存结构. 我认为关于 fork 函数无需更多的示例, 希望各位通过该示例充分理解 fork 函数创建进程的方法.

10.2 进程和僵尸进程

文件操作中, 关闭文件和打开文件同等重要. 同样, 进程的销毁也和进程创建同等重要. 如果未认真对待进程销毁, 它们将变成僵尸进程困扰各位. 大家可能觉得这是在开玩笑, 但事实的确如此.

僵尸(Zombie) 进程

大家应该听过僵尸. 恐怖电影中的僵尸可以复活, 给主人公造成极大的麻烦. 一两只还好对付, 但它们一般都成群结队, 给观众一种刺激和紧张感. 但我们的主人公通常神通广大, 即使几百只僵尸同时出现也能顺利脱险(僵尸通常行动缓慢), 因为主人公知道如何对付僵尸. 结局就是所有僵尸都会走向灭亡.

进程的世界同样如此. 进程完成工作后(执行完main函数的程序后)应被销毁, 但有时这些进程将会变成僵尸进程, 占用系统中的重要资源. 这种状态下的进程称作"僵尸进程", 这也是给系统带来负担的原因之一. 就像电影中描述的那样, 我们应该消灭这种进程. 当然应掌握真确的方法, 否则它会死灰复燃.

产生僵尸进程的原因

为了防止僵尸进程的产生, 先解析产生僵尸进程的原因. 利用如下两个示例展示调用 fork 函数产生子进程的终止方式.
在这里插入图片描述
向exit 函数传递的参数值和main 函数的 return 语句返回的值都会传递给操作系统. 而操作系统不会销毁子进程, 直到把这些值传递给产生该子进程的父进程. 处于这种状态下的进程就是僵尸进程. 也就是说, 将子进程变成僵尸的正是操作系统. 既然如此, 此僵尸进程何时被销毁呢? 其实已经给出提示.
在这里插入图片描述
如何向父进程传递这些值呢? 操作系统不会主动把这些值传递给父进程. 只有父进程主动发起请求(函数调用)时, 操作系统才会传递该值. 换言之, 如果父进程未主动要求获得子进程的结束状态值, 操作系统将一直保存, 并让子进程长时间处于僵尸状态. 也就是说, 父母要负责收回自己生的孩子(也许这种描述有些不当). 接下来示例将创建僵尸进行.

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

int main(int argc, char *argv[])
{
    pid_t pid = fork();

    if (pid == 0)
    {
        puts("Hi, I am a child process");
    }
    else
    {
        printf("Child Process ID: %d \n", pid);
        sleep(30);
    }

    if (pid == 0)
    {
        puts("End child process");
    }
    else
    {
        puts("End parent process");
    }
    
    return 0;
}

运行结果:
在这里插入图片描述
程序开始运行后, 将如上所示的状态暂停. 跳出这种状态前(30秒内), 应验证子进程是否为僵尸进程. 该验证在其他控制窗口进行.
在这里插入图片描述
可以看出, PID 为2373 的进程状态为僵尸进程(Z+). 另外, 经过30秒的等待时间后, PID 为2372的父进程和之前的僵尸子进程同时销毁.
在这里插入图片描述

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

如前所述, 为了销毁子进程, 父进程应主动请求获取子进程的返回值. 接下来讨论发起请求的具体方法(幸好非常简单), 同有2种, 其中一种之一就是调用如下函数.
在这里插入图片描述
调用此函数时如果已有子进程终止, 那么子进程终止时传递的返回值(exit函数的参数值, main函数的return 返回值) 将保存到该函数的参数所值内存空间. 但函数参数指向的单元中还包含其他信息, 因此需要通过下列宏进行分离.
在这里插入图片描述
也就是说, 向wait 函数传递变量status 地址时, 调用 wait 函数后应编写如下代码.
在这里插入图片描述
根据上述内容编写示例, 此示例不会再让子进程变成僵尸进程.

#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 ont: %d \n", WEXITSTATUS(status));
        }

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

运行结果:
在这里插入图片描述
在这里插入图片描述
系统中并无上述结果中的PID 对应的进程, 希望各位进行验证. 这是因为调用了wait 函数, 完全消灭了该进程. 另外2个子进程终止时返回的3和7传递到了父进程.

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

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

wait 函数会引起程序阻塞, 还可以考虑调用 waitpid 函数. 这是防止僵尸进程的第二个方法, 也是防止阻塞的方法.
在这里插入图片描述
下面介绍调用上述函数的示例. 调用 waitpid 函数时, 程序不会阻塞. 各位应重点观察这点

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

int main(int argc, char *argv[])
{
    int status;
    pid_t pid = fork();

    if (pid == 0)
    {
        sleep(15);
        return 24;
    }
    else
    {
        while (!waitpid(-1, &status, WNOHANG))
        {
            sleep(3);
            puts("sleep 3sec.");
        }

        if (WIFEXITED(status))
        {
            printf("Child send %d \n", WEXITSTATUS(status));
        }
    }
    
    return 0;
}

运行结果:
在这里插入图片描述
可以看出第20行共执行了5次. 另外, 这也证明waitpid 函数并未阻塞.

10.3 信号处理

我们已经知道了进程创建及销毁方法, 但还有一个问题没解决.
在这里插入图片描述
父进程往往与子进程一样繁忙, 因此不能只调用 waitpid 函数以等待子进程终止. 接下来讨论解决方案.

向操作系统求助

子进程终止的识别主体是操作系统, 因此, 若操作系统能把如下信息告诉正忙于工作的父进程, 将有助于构建高效的程序.
在这里插入图片描述
此时父进程将暂时放下工作, 处理子进程终止相关事宜. 这是不是既合理又很酷的想法呢? 为了实现该想法, 我们引入信号处理 (Signal Handling) 机制. 此处的"信号" 是在特定事件发生时由操作系统向进程发送消息. 另外, 为了响应该消息, 执行与消息相关的自定义操作的过程称为"处理"或"信号处理". 关于这两点稍后将再次说明, 各位现在不用完全理解这些概念.

关于 JAVA 的题外话: 保持开发思维

路途漫漫, 我们休息一下, 先讨论一下 JAVA. 我想讨论的主题如下:
在这里插入图片描述
JAVA 语言具有平台移植性, 经历长时间的发展和变化, 其优势在企业级开发环境下尤为明显. 理论介绍到此为止, 接下来通过讨论 JAVA 扩展视野.

JAVA 在编程语言层面支持进程或线程(稍后将讲解), 但C语言及C++ 语言并不支持. 也就是说, ANSI 标准并未定义支持进程或线程的函数(JAVA的方法). 但仔细想想, 这也是合理的. 进程或线程应有操作系统提供支持. 因此, Windows 中按照 Windows 的方法, Linux 中按照 Linux 的方法创建进程或线程. 但JAVA 为了平台移植性, 以独立操作系统的方法提供进程和线程的创建方法. 因此, JAVA 在语言层面支持进程和线程的创建.

既然如此, JAVA 网络编程是否相对简单? 就像大家之前学习过的, 网络编程中需要一定的操作系统相关知识, 因此, 有些人会把网络编程当作系统编程的一部分. 基于 JAVA 进行网络编程时, 的确会摆脱特定的操作系统, 所以有人会误认为 JAVA 网络编程相当简单.

如果在语言层面支持网络编程所需的所有机制, 将延长学习时间. 要通过面向对象的方法编写高性能网络程序, 需要更多努力和知识. 如果有机会, 还是希望大家尝试 JAVA 网络编程, 而不仅仅 局限于 Linux 或 Windows , JAVA 在分布式环境中提供理想的网络模型.
在这里插入图片描述

结语:

你可以下面这个网站下载这本书<TCP/IP网络编程>
https://www.jiumodiary.com/

时间: 2020-06-02

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值