IO模型

IO模型:解决多客户端通信的核心策略

问题背景

网络服务器(UDP/TCP)需处理多客户端通信,核心痛点源于套接字默认阻塞特性,具体差异如下:

  • UDP协议:无需连接,单个套接字即可与任意客户端通信,但默认阻塞——若客户端无消息,调用recvfrom()会无限阻塞,无法处理其他任务。
  • TCP协议:每成功建立一个客户端连接,服务端会生成一个新的“已连接套接字”;随着客户端增多,需高效管理大量并发套接字。

四种核心IO模型

多任务并发

  • 核心逻辑:利用“一个客户端对应一个线程”的策略,规避套接字阻塞问题——新客户端连接成功后,立即创建独立线程处理其通信。
多任务并发模型示意图
  • 关键代码
while(1)
{
    // 阻塞等待新客户端连接(监听套接字)
    int NewClient = accept(tcp_socket, (struct sockaddr *)&ClientAddr, &addrlen);
    if (NewClient < 0)
    {
        perror("accept error");
        return -1 ;
    }
    perror("accept ");

    // 存储已连接套接字
    ClientArr[Index++] = NewClient ;

    // 启动新线程为该客户端服务
    pthread_create( &tid , NULL , RecvMsgTask , (void *) NewClient );
    pthread_detach(tid); // 强制分离线程,避免僵尸线程问题
}
  • 特点
    • 优点:实现简单,客户端通信独立无干扰,调试便捷。
    • 缺点:高并发场景下线程资源消耗大(内存、CPU上下文切换),线程数量上限受限。

非阻塞轮询

  • 核心逻辑:将所有套接字设为“非阻塞模式”,通过循环持续检测每个套接字是否有数据到达,避免单个套接字阻塞导致整体卡死。
非阻塞轮询示意图
  • 关键操作:通过fcntl函数修改套接字状态为非阻塞:
// 获取套接字当前状态
long state = fcntl(sockfd, F_GETFL);
// 追加非阻塞标志
state |= O_NONBLOCK;
// 设置新状态
fcntl(sockfd, F_SETFL, state);
  • 特点
    • 优点:无需多线程,避免线程资源浪费。
    • 缺点:CPU需持续轮询所有套接字,即使无数据也占用高CPU,效率极低,仅适用于少量套接字场景。

异步信号

  • 核心逻辑:利用系统信号(SIGIO)触发IO事件——当套接字有数据时,内核自动发送SIGIO信号,服务端通过信号处理函数处理数据。
异步信号模型示意图
  • 关键特性
    • 触发信号:仅SIGIO(第29号信号),所有IO事件(连接、数据、断开)均触发同一信号。
    • 适用场景:仅支持UDP协议(TCP事件类型多,无法通过单一信号区分;UDP事件简单,仅需处理数据接收)。
  • 实现步骤
  1. 设置SIGIO信号的响应函数(信号处理函数);
  2. 指定SIGIO信号的属主进程(明确哪个进程接收信号):
    fcntl(sockfd, F_SETOWN, getpid()); // getpid()获取当前进程ID
    
  3. 开启套接字的异步信号模式:
    long state = fcntl(sockfd, F_GETFL);
    state |= O_ASYNC; // 追加异步标志
    fcntl(sockfd, F_SETFL, state);
    
  • 特点
    • 优点:无需轮询,CPU仅在有信号时工作,资源占用低。
    • 缺点:仅支持UDP,TCP场景无法区分事件类型,实用性有限。

多路复用

  • 核心逻辑:通过统一接口(如select/poll/epoll)同时监听多个阻塞套接字,仅当至少一个套接字就绪(有数据/可写/异常)时才返回,实现“单线程处理多套接字”。
多路复用对比示意图
  • 核心优势:兼顾“无多线程资源消耗”和“低CPU占用”,是高并发场景的基础模型。

多路复用深度解析

准备知识:套接字就绪状态

多路复用监听的套接字有三种就绪状态,需明确区分:

  • 读就绪:套接字缓冲区有数据可读取(如客户端发送数据、断开连接)。
  • 写就绪:套接字缓冲区未满,可写入数据(如发送缓存空闲)。
  • 异常就绪:套接字收到紧急数据(非错误,如TCP紧急指针数据)。

