5UNIX网络编程(第3版)

TCP客户/服务器程序示例

实例分析

回显服务器并发处理错误处理

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第5章 TCP客户/服务器程序示例

实例分析

一、导读

5.1 本章学习目标

第4章掌握了TCP数据传输的基本操作后,本章通过完整的客户/服务器示例,将前面学到的知识串联起来,构建真实可用的网络应用。本章的重点不仅是"怎么写",更是"怎么写好"——如何设计并发模型、如何处理错误、如何应对各种边界情况。

本章的核心学习目标包括:

  • 理解迭代服务器与并发服务器的区别:各自的优缺点和适用场景。
  • 掌握fork创建并发服务器的方法:父子进程如何共享文件描述符?如何正确关闭不需要的描述符?
  • 学会处理SIGCHLD信号:如何回收已终止的子进程,避免僵尸进程?
  • 理解并发服务器的性能特征:每连接一个进程的模型有什么局限性?
  • 掌握多线程服务器的实现:与多进程相比有什么优劣?
  • 学会编写健壮的网络代码:错误处理、资源清理、异常情况的应对。

5.2 为什么本章如此重要

从简单的echo程序到生产级的网络服务器,中间隔着巨大的工程鸿沟。本章正是跨越这道鸿沟的桥梁。Stevens通过逐步改进一个回显服务器——从迭代版本到并发版本,从多进程到多线程——展示了网络服务器设计的演进过程和每种方案的权衡。

理解这些内容,你就能回答以下关键问题:

  • 为什么Apache HTTP Server早期使用fork模型,后来改用线程模型?
  • 为什么Nginx使用事件驱动模型而不是每连接一个线程?
  • 当你的服务器需要同时处理10000个连接时,应该选择什么架构?

5.3 核心问题引导

在阅读本章时,请带着以下问题思考:

迭代服务器在什么情况下会"卡住"?一个慢客户如何影响其他客户?

fork() 后父子进程的文件描述符引用计数如何变化?为什么子进程必须关闭 listenfd?

为什么回收子进程时要用 while (waitpid(-1, NULL, WNOHANG) > 0) 而不是 wait(NULL)?

如果一个服务器同时有100个客户连接,使用fork模型需要多少系统资源?

多线程服务器中,多个线程共享哪些资源?需要注意什么同步问题?

什么是"C10K问题"?为什么传统的并发模型无法解决它?


二、核心概念详解

5.1 迭代服务器

5.1.1 基本模型

迭代服务器是最简单的服务器模型——一次只处理一个客户请求,处理完一个才处理下一个。

服务器启动 → accept() → 处理请求 → 关闭连接 → accept() → 处理请求 → ...
c
// 迭代echo服务器
int main(void) {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...

    for (;;) {
        int connfd = accept(listenfd, NULL, NULL);
        
        // 处理当前客户(阻塞在这里,直到客户关闭连接)
        char buf[1024];
        ssize_t n;
        while ((n = read(connfd, buf, sizeof(buf))) > 0) {
            write(connfd, buf, n);
        }
        
        close(connfd);
        // 回到accept,等待下一个客户
    }
}

5.1.2 适用场景

迭代服务器只适用于以下场景:

  • 每次请求的处理时间很短(毫秒级)
  • 不需要同时服务多个客户
  • 请求是独立的,没有长连接

典型应用:DNS服务器、DHCP服务器、简单的时间服务器。

5.1.3 局限性

时间线:
客户A: ──连接──[=====处理5秒=====]──关闭──
客户B:    ──连接──[等待5秒...][处理]──关闭──
客户C:      ──连接──[等待10秒...][处理]──关闭──

客户B和C必须等待客户A处理完毕才能被服务!

如果一个客户的处理时间很长(如大文件下载),或者客户连接后不发送数据(慢速客户),整个服务器就会被阻塞,其他客户无法得到服务。

5.2 并发服务器:fork模型

5.2.1 基本模型

并发服务器为每个客户创建一个子进程,子进程专门处理该客户的请求,父进程继续接受新的连接。

父进程: accept() → fork() → accept() → fork() → ...
              │              │
子进程1:      └── 处理客户1 ── close()
子进程2:                     └── 处理客户2 ── close()
c
// 并发echo服务器(fork模型)
#include <signal.h>
#include <sys/wait.h>

void sigchld_handler(int sig) {
    // 回收所有已终止的子进程
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
}

