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

select、poll、epoll的编程接口与使用方法详解

2022-06-093.5k 阅读

一、select 编程接口与使用方法

1.1 select 函数原型

select函数是 Unix 系统中用于多路复用 I/O 的系统调用,其函数原型如下:

#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。这是为了遍历所有可能的文件描述符,因为fd_set是一个位图表示,需要知道最大的位位置来进行有效的遍历。
  • readfds:指向fd_set结构的指针,用于检查可读性的文件描述符集合。
  • writefds:指向fd_set结构的指针,用于检查可写性的文件描述符集合。
  • exceptfds:指向fd_set结构的指针,用于检查异常条件的文件描述符集合。
  • timeout:指向struct timeval结构的指针,用于设置select等待的最长时间。如果设为NULL,则select会一直阻塞,直到有事件发生;如果设为{0, 0},则select会立即返回,不等待任何事件。

1.2 fd_set 数据结构操作

fd_set是一个文件描述符集合的数据结构,在使用select时,需要对其进行相应的操作。相关的操作宏定义如下:

  • FD_ZERO(fd_set *set):清空set集合。
  • FD_SET(int fd, fd_set *set):将文件描述符fd添加到set集合中。
  • FD_CLR(int fd, fd_set *set):将文件描述符fdset集合中移除。
  • FD_ISSET(int fd, fd_set *set):检查文件描述符fd是否在set集合中。

1.3 代码示例

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

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.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};

    // 创建套接字
    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, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    fd_set read_fds;
    fd_set tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);

    int activity, new_socket_fd;
    while (1) {
        tmp_fds = read_fds;
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);

        if (activity < 0) {
            perror("select error");
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket_fd, &read_fds);
                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket_fd, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            }

            for (int 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';
                            send(i, buffer, strlen(buffer), 0);
                        }
                    }
                }
            }
        }
    }
    return 0;
}

在上述代码中,首先创建了一个 TCP 套接字并进行绑定和监听。然后使用select函数来监听服务器套接字和已连接客户端套接字的可读事件。当有新连接到达时,接受连接并将新的套接字添加到read_fds集合中。当客户端有数据可读时,读取数据并回显给客户端。如果客户端关闭连接,相应的套接字从read_fds集合中移除。

1.4 select 的局限性

  • 文件描述符数量限制:在传统的实现中,fd_set的大小是固定的,这限制了可监听的文件描述符数量。虽然可以通过修改系统参数来增加这个限制,但这不是一个优雅的解决方案。
  • 线性扫描select在返回后需要线性扫描所有的文件描述符来确定哪些发生了事件,这在文件描述符数量较多时效率较低。
  • 数据拷贝:每次调用select时,需要将文件描述符集合从用户空间拷贝到内核空间,返回时又要从内核空间拷贝回用户空间,这增加了系统开销。

二、poll 编程接口与使用方法

2.1 poll 函数原型

poll函数也是 Unix 系统中用于多路复用 I/O 的函数,其函数原型如下:

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:一个指向struct pollfd数组的指针,该数组包含了要监视的文件描述符及其相关事件。
  • nfdsfds数组中的元素个数。
  • timeout:等待事件发生的最长时间,单位为毫秒。如果为-1,则poll会一直阻塞,直到有事件发生;如果为0,则poll会立即返回,不等待任何事件。

2.2 pollfd 数据结构

struct pollfd数据结构定义如下:

struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 等待的事件 */
    short revents;  /* 发生的事件 */
};
  • fd:要监视的文件描述符。如果fd-1,则该pollfd结构将被忽略。
  • events:指定要监视的事件,常用的事件有POLLIN(可读)、POLLOUT(可写)、POLLERR(错误)等。
  • revents:返回实际发生的事件,其值是events中指定事件的子集。

2.3 代码示例

