网络编程之IO多路复用select、poll、epoll

目录

select

参数

nfds(需要检查的最大文件描述符值 + 1)

readfds(读文件描述符集合)

writefds(写文件描述符集合)

exceptfds(异常文件描述符集合)

select 的局限性

poll

 参数

(1)struct pollfd *fds

(2)nfds_t nfds

(3)int timeout

返回值

使用

对比 select 的差异

poll的局限性

1. 线性扫描开销大

2. 内核与用户空间的数据拷贝

3. 内核实现效率低

4. 水平触发(LT)模式的潜在问题

5. 不适合超大规模连接

epoll

核心概念

epoll 实例:借助 epoll_create() 函数能够创建一个 epoll 实例,它其实是内核里的一个数据结构,其作用是存储待监控的文件描述符以及相关事件。

epoll_create()参数说明

返回值

注册事件:可以使用 epoll_ctl() 函数对文件描述符进行管理,能执行添加、修改或者删除操作。在注册事件时,需要指定感兴趣的事件类型,像 EPOLLIN(可读事件)、EPOLLOUT(可写事件)等。

epoll_ctl() 参数说明

常用事件类型

返回值

事件等待:通过 epoll_wait() 函数,进程会被阻塞,直到有注册的事件发生。此时,该函数会返回一个包含就绪文件描述符的列表。

epoll_wait()参数说明

关键要点

        非阻塞模式

边缘触发(ET)与水平触发(LT):

 用epoll实现一个简单的TCP服务器

总结


 

select

#include <sys/select.h>

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

参数

  1. nfds(需要检查的最大文件描述符值 + 1)

    • 作用:指定需要检查的文件描述符范围,通常是所有监控的文件描述符中的最大值加 1。
    • 示例:如果监控的文件描述符为 3、4、7,则 nfds 应为 7 + 1 = 8
  2. readfds(读文件描述符集合)

    • 作用:传入需要监控可读事件的文件描述符集合。
    • 返回时:集合中包含变为可读的文件描述符。
  3. writefds(写文件描述符集合)

    • 作用:传入需要监控可写事件的文件描述符集合。
    • 返回时:集合中包含变为可写的文件描述符。
  4. exceptfds(异常文件描述符集合)

    • 作用:传入需要监控异常事件的文件描述符集合。
    • 返回时:集合中包含发生异常的文件描述符。
  5. timeout(超时时间)

    • 作用:指定 select() 函数的阻塞时间。
    • 三种情况:
      • NULL:永久阻塞,直到有文件描述符就绪。
      • 0:非阻塞模式,立即返回(轮询)。
      • >0:指定等待的最长时间(秒和微秒)。

 使用

    int nready=select(maxfd,rset,wset,eset,timeout);

    fd_set rfds,rset;
    FD_ZERO(&rfds);
    FD_SET(sockfd,&rfds);

    int maxfd = sockfd;

    while(1){
        rset = rfds;
        int nready=select(maxfd+1,&rset,NULL,NULL, NULL);
        if(FD_ISSET(sockfd,&rset))
        {
            struct sockaddr_in clientaddr;
            socklen_tlen=sizeof(clientaddr); 
            int clientfd = accept(sockfd,
                (struct sockaddr*)&clientaddr,&len);
            printf("sockfd:%d\n",clientfd);
            FD_SET(clientfd, &rfds);
            maxfd = clientfd;
        }    

    int i =0;
    for(i=sockfd+l;i<= maxfd;i ++){
        if(FD ISSET(i,&rset)){
            char buffer[128]={0};
            int countrecv(i,buffer,128,0);
            if(count == 0){
                FD CLR(i,&rfds);
                close(i);
                break;
        }
        send(i,buffer,count,0);
        }
    }

            

select 的局限性

  1. 参数较多,且每次调用前需重新初始化
  2. 存在内存拷贝开销,由于 select() 的文件描述符集合(fd_set)采用值传递机制,每次调用都需要在用户空间和内核空间之间进行数据拷贝
  3. 支持的文件描述符数量有限
  4. 采用线性扫描机制,效率较低

 

poll

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
    int   fd;         // 文件描述符
    short events;     // 关注的事件(输入掩码)
    short revents;    // 返回的就绪事件(输出掩码)
};

 

  • 常见事件类型(通过 events 设置):
    • POLLIN:数据可读(包括连接关闭、半关闭)。
    • POLLOUT:数据可写。
    • POLLERR:发生错误(自动关注,无需手动设置)。
    • POLLHUP:发生挂起(如连接断开)。

 参数

