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

IO多路复用技术在网络编程中的最佳实践

2021-05-213.6k 阅读

IO 多路复用技术概述

在深入探讨 IO 多路复用技术在网络编程中的最佳实践之前,我们先来理解一下 IO 多路复用技术的基本概念。在网络编程中,一个服务器往往需要处理多个客户端的连接请求,传统的方法是为每个客户端连接创建一个新的进程或线程来处理。然而,这种方式在高并发场景下会带来巨大的资源开销,因为每个进程或线程都需要占用一定的系统资源,如内存、文件描述符等。

IO 多路复用技术的出现就是为了解决这个问题。它允许一个进程同时监听多个文件描述符(例如套接字)的事件,当其中任何一个文件描述符准备好进行读写操作时,系统会通知进程,进程就可以对相应的文件描述符进行操作。这样,一个进程就可以高效地处理多个客户端连接,大大提高了系统的并发处理能力。

常见的 IO 多路复用机制

在不同的操作系统中,有多种实现 IO 多路复用的机制,下面我们来介绍几种常见的机制。

  1. select:select 是最早的 IO 多路复用机制,它在几乎所有的操作系统上都有实现。select 函数通过一个文件描述符集合来监听多个文件描述符,当集合中的任何一个文件描述符准备好时,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);

其中,nfds 是需要监听的文件描述符集合中的最大文件描述符加 1;readfds、writefds 和 exceptfds 分别是用于监听读、写和异常事件的文件描述符集合;timeout 是一个时间结构体,用于设置 select 函数的超时时间。如果 timeout 为 NULL,select 函数将一直阻塞,直到有文件描述符准备好或发生错误。

  1. poll:poll 是 select 的改进版本,它在功能上与 select 类似,但在实现上有所不同。poll 使用一个 pollfd 结构体数组来表示需要监听的文件描述符及其事件,而不是像 select 那样使用文件描述符集合。poll 函数的原型如下:
#include <poll.h>

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

其中,fds 是一个 pollfd 结构体数组,nfds 是数组中元素的个数,timeout 是超时时间,单位为毫秒。如果 timeout 为 -1,poll 函数将一直阻塞,直到有文件描述符准备好或发生错误。

  1. epoll:epoll 是 Linux 特有的 IO 多路复用机制,它在高并发场景下表现得尤为出色。epoll 采用事件驱动的方式,当有文件描述符准备好时,系统会主动通知进程,而不像 select 和 poll 那样需要进程轮询检查所有的文件描述符。epoll 有两种工作模式:LT(水平触发)和 ET(边缘触发)。在 LT 模式下,只要文件描述符上还有未处理的数据,epoll_wait 就会一直返回;在 ET 模式下,只有当文件描述符上有新的数据到达或状态发生改变时,epoll_wait 才会返回。epoll 相关的函数主要有以下几个:
#include <sys/epoll.h>

// 创建一个 epoll 实例
int epoll_create(int size);

// 向 epoll 实例中添加、修改或删除文件描述符及其事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 等待 epoll 实例中的文件描述符准备好
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

其中,epoll_create 函数创建一个 epoll 实例,返回一个文件描述符 epfd;epoll_ctl 函数用于向 epoll 实例中添加、修改或删除文件描述符及其事件,op 参数指定操作类型(EPOLL_CTL_ADD、EPOLL_CTL_MOD 或 EPOLL_CTL_DEL),fd 是要操作的文件描述符,event 是一个 epoll_event 结构体,用于指定事件类型和数据;epoll_wait 函数等待 epoll 实例中的文件描述符准备好,events 是一个 epoll_event 结构体数组,用于存储准备好的文件描述符及其事件,maxevents 是数组的大小,timeout 是超时时间,单位为毫秒。如果 timeout 为 -1,epoll_wait 函数将一直阻塞,直到有文件描述符准备好或发生错误。

IO 多路复用技术在网络编程中的应用场景

高并发网络服务器

