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

select、poll、epoll机制对比与应用场景

2023-11-174.6k 阅读

1. 网络编程中的I/O模型概述

在深入探讨select、poll、epoll机制之前,我们先来了解一下网络编程中的I/O模型。常见的I/O模型有以下几种:

1.1 阻塞I/O(Blocking I/O)

在阻塞I/O模型中,当应用程序调用一个I/O操作(如read)时,内核会等待数据准备好,然后将数据从内核缓冲区拷贝到用户缓冲区,在这个过程中,应用程序会一直阻塞,直到操作完成。例如:

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

#define BUFFER_SIZE 1024

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(8080);
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");

    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    char buffer[BUFFER_SIZE];
    ssize_t bytesRead = read(sockfd, buffer, sizeof(buffer));
    if (bytesRead > 0) {
        buffer[bytesRead] = '\0';
        printf("Received: %s\n", buffer);
    }

    close(sockfd);
    return 0;
}

在上述代码中,read函数会阻塞当前线程,直到有数据可读。这种模型简单直观,但在高并发场景下效率较低,因为一个线程只能处理一个I/O操作。

1.2 非阻塞I/O(Non - blocking I/O)

非阻塞I/O允许应用程序在I/O操作未完成时立即返回,而不是阻塞等待。应用程序需要不断轮询检查I/O操作是否完成。例如:

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

#define BUFFER_SIZE 1024

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(8080);
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");

    connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    char buffer[BUFFER_SIZE];
    ssize_t bytesRead;
    while ((bytesRead = read(sockfd, buffer, sizeof(buffer))) == -1) {
        if (errno != EAGAIN && errno != EWOULDBLOCK) {
            perror("read error");
            break;
        }
    }
    if (bytesRead > 0) {
        buffer[bytesRead] = '\0';
        printf("Received: %s\n", buffer);
    }

    close(sockfd);
    return 0;
}

在上述代码中,通过fcntl函数将套接字设置为非阻塞模式。虽然非阻塞I/O避免了阻塞等待,但频繁的轮询会消耗大量的CPU资源。

1.3 I/O多路复用(I/O Multiplexing)

I/O多路复用允许一个进程监视多个文件描述符(如套接字),当其中一个或多个文件描述符准备好进行I/O操作时,通知应用程序。select、poll、epoll都是I/O多路复用的具体实现。这种方式可以让一个进程高效地处理多个I/O操作,提高了资源利用率。

1.4 信号驱动I/O(Signal - driven I/O)

信号驱动I/O利用信号机制,当数据准备好时,内核向应用程序发送一个信号,应用程序接收到信号后进行I/O操作。这种方式减少了轮询带来的开销,但信号处理函数的编写和管理相对复杂。

1.5 异步I/O(Asynchronous I/O)

异步I/O允许应用程序在发起I/O操作后继续执行其他任务,当I/O操作完成时,内核通过回调函数通知应用程序。这种模型在现代操作系统中越来越受到重视,它可以充分利用多核CPU的性能,提高系统的并发处理能力。

2. select机制

2.1 select函数原型

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:监控的文件描述符集里最大文件描述符加1。
  • readfds:监控有读数据到达文件描述符集合,传入传出参数。
  • writefds:监控写数据到达文件描述符集合,传入传出参数。
  • exceptfds:监控异常发生达文件描述符集合, 传入传出参数。
  • timeout:定时阻塞监控时间,3种情况:
    • NULL,永远等下去。
    • 设置timevaltv_sectv_usec均为0,检查描述字后立即返回,轮询。
    • 设置timevaltv_sectv_usec均为非0,等待指定时间,当有事件发生或者时间用完,返回。

2.2 工作原理

select会将用户传入的文件描述符集合拷贝到内核空间,然后内核遍历这些文件描述符,检查是否有就绪的I/O操作。如果有,则将就绪的文件描述符集合返回给用户空间。用户需要再次遍历这个集合,找出具体哪些文件描述符就绪并进行相应处理。

2.3 代码示例

#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <string.h>