基础多路复用:select

API接口定义

#include <sys/select.h>

int select(int nfds, 
           fd_set *_Nullable restrict readfds, 
           fd_set *_Nullable restrict writefds,
           fd_set *_Nullable restrict exceptfds,
           struct timeval *_Nullable restrict timeout);

参数详解

参数名 含义
nfds 所有监听集合中“最大文件描述符编号 + 1”(内核通过此值遍历集合)
readfds 读就绪监听集合(需检测“有数据可读”的套接字),NULL表示不监听
writefds 写就绪监听集合(需检测“可写入数据”的套接字),NULL表示不监听
exceptfds 异常就绪监听集合(需检测“紧急数据”的套接字),NULL表示不监听
timeout 超时时间(struct timeval类型),NULL表示无限阻塞,0表示立即返回
  • 超时时间结构体定义:
    struct timeval {
        time_t      tv_sec;  // 秒
        suseconds_t tv_usec; // 微秒(1秒=1e6微秒)
    };
    

返回值说明

  • 成功:返回“就绪状态的套接字总数”(读+写+异常)。
  • 失败:返回-1(如信号中断),并设置errno
  • 超时:返回0(无任何套接字就绪)。

集合操作函数

fd_set是套接字集合类型,需通过专用函数操作(不可直接赋值):

函数原型 功能
void FD_ZERO(fd_set *set) 清空集合(初始化必须调用)
void FD_SET(int fd, fd_set *set) 将套接字fd添加到集合set
void FD_CLR(int fd, fd_set *set) 将套接字fd从集合set中移除
int FD_ISSET(int fd, fd_set *set) 判断fd是否在set中(就绪返回非0)

使用示例:监听标准输入(0号描述符)

#include <stdio.h>
#include <sys/select.h>
#include <unistd.h>

int main()
{
    fd_set read_set; // 读监听集合
    while (1)
    {
        FD_ZERO(&read_set);          // 1. 清空集合
        FD_SET(0, &read_set);        // 2. 添加标准输入(0号描述符)到集合
        printf("等待输入(5秒超时)...\n");

        // 3. 设置超时时间:5秒
        struct timeval timeout = {5, 0};
        // 4. 调用select监听
        int ret = select(0 + 1, &read_set, NULL, NULL, &timeout);

        if (ret > 0)
        {
            // 5. 判断标准输入是否就绪
            if (FD_ISSET(0, &read_set))
            {
                char buf[1024] = {0};
                read(0, buf, sizeof(buf)); // 读取输入数据
                printf("你输入了:%s", buf);
            }
        }
        else if (ret == 0)
        {
            printf("超时!5秒内无输入\n");
        }
        else
        {
            perror("select error");
            return -1;
        }
    }
    return 0;
}

进阶多路复用:epoll(Linux专属)

什么是epoll?

epoll是Linux内核提供的高性能多路复用机制,专为高并发场景设计,解决select/poll的效率瓶颈。

核心特点

  • 高效处理海量连接select/poll需遍历所有监听套接字,epoll通过“事件回调”直接返回就绪套接字,效率不随连接数增加而下降。
  • 事件驱动:仅当套接字状态变化(就绪)时,才通知应用程序,无无效轮询。
  • 两种触发模式
    • 水平触发(LT,Level Trigger):默认模式,只要套接字就绪(如缓冲区有数据),就持续通知,直到数据处理完。
    • 边缘触发(ET,Edge Trigger):仅在套接字状态从“未就绪”变为“就绪”时通知一次,需一次性处理完所有数据(避免遗漏)。
  • 无文件描述符上限select受限于FD_SETSIZE(默认1024),epoll仅受系统内存限制。

核心API

#include <sys/epoll.h>

// 1. 创建epoll实例(返回epoll文件描述符)
int epoll_create(int size); 
// size:早期版本用于指定最大监听数,现在已忽略(需传>=1的值)