int main(void) {
    signal(SIGCHLD, sigchld_handler);

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...

    for (;;) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);
        int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
        
        if (connfd < 0) {
            if (errno == EINTR) continue;  // 被SIGCHLD中断
            perror("accept error");
            continue;
        }

        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            close(listenfd);   // 子进程不需要监听套接字
            str_echo(connfd);  // 处理客户请求
            close(connfd);
            _exit(0);
        }
        // 父进程
        close(connfd);  // 父进程不需要已连接套接字
    }
}

5.2.2 fork后的文件描述符

fork() 创建子进程时,子进程继承父进程的所有文件描述符。但父子进程共享的是同一个文件表项(内核中的 struct file),引用计数增加。

fork前:
  listenfd → [内核文件表项A] (ref=1)
  connfd   → [内核文件表项B] (ref=1)

fork后:
  父进程: listenfd → [内核文件表项A] (ref=2)
          connfd   → [内核文件表项B] (ref=2)
  子进程: listenfd → [内核文件表项A] ↗
          connfd   → [内核文件表项B] ↗

子进程close(connfd):
  connfd → [内核文件表项B] (ref=1)  ← 连接不关闭!
父进程close(connfd):
  connfd → [内核文件表项B] (ref=0)  ← 连接关闭,发送FIN

为什么子进程必须close(listenfd)?

  • 子进程不需要接受新连接,listenfd对它无用
  • 如果不关闭,listenfd的引用计数不会减到0,即使父进程退出,内核也不会释放监听套接字
  • 更严重的是,如果父进程崩溃重启,旧的子进程持有的listenfd会阻止新服务器bind同一端口

为什么父进程必须close(connfd)?

  • 父进程只负责接受新连接,不处理客户数据
  • 如果不关闭,connfd的引用计数不会减到0,客户关闭连接后连接不会真正关闭
  • 大量未关闭的connfd会耗尽文件描述符

5.2.3 SIGCHLD与僵尸进程

当子进程终止时,内核将其变为"僵尸进程"(zombie),保留其进程控制块(PCB)直到父进程回收。如果父进程不回收,僵尸进程会一直存在,浪费系统资源。

c
// 错误做法:使用wait()
void sigchld_handler(int sig) {
    wait(NULL);  // 只回收一个子进程
    // 如果同时有多个子进程终止,只会回收一个!
    // 因为信号不排队,多个SIGCHLD可能合并为一个
}

// 正确做法:使用waitpid循环
void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;  // 回收所有已终止的子进程
}

// waitpid参数说明:
// -1: 等待任何子进程
// NULL: 不关心退出状态
// WNOHANG: 非阻塞,没有已终止的子进程立即返回0

信号不排队的问题:

如果同时有3个子进程终止,内核只发送一个SIGCHLD信号(信号不排队)。如果信号处理函数只调用一次 wait(),只会回收一个子进程,另外两个变成僵尸进程。因此必须用 while 循环回收所有已终止的子进程。

5.2.4 accept被信号中断

当SIGCHLD信号到达时,如果父进程正在 accept() 中阻塞,accept 会被中断,返回-1,errno 设为 EINTR。

c
// 正确处理accept被信号中断
int connfd;
for (;;) {
    connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
    if (connfd < 0) {
        if (errno == EINTR) {
            continue;  // 被SIGCHLD中断,重新accept
        }
        perror("accept error");
        continue;
    }
    // 处理连接...
}

5.3 并发服务器:线程模型

5.3.1 基本模型

与fork模型类似,但使用线程而不是进程。每个客户连接由一个线程处理。

c
#include <pthread.h>

void *thread_handler(void *arg) {
    int connfd = *(int *)arg;
    free(arg);
    
    str_echo(connfd);
    close(connfd);
    return NULL;
}

int main(void) {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...

    for (;;) {
        int connfd = accept(listenfd, NULL, NULL);
        
        int *arg = malloc(sizeof(int));
        *arg = connfd;
        
        pthread_t tid;
        pthread_create(&tid, NULL, thread_handler, arg);
        pthread_detach(tid);  // 分离线程,自动回收资源
        
        close(connfd);  // 主线程不需要connfd
    }
}

5.3.2 线程 vs 进程

特性多进程(fork)多线程(pthread)
资源开销大(独立地址空间)小(共享地址空间)
创建速度慢快
内存占用高(每个进程独立)低(共享内存)
隔离性强(一个崩溃不影响其他)弱(一个崩溃全部崩溃)
同步需求低(独立地址空间)高(共享数据需加锁)
可移植性好(POSIX标准)好(POSIX标准)
适用场景需要强隔离高性能、大量连接