#define BUFFER_SIZE 1024
#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = INADDR_ANY;

    bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, MAX_CLIENTS);

    fd_set readFds;
    FD_ZERO(&readFds);
    FD_SET(serverSocket, &readFds);
    int maxFd = serverSocket;

    while (1) {
        fd_set tmpFds = readFds;
        int activity = select(maxFd + 1, &tmpFds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(serverSocket, &tmpFds)) {
                int clientSocket = accept(serverSocket, NULL, NULL);
                FD_SET(clientSocket, &readFds);
                if (clientSocket > maxFd) {
                    maxFd = clientSocket;
                }
            }
            for (int i = serverSocket + 1; i <= maxFd; ++i) {
                if (FD_ISSET(i, &tmpFds)) {
                    char buffer[BUFFER_SIZE];
                    ssize_t bytesRead = read(i, buffer, sizeof(buffer));
                    if (bytesRead > 0) {
                        buffer[bytesRead] = '\0';
                        printf("Received from client %d: %s\n", i, buffer);
                    } else if (bytesRead == 0) {
                        printf("Client %d disconnected\n", i);
                        close(i);
                        FD_CLR(i, &readFds);
                    } else {
                        perror("read error");
                    }
                }
            }
        }
    }

    close(serverSocket);
    return 0;
}

在上述代码中,我们使用select函数监听服务器套接字和客户端套接字。当有新的客户端连接或者已有客户端发送数据时,select函数返回,我们通过FD_ISSET宏判断具体哪个套接字有事件发生并进行处理。

2.4 优缺点

  • 优点
    • 跨平台性好,几乎在所有的操作系统上都支持。
    • 简单易用,对于小规模的I/O操作场景,使用select可以快速实现。
  • 缺点
    • 单个进程能够监控的文件描述符数量有限,在Linux系统中默认是1024个。虽然可以通过修改内核参数来提高这个限制,但会带来系统性能问题。
    • 每次调用select都需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。
    • 内核遍历文件描述符集合的时间复杂度为O(n),当文件描述符数量较多时,性能会急剧下降。

3. poll机制

3.1 poll函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:一个pollfd结构数组,每个pollfd结构包含要监控的文件描述符、监控的事件类型和返回的事件类型。
struct pollfd {
    int fd;         /* file descriptor */
    short events;   /* requested events */
    short revents;  /* returned events */
};
  • nfdsfds数组中元素的个数。
  • timeout:等待的超时时间,单位为毫秒。如果为-1,表示永远等待;如果为0,表示立即返回,不等待。

3.2 工作原理

poll与select类似,也是将用户传入的文件描述符集合拷贝到内核空间,然后内核遍历这些文件描述符,检查是否有就绪的I/O操作。不同的是,poll使用一个pollfd结构数组来存储文件描述符及其相关事件,并且没有最大文件描述符数量的限制(理论上)。

3.3 代码示例

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

#define BUFFER_SIZE 1024
#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = INADDR_ANY;

    bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, MAX_CLIENTS);

    struct pollfd fds[MAX_CLIENTS + 1];
    fds[0].fd = serverSocket;
    fds[0].events = POLLIN;
    int nfds = 1;

    while (1) {
        int activity = poll(fds, nfds, -1);
        if (activity < 0) {
            perror("poll error");
            break;
        } else if (activity > 0) {
            if (fds[0].revents & POLLIN) {
                int clientSocket = accept(serverSocket, NULL, NULL);
                fds[nfds].fd = clientSocket;
                fds[nfds].events = POLLIN;
                nfds++;
            }
            for (int i = 1; i < nfds; ++i) {
                if (fds[i].revents & POLLIN) {
                    char buffer[BUFFER_SIZE];
                    ssize_t bytesRead = read(fds[i].fd, buffer, sizeof(buffer));
                    if (bytesRead > 0) {
                        buffer[bytesRead] = '\0';
                        printf("Received from client %d: %s\n", fds[i].fd, buffer);
                    } else if (bytesRead == 0) {
                        printf("Client %d disconnected\n", fds[i].fd);
                        close(fds[i].fd);
                        for (int j = i; j < nfds - 1; ++j) {
                            fds[j] = fds[j + 1];
                        }
                        nfds--;
                    } else {
                        perror("read error");
                    }
                }
            }
        }
    }

    close(serverSocket);
    return 0;
}

