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

Linux C语言poll机制与select对比

2021-09-264.5k 阅读

一、Linux 下 I/O 多路复用概述

在 Linux 系统编程中,I/O 多路复用是一种重要的技术,它允许程序同时监控多个文件描述符(file descriptor),当其中任何一个文件描述符准备好进行 I/O 操作时,程序就能得到通知并进行相应处理。这种机制极大地提高了程序处理多个 I/O 源的效率,避免了阻塞在单个 I/O 操作上而浪费资源。

常见的 I/O 多路复用技术有 select、poll 和 epoll。本文主要聚焦于 select 和 poll,深入探讨它们的原理、特点及在 Linux C 语言编程中的应用,并通过实际代码示例进行对比分析。

1.1 文件描述符(File Descriptor)

在 Linux 系统中,文件描述符是一个非负整数,它是内核为了管理已打开的文件所创建的数据结构的索引。当一个程序打开一个新文件或者创建一个新的套接字(socket)时,内核会返回一个文件描述符。对于标准输入(stdin),文件描述符通常为 0;标准输出(stdout)为 1;标准错误输出(stderr)为 2。对于其他打开的文件或套接字,文件描述符是从 3 开始的递增整数。

1.2 I/O 多路复用的需求背景

在传统的 I/O 编程中,如果一个程序需要处理多个 I/O 源(比如多个套接字连接),通常的做法是使用多进程或多线程。然而,创建和管理多个进程或线程需要消耗大量的系统资源,包括内存、CPU 时间等。I/O 多路复用技术提供了一种更高效的解决方案,它允许在一个进程内同时监控多个文件描述符的状态,从而减少资源消耗并提高程序的并发处理能力。

二、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。这是因为 select 会检查从 0 到 nfds - 1 的所有文件描述符。
  • readfds:指向一个文件描述符集的指针,该集合中的文件描述符用于检查是否有数据可读。
  • writefds:指向一个文件描述符集的指针,该集合中的文件描述符用于检查是否可以进行写操作。
  • exceptfds:指向一个文件描述符集的指针,该集合中的文件描述符用于检查是否有异常情况发生。
  • timeout:指向一个 struct timeval 结构体的指针,用于设置 select 等待的最长时间。如果设置为 NULL,select 将一直阻塞,直到有文件描述符准备好。struct timeval 结构体定义如下:
struct timeval {
    time_t      tv_sec;         /* seconds */
    suseconds_t tv_usec;        /* microseconds */
};

2.2 select 的工作原理

select 函数通过检查 readfdswritefdsexceptfds 这三个文件描述符集,来确定哪些文件描述符已经准备好进行相应的 I/O 操作。内核会遍历从 0 到 nfds - 1 的所有文件描述符,检查每个文件描述符的状态。如果在 timeout 时间内,有任何一个文件描述符准备好,select 函数就会返回。返回值表示准备好的文件描述符的总数。如果 timeout 时间到了,仍没有文件描述符准备好,select 函数返回 0。如果发生错误,select 函数返回 -1,并设置 errno 来指示错误类型。

2.3 文件描述符集操作函数

为了方便操作文件描述符集,Linux 提供了一组宏定义:

  • FD_ZERO(fd_set *set):清空文件描述符集 set
  • FD_SET(int fd, fd_set *set):将文件描述符 fd 添加到文件描述符集 set 中。
  • FD_CLR(int fd, fd_set *set):将文件描述符 fd 从文件描述符集 set 中移除。
  • FD_ISSET(int fd, fd_set *set):检查文件描述符 fd 是否在文件描述符集 set 中,并且该文件描述符是否准备好。

2.4 select 示例代码

下面是一个简单的使用 select 监控标准输入是否有数据可读的示例代码:

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

#define BUFFER_SIZE 1024

int main() {
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(0, &read_fds);

    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;

    char buffer[BUFFER_SIZE];
    int ret = select(1, &read_fds, NULL, NULL, &timeout);
    if (ret == -1) {
        perror("select error");
        return 1;
    } else if (ret == 0) {
        printf("Timeout, no data read\n");
    } else {
        if (FD_ISSET(0, &read_fds)) {
            ssize_t bytes_read = read(0, buffer, sizeof(buffer));
            if (bytes_read > 0) {
                buffer[bytes_read] = '\0';
                printf("Read data: %s", buffer);
            }
        }
    }
    return 0;
}

在这个示例中,我们首先初始化一个文件描述符集 read_fds,并将标准输入(文件描述符 0)添加到该集合中。然后设置一个 5 秒的超时时间,调用 select 函数等待标准输入是否有数据可读。如果 select 返回,我们通过 FD_ISSET 宏检查标准输入是否在准备好的文件描述符集中,如果是,则读取数据并打印。

