TCP并发服务器:poll和epoll的多路复用

关于TCP并发服务器的构建,之前已经介绍了select,今天本文将深入探讨两种主流的I/O多路复用机制------pollepoll,并通过完整代码示例演示如何构建高并发TCP服务器。


1. I/O模型概述

Linux下主要有四种I/O模型:

  • 阻塞I/O :进程调用recvfrom等系统调用后,数据未准备好时进程阻塞,直至数据到达内核并复制到用户空间。

  • 非阻塞I/O :通过设置文件描述符为非阻塞,调用后立即返回,若数据未准备好则返回EAGAIN错误,需要轮询。

  • I/O多路复用 :使用select/poll/epoll同时监视多个描述符,当其中任何一个就绪时通知应用程序,再由应用程序调用相应的I/O操作。

  • 异步I/O:应用程序发起操作后立即返回,内核完成所有操作(数据拷贝到用户空间)后通知进程。

其中,I/O多路复用是构建高并发网络服务器的核心技术。


2. poll函数详解

2.1 函数原型与数据结构

c

复制代码
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:指向pollfd结构体数组的指针,每个结构体描述一个需要监视的文件描述符及其关注的事件。

  • nfdsfds数组的元素个数。

  • timeout:超时时间(毫秒)。-1表示永远阻塞;0表示立即返回;大于0表示等待指定的毫秒数。

struct pollfd定义如下:

c

复制代码
struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 关注的事件位掩码 */
    short revents;    /* 返回的就绪事件位掩码 */
};

常用事件:

  • POLLIN:有数据可读(包括普通数据和紧急数据)。

  • POLLOUT:可写数据。

  • POLLERR:发生错误(仅输出)。

  • POLLHUP:挂起(仅输出)。

2.2 poll的特点

  • 优点:没有最大文件描述符数量的限制(基于链表存储,受系统内存限制)。

  • 缺点

    • 每次调用poll都需要将fds数组从用户态拷贝到内核态,开销随监视描述符数量线性增长。

    • 内核检测就绪描述符后,返回时仍需遍历整个数组查找哪些revents非零,时间复杂度O(n)。

    • 仅支持水平触发(LT),即如果文件描述符就绪后不处理,下次poll会再次通知。

2.3 使用poll实现TCP并发服务器

下面是一个使用poll的TCP回显服务器示例。它监听一个端口,接受多个客户端连接,并将客户端发送的数据原样返回。

c

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

#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024

int main(int argc, char *argv[]) {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    int i, nready, ret;

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

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

    // 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 监听
    if (listen(listen_fd, 10) < 0) {
        perror("listen");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 初始化pollfd数组
    struct pollfd fds[MAX_CLIENTS];
    for (i = 0; i < MAX_CLIENTS; i++) {
        fds[i].fd = -1;
    }
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;
    int maxi = 0; // fds数组当前最大下标

    printf("Poll echo server started on port 8888\n");

    while (1) {
        nready = poll(fds, maxi + 1, -1); // 永远阻塞
        if (nready < 0) {
            perror("poll");
            break;
        }

        // 检查监听套接字是否有新连接
        if (fds[0].revents & POLLIN) {
            client_len = sizeof(client_addr);
            conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
            if (conn_fd < 0) {
                perror("accept");
                continue;
            }
            printf("New client connected: %s:%d\n",
                   inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

            // 将新连接添加到fds数组
            for (i = 1; i < MAX_CLIENTS; i++) {
                if (fds[i].fd == -1) {
                    fds[i].fd = conn_fd;
                    fds[i].events = POLLIN;
                    if (i > maxi) maxi = i;
                    break;
                }
            }
            if (i == MAX_CLIENTS) {
                printf("Too many clients, closing new connection\n");
                close(conn_fd);
            }

            if (--nready <= 0) continue; // 如果没有其他就绪事件,继续下一次循环
        }

        // 检查现有客户端是否有数据可读
        for (i = 1; i <= maxi; i++) {
            int fd = fds[i].fd;
            if (fd == -1) continue;

            if (fds[i].revents & POLLIN) {
                memset(buffer, 0, BUFFER_SIZE);
                ret = read(fd, buffer, BUFFER_SIZE - 1);
                if (ret < 0) {
                    perror("read");
                    close(fd);
                    fds[i].fd = -1;
                } else if (ret == 0) { // 客户端关闭
                    printf("Client %d closed\n", fd);
                    close(fd);
                    fds[i].fd = -1;
                } else {
                    printf("Received %d bytes from %d: %s", ret, fd, buffer);
                    // 回显数据
                    write(fd, buffer, ret);
                }

                if (--nready <= 0) break;
            }
        }
    }

    close(listen_fd);
    return 0;
}

代码说明

  • 使用一个pollfd数组管理所有套接字(监听套接字+已连接套接字)。

  • 监听套接字始终关注POLLIN事件。

  • poll返回后,首先检查监听套接字,若有新连接则accept并将新套接字加入数组。

  • 然后遍历数组,检查每个客户端套接字是否就绪可读,读取数据并回显。

  • 注意处理客户端关闭(read返回0)或出错的情况,及时从数组中移除。


3. epoll函数详解

epoll是Linux特有的I/O多路复用机制,在2.6内核中引入,专门为处理大量文件描述符而设计,具有更高的性能和灵活性。

3.1 核心函数

c

复制代码
#include <sys/epoll.h>

// 创建一个epoll实例,返回一个文件描述符(epoll句柄)
int epoll_create(int size);   // size参数在Linux 2.6.8后忽略,但需大于0

// 控制epoll实例中的事件:添加、修改、删除
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);

struct epoll_event定义:

c

复制代码
typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;    /* 关注的事件掩码 */
    epoll_data_t data;      /* 用户数据 */
};