5.3.3 线程池模型

创建和销毁线程有开销。线程池预先创建一组线程,从任务队列中取出连接处理。

c
#include <pthread.h>

#define THREAD_POOL_SIZE 10

typedef struct {
    int connfd;
} client_task;

typedef struct {
    client_task tasks[100];
    int head, tail, count;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
} task_queue;

task_queue queue;

void *worker_thread(void *arg) {
    for (;;) {
        pthread_mutex_lock(&queue.mutex);
        while (queue.count == 0) {
            pthread_cond_wait(&queue.cond, &queue.mutex);
        }
        
        client_task task = queue.tasks[queue.head];
        queue.head = (queue.head + 1) % 100;
        queue.count--;
        
        pthread_mutex_unlock(&queue.mutex);
        
        // 处理客户
        str_echo(task.connfd);
        close(task.connfd);
    }
}

int main(void) {
    // 初始化队列
    queue.head = queue.tail = queue.count = 0;
    pthread_mutex_init(&queue.mutex, NULL);
    pthread_cond_init(&queue.cond, NULL);
    
    // 创建线程池
    pthread_t threads[THREAD_POOL_SIZE];
    for (int i = 0; i < THREAD_POOL_SIZE; i++) {
        pthread_create(&threads[i], NULL, worker_thread, NULL);
    }
    
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...
    
    for (;;) {
        int connfd = accept(listenfd, NULL, NULL);
        
        pthread_mutex_lock(&queue.mutex);
        queue.tasks[queue.tail].connfd = connfd;
        queue.tail = (queue.tail + 1) % 100;
        queue.count++;
        pthread_cond_signal(&queue.cond);
        pthread_mutex_unlock(&queue.mutex);
    }
}

5.4 并发模型比较

5.4.1 各种模型的性能特征

模型最大连接数内存开销CPU开销编程复杂度
迭代1最低最低最简单
每连接一进程~1000高高(上下文切换)中等
每连接一线程~5000中中中等(需同步)
线程池受线程数限制中中较高
I/O多路复用~100000低低高

5.4.2 C10K问题

C10K问题是指同时处理10000个并发连接的问题。传统的每连接一进程/线程模型面临以下瓶颈:

  • 内存:10000个线程,每个线程栈8MB → 80GB内存
  • CPU:10000个线程的上下文切换开销巨大
  • 文件描述符:需要调整系统限制

解决方案:I/O多路复用(select/poll/epoll),一个线程处理多个连接。这是第6章的主题。

5.5 错误处理与健壮性

5.5.1 常见的网络错误

c
// 错误处理模板
int ret = some_network_call(...);
if (ret < 0) {
    switch (errno) {
        case EINTR:
            // 被信号中断,通常可以重试
            break;
        case ECONNRESET:
            // 连接被对端重置
            // 通常意味着对端进程崩溃或主动发送RST
            break;
        case ECONNREFUSED:
            // 连接被拒绝
            // 通常意味着对端没有进程监听
            break;
        case ETIMEDOUT:
            // 连接超时
            // 通常意味着网络不可达或防火墙丢弃
            break;
        case EPIPE:
            // 管道破裂
            // 向已关闭的连接写入
            break;
        default:
            perror("network error");
            break;
    }
}

5.5.2 资源清理

c
// 确保资源被正确释放
void handle_client(int connfd) {
    char *buf = malloc(BUF_SIZE);
    if (!buf) {
        close(connfd);
        return;
    }
    
    // 使用goto统一清理(Linux内核常用风格)
    int ret = 0;
    
    ret = read_request(connfd, buf, BUF_SIZE);
    if (ret < 0) goto cleanup;
    
    ret = process_request(buf);
    if (ret < 0) goto cleanup;
    
    ret = send_response(connfd, buf, ret);
    if (ret < 0) goto cleanup;
    
cleanup:
    free(buf);
    close(connfd);
}

三、实战案例

5.1 完整的并发回显服务器

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>

#define MAXLINE 4096
#define LISTENQ 128
#define SERV_PORT 8080

void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
}

void str_echo(int connfd) {
    ssize_t n;
    char line[MAXLINE];

    while ((n = read(connfd, line, MAXLINE)) > 0) {
        if (write(connfd, line, n) != n) {
            perror("write error");
            return;
        }
    }

    if (n < 0) {
        perror("read error");
    }
}