三、poll 机制详解

3.1 poll 函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:指向一个 struct pollfd 结构体数组的指针,该数组包含了需要监控的文件描述符及其相关事件。struct pollfd 结构体定义如下:
struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};
  • nfdsfds 数组中元素的个数。
  • timeout:等待的最长时间,单位为毫秒。如果设置为 -1,poll 将一直阻塞,直到有文件描述符准备好。如果设置为 0,poll 会立即返回,不等待任何事件。

3.2 poll 的工作原理

poll 函数通过检查 fds 数组中每个 struct pollfd 结构体所指定的文件描述符及其相关事件来确定哪些文件描述符已经准备好。对于每个文件描述符,我们在 events 字段中指定需要监控的事件,例如 POLLIN 表示数据可读,POLLOUT 表示可以进行写操作等。当 poll 函数返回时,revents 字段会被设置为实际发生的事件。poll 函数返回值表示准备好的文件描述符的总数,如果 timeout 时间到了,仍没有文件描述符准备好,poll 函数返回 0。如果发生错误,poll 函数返回 -1,并设置 errno 来指示错误类型。

3.3 poll 事件类型

poll 支持多种事件类型,常见的有:

  • POLLIN:数据可读,包括普通数据和优先级带数据。
  • POLLOUT:可以进行写操作。
  • POLLRDHUP:TCP 连接的对方关闭连接,或者对方关闭了一半连接(半关闭)。
  • POLLERR:发生错误。
  • POLLHUP:发生挂起(hang up)事件,通常表示连接已关闭。

3.4 poll 示例代码

下面是一个使用 poll 监控标准输入是否有数据可读的示例代码:

#include <stdio.h>
#include <poll.h>
#include <unistd.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    struct pollfd fds[1];
    fds[0].fd = 0;
    fds[0].events = POLLIN;

    char buffer[BUFFER_SIZE];
    int ret = poll(fds, 1, 5000);
    if (ret == -1) {
        perror("poll error");
        return 1;
    } else if (ret == 0) {
        printf("Timeout, no data read\n");
    } else {
        if (fds[0].revents & POLLIN) {
            ssize_t bytes_read = read(0, buffer, sizeof(buffer));
            if (bytes_read > 0) {
                buffer[bytes_read] = '\0';
                printf("Read data: %s", buffer);
            }
        }
    }
    return 0;
}

在这个示例中,我们初始化一个 struct pollfd 结构体数组 fds,将标准输入(文件描述符 0)及其需要监控的事件 POLLIN 赋值给数组的第一个元素。然后设置一个 5000 毫秒(即 5 秒)的超时时间,调用 poll 函数等待标准输入是否有数据可读。如果 poll 返回,我们检查 revents 字段是否包含 POLLIN 事件,如果是,则读取数据并打印。

四、select 与 poll 的对比

4.1 数据结构的差异

  • select:使用固定大小的文件描述符集,通过 fd_set 结构体来表示。fd_set 本质上是一个位数组,其大小通常是固定的(例如在一些系统中是 1024 位),这就限制了 select 所能监控的文件描述符的数量。在使用 select 时,需要手动管理文件描述符集,通过 FD_ZEROFD_SETFD_CLRFD_ISSET 等宏来操作。
  • poll:使用 struct pollfd 结构体数组来表示需要监控的文件描述符及其事件。struct pollfd 结构体包含文件描述符 fd、请求事件 events 和返回事件 revents 三个字段。这种方式没有文件描述符数量的限制(仅受限于系统资源),并且在表示和管理监控的文件描述符和事件上更加灵活。

4.2 文件描述符数量限制

  • select:由于 fd_set 的大小限制,select 所能监控的文件描述符数量通常是有限的,一般为 1024 个。虽然有些系统可以通过修改内核参数等方式来扩大这个限制,但这并不是一种通用的做法,并且可能会带来性能问题。
  • poll:理论上没有文件描述符数量的限制,它只受限于系统资源(如内存等)。这使得 poll 在需要监控大量文件描述符的场景下更具优势。

4.3 性能差异

  • select:每次调用 select 时,内核需要遍历从 0 到 nfds - 1 的所有文件描述符,检查它们的状态。随着监控的文件描述符数量增加,遍历的开销会线性增长,性能会逐渐下降。此外,select 返回后,应用程序需要通过 FD_ISSET 宏再次遍历文件描述符集来确定哪些文件描述符准备好,这也增加了额外的开销。
  • poll:poll 同样需要遍历 struct pollfd 数组来检查文件描述符的状态,但它在返回时,revents 字段已经明确标识了每个文件描述符发生的事件,应用程序不需要再次遍历整个数组来确定哪些文件描述符准备好,这在一定程度上提高了性能。特别是在监控大量文件描述符时,poll 的性能优势更加明显。

