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

深入理解非阻塞Socket编程中的轮询机制

2021-08-157.5k 阅读

非阻塞Socket编程基础

在网络编程中,Socket 是一种常用的通信机制,用于在不同的进程或主机之间进行数据传输。传统的阻塞式 Socket 编程中,当执行诸如 recvsend 等操作时,如果数据不可用,程序会一直阻塞等待,直到数据可用或出现错误。这在需要同时处理多个连接或大量 I/O 操作的场景下效率较低。

而非阻塞 Socket 则不同,当执行 I/O 操作时,如果数据不可用,函数会立即返回,返回值通常表示操作是否成功或者是否需要重试。这样,程序可以在等待数据的同时去执行其他任务,从而提高了程序的并发处理能力。

要将一个 Socket 设置为非阻塞模式,在 Unix/Linux 系统中,可以使用 fcntl 函数:

#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>

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

在 Windows 系统中,可以使用 ioctlsocket 函数:

#include <winsock2.h>

SOCKET sockfd = socket(AF_INET, SOCK_STREAM, 0);
u_long mode = 1;
ioctlsocket(sockfd, FIONBIO, &mode);

轮询机制的引入

虽然非阻塞 Socket 解决了阻塞等待的问题,但我们又面临新的问题:如何知道何时数据已经准备好可以进行读取或写入呢?这就是轮询机制发挥作用的地方。

轮询机制的基本思想是定期检查一组 Socket 的状态,看哪些 Socket 已经准备好进行 I/O 操作。通过这种方式,程序可以在不阻塞的情况下高效地管理多个 Socket。

常见的轮询机制

select

select 是一种较为古老但广泛支持的轮询机制。它通过监控一组文件描述符(在网络编程中即 Socket),当其中任何一个文件描述符准备好进行读、写或错误处理时,select 函数返回。

select 函数的原型如下:

#include <sys/select.h>

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

其中,nfds 是需要检查的文件描述符的最大值加 1;readfdswritefdsexceptfds 分别是用于检查可读、可写和异常状态的文件描述符集合;timeout 用于设置等待的超时时间。

示例代码如下:

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

int main() {
    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;

    // 创建 socket 文件描述符
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置 socket 选项
    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);

    // 绑定 socket 到指定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听 socket
    if (listen(server_fd, MAX_CLIENTS) < 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");
                    continue;
                }

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

    return 0;
}

select 的优点是跨平台支持较好,几乎所有操作系统都提供了实现。然而,它也有一些缺点。首先,select 支持的文件描述符数量有限,通常在 1024 左右。其次,每次调用 select 时都需要将文件描述符集合从用户空间复制到内核空间,并且返回后还需要遍历整个集合来检查哪些文件描述符准备好了,这在处理大量文件描述符时效率较低。

poll

poll 是另一种轮询机制,它在一定程度上改进了 select 的一些缺点。poll 函数的原型如下:

#include <poll.h>

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

pollfd 结构体定义如下:

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

events 字段可以设置为 POLLIN(可读)、POLLOUT(可写)等事件。revents 字段在函数返回时填充实际发生的事件。

示例代码如下:

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