在高并发的网络服务器场景中,如 Web 服务器、游戏服务器等,服务器需要同时处理大量的客户端连接。使用 IO 多路复用技术可以使服务器在一个进程或线程中高效地管理这些连接,避免为每个连接创建单独的进程或线程带来的资源开销。例如,一个 Web 服务器可能同时接收成千上万个用户的 HTTP 请求,通过 IO 多路复用技术,服务器可以监听这些连接的读事件,当有请求数据到达时,及时处理请求并返回响应。

实时通信系统

在实时通信系统中,如即时通讯软件、在线游戏等,需要实时处理客户端的消息发送和接收。IO 多路复用技术可以帮助服务器同时监听多个客户端连接的读写事件,当有消息发送或接收时,及时进行处理,保证通信的实时性。例如,在一个即时通讯服务器中,服务器需要同时接收多个用户发送的聊天消息,并将消息转发给相应的接收者。通过 IO 多路复用技术,服务器可以高效地管理这些连接,确保消息能够及时传递。

网络爬虫

网络爬虫需要同时处理多个网页的下载和解析任务。使用 IO 多路复用技术,爬虫可以同时发起多个 HTTP 请求,并监听这些请求的响应事件。当有响应数据到达时,及时处理响应数据,提取所需的信息。这样可以大大提高爬虫的效率,加快数据采集的速度。

基于 select 的网络编程示例

下面我们通过一个简单的示例来演示如何使用 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 BUF_SIZE 1024
#define PORT 9999

int main() {
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size;
    char buf[BUF_SIZE];

    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket() error");
        exit(1);
    }

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);

    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind() error");
        close(serv_sock);
        exit(1);
    }

    if (listen(serv_sock, 5) == -1) {
        perror("listen() error");
        close(serv_sock);
        exit(1);
    }

    fd_set read_fds, tmp_fds;
    FD_ZERO(&read_fds);
    FD_SET(serv_sock, &read_fds);
    int fd_max = serv_sock;

    while (1) {
        tmp_fds = read_fds;
        int fd_num = select(fd_max + 1, &tmp_fds, NULL, NULL, NULL);
        if (fd_num == -1) {
            perror("select() error");
            break;
        } else if (fd_num == 0) {
            continue;
        } else {
            for (int i = 0; i <= fd_max; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    if (i == serv_sock) {
                        clnt_addr_size = sizeof(clnt_addr);
                        clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
                        FD_SET(clnt_sock, &read_fds);
                        if (fd_max < clnt_sock) {
                            fd_max = clnt_sock;
                        }
                        printf("new client connected: %d\n", clnt_sock);
                    } else {
                        int str_len = read(i, buf, BUF_SIZE - 1);
                        if (str_len == 0) {
                            FD_CLR(i, &read_fds);
                            close(i);
                            printf("client disconnected: %d\n", i);
                        } else {
                            buf[str_len] = '\0';
                            printf("received from %d: %s\n", i, buf);
                            write(i, buf, str_len);
                        }
                    }
                }
            }
        }
    }
    close(serv_sock);
    return 0;
}

在这个示例中,我们首先创建了一个 TCP 套接字,并绑定到指定的端口。然后,我们使用 select 函数来监听套接字的读事件。在每次循环中,我们将 read_fds 复制到 tmp_fds 中,并调用 select 函数。如果 select 函数返回,我们检查每个文件描述符是否在 tmp_fds 中,如果是,则根据文件描述符是服务器套接字还是客户端套接字进行相应的处理。如果是服务器套接字,我们接受新的客户端连接,并将新的客户端套接字添加到 read_fds 中;如果是客户端套接字,我们读取客户端发送的数据,并将数据回显给客户端。如果客户端关闭连接,我们从 read_fds 中移除相应的文件描述符,并关闭套接字。

基于 poll 的网络编程示例

接下来,我们看一个使用 poll 实现的网络服务器示例。这个示例与基于 select 的示例功能类似,但使用了 poll 机制。

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

#define BUF_SIZE 1024
#define PORT 9999
#define MAX_CLNT 256

