欢迎光临
我们一直在努力

《UNIX 网络编程-卷1》阅读笔记06: TCP 客户 / 服务器程序示例

作者: andylin02
学习章节: 第五章 TCP客户/服务器程序示例
关键词: TCP回射程序, 并发服务器, SIGCHLD信号, 僵死进程, waitpid, EINTR, 慢系统调用, 服务器进程终止, 服务器主机崩溃, SIGPIPE, SO_KEEPALIVE, 数据格式, 可重入函数


一、章节概述

1.1 本章焦点

第五章是UNP全书中真正开始“打仗”的一章。前三章学了地址结构和字节序的基础,第四章学会了基本的函数调用流程,而第五章的目标是:编写一个完整的、能在真实网络环境中运行的、能够处理各种异常情况的TCP客户/服务器程序。

本章通过一个TCP回射(Echo)服务器作为贯穿全章的案例,逐层深入探讨以下核心问题:

  • 僵尸进程问题:并发服务器中fork出的子进程终止后如何被正确回收?
  • 信号处理:SIGCHLD信号的正确捕获方式,为什么wait不够而必须使用waitpid?
  • 系统调用中断:慢系统调用被信号中断后如何处理EINTR?
  • 服务器故障场景分析:服务器进程终止、主机崩溃、主机重启等情况下客户端的表现
  • SIGPIPE信号:向已关闭的套接字写入数据会发生什么?
  • 数据格式问题:网络传输中的二进制数据与文本数据的陷阱
  • 💡 本章核心价值:读完第五章,你应该能够写出一个工业级健壮性的TCP服务器——能够正确处理僵尸进程、处理系统调用中断、处理各种网络异常情况。

    二、TCP回射服务器/客户端架构

    2.1 程序功能说明

    回射服务器是一个经典的教学示例,其功能非常简单:客户端发送什么,服务器就原样返回什么。

    具体步骤:

  • 客户端从标准输入读入一行文本
  • 客户端将文本写入服务器的套接字
  • 服务器从套接字读入这行文本
  • 服务器将文本原样回射给客户端
  • 客户端从套接字读入回射文本,并显示在标准输出上
  • 尽管功能简单,但这个程序涵盖了实现任何网络应用所需的全部基本步骤:套接字创建、连接建立、数据收发、连接终止。

    2.2 程序架构图

    ┌─────────────────────────────────────────────────────────────────────────────┐
    │ TCP回射客户/服务器程序架构 │
    ├─────────────────────────────────────────────────────────────────────────────┤
    │ │
    │ 客户端 服务器 │
    │ │
    │ ┌─────────────────────┐ ┌─────────────────────────┐ │
    │ │ main │ │ main │ │
    │ │ ┌─────────────────┐│ │ ┌─────────────────┐ │ │
    │ │ │ Socket()创建套接字││ │ │ Socket()创建套接字│ │ │
    │ │ └────────┬────────┘│ │ └────────┬────────┘ │ │
    │ │ │ │ │ │ │ │
    │ │ ┌────────▼────────┐│ │ ┌────────▼────────┐ │ │
    │ │ │ Connect()连接服务器││ │ │ Bind()绑定地址端口│ │ │
    │ │ └────────┬────────┘│ │ └────────┬────────┘ │ │
    │ │ │ │ │ │ │ │
    │ │ │ │ │ ┌────────▼────────┐ │ │
    │ │ │ │ TCP三次握手 │ │ Listen()监听 │ │ │
    │ │ └─────────┼─────────────────────→│ └────────┬────────┘ │ │
    │ │ │ │ │ │ │
    │ │ ┌─────────────────┐│ │ ┌────────▼────────┐ │ │
    │ │ │ str_cli() ││ │ │ Accept()等待连接 │ │ │
    │ │ │ ┌─────────────┐││ │ └────────┬────────┘ │ │
    │ │ │ │ fgets()读取 │││ │ │ │ │
    │ │ │ │ 用户输入 │││ │ │ fork() │ │
    │ │ │ └──────┬──────┘││ │ ┌──────┴──────┐ │ │
    │ │ │ │ ││ │ │ │ │ │
    │ │ │ ┌──────▼──────┐││ │ ▼ ▼ │ │
    │ │ │ │ Writen()发送│││ 父进程 子进程 │ │
    │ │ │ └──────┬──────┘││ │ │ │ │ │
    │ │ │ │ ││ │ │ ┌──────────▼──┐ │ │
    │ │ │ ┌──────▼──────┐││ │ │ │ Close(listen)│ │ │
    │ │ │ │ Readline() │││ │ │ │ 关闭监听套接字│ │ │
    │ │ │ │ 读取回射 │││ │ │ └──────┬──────┘ │ │
    │ │ │ └──────┬──────┘││ │ │ │ │ │
    │ │ │ │ ││ │ │ ┌──────▼──────┐ │ │
    │ │ │ ┌──────▼──────┐││ │ │ │ str_echo() │ │ │
    │ │ │ │ fputs()显示 │││ │ │ │ 处理回射请求│ │ │
    │ │ │ └─────────────┘││ │ │ └──────┬──────┘ │ │
    │ │ └─────────────────┘│ │ │ │ │ │
    │ └─────────────────────┘ │ │ ┌──────▼──────┐ │ │
    │ │ │ │ │ Close(conn) │ │ │
    │ │ │ │ │ exit(0) │ │ │
    │ ┌─────────────────▼─────────────────────────┐│ │ └─────────────┘ │ │
    │ │ 数据流向 ││ │ │ │
    │ │ 标准输入 → Writen → 网络 → str_echo → 网络 ││ │ ┌─────────────┐ │ │
    │ │ ← Readline ← 网络 ← Writen ← 网络││ │ │ Close(conn) │ │ │
    │ └────────────────────────────────────────────┘│ │ │ 继续accept() │ │ │
    │ │ │ └─────────────┘ │ │
    └──────────────────────────────────────────────────┴─────────────────────────┘ ┘

    三、完整源代码分析

    3.1 TCP回射服务器——main函数(并发版本)

    #include "unp.h"

    int main(int argc, char **argv)
    {
    int listenfd, connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;

    // 1. 创建TCP套接字(IPv4,字节流)
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);

    // 2. 初始化服务器地址结构
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 通配地址,监听所有网络接口
    servaddr.sin_port = htons(SERV_PORT); // 在unp.h中定义为9877

    // 3. 绑定地址和端口
    Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

    // 4. 转换为监听套接字
    Listen(listenfd, LISTENQ);

    // 5. 主循环:接受连接并fork子进程处理
    for ( ; ; ) {
    clilen = sizeof(cliaddr);
    connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);

    if ((childpid = Fork()) == 0) {
    /* 子进程 */
    Close(listenfd); // 子进程不需要监听套接字
    str_echo(connfd); // 处理客户请求的回射函数
    Close(connfd);
    exit(0);
    }
    /* 父进程 */
    Close(connfd); // 父进程不需要已连接套接字
    }
    }

    关键设计要点:

    设计点说明
    端口选择(SERV_PORT=9877) 大于1023(非保留端口),大于5000(避免与临时端口范围冲突),小于49152(避免与临时端口范围重叠),且不与已注册端口冲突
    INADDR_ANY 告诉系统接受目的地址为任何本地接口的连接。如果服务器是多宿主机(有多个IP地址),INADDR_ANY允许监听所有接口
    fork后的套接字关闭 子进程关闭listenfd,父进程关闭connfd,这是引用计数机制的要求

    💡 为什么父子进程都要close不用的套接字?

    fork后,父子进程共享所有打开的文件描述符,listenfd和connfd的引用计数变为2。如果父进程不关闭connfd,子进程关闭后引用计数仍为1,TCP连接不会真正终止。如果子进程不关闭listenfd,父进程关闭后引用计数仍为1,端口无法释放。这是并发服务器中最容易遗漏的关键步骤。

    3.2 TCP回射服务器——str_echo回射处理函数

    #include "unp.h"

    void str_echo(int sockfd)
    {
    ssize_t n;
    char buf[MAXLINE];

    again:
    while ((n = read(sockfd, buf, MAXLINE)) > 0) {
    Writen(sockfd, buf, n); // 将接收到的数据原样回射
    }

    // 处理被信号中断的情况(EINTR)
    if (n < 0 && errno == EINTR) {
    goto again;
    } else if (n < 0) {
    err_sys("str_echo: read error");
    }
    }

    read的三种返回值:

    返回值含义程序处理
    > 0 成功读取到数据 将数据回射给客户端
    = 0 客户端关闭连接(收到FIN) 退出循环,函数返回
    < 0 发生错误 根据errno判断:EINTR则重试,其他错误则终止

    为什么需要EINTR处理?

    read、write、accept等系统调用属于“慢系统调用”,在阻塞状态下可能被捕获的信号中断。如果信号处理函数(如SIGCHLD处理函数)执行完后返回,这些系统调用会返回-1并设置errno=EINTR。健壮的程序必须处理这个错误,在EINTR时重试,而不是直接退出。

    3.3 TCP回射客户端——main函数

    #include "unp.h"

    int main(int argc, char **argv)
    {
    int sockfd;
    struct sockaddr_in servaddr;

    if (argc != 2)
    err_quit("usage: tcpcli <IPaddress>");

    // 1. 创建TCP套接字
    sockfd = Socket(AF_INET, SOCK_STREAM, 0);

    // 2. 初始化服务器地址结构
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    // 3. 连接服务器(触发TCP三次握手)
    Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

    // 4. 处理输入输出(循环读取标准输入,发送并接收回射数据)
    str_cli(stdin, sockfd);

    exit(0);
    }

    3.4 TCP回射客户端——str_cli函数

    #include "unp.h"

    void str_cli(FILE *fp, int sockfd)
    {
    char sendline[MAXLINE], recvline[MAXLINE];

    while (Fgets(sendline, MAXLINE, fp) != NULL) {
    // 1. 将输入行发送给服务器
    Writen(sockfd, sendline, strlen(sendline));

    // 2. 读取服务器回射的数据
    if (Readline(sockfd, recvline, MAXLINE) == 0)
    err_quit("str_cli: server terminated prematurely");

    // 3. 打印到标准输出
    Fputs(recvline, stdout);
    }
    }

    Fgets vs Readline 对比:

    函数输入源行为
    Fgets() 标准I/O文件指针(stdin) 从标准输入读取一行,阻塞等待用户输入
    Readline() 套接字描述符 从网络读取一行,阻塞等待服务器数据

    四、正常启动和终止流程

    4.1 程序启动时的进程状态

    正常启动后,三个进程的状态如下:

    ┌─────────────────────────────────────────────────────────────────┐
    │ 初始状态 │
    ├─────────────────────────────────────────────────────────────────┤
    │ │
    │ 客户端进程 服务器父进程 服务器子进程 │
    │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
    │ │ fgets()阻塞 │ │ accept()阻塞│ │ read()阻塞 │ │
    │ │ (等待输入) │ │ (等待连接) │ │ (等待数据) │ │
    │ └─────────────┘ └─────────────┘ └─────────────┘ │
    │ │
    │ 客户端TCP 服务器TCP(监听) 服务器TCP(已连接) │
    │ 连接已建立(ESTABLISHED) │
    │ │
    └─────────────────────────────────────────────────────────────────┘

    4.2 正常终止流程(客户端输入EOF)

    当用户在客户端键入Ctrl+D(EOF),完整的终止过程如下:

    客户端进程 服务器子进程 服务器父进程
    │ │ │
    │ 用户键入Ctrl+D │ │
    ▼ │ │
    ┌─────────┐ │ │
    │ Fgets() │ │ │
    │ 返回NULL│ │ │
    └────┬────┘ │ │
    │ │ │
    │ str_cli返回 │ │
    │ exit(0) │ │
    ▼ │ │
    ┌─────────┐ │ │
    │ 内核关闭│ │ │
    │ 套接字 │ │ │
    └────┬────┘ │ │
    │ │ │
    │ ───────── FIN ────────────→│ │
    │ ▼ │
    │ read()返回0 │
    │ (检测到EOF) │
    │ │ │
    │ str_echo返回 │
    │ main函数退出 │
    │ exit(0) │
    │ │ │
    │ │ ──────── SIGCHLD ────────→ │
    │ │ ▼
    │ │ 信号处理函数
    │ │ wait/waitpid
    │ │ │
    │ │ ───────── FIN ────────────→│
    │ │ │
    │ ←──────── ACK ─────────────│ │
    │ │ │
    │ 连接完全关闭 │ │
    ▼ ▼ ▼

    4.3 正常终止时序图(文本流程)

    步骤动作说明
    1 客户端键入Ctrl+D Fgets()返回NULL,str_cli函数返回
    2 客户端exit(0) 内核关闭所有打开的描述符(包括套接字)
    3 客户端TCP发送FIN 四次挥手的第一次挥手
    4 服务器子进程read()返回0 检测到EOF,表示客户端已关闭连接
    5 服务器子进程退出 str_echo返回,main退出,调用exit(0)
    6 服务器子进程发送FIN 四次挥手的第三次挥手
    7 服务器子进程向父进程发送SIGCHLD 通知父进程子进程已终止
    8 父进程信号处理函数回收子进程 wait或waitpid,避免僵尸进程
    9 客户端TCP发送ACK 四次挥手的第四次挥手
    10 连接完全关闭 双方都进入CLOSED状态

    ⚠️ 如果没有信号处理函数会发生什么?

    如果父进程没有捕获SIGCHLD信号,子进程终止后将成为僵尸进程(zombie),占用内核资源。僵尸进程的目的是维护子进程的信息(进程ID、终止状态等),以便父进程在以后某个时候获取。但如果父进程一直不调用wait/waitpid,僵尸进程会一直存在。

    五、防止僵尸进程——SIGCHLD信号处理

    5.1 问题:僵尸进程的成因

    什么是僵尸进程?

    设置僵尸(zombie)状态的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID、终止状态以及资源利用信息(CPU时间、内存使用量等)。

    如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们。

    为什么会产生僵尸进程?

    当子进程终止时,内核会向父进程发送SIGCHLD信号。如果父进程没有捕获这个信号并调用wait()或waitpid(),子进程就会保持在僵尸状态,直到父进程终止(此时init进程会接管并清理)。

    僵尸进程的危害:

    僵尸进程占用内核中的进程表项,最终可能导致耗尽进程资源。

    5.2 解决方案:信号处理函数

    为了处理僵尸进程,需要建立一个俘获SIGCHLD信号的信号处理函数,在函数体中调用wait或waitpid。

    基本的信号处理函数(使用wait):

    #include <signal.h>
    #include <sys/wait.h>

    void sig_chld(int signo)
    {
    pid_t pid;
    int stat;

    pid = wait(&stat);
    printf("child %d terminated\\n", pid);
    return;
    }

    // 在main函数中,listen()之后fork()之前调用
    Signal(SIGCHLD, sig_chld);

    为什么wait不够?

    假设有5个客户端几乎同时关闭连接,5个子进程几乎同时终止。由于UNIX信号不排队,多个相同信号合并为一个信号,sig_chld信号处理函数可能只被调用一次。如果sig_chld中只调用一次wait,那么只会回收一个子进程,剩下的4个将变成僵尸进程。

    正确的信号处理函数(使用waitpid + WNOHANG):

    #include <signal.h>
    #include <sys/wait.h>

    void sig_chld(int signo)
    {
    pid_t pid;
    int stat;

    // 循环处理所有已终止的子进程
    while ((pid = waitpid(1, &stat, WNOHANG)) > 0) {
    printf("child %d terminated\\n", pid);
    }
    return;
    }

    waitpid的参数说明:

    参数值含义
    pid -1 等待任意子进程
    statloc &stat 存储子进程终止状态的变量
    options WNOHANG 当有尚未终止的子进程时不阻塞,立即返回0

    waitpid通过设置第三个参数为WNOHANG来告知:当有尚未终止的子进程时不要阻塞。这样就可以通过循环处理所有终止的子进程,不会有僵尸进程残留。

    5.3 安装信号处理函数的时机

    必须在fork第一个子进程之前完成信号处理函数的安装,且只需做一次。

    // 在main函数中的正确位置
    Listen(listenfd, LISTENQ);
    Signal(SIGCHLD, sig_chld); // 必须在fork之前调用

    for ( ; ; ) {
    connfd = Accept(listenfd, ...);
    if ((childpid = Fork()) == 0) {
    // 子进程代码
    }
    // 父进程代码
    }

    六、慢系统调用与EINTR错误

    6.1 什么是慢系统调用?

    read、write、accept等系统调用属于“慢系统调用”。它们在阻塞状态下可能永远阻塞(例如等待网络数据或连接请求)。当这些系统调用被信号中断时,会返回-1并设置errno = EINTR。

    6.2 accept的EINTR处理

    当信号处理函数执行时,正在阻塞的accept会被中断,返回EINTR错误。服务器必须处理这个错误并重试:

    for ( ; ; ) {
    clilen = sizeof(cliaddr);
    if ((connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
    if (errno == EINTR) {
    continue; // 被信号中断,重试
    } else {
    err_sys("accept error");
    }
    }
    // 处理连接…
    }

    6.3 str_echo的EINTR处理

    同样,read调用也可能被信号中断:

    void str_echo(int sockfd)
    {
    ssize_t n;
    char buf[MAXLINE];

    again:
    while ((n = read(sockfd, buf, MAXLINE)) > 0)
    Writen(sockfd, buf, n);

    if (n < 0 && errno == EINTR)
    goto again; // 被信号中断,重试
    else if (n < 0)
    err_sys("str_echo: read error");
    }

    七、服务器故障场景深度分析

    7.1 场景1:服务器进程在客户之前终止

    场景描述:在同一个主机上启动服务器和客户端,找到服务器子进程的进程ID,用kill命令杀死它。

    完整的事件序列:

    步骤事件详细说明
    1 杀死服务器子进程 服务器子进程终止
    2 服务器TCP发送FIN 子进程终止时,内核自动关闭所有打开的描述符,向客户端发送FIN
    3 客户端TCP回复ACK 客户端内核收到FIN后回复ACK
    4 父进程收到SIGCHLD 服务器父进程收到信号,调用信号处理函数回收子进程
    5 客户端程序状态 客户端进程正阻塞在fgets()上等待用户输入,对FIN毫不知情
    6 用户键入新文本 用户输入文本并回车
    7 客户端调用writen()发送数据 客户端TCP将数据发送给服务器
    8 服务器TCP收到数据 由于之前打开套接字的进程已经终止,服务器TCP回复RST
    9 客户端调用readline() 此时之前接收的FIN已到达,readline()立即返回0
    10 客户端退出 检测到服务器提前终止,打印错误信息并退出

    💡 关键理解:

    FIN的接收只是告知客户端:服务器进程已关闭了连接的服务端,不再往其中发送任何数据。FIN并没有告知客户端服务器进程已经终止。因此客户端TCP允许继续发送数据。

    当服务器TCP收到来自客户端的数据时,由于先前打开那个套接字的进程已经终止,于是回复一个RST。

    7.2 场景2:客户进程忽略read错误继续写数据

    问题:如果客户不理会readline返回的错误,继续向服务器写入更多数据会发生什么?

    当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号的默认行为是终止进程,因此进程必须捕获它以免不情愿地被终止。

    // 捕获SIGPIPE信号,避免进程被终止
    void sig_pipe(int signo)
    {
    // 处理或记录错误
    return;
    }

    Signal(SIGPIPE, sig_pipe);

    7.3 场景3:服务器主机崩溃

    场景描述:在不同主机上运行客户和服务器。先启动服务器,再启动客户端,连接正常工作后,从网络上断开服务器主机,然后在客户端上键入另一行文本。

    事件序列:

    步骤事件说明
    1 服务器主机崩溃 已有的网络连接上不发出任何东西(无FIN)
    2 客户端键入文本 调用writen()写入内核,由客户端TCP作为数据分节发送
    3 客户端TCP持续重传 等待服务器回复ACK,进行指数退避重传
    4 重传超时(约9分钟) 如果服务器主机没有重新启动,客户端返回错误

    可能返回的错误类型:

    错误类型触发条件
    ETIMEDOUT TCP重传全部失败,无ICMP消息
    EHOSTUNREACH / ENETUNREACH 某个中间路由器判断服务器主机已不可达,返回“destination unreachable”ICMP消息

    💡 如何主动检测服务器崩溃?

    这种问题只有在客户端向服务器发送数据后才能检测到服务器已崩溃。如果想在客户端不主动发送数据的情况下检测服务器崩溃状态,需要使用SO_KEEPALIVE套接字选项。

    7.4 场景4:服务器主机崩溃后重启

    场景描述:服务器主机崩溃后重启,客户端继续尝试通信。

    事件序列:

    步骤事件说明
    1 服务器主机崩溃 连接信息丢失
    2 服务器主机重启 丢失了崩溃前的所有连接信息
    3 客户端发送数据 发送给服务器TCP
    4 服务器TCP响应 由于没有该连接的记录,服务器TCP回复RST
    5 客户端的readline() 返回ECONNRESET错误

    八、SIGPIPE信号深度解析

    8.1 什么时候会产生SIGPIPE?

    当一个进程向某个已收到RST的套接字执行写操作时,内核会向该进程发送SIGPIPE信号。

    典型场景:

    • 服务器进程已终止,客户端向该套接字写入数据
    • 服务器主机崩溃后重启,客户端向该套接字写入数据

    8.2 两种写入错误的区别

    写入场景结果说明
    向已收到FIN的套接字写入 写入成功 FIN只表示对方不再发送数据,但仍可接收数据
    向已收到RST的套接字写入 产生SIGPIPE RST表示连接已不可用,写入是非法的

    💡 注意:SIGPIPE信号的默认行为是终止进程。在网络程序中,通常需要捕获这个信号,或者设置套接字选项SO_NOSIGPIPE(某些系统)来避免进程意外终止。

    8.3 处理SIGPIPE的方法

    // 方法1:忽略信号
    Signal(SIGPIPE, SIG_IGN);

    // 方法2:捕获信号并处理
    void sig_pipe_handler(int signo)
    {
    // 记录日志,清理资源,优雅退出
    }

    Signal(SIGPIPE, sig_pipe_handler);

    九、数据格式问题

    9.1 二进制数据 vs 文本数据

    网络传输中的数据格式分为两类:

    类型特点优缺点
    文本数据 人类可读,用分隔符(如换行符)划分消息 易于调试,但效率较低
    二进制数据 机器可读,紧凑高效 效率高,但存在字节序和对齐问题

    9.2 二进制数据的陷阱

    字节序问题:发送端和接收端主机可能使用不同的字节序(大端 vs 小端)。

    // 错误做法:直接发送整数的原始内存表示
    int value = 12345;
    write(sockfd, &value, sizeof(value));

    // 正确做法:统一转换为网络字节序
    int value = htonl(12345);
    write(sockfd, &value, sizeof(value));

    结构体对齐问题:不同编译器、不同平台对结构体的填充(padding)方式可能不同,直接发送结构体可能导致解析错误。

    // 危险:结构体有对齐填充
    struct Message {
    int id; // 4字节
    char flag; // 1字节,后面可能有3字节填充
    int data; // 4字节
    };
    // 实际大小可能不是9字节,不同平台结果不同

    // 安全:显式序列化
    char buffer[9];
    memcpy(buffer, &id, 4);
    buffer[4] = flag;
    memcpy(buffer+5, &data, 4);

    9.3 消息边界处理

    TCP是字节流协议,没有消息边界。本章的Readline函数通过查找换行符来划分消息边界。这是实现基于文本协议的一种常见方式。

    十、POSIX信号处理与可重入函数

    10.1 POSIX信号语义

    POSIX标准定义了一套信号处理规则,包括信号处理函数、信号阻塞等。

    关键点:

    • 被捕获的信号在信号处理函数执行期间被阻塞
    • 不排队的信号:如果一个信号在信号处理函数执行期间再次产生,该信号会被标记为待处理,但只记录一次

    10.2 可重入函数

    什么是可重入函数?

    可重入函数是指可以被多个控制流(如信号处理函数和主程序)同时调用而不会产生不确定结果的函数。

    不可重入的函数包括:

    • 使用静态数据的函数(如inet_ntoa、asctime)
    • 标准I/O函数(如printf、fgets)
    • 操作全局变量的函数

    💡 注意事项:在信号处理函数中调用诸如printf这样的标准I/O函数是不合适的。本书在示例中调用printf只是作为查看子进程何时终止的诊断手段。在生产代码中,应避免在信号处理函数中使用不可重入函数。

    十一、本章练习题精解

    11.1 习题5.1

    问题:在str_echo函数中,如果在read和writen之间插入sleep(1)调用,会发生什么?

    答案:程序仍然可以正常工作。因为TCP是字节流协议,多个小数据段可能会在传输过程中被合并成一个大的数据段。在read和writen之间加入sleep可能导致服务器处理的吞吐量下降,但不影响功能的正确性。

    11.2 习题5.2

    问题:在str_cli函数中,如果使用write而不是writen,可能出现什么问题?

    答案:write不能保证一次写入所有数据。如果内核中的套接字发送缓冲区没有足够空间,write可能只写入部分数据。而writen通过循环调用确保写入全部数据。使用普通write可能导致数据发送不完整。

    11.3 习题5.3

    问题:在str_echo函数中,如果使用write而不是writen,可能出现什么问题?

    答案:write同样不能保证一次写入所有数据。回射数据可能不完整,导致客户端接收到的数据与发送的数据不一致。

    11.4 习题5.4

    问题:为什么在信号处理函数中使用waitpid而不是wait?

    答案:当多个子进程同时终止时,多个SIGCHLD信号会合并为一个信号。如果信号处理函数只调用一次wait,只能回收一个子进程,其他子进程会成为僵尸进程。使用waitpid(-1, &stat, WNOHANG)循环可以回收所有已终止的子进程。

    11.5 习题5.5

    问题:如果在信号处理函数中调用wait而不是waitpid,并且设置了SA_RESTART标志,accept的EINTR错误还会发生吗?

    答案:SA_RESTART标志可以使某些系统调用被信号中断后自动重启。然而accept是否被自动重启取决于具体实现。即使设置了SA_RESTART,编写健壮的代码仍然应该显式处理EINTR错误,以保证可移植性。

    十二、核心知识点总结

    12.1 健壮TCP服务器检查清单

    问题解决方案本章位置
    僵尸进程 捕获SIGCHLD信号,使用waitpid循环回收 第5.4节
    多个信号合并 waitpid + WNOHANG循环 第5.4节
    accept被中断 检查EINTR并重试 第5.5节
    read被中断 检查EINTR并重试 第5.5节
    服务器进程终止 处理readline返回0的情况 第5.10节
    向已关闭连接写入 捕获SIGPIPE信号 第5.11节
    服务器主机崩溃 SO_KEEPALIVE或应用层心跳 第5.12节
    数据格式问题 统一字节序,显式序列化 第5.17节

    12.2 服务器故障场景汇总表

    故障场景客户端表现检测方式解决方案
    服务器进程终止 FIN到达,后续写操作导致RST readline返回0 检测EOF并重连
    写入RST套接字 SIGPIPE信号,默认终止进程 信号处理函数 捕获SIGPIPE
    服务器主机崩溃 无通知,TCP重传超时 readline超时返回ETIMEDOUT 设置超时或SO_KEEPALIVE
    服务器主机重启 RST响应 readline返回ECONNRESET 检测错误并重连

    12.3 本章思维导图

    第五章 TCP客户/服务器程序示例
    ├── 回射程序架构
    │ ├── 服务器:监听套接字 → accept → fork → 子进程处理
    │ └── 客户端:连接 → 循环读取输入 → 发送 → 接收回射
    ├── 正常启动和终止
    │ ├── 启动:三个进程阻塞等待
    │ └── 终止:FIN交换 + SIGCHLD信号
    ├── 信号处理
    │ ├── SIGCHLD:防止僵尸进程
    │ │ ├── wait的问题(只能回收一个)
    │ │ └── waitpid + WNOHANG(循环回收所有)
    │ ├── EINTR处理
    │ │ ├── accept被中断 → continue
    │ │ └── read被中断 → goto again
    │ └── SIGPIPE:写入RST套接字时的信号
    ├── 服务器故障场景
    │ ├── 服务器进程终止 → FIN → read返回0 → SIGPIPE
    │ ├── 服务器主机崩溃 → 无通知 → TCP重传超时
    │ └── 服务器主机重启 → RST → ECONNRESET
    └── 数据格式
    ├── 文本数据 vs 二进制数据
    ├── 字节序问题(htonl/ntohl)
    └── 结构体对齐问题

    十三、下一章预告

    📌 下一篇:《UNIX网络编程》读书笔记(六):第六章 I/O复用——select和poll函数

    第六章将详细讲解:

  • 五种I/O模型详解:

    • 阻塞式I/O模型(Blocking I/O)
    • 非阻塞式I/O模型(Nonblocking I/O)
    • I/O复用模型(select/poll/epoll)
    • 信号驱动式I/O模型(Signal-Driven I/O)
    • 异步I/O模型(Asynchronous I/O)
  • select函数深度解析:

    • 参数详解:nfds、readfds、writefds、exceptfds、timeout
    • 描述符集的操作宏:FD_ZERO、FD_SET、FD_CLR、FD_ISSET
    • select的返回值及错误处理
  • select服务器的实现:

    • 如何同时处理监听套接字和已连接套接字
    • 使用select的TCP回射服务器完整代码
  • select vs poll对比:

    • poll的struct pollfd结构
    • poll的事件类型:POLLIN、POLLOUT、POLLERR等
    • select的FD_SETSIZE限制(1024)
  • shutdown函数:

    • 半关闭的概念
    • shutdown vs close的区别
  • pselect函数:POSIX提供的增强版select

  • 学习目标:学完第六章后,你将能够——

    • 理解五种I/O模型的本质区别
    • 使用select编写单进程并发处理多客户端的服务器
    • 解决客户端同时等待标准输入和套接字输入的问题(第五章遗留问题)
    • 理解poll函数的使用方法
    • 区分shutdown和close的不同用途

    敬请期待!

    参考资料

  • W. Richard Stevens, Bill Fenner, Andrew M. Rudoff. 《UNIX网络编程 卷1:套接字联网API(第3版)》. 北京:人民邮电出版社
  • UNIX网络编程(UNP) 第五章学习笔记,CSDN,https://blog.csdn.net/a348752377/article/details/103210338
  • 《UNIX网络编程卷1》读书笔记–第五章TCP客户/服务实例,CSDN,https://blog.csdn.net/mashuiping/article/details/65628979
  • UNIX网络编程卷一 学习笔记 第五章 TCP客户/服务器程序示例,博客园,https://www.cnblogs.com/gblog6/p/17518148.html
  • UNP – 第五章 TCP客户/服务器示例 – 学习笔记,CSDN,https://blog.csdn.net/sjwl2012/article/details/77747273
  • UNP编程:09—TCP之(SIGCHLD信号处理:僵死进程),51CTO,https://blog.51cto.com/u_15346415/5171289
  • 学习unix网络编程(一),CSDN,https://blog.csdn.net/qq_20853741/article/details/114808889
  • UNP学习总结(一),博客园,https://www.cnblogs.com/caiminfeng/p/6501759.html
  • UNP 学习笔记——TCP 客户 / 服务器程序示例,LearnKu,https://learnku.com/articles/13856/unp-learning-notes-examples-of-tcp-client-server-programs
  • 《UNIX网络编程卷1:套接字联网API》第5章 TCP客户服务器程序示例,技术栈,https://jishuzhan.net/article/1907598319239639041
  • Unix网络编程(3)——C/S模型几种情况,术之多,https://www.shuzhiduo.com/A/8Bz8VaMN5x/
  • UNP Chapter 5 – TCP客户-服务器程序例子,e-com-net,https://www.e-com-net.com/archives/68
  • 《Unix网络编程卷1:套接字联网API》读书笔记,CSDN,https://blog.csdn.net/anque1234/article/details/101423313

  • 本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
    👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

    赞(0)
    未经允许不得转载:171主机测评 » 《UNIX 网络编程-卷1》阅读笔记06: TCP 客户 / 服务器程序示例
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址