下面是一个使用poll实现的服务器端代码示例,功能与上述select示例类似:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.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};

    // 创建套接字
    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, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

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

    for (int i = 1; i <= MAX_CLIENTS; i++) {
        fds[i].fd = -1;
    }

    int activity;
    while (1) {
        activity = poll(fds, nfds, -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");
                    continue;
                }

                for (int i = 1; i <= MAX_CLIENTS; i++) {
                    if (fds[i].fd == -1) {
                        fds[i].fd = new_socket;
                        fds[i].events = POLLIN;
                        nfds++;
                        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 (int i = 1; i < nfds; i++) {
                if (fds[i].revents & (POLLIN | POLLERR)) {
                    valread = read(fds[i].fd, buffer, 1024);
                    if (valread == 0) {
                        struct sockaddr_in client_addr;
                        socklen_t client_addrlen = sizeof(client_addr);
                        getpeername(fds[i].fd, (struct sockaddr *)&client_addr, &client_addrlen);
                        printf("Host disconnected, ip %s, port %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                        close(fds[i].fd);
                        fds[i].fd = -1;
                        nfds--;
                    } else {
                        buffer[valread] = '\0';
                        send(fds[i].fd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }
    return 0;
}

在这段代码中,首先创建并设置服务器套接字。然后使用poll来监听服务器套接字和已连接客户端套接字的事件。当有新连接到达时,将新的套接字添加到fds数组中并设置要监听的事件。当客户端有数据可读或发生错误时,进行相应的处理。

2.4 poll 的优势与不足

  • 优势
    • 没有文件描述符数量限制poll通过nfds参数指定要监视的文件描述符数组的大小,理论上没有像select那样固定的文件描述符数量限制。
    • 更灵活的事件定义poll使用pollfd结构中的eventsrevents来定义和返回事件,比select中使用fd_set更为灵活。
  • 不足
    • 线性扫描:与select类似,poll在返回后仍需要线性扫描所有的pollfd结构来确定哪些文件描述符发生了事件,在文件描述符数量较多时效率不高。
    • 数据拷贝:每次调用poll时,同样需要将pollfd数组从用户空间拷贝到内核空间,返回时又要从内核空间拷贝回用户空间,存在一定的系统开销。

三、epoll 编程接口与使用方法

3.1 epoll 概述

epoll是 Linux 内核为处理大规模并发连接而设计的高性能 I/O 多路复用技术。它采用事件驱动的方式,克服了selectpoll在处理大量文件描述符时的性能瓶颈。

3.2 epoll 相关函数

  • epoll_create(int size):创建一个epoll实例,size参数在内核 2.6.8 之后已被忽略,但仍需提供一个大于 0 的值。该函数返回一个epoll文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
  • epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):用于控制epoll实例,向其中添加、修改或删除要监视的文件描述符及其相关事件。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfdepoll_create返回的epoll文件描述符。
  • op:操作类型,常用的有EPOLL_CTL_ADD(添加文件描述符)、EPOLL_CTL_MOD(修改文件描述符的事件)、EPOLL_CTL_DEL(删除文件描述符)。
  • fd:要操作的文件描述符。
  • event:指向struct epoll_event结构的指针,用于指定要监视的事件。
  • epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):等待epoll实例上的事件发生。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfdepoll_create返回的epoll文件描述符。
  • events:一个struct epoll_event类型的数组,用于存储发生的事件。
  • maxeventsevents数组的大小。
  • timeout:等待事件发生的最长时间,单位为毫秒。如果为-1,则epoll_wait会一直阻塞,直到有事件发生;如果为0,则epoll_wait会立即返回,不等待任何事件。

3.3 epoll_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;
  • events:指定要监视的事件,常用的事件有EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLERR(错误)等,还可以使用一些标志位如EPOLLET(边缘触发模式)。
  • data:可以是一个指向用户自定义数据的指针,也可以是一个文件描述符等。

3.4 代码示例

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

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

#define PORT 8080
#define MAX_CLIENTS 100
#define EVENTS_MAX 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};

    // 创建套接字
    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, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    int epoll_fd = epoll_create(1);
    if (epoll_fd == -1) {
        perror("epoll_create");
        exit(EXIT_FAILURE);
    }

    struct epoll_event event;
    event.data.fd = server_fd;
    event.events = EPOLLIN;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
        perror("epoll_ctl: server_fd");
        exit(EXIT_FAILURE);
    }

    struct epoll_event events[EVENTS_MAX];
    while (1) {
        int n = epoll_wait(epoll_fd, events, EVENTS_MAX, -1);
        if (n == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == server_fd) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) == -1) {
                    perror("accept");
                    continue;
                }

                event.data.fd = new_socket;
                event.events = EPOLLIN | EPOLLET;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
                    perror("epoll_ctl: new_socket");
                    close(new_socket);
                }
                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            } else {
                int client_fd = events[i].data.fd;
                valread = read(client_fd, buffer, 1024);
                if (valread == 0) {
                    struct sockaddr_in client_addr;
                    socklen_t client_addrlen = sizeof(client_addr);
                    getpeername(client_fd, (struct sockaddr *)&client_addr, &client_addrlen);
                    printf("Host disconnected, ip %s, port %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                    close(client_fd);
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL) == -1) {
                        perror("epoll_ctl: del client_fd");
                    }
                } else {
                    buffer[valread] = '\0';
                    send(client_fd, buffer, strlen(buffer), 0);
                }
            }
        }
    }
    close(epoll_fd);
    return 0;
}

在上述代码中,首先创建服务器套接字并进行绑定和监听。然后使用epoll_create创建一个epoll实例,并通过epoll_ctl将服务器套接字添加到epoll实例中,监听其可读事件。在主循环中,使用epoll_wait等待事件发生。当有新连接到达时,接受连接并将新的套接字添加到epoll实例中,设置为边缘触发模式。当客户端有数据可读时,读取数据并回显给客户端。如果客户端关闭连接,则从epoll实例中删除相应的套接字。

3.5 epoll 的工作模式

  • 水平触发(LT, Level Triggered):这是epoll的默认工作模式。在这种模式下,当一个文件描述符上有未处理的事件时,epoll_wait会一直返回该文件描述符,直到该事件被处理。
  • 边缘触发(ET, Edge Triggered):在边缘触发模式下,epoll_wait只有在文件描述符状态发生变化时才会返回。这意味着应用程序需要在一次事件通知中尽可能多地处理数据,否则可能会丢失后续的数据。边缘触发模式通常能提供更高的性能,但编程复杂度也相对较高。

3.6 epoll 的优势

  • 事件驱动epoll采用事件驱动的方式,只有在文件描述符上有事件发生时才会通知应用程序,避免了线性扫描所有文件描述符的开销,适用于处理大量并发连接。
  • 内核缓存epoll在内核中维护一个事件表,避免了每次调用时将文件描述符集合从用户空间拷贝到内核空间的开销。
  • 支持边缘触发模式:边缘触发模式可以减少不必要的系统调用,提高 I/O 效率,特别适合高并发、低延迟的应用场景。

通过对selectpollepoll的编程接口与使用方法的详细介绍,我们可以根据不同的应用场景选择合适的 I/O 多路复用技术,以提高程序的性能和可扩展性。在处理少量连接时,selectpoll可能已经足够;而在处理大规模并发连接时,epoll则是更好的选择。