int main() {
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size;
    char buf[BUF_SIZE];

    struct pollfd fds[MAX_CLNT + 1];
    int fd_num, str_len;

    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket() error");
        exit(1);
    }

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);

    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind() error");
        close(serv_sock);
        exit(1);
    }

    if (listen(serv_sock, 5) == -1) {
        perror("listen() error");
        close(serv_sock);
        exit(1);
    }

    fds[0].fd = serv_sock;
    fds[0].events = POLLRDNORM;
    for (int i = 1; i <= MAX_CLNT; i++) {
        fds[i].fd = -1;
    }
    int fd_max = 0;

    while (1) {
        fd_num = poll(fds, fd_max + 1, -1);
        if (fd_num == -1) {
            perror("poll() error");
            break;
        } else if (fd_num == 0) {
            continue;
        } else {
            if (fds[0].revents & POLLRDNORM) {
                clnt_addr_size = sizeof(clnt_addr);
                clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
                for (int i = 1; i <= MAX_CLNT; i++) {
                    if (fds[i].fd == -1) {
                        fds[i].fd = clnt_sock;
                        fds[i].events = POLLRDNORM;
                        if (fd_max < i) {
                            fd_max = i;
                        }
                        break;
                    }
                }
                if (fd_num == 1) {
                    continue;
                }
            }
            for (int i = 1; i <= fd_max; i++) {
                if (fds[i].fd != -1 && (fds[i].revents & (POLLRDNORM | POLLERR))) {
                    str_len = read(fds[i].fd, buf, BUF_SIZE - 1);
                    if (str_len <= 0) {
                        if (str_len == 0) {
                            printf("client disconnected: %d\n", fds[i].fd);
                        } else {
                            perror("read() error");
                        }
                        close(fds[i].fd);
                        fds[i].fd = -1;
                    } else {
                        buf[str_len] = '\0';
                        printf("received from %d: %s\n", fds[i].fd, buf);
                        write(fds[i].fd, buf, str_len);
                    }
                }
            }
        }
    }
    close(serv_sock);
    return 0;
}

在这个示例中,我们使用 pollfd 结构体数组来管理需要监听的文件描述符及其事件。在初始化时,我们将服务器套接字添加到 fds 数组的第一个元素中,并设置其事件为 POLLRDNORM(表示正常读事件)。然后,我们初始化数组的其他元素的 fd 为 -1,表示尚未使用。在每次循环中,我们调用 poll 函数等待文件描述符准备好。如果 poll 函数返回,我们首先检查服务器套接字是否有读事件,如果有,则接受新的客户端连接,并将新的客户端套接字添加到 fds 数组中。然后,我们检查其他客户端套接字是否有读事件或错误事件,如果有,则进行相应的处理,如读取数据、回显数据或关闭套接字。

基于 epoll 的网络编程示例

最后,我们来看一个使用 epoll 实现的高性能网络服务器示例。这个示例将展示如何利用 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 BUF_SIZE 1024
#define PORT 9999
#define EPOLL_SIZE 50

int main() {
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size;
    char buf[BUF_SIZE];

    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket() error");
        exit(1);
    }

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);

    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind() error");
        close(serv_sock);
        exit(1);
    }

    if (listen(serv_sock, 5) == -1) {
        perror("listen() error");
        close(serv_sock);
        exit(1);
    }

    int epfd = epoll_create(EPOLL_SIZE);
    struct epoll_event *ep_events;
    struct epoll_event event;
    ep_events = (struct epoll_event *)malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

    event.events = EPOLLIN;
    event.data.fd = serv_sock;
    epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

    while (1) {
        int event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
        if (event_cnt == -1) {
            perror("epoll_wait() error");
            break;
        }
        for (int i = 0; i < event_cnt; i++) {
            if (ep_events[i].data.fd == serv_sock) {
                clnt_addr_size = sizeof(clnt_addr);
                clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
                event.events = EPOLLIN;
                event.data.fd = clnt_sock;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
                printf("new client connected: %d\n", clnt_sock);
            } else {
                int fd = ep_events[i].data.fd;
                int str_len = read(fd, buf, BUF_SIZE - 1);
                if (str_len == 0) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                    printf("client disconnected: %d\n", fd);
                } else {
                    buf[str_len] = '\0';
                    printf("received from %d: %s\n", fd, buf);
                    write(fd, buf, str_len);
                }
            }
        }
    }
    close(serv_sock);
    close(epfd);
    free(ep_events);
    return 0;
}