(1)struct pollfd *fds

    • 描述符数组:每个元素表示一个要监控的文件描述符及其关注的事件。

(2)nfds_t nfds

  • 监控的描述符数量:即 fds 数组的有效长度。

(3)int timeout

  • 超时时间(毫秒):
    • -1:永久阻塞,直到有事件发生。
    • 0:立即返回(非阻塞)。
    • >0:等待指定毫秒数后返回。

返回值

  • 正数:就绪的描述符总数(即 revents 非零的描述符数量)。
  • 0:超时且无事件发生。
  • -1:出错(设置 errno)。

使用

// 初始化 pollfd 数组
    struct pollfd fds = {0};

    int nfds = 1;  // 初始只有监听套接字
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;  // 监听可读事件(新连接到来)

    while (1) {
        // 调用 poll 等待事件
        int nready = poll(fds, nfds, -1);
        if (nready == -1) {
            perror("poll");
            break;
        }

        // 处理监听套接字上的新连接
        if (fds[0].revents & POLLIN) {
            struct sockaddr_in client_addr;
            socklen_t client_len = sizeof(client_addr);
            int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
            if (conn_fd == -1) {
                perror("accept");
                continue;
            }

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

            // 将新连接添加到 pollfd 数组
            if (nfds < MAX_EVENTS) {
                fds[nfds].fd = conn_fd;
                fds[nfds].events = POLLIN;  // 监控新连接的可读事件
                nfds++;
            } else {
                printf("Too many connections, max is %d\n", MAX_EVENTS);
                close(conn_fd);  // 超出最大连接数,关闭新连接
            }
        }

        // 处理其他已连接套接字的可读事件
        for (int i = 1; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buf[1024];
                int n = read(fds[i].fd, buf, sizeof(buf));
                if (n <= 0) {
                    // 客户端关闭连接或发生错误
                    printf("Connection closed on fd %d\n", fds[i].fd);
                    close(fds[i].fd);
                    
                    // 移除 pollfd 数组中的元素(将最后一个元素移到当前位置)
                    fds[i] = fds[nfds - 1];
                    nfds--;
                    i--;  // 重新检查当前位置的元素
                } else {
                    // 处理接收到的数据
                    printf("Received %d bytes on fd %d\n", n, fds[i].fd);
                }
            }
        }
    }
free(fds);

 

对比 select 的差异

  • select():使用 fd_set 位图,需手动维护最大描述符值(maxfd),且每次调用前需重置整个集合。
  • poll():使用数组,直接添加 / 删除元素,无需重置,更直观灵活。

 

poll的局限性

1. 线性扫描开销大

  • 问题:每次调用 poll() 返回后,需要遍历所有监控的文件描述符(数组中的 nfds 个元素),逐个检查 revents 字段是否就绪。
  • 时间复杂度:O (n),其中 n 是监控的描述符数量。当连接数很大(如数千或数万)时,遍历操作会成为性能瓶颈。
  • 对比epoll() 使用事件通知机制(回调),仅关注就绪的描述符,时间复杂度为 O (1)。

