TCP/IP 网络编程(九)---多进程服务器端

51 阅读17分钟

进程

(一)进程的定义

  • 进程

“占用内存空间的正在运行的程序。”

进程(Process)是计算机程序在一个数据集合上的一次运行活动。它是操作系统中资源分配的基本单位,同时也是程序执行的一个独立实体。简单来说,进程就是程序的一次执行过程。

  • 线程

线程(Thread)是程序执行的最小单位,是进程中的一个执行路径。与进程不同,线程不独立存在,而是在进程的上下文中运行。一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件句柄等),但它们可以独立执行。

  • 进程和线程的区别
    • 进程是资源分配的最小单位,而线程是程序执行的最小单位。

    • 线程可以看作是进程中的一个执行路径,因此一个进程可以包含多个线程,共享进程的资源。

(二)进程 ID

无论进程是如何创建的,所有进程都会从操作系统分配到 ID。此 ID 称为“进程 ID”,其值为大于2的整数,1要分配给操作系统启动后的(用于协助操作系统)首个进程,所以用户进程无法得到 ID 值1。

终端输入命令 ps au 可显示所有进程的详细信息:

image.png

(三)调用 fork 函数创建进程

(1)fork() 函数

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

fork() 函数创建的子进程是调用该函数的进程(父进程)的副本。子进程继承父进程的几乎所有属性(如程序代码、数据、堆、栈、文件描述符等),但有独立的地址空间。

利用 fork() 函数的以下特点区分程序执行流程:

  • 父进程:在父进程中,fork() 返回子进程的进程 ID (PID)。

  • 子进程:在子进程中,fork() 返回 0。

“父进程”(Parent Process) 是指原进程,即调用 fork 函数的主体,而“子进程”(Child Process)是通过父进程调用 fork 函数复制出的进程:

image.png

(2)示例

#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();


    //两个进程都将执行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;
}
image.png

僵尸进程

(一)什么是僵尸进程

僵尸进程(Zombie Process),也称为“死亡进程”或“幽灵进程”,是指在 Unix/Linux 系统中,一个已经终止但其父进程尚未回收其退出状态的进程。

(二)僵尸进程的形成过程

  • 子进程终止:当一个子进程通过 exit() 系统调用或正常执行完毕而终止时,它的进程控制块(PCB)中的大部分资源(如内存等)都会被释放,但它的进程 ID (PID) 和一些信息(如退出状态)会保留。

  • 父进程尚未调用 wait() 系列函数:子进程终止后,内核会向父进程发送 SIGCHLD 信号,通知父进程子进程已终止。如果父进程没有调用 wait()waitpid() 函数来回收(reap)子进程的退出状态信息,则该子进程将保持僵尸状态。

  • 僵尸进程:此时,子进程的进程号 (PID) 依然存在,但它已经不再运行,也不占用系统资源(除了进程表中的一项)。这种状态的进程称为僵尸进程。

(三)僵尸进程示例

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

int main(int argc, char* argv[])
{
    // 调用 fork() 函数创建子进程
    pid_t pid = fork();

    // 检查 fork() 返回值
    if(pid == 0)
        puts("Hi, I am a child process"); // 子进程执行的代码
    else
    {
        printf("Child Process ID: %d \n", pid); // 父进程输出子进程的 PID
        sleep(30); // 父进程休眠30秒
    }

    // 根据 pid 判断是父进程还是子进程并输出相应信息
    if(pid == 0)
        puts("End child process"); // 子进程执行的代码
    else
        puts("End parent process"); // 父进程执行的代码

    return 0;
}

image.png

这段代码中,如果子进程在父进程休眠的 30 秒内结束,它会进入僵尸状态,直到父进程结束或调用 wait() 函数来回收子进程的资源。然而,这段代码中没有调用 wait(),因此在父进程休眠期间会产生一个僵尸进程。

当父进程结束时,所有子进程(包括僵尸进程)的资源会被内核自动清理。

(四)销毁僵尸进程

前面已经提到,父进程可以通过调用 wait()waitpid() 函数回收子进程的资源,即销毁僵尸进程。

(1)wait() 函数

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

status 参数用于存储子进程的终止状态,可以通过宏来解析状态值:

  • WIFEXITED(status) :如果子进程正常终止,则此宏返回一个非零值。
  • WEXITSTATUS(status) :在 WIFEXITED 为真时,返回子进程的退出状态代码。