4.4 事件处理的灵活性

  • select:通过不同的文件描述符集(readfdswritefdsexceptfds)来分别监控读、写和异常事件。这种方式相对比较简单,但不够灵活,例如在某些情况下,可能需要分别对读和写事件进行不同的处理逻辑,但 select 的这种设计使得代码结构可能会变得复杂。
  • poll:通过在 struct pollfd 结构体的 events 字段中设置不同的事件标志,可以更加灵活地监控多种类型的事件,并且在 revents 字段中能够清晰地获取到实际发生的事件。这使得 poll 在处理复杂的事件场景时更加方便,代码结构也更加清晰。

4.5 可移植性

  • select:是 POSIX 标准的一部分,具有很好的跨平台可移植性,几乎所有支持 POSIX 标准的操作系统都提供了 select 函数,包括 Linux、Unix、Mac OS 等。
  • poll:虽然也在很多操作系统中得到支持,但相对 select 来说,其可移植性略逊一筹。在一些老旧的系统或特定的嵌入式系统中,可能不支持 poll 函数。

五、实际应用场景选择

5.1 小文件描述符数量场景

如果应用程序只需要监控少量的文件描述符(例如小于 100 个),并且对性能要求不是特别高,select 是一个不错的选择。由于其简单的接口和良好的可移植性,在一些简单的客户端程序或者对资源要求不高的小型项目中,使用 select 可以快速实现功能。例如,一个简单的命令行工具,它只需要监控标准输入和一个套接字连接,使用 select 就可以轻松实现对这两个文件描述符的 I/O 多路复用。

5.2 大量文件描述符场景

当应用程序需要监控大量的文件描述符(例如成百上千个)时,poll 通常是更好的选择。由于 poll 没有文件描述符数量的限制,并且在性能上相对 select 更具优势,特别是在遍历和确定准备好的文件描述符方面。例如,一个高性能的网络服务器,需要同时处理大量的客户端连接,使用 poll 可以有效地管理这些连接的 I/O 操作,提高服务器的并发处理能力。

5.3 可移植性要求高的场景

如果应用程序需要在多种不同的操作系统上运行,并且对可移植性有较高的要求,select 是首选。因为 select 是 POSIX 标准的一部分,在几乎所有支持 POSIX 标准的操作系统上都能使用。而 poll 在一些老旧或特定的系统中可能不被支持,这可能会给应用程序的跨平台部署带来困难。例如,开发一个通用的网络库,需要在 Linux、Unix 和 Windows(通过 Cygwin 等工具模拟 POSIX 环境)等多种操作系统上运行,select 就是一个比较合适的 I/O 多路复用技术。

5.4 事件处理复杂场景

当应用程序需要处理复杂的事件类型,并且对事件处理的灵活性有较高要求时,poll 更适合。通过在 struct pollfd 结构体中灵活设置 events 字段和获取 revents 字段,poll 可以方便地处理多种不同类型的事件,并且能够清晰地知道每个文件描述符发生的具体事件。例如,一个多媒体服务器,除了需要监控网络套接字的读写事件外,还需要监控一些设备文件的特定事件(如摄像头设备的状态变化),poll 就可以更好地满足这种复杂事件处理的需求。

六、总结与展望

通过对 select 和 poll 机制的详细介绍、原理分析、代码示例以及对比,我们可以清晰地了解到它们各自的特点和适用场景。在实际的 Linux C 语言编程中,根据应用程序的具体需求,合理选择 select 或 poll 作为 I/O 多路复用技术,可以有效地提高程序的性能和资源利用率。

随着技术的不断发展,虽然 epoll 等更高效的 I/O 多路复用技术逐渐成为主流,特别是在高性能网络编程领域,但 select 和 poll 作为经典的技术,仍然在一些场景下具有重要的应用价值。对于开发者来说,深入理解这些基础技术,不仅有助于更好地掌握 Linux 系统编程,还能为选择合适的技术方案提供坚实的理论基础。在未来的开发中,我们可以根据不同的项目需求,灵活运用这些技术,打造出更加高效、稳定的软件系统。

希望本文的内容能够帮助读者深入理解 Linux C 语言中 select 和 poll 机制的差异,并在实际编程中做出明智的选择。同时,也鼓励读者进一步探索 epoll 等其他 I/O 多路复用技术,不断提升自己在 Linux 系统编程方面的能力。