常用事件:

  • EPOLLIN:可读。

  • EPOLLOUT:可写。

  • EPOLLET:设置为边沿触发模式(默认是水平触发)。

  • EPOLLRDHUP:对方关闭连接(需2.6.17以上内核)。

  • EPOLLERR:错误。

  • EPOLLHUP:挂起。

3.2 epoll的特点

  • 无上限:监视的描述符数量理论上只受系统内存限制。

  • 高效

    • 通过epoll_ctl注册事件,内核维护一棵红黑树,每次只需拷贝事件到内核一次,后续epoll_wait无需重复拷贝所有描述符。

    • 内核通过回调机制将就绪描述符添加到就绪链表,epoll_wait直接返回就绪事件,无需遍历所有描述符,时间复杂度O(1)。

  • 支持两种触发模式

    • 水平触发(LT) :默认模式,只要文件描述符还有数据可读,每次epoll_wait都会返回该事件。与poll行为一致,编程简单。

    • 边沿触发(ET) :仅当描述符状态发生变化时(如从未就绪变为就绪)才通知一次。若未将数据全部读写完,下次不会再通知,因此通常需要配合非阻塞I/O循环读写直到EAGAIN。ET模式效率更高,但编程复杂。

3.3 使用epoll实现TCP并发服务器(LT模式)

下面是一个基于epoll(LT模式)的TCP回显服务器,逻辑与poll版本类似,但更简洁。

c

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

#define MAX_EVENTS 1024
#define BUFFER_SIZE 1024

int main(int argc, char *argv[]) {
    int listen_fd, conn_fd, epfd, nfds, i;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    struct epoll_event ev, events[MAX_EVENTS];

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

    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    if (listen(listen_fd, 10) < 0) {
        perror("listen");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 创建epoll实例
    epfd = epoll_create(1);
    if (epfd < 0) {
        perror("epoll_create");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 将监听套接字加入epoll事件表
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
        perror("epoll_ctl add listen_fd");
        close(listen_fd);
        close(epfd);
        exit(EXIT_FAILURE);
    }

    printf("Epoll (LT) echo server started on port 8888\n");

    while (1) {
        nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait");
            break;
        }

        for (i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 新连接
                client_len = sizeof(client_addr);
                conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
                if (conn_fd < 0) {
                    perror("accept");
                    continue;
                }
                printf("New client connected: %s:%d\n",
                       inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

                // 将新连接套接字加入epoll,使用LT模式(默认)
                ev.events = EPOLLIN;
                ev.data.fd = conn_fd;
                if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) < 0) {
                    perror("epoll_ctl add conn_fd");
                    close(conn_fd);
                }
            } else {
                // 客户端套接字有数据可读
                int fd = events[i].data.fd;
                memset(buffer, 0, BUFFER_SIZE);
                int ret = read(fd, buffer, BUFFER_SIZE - 1);
                if (ret < 0) {
                    perror("read");
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else if (ret == 0) {
                    printf("Client %d closed\n", fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    printf("Received %d bytes from %d: %s", ret, fd, buffer);
                    write(fd, buffer, ret);
                }
            }
        }
    }

    close(listen_fd);
    close(epfd);
    return 0;
}

