MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言select/poll/epoll机制对比与选择

2022-08-034.2k 阅读

一、I/O 多路复用概述

在深入探讨 selectpollepoll 机制之前,先来了解一下 I/O 多路复用的概念。在网络编程中,一个服务器往往需要处理多个客户端的连接请求和数据传输。传统的做法是为每个客户端创建一个单独的进程或线程来处理,然而这种方式在面对大量客户端时,会消耗大量的系统资源,导致性能下降。

I/O 多路复用技术就是为了解决这个问题而诞生的。它允许一个进程同时监视多个文件描述符(如套接字)的状态变化,当其中任何一个文件描述符就绪(可读、可写或有异常)时,就通知应用程序进行相应处理。这样,一个进程就可以高效地管理多个 I/O 流,大大提高了系统的并发处理能力。

二、select 机制

2.1 select 原理

select 是最早的 I/O 多路复用机制,它通过一个 fd_set 结构体来表示一组文件描述符。fd_set 本质上是一个位图,每一位对应一个文件描述符。select 函数会阻塞等待,直到 fd_set 中的某个文件描述符就绪,或者超时时间到达。

select 函数的原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds:需要检查的文件描述符集合中最大文件描述符加 1。
  • readfds:指向读文件描述符集合的指针。
  • writefds:指向写文件描述符集合的指针。
  • exceptfds:指向异常文件描述符集合的指针。
  • timeout:设置的超时时间,如果为 NULL,则 select 会一直阻塞,直到有文件描述符就绪。

select 函数返回时,readfdswritefdsexceptfds 会被修改,只包含就绪的文件描述符。应用程序需要遍历这些集合,才能确定哪些文件描述符真正就绪。

2.2 select 示例代码

下面是一个简单的使用 select 实现的服务器端代码示例,监听一个端口,接收客户端连接并处理数据:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/time.h>

#define PORT 8080
#define MAX_CLIENTS 100

int main(int argc, char const *argv[]) {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    fd_set read_fds;
    fd_set tmp_fds;
    int activity, i, val;

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    // 初始化文件描述符集合
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);

    while (1) {
        tmp_fds = read_fds;

        // 等待文件描述符就绪
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);

        if ((activity < 0) && (errno != EINTR)) {
            printf("select error");
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    exit(EXIT_FAILURE);
                }

                // 将新连接的套接字加入文件描述符集合
                FD_SET(new_socket, &read_fds);
                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            }

            for (i = 0; i <= server_fd; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    if (i != server_fd) {
                        valread = read(i, buffer, 1024);
                        if (valread == 0) {
                            // 客户端关闭连接
                            getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                            printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
                            close(i);
                            FD_CLR(i, &read_fds);
                        } else {
                            buffer[valread] = '\0';
                            printf("Message from client %d: %s\n", i, buffer);
                            val = write(i, buffer, strlen(buffer));
                        }
                    }
                }
            }
        }
    }
    return 0;
}

2.3 select 优缺点

优点

  • 几乎所有操作系统都支持 select,具有良好的跨平台性。

缺点

  • fd_set 大小受限,在 Linux 上默认最大为 1024,这限制了可同时处理的文件描述符数量。
  • select 返回后,需要遍历整个 fd_set 来确定哪些文件描述符就绪,时间复杂度为 O(n),在文件描述符数量较多时性能较低。
  • selectfd_set 参数在返回时会被修改,每次调用 select 前都需要重新设置。

三、poll 机制

3.1 poll 原理

poll 机制改进了 select 中文件描述符集合大小受限的问题。poll 使用一个 pollfd 结构体数组来表示需要监视的文件描述符及其事件。pollfd 结构体定义如下:

struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 等待的事件 */
    short revents;  /* 实际发生的事件 */
};

poll 函数的原型为:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:指向 pollfd 结构体数组的指针。
  • nfds:数组中元素的个数。
  • timeout:超时时间,单位为毫秒,-1 表示无限期阻塞。

