深入剖析 I/O 复用之 select 机制
深入剖析 I/O 复用之 select 机制
在网络编程中,I/O 复用是一项关键技术,它允许程序同时监控多个文件描述符的状态变化,从而高效地处理多个 I/O 操作。select
作为 I/O 复用的经典实现方式,在众多网络应用中扮演着重要角色。本文将深入探讨 select
的原理、使用方法、相关数据结构以及实际应用示例。
一、I/O 复用概述
I/O 复用使得程序能够同时监听多个文件描述符,适用于多种场景:
- 客户端程序需要同时处理多个套接字。
- 客户端要兼顾用户输入和网络连接处理。
- TCP 服务器需同时管理监听套接字和已连接套接字。
- 服务器要同时处理 TCP 请求和 UDP 请求。
- 服务器需要监听多个端口。
二、select 原理
select
系统调用通过维护三个文件描述符集合(读集合、写集合和异常集合)来监视不同类型的事件。它会阻塞当前进程,直到有一个或多个文件描述符就绪(有数据可读、可写或发生异常),或者达到指定的超时时间。
三、select 使用方法
函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:需要监视的最大文件描述符编号加 1。readfds
:读文件描述符集合,用于监视文件描述符是否有数据可读。writefds
:写文件描述符集合,用于监视文件描述符是否可写。exceptfds
:异常文件描述符集合,用于监视文件描述符是否发生异常。timeout
:超时时间,指定select
函数的最大阻塞时间。如果为NULL
,则会一直阻塞直到有文件描述符就绪。
fd_set 数据结构
fd_set
用于存储文件描述符集合,本质上是一个位图(bitmask)。每个文件描述符对应位图中的一位,若该位置为 1,则表示该文件描述符在集合内;若为 0,则表示不在集合内。
操作 fd_set
的宏函数
FD_ZERO(fd_set *set)
:将fd_set
集合初始化为空,即把集合中所有位都置为 0。FD_SET(int fd, fd_set *set)
:将指定的文件描述符fd
添加到fd_set
集合中,把对应位设置为 1。FD_CLR(int fd, fd_set *set)
:从fd_set
集合中移除指定的文件描述符fd
,将对应位设置为 0。FD_ISSET(int fd, fd_set *set)
:检查指定的文件描述符fd
是否在fd_set
集合中。若在集合中则返回非零值,否则返回 0。
timeout
结构体
struct timeval { long tv_sec; // 秒数 long tv_usec; // 微秒数
};
用于指定 select
函数的超时时间。
四、使用 select
实现 TCP 服务器示例代码解析
代码功能
此代码创建了一个 TCP 服务器,借助 select
函数实现 I/O 复用,能够同时处理多个客户端的连接与数据收发。服务器监听本地地址 127.0.0.1
的 6000
端口,当有新的客户端连接时会接受连接,接收客户端发送的数据,并向客户端回复 "ok"
。
代码逐段解析
头文件与常量定义
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h> #define MAXARR 10
引入所需头文件,并定义常量 MAXARR
表示存储文件描述符数组的最大长度。
函数声明与实现
socket_init
函数:
该函数用于初始化服务器套接字,包括创建套接字、绑定地址和端口、开始监听连接。若出现错误则返回int socket_init() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket err"); return -1; } struct sockaddr_in saddr; memset(&saddr, 0, sizeof(saddr)); saddr.sin_family = AF_INET; saddr.sin_port = htons(6000); saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)); if (res == -1) { perror("bind err"); return -1; } if ((res = listen(sockfd, 5) < 0)) { perror("listen err"); return -1; } return sockfd; }
-1
。arr_init
函数:
把存储文件描述符的数组初始化为void arr_init(int arr[]) { for (int i = 0; i < MAXARR; i++) { arr[i] = -1; } }
-1
,表示数组中没有有效的文件描述符。arr_add
函数:
把新的文件描述符添加到数组中,找到第一个值为void arr_add(int arr[], int fd) { for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { arr[i] = fd; break; } } }
-1
的位置,将新的文件描述符存入该位置。arr_del
函数:
从数组中删除指定的文件描述符,找到该文件描述符所在的位置,将其值置为void arr_del(int arr[], int fd) { for (int i = 0; i < MAXARR; i++) { if (arr[i] == fd) { arr[i] = -1; break; } } }
-1
。accept_cli
函数:
接受新的客户端连接,若接受成功则将客户端的套接字文件描述符添加到数组中。void accept_cli(int sockfd, int arr[]) { int c = accept(sockfd, NULL, NULL); if (c == -1) { perror("accept err"); return; } printf("cli(%d) accept\n", c); arr_add(arr, c); }
recv_cli
函数:
接收客户端发送的数据,若接收出错或者客户端关闭连接,则关闭对应的套接字并从数组中删除该文件描述符;若接收到数据,则打印数据并向客户端回复void recv_cli(int fd, int arr[]) { char buff[128] = {0}; int n = recv(fd, buff, 127, 0); if (n < 0) { perror("recv err"); printf("cli(%d) close\n", fd); close(fd); arr_del(arr, fd); return; } if (n == 0) { printf("cli(%d) close\n", fd); close(fd); arr_del(arr, fd); return; } printf("buff(c=%d):%s\n", fd, buff); send(fd, "ok", 2, 0); }
"ok"
。
main
函数
int main() { int sockfd = socket_init(); if (sockfd == -1) { exit(1); } int arr[MAXARR]; arr_init(arr); arr_add(arr, sockfd); fd_set fdset; while (1) { FD_ZERO(&fdset); int maxfd = -1; for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { continue; } FD_SET(arr[i], &fdset); if (arr[i] > maxfd) { maxfd = arr[i]; } } struct timeval tv = {5, 0}; int n = select(maxfd + 1, &fdset, NULL, NULL, &tv); if (n == -1) { perror("select err"); continue; } else if (n == 0) { printf("TIME OUT\n"); continue; } else { for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { continue; } else { if (FD_ISSET(arr[i], &fdset)) { if (arr[i] == sockfd) { accept_cli(arr[i], arr); } else { recv_cli(arr[i], arr); } } } } } }
}
- 初始化服务器套接字,若失败则退出程序。
- 初始化存储文件描述符的数组,并将服务器套接字文件描述符添加到数组中。
- 进入无限循环:
- 每次循环开始时,清空
fd_set
集合。 - 遍历数组,将有效的文件描述符添加到
fd_set
集合中,并找出最大的文件描述符。 - 设置
select
函数的超时时间为 5 秒。 - 调用
select
函数进行监听,根据返回值判断情况:若返回-1
表示出错,打印错误信息并继续循环;若返回0
表示超时,打印超时信息并继续循环;若返回大于0
的值,表示有文件描述符就绪。 - 再次遍历数组,检查哪些文件描述符就绪。若为服务器套接字,则调用
accept_cli
函数接受新的连接;若为客户端套接字,则调用recv_cli
函数接收数据。
- 每次循环开始时,清空
五、select 的优缺点
优点
- 跨平台支持:
select
是一种标准的系统调用,几乎所有的 Unix/Linux 系统和 Windows 系统都支持,具有良好的跨平台性。 - 简单易用:
select
的接口相对简单,使用起来比较方便,对于小规模的应用场景非常适用。
缺点
- 文件描述符数量限制:
select
有最大文件描述符数量的限制,一般为 1024。如果需要处理大量的文件描述符,可能会受到限制。 - 性能问题:
select
需要遍历所有的文件描述符来检查其状态,时间复杂度为 O(n),当文件描述符数量较多时,性能会受到影响。 - 内核和用户空间数据拷贝:每次调用
select
时,都需要将文件描述符集合从用户空间拷贝到内核空间,在文件描述符数量较多时,会带来一定的开销。
六、适用场景
由于 select
存在一些局限性,它适用于文件描述符数量较少、对性能要求不是特别高的场景,例如一些简单的网络服务器、嵌入式系统等。在实际应用中,若需要处理大量文件描述符或对性能有更高要求,可以考虑使用 poll
或 epoll
等更高级的 I/O 复用机制。
通过深入理解 select
的原理、使用方法和优缺点,我们能够在网络编程中更好地运用这一技术,构建高效稳定的网络应用。