2. 内核与用户空间的数据拷贝

  • 问题poll() 的 struct pollfd 数组是用户空间和内核空间之间来回传递的。每次调用 poll() 时,内核需要将整个数组从用户空间拷贝到内核空间,返回时再将就绪状态从内核空间拷贝回用户空间。
  • 影响:频繁调用 poll() 时,内存拷贝开销显著,尤其是当监控的描述符数量较多时。
  • 对比epoll() 通过 epoll_ctl() 预先在内核中注册描述符,后续只需传递少量事件信息,减少了拷贝开销。

3. 内核实现效率低

  • 问题poll() 在内核中的实现通常使用轮询(polling)方式,即内核会不断检查所有注册的描述符状态,直到有事件发生或超时。
  • 对比epoll() 使用事件驱动(event-driven)机制,通过回调函数直接将就绪事件通知给应用程序,避免了无意义的轮询。

4. 水平触发(LT)模式的潜在问题

  • 问题poll() 仅支持水平触发模式(Level Triggered)。在这种模式下,如果应用程序没有完全处理完就绪描述符上的数据,poll() 会不断通知该描述符就绪,可能导致 “忙等待”。
  • 对比epoll() 支持边缘触发(Edge Triggered)模式,仅在描述符状态变化时通知一次,强制应用程序必须一次性处理完所有数据,减少了重复通知的开销。

5. 不适合超大规模连接

  • 问题:虽然 poll() 没有 select() 的 FD_SETSIZE 限制,但由于其 O (n) 的时间复杂度和线性扫描机制,当监控的描述符数量超过数千时,性能会显著下降。
  • 适用场景poll() 更适合中等规模的并发连接(如几百个),对于大规模高并发场景(如 10K+ 连接),epoll() 或 kqueue() 是更优选择。

 

epoll

在 Linux 系统编程里,epoll 是一种用于高效处理大量并发连接的 I/O 多路复用技术。和 selectpoll 相比,它在处理大量文件描述符时,性能表现更为出色。

核心概念

epoll 实例:借助 epoll_create() 函数能够创建一个 epoll 实例,它其实是内核里的一个数据结构,其作用是存储待监控的文件描述符以及相关事件。

#include <sys/epoll.h>
int epoll_create(int size); 

epoll_create()参数说明

  • size:这个参数用于提示内核需要创建的事件表大小。但必须设置为大于 0 的值。

返回值

如果函数调用成功,将返回一个指向新创建的 epoll 实例的文件描述符;若调用失败,则返回 -1,并设置相应的 errno

注册事件:可以使用 epoll_ctl() 函数对文件描述符进行管理,能执行添加、修改或者删除操作。在注册事件时,需要指定感兴趣的事件类型,像 EPOLLIN(可读事件)、EPOLLOUT(可写事件)等。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl() 参数说明

  • epfd:由 epoll_create() 返回的 epoll 实例文件描述符。
  • op:要执行的操作,其取值如下:
    • EPOLL_CTL_ADD:将 fd 添加到 epoll 实例中,并注册 event 所指定的事件。
    • EPOLL_CTL_MOD:修改已经注册的 fd 的事件为 event 所指定的事件。
    • EPOLL_CTL_DEL:从 epoll 实例中删除 fd,此时 event 参数可以设为 NULL
  • fd:需要监控的目标文件描述符。
  • event:这是一个指向 struct epoll_event 的指针,用于指定要监控的事件类型和相关数据。其结构如下:

 

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

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

常用事件类型

  • EPOLLIN:文件描述符可读。
  • EPOLLOUT:文件描述符可写。
  • EPOLLET:设置为边缘触发模式(默认是水平触发模式)。
  • EPOLLONESHOT:设置为一次性触发事件,事件触发后需要重新注册。

返回值

若函数调用成功,返回 0;若调用失败,返回 -1,并设置 errno