// 2. 控制epoll事件(添加/修改/删除监听套接字)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 3. 等待epoll事件(阻塞直到有套接字就绪)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epoll_ctl参数说明

    参数名 含义
    epfd epoll_create返回的epoll实例描述符
    op 操作类型:
    EPOLL_CTL_ADD(添加监听)
    EPOLL_CTL_MOD(修改事件)
    EPOLL_CTL_DEL(删除监听)
    fd 需监听的套接字
    event 监听事件配置(如读就绪、边缘触发)
  • epoll_event结构体

    struct epoll_event {
        uint32_t     events;  // 监听的事件类型(如EPOLLIN、EPOLLET)
        epoll_data_t data;    // 关联数据(通常存储套接字fd)
    };
    
    // 关联数据的联合体(常用fd字段)
    typedef union epoll_data {
        void    *ptr;
        int      fd;          // 套接字描述符
        uint32_t u32;
        uint64_t u64;
    } epoll_data_t;
    
  • 常见事件类型

    事件宏 含义
    EPOLLIN 读就绪(有数据可读)
    EPOLLOUT 写就绪(可写入数据)
    EPOLLET 边缘触发模式(默认LT)
    EPOLLONESHOT 仅触发一次事件(需重新添加监听)

使用示例:TCP服务端(处理新连接+数据接收)

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

#define MAX_EVENTS 10  // 最大同时处理的就绪事件数
#define PORT 8888      // 监听端口

// 处理客户端数据
void handle_client_data(int client_fd) {
    char buf[1024] = {0};
    int n = read(client_fd, buf, sizeof(buf));
    if (n <= 0) {
        // 客户端断开或读错误,关闭套接字
        close(client_fd);
        printf("客户端断开连接(fd=%d)\n", client_fd);
        return;
    }
    printf("收到客户端(fd=%d)数据:%s", client_fd, buf);
    // 回声测试:将数据回发客户端
    write(client_fd, buf, n);
}

// 处理新客户端连接
void accept_connection(int listen_fd, int epoll_fd) {
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);
    // 接收新连接
    int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
    if (client_fd < 0) {
        perror("accept error");
        return;
    }
    printf("新客户端连接(fd=%d)\n", client_fd);

    // 配置epoll事件:监听读就绪,LT模式
    struct epoll_event ev;
    ev.events = EPOLLIN;  // 读就绪事件
    ev.data.fd = client_fd;
    // 将新客户端套接字添加到epoll监听
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
}

int main() {
    // 1. 创建TCP监听套接字
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket error");
        return -1;
    }

    // 2. 绑定端口和IP
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;         // IPv4
    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)) < 0) {
        perror("bind error");
        close(listen_fd);
        return -1;
    }

    // 3. 开始监听
    if (listen(listen_fd, 5) < 0) { // 监听队列大小5
        perror("listen error");
        close(listen_fd);
        return -1;
    }
    printf("TCP服务端启动,监听端口%d...\n", PORT);

    // 4. 创建epoll实例
    int epoll_fd = epoll_create(1); // size传1即可(忽略)
    if (epoll_fd < 0) {
        perror("epoll_create error");
        close(listen_fd);
        return -1;
    }

    // 5. 将监听套接字添加到epoll
    struct epoll_event ev;
    ev.events = EPOLLIN;          // 监听读就绪(新连接触发)
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    // 6. 循环等待epoll事件
    struct epoll_event events[MAX_EVENTS]; // 存储就绪事件
    while (1) {
        // 等待事件(超时-1表示无限阻塞)
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait error");
            break;
        }

        // 遍历所有就绪事件
        for (int i = 0; i < nfds; i++) {
            int curr_fd = events[i].data.fd;
            // 情况1:监听套接字就绪(新连接)
            if (curr_fd == listen_fd) {
                accept_connection(listen_fd, epoll_fd);
            }
            // 情况2:客户端套接字就绪(有数据)
            else {
                handle_client_data(curr_fd);
            }
        }
    }

    // 7. 释放资源
    close(epoll_fd);
    close(listen_fd);
    return 0;
}

epoll与select/poll对比

