select() 函数作为一种经典的多路复用机制,在处理多个文件描述符(如套接字)时扮演着重要角色。虽然已经过时,但对于我们进一步了解其他复用有极大的帮助。


一、什么是 select() 函数?

select() 是一个系统调用,位于 POSIX 标准中,主要用于监视多个文件描述符,以检测哪些文件描述符准备好进行 I/O 操作(如读、写或发生异常)。它允许程序在单个线程或进程中同时处理多个 I/O 事件,从而实现高效的资源利用。

1
2
3
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:需要监视的文件描述符集合中最大文件描述符的值加一。
  • readfds:指向 fd_set 结构的指针,用于监视可读事件的文件描述符集合。
  • writefds:指向 fd_set 结构的指针,用于监视可写事件的文件描述符集合。
  • exceptfds:指向 fd_set 结构的指针,用于监视异常事件的文件描述符集合。
  • timeout:指向 timeval 结构的指针,指定 select() 函数的超时时间。如果为 NULLselect() 会一直阻塞直到有事件发生。

返回值

  • 正数:表示发生事件的文件描述符数量。
  • 0:表示 select() 超时,没有事件发生。
  • -1:表示调用失败,错误信息存储在 errno 中。

二、select() 的工作原理

select() 的核心思想是利用文件描述符集合(fd_set)来监视多个文件描述符的状态变化。它通过以下步骤工作:

  1. 初始化文件描述符集合:使用 FD_ZERO 清空集合,使用 FD_SET 将需要监视的文件描述符加入集合。
  2. 调用 select():将准备好的 fd_set 传递给 select(),并指定监视的事件类型(读、写、异常)。
  3. 等待事件发生select() 会阻塞,直到有文件描述符满足指定的事件,或超时时间到达。
  4. 处理事件select() 返回后,遍历文件描述符集合,使用 FD_ISSET 判断哪些文件描述符发生了事件,并进行相应的处理。

注意select() 会修改传入的 fd_set 集合,仅保留发生事件的文件描述符。因此,在每次调用 select() 前,需要重新初始化或复制原始的 fd_set


三、select() 的使用方法

以下是一个基于 select() 的简单 TCP 服务器示例,能够同时处理多个客户端连接,并回显客户端发送的数据。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/fcntl.h>
#include <errno.h>

#define BUFFER_SIZE 1024

// 初始化服务端的监听端口。
int initserver(int port);

int main(int argc, char *argv[])
{
    if (argc != 2) 
    { 
        fprintf(stderr, "Usage: %s <port>\n", argv[0]); 
        return EXIT_FAILURE; 
    }

    // 初始化服务端用于监听的socket。
    int listensock = initserver(atoi(argv[1]));
    if (listensock < 0) 
    { 
        fprintf(stderr, "initserver() failed.\n"); 
        return EXIT_FAILURE; 
    }

    printf("Listening on socket=%d\n", listensock);

    fd_set readfds;                         
    FD_ZERO(&readfds);                
    FD_SET(listensock, &readfds);  

    int maxfd = listensock;              

    while (1)        // 事件循环。
    {
        struct timeval timeout;     
        timeout.tv_sec = 10;        // 秒
        timeout.tv_usec = 0;        // 微秒。

        fd_set tmpfds = readfds;      // 复制 readfds,避免被 select() 修改

        // 调用select() 等待事件的发生(监视哪些socket发生了事件)。
        int infds = select(maxfd + 1, &tmpfds, NULL, NULL, &timeout); 

        // 如果infds<0,表示调用select()失败。
        if (infds < 0)
        {
            if (errno == EINTR)
            {
                // 被信号中断,继续循环
                continue;
            }
            perror("select() failed"); 
            break;
        }

        // 如果infds==0,表示select()超时。
        if (infds == 0)
        {
            printf("select() timeout.\n"); 
            continue;
        }

        // 如果infds>0,表示有事件发生,infds存放了已发生事件的个数。
        for (int eventfd = 0; eventfd <= maxfd && infds > 0; eventfd++)
        {
            if (FD_ISSET(eventfd, &tmpfds))
            {
                infds--;

                // 如果发生事件的是listensock,表示有新的客户端连上来了。
                if (eventfd == listensock)
                {
                    struct sockaddr_in client;
                    socklen_t len = sizeof(client);
                    int clientsock = accept(listensock, (struct sockaddr*)&client, &len);
                    if (clientsock < 0) 
                    { 
                        perror("accept() failed"); 
                        continue; 
                    }

                    printf("Accepted client(socket=%d) from %s:%d.\n", 
                           clientsock, inet_ntoa(client.sin_addr), ntohs(client.sin_port));

                    FD_SET(clientsock, &readfds);                      // 把新连上来的客户端的标志位置为1。

                    if (clientsock > maxfd) maxfd = clientsock;    // 更新maxfd的值。
                }
                else
                {
                    // 如果是客户端连接的socket有事件,表示接收缓存中有数据可以读,或者有客户端已断开连接。
                    char buffer[BUFFER_SIZE];
                    memset(buffer, 0, sizeof(buffer));
                    ssize_t bytes_received = recv(eventfd, buffer, sizeof(buffer) - 1, 0);

                    if (bytes_received <= 0)
                    {
                        if (bytes_received == 0)
                        {
                            // 客户端关闭连接
                            printf("Client(socket=%d) disconnected.\n", eventfd);
                        }
                        else
                        {
                            perror("recv() failed");
                        }

                        close(eventfd);                         // 关闭客户端的socket

                        FD_CLR(eventfd, &readfds);     // 把bitmap中已关闭客户端的标志位清空。

                        if (eventfd == maxfd)              // 重新计算maxfd的值,只有当eventfd==maxfd时才需要计算。
                        {
                            while (FD_ISSET(maxfd, &readfds) == 0 && maxfd > listensock)
                            {
                                maxfd--;
                            }
                        }
                    }
                    else
                    {
                        // 如果客户端有报文发过来。
                        buffer[bytes_received] = '\0'; // 确保字符串终止
                        printf("Received from socket=%d: %s\n", eventfd, buffer);

                        // 把接收到的报文内容原封不动的发回去。
                        ssize_t bytes_sent = send(eventfd, buffer, bytes_received, 0);
                        if (bytes_sent < 0)
                        {
                            perror("send() failed");
                            close(eventfd);                         // 关闭客户端的socket
                            FD_CLR(eventfd, &readfds);     // 把bitmap中已关闭客户端的标志位清空。

                            if (eventfd == maxfd)              
                            {
                                while (FD_ISSET(maxfd, &readfds) == 0 && maxfd > listensock)
                                {
                                    maxfd--;
                                }
                            }
                        }
                        else
                        {
                            printf("Echoed back to socket=%d.\n", eventfd);
                        }
                    }
                }
            }
        }
    }

    close(listensock); // 程序结束前关闭监听socket
    return 0;
}