3.4 边沿触发(ET)模式的改进

ET模式能减少事件被重复触发的次数,提高并发性能。使用ET模式需要注意:

  • 文件描述符必须设置为非阻塞,以避免读写操作阻塞在最后一次操作。

  • epoll_wait返回EPOLLIN事件后,应循环调用read直到返回EAGAIN(表示暂时无数据),确保本次数据全部读完。

  • 同样,写操作可能需要循环直到EAGAIN

下面给出一个ET模式下的回显服务器片段(仅展示修改部分):

c

复制代码
// 将文件描述符设为非阻塞
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 在accept后设置新连接为非阻塞
conn_fd = accept(...);
set_nonblocking(conn_fd);
ev.events = EPOLLIN | EPOLLET;  // 加入ET标志
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);

// 处理客户端数据时,循环读取直到EAGAIN
if (events[i].events & EPOLLIN) {
    while (1) {
        memset(buffer, 0, BUFFER_SIZE);
        ret = read(fd, buffer, BUFFER_SIZE - 1);
        if (ret < 0) {
            if (errno == EAGAIN) {
                // 数据已经读完
                break;
            } else {
                perror("read");
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                close(fd);
                break;
            }
        } else if (ret == 0) {
            printf("Client %d closed\n", fd);
            epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
            close(fd);
            break;
        } else {
            printf("Received %d bytes from %d: %s", ret, fd, buffer);
            // 注意:此处write也可能阻塞,但在ET模式下,应该也循环写入,或者关注EPOLLOUT事件。
            write(fd, buffer, ret); // 简化处理,实际应用应使用非阻塞写入
        }
    }
}

4. pollepoll对比

特性 poll epoll
最大连接数 受系统内存限制(链表存储) 受系统内存限制(红黑树+链表)
I/O效率 每次调用需将所有描述符从用户态拷贝到内核态,返回后遍历所有描述符查找就绪项,时间复杂度O(n) 通过epoll_ctl注册,每次epoll_wait只返回就绪描述符,无需遍历全部,时间复杂度O(1)
数据拷贝 每次调用poll都会拷贝所有pollfd结构 调用epoll_ctl时拷贝注册的事件,epoll_wait仅拷贝就绪事件,效率高
触发模式 仅支持水平触发(LT) 支持水平触发(LT)和边沿触发(ET)
编程复杂度 较低,需维护一个数组 稍高,尤其是ET模式需处理非阻塞和循环读写
平台支持 POSIX标准,广泛支持 Linux特有

5. 总结与选型建议

  • 连接数较少(几百以内) 且代码需要跨平台时,使用poll足够简单,移植性好。

  • 连接数较大(成千上万) 且运行在Linux平台时,epoll是当之无愧的首选,它能够提供线性扩展的性能。

  • 对于追求极致性能 的场景,建议采用epoll的ET模式,但务必正确处理好非阻塞I/O和资源回收,避免因编程疏忽导致数据丢失或死循环。

I/O多路复用是Linux高并发编程的基石,理解pollepoll的原理与使用,能够帮助开发者设计出高效的网络服务。希望本文的示例代码能够为你动手实践提供帮助。如果你有任何问题或想法,欢迎在评论区交流讨论

相关推荐
浅碎时光8071 小时前
Qt (按钮/显示/输入/容器类控件 布局管理器)
开发语言·qt
bubiyoushang8882 小时前
OFDM系统信道估计MATLAB实现(LS、MMSE、DCT、LRMMSE方法)
开发语言·网络·matlab
tryqaaa_2 小时前
文件上传漏洞2总结篇(含思维导图,齐全)
web安全·php·web
Felven2 小时前
C. Dora and Search
c语言·开发语言
John Song4 小时前
Python创建虚拟环境的方式对比与区别?
开发语言·python
搞程序的心海4 小时前
Python面试题(一):5个最常见的Python基础问题
开发语言·python
tryqaaa_10 小时前
md5和sha1常见绕过【详细附新生赛题目】
web安全·php·web
MediaTea10 小时前
Python:collections.Counter 常用函数及应用
开发语言·python