欢迎来到我的频道!【点击跳转专栏】
本文所有代码已托管至码云:【点此转跳】
文章目录
- [1. I/O多路转接之select](#1. I/O多路转接之select)
- [1.1 初识select](#1.1 初识select)
- [1.2 select函数原型](#1.2 select函数原型)
- [1.3 文件fd的事件](#1.3 文件fd的事件)
- [1.4 fd_set(内核级类型)](#1.4 fd_set(内核级类型))
- [1.4 select 原理](#1.4 select 原理)
- [2. 使用 select 实现简易服务器](#2. 使用 select 实现简易服务器)
- [2.1 SelectServer 类成员](#2.1 SelectServer 类成员)
- [2.2 Main.cc:创建并启动服务器](#2.2 Main.cc:创建并启动服务器)
- [2.3 SelectServer 初始化](#2.3 SelectServer 初始化)
- [2.4 Loop():使用 select 等待事件](#2.4 Loop():使用 select 等待事件)
- [2.5 Dispatcher():分发就绪事件](#2.5 Dispatcher():分发就绪事件)
- [2.6 Accepter():处理新连接](#2.6 Accepter():处理新连接)
- [2.7 Recvr():处理客户端数据](#2.7 Recvr():处理客户端数据)
- [2.8. 整体工作流程](#2.8. 整体工作流程)
- [2.9 完整代码](#2.9 完整代码)
- 3.select的优缺点
- [3.1 优点](#3.1 优点)
- [3.2 缺点](#3.2 缺点)
- 3.3总结
1. I/O多路转接之select
1.1 初识select
众所周知,
IO = 等 + 拷贝,select就是负责里面等的环节,一次可以等待多个fd是否可读、可写。select的核心工作就是:等!目标是:检测fd中的事件是否就绪,即 缓冲区是否有数据!从而解放read、recv、send...函数等待的过程!
系统提供select函数来实现多路复用输入/输出模型.
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;- 程序会停在
select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
1.2 select函数原型
select() 是一种 I/O 多路复用函数,可以同时监视多个文件描述符(包括 socket),等待它们变得可读、可写或发生异常。这样程序就不必为每个连接单独阻塞等待。
c
#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:最多等待多久;传NULL表示一直等,传零表示立即检查。
返回值 表示 就绪的描述符数量 ; 没有事件、超时返回0,出错返回-1。调用后,fd_set会被改成"已就绪"的描述符集合,所以下次调用前通常要重新设置。
参数timeout取值:
- NULL: 则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
- 0: 仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
- 特定的时间值: 如果在指定的时间段里没有事件发生,select将超时返回。
struct timeval结构:
timeval结构⽤于描述⼀段时间⻓度,如果在这个时间内,需要监视的描述符没有事件发⽣则函数返回,返回值为0
cpp
struct timeval
{
long tv_sec; // 秒数(自 Unix 纪元起)
long tv_usec; // 微秒数(0~999999)
};
返回值:
- 执行成功则返回文件描述词状态已改变的个数
- 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
- 当有错误发生时则返回
-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
错误值可能为:
EBADF文件描述词为无效的或该文件已关闭EINTR此调用被信号所中断EINVAL参数nfds为负值ENOMEM核心内存不足
⚠️:fd_set参数后面单独介绍
1.3 文件fd的事件
一直说事件就绪,那么事件到底是啥!
- 读事件就绪,即接收缓冲区有数据
- 写事件就绪,即发送缓冲区有空间
- 异常事件就绪,如 信号中断、内存不足、fd被关闭、传参错误等
关于socket的读就绪
socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符,并且返回值大于0;socketTCP通信中,对端关闭连接,此时对该socket读,则返回0;- 监听的
socket上有新的连接请求;socket上有未处理的错误;
1.4 fd_set(内核级类型)
fd_set叫 文件描述符集! 是内核级类型,只要是类型,就一定有它的大小!

大小为128byte!这个文件描述符集,底层其实就是一个位图!128byte,即 1024bit,也就是fd能等待的上限是1024个!

这三个参数,都是输入输出型参数!我们以readfds举例:
- 作为输入时: 本质是用户在告诉内核,比特位的位置,标识比特位的标号,比特位的内容(1位是,0位否)标识是否关心对应位置
fd的 读事件是否就绪! - 作为输出是: 内核通知用户,哪些
fd读就绪了,其中比特位的位置,标识fd的编号,比特位的内容表示 事件是否发生!(1为是,0为否)
就绪描述符,永远是设置的描述符的子集或者相同!也就是你标识了1、2、7,4号描述符就绪了,输出时4号bit位依然是0!
Linux 提供了⼀组操作fd_set的接⼝, 来⽐较⽅便的操作位图.
cpp
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的全部位
补充:一些细节问题
- 我们一般不会同时关心读写,我们一般比较关心先读后写!
- 因为 对任意一个
fd来说,读事件一般都是默认不就绪的(一开始fd没有数据),写事件一般是默认就绪的(对于一个任意空fd来说,发送缓冲区一般一直默认有空间) - 也就是说 写事件一般不会常设,而是按需设制!因为只有不就绪的事件才会需要
select帮忙!后续我们暂时只关心读事件(对于不关心的事件直接设置成NULL即可!)
1.4 select 原理
select底层是通过采用遍历的方式进行测fd是否就绪的!所以为什么select第一个参数必须要最大fd值+1【比如最大fd是9,那么就必须要遍历到<10,遍历的时候约定俗成左闭右开,懂得都懂】,因为我们都懂,文件描述符本质就是数组下标
遍历的时候每个文件都有个对应的struct file,为了实现一切皆文件,每个文件里面有个函数指针表(ops表),里面有个函数方法:(*poll)();用于检测 一个fd是否就绪!
这个poll(这个poll不会阻塞进程)在网络中就表明检测 你接收队列是否有数据、发送队列是否有空间,或者是全连接队列(accept 队列)等中是否有建立好的链接,它都能检测!
select会先然后根据位图,遍历位图上的每个fd,底层调用poll方法时,当发现有fd就绪时,对应的fd_set位图就置1,反之置0,遍历完结束后,只要有至少一个就绪,select就立刻返回就绪的fd的数量!
如果fd_set位图全部置0,内核会阻塞当前进程 ,将进程置入每一个文件的等待队列中, 也就是所谓的 阻塞住了
等待队列,就是挂在某个内核对象(socket、fd、文件)上的一个链表,放着所有正在阻塞等待这个 fd 事件的进程 / 任务
file结构体(其实放在inode、socket里)里面,都自带一个等待队列头wait_queue_head_t
那么什么时候进程被唤醒呢?
- 网卡收到数据包 → 硬件中断
- 中断处理函数把数据放到
socket 内核缓冲区 - 同时,该函数会遍历这个 socket fd 对应的等待队列 ,把队列上所有等待读事件的进程唤醒(调用
wake_up) - 进程唤醒后, 同时把进程从所有 fd 等待队列上摘除,然后再次一轮遍历全部 fd,检查哪些真正就绪,生成结果位图返回给用户
问题:上一轮遍历已经把 fd_set(用户级别的输入输出参数) 清 0 了,阻塞被唤醒之后哪来的 fd 集合继续遍历?
select本质是系统调用,所以肯定要陷入内核,用户态 fd_set 和 内核里保存一份「本次 select 关心哪些 fd」的备份(它属于这次 select 系统调用上下文,不会销毁),清零的是用户态的fd_set,跟我内核里的备份有毛关系!
但是若是轮询式检测: select 每次调用,内核临时拷贝传入的 fd_set,调用结束后内核就丢弃这份拷贝,不会留存
2. 使用 select 实现简易服务器
套接字部分已经封装过了 关于内容感兴趣的可以参考【点击转跳】
2.1 SelectServer 类成员
cpp
class SelectServer
{
private:
uint16_t _port; // 服务器监听端口
// 监听 socket,负责接收客户端连接
std::unique_ptr<Socket> _listensock;
// 保存监听 socket 和客户端 socket
// -1 表示当前位置没有使用
int _rfdset[gfdsum];
// 控制服务器是否退出事件循环
bool _quit;
};
各成员作用:
_port: 保存服务器监听端口,例如8080。_listensock: 指向监听socket,主要用于执行accept(),获取新的客户端连接。_rfdset: 文件描述符数组,用来保存所有需要监听的socket; 数组中既有监听socket,也有客户端socket。_quit: 控制服务器是否继续运行,_quit为false时,服务器持续处理事件。gfdsum: 表示文件描述符数组的最大容量,也就是数组最多可以保存多少个socket。
2.2 Main.cc:创建并启动服务器
cpp
int main(int argc, char* argv[])
{
uint16_t port = std::stoi(argv[1]);
// 创建 SelectServer,并完成监听 socket 初始化
std::unique_ptr<SelectServer> svr =
std::make_unique<SelectServer>(port);
// 进入 select 事件循环
svr->Loop();
return 0;
}
2.3 SelectServer 初始化
cpp
// 创建 TCP 监听 socket,内部依次执行:
// socket()、bind()、listen()
_listensock->BuildTcpSocketMethod(port);
// 初始化文件描述符数组
// -1 表示该位置没有保存 socket
for (int i = 0; i < gfdsum; ++i)
{
_rfdset[i] = -1;
}
// 保存监听 socket
// 后续 select 会监听它是否有新连接
_rfdset[0] = _listensock->Sockfd();
2.4 Loop():使用 select 等待事件
cpp
void Loop()
{
while (!_quit)
{
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = -1;
// 将所有有效 socket 加入读事件集合
for (int i = 0; i < gfdsum; ++i)
{
if (_rfdset[i] == -1)
{
continue;
}
FD_SET(_rfdset[i], &rfds);
// select 的第一个参数要求传入最大 fd + 1
if (maxfd < _rfdset[i])
{
maxfd = _rfdset[i];
}
}
// 阻塞等待:
// 监听 socket 可读,表示有新连接;
// 客户端 socket 可读,表示有数据或连接断开。
// nullptr 表示不监听写事件、异常事件和超时。
int n = select(
maxfd + 1,
&rfds,
nullptr,
nullptr,
nullptr
);
if (n < 0)
{
continue;
}
// 判断具体是哪个 socket 就绪
Dispatcher(rfds);
}
}
2.5 Dispatcher():分发就绪事件
cpp
void Dispatcher(fd_set& rfds)
{
for (int i = 0; i < gfdsum; ++i)
{
if (_rfdset[i] == -1)
{
continue;
}
// 判断当前 socket 是否在 select 返回的就绪集合中
if (!FD_ISSET(_rfdset[i], &rfds))
{
continue;
}
if (_rfdset[i] == _listensock->Sockfd())
{
// 监听 socket 就绪,说明有新的客户端连接
Accepter();
}
else
{
// 客户端 socket 就绪,读取客户端数据
Recvr(i);
}
}
}
2.6 Accepter():处理新连接
cpp
void Accepter()
{
InetAddr clientaddr;
// accept() 获取新的客户端 socket
int sockfd = _listensock->Accepter(clientaddr);
if (sockfd < 0)
{
return;
}
// 找到数组中的空闲位置,保存客户端 socket
for (int i = 0; i < gfdsum; ++i)
{
if (_rfdset[i] == -1)
{
_rfdset[i] = sockfd;
break;
}
}
// 下一轮 select 会开始监听这个客户端 socket
}
2.7 Recvr():处理客户端数据
cpp
void Recvr(int i)
{
char buffer[1024];
// recv() 读取客户端发送的数据
ssize_t n = recv(
_rfdset[i],
buffer,
sizeof(buffer) - 1, // 预留一个字节存放 '\0'
0
);
if (n > 0)
{
buffer[n] = '\0';
// 收到数据后,拼接 Echo 响应并发送回客户端
std::string echo_string = "echo #";
echo_string += buffer;
send(
_rfdset[i],
echo_string.c_str(),
echo_string.size(),
0
);
}
else
{
// n == 0:客户端主动断开
// n < 0:读取发生错误
// 两种情况都需要关闭并移除 socket
close(_rfdset[i]);
_rfdset[i] = -1;
}
}
select 服务器的核心流程就是:
text
创建监听 socket
↓
select 等待事件
↓
监听 socket 就绪?──是──> accept 新连接
│
否
↓
recv 读取客户端数据并响应
↓
继续 select 等待下一轮事件
2.8. 整体工作流程
text
创建监听 socket
↓
将监听 socket 加入数组
↓
select() 等待事件
↓
判断就绪 socket
┌────┴────┐
↓ ↓
新连接 客户端数据
accept() recv()
↓ ↓
保存 fd send() 回显

2.9 完整代码
SelectServer.hpp
cpp
#pragma once
#include "InetAddr.hpp"
#include "Logger.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
using namespace NS_LOG_MODULE;
using namespace NS_SOCKET_MODULE;
static const int gfdsum = sizeof(fd_set) * 8;
static const int gdefaultfd = -1;
class SelectServer
{
public:
SelectServer(uint16_t port = 8080)
: _port(port), _listensock(std::make_unique<TcpSocket>()), _quit(false)
{
_listensock->BuildTcpSocketMethod(port);
LOG(LogLevel::DEBUG)
<< "create listensock success,fd: " << _listensock->Sockfd();
for (int i = 0; i < gfdsum; ++i)
{
_rfdset[i] = gdefaultfd;
}
//默认直接把刚开始的fd,直接添加到数组中
_rfdset[0] = _listensock->Sockfd();
}
void Accepter()
{
//监听套接字就绪
LOG(LogLevel::INFO) << "listensockfd event ready!";
InetAddr clientaddr;
int sockfd = _listensock->Accepter(clientaddr);
LOG(LogLevel::INFO)
<< "get a new link,sockfd is :" << sockfd << clientaddr.ToString();
//此时链接已经获得了,能直接读嘛?不能直接读,而因该托管给select!!如何托管?只要把这个fd添加到辅助数组中去就可以了
int pos = 0;
//找第一个空位
for (; pos < gfdsum; pos++)
{
if (_rfdset[pos] == gdefaultfd)
{
break;
}
}
if (pos == gfdsum)
{
LOG(LogLevel::WARNING) << "server is full!";
close(sockfd);
}
else
{
_rfdset[pos] = sockfd;
}
}
void Recvr(int i)
{
//普通文件描述服就绪
char buffer[1024];
//只限于读这一次不会阻塞
ssize_t n = recv(_rfdset[i], buffer, sizeof(buffer), 0);
//此时直接读取不会再造成阻塞了!
if (n > 0)
{
buffer[n] = 0;
LOG(LogLevel::DEBUG) << "buffer : " << buffer;
//后面对于报文处理分析粘包问题解决就是自定义协议
//序列反序列化的工作了 我这为了简化就直接发了
std::string echo_string = "echo #";
echo_string += buffer;
//我们可以直接发送数据吗?一般写事件是默认就绪的,不过没发完一般就是发送缓冲区满了也需要添加select写事件,这里就不写了
send(_rfdset[i], echo_string.c_str(), echo_string.size(), 0);
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit: " << _rfdset[i];
// 0.先关闭fd
close(_rfdset[i]);
// 1. 从select中移除fd
_rfdset[i] = gdefaultfd;
}
else
{
LOG(LogLevel::WARNING) << "recv error:" << _rfdset[i];
// 0.先关闭fd
close(_rfdset[i]);
// 1. 从select中移除fd
_rfdset[i] = gdefaultfd;
}
}
void Dispatcher(fd_set &rfds)
{
for (int i = 0; i < gfdsum; i++)
{
//判断文件描述符是否合法
if (_rfdset[i] == gdefaultfd)
{
continue;
}
else
{
//判读是否就绪
if (FD_ISSET(_rfdset[i], &rfds))
{
//判读是不是listen套接字
if (_rfdset[i] == _listensock->Sockfd())
{
//链接管理器
Accepter();
}
else
{
//IO处理器
Recvr(i);
}
//将来这两个模块可以单独开一个文件写一个类,让业务代码进行分层解耦
}
}
}
}
void PrintFd()
{
std::cout << "select fd list: ";
for (int i = 0; i < gfdsum; i++)
{
if (_rfdset[i] == gdefaultfd)
{
continue;
}
std::cout << _rfdset[i] << " ";
}
std::cout << std::endl;
}
void Loop()
{
while (!_quit)
{
//一个tcp服务器,最开始的时候,只有一个fd,就是监听socket,随着链接的增多,fd才会增多!
// listen sockfd,我们直接调用accept = 等待 + 获取链接 ,
// 所以我们应该把等待的工作,交给select 针对listen套接字
//我们只关心读事件就绪!
fd_set rfds; // read fd set
FD_ZERO(&rfds);
int maxfd = gdefaultfd;
// FD_SET(_listensock->Sockfd(), &rfds);
// struct timeval timeout = {3, 0};
for (int i = 0; i < gfdsum; i++)
{
if (_rfdset[i] == gdefaultfd)
{
continue;
}
//一定是一个合法的fd
// 1.吧fd添加到rfds位图中,重置位图
FD_SET(_rfdset[i], &rfds);
// 2.更新最大的fd
if (maxfd < _rfdset[i])
{
maxfd = _rfdset[i];
}
}
//只负责等待 把这个理解成事件监听器
int n = select(maxfd + 1, &rfds, NULL, NULL, NULL);
PrintFd();
switch (n)
{
case 0:
LOG(LogLevel::INFO) << "time out";
break;
case -1:
LOG(LogLevel::WARNING) << "select error";
break;
default:
LOG(LogLevel::INFO) << "event ready n= " << n;
//把这个理解成事件派发器
Dispatcher(rfds); //不仅仅处理新链接,还需要处理普通额IO事件。
break;
}
}
}
~SelectServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensock;
bool _quit;
// select服务器,往往需要结合一个辅助数组,才能完成工作
int _rfdset[gfdsum]; //保存所有合法文件fd
};
//这种代码方式称为事件式编程
Main.cc
cpp
#include "Logger.hpp"
#include "SelectServer.hpp"
#include <iostream>
#include <memory>
using namespace NS_LOG_MODULE;
int main(int argc, char *argv[])
{
if (argc!=2)
{
std::cout << "Usage:"<<argv[0]<<"port"<<std::endl;
return 1;
}
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t port =std::stoi(argv[1]);
std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);
svr->Loop();
return 0;
}
3.select的优缺点
3.1 优点
使用单进程就可以同时处理多文件描述符的io的问题, 可以聚合fd,减少等的比重,提高IO效率。
3.2 缺点
- 每次调用
select,都需要手动设置fd集合,从接口使用角度来说也非常不便. - 每次调用
select,都需要把fd集合从用户态拷贝到内核态,算是一个额外内存开销 - 同时每次调用
select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时很大 - 每次使用
select时,在用户层面设置参数和查看参数的时候,用户也要遍历,时间复杂度高 select支持的文件描述符数量太小.
3.3总结
select可以提高IO效率,但是因为支持的文件描述符数量太小,增幅是有明显的上限!因为select在内核和用户层面要遍历。即使文件描述符无上限,fd越多其固定遍历周期的时间也就越长!反而有可能会适得其反,将iO效率降低!
问题这么多,那么select还有学习的意义吗?其实select带来的最大意义: 其实是帮我们程序员基于多路转接实现一些就绪事件的通知机制!