// 初始化服务端的监听端口。
int initserver(int port)
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        perror("socket() failed");
        return -1;
    }

    int opt = 1;
    unsigned int len = sizeof(opt);
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len) < 0)
    {
        perror("setsockopt() failed");
        close(sock);
        return -1;
    }

    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(port);

    if (bind(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
    {
        perror("bind() failed");
        close(sock);
        return -1;
    }

    if (listen(sock, 5) != 0)
    {
        perror("listen() failed");
        close(sock);
        return -1;
    }

    return sock;
}

代码详解

  1. 初始化服务器套接字

    • 创建套接字:使用 socket(AF_INET, SOCK_STREAM, 0) 创建一个 TCP 套接字。
    • 设置套接字选项:通过 setsockopt 设置 SO_REUSEADDR 选项,允许重用本地地址和端口,避免在服务器重启后出现 bind 失败的情况。
    • 绑定地址和端口:将套接字绑定到指定端口和所有可用接口 (INADDR_ANY)。
    • 监听:将套接字置于监听状态,等待客户端连接。
  2. 初始化 fd_set 集合

    • 使用 FD_ZERO 清空 readfds 集合。
    • 使用 FD_SET 将监听套接字加入 readfds 集合。
    • 维护一个变量 maxfd,记录当前监视的最大文件描述符值,用于 select() 函数的第一个参数。
  3. 事件循环

    • 超时设置:定义一个 timeval 结构体,设置 select() 的超时时间为 10 秒。
    • 复制 fd_set:每次调用 select() 前,将原始的 readfds 集合复制到一个临时的 tmpfds 集合,因为 select() 会修改传入的集合。
    • 调用 select():监视读事件,等待事件发生或超时。
    • 处理事件
      • 新连接事件:如果监听套接字有事件,调用 accept() 接受新的客户端连接,将新客户端的套接字加入 readfds 集合,并更新 maxfd
      • 数据接收事件:如果已有客户端套接字有事件,调用 recv() 接收数据。如果 recv() 返回值 <= 0,表示客户端断开连接,关闭套接字并从 readfds 中移除;否则,将接收到的数据回显给客户端。
  4. 资源管理

    • 在客户端断开连接时,关闭相应的套接字并从 readfds 中移除,防止资源泄漏。
    • 程序结束前,关闭监听套接字,释放资源。

3.1 关键点解析

为什么需要复制 readfdstmpfds

select() 函数会修改传入的 fd_set 集合,仅保留发生事件的文件描述符。因此,在每次调用 select() 前,需要将原始的 readfds 复制到一个临时集合 tmpfds,以便在下一次循环中仍能监视所有需要的文件描述符。

1
2
fd_set tmpfds = readfds;
int infds = select(maxfd + 1, &tmpfds, NULL, NULL, &timeout);

否则,如果直接传入 readfds,每次 select() 调用后,readfds 只会包含发生了事件的文件描述符,导致无法继续监视其他套接字。

维护 maxfd

select() 的第一个参数 nfds 需要设置为所有监视的文件描述符中最大的文件描述符值加一。因此,每次有新的客户端连接时,需要更新 maxfd

1
if (clientsock > maxfd) maxfd = clientsock;

当有客户端断开连接且该套接字是当前 maxfd 时,需要重新计算新的 maxfd

1
2
3
4
5
6
7
if (eventfd == maxfd)
{
    while (FD_ISSET(maxfd, &readfds) == 0 && maxfd > listensock)
    {
        maxfd--;
    }
}

超时处理

在原始代码中,虽然定义了 timeout,但传入 select() 的参数是 0,表示无限等待。正确的做法是将 &timeout 传入 select(),以实现超时机制。

1
int infds = select(maxfd + 1, &tmpfds, NULL, NULL, &timeout);

这样,当超过 10 秒没有事件发生时,select() 会返回 0,程序可以根据需要执行相应的操作,如打印超时信息。

缓冲区安全

在接收数据后,手动添加 null 字符,确保字符串安全,防止缓冲区溢出。

1
buffer[bytes_received] = '\0'; // 确保字符串终止

同时,recv() 调用中,使用 sizeof(buffer) - 1 作为接收长度,留出一个字节用于存储 null 字符。

错误处理

在套接字操作中,合理处理错误情况,如 accept()recv()send() 失败时,打印错误信息并采取相应的措施(如关闭套接字、移除集合等),确保程序的健壮性。


四、select() 的优缺点

优点

  1. 简单易用select() 是一种相对简单的多路复用机制,易于理解和实现。
  2. 跨平台支持select() 在大多数 POSIX 系统(如 Linux、BSD、macOS)和 Windows 平台上都有实现,具有良好的跨平台兼容性。
  3. 无需额外库:使用 select() 不需要依赖额外的库,适用于轻量级应用。

缺点

  1. 文件描述符数量限制
    • select() 受限于 FD_SETSIZE(通常为 1024),无法处理大量并发连接。
    • 在高并发场景下,select() 无法满足需求。
  2. 性能问题
    • select() 需要线性扫描所有文件描述符,时间复杂度为 O(n),在监视大量文件描述符时效率低下。
    • 每次调用 select() 都需要重新设置 fd_set,增加了系统调用的开销。
  3. 不支持边缘触发
    • select() 仅支持水平触发(Level-Triggered),无法像 epoll 支持边缘触发(Edge-Triggered)那样高效处理事件。
  4. 可读性与维护性
    • 随着监视的文件描述符增多,代码的复杂性和可维护性降低。

五、select() 的应用场景

尽管 select() 存在一些限制,但在某些场景下,select() 仍然是一个合适的选择:

  1. 低并发连接
    • 对于连接数较少的服务器,select() 可以有效管理多个客户端连接,无需担心性能问题。
  2. 跨平台应用
    • 需要在不同操作系统间移植的应用,select() 的广泛支持使其成为首选。
  3. 简单的 I/O 多路复用需求
    • 在需要简单实现 I/O 多路复用功能但不需要处理大量并发连接的情况下,select() 是一种便捷的选择。
  4. 嵌入式系统
    • 资源有限的嵌入式系统中,select() 的轻量级特性使其适用于简单的网络通信任务。

六、select() 的替代方案

随着网络应用的发展,出现了多种替代 select() 的多路复用机制,以克服其不足。这些替代方案在处理大量并发连接和提升性能方面具有显著优势。

6.1 poll()

poll()select() 的直接替代品,具有以下特点:

  • 文件描述符数量不受限制:不像 select()FD_SETSIZE 的限制,poll() 可以处理更多的文件描述符。
  • 更简洁的接口:使用数组来管理文件描述符,避免了 FD_SET 等宏的复杂性。

6.2 epoll(Linux 特有)

epoll 是 Linux 提供的一种高效的 I/O 多路复用机制,具有以下优点:

  • 高性能epoll 采用事件驱动机制,时间复杂度为 O(1),适合处理大规模并发连接。
  • 支持边缘触发:相比 select() 仅支持水平触发,epoll 支持边缘触发,减少不必要的事件通知。
  • 内核与用户空间共享事件表:避免了每次调用 epoll_wait() 时都需要重新传递大量的文件描述符,提高效率。

6.3 kqueue(BSD 系统特有)

kqueue 是 BSD 系统(如 FreeBSD、macOS)提供的一种高效的事件通知机制,具有以下特点:

  • 高效的事件处理:类似于 epollkqueue 提供高效的事件通知和管理。
  • 灵活的事件过滤:支持多种类型的事件过滤,如文件描述符事件、信号事件等。

6.4 多线程/多进程模型

除了基于 I/O 多路复用的方案,多线程和多进程模型也是处理并发连接的常见方法:

  • 多线程
    • 每个客户端连接由一个独立的线程处理,适用于连接数较少的场景。
    • 需要处理线程同步和资源共享问题。
  • 多进程
    • 使用 fork() 为每个客户端创建一个独立的子进程,适用于需要隔离不同客户端的场景。
    • 进程间资源消耗较高,管理复杂。

6.5 异步 I/O

现代编程语言和框架(如 C++ 的 Boost.Asio、Python 的 asyncio)提供了异步 I/O 支持,结合事件驱动机制,可以高效地处理并发连接。