对比维度 select poll epoll(Linux)
最大连接数 有限(默认1024,受FD_SETSIZE限制) 无限制(基于链表) 无限制(仅受系统内存)
效率 低(遍历所有监听套接字) 中(遍历所有监听套接字) 高(事件回调,仅遍历就绪套接字)
触发模式 仅水平触发(LT) 仅水平触发(LT) 支持LT/ET(默认LT)
内存开销 固定(fd_set大小) 随连接数增加(链表) 随连接数增加(动态内存)
使用复杂度 简单 简单 中等(需理解事件模型)
适用场景 少量并发(<1000) 中量并发(1000-10000) 高并发(>10000,如Nginx/Redis)

拓展:IO模型的深度对比与实践选择

四种IO模型性能对比

模型 并发能力 CPU占用 实现复杂度 适用场景
多任务并发 低-中 简单 客户端数量少(<100),需独立通信
非阻塞轮询 极高 简单 调试场景,或仅1-2个套接字
异步信号 中等 UDP服务端(如物联网设备数据接收)
多路复用(epoll) 中等 高并发TCP/UDP服务(如服务器、网关)

select的核心局限性(为何被epoll取代)

  1. 连接数限制:默认1024,修改需重新编译内核,成本高。
  2. 效率瓶颈:每次调用需遍历所有监听套接字,即使仅1个就绪,也需遍历全部。
  3. 集合重置select返回后,就绪集合会被修改(仅保留就绪套接字),下次调用需重新添加所有监听套接字,操作繁琐。
  4. 内存拷贝:每次调用select,需将fd_set从用户态拷贝到内核态,高并发时拷贝开销大。

epoll LT与ET模式的实践差异

模式 触发逻辑 数据处理要求 优点 缺点
水平触发(LT) 只要套接字就绪,就持续通知 可分多次读取数据,无需一次读完 实现简单,不易遗漏数据 若未及时处理,会重复通知,占用CPU
边缘触发(ET) 仅在“未就绪→就绪”状态变化时通知一次 必须一次性读完所有数据(循环读) 仅通知一次,CPU占用低 实现复杂,漏读会导致数据丢失
  • ET模式关键注意点
    • 套接字必须设为非阻塞(避免一次性读不完时阻塞);
    • 读取数据需循环调用read,直到返回-1errno=EAGAIN(表示当前无数据)。

实际项目中IO模型的选择依据

  1. 并发量
    • 低并发(<100):多任务并发(实现快,调试简单)。
    • 中并发(100-1000):select/poll(无需深入理解epoll,满足需求)。
    • 高并发(>1000):epoll(Linux)/kqueue(BSD/Mac)(性能最优)。
  2. 协议类型
    • UDP:优先异步信号(简单高效),其次epoll。
    • TCP:优先epoll(需处理大量连接和事件),避免异步信号。
  3. 开发成本
    • 快速迭代项目:多任务并发/select(开发快)。
    • 长期维护项目:epoll(性能稳定,扩展性强)。

总结

IO模型的核心目标是高效处理多客户端并发通信,不同模型对应不同场景:

  • 简单场景(少客户端):多任务并发、非阻塞轮询(优先前者)。
  • UDP场景:异步信号(性价比最高)。
  • 高并发场景(大量TCP连接):epoll(Linux)是唯一选择,也是Nginx、Redis等中间件的底层IO模型。

示例

select 多路复用示例:TCP 服务端(处理多客户端)

select 是最基础的多路复用接口,需手动维护监听集合,适合中低并发场景(<1000 连接)。

功能说明

  • 监听套接字:接收新客户端连接
  • 已连接套接字:接收客户端数据并回声(将数据回发)
  • 处理客户端断开连接
  • 每次 select 前需重置监听集合(因 select 会修改集合)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>

#define PORT 65000         // 服务端端口
#define MAX_CLIENTS 100    // 最大支持客户端数(不超过 FD_SETSIZE 默认1024)
#define BUF_SIZE 1024      // 数据缓冲区大小

