深入理解 TCP 并发服务器:从 IO 模型到多路复用实现

在网络编程中,TCP 服务器面临的核心挑战之一是如何高效处理多个客户端的并发连接。由于accept()recv()等核心 IO 接口默认是阻塞的,单线程服务器无法同时处理 "等待新连接" 和 "接收已有连接数据" 两个任务。本文将从 IO 模型底层原理出发,详解 TCP 并发服务器的实现方案,重点剖析多路复用模型的核心逻辑与实践。

一、TCP 并发服务器的核心问题

单线程 TCP 服务器的执行逻辑存在天然缺陷:

  • accept()会阻塞等待客户端三次握手,期间无法处理已连接客户端的消息
  • recv()会阻塞等待数据到达,期间无法响应新的客户端连接
  • 阻塞 IO 导致服务器只能串行处理一个客户端的请求,完全无法支撑并发

针对这个问题,主流有两类解决方案:

1. 线程 / 进程模型

  • 优点:实现简单,为每个 TCP 连接创建独立线程 / 进程,各自处理阻塞 IO
  • 缺点:资源消耗大,连接数增多时线程 / 进程切换开销剧增,性能瓶颈明显

2. 多路复用模型

通过一个核心接口监听多个文件描述符的 IO 事件,仅在事件发生时才进行处理,从根本上解决阻塞 IO 的并发问题,是高性能服务器的首选方案。

二、Linux 系统的 4 种 IO 模型

理解多路复用前,先掌握 Linux 的 4 种基础 IO 模型:

表格

IO 模型 核心特点 适用场景
阻塞 IO 数据未就绪时,进程阻塞等待,不占用 CPU 资源 简单场景、低并发连接
非阻塞 IO 轮询检查 IO 状态,CPU 空转率高 特殊低延迟场景
异步 IO 内核主动向应用层上报 IO 事件,完全异步 高性能异步框架
多路复用 IO 单接口监听多文件描述符,事件驱动 高并发 TCP 服务器

三、多路复用的三种实现:select/poll/epoll

Linux 提供了selectpollepoll三种多路复用接口,核心能力和性能差异显著。

1. select

c

运行

复制代码
#include <sys/select.h>
// 核心函数
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
// 辅助宏
void FD_CLR(int fd, fd_set *set);  // 从集合删除fd
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在集合中
void FD_SET(int fd, fd_set *set);  // 添加fd到集合
void FD_ZERO(fd_set *set);         // 清空集合

核心缺点

  • 文件描述符上限(默认 1024),无法支撑高并发
  • 每次调用需拷贝文件描述符集合到内核,开销大
  • 仅支持水平触发(LT),需反复处理未完成的事件
  • 需遍历所有文件描述符才能找到触发事件的 fd

2. poll

c

运行

复制代码
#include <poll.h>
struct pollfd {
    int fd;         // 监听的文件描述符
    short events;   // 期望监听的事件(POLLIN/POLLOUT等)
    short revents;  // 实际发生的事件
};
// 核心函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

核心改进

  • 用链表存储监听事件,无文件描述符数量上限
  • 其余缺点与 select 一致(内核态 / 用户态拷贝、水平触发、遍历查找)

3. epoll(高性能首选)

epoll 是 Linux 特有的多路复用接口,完美解决 select/poll 的痛点:

c

运行

复制代码
#include <sys/epoll.h>
struct epoll_event {
    uint32_t events; // 监听事件(EPOLLIN/EPOLLOUT/EPOLLET等)
    epoll_data_t data; // 关联数据(通常存fd)
};
// 创建内核事件表
int epoll_create(int size);
// 管理事件(添加/修改/删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待事件触发
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

核心优势

  • 无文件描述符数量上限(仅受系统资源限制)
  • 事件表存储在内核态,无需反复拷贝
  • 支持水平触发(LT,默认)和边沿触发(ET,高性能模式)
  • 直接返回触发事件的 fd,无需遍历所有监听对象

四、epoll 实现 TCP 并发服务器示例

以下是基于 epoll 的 TCP 并发服务器完整实现,采用边沿触发模式提升性能:

c

运行

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>

#define MAX_EVENTS 1024   // 最大事件数
#define PORT 8888         // 监听端口
#define BUF_SIZE 1024     // 缓冲区大小

// 设置文件描述符为非阻塞
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL");
        return -1;
    }
    if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
        perror("fcntl F_SETFL");
        return -1;
    }
    return 0;
}