事件等待:通过 epoll_wait() 函数,进程会被阻塞,直到有注册的事件发生。此时,该函数会返回一个包含就绪文件描述符的列表。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait()参数说明

  • epfd:epoll 实例的文件描述符。
  • events:用于存储就绪事件的数组,该数组由用户分配内存。
  • maxevents:指定 events 数组的最大容量,必须大于 0。
  • timeout:超时时间(单位为毫秒),其取值情况如下:
    • -1:无限等待,直到有事件发生。
    • 0:立即返回,即使没有就绪事件。
    • >0:等待指定的毫秒数。

返回值

  • 若函数调用成功,返回就绪事件的数量。
  • 若超时时间到仍没有事件发生,返回 0。
  • 若调用失败,返回 -1,并设置 errno

关键要点

        非阻塞模式

         在使用 epoll 的边缘触发模式时,必须将文件描述符设置为非阻塞模式,以避免在处理大量并发连接时出现阻塞。

  • 边缘触发(ET)与水平触发(LT)

    • ET 模式:只有当文件描述符的状态发生变化时才会触发事件,这就要求应用程序必须处理完所有数据,否则未处理的数据不会再次触发通知。
    • LT 模式:只要文件描述符处于就绪状态,就会持续触发事件。
  • 错误处理:在实际应用中,需要处理各种可能的错误情况,如 EPOLLERREPOLLHUP 等事件。

 用epoll实现一个简单的TCP服务器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>

#define MAX_EVENTS 10
#define BUFFER_SIZE 1024

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl");
        return -1;
    }
    if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
        perror("fcntl");
        return -1;
    }
    return 0;
}

int main() {
    int listen_fd, epfd;
    struct sockaddr_in server_addr;
    struct epoll_event ev, events[MAX_EVENTS];
    int nfds, conn_sock;
    char buffer[BUFFER_SIZE];

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

    // 设置套接字选项
    int opt = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    // 绑定地址
    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(8888);
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(listen_fd, SOMAXCONN) == -1) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    // 设置为非阻塞模式
    if (set_nonblocking(listen_fd) == -1) {
        exit(EXIT_FAILURE);
    }

    // 创建 epoll 实例
    epfd = epoll_create1(0);
    if (epfd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }

    // 注册监听套接字的读事件
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
        perror("epoll_ctl: listen_fd");
        exit(EXIT_FAILURE);
    }

    printf("Server started, listening on port 8888...\n");

    // 事件循环
    while (1) {
        // 等待事件发生
        nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

        // 处理就绪事件
        for (int i = 0; i < nfds; i++) {
            // 处理新连接
            if (events[i].data.fd == listen_fd) {
                conn_sock = accept(listen_fd, NULL, NULL);
                if (conn_sock == -1) {
                    perror("accept");
                    continue;
                }
                if (set_nonblocking(conn_sock) == -1) {
                    close(conn_sock);
                    continue;
                }
                ev.events = EPOLLIN | EPOLLET;  // 边缘触发模式
                ev.data.fd = conn_sock;
                if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
                    perror("epoll_ctl: conn_sock");
                    close(conn_sock);
                    continue;
                }
                printf("New connection established\n");
            }
            // 处理客户端数据
            else {
                memset(buffer, 0, BUFFER_SIZE);
                int n = read(events[i].data.fd, buffer, BUFFER_SIZE);
                if (n <= 0) {
                    // 客户端关闭连接或发生错误
                    close(events[i].data.fd);
                    printf("Connection closed\n");
                } else {
                    // 回显数据给客户端
                    write(events[i].data.fd, buffer, n);
                }
            }
        }
    }

    // 清理资源
    close(listen_fd);
    close(epfd);
    return 0;
}

总结

总体而言,select 出现最早,接口简单但存在诸多限制;poll 改进了文件描述符数量限制问题,但本质上与 select 一样存在性能瓶颈;epoll 是 Linux 下高效的 I/O 多路复用方案,通过事件驱动和优化的数据结构,解决了前两者在高并发场景下的性能问题,是目前 Linux 网络编程中处理大量并发连接的首选方案 。

 

EPOLL-拿个例子让你明白整个事件处理流程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值