int main() {
    int listen_fd, client_fds[MAX_CLIENTS]; // 监听套接字 + 已连接客户端套接字数组
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len = sizeof(client_addr);
    fd_set read_fds;       // select 读监听集合
    int max_fd;            // 监听的最大文件描述符(select 第一个参数需 max_fd+1)
    int client_count = 0;  // 当前已连接客户端数

    // 1. 创建 TCP 监听套接字
    if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket create failed");
        exit(EXIT_FAILURE);
    }
    printf("监听套接字创建成功(fd=%d)\n", listen_fd);

    // 2. 初始化客户端套接字数组(-1 表示未使用)
    memset(client_fds, -1, sizeof(client_fds));

    // 3. 配置服务端地址(绑定端口和IP)
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;         // IPv4 协议
    server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡(0.0.0.0)
    server_addr.sin_port = htons(PORT);       // 端口转换为网络字节序(主机→网络)

    // 4. 绑定套接字与地址
    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind failed");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("服务端绑定端口 %d 成功\n", PORT);

    // 5. 开始监听(监听队列大小为 5,即最多缓存 5 个未处理连接)
    if (listen(listen_fd, 5) == -1) {
        perror("listen failed");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("服务端启动成功,等待客户端连接...\n");

    // 6. 循环处理 select 事件
    while (1) {
        // -------------------------- 关键:重置 select 监听集合 --------------------------
        FD_ZERO(&read_fds);                  // 清空集合
        FD_SET(listen_fd, &read_fds);        // 添加监听套接字(检测新连接)
        max_fd = listen_fd;                  // 初始化最大 fd 为监听套接字

        // 将所有已连接的客户端套接字添加到监听集合
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (client_fds[i] != -1) {       // 仅添加有效套接字
                FD_SET(client_fds[i], &read_fds);
                // 更新最大 fd(select 需传入 max_fd + 1)
                if (client_fds[i] > max_fd) {
                    max_fd = client_fds[i];
                }
            }
        }

        // -------------------------- 调用 select 监听读事件 --------------------------
        // 超时时间设为 NULL:无限阻塞,直到有事件触发
        int ret = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (ret == -1) {
            perror("select failed");
            continue; // 忽略错误,继续循环
        } else if (ret == 0) {
            printf("select timeout(本示例无超时)\n");
            continue;
        }

        // -------------------------- 处理就绪事件 --------------------------
        // 1. 处理新客户端连接(监听套接字就绪)
        if (FD_ISSET(listen_fd, &read_fds)) {
            // 接收新连接(获取客户端套接字)
            int new_client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
            if (new_client_fd == -1) {
                perror("accept failed");
                continue;
            }

            // 检查是否超过最大客户端数
            if (client_count >= MAX_CLIENTS) {
                const char* msg = "服务端客户端数已达上限,无法连接\n";
                send(new_client_fd, msg, strlen(msg), 0);
                close(new_client_fd);
                printf("客户端连接被拒绝:已达最大连接数 %d\n", MAX_CLIENTS);
                continue;
            }

            // 将新客户端套接字加入数组
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_fds[i] == -1) {
                    client_fds[i] = new_client_fd;
                    client_count++;
                    break;
                }
            }

            // 打印客户端信息(IP + 端口)
            char client_ip[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
            int client_port = ntohs(client_addr.sin_port);
            printf("新客户端连接:IP=%s, Port=%d, fd=%d(当前连接数:%d)\n",
                   client_ip, client_port, new_client_fd, client_count);
        }

        // 2. 处理客户端数据(已连接套接字就绪)
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int curr_client_fd = client_fds[i];
            if (curr_client_fd == -1) continue; // 跳过无效套接字

            // 检查当前客户端是否有数据可读
            if (FD_ISSET(curr_client_fd, &read_fds)) {
                char buf[BUF_SIZE] = {0};
                // 读取客户端数据(返回值:实际读取字节数,0=客户端断开,-1=错误)
                int recv_len = recv(curr_client_fd, buf, BUF_SIZE - 1, 0);
                if (recv_len <= 0) {
                    // 客户端断开或读错误,关闭套接字并清理
                    close(curr_client_fd);
                    client_fds[i] = -1;
                    client_count--;
                    printf("客户端断开连接:fd=%d(当前连接数:%d)\n", curr_client_fd, client_count);
                } else {
                    // 回声逻辑:将收到的数据回发给客户端
                    buf[recv_len] = '\0'; // 确保字符串结束
                    printf("收到客户端(fd=%d)数据:%s", curr_client_fd, buf);
                    send(curr_client_fd, buf, recv_len, 0); // 回发数据
                }
            }
        }
    }

    // 理论上不会执行到这里(循环无限),仅作资源清理
    close(listen_fd);
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_fds[i] != -1) close(client_fds[i]);
    }
    return 0;
}