// 添加fd到epoll监听集合
void add_epoll_fd(int epfd, int fd, int enable_et) {
    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN;  // 监听读事件
    if (enable_et) {
        ev.events |= EPOLLET; // 启用边沿触发
    }
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
        perror("epoll_ctl add");
        exit(EXIT_FAILURE);
    }
    set_nonblocking(fd); // ET模式必须配合非阻塞IO
}

int main() {
    int listen_fd, conn_fd, epfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct epoll_event events[MAX_EVENTS];
    char buf[BUF_SIZE];

    // 1. 创建监听socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 2. 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 3. 绑定地址和端口
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(EXIT_FAILURE);
    }

    // 4. 开始监听
    if (listen(listen_fd, 128) == -1) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    // 5. 创建epoll实例
    epfd = epoll_create(MAX_EVENTS);
    if (epfd == -1) {
        perror("epoll_create");
        exit(EXIT_FAILURE);
    }

    // 6. 添加监听fd到epoll
    add_epoll_fd(epfd, listen_fd, 0); // 监听fd用LT模式即可

    printf("TCP server running on port %d...\n", PORT);

    // 7. 事件循环
    while (1) {
        // 等待事件触发,-1表示永久阻塞
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

        // 8. 处理所有触发的事件
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            // 8.1 新客户端连接事件
            if (fd == listen_fd) {
                conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
                if (conn_fd == -1) {
                    perror("accept");
                    continue;
                }
                printf("New client connected: %s:%d\n", 
                       inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                // 添加新连接fd到epoll,启用ET模式
                add_epoll_fd(epfd, conn_fd, 1);
            }
            // 8.2 已有客户端数据可读
            else if (events[i].events & EPOLLIN) {
                memset(buf, 0, BUF_SIZE);
                ssize_t n = read(fd, buf, BUF_SIZE - 1);
                // 客户端关闭连接
                if (n == 0) {
                    printf("Client %d disconnected\n", fd);
                    close(fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    continue;
                }
                // 读数据出错
                if (n < 0) {
                    if (errno != EAGAIN && errno != EWOULDBLOCK) {
                        perror("read");
                        close(fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    }
                    continue;
                }
                // 成功读取数据
                printf("Recv from client %d: %s\n", fd, buf);
                // 回显数据给客户端
                write(fd, buf, n);
            }
        }
    }

    // 关闭资源(实际不会执行到)
    close(listen_fd);
    close(epfd);
    return 0;
}

编译运行

bash

运行

复制代码
# 编译
gcc tcp_epoll_server.c -o tcp_server
# 运行
./tcp_server
# 测试(新开终端)
telnet 127.0.0.1 8888
# 或使用nc工具
nc 127.0.0.1 8888

五、核心注意事项

  1. ET 模式必须配合非阻塞 IO :边沿触发仅在状态变化时通知一次,需循环读取直到EAGAIN,避免数据残留
  2. 监听 fd 建议用 LT 模式:新连接事件无需频繁处理,LT 模式更简单
  3. 端口复用SO_REUSEADDR确保服务器重启时可立即绑定端口
  4. 资源释放:客户端断开连接时,需关闭 fd 并从 epoll 中删除

总结

  1. TCP 并发服务器的核心是解决阻塞 IO 的并发问题,多路复用模型是高性能首选方案;
  2. epoll 相比 select/poll 无 fd 数量限制、无需反复拷贝数据、支持 ET 模式,是高并发场景的最优选择;
  3. 实现 epoll 服务器时,ET 模式需配合非阻塞 IO,且要正确处理客户端断开连接的资源释放逻辑。
相关推荐
LCG元2 小时前
以太网通信实战:STM32F407+LAN8720A+LwIP,TCP/IP协议栈应用
stm32·嵌入式硬件·tcp/ip
gaize12132 小时前
个人博客 / 官网云服务器|简单好用不贵
运维·服务器
野犬寒鸦2 小时前
SAP后端实习开发面试:操作系统与网络核心考点及Linux与Redis
java·服务器·网络·后端·面试
战神/calmness2 小时前
应急响应-勒索病毒 13
网络·web安全·php·勒索病毒
somi73 小时前
Linux-基于网络爬虫技术的天气数据查询
linux·运维·服务器
袁小皮皮不皮3 小时前
【HCIA】第一章网络基础
运维·服务器·网络·网络协议·智能路由器
ascarl20103 小时前
Kylin V10 服务器,解决Xorg 占用内存很高的问题
服务器·github·kylin
AI周红伟3 小时前
周红伟:OpenClaw+ 微信+ QQ+云上OpenClaw(Clawdbot)快速接入企业微信指南
运维·服务器·网络
ZStack开发者社区3 小时前
技术解析:ZStack 计算 + 存储双利旧,破解数据中心异构纳管与资产浪费痛点
服务器·云计算