当前位置: 首页 > java >正文

IO多路转接(select方案)

linux的三种多路转接方案

linux中常见的多路转接方案:select/poll/epoll
多路转接的核心作用:通过对多个文件描述符进行等待(手段),来通知上层——哪些fd已经就绪
多路转接的本质是一种对IO事件就绪的通知机制!!

select 系统调用介绍

在传统的阻塞 I/O 模型中,程序对一个文件描述符(如socket、管道、设备文件)执行 read/write 时,若该 FD 无数据/不可写,程序会被内核阻塞,无法处理其他任务。
select 的核心价值是**“一次等待,监控多个 FD”**:程序可将多个 FD 加入监控集合,由内核统一等待其中任意一个 FD 就绪(满足 I/O 条件),再唤醒程序处理就绪的 FD,从而提升 CPU 利用率,实现“伪并发”。

select 是系统调用,需包含头文件 <sys/select.h><sys/time.h>,函数原型如下:

#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

下面我们就来一个个地认识这些参数,我们先来认识几个好理解的

(1) int nfds

nfds = select等待的所有fd中最大的那个fd再加个1
比如我等待的三个文件的文件描述符分别是4 5 6,那么此时nfds = 6+1=7

(2) struct timeval *timeout

struct timeval的定义如下

struct timeval {long tv_sec;  // 秒数(seconds)long tv_usec; // 微秒数(microseconds,1秒=1e6微秒)
};

struct timeval *timeout是一个输入输出型参数!也就是说你在用的时候不仅要编写它的输入值,还要关注他的输出值!

首先我们先来看一下输入,传入的timeout有三种情况

  1. timeout = NULL:select 会无限阻塞,直到监控集合中有 FD 就绪或程序被信号中断。
  2. timeout.tv_sec = 0timeout.tv_usec = 0:select 不阻塞,直接 “轮询” 一次监控集合,返回当前就绪的 FD 数量。
  3. timeout.tv_sec > 0 timeout.tv_usec > 0:select 阻塞指定时间,若超时前有 FD 就绪则提前返回,否则超时后返回 0。举个例子就是timeout.tv_sec = 5,此时的策略就是5秒以内阻塞等待。5秒一到立即返回当前等待的fd集合中就绪的fd数量

在这里插入图片描述
然后我们还要特别注意, struct timeval *timeout作为输出型参数的含义——调用返回时,timeout 会被内核修改为剩余的未使用时间(即实际等待时间与请求等待时间的差值)。
举个例子,我之前select设置的等待策略是5秒之内阻塞等待,超过5秒立即返回。假如说在第3秒结束的时候,有一个fd就绪了,那么select就会提前返回,此时输出型参数timeout 就会被设为 tv_sec=2, tv_usec=0(剩余 2 秒未使用)。

(3) select的返回值

我们记select 的返回值为n,n的取值有下面三种情况在这里插入图片描述

返回值类型含义
n > 0监控集合中就绪的 FD 总数(需通过 FD_ISSET 逐个检查哪些 FD 就绪)。
0超时时间到,且没有任何 FD 就绪。
-1调用失败(需通过 errno 查看具体错误)

其中errno的常见错误包括:

  • EINTRselect 被信号中断(如程序收到 SIGINT 信号),可重试调用。
  • EBADF:监控集合中包含无效的 FD(如已关闭的 FD)。
  • EINVALnfds 小于 0timeout 的值非法(如 tv_usec 超过 1e6)。

除此之外我们还要特别注意!参数struct timeval *timeout是一个输入输出型参数!当select提前返回时,timeout里面存的就是剩余的等待时间

参数名类型作用说明
nfdsint监控的最大 FD 编号 + 1(内核通过此值确定需遍历的 FD 范围,避免无效检查)。
readfdsfd_set*需监控“可读事件”的 FD 集合(若为 NULL,表示不监控可读事件)。
writefdsfd_set*需监控“可写事件”的 FD 集合(若为 NULL,表示不监控可写事件)。
exceptfdsfd_set*需监控“异常事件”的 FD 集合(如 socket 发生错误,若为 NULL,表示不监控)。
timeoutstruct timeval*超时时间,控制 select 的阻塞行为(见下文“超时机制”)。
(4) 关键数据结构 fd_set:文件描述符集合

想要知道剩下的三个参数的含义。我们必须首先来认识一个关键的数据结构: fd_set
fd_set 是一个位图结构(本质是固定大小的数组),每一位对应一个 FD

  • fd_set 中的某个比特位为 1,表示该 FD 被加入监控集合(表示这个文件是select等待的文件之一)
  • fd_set 中的某个比特位是 0 ,则表示不监控(表示select不等它)
    在前面我们举的例子中,假如说select要等待文件描述符为4 5 6的这三个文件,那fd_set位图中的第4 5 6位比特一定是1,其他的比特位就都是0