int main(int argc, char **argv) {
    int listenfd, connfd;
    struct sockaddr_in servaddr, cliaddr;
    socklen_t clilen;
    int port = SERV_PORT;

    if (argc > 1) {
        port = atoi(argv[1]);
    }

    // 设置SIGCHLD处理器
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);

    // 忽略SIGPIPE
    signal(SIGPIPE, SIG_IGN);

    // 创建监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket error");
        exit(1);
    }

    // 设置SO_REUSEADDR
    int optval = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

    // 绑定
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(port);

    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind error");
        exit(1);
    }

    // 监听
    if (listen(listenfd, LISTENQ) < 0) {
        perror("listen error");
        exit(1);
    }

    printf("Concurrent echo server on port %d\n", port);

    // 主循环
    for (;;) {
        clilen = sizeof(cliaddr);
        connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);

        if (connfd < 0) {
            if (errno == EINTR) {
                continue;
            }
            perror("accept error");
            continue;
        }

        // 打印客户信息
        char cli_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &cliaddr.sin_addr, cli_ip, sizeof(cli_ip));
        printf("Connection from %s:%d\n", cli_ip, ntohs(cliaddr.sin_port));

        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            close(listenfd);
            str_echo(connfd);
            close(connfd);
            _exit(0);
        }

        // 父进程
        close(connfd);
    }

    return 0;
}

5.2 多线程版本

c
#include <pthread.h>

typedef struct {
    int connfd;
    struct sockaddr_in cliaddr;
} thread_args;

void *client_handler(void *arg) {
    thread_args *args = (thread_args *)arg;
    int connfd = args->connfd;

    char cli_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &args->cliaddr.sin_addr, cli_ip, sizeof(cli_ip));
    printf("[Thread %lu] Connection from %s:%d\n",
           (unsigned long)pthread_self(), cli_ip, ntohs(args->cliaddr.sin_port));

    free(args);

    str_echo(connfd);
    close(connfd);
    return NULL;
}

int main(int argc, char **argv) {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen (同上) ...

    for (;;) {
        thread_args *args = malloc(sizeof(thread_args));
        socklen_t clilen = sizeof(args->cliaddr);

        args->connfd = accept(listenfd, (struct sockaddr *)&args->cliaddr, &clilen);
        if (args->connfd < 0) {
            free(args);
            if (errno == EINTR) continue;
            perror("accept error");
            continue;
        }

        pthread_t tid;
        if (pthread_create(&tid, NULL, client_handler, args) != 0) {
            perror("pthread_create");
            close(args->connfd);
            free(args);
            continue;
        }
        pthread_detach(tid);
    }
}

四、本章小结

本章通过完整的示例展示了TCP客户/服务器的设计模式,核心要点回顾:

迭代服务器:一次处理一个客户,简单但效率低,只适合短连接、快请求。

fork并发服务器:每连接一个子进程。注意文件描述符的引用计数——子进程close(listenfd),父进程close(connfd)。使用 waitpid(-1, NULL, WNOHANG) 循环回收子进程,避免僵尸进程。

线程并发服务器:每连接一个线程,资源开销小于进程模型。但需要注意线程同步问题,一个线程崩溃会影响整个进程。

线程池:预先创建固定数量的线程,从任务队列中取任务。避免了频繁创建/销毁线程的开销。

C10K问题:传统模型无法高效处理大量并发连接。解决方案是I/O多路复用(下一章的主题)。

错误处理:网络编程必须检查每个系统调用的返回值,区分EINTR、ECONNRESET、EPIPE等不同错误,采取相应的处理策略。


五、思考与练习

思考题

如果fork后子进程没有close(listenfd),当父进程崩溃重启时,会发生什么?

为什么信号处理函数中不能使用printf?在sigchld_handler中应该避免哪些操作?

线程池的大小应该如何选择?太大和太小分别有什么问题?

如果一个客户连接后不发送任何数据,fork模型和线程模型分别会怎样?

为什么fork模型中子进程使用 _exit(0) 而不是 exit(0)?

编程练习

并发聊天服务器:实现一个支持多个客户同时连接的聊天服务器。每个客户发送的消息被广播给所有其他客户。使用fork模型。

带超时的服务器:修改echo服务器,如果客户在30秒内没有发送数据,服务器主动关闭连接。提示:使用 setsockopt 设置 SO_RCVTIMEO。

连接计数器:实现一个服务器,记录当前活跃连接数和总连接数。每当新客户连接或旧客户断开时,打印统计信息。

5/10TCP客
0%