epoll 多路复用示例:TCP 服务端(LT 模式,高并发)

epoll 是 Linux 专属高性能多路复用接口,支持海量连接,无需手动重置集合,适合高并发场景(如 Nginx/Redis 底层)。

功能说明

  • 基于 水平触发(LT) 模式(默认,易实现)
  • 自动管理监听事件(添加/删除客户端套接字)
  • 处理新连接、客户端数据收发、断开连接
  • 支持远超 1024 的并发连接(仅受系统内存限制)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <arpa/inet.h>

#define PORT 65000          // 服务端端口
#define MAX_EVENTS 100     // 单次 epoll_wait 最多处理的事件数
#define BUF_SIZE 1024      // 数据缓冲区大小

// 处理客户端数据(回声逻辑)
void handle_client_data(int client_fd) {
    char buf[BUF_SIZE] = {0};
    int recv_len = recv(client_fd, buf, BUF_SIZE - 1, 0);
    if (recv_len <= 0) {
        // 客户端断开或读错误,关闭套接字
        close(client_fd);
        printf("客户端断开连接:fd=%d\n", client_fd);
    } else {
        // 回声:回发数据
        buf[recv_len] = '\0';
        printf("收到客户端(fd=%d)数据:%s", client_fd, buf);
        send(client_fd, buf, recv_len, 0);
    }
}

// 处理新客户端连接(添加到 epoll 监听)
void accept_new_connection(int listen_fd, int epoll_fd) {
    struct sockaddr_in client_addr;
    socklen_t client_addr_len = sizeof(client_addr);

    // 接收新连接
    int new_client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
    if (new_client_fd == -1) {
        perror("accept failed");
        return;
    }

    // 打印客户端信息
    char client_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
    int client_port = ntohs(client_addr.sin_port);
    printf("新客户端连接:IP=%s, Port=%d, fd=%d\n", client_ip, client_port, new_client_fd);

    // -------------------------- 关键:将新客户端套接字添加到 epoll 监听 --------------------------
    struct epoll_event ev;
    ev.events = EPOLLIN;              // 监听读事件(数据接收/断开)
    ev.data.fd = new_client_fd;       // 关联客户端套接字(通过事件传递)
    // 调用 epoll_ctl 添加事件(EPOLL_CTL_ADD = 添加)
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_client_fd, &ev) == -1) {
        perror("epoll_ctl add client failed");
        close(new_client_fd);
        return;
    }
}

int main() {
    int listen_fd, epoll_fd;
    struct sockaddr_in server_addr;
    struct epoll_event ev, events[MAX_EVENTS]; // 单个事件配置 + 事件数组(存储就绪事件)

    // 1. 创建 TCP 监听套接字
    if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket create failed");
        exit(EXIT_FAILURE);
    }
    printf("监听套接字创建成功(fd=%d)\n", listen_fd);

    // 2. 配置服务端地址并绑定
    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 failed");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("服务端绑定端口 %d 成功\n", PORT);

    // 3. 开始监听
    if (listen(listen_fd, 5) == -1) {
        perror("listen failed");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("服务端启动成功,等待客户端连接...\n");

    // 4. 创建 epoll 实例(参数 size 已忽略,传 1 即可)
    if ((epoll_fd = epoll_create(1)) == -1) {
        perror("epoll_create failed");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("epoll 实例创建成功(epoll_fd=%d)\n", epoll_fd);

    // 5. 将监听套接字添加到 epoll 监听(检测新连接)
    ev.events = EPOLLIN;              // 监听读事件(新连接触发)
    ev.data.fd = listen_fd;           // 关联监听套接字
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
        perror("epoll_ctl add listen failed");
        close(epoll_fd);
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 6. 循环处理 epoll 就绪事件
    while (1) {
        // -------------------------- 调用 epoll_wait 等待事件 --------------------------
        // 参数:epoll实例、事件数组、最大事件数、超时时间(-1=无限阻塞)
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait failed");
            continue;
        } else if (nfds == 0) {
            printf("epoll_wait timeout(本示例无超时)\n");
            continue;
        }

        // -------------------------- 遍历所有就绪事件 --------------------------
        for (int i = 0; i < nfds; i++) {
            int curr_fd = events[i].data.fd; // 获取事件关联的套接字

            // 情况 1:监听套接字就绪 → 新客户端连接
            if (curr_fd == listen_fd) {
                accept_new_connection(listen_fd, epoll_fd);
            }
            // 情况 2:客户端套接字就绪 → 数据接收/断开
            else {
                // 处理数据后,无需手动重新添加事件(LT 模式会自动监听)
                handle_client_data(curr_fd);
                // 注意:客户端断开后,需从 epoll 中删除事件(否则会持续触发)
                struct epoll_event del_ev;
                del_ev.data.fd = curr_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_DEL, curr_fd, &del_ev);
            }
        }
    }

    // 资源清理(理论上不执行)
    close(epoll_fd);
    close(listen_fd);
    return 0;
}