poll 函数会阻塞等待,直到数组中的某个文件描述符就绪,或者超时时间到达。返回时,revents 字段会标记实际发生的事件,应用程序可以通过遍历 pollfd 数组来确定就绪的文件描述符。

3.2 poll 示例代码

下面是使用 poll 实现的类似服务器端代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <poll.h>

#define PORT 8080
#define MAX_CLIENTS 100

int main(int argc, char const *argv[]) {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    struct pollfd fds[MAX_CLIENTS + 1];
    int activity, i, val;

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    // 初始化 pollfd 数组
    for (i = 0; i <= MAX_CLIENTS; i++) {
        fds[i].fd = -1;
        fds[i].events = 0;
        fds[i].revents = 0;
    }

    fds[0].fd = server_fd;
    fds[0].events = POLLIN;

    int client_count = 0;

    while (1) {
        // 等待文件描述符就绪
        activity = poll(fds, client_count + 1, -1);

        if (activity < 0) {
            perror("poll error");
        } else if (activity > 0) {
            if (fds[0].revents & POLLIN) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    exit(EXIT_FAILURE);
                }

                // 将新连接的套接字加入 pollfd 数组
                for (i = 1; i <= MAX_CLIENTS; i++) {
                    if (fds[i].fd == -1) {
                        fds[i].fd = new_socket;
                        fds[i].events = POLLIN;
                        client_count++;
                        break;
                    }
                }

                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            }

            for (i = 1; i <= client_count; i++) {
                if (fds[i].revents & (POLLIN | POLLERR)) {
                    valread = read(fds[i].fd, buffer, 1024);
                    if (valread == 0) {
                        // 客户端关闭连接
                        getpeername(fds[i].fd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
                        close(fds[i].fd);
                        fds[i].fd = -1;
                        client_count--;
                    } else {
                        buffer[valread] = '\0';
                        printf("Message from client %d: %s\n", fds[i].fd, buffer);
                        val = write(fds[i].fd, buffer, strlen(buffer));
                    }
                }
            }
        }
    }
    return 0;
}

3.3 poll 优缺点

优点

  • 没有文件描述符数量的限制,理论上可以处理任意数量的文件描述符。
  • pollfd 结构体数组在返回时不会被修改,不需要每次重新设置。

缺点

  • 虽然没有文件描述符数量限制,但实际应用中,由于需要传递一个较大的数组,在文件描述符数量非常大时,会消耗较多的内核资源。
  • select 类似,poll 返回后需要遍历整个数组来确定就绪的文件描述符,时间复杂度为 O(n),在文件描述符数量较多时性能不高。

四、epoll 机制

4.1 epoll 原理

epoll 是 Linux 特有的 I/O 多路复用机制,它在性能上比 selectpoll 有了很大提升。epoll 基于事件驱动,采用回调机制,当文件描述符就绪时,内核会主动将其添加到一个就绪队列中,应用程序只需从这个队列中获取就绪的文件描述符,而不需要遍历所有监视的文件描述符。

epoll 有三个主要函数:

  1. epoll_create:创建一个 epoll 实例,返回一个 epoll 文件描述符。
int epoll_create(int size);

参数 size 是创建 epoll 实例时预分配的文件描述符数量,从 Linux 2.6.8 开始,该参数被忽略,但仍需提供一个大于 0 的值。

  1. epoll_ctl:用于控制 epoll 实例,向其中添加、修改或删除监视的文件描述符及其事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfdepoll 实例的文件描述符。
  • op:操作类型,有 EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。
  • fd:要操作的文件描述符。
  • event:指向 epoll_event 结构体的指针,用于指定事件类型和数据。epoll_event 结构体定义如下:
struct epoll_event {
    uint32_t events;      /* 事件类型 */
    epoll_data_t data;    /* 用户数据 */
};

