深入剖析 I/O 复用之 select 机制

发布于:2025-05-11 ⋅ 阅读:(14) ⋅ 点赞:(0)

深入剖析 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.16000 端口,当有新的客户端连接时会接受连接,接收客户端发送的数据,并向客户端回复 "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 存在一些局限性,它适用于文件描述符数量较少、对性能要求不是特别高的场景,例如一些简单的网络服务器、嵌入式系统等。在实际应用中,若需要处理大量文件描述符或对性能有更高要求,可以考虑使用 pollepoll 等更高级的 I/O 复用机制。

通过深入理解 select 的原理、使用方法和优缺点,我们能够在网络编程中更好地运用这一技术,构建高效稳定的网络应用。


网站公告

今日签到

点亮在社区的每一天
去签到