int main() {
    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;

    // 创建 socket 文件描述符
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置 socket 选项
    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);

    // 绑定 socket 到指定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听 socket
    if (listen(server_fd, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

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

    while (1) {
        activity = poll(fds, MAX_CLIENTS + 1, -1);

        if (activity < 0) {
            printf("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 (i = 1; i <= MAX_CLIENTS; i++) {
                    if (fds[i].fd < 0) {
                        fds[i].fd = new_socket;
                        fds[i].events = POLLIN;
                        break;
                    }
                }
            }

            for (i = 1; i <= MAX_CLIENTS; i++) {
                if (fds[i].fd > 0 && (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;
                    } else {
                        buffer[valread] = '\0';
                        send(fds[i].fd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    return 0;
}

poll 相比 select 的优点在于它没有文件描述符数量的限制,并且在检查就绪的文件描述符时效率更高,因为它通过 revents 字段直接告诉应用程序哪些事件发生了,而不需要像 select 那样遍历整个集合。然而,poll 仍然需要将数据从用户空间复制到内核空间,在处理大量文件描述符时性能还是会受到一定影响。

epoll

epoll 是 Linux 特有的一种高性能的轮询机制,它采用事件驱动的方式,大大提高了处理大量并发连接的效率。epoll 有两种工作模式:水平触发(LT, Level Triggered)和边缘触发(ET, Edge Triggered)。

epoll 使用三个主要的函数:epoll_createepoll_ctlepoll_wait

epoll_create 函数用于创建一个 epoll 实例:

#include <sys/epoll.h>

int epoll_create(int size);

size 参数在 Linux 2.6.8 之后被忽略,但仍然需要提供一个大于 0 的值。

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;/* 与事件关联的数据 */
};

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

events 字段可以设置为 EPOLLIN(可读)、EPOLLOUT(可写)等事件。

epoll_wait 函数用于等待事件的发生:

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

epfdepoll 实例的文件描述符;events 是一个数组,用于存储发生的事件;maxeventsevents 数组的大小;timeout 是等待的超时时间。

示例代码如下:

#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 1024
#define EVENTS_MAX 64

int main() {
    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 ev, events[EVENTS_MAX];

    // 创建 socket 文件描述符
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置 socket 选项
    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);

    // 绑定 socket 到指定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听 socket
    if (listen(server_fd, MAX_CLIENTS) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

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

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

    while (1) {
        nfds = epoll_wait(epollfd, events, EVENTS_MAX, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            exit(EXIT_FAILURE);
        }

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

                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = new_socket;
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_socket, &ev) == -1) {
                    perror("epoll_ctl: new_socket");
                    close(new_socket);
                }
            } else {
                int client_fd = events[n].data.fd;
                valread = read(client_fd, buffer, 1024);
                if (valread == 0) {
                    getpeername(client_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(client_fd);
                    if (epoll_ctl(epollfd, 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(server_fd);
    close(epollfd);
    return 0;
}

在水平触发模式下,只要文件描述符上还有未处理的数据,epoll_wait 就会不断返回该文件描述符。而在边缘触发模式下,只有当文件描述符状态发生变化时(例如有新数据到达),epoll_wait 才会返回。边缘触发模式通常可以减少不必要的系统调用,提高性能,但编程实现相对复杂,需要应用程序在一次事件通知中尽可能多地处理数据,直到 readwrite 返回 EAGAIN 错误。

epoll 的优点在于它使用事件驱动,不需要每次都将所有文件描述符从用户空间复制到内核空间,并且在处理大量并发连接时性能出色。这使得它成为处理高并发网络应用的首选轮询机制。

轮询机制的性能对比

在实际应用中,选择合适的轮询机制对于系统性能至关重要。下面我们从几个方面对 selectpollepoll 进行性能对比。

文件描述符数量限制

select 通常有文件描述符数量的限制,一般在 1024 左右,这在处理大量并发连接时会成为瓶颈。poll 理论上没有文件描述符数量的限制,而 epoll 同样不受此限制,并且在处理大量文件描述符时性能更优。

数据复制开销

selectpoll 在每次调用时都需要将文件描述符集合从用户空间复制到内核空间,这在处理大量文件描述符时会带来较大的开销。而 epoll 采用事件驱动的方式,只在添加或修改文件描述符时进行少量的数据复制,大大减少了这种开销。

事件检查效率

select 在返回后需要遍历整个文件描述符集合来检查哪些文件描述符准备好了,效率较低。poll 通过 revents 字段可以直接得知哪些事件发生了,效率有所提高。epoll 则在这方面表现最佳,它采用事件驱动,直接返回发生事件的文件描述符,在处理大量并发连接时性能优势明显。

应用场景

对于并发连接数较少且对跨平台性要求较高的应用,select 可能是一个简单的选择。如果需要处理较多的并发连接,且在类 Unix 系统上运行,poll 可以提供比 select 更好的性能。而对于高性能的高并发网络应用,尤其是在 Linux 平台上,epoll 无疑是最佳选择。

轮询机制在实际项目中的应用

在实际的后端开发项目中,轮询机制广泛应用于各种网络服务,如 Web 服务器、即时通讯服务器等。

以 Web 服务器为例,它需要同时处理大量客户端的连接请求,包括读取 HTTP 请求、处理业务逻辑并返回 HTTP 响应。通过使用非阻塞 Socket 结合轮询机制,Web 服务器可以高效地处理这些并发请求,提高系统的吞吐量和响应速度。

在即时通讯服务器中,需要实时处理大量用户的消息收发。非阻塞 Socket 编程和轮询机制可以确保服务器在不阻塞的情况下及时处理每个用户的消息,实现高效的实时通讯功能。

总结轮询机制的要点

在非阻塞 Socket 编程中,轮询机制是实现高效并发处理的关键。selectpollepoll 是常见的轮询机制,它们各有优缺点和适用场景。

select 虽然古老且有一定局限性,但跨平台性好,适用于简单的低并发场景。poll 在一定程度上改进了 select 的缺点,支持更多的文件描述符且检查就绪事件效率更高。而 epoll 作为 Linux 特有的高性能轮询机制,在处理大量并发连接时表现卓越,是高并发网络应用的首选。

在实际项目中,需要根据具体的需求和运行环境,合理选择轮询机制,以实现最优的性能和资源利用。同时,对于边缘触发模式等高级特性的正确使用,也可以进一步提升系统的性能和效率。通过深入理解和熟练运用轮询机制,后端开发人员可以构建出更加高效、稳定的网络应用。

希望通过本文的介绍,读者对非阻塞 Socket 编程中的轮询机制有了更深入的理解,能够在实际项目中灵活运用这些知识,提升自己的开发能力。