在上述代码中,我们使用poll函数监听服务器套接字和客户端套接字。pollfd数组存储了要监控的套接字及其事件,通过poll函数返回的结果,我们可以判断哪些套接字有事件发生并进行相应处理。

3.4 优缺点

  • 优点
    • 没有最大文件描述符数量的限制,理论上可以监控任意数量的文件描述符。
    • 与select相比,poll使用pollfd结构数组,代码实现上更加清晰,逻辑更加紧凑。
  • 缺点
    • 每次调用poll同样需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。
    • 内核遍历文件描述符集合的时间复杂度仍然为O(n),在高并发场景下,随着文件描述符数量的增加,性能会受到影响。

4. epoll机制

4.1 epoll相关函数

  • epoll_create:创建一个epoll实例,返回一个epoll文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
  • epoll_ctl:用于控制某个epoll文件描述符上的事件,可以注册、修改或者删除事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- `epfd`:epoll实例的文件描述符。
- `op`:操作类型,如`EPOLL_CTL_ADD`(添加)、`EPOLL_CTL_MOD`(修改)、`EPOLL_CTL_DEL`(删除)。
- `fd`:要操作的文件描述符。
- `event`:指向`epoll_event`结构的指针,用于指定事件类型。
struct epoll_event {
    uint32_t events;      /* Epoll events */
    epoll_data_t data;    /* User data variable */
};
typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
  • epoll_wait:等待事件的发生,返回发生事件的文件描述符数量。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- `epfd`:epoll实例的文件描述符。
- `events`:用于存储发生事件的`epoll_event`结构数组。
- `maxevents`:`events`数组的大小。
- `timeout`:等待的超时时间,单位为毫秒。如果为-1,表示永远等待;如果为0,表示立即返回,不等待。

4.2 工作原理

epoll采用了一种基于事件通知的机制。当应用程序调用epoll_create创建一个epoll实例后,通过epoll_ctl将需要监控的文件描述符及其事件注册到内核的红黑树中。当有事件发生时,内核将这些事件添加到一个就绪链表中。应用程序调用epoll_wait时,只需要从这个就绪链表中获取就绪的事件,而不需要像select和poll那样遍历所有的文件描述符。这种方式大大提高了效率,其时间复杂度为O(1)。

4.3 代码示例

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

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

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = INADDR_ANY;

    bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, MAX_EVENTS);

    int epollFd = epoll_create(MAX_EVENTS);
    struct epoll_event event;
    event.data.fd = serverSocket;
    event.events = EPOLLIN;
    epoll_ctl(epollFd, EPOLL_CTL_ADD, serverSocket, &event);

    struct epoll_event events[MAX_EVENTS];
    while (1) {
        int numEvents = epoll_wait(epollFd, events, MAX_EVENTS, -1);
        if (numEvents < 0) {
            perror("epoll_wait error");
            break;
        }
        for (int i = 0; i < numEvents; ++i) {
            if (events[i].data.fd == serverSocket) {
                int clientSocket = accept(serverSocket, NULL, NULL);
                event.data.fd = clientSocket;
                event.events = EPOLLIN;
                epoll_ctl(epollFd, EPOLL_CTL_ADD, clientSocket, &event);
            } else {
                int clientSocket = events[i].data.fd;
                char buffer[BUFFER_SIZE];
                ssize_t bytesRead = read(clientSocket, buffer, sizeof(buffer));
                if (bytesRead > 0) {
                    buffer[bytesRead] = '\0';
                    printf("Received from client %d: %s\n", clientSocket, buffer);
                } else if (bytesRead == 0) {
                    printf("Client %d disconnected\n", clientSocket);
                    close(clientSocket);
                    epoll_ctl(epollFd, EPOLL_CTL_DEL, clientSocket, NULL);
                } else {
                    perror("read error");
                }
            }
        }
    }

    close(serverSocket);
    close(epollFd);
    return 0;
}

在上述代码中,我们首先通过epoll_create创建一个epoll实例,然后使用epoll_ctl将服务器套接字添加到监控列表中。在主循环中,通过epoll_wait等待事件发生,当有事件发生时,根据事件类型进行相应处理。

