SELECT 和 EPOLL

EPOLL 实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>

#define PORT 8080
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024

// 设置非阻塞
int set_nonblocking(int fd) {
    // F_GETFL 是 fcntl 函数的一个命令常量,用于获取文件描述符的状态标志(status flags)。
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    //先用 F_GETFL 获取原始标志。
    // 再通过 F_SETFL 设置新的标志:将 O_NONBLOCK 加入其中,使得该文件描述符的操作不会阻塞等待。
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int server_fd, client_fd, epoll_fd;
    struct sockaddr_in addr;
    struct epoll_event ev, events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];
    // AF_INET  表示使用 IPv4 地址协议族。
    // SOCK_STREAM 表示这是一个面向连接、可靠的流式套接字(即 TCP)
    //  0  协议类型,设为 0 表示使用默认协议(对应 TCP)。
    // 创建 socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 设置地址复用
    int opt = 1;
    //server_fd:socket 文件描述符。
    //SOL_SOCKET:表示设置的是 socket 层级的选项。
    //SO_REUSEADDR:具体的选项名称,表示允许重用本地地址
    //&opt:指向存放选项值的指针
    //sizeof(opt):选项值的大小。
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定地址和端口
    // 设置地址族为 AF_INET,表示使用 IPv4 地址。
    addr.sin_family = AF_INET;
    // 设置 IP 地址为 INADDR_ANY,表示监听所有网络接口(即允许来自任何 IP 地址的连接)
    addr.sin_addr.s_addr = INADDR_ANY;
    // 设置端口号为 PORT(本例中是 8080),htons 函数用于将主机字节序的端口号转换为网络字节序(大端格式)。
    addr.sin_port = htons(PORT);
    // 调用 bind() 函数将 socket 文件描述符 server_fd 绑定到本地地址结构 addr
    // server_fd:之前通过 socket() 创建的 socket 描述符。
    // (struct sockaddr*)&addr:指向本地地址结构的指针(强制类型转换为通用 socket 地址类型)。
    // sizeof(addr):地址结构体的大小
    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 将 server_fd 标识的 socket 设置为监听模式,允许接受客户端连接。
    // 第一个参数 server_fd 是创建的 socket 文件描述符。
    //第二个参数 SOMAXCONN 表示系统允许的最大连接队列长度(包括已完成连接队列和未完成连接握手队列的总和)
    if (listen(server_fd, SOMAXCONN) < 0) {
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 设置非阻塞
    set_nonblocking(server_fd);

    // 调用 epoll_create1 函数创建一个 epoll 文件描述符:
    //参数 0 表示使用默认标志创建。
    //返回值 epoll_fd 是新创建的 epoll 文件描述符。
    epoll_fd = epoll_create1(0);
    if (epoll_fd < 0) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }
    //调用 epoll_ctl 函数对 epoll 实例进行控制操作:
    //epoll_fd:之前通过 epoll_create1 创建的 epoll 文件描述符。
    //EPOLL_CTL_ADD:表示要添加一个新的监听事件。
    //server_fd:要监听的文件描述符(这里是服务器 socket)。
    //&ev:指向 epoll_event 结构体的指针,其中包含了要监听的事件类型和关联的数据(如 server_fd)。
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0) {
        perror("epoll_ctl: server_fd");
        exit(EXIT_FAILURE);
    }

    printf("Server listening on port %d...\n", PORT);

    // 事件循环
    while (1) {
        //调用 epoll_wait() 等待注册的 I/O 事件发生:
        //epoll_fd:之前创建的 epoll 文件描述符。
        //events:用于存储就绪事件的数组,类型为 struct epoll_event。
        //MAX_EVENTS:最多返回多少个就绪事件(这里是 10)。
        //-1:表示无限等待,直到有事件发生为止。
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            // 新连接
            if (events[i].data.fd == server_fd) {
                //调用 accept() 函数从已完成连接队列中取出一个客户端连接:
                //server_fd:服务器监听 socket 文件描述符。
                //第二个参数为 NULL,表示不关心客户端的地址信息。
                //第三  个参数也为NULL,表示不关心地址结构体的长度。
                client_fd = accept(server_fd, NULL, NULL);
                if (client_fd < 0) {
                    perror("accept");
                    continue;
                }
                set_nonblocking(client_fd);
                ev.events = EPOLLIN | EPOLLET; // 边缘触发
                ev.data.fd = client_fd;
                //调用 epoll_ctl() 函数,尝试将客户端 socket client_fd 添加进 epoll 实例:
                //epoll_fd:之前创建的 epoll 文件描述符。
                //EPOLL_CTL_ADD:表示要添加一个新的监听项。
                //client_fd:要监听的客户端 socket。
                //&ev:事件结构体,定义了监听的事件类型(如 EPOLLIN)和绑定的文件描述符。
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0) {
                    perror("epoll_ctl: client_fd");
                    close(client_fd);
                }

                printf("Accepted new client: %d\n", client_fd);
            }
            // 客户端发来数据
            else {
                int client = events[i].data.fd;
                //read() 是系统调用函数,用于从文件描述符(这里是 socket)中读取数据。
                //client:客户端 socket 文件描述符。
                //buffer:用于存储读取数据的缓冲区(字符数组)。
                //BUFFER_SIZE:最大读取字节数(本例中为 1024)。
                //返回值 n 表示实际读取的字节数:
                //如果 n > 0:表示成功读取了 n 字节的数据。
                //如果 n == 0:表示客户端关闭了连接(没有更多数据可读)。
                //如果 n < 0:表示读取出错(例如 EAGAIN 或 EWOULDBLOCK 在非阻塞模式下)。
                int n = read(client, buffer, BUFFER_SIZE);
                if (n <= 0) {
                    printf("Client %d disconnected.\n", client);
                    close(client);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client, NULL);
                } else {
                    buffer[n] = '\0';
                    printf("Received from client %d: %s", client, buffer);
                    write(client, buffer, n); // 回显
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}