events 可以是 EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLERR(错误)等事件的组合。data 可以是一个整数、指针或 epoll_data 联合体中的其他成员。

  1. epoll_wait:等待 epoll 实例中的文件描述符就绪,返回就绪的文件描述符数量。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfdepoll 实例的文件描述符。
  • events:用于存储就绪事件的数组。
  • maxeventsevents 数组的大小。
  • timeout:超时时间,单位为毫秒,-1 表示无限期阻塞。

4.2 epoll 示例代码

下面是使用 epoll 实现的服务器端代码:

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

#define PORT 8080
#define MAX_EVENTS 10

int main(int argc, char const *argv[]) {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    int epollfd, nfds;
    struct epoll_event event;
    struct epoll_event *events;

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听套接字
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

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

    // 将监听套接字添加到 epoll 实例中
    event.data.fd = server_fd;
    event.events = EPOLLIN | EPOLLET;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
        perror("epoll_ctl: listen_sock");
        exit(EXIT_FAILURE);
    }

    // 分配内存用于存储就绪事件
    events = calloc(MAX_EVENTS, sizeof(event));

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

        for (int n = 0; n < nfds; ++n) {
            if (events[n].data.fd == server_fd) {
                while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) != -1) {
                    event.data.fd = new_socket;
                    event.events = EPOLLIN | EPOLLET;
                    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
                        perror("epoll_ctl: new_socket");
                        close(new_socket);
                    }
                }
                if (errno != EAGAIN && errno != EWOULDBLOCK) {
                    perror("accept");
                }
            } else {
                valread = read(events[n].data.fd, buffer, 1024);
                if (valread == 0) {
                    // 客户端关闭连接
                    close(events[n].data.fd);
                    if (epoll_ctl(epollfd, EPOLL_CTL_DEL, events[n].data.fd, NULL) == -1) {
                        perror("epoll_ctl: del");
                    }
                } else {
                    buffer[valread] = '\0';
                    printf("Message from client %d: %s\n", events[n].data.fd, buffer);
                    write(events[n].data.fd, buffer, strlen(buffer));
                }
            }
        }
    }

    free(events);
    close(epollfd);
    close(server_fd);
    return 0;
}

4.3 epoll 优缺点

优点

  • 支持大量文件描述符,性能不会随着文件描述符数量的增加而显著下降,因为它采用事件驱动,时间复杂度为 O(1)。
  • 内核和用户空间之间的数据拷贝只需要一次,而 selectpoll 每次都需要将文件描述符集合从用户空间拷贝到内核空间。
  • 提供了边缘触发(Edge Triggered,ET)和水平触发(Level Triggered,LT)两种模式,ET 模式效率更高,适合处理高速流数据。

缺点

  • epoll 是 Linux 特有的机制,不具备跨平台性,在其他操作系统上无法使用。

五、select、poll、epoll 对比与选择

  1. 文件描述符数量限制

    • select 受限于 fd_set 的大小,默认最大为 1024。
    • poll 理论上没有限制,但在实际应用中,大量文件描述符会消耗较多内核资源。
    • epoll 支持大量文件描述符,非常适合处理高并发场景。
  2. 性能

    • selectpoll 在文件描述符数量较多时,由于需要遍历所有文件描述符来确定就绪的,时间复杂度为 O(n),性能较低。
    • epoll 采用事件驱动,时间复杂度为 O(1),在高并发场景下性能优势明显。
  3. 跨平台性

    • select 几乎支持所有操作系统,具有良好的跨平台性。
    • poll 也有较好的跨平台性,但在不同系统上可能有一些细微差异。
    • epoll 是 Linux 特有的,不具备跨平台性。
  4. 使用场景选择

    • 如果需要处理的文件描述符数量较少,并且对跨平台性有要求,selectpoll 可以满足需求。
    • 如果需要处理大量文件描述符,特别是在高并发的网络服务器场景下,epoll 是最佳选择。

在实际应用中,应根据具体的需求和场景来选择合适的 I/O 多路复用机制,以达到最优的性能和资源利用效率。同时,对于需要跨平台的应用,要充分考虑不同机制在不同操作系统上的兼容性和性能表现。