非阻塞式客户端(用 select 实现双向通信)

#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/select.h>

#define BUF_SIZE 1024      // 数据缓冲区大小
#define SERVER_PORT 8888   // 服务端端口(需与服务端一致)
#define TIMEOUT 3          // select 超时时间(秒),避免无限阻塞

int main(int argc, char *argv[]) {
    // 检查参数:需传入服务端IP
    if (argc != 2) {
        fprintf(stderr, "用法:%s <服务端IP>\n", argv[0]);
        fprintf(stderr, "示例:%s 127.0.0.1\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    const char *server_ip = argv[1];

    int client_fd;
    struct sockaddr_in server_addr;
    fd_set read_fds;       // select 监听集合(标准输入 + 客户端套接字)
    int max_fd;            // 最大文件描述符(select 需 max_fd + 1)

    // 1. 创建 TCP 客户端套接字
    if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket 创建失败");
        exit(EXIT_FAILURE);
    }
    printf("客户端套接字创建成功(fd=%d)\n", client_fd);

    // 2. 配置服务端地址并连接
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERVER_PORT);
    if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
        perror("无效的服务端IP");
        close(client_fd);
        exit(EXIT_FAILURE);
    }

    if (connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("连接服务端失败");
        close(client_fd);
        exit(EXIT_FAILURE);
    }
    printf("成功连接服务端(IP:%s,端口:%d)\n", server_ip, SERVER_PORT);
    printf("提示:输入任意内容发送(按回车),输入 'exit' 退出,实时接收服务端消息\n");

    // 3. 初始化 select 相关参数
    max_fd = (client_fd > 0) ? client_fd : 0; // 0 = 标准输入描述符
    char send_buf[BUF_SIZE] = {0};
    char recv_buf[BUF_SIZE] = {0};

    // 4. 循环:用 select 监听事件
    while (1) {
        // -------------------------- 重置 select 监听集合 --------------------------
        FD_ZERO(&read_fds);                // 清空集合
        FD_SET(0, &read_fds);              // 添加标准输入(监听键盘输入)
        FD_SET(client_fd, &read_fds);      // 添加客户端套接字(监听服务端回声)

        // 设置超时时间(避免 select 无限阻塞,每隔 TIMEOUT 秒唤醒一次)
        struct timeval timeout = {TIMEOUT, 0}; // 秒 + 微秒

        // -------------------------- 调用 select 监听 --------------------------
        int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
        if (ret == -1) {
            perror("select 失败");
            break;
        } else if (ret == 0) {
            // 超时:无事件触发,打印提示(可选,避免客户端看起来“无响应”)
            // printf("等待输入或服务端消息...(%d秒超时)\n", TIMEOUT);
            continue;
        }

        // -------------------------- 处理就绪事件 --------------------------
        // 1. 标准输入就绪 → 读取键盘输入并发送给服务端
        if (FD_ISSET(0, &read_fds)) {
            // 读取用户输入(fgets 会读取换行符,后续处理)
            if (fgets(send_buf, BUF_SIZE, stdin) == NULL) {
                perror("读取输入失败");
                break;
            }

            // 处理输入:去掉换行符,判断是否退出
            int send_len = strlen(send_buf);
            if (send_buf[send_len - 1] == '\n') {
                send_buf[send_len - 1] = '\0';
                send_len--;
            }

            // 退出逻辑
            if (strcmp(send_buf, "exit") == 0) {
                printf("正在断开与服务端的连接...\n");
                break;
            }

            // 发送数据(若输入为空,不发送)
            if (send_len > 0) {
                if (send(client_fd, send_buf, send_len, 0) == -1) {
                    perror("发送数据失败");
                    break;
                }
                printf("已发送:%s(字节数:%d)\n", send_buf, send_len);
                memset(send_buf, 0, BUF_SIZE); // 清空缓冲区
            }
        }

        // 2. 客户端套接字就绪 → 接收服务端回声并打印
        if (FD_ISSET(client_fd, &read_fds)) {
            int recv_len = recv(client_fd, recv_buf, BUF_SIZE - 1, 0);
            if (recv_len <= 0) {
                if (recv_len == 0) {
                    printf("服务端已断开连接\n");
                } else {
                    perror("接收回声失败");
                }
                break;
            }
            recv_buf[recv_len] = '\0';
            printf("\n收到服务端回声:%s(字节数:%d)\n", recv_buf, recv_len);
            printf("请输入发送内容:"); // 提示用户继续输入
            fflush(stdout); // 刷新输出缓冲区,确保提示立即显示
            memset(recv_buf, 0, BUF_SIZE); // 清空缓冲区
        }
    }

    // 5. 释放资源
    close(client_fd);
    printf("客户端已退出\n");
    return 0;
}

