> 技术文档 > 【Linux高级IO】掌握Linux高效编程:深入探索多路转接select机制

【Linux高级IO】掌握Linux高效编程:深入探索多路转接select机制


📝个人主页🌹:Eternity._
⏩收录专栏⏪:Linux “ 登神长阶 ”
🌹🌹期待您的关注 🌹🌹

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

❀ Linux高级IO

  • 其他高级IO
  • 非阻塞IO:fcntl
    • 实现函数SetNoBlock
    • 轮询方式读取标准输入
  • I/O多路转接之select
    • select函数原型
    • socket的就绪条件
    • select的特点
    • select的缺点
    • select使用示例
  • 总结

前言: Linux作为一个功能强大、灵活多变的操作系统,提供了丰富多样的I/O处理方式。从传统的阻塞I/O到非阻塞I/O,再到更加高效的异步I/O和内存映射I/O,每一种方式都有其独特的适用场景和性能特点。掌握这些高级I/O机制,不仅能够帮助我们更好地理解和优化系统性能,还能在开发高并发、高性能的应用程序时游刃有余。

select机制,则是Linux中处理多路复用I/O的一种经典方法。它允许一个进程同时监视多个文件描述符,以等待其中的任何一个变为可读、可写或有错误条件发生。这种机制极大地提高了I/O处理的灵活性和效率,特别是在处理大量并发连接时,select机制的优势更加明显。

让我们携手踏上这段探索之旅,一同揭开Linux高级I/O与select机制的神秘面纱。

其他高级IO


非阻塞IO,纪录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射
IO(mmap),这些统称为高级IO,本篇我们则是重点讨论I/O多路转接

非阻塞IO:fcntl


fcntl 是 Linux 系统编程中一个非常重要的函数,全称为 File Control,即文件控制。它提供了对文件描述符的广泛控制,包括复制文件描述符、获取/设置文件描述符标志、获取/设置文件锁以及获取/设置文件描述符的所有者等。fcntl 函数的灵活性使其成为处理文件 I/O 操作时不可或缺的工具

一个文件描述符, 默认都是阻塞IO,函数原型如下:

#include #include int fcntl(int fd, int cmd, ... /* arg */ );

后面追加的参数根据cmd的值的不同而产生不同

fcntl函数有5种功能:

  • 复制一个现有的描述符(cmd=F_DUPFD)
  • 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD)
  • 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)
  • 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)
  • 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)

我们现在只需要使用第三个功能,就能满足当前需要,将一个文件描述符设置为非阻塞

实现函数SetNoBlock


基于fcntl, 我们实现一个SetNoBlock函数, 将文件描述符设置为非阻塞