示例:

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


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


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

    if(pid == 0)
        return 3;
    else
    {
        printf("Child ID: %d \n", pid);
        pid = fork();
        if(pid == 0)
            exit(7);
        else
        {
            printf("Child ID: %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;
}

image.png

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

(2)waitpid() 函数

之前提到 wait() 函数会引起程序阻塞,而 waitpid() 函数可以防止程序阻塞

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

    • pid > 0:等待进程 ID 为 pid 的特定子进程。
    • pid == 0:等待与调用进程处于同一进程组中的任何子进程。
    • pid < -1:等待进程组 ID 为 abs(pid) 的任何子进程。
    • pid == -1:等待任何子进程(与 wait 行为相同)。
  • status:与 wait 相同,指向一个整数,用于存储子进程的终止状态。如果不关心子进程的退出状态,可以传入 NULL

  • options:提供一些额外的选项,通过它可以控制 waitpid 的行为。常用选项包括:

    • WNOHANG非阻塞模式。如果没有子进程退出,waitpid 立即返回 0。
    • WUNTRACED:等待任何已停止的子进程(但未被追踪),即使它没有终止。
    • WCONTINUED:等待那些在接收到 SIGCONT 信号后恢复执行的子进程。

示例:

#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
    {
        // 成功时返回终止的子进程ID(或0),失败时返回-1
        while(!waitpid(-1, &status, WNOHANG))
        {
            sleep(3);
            puts("sleep 3sec.");
        }

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

可以看到第21行共执行了5次,证明了 waitpid() 函数并未阻塞。

信号处理

通过前面的内容,我们已经知道了进程创建及销毁方法,但是还有一个问题没有解决:

子进程究竟何时终止?调用 waitpid 函数后要无休止地等待吗?

父进程往往与子进程一样繁忙,因此不能只调用 waitpid 函数以等待子进程终止。解决方法需要引入信号处理(Signal Handling) 机制。子进程终止的识别主体是操作系统,可以由操作系统告知父进程其子进程终止,此时父进程暂时放下工作,处理子进程终止相关事宜。

(一)信号与 signal 函数

(1)signal() 函数

#include <signal.h>
void (*signal(int signo, void(*func)(int)))(int);
  • int signo:表示信号编号,即需要捕捉的信号类型。

    • SIGALRM:已到通过调用 alarm 函数注册的时间。
    • SIGINT:输入 CTRL + C
    • SIGCHLD:子进程终止。
  • void (*func)(int) :这是一个函数指针,指向用户定义的信号处理函数。该处理函数接受一个 int 类型的参数(通常是信号编号)并返回 void

  • void (*)(int)signal 函数的返回值是一个指向函数的指针,该函数接受一个 int 类型的参数并返回 void。这个返回的函数指针通常是之前为该信号设置的旧的信号处理程序。

下面是 signal 函数的一种更简洁的定义方式

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

  • typedef void (*sighandler_t)(int);

    • 这行代码使用 typedef 定义了一个新的类型别名 sighandler_t
    • sighandler_t 实际上是一个函数指针类型,它指向一个接受 int 参数并返回 void 的函数。这种函数通常用作信号处理函数。
  • sighandler_t signal(int signum, sighandler_t handler);

    • 这是 signal 函数的原型。

    • signal 函数接受两个参数:

      • int signum:信号编号。
      • sighandler_t handler:信号处理函数的函数指针。
    • signal 函数的返回值类型是 sighandler_t,即一个函数指针,指向之前为该信号设置的处理函数。

alarm 函数
#include <unistd.h>
// 返回0或以秒为单位的距SIGALRM信号发生的所剩时间
unsigned int alarm(unsigned int seconds);
  • unsigned int seconds:指定在多少秒后发送 SIGALRM 信号。如果 seconds 为 0,任何先前设置的闹钟都会被取消(即不会发送 SIGALRM 信号)。
② signal 示例
#include <stdio.h>
#include <unistd.h>
#include <signal.h>


void timeout(int sig)
{
    if(sig == SIGALRM)  // SIGALRM:已到通过调用alarm函数注册的时间
        puts("Time out!");
    alarm(2);   // alarm函数在给定时间后产生signal
}

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

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

    for(int i = 0; i < 3; i++)
    {
        puts("wait...");
        // 发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程
        sleep(100);
    }
    return 0;
}

image.png

上述是没有任何输入时的运行结果,当在运行过程中输入 CTRL + C,可以看到 "CTRL+C pressed" 字符串。

上述程序中,有一点需要说明:

发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程”。

(2) sigaction() 函数

sigaction 函数是用于设置信号处理程序的系统调用。与 signal 函数相比,sigaction 提供了更强大的功能和更细粒度的控制,例如可以指定额外的信号处理行为,并且避免了一些历史遗留的陷阱,因此在现代 Unix/Linux 编程中,通常推荐使用 sigaction 代替 signal

#include <signal.h>
// 成功时返回0,失败时返回-1
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • int signum:信号编号,表示要处理的信号类型(例如 SIGINT, SIGTERM 等)。

  • const struct sigaction *act:一个指向 sigaction 结构体的指针,用于指定新的信号处理程序和相关行为。

  • struct sigaction *oldact:一个指向 sigaction 结构体的指针,用于保存先前的信号处理设置。如果不需要保存,可以传递 NULL 或 0。

struct sigaction 结构体

sigaction 函数依赖于 struct sigaction 结构体来描述信号处理的行为。该结构体定义如下:

struct sigaction {
    void (*sa_handler)(int);        // 信号处理函数指针
    sigset_t sa_mask;               // 在处理该信号时需要阻塞的其他信号
    int sa_flags;                   // 控制信号处理行为的标志
};
  • sa_handler:这是一个指向信号处理函数的指针,当指定信号到达时,调用该函数。这个成员类似于 signal 函数中的处理函数。

  • sa_mask:在信号处理程序执行期间需要阻塞的信号集。使用 sigemptysetsigfillsetsigaddset 等函数来设置这个信号集。

  • sa_flags:控制信号处理行为的标志位。例如:

    • SA_RESTART:使被信号中断的系统调用自动重新启动。
    • SA_NOCLDSTOP:如果 signumSIGCHLD,则当子进程停止时,不会向父进程发送该信号。
    • SA_SIGINFO:使用 sa_sigaction 代替 sa_handler 处理信号。
① sigaction 示例
#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[])
{
    struct sigaction act;
    act.sa_handler = timeout;
    sigemptyset(&act.sa_mask);  // 调用sigemptyset函数将sa_mask成员的所有位初始化为0
    act.sa_flags = 0;

    // int sigaction(int signo, struct sigaction* act, struct sigaction* oldact)
    sigaction(SIGALRM, &act, 0);

    alarm(2);

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

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

之前提到子进程终止时将产生 SIGCHLD 信号,可以利用这一点处理僵尸进程

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

// 信号处理函数,用于处理 SIGCHLD 信号
void read_childproc(int sig) {
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);
    if(WIFEXITED(status)) {  // 检查子进程是否正常退出
        printf("Remove proc id: %d \n", id);
        printf("Child send: %d \n", WEXITSTATUS(status));  // 获取子进程的退出状态
    }
}

int main(int argc, char* argv[]) {
    struct sigaction act;
    act.sa_handler = read_childproc;  // 设置信号处理函数
    sigemptyset(&act.sa_mask);        // 清空信号屏蔽集
    act.sa_flags = 0;
    sigaction(SIGCHLD, &act, 0);      // 为 SIGCHLD 信号设置处理程序

    pid_t pid = fork();  // 创建第一个子进程
    if(pid == 0) {       // 子进程代码
        puts("Hi! I am child process1");
        sleep(10);       // 子进程休眠10秒
        return 12;       // 子进程返回状态 12
    } else {             // 父进程代码
        printf("Child proc id: %d \n", pid);
        pid = fork();    // 创建第二个子进程
        if(pid == 0) {   // 第二个子进程代码
            puts("Hi, I'm child process2");
            sleep(10);   // 第二个子进程休眠10秒
            exit(24);    // 子进程退出,状态为 24
        } else {         // 父进程代码
            printf("Child proc id: %d \n", pid);
            for(int i = 0; i < 5; i++) {
                puts("wait...");
                sleep(5);  // 父进程每5秒输出一次 "wait..."
            }
        }
    }
    return 0;
}
  • 设置信号处理函数

    • 使用 sigaction 函数将 SIGCHLD 信号与自定义的信号处理函数 read_childproc 关联。当一个子进程终止时,操作系统会发送 SIGCHLD 信号给父进程,read_childproc 函数将被调用。
  • 创建子进程

    • 父进程通过 fork() 创建了两个子进程。每个子进程都休眠 10 秒,然后返回一个退出状态(第一个子进程返回 12,第二个子进程返回 24)。
  • 处理子进程终止

    • 当子进程终止时,SIGCHLD 信号会触发 read_childproc 函数,该函数使用 waitpid 非阻塞地(通过 WNOHANG 选项)获取已终止的子进程的状态。
    • 如果子进程正常退出 (WIFEXITED(status) 为真),则打印子进程的 PID 和它的退出状态。
  • 父进程的等待循环

    • 父进程在主循环中每 5 秒输出一次 "wait...",并且等待子进程终止。由于子进程只休眠 10 秒,所以在主循环运行期间,子进程会在 SIGCHLD 信号处理程序中被处理并清理。

image.png

基于多任务的并发服务器

(一)基于进程的并发服务器模型

image.png

每当有客户端请求服务(连接请求)时,回声服务器端都创建子进程以提供服务,为了完成服务,需要经过如下过程:

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

(二) 实现并发服务器

(1)服务器端(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(const char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

void read_childproc(int sig)
{
    pid_t pid;
    int status;
    pid = waitpid(-1, &status, WNOHANG);    // waitpid函数防止僵尸进程
    printf("removed proc id: %d \n", pid);
}

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        printf("Usage: %s <port>\n", argv[0]);
        exit(1);
    }

    struct sigaction act;
    act.sa_handler=  read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    int state = sigaction(SIGCHLD, &act, 0);
    int serv_sock = socket(PF_INET, SOCK_STREAM, 0);

    struct sockaddr_in serv_adr, clnt_adr;
    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)
    {
        socklen_t adr_sz = sizeof(clnt_adr);
        int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
        if(clnt_sock == -1)
            continue;
        else
            puts("new client connected...");

        pid_t pid = fork();
        if(pid == -1)
        {
            close(clnt_sock);
            continue;
        }
        if(pid == 0)
        {
            close(serv_sock);
            int str_len;
            char buf[BUF_SIZE];
            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;
}

  • 如果 fork() 返回 0,表示这是子进程,此时子进程关闭服务器套接字 serv_sock,并专注于处理客户端连接。它会不断读取客户端发送的数据,并将数据返回给客户端(即回显)。

  • 如果 fork() 返回的是正值,表示这是父进程,它关闭客户端套接字 clnt_sock,并继续等待新的连接请求。

  • 在子进程中

    • close(serv_sock):子进程关闭服务器套接字,因为子进程只负责处理当前客户端连接,不再需要接受新的连接请求。
    • close(clnt_sock):子进程在处理完客户端请求后关闭客户端套接字,完成数据传输并断开连接。
  • 在父进程中

    • close(clnt_sock):父进程关闭客户端套接字,因为父进程只负责接受新的连接请求,不需要继续处理当前连接。

(2)客户端(echo_client.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024

void error_handling(const char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

int main(int argc, char* argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;
    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);
    if(sock == -1)
        error_handling("socket() error");

    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");
    else
        puts("Connected.........");

    while(1)
    {
        fputs("Input message(Q to quit):", stdout);
        fgets(message, BUF_SIZE, stdin);

        if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
            break;

        write(sock, message, strlen(message));
        str_len = read(sock, message, BUF_SIZE - 1);
        message[str_len] = 0;
        printf("Message from server: %s", message);
    }
    close(sock);
    return 0;
}

(3)运行结果

image.png

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

上述服务器端的父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给了子进程。

“只是复制文件描述符吗?是否也复制了套接字呢?”

其实并未复制套接字,父进程和子进程共享底层套接字

image.png

上图1个套接字中存在2个文件描述符,只有2个文件描述符都终止(销毁)后,才能销毁套接字。所以调用 fork 函数后,要将无关的套接字文件描述符关掉:

image.png

分割 TCP 的 I/O 程序

(一)分割 I/O 程序的优点

在之前的实现的回声客户端中,数据回声方式如下:

“向服务器端传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。”

传输数据后需要等待服务器端返回的数据,因为程序代码中重复调用了 readwrite 函数。只能这么写的原因之一是,程序在1个进程中运行。

现在可以创建多个进程,因此可以分割数据收发过程,模型如下图所示:

image.png

综上所述,分割 I/O 程序的好处有:

① 简化程序实现,父进程和子进程分开实现特定功能。

② 可以提高频繁交换数据的程序性能:

image.png

(二)回声客户端的 I/O 程序分割

#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(const char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

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));
    }
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        printf("Usage: %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    int sock = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_adr;
    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_t pid = fork();
    char buf[BUF_SIZE];
    if(pid == 0)
        write_routine(sock, buf);
    else
        read_routine(sock, buf);
    close(sock);
    return 0;
}

第36行调用 shutdown 函数向服务器端传递 EOF。当然,执行第37行的 return 语句后,可以调用第66行的 close 函数传递 EOF。但现在已通过第60行的 fork 函数调用复制了文件描述符,此时无法通过1次 close 函数调用传递 EOF,所以需要通过 shutdown 函数调用另外传递。