epoll 边缘触发(ET)模式补充说明

若需使用 ET 模式(更高效率,仅触发一次状态变化),需修改两处代码:

  1. 添加事件时,在 events 中追加 EPOLLET 标志:
    ev.events = EPOLLIN | EPOLLET; // 读事件 + 边缘触发
    
  2. 读取数据时需 循环读(直到 recv 返回 -1errno=EAGAIN),避免漏读:
    void handle_client_data(int client_fd) {
        char buf[BUF_SIZE] = {0};
        int recv_len;
        // 循环读:直到无数据可读(ET 模式需一次性读完)
        while ((recv_len = recv(client_fd, buf, BUF_SIZE - 1, 0)) > 0) {
            buf[recv_len] = '\0';
            printf("收到客户端(fd=%d)数据:%s", client_fd, buf);
            send(client_fd, buf, recv_len, 0); // 回声
            memset(buf, 0, BUF_SIZE); // 清空缓冲区
        }
        // 处理客户端断开或错误
        if (recv_len <= 0) {
            if (recv_len < 0 && errno != EAGAIN) { // 排除“暂时无数据”的错误
                perror("recv failed");
            }
            close(client_fd);
            printf("客户端断开连接:fd=%d\n", client_fd);
        }
    }
    
  • ET 模式需将套接字设为非阻塞(避免循环读时阻塞),可通过 fcntl 设置:
    // 给新客户端套接字设置非阻塞
    int flags = fcntl(new_client_fd, F_GETFL, 0);
    fcntl(new_client_fd, F_SETFL, flags | O_NONBLOCK);
    

示例代码核心差异总结

对比维度 select 示例 epoll 示例(LT)
集合维护 手动重置(每次 select 前 FD_ZERO/FD_SET) 自动维护(epoll_ctl 添加/删除)
最大连接数 受 FD_SETSIZE 限制(默认1024) 无限制(仅受系统内存)
事件处理效率 遍历所有监听套接字(低) 仅遍历就绪套接字(高)
适用场景 中低并发(<1000 连接) 高并发(>1000 连接)
代码复杂度 较高(需维护客户端数组+重置集合) 较低(事件驱动,自动管理)
posted @ 2025-12-11 08:32  Jaklin  阅读(42)  评论(0)    收藏  举报