SELECT 实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <arpa/inet.h>

#define PORT 8888
#define MAX_CLIENTS  FD_SETSIZE  // 通常是 1024
#define BUFFER_SIZE  1024

int main() {
    int listen_fd, conn_fd, sockfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len;
    char buffer[BUFFER_SIZE];
    int client[MAX_CLIENTS]; // 客户端 fd 列表
    fd_set all_fds, read_fds;
    int max_fd;
    int i, n;

    //调用 socket 函数创建一个新的 socket。
    //AF_INET 表示使用 IPv4 地址族。
    //SOCK_STREAM 表示使用 TCP 协议(面向连接的可靠传输)。
    //第三个参数为 0,表示使用默认协议(即 TCP)。
    //返回值 listen_fd 是 socket 文件描述符,如果创建失败则返回 -1。
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket error");
        exit(EXIT_FAILURE);
    }


    int opt = 1;
    //listen_fd 要操作的 socket 文件描述符(即你之前创建的监听 socket)。
    //SOL_SOCKET 表示我们要设置的是 socket 层级的选项。
    //SO_REUSEADDR 选项名,表示允许重复使用本地地址(端口和 IP 地址组合)。即使该地址当前处于 TIME_WAIT 状态,也可以被重新绑定。
    //&opt 指向一个整型变量的指针,值为 1(启用该选项),如果为 0 则表示禁用。
    //sizeof(opt) 表示传递的选项值的大小。
    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_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    //调用 bind() 函数将 socket 文件描述符 listen_fd 与地址结构 server_addr 进行绑定。
    //listen_fd:之前创建好的 socket 描述符。
    //(struct sockaddr *)&server_addr:将指向 server_addr 的指针强制转换为通用 socket 地址类型。
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind error");
        exit(EXIT_FAILURE);
    }

    //调用 listen() 函数将 listen_fd 所代表的 socket 设置为监听状态。
    //第二个参数 10 是连接队列的最大长度,即系统可以为该 socket 暂时排队的未处理连接请求数量(也称为“backlog”)。
    //如果有多个客户端同时发起连接而服务器还没来得及调用 accept() 处理,系统会将这些连接放入队列中等待处理。
    //队列满后的新连接请求可能会被拒绝。
    if (listen(listen_fd, 10) < 0) {
        perror("listen error");
        exit(EXIT_FAILURE);
    }

    // 5. 初始化客户端数组
    for (i = 0; i < MAX_CLIENTS; i++) client[i] = -1;
    //使用宏 FD_ZERO() 来清空一个 fd_set 类型的集合 all_fds。
    //all_fds 是 select() 多路复用机制中用于监听所有关注的文件描述符的集合。
    FD_ZERO(&all_fds);
    //使用 FD_SET() 将监听 socket 的文件描述符 listen_fd 添加进 all_fds 集合中。
    //这样在调用 select() 的时候,就能监听这个 socket 上是否有新的连接请求到来。
    FD_SET(listen_fd, &all_fds);
    //max_fd 是传给 select() 函数的第一个参数,表示要监听的最大文件描述符值 +1。
    //初始时只有 listen_fd,所以将 max_fd 设为它
    max_fd = listen_fd;
    printf("Server listening on port %d...\n", PORT);
    while (1) {
        read_fds = all_fds;
        //select(max_fd + 1, &read_fds, NULL, NULL, NULL)
        //max_fd + 1:要检查的最大文件描述符值 +1(select() 的历史遗留要求)
        //&read_fds:监听可读事件的 fd 集合(这里关注是否有数据可读)
        //三个 NULL:分别表示不监听可写事件、异常事件和超时时间(无限等待)
        //作用:阻塞直到以下任一情况发生:
        //监听 socket (listen_fd) 有新连接(可读)
        //已有客户端 socket 发送了数据(可读)
        int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (ready < 0) {
            perror("select error");
            continue;
        }
        //FD_ISSET(fd, &set) 是标准库提供的宏,用于 直接检查 指定的文件描述符 fd 是否在集合 set 中
        if (FD_ISSET(listen_fd, &read_fds)) {
            addr_len = sizeof(client_addr);
            //从已完成连接队列中取出一个客户端连接,并返回一个新的 socket 文件描述符 (conn_fd)。
            //listen_fd:监听 socket 的描述符(门卫)
            //&client_addr:用于存储客户端地址信息的结构体(输出参数)
            //&addr_len:输入输出参数,传入地址结构体大小,返回实际地址长度
            conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len);
            if (conn_fd < 0) {
                perror("accept error");
                continue;
            }

            printf("New connection from %s:%d\n", inet_ntoa(client_addr.sin_addr),
                   ntohs(client_addr.sin_port));

            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client[i] < 0) {
                    client[i] = conn_fd;
                    break;
                }
            }

            if (i == MAX_CLIENTS) {
                fprintf(stderr, "Too many clients\n");
                close(conn_fd);
                continue;
            }
            //FD_SET(conn_fd, &all_fds)
            //作用:将新客户端 socket (conn_fd) 加入监控集合 all_fds
            //新连接的 socket 必须手动加入集合才会被监控
            FD_SET(conn_fd, &all_fds);
            if (conn_fd > max_fd) max_fd = conn_fd;

            if (--ready <= 0) continue; // 没有更多就绪的 fd
        }

        // 7. 检查客户端连接
        for (i = 0; i < MAX_CLIENTS; i++) {
            if ((sockfd = client[i]) < 0) continue;

            if (FD_ISSET(sockfd, &read_fds)) {
                memset(buffer, 0, sizeof(buffer));
                if ((n = read(sockfd, buffer, BUFFER_SIZE)) <= 0) {
                    // 客户端关闭连接
                    if (n == 0)
                        printf("Client fd %d disconnected\n", sockfd);
                    else
                        perror("read error");

                    close(sockfd);
                    FD_CLR(sockfd, &all_fds);
                    client[i] = -1;
                } else {
                    printf("Received from client fd %d: %s", sockfd, buffer);
                    write(sockfd, buffer, n);
                }

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

    return 0;
}

为什么需要这段代码?setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

当你运行一个 TCP 服务器并绑定到某个端口(如 8888)时,如果你关闭服务器再立刻重启,可能会遇到如下问题:
bind error: Address already in use
这是因为 TCP 协议为了确保最后一个数据包在网络中消失,会将连接保留在 TIME_WAIT 状态一段时间(通常是几分钟)。此时虽然服务已经关闭,但地址仍被视为“正在使用”。
通过设置 SO_REUSEADDR,你可以告诉操作系统:即使这个地址还处于 TIME_WAIT 状态,也允许新的 socket 绑定到这个地址。

✅ select vs epoll 全面对比总结

对比项 select epoll
出现时间 早期 POSIX 标准,几乎所有平台都支持 Linux 2.6 引入,仅限 Linux 平台使用
工作机制 每次调用都轮询全部 FD 基于事件驱动,FD 状态变化由内核回调通知
文件描述符限制 FD_SETSIZE 默认 1024(可修改但不便) 理论无上限,仅受系统资源限制
性能复杂度 O(n):每次都遍历全部 FD,连接数越多越慢 O(1):活跃事件直接返回,与监听 FD 数量无关
用户态与内核态交互 每次调用都要拷贝 FD 集合到内核 只在注册或修改 FD 时交互,节省大量拷贝开销
触发方式 仅支持水平触发(LT) 支持水平触发(LT)和边缘触发(ET)
事件返回方式 返回所有就绪 FD,需要应用自己遍历筛选 仅返回发生事件的 FD,避免无效遍历
使用接口 简单但不灵活:每次都设置读写集合 接口复杂(epoll_create/epoll_ctl/epoll_wait),但注册一次即可复用
线程安全 线程不安全,需要额外处理 epoll 本身线程安全,适合多线程
跨平台性 跨平台兼容性好 仅支持 Linux
适用场景 少量连接、调试、教学用途、兼容性要求高场合 高并发、高性能网络服务,如 nginx、redis、kafka 等

📌 补充说明

  • epoll的边缘触发(ET)模式更高效,但使用时必须配合非阻塞IO,否则容易漏掉事件。
  • 水平触发(LT)适合简单逻辑,不容易遗漏事件,但效率稍低。
  • epoll本质上是将事件注册到内核,省去了每次拷贝和轮询;而select是每次都要"重报家门"。
posted @ 2025-05-10 09:34  不报异常的空指针  阅读(16)  评论(0)    收藏  举报