fd_set这个结构体的大小是多大呢?
  • 一般默认为 1024字节,这也导致 select 监控的 FD 数量存在上限(这是 select 的核心缺陷之一
(5) fd_set *readfds, fd_set *writefds, fd_set *exceptfds

readfds / writefds / exceptfds这三个参数的类型都是fd_set,也就是说他们仨都是文件描述符集,readfds是读文件描述符集,writefds是写文件描述符集,exceptfds是异常文件描述符集,他仨全都是输入输出型参数!
在这里插入图片描述

作为输入参数,这三个位图的含义
  • readfds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件的 “可读事件”
  • writefds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件 的 “可读事件”
  • exceptfds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件的异常事件

fd=5的可读事件,指的就是内核对fd=5对应文件的接收缓冲区中还有数据,可以对这个文件进行读操作
fd=5的可写事件,指的是内核对fd=5对应文件的发送缓冲区中还有剩余的空间,可以对这个文件进行写操作
fd=5 的异常事件,指的是文件描述符 5(FD 5)发生了特定的异常状态或错误,需要程序处理

作为输出型参数,这三个位图的含义
  • readfds中第五位bit=1,表示的含义就是fd=5的这个文件 的 可读事件 已经就绪了,表示这个文件现在可读了(不用等,直接读)
  • writefds中第五位bit=1,表示的含义是fd=5的这个文件 的 可写事件 已经就绪了,表示这个文件现在可写了(不用等,直接写)
  • exceptfds中第五位bit=1,表示的含义就是fd=5的这个文件在读写过程中确实发生了特定的异常状态或错误,需要程序处理

注意!只有那些用户在输入时想要监控的那些比特位,在输出时有可能被置为一。比如说我一开始只想监控fd等于0~5的这几个文件的可读事件,但是在select退出时fd=8的文件的可读事件也就绪了,那在返回的时候,readfds中第八个比特位是0还是1呢?答案是0,因为select根本不关心8,无论退出的时候8的状态是怎样的,readfds中第八个比特位始终都是0

在这里插入图片描述

fd_set 操作宏

假如说我在调用之前想将读文件描述符集合中的第二个比特位置1,表示我想关心Fd等于2的这个文件的可读事件是否就绪,那么按照我们的直观想法,应该是将原来的位图和0x02这个数进行按位或操作。但是由于需兼容不同系统的位图布局,fd_set 的位操作无法直接通过位运算符实现,因此我们必须通过系统提供的宏函数来实现fd_set的位操作

宏函数作用
FD_ZERO(fd_set *set)清空 set 集合(将所有位设为 0),初始化集合前必须调用
FD_SET(int fd, fd_set *set)将 FD fd 加入 set 集合(将对应位设为 1)。
FD_CLR(int fd, fd_set *set)将 FD fdset 集合中移除(将对应位设为 0)。
FD_ISSET(int fd, fd_set *set)检查 FD fd 是否在 set 集合中(返回非 0 表示在集合中,即就绪)。

代码实现:基于select的echoserver

main函数代码

#include "SelectServer.hpp"// ./select_server 8080
int main(int argc, char *argv[])
{if(argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;return 1;}ENABLE_CONSOLE_LOG();uint16_t local_port = std::stoi(argv[1]);std::unique_ptr<SelectServer> ssvr = std::make_unique<SelectServer>(local_port);ssvr->Init();ssvr->Loop();return 0;
}

SelectServer代码

#pragma once#include <iostream>
#include <string>
#include <memory>
#include <sys/select.h>
#include "Log.hpp"
#include "Socket.hpp"using namespace SocketModule;
using namespace LogModule;#define NUM sizeof(fd_set) * 8const int gdefaultfd = -1;// 最开始的时候,tcpserver只有一个listensockfd
class SelectServer
{
public:// SelectServer构造函数SelectServer(int port): _port(port),_listen_socket(std::make_unique<TcpSocket>()),_isrunning(false){}// SelectServer初始化函数void Init(){// 监听套接字的初始化_listen_socket->BuildTcpSocketMethod(_port);// _fd_array数组的初始化for (int i = 0; i < NUM; i++)_fd_array[i] = gdefaultfd;// 将监听套接字添加到数组中,表示我每次调用select时都期望内核去检测一下监听套接字是否读就绪_fd_array[0] = _listen_socket->Fd();}void Loop(){fd_set rfds; // 读文件描述符集_isrunning = true;while (_isrunning){// 清空rfdsFD_ZERO(&rfds);struct timeval timeout = {10, 0};int maxfd = gdefaultfd;for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd)continue;// 合法的fdFD_SET(_fd_array[i], &rfds); // 包含listensockfd// 更新出最大值if (maxfd < _fd_array[i]){maxfd = _fd_array[i];}}// 我们不能让accept监听新链接,因为它是阻塞监听,因此我们要让select来负责进行就绪事件的检测// 用户告诉内核,你要帮我关心&rfds里面的读事件啊!!// 内核会帮我检测&rfds里面的fd,是否有读事件就绪了// 就绪了,我就把就绪的fd,告诉用户,我放在&rfds里面int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout); // 通知上层的任务!switch (n){case 0:std::cout << "time out..." << std::endl;break;case -1:perror("select");break;default:// 有事件就绪了// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!std::cout << "有事件就绪啦..., timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;Dispatcher(rfds); // 把已经就绪的sockfd,派发给指定的模块break;}}_isrunning = false;}// Accepter() 函数的主要功能就是调用accept函数从监听套接字中获取一个新连接,为其创建专门的通信套接字,负责后续的通信// 同时也将这个新创建的通信套接字的文件描述符fd加入到我们fd_array中// 告诉select以后你还要帮我关心这个新的fd上面的读事件是否就绪void Accepter() // 回调函数呢?{InetAddr client;// listensockfd就绪了!获取新连接不就好了吗?int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"if (newfd < 0)return;else{std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;// recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪// 怎么把新的newfd托管给select?让select帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!int pos = -1;for (int j = 0; j < NUM; j++){if (_fd_array[j] == gdefaultfd){pos = j;break;}}// 从左到右遍历,找到第一个默认值的位置,这就是后面我们要将新的fd添加到的位置if (pos == -1){LOG(LogLevel::ERROR) << "服务器已经满了...";close(newfd);}else{_fd_array[pos] = newfd;}}}// 如果这个就绪的fd不是监听套接字,说明这个fd就是我们前面调用accepter创建的一个新通信套接字,// 这时候如果他就绪了,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你处理呢// 这里我们就执行最简单的处理方法,即原封不动地把客户端发来的数据打印到我们的屏幕上void Recver(int who) // 回调函数?{// 合法的,就绪的,普通的fd// 这里的recv,对不对啊!不完善!必须得有协议!char buffer[1024];ssize_t n = recv(_fd_array[who], buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了if (n > 0){buffer[n] = 0;std::cout << "client# " << buffer << std::endl;// 把读到的信息,在回显会去std::string message = "echo# ";message += buffer;send(_fd_array[who], message.c_str(), message.size(), 0); // bug}else if (n == 0){LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fd_array[who];close(_fd_array[who]);_fd_array[who] = gdefaultfd;}else{LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " << _fd_array[who];close(_fd_array[who]);_fd_array[who] = gdefaultfd;}}// 事件分发器的作用:根据就绪的fd,去调用对应的回调函数// 如果这个就绪的fd是监听套接字,说明这时候监听套接字又收到了其他客户端发来的链接请求//这时候我们就调用accepter,从监听套接字中获取一个新请求,为其创建一个新的通信套接字// 同时也将这个新创建的通信套接字的文件描述符加入到我们的fd_array中// 如果这个就绪的fd不是监听套接字,说明这个fd就是我们前面调用accepter创建的一个新通信套接字,// 他就绪了,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你收呢// 这个时候我们就调用recver函数,去处理这个客户端的IO请求void Dispatcher(fd_set &rfds) // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了{// 遍历_fd_array[i],找出for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd)continue;// 文件描述符,先得是合法的if (_fd_array[i] == _listen_socket->Fd()){  // 走到这里说明,_fd_array[i]里面存的那个文件描述符,是listensockfd// 紧接着我们要查看listensockfd是否在rfds里面// 如果在,说明有新的连接到来if (FD_ISSET(_fd_array[i], &rfds)){Accepter(); // 连接的获取}}else{// 这个_fd_array[i]是我们曾经从监听套接字中读出的一次客户端请求,为了方便后续通信,// 我们在accepter函数中为了处理这个客户端的IO请求专门创建了一个新的套接字,后续服务器与客户端的这个IO请求之间的数据收发操作(如调用 recv 接收数据、调用 send 发送数据 )都将通过这个新的套接字文件描述符来进行。// 走到这里时就说明,这个新的套接字文件描述符上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你收呢// 这个时候我们就调用recver函数,去处理这个客户端的IO请求if (FD_ISSET(_fd_array[i], &rfds)){Recver(i); // IO的处理}}}}~SelectServer(){}private:uint16_t _port;std::unique_ptr<Socket> _listen_socket;bool _isrunning;int _fd_array[NUM]; // 辅助数组
};

select的特点与缺陷

select的特点

  • 可监控的文件描述符个数取决于sizeof(fd_set)的值。我这边服务器上sizeof(fd_set) = 512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096。
  • 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,
    • 一是用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断。
    • 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

备注:
fd_set的大小可以调整,可能涉及到重新编译内核,感兴趣的同学可以自己去收集相关资料。

select缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小。
缺陷具体描述
FD 数量上限FD_SETSIZE 限制(默认 1024),虽可通过修改内核参数扩大,但会导致性能下降。
集合需重复初始化select 调用后会修改传入的 fd_set 集合(仅保留就绪的 FD),下次调用前必须重新清空并添加所有 FD,增加开销。
线性遍历效率低每次 select 返回后,程序需通过 FD_ISSET 逐个遍历所有监控的 FD 才能找到就绪的 FD,当 FD 数量庞大时(如万级),遍历开销显著。
内核/用户空间拷贝select 每次调用需将 fd_set 从用户空间拷贝到内核空间,FD 数量越多,拷贝开销越大。

这里我就有一个问题。你进程打开的文件数本身不是也有上限吗?因为进程打开的文件是记录在PCB中的进程打开文件表中的,更具体来说是记录在int fd_array[]数组中的,只要是数组就一定会有上限。因此select能监控的文件数量也会受到进程最大打开文件数的限制,既然如此,那你怎么还说Fd数量的上限是select的一个缺陷呢?

int fd_array[]数组也是支持动态扩容的,这不能成为你select具有FD数量上限的借口

select适用场景

尽管 select 有缺陷,但在以下场景中仍有一定价值:

  • 对性能要求不高的场景:监控的 FD 数量较少(如不超过 100 个),对性能要求不高,这种简单场景下就用select,你也省事他也省事
  • 硬件性能自身就比较低的场景:有些场景下,硬件自身性能就比较低,它就仅支持select,不支持poll和epoll,这时候你就只能用select
  • 跨平台兼容性select 是 POSIX 标准接口,在所有 Unix/Linux、macOS、Windows(通过 WSL 或 Cygwin)系统中均支持,而 epoll 仅支持 Linux。
  • 教学与调试:逻辑简单易懂,适合作为 I/O 多路复用的入门学习案例。

总结

select 是 I/O 多路复用的“鼻祖”,通过内核统一等待多个 FD 就绪,解决了传统阻塞 I/O 的并发问题。但其 FD 数量上限、线性遍历等缺陷,使其仅适用于小规模并发场景。在现代高并发系统中,epoll(Linux)或 kqueue(BSD)已成为主流,但理解 select 的原理和使用方式,是掌握 I/O 模型的重要基础。

http://www.xdnf.cn/news/18789.html

相关文章:

  • 测试用例如何评审?
  • `mysql_query()` 数据库查询函数
  • 如何监控ElasticSearch的集群状态?
  • THM trypwnme2
  • 【广告系列】流量归因模型
  • LeetCode热题100--102. 二叉树的层序遍历--中等
  • 云计算学习笔记——Linux用户和组的归属权限管理、附加权限、ACL策略管理篇
  • CentOS安装Jenkins全流程指南
  • 【大白话解析】 OpenZeppelin 的 ECDSA 库:以太坊签名验证安全工具箱(附源代码)
  • 零基础也能写博客:cpolar简化Docsify远程发布流程
  • 自学嵌入式第二十七天:Linux系统编程-进程
  • MQTT 协议模型:客户端、 broker 与主题详解(二)
  • Java 学习笔记(基础篇10)
  • Qwen2-Plus与DeepSeek-V3深度测评:从API成本到场景适配的全面解析
  • Coze用户账号设置修改用户头像-后端源码
  • 大模型的多机多卡训练
  • 09-数据存储与服务开发
  • 深度学习分类网络初篇
  • react+taro打包到不同小程序
  • Nginx与Apache:Web服务器性能大比拼
  • Docker:技巧汇总
  • 连锁零售排班难?自动排班系统来解决
  • Swiper属性全解析:快速掌握滑块视图核心配置!(2.3补充细节,详细文档在uniapp官网)
  • 从C语言到数据结构:保姆级顺序表解析
  • 数据库之两段锁协议相关理论及应用
  • 前端开发:详细介绍npm、pnpm和cnpm分别是什么,使用方法以及之间有哪些关系
  • Ansible 任务控制与事实管理指南:从事实收集到任务流程掌控
  • 面向过程与面向对象
  • AP服务发现中两条重启检测路径
  • Linux系统操作编程——http