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

select、poll、epoll的内存占用与效率对比

2021-12-253.8k 阅读

一、概述

在后端开发的网络编程中,selectpollepoll是三种重要的I/O多路复用机制,它们允许程序同时监控多个文件描述符(如套接字)的状态变化,以实现高效的并发处理。在实际应用中,内存占用和效率是评估这些机制的关键指标。了解它们在这两方面的特性,对于优化网络应用程序的性能至关重要。

二、select

2.1 select原理

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);

select通过轮询方式检查readfdswritefdsexceptfds这三个描述符集合中是否有文件描述符准备好进行相应的I/O操作(读、写或异常)。nfds是需要检查的文件描述符集合中最大文件描述符加1。timeout参数用于设置等待的超时时间。

2.2 内存占用

select的内存占用主要体现在它所使用的文件描述符集合。在Linux系统中,fd_set是一个固定大小的数组,通常表示为一个unsigned long类型的数组。其大小在不同系统中可能有所不同,但一般限制了可监控的文件描述符数量,常见的上限为1024。这意味着,当需要监控大量文件描述符时,select可能无法满足需求,并且其内存占用相对固定,与实际监控的文件描述符数量无关。

2.3 效率

select的效率较低,主要原因在于:

  1. 轮询方式:每次调用select时,内核需要遍历所有被监控的文件描述符,检查其状态。这使得时间复杂度为O(n),当监控的文件描述符数量较多时,性能会显著下降。
  2. 内核态与用户态数据拷贝select需要将用户态的文件描述符集合拷贝到内核态,在返回时又将结果从内核态拷贝回用户态,这增加了额外的开销。
  3. 文件描述符限制:由于fd_set的大小限制,无法处理大量的文件描述符,进一步限制了其在高并发场景下的应用。

2.4 代码示例

下面是一个简单的使用select的TCP服务器代码示例:

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