在这个示例中,我们首先创建了一个 epoll 实例,并将服务器套接字添加到 epoll 实例中,设置其事件为 EPOLLIN(表示读事件)。在每次循环中,我们调用 epoll_wait 函数等待文件描述符准备好。如果有文件描述符准备好,我们检查是服务器套接字还是客户端套接字。如果是服务器套接字,我们接受新的客户端连接,并将新的客户端套接字添加到 epoll 实例中;如果是客户端套接字,我们读取客户端发送的数据,并将数据回显给客户端。如果客户端关闭连接,我们从 epoll 实例中移除相应的文件描述符,并关闭套接字。

不同 IO 多路复用机制的性能比较

在实际应用中,选择合适的 IO 多路复用机制对于系统的性能至关重要。下面我们来分析一下 select、poll 和 epoll 这三种机制在性能方面的特点。

  1. select:select 的优点是跨平台性好,几乎所有的操作系统都支持。但其缺点也很明显,首先,select 支持的文件描述符数量有限,通常在 1024 左右;其次,select 使用线性搜索的方式检查文件描述符集合,当文件描述符数量较多时,性能会急剧下降;最后,select 在每次调用时都需要将文件描述符集合从用户空间复制到内核空间,这也会带来一定的性能开销。
  2. poll:poll 在功能上与 select 类似,但它解决了 select 支持文件描述符数量有限的问题,理论上可以支持无限个文件描述符。然而,poll 同样使用线性搜索的方式检查文件描述符,在高并发场景下性能仍然不理想。此外,poll 每次调用也需要将文件描述符信息从用户空间复制到内核空间。
  3. epoll:epoll 是 Linux 特有的机制,在高并发场景下表现出色。epoll 采用事件驱动的方式,只有当文件描述符准备好时才会通知进程,避免了轮询带来的性能开销。而且,epoll 在创建实例时会在内核中开辟一块空间来存储文件描述符及其事件,不需要每次调用都进行数据复制。因此,epoll 在处理大量文件描述符时性能远远优于 select 和 poll。

综上所述,在 Linux 平台上,如果需要处理高并发的网络连接,epoll 是最佳选择;如果需要跨平台支持,并且文件描述符数量较少,select 或 poll 也是可以考虑的。

最佳实践建议

  1. 根据场景选择合适的机制:在设计网络应用程序时,要根据具体的应用场景选择合适的 IO 多路复用机制。如果是在 Linux 平台上开发高并发的服务器,优先选择 epoll;如果需要跨平台支持,并且并发量不是特别高,可以考虑 select 或 poll。
  2. 合理设置超时时间:在使用 IO 多路复用机制时,合理设置超时时间可以避免进程长时间阻塞,提高系统的响应能力。例如,在网络爬虫中,可以设置较短的超时时间,以便及时处理下一个网页的请求;在一些对实时性要求较高的应用中,如即时通讯服务器,超时时间可以设置得稍长一些,但也不能过长,以免影响用户体验。
  3. 优化内存使用:在处理大量文件描述符时,要注意优化内存使用。例如,在使用 epoll 时,可以根据预估的最大连接数合理分配 epoll_event 结构体数组的大小,避免不必要的内存浪费。
  4. 错误处理:在网络编程中,错误处理是非常重要的。在使用 IO 多路复用机制时,要对函数调用的返回值进行仔细检查,及时处理各种错误情况,如 socket 创建失败、bind 失败、select/poll/epoll_wait 失败等,确保程序的稳定性和可靠性。

总结

IO 多路复用技术是网络编程中非常重要的一项技术,它可以大大提高系统的并发处理能力。通过深入理解 select、poll 和 epoll 等常见的 IO 多路复用机制,并根据具体的应用场景选择合适的机制,合理设置参数,优化内存使用和错误处理,我们可以开发出高性能、稳定可靠的网络应用程序。在实际开发中,还需要不断地进行测试和优化,以满足不同场景下的需求。希望通过本文的介绍和示例代码,读者能够对 IO 多路复用技术在网络编程中的最佳实践有更深入的理解和掌握。