4.4 优缺点

  • 优点
    • 支持大量的文件描述符,理论上没有数量限制,因为它采用红黑树来管理文件描述符。
    • 高效的事件通知机制,时间复杂度为O(1),避免了遍历所有文件描述符的开销。
    • 只需要将文件描述符集合注册一次,后续操作不需要重复拷贝到内核空间,减少了系统开销。
  • 缺点
    • epoll是Linux特有的机制,不具备跨平台性。
    • 相比select和poll,epoll的使用相对复杂,需要对其原理有较深入的理解才能正确使用。

5. select、poll、epoll机制对比

5.1 文件描述符数量限制

  • select:在Linux系统中默认有1024个文件描述符的限制,虽然可以通过修改内核参数来提高,但会影响系统性能。
  • poll:理论上没有文件描述符数量的限制,因为它使用pollfd数组来管理文件描述符。
  • epoll:同样理论上没有文件描述符数量的限制,它通过红黑树来管理文件描述符。

5.2 时间复杂度

  • select:内核遍历文件描述符集合的时间复杂度为O(n),当文件描述符数量较多时,性能会急剧下降。
  • poll:时间复杂度同样为O(n),因为它也需要遍历所有注册的文件描述符。
  • epoll:采用基于事件通知的机制,时间复杂度为O(1),无论文件描述符数量多少,性能都比较稳定。

5.3 内核与用户空间数据拷贝

  • select:每次调用select都需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。
  • poll:与select类似,每次调用poll也需要进行数据拷贝。
  • epoll:只需要在注册文件描述符时将数据拷贝到内核空间,后续操作不需要重复拷贝,减少了系统开销。

5.4 事件通知方式

  • select:通过将就绪的文件描述符集合返回给用户空间,用户需要再次遍历这个集合来找出具体哪些文件描述符就绪。
  • poll:与select类似,也是通过返回的pollfd数组中的revents字段来判断哪些文件描述符就绪,需要遍历。
  • epoll:采用基于事件通知的机制,内核将就绪的事件添加到就绪链表中,应用程序通过epoll_wait直接从就绪链表中获取就绪事件,不需要遍历所有文件描述符。

5.5 跨平台性

  • select:跨平台性好,几乎在所有操作系统上都支持。
  • poll:跨平台性相对较好,在大部分操作系统上都有实现。
  • epoll:是Linux特有的机制,不具备跨平台性。

6. 应用场景

6.1 select的应用场景

  • 小规模I/O场景:如果应用程序只需要监控少量的文件描述符,并且对性能要求不是特别高,select是一个简单易用的选择。例如,一些简单的网络工具或者单机应用程序,其I/O操作较少,使用select可以快速实现功能。
  • 跨平台需求:当应用程序需要在多种操作系统上运行,并且对性能要求不苛刻时,select的跨平台性使其成为一个合适的选择。

6.2 poll的应用场景

  • 中等规模I/O场景:poll相比select没有文件描述符数量的限制,对于需要监控较多文件描述符,但又没有达到非常大规模的场景,poll是一个不错的选择。例如,一些小型的网络服务器,其并发连接数在几百个以内,使用poll可以在一定程度上提高性能,并且代码实现相对简单。
  • 需要更清晰逻辑的场景:由于poll使用pollfd结构数组,代码结构相对select更加清晰,对于一些对代码可读性要求较高的项目,poll可能更受欢迎。

6.3 epoll的应用场景

  • 高并发I/O场景:在高并发的网络服务器中,epoll是首选的I/O多路复用机制。例如,大型的Web服务器、游戏服务器等,需要处理大量的并发连接,epoll的高效事件通知机制和低系统开销可以大大提高服务器的性能和并发处理能力。
  • Linux平台特定应用:如果应用程序是专门为Linux平台开发的,并且对性能有较高的要求,epoll是不二之选。它可以充分发挥Linux内核的优势,提供高效稳定的I/O处理能力。

在实际开发中,应根据具体的应用场景和需求来选择合适的I/O多路复用机制,以达到最佳的性能和开发效率。