#define PORT 8080
#define MAX_CLIENTS 1024

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 < MAX_CLIENTS; i++) {
                val = i;
                if (FD_ISSET(val, &tmp_fds)) {
                    if ((valread = read(val, buffer, 1024)) == 0) {
                        // 连接关闭
                        getpeername(val, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
                        close(val);
                        FD_CLR(val, &read_fds);
                    } else {
                        buffer[valread] = '\0';
                        send(val, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    return 0;
}

三、poll

3.1 poll原理

poll函数的原型如下:

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll通过一个struct pollfd类型的数组来监控文件描述符。struct pollfd结构体定义如下:

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

poll会遍历这个数组,检查每个文件描述符的状态。与select不同,poll没有文件描述符数量的硬限制,通过调整数组大小可以监控更多的文件描述符。

3.2 内存占用

poll的内存占用主要取决于struct pollfd数组的大小。由于每个struct pollfd结构体包含文件描述符、等待事件和发生事件等信息,随着监控的文件描述符数量增加,内存占用也会线性增长。但相对select来说,它更灵活,不受固定大小的fd_set限制。

3.3 效率

poll在效率上相对于select有一定提升:

  1. 无文件描述符数量限制poll没有像select那样的文件描述符数量硬限制,理论上可以监控更多的文件描述符,适用于更高并发的场景。
  2. 轮询方式改进:虽然仍然是轮询,但poll在实现上避免了select中一些固定大小数组带来的限制,减少了不必要的开销。不过,其时间复杂度仍然是O(n),在高并发场景下,随着文件描述符数量的增加,性能仍会受到影响。
  3. 内核态与用户态数据拷贝poll同样需要在用户态和内核态之间拷贝数据,这一点与select类似,也会带来一定的性能开销。

3.4 代码示例

下面是一个使用poll的TCP服务器代码示例:

#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 1024

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 nfds = 1;

    // 创建套接字
    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数组
    fds[0].fd = server_fd;
    fds[0].events = POLLIN;

    while (1) {
        int activity = poll(fds, nfds, -1);

        if (activity < 0) {
            perror("poll error");
            break;
        } 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数组
                fds[nfds].fd = new_socket;
                fds[nfds].events = POLLIN;
                nfds++;
                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) {
                    valread = read(fds[i].fd, buffer, 1024);
                    if (valread == 0) {
                        // 连接关闭
                        close(fds[i].fd);
                        for (int j = i; j < nfds - 1; j++) {
                            fds[j] = fds[j + 1];
                        }
                        nfds--;
                        i--;
                        printf("Host disconnected\n");
                    } else {
                        buffer[valread] = '\0';
                        send(fds[i].fd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    return 0;
}

四、epoll

4.1 epoll原理

epoll是Linux特有的I/O多路复用机制,它提供了比selectpoll更高效的方式来处理大量并发连接。epoll有两种工作模式:水平触发(LT, Level Triggered)和边缘触发(ET, Edge Triggered)。

epoll使用三个系统调用:epoll_createepoll_ctlepoll_wait

  1. epoll_create:用于创建一个epoll实例,返回一个epoll文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
  1. epoll_ctl:用于控制epoll实例,添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

其中,op可以是EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)或EPOLL_CTL_DEL(删除)。event结构体定义了要监控的事件。 3. epoll_wait:用于等待事件发生,返回发生事件的文件描述符集合。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

4.2 内存占用

epoll的内存占用相对较为灵活。epoll_create创建的epoll实例占用一定的内核内存,用于存储监控的文件描述符和相关事件信息。随着添加的文件描述符数量增加,内存占用会相应增长,但它采用了更高效的数据结构(红黑树)来管理文件描述符,相比poll的线性数组结构,在内存管理上更具优势。特别是在监控大量文件描述符时,epoll的内存占用增长更为平缓。

4.3 效率

epoll在效率方面具有显著优势:

  1. 事件驱动机制epoll采用事件驱动的方式,当有文件描述符就绪时,内核会通过回调机制将其加入就绪队列。epoll_wait只需要检查就绪队列,而不需要像selectpoll那样轮询所有文件描述符,时间复杂度为O(1),大大提高了在高并发场景下的效率。
  2. 内核态与用户态数据拷贝优化epoll在内核态和用户态之间的数据拷贝采用了内存映射(mmap)技术,减少了数据拷贝的次数,进一步提高了性能。
  3. 支持边缘触发模式:边缘触发模式下,只有当文件描述符状态发生变化时才会触发事件,这在一些高性能场景下可以减少不必要的系统调用,提高效率。但边缘触发模式需要开发者更加小心地处理数据,以避免数据丢失。

4.4 代码示例

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

#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_EVENTS 1024

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 epoll_fd, events_num;
    struct epoll_event events[MAX_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实例
    epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }

    // 将服务器套接字添加到epoll实例
    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);
    }

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

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

                // 将新连接的套接字添加到epoll实例
                event.data.fd = new_socket;
                event.events = EPOLLIN;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
                    perror("epoll_ctl: 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) {
                    // 连接关闭
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL) == -1) {
                        perror("epoll_ctl: del client_fd");
                    }
                    close(client_fd);
                    printf("Host disconnected\n");
                } else {
                    buffer[valread] = '\0';
                    send(client_fd, buffer, strlen(buffer), 0);
                }
            }
        }
    }

    return 0;
}

五、内存占用对比总结

  1. select:内存占用相对固定,受限于fd_set的大小,一般为1024,不适用于监控大量文件描述符的场景。
  2. poll:内存占用随监控的文件描述符数量线性增长,通过调整struct pollfd数组大小可以监控更多文件描述符,但在大量文件描述符场景下内存开销较大。
  3. epoll:内存占用相对灵活,采用红黑树结构管理文件描述符,在监控大量文件描述符时内存增长更为平缓,内存管理效率较高。

六、效率对比总结

  1. select:效率较低,时间复杂度为O(n),轮询所有文件描述符,且存在内核态与用户态数据拷贝开销,文件描述符数量有限制,不适合高并发场景。
  2. poll:相对于select有一定提升,无文件描述符数量硬限制,但仍然是轮询方式,时间复杂度为O(n),在高并发场景下性能仍会受到影响。
  3. epoll:效率显著高于selectpoll,采用事件驱动机制,时间复杂度为O(1),并且优化了内核态与用户态数据拷贝,支持边缘触发模式,非常适合高并发网络编程场景。

在实际的后端开发网络编程中,应根据具体的应用场景和需求来选择合适的I/O多路复用机制。如果是低并发、文件描述符数量较少的场景,selectpoll可能已经足够;而对于高并发、大量文件描述符的场景,epoll无疑是更好的选择。