void SetNoBlock(int fd) {int fl = fcntl(fd, F_GETFL);if (fl < 0) {perror(\"fcntl\");return;}fcntl(fd, F_SETFL, fl | O_NONBLOCK);}
  • 使用F_GETFL将当前的文件描述符的属性取出来
  • 然后再使用F_SETFL将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK参数,O_NONBLOCK就是设置非阻塞

轮询方式读取标准输入


#include #include #include #include #include void SetNoBlock(int fd){ int fl = fcntl(fd, F_GETFL); if (fl < 0) { std::cerr << \"fcntl error\" << std::endl; exit(0); } fcntl(fd, F_SETFL, fl | O_NONBLOCK);}int main(){ SetNoBlock(0); while (true) { char buffer[1024]; ssize_t s = read(0, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s] = 0; std::cout << \"echo# \" << buffer << std::endl; } else if (s == 0) { std::cout << \"end stdin\" << std::endl; break; } else { // 非阻塞等待,如果数据没有准备好就会按照错误返回,s == -1 // 那我们怎么知道出错的原因是数据没有准备好,还是真的出错了呢?s是怎么区分的? // read, recv会以出错的形式告知上层,数据还没有准备好 if(errno == EWOULDBLOCK) { std::cout << \"OS的底层数据还没有准备好, error: \" << errno << std::endl; // other } else if(errno == EINTR) { std::cout << \"IO interrupted by signal, try again\" << std::endl; } else { std::cout << \"read error!\" << std::endl; break; } } sleep(1); } return 0;}

我们不断的去查看数据是否准备好,只要准备好我们就拿走,没有准备好,我们就去做其他事情

I/O多路转接之select


初识select:

系统提供select函数来实现多路复用输入/输出模型:

  • select系统调用是用来让我们的程序监视多个文件描述符的状态变化的
  • 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

select函数原型


#include int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

参数解释:

  • 参数nfds是需要监视的最大的文件描述符值+1
  • rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合
  • 参数timeout为结构timeval,用来设置select()的等待时间

参数timeout取值:

  • NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生
  • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回

关于fd_set结构:

这个结构就是一个整数数组, 更严格的说, 是一个 \"位图\",使用位图中对应的位来表示要监视的文件描述符,用比特位的内容来告诉内核是否关心这个位置的发生事件,其中给出了一组接口来方便操作位图

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

关于timeval结构:

timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0

函数返回值:

  • 执行成功则返回文件描述词状态已改变的个数
  • 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
  • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测

错误值可能为:

  • EBADF 文件描述词为无效的或该文件已关闭
  • EINTR 此调用被信号所中断
  • EINVAL 参数n 为负值。
  • ENOMEM 核心内存不足

socket的就绪条件


读就绪:

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  • 监听的socket上有新的连接请求;
  • socket上有未处理的错误;

写就绪:

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
    SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号
  • socket使用非阻塞connect连接成功或失败之后
  • socket上有未读取的错误

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的第一个参数。

select的缺点


  • 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小

select使用示例


讲了这么多,就让我们用用select正是操作一把,单进程实现多服务器消息交流,体现多路转接的真正实力

SelectServer:

#pragma once#include #include #include #include \"Log.hpp\"#include \"Socket.hpp\"using namespace Net_Work;const static int gdefaultport = 8888;const static int gbacklog = 8;const static int num = sizeof(fd_set) * 8;class SelectServer{private: void HandlerEvent(fd_set &rfds) { for (int i = 0; i < num; i++) { if (_rfds_array[i] == nullptr) continue; // 合法的fd // 读事件分两种,一类是新链接的到来,一类是新数据的到来 int fd = _rfds_array[i]->GetSocket(); if (FD_ISSET(fd, &rfds)) { // 读事件就绪 -> 新链接的到来 if (fd == _listensock->GetSocket()) {  lg.LogMessage(Info, \"get a new link\\n\");  std::string clientip;  uint16_t clientport;  // 这里不会阻塞,因为select已经检测到listensock就绪了  Socket *sock = _listensock->AcceptConnection(&clientip, &clientport);  if (!sock)  { lg.LogMessage(Error, \"accept error\\n\"); return;  }  lg.LogMessage(Info, \"get a client, client info is# %s:%d, fd:%d\\n\", clientip.c_str(), clientport, sock->GetSocket());  // 获取成功了,但是我们不能直接读写,底层的数据不确定是否就绪  // 新链接fd到来时,要把新链接fd交给select托管 --- 只需要添加到数组_rfds_array中即可  int pos = 0;  for (; pos < num; pos++)  { if (_rfds_array[pos] == nullptr) { _rfds_array[pos] = sock; break; }  }  if (pos == num)  { sock->CloseSocket(); delete sock; lg.LogMessage(Warning, \"server is full ... !\\n\");  } } // 新数据的到来 else {  std::string buffer;  bool res = _rfds_array[i]->Recv(&buffer, 1024);  if(res)  { lg.LogMessage(Info, \"client say# %s\\n\", buffer.c_str()); buffer.clear();  }  else  { lg.LogMessage(Warning, \"client quit, maybe close or error, close fd: %d\\n\", _rfds_array[i]->GetSocket()); _rfds_array[i]->CloseSocket(); delete _rfds_array[i]; _rfds_array[i] = nullptr;  } } } } }public: SelectServer(int port = gdefaultport) : _port(port), _listensock(new TcpSocket()) { } void InitServer() { _listensock->BuildListenSocketMethod(_port, gbacklog); for (int i = 0; i < num; i++) { _rfds_array[i] = nullptr; } _rfds_array[0] = _listensock.get(); } void Loop() { _isrunning = true; while (_isrunning) { // 不能直接accept新连接,而是要将selete交给selete, 只有selete有资格知道IO事件有没有就绪 fd_set rfds; FD_ZERO(&rfds); int max_fd = _listensock->GetSocket(); for (int i = 0; i < num; i++) { if (_rfds_array[i] == nullptr) {  continue; } else {  int fd = _listensock->GetSocket();  FD_SET(fd, &rfds); // 添加所有合法的fd到rfds集合中  if (max_fd < fd) // 更新最大fd  { max_fd = fd;  } } } // 遍历数组,1.找最大值 2.合法的fd添加到rfds集合中 // 定义时间 struct timeval timeout = {0, 0}; // rfds本质是一个输入输出型参数,rfds是在select调用返回的时候,不断被修改,所以每次都要重置 PrintDebug(); int n = select(max_fd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr); switch (n) { case 0: lg.LogMessage(Info, \"select timeout ... last time: %u.%u\\n\", timeout.tv_sec, timeout.tv_usec); break; case -1: lg.LogMessage(Error, \"select error !!! \\n\"); default: lg.LogMessage(Info, \"select success, begin event handler, last time: %u.%u\\n\", timeout.tv_sec, timeout.tv_usec); HandlerEvent(rfds); break; } } _isrunning = false; } void stop() { _isrunning = false; } void PrintDebug() { std::cout << \"current select rfds list is: \"; for (int i = 0; i < num; i++) { if (_rfds_array[i] == nullptr) continue; else std::cout << _rfds_array[i]->GetSocket() << \" \"; } std::cout << std::endl; } ~SelectServer() { }private: std::unique_ptr<Socket> _listensock; int _port; bool _isrunning; Socket *_rfds_array[num];};

git完整代码链接

总结


虽然说select实现了我们之前从未做到过的功能,select 只负责等待,可以等待多个fd,IO的时候,效率比较高一些,但是对于它的缺点来说,它还是不适合我们使用的

缺点:

  • 1.我们每次都要对select的参数进行重置
  • 2.编写代码的时候,select因为要使用第三方数组,所以充满了遍历。可能会影响select 的效率
  • 3.用户到内核,内核到用户,每次select调用和返回,都要对位图进行重新设置。用户和内核之间,要一直进行效据拷贝
  • 4.select 让OS在底层遍历要关心的所有的fd,这个会造成效率低下
  • 5.fd set:是一个系统提供的类型,fd set大小是固定的,也就是位图个数是固定的,也就是 select最多能够检测的付d总数是有上限的
int n = select(max_fd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr);// max_fd + 1 表示的是

正因为这些缺点,select被我们放弃,但我们也不会损失什么,因为后面还有更厉害的工具等待着我们

随着我们一同走过这段关于Linux高级I/O与select机制的学习之旅,我们不难发现,这些技术不仅是系统编程中的关键要素,更是提升应用程序性能和稳定性的有力武器。从非阻塞I/O到异步I/O,从内存映射到文件锁定,再到select机制的多路复用处理,每一项技术都为我们打开了新的视角,让我们能够更加深入地理解和优化系统行为。

最后,让我们携手开启系统编程的新篇章,继续深入探索Linux的奥秘,共同推动技术的进步和发展。在未来的日子里,愿我们都能在技术的海洋中畅游,收获满满的知识与智慧。再见!

在这里插入图片描述

希望本文能够为你提供有益的参考和启示,让我们一起在编程的道路上不断前行!
谢谢大家支持本篇到这里就结束了,祝大家天天开心!

在这里插入图片描述