【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 <unistd.h>
#include <fcntl.h>
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 <iostream>
#include <stdio.h>
#include <unistd.h>
#include <cstdlib>
#include <fcntl.h>
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 <sys/select.h>
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 <iostream>
#include <string>
#include <sys/select.h>
#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的奥秘,共同推动技术的进步和发展。在未来的日子里,愿我们都能在技术的海洋中畅游,收获满满的知识与智慧。再见!
希望本文能够为你提供有益的参考和启示,让我们一起在编程的道路上不断前行!
谢谢大家支持本篇到这里就结束了,祝大家天天开心!