
半桔 :个人主页
🔥 个人专栏 : 《IO多路转接》《手撕面试算法》《C++从入门到入土》
🔖当你一无所有,你没有什么可以失去。《泰坦尼克号》
文章目录
- 前言
- [一. 五种IO模型](#一. 五种IO模型)
- [二. select实现多路转接](#二. select实现多路转接)
-
- [2.1 select接口](#2.1 select接口)
- [2.2 select服务器实现](#2.2 select服务器实现)
-
- [2.2.1 对网络套接字进行封装](#2.2.1 对网络套接字进行封装)
- [2.2.2 构建出服务器类](#2.2.2 构建出服务器类)
- [2.2.3 进行初始化](#2.2.3 进行初始化)
- [2.2.4 获取要进行等待的fd_set对象](#2.2.4 获取要进行等待的fd_set对象)
- [2.2.5 对读写事件就绪的文件进行处理](#2.2.5 对读写事件就绪的文件进行处理)
- [2.2.6 服务器主循环](#2.2.6 服务器主循环)
- [三. select 的优缺点](#三. select 的优缺点)
前言
在网络编程领域,IO 模型 是支撑高效通信的核心基础之一。当需要让单个进程或线程同时处理多个网络连接的 IO 事件时,"IO 多路复用(多路转接)" 技术成为了关键解法 ------ 它能让程序通过少量进程 / 线程,高效监控并处理多个 IO 事件,极大提升系统对 IO 资源的利用效率。
select作为 IO 多路复用模型中经典且具有代表性 的实现,是开发者接触 "多路转接" 的重要入门点。尽管随着技术演进,它逐渐显现出一些局限性,但深入理解select的工作机制、使用逻辑及其优缺点,不仅能帮助我们掌握 "单进程管理多连接" 的核心思路,更是学习更先进多路复用技术(如poll、epoll)的重要前提。
本文将围绕 "select 实现多路转接" 展开,从select接口的基本定义入手,逐步讲解基于select的多路转接服务器实现(包含套接字封装、初始化流程、fd_set对象操作及服务器主循环设计等),最后剖析select自身的优势与不足。希望通过对这些内容的梳理,能让读者清晰把握select在多路 IO 转接中的核心作用,为后续 IO 模型学习与网络编程实践筑牢基础。
在介绍select这三种多路转接的IO模型之前,有必要先介绍以下5中IO模型分别是哪几种。
一. 五种IO模型
我们在操作系统中直接调用,read && write将数据读取上来,其本质就是将数据从用户层拷贝到操作系统中/从操作系统中拷贝到用户层------就是"拷贝";
- 虽然我们通过拷贝来发送/获取数据,但是我们必须要明确一个概念:IO = 等数据 + 拷贝,而不仅仅是对数据进行拷贝;
- 对于写于要等发送缓冲区中有位置,对于读取要等接收缓冲区中有数据。
因此在进行拷贝之前,必须先判断条件是否成立,也就是读写事件是否就绪。
我们通常定义高效IO指的是:单位时间内,IO过程中,等的比重越小,效率越高。
下面介绍五种IO模型:
- 阻塞性:直到 "等待数据就绪" 和 "数据拷贝" 两个阶段完全完成,IO 调用才返回;
- 非阻塞性:等待数据就绪阶段不阻塞 (内核会立即返回结果),即若数据未就绪,内核会返回
EAGAIN或EWOULDBLOCK错误; - 信号驱动型:用一个线程监控多个 IO,避免进程在单个未就绪 IO 上阻塞;
- 多路复用/多路转接型:让内核在 IO 数据就绪时主动发送
SIGIO信号通知进程来拿取数据; - 异步IO型:应用进程发起异步 IO 调用后,两个阶段(等待就绪、数据拷贝)均由内核完成,全程不阻塞进程。内核在完成所有操作后,通过 "信号" 或 "回调函数" 通知进程,进程直接使用已拷贝到用户缓冲区的数据。
- 对于阻塞IO和非阻塞IO在效率上并没有什么区别,只不过非阻塞IO在不等待期间可以做其他事情,因此我们通常说它的效率更高一些。
下面介绍实现多路转接IO的3中方式。
二. select实现多路转接
关于select实现多路转接,此处将分为两部分进行介绍:
- 介绍select的接口;
- 使用select实现一个简单的ech服务器。
2.1 select接口
select可以一次等待多个文件,当有一个文件就绪了就返回,这样可以一次性等待多个文件,提高了等待的效率。
int select(int nfds , fd_set *readfds , fd_set *writefds , fd_set *expectfds , struct timeval *timeout*);
该就接口就是select的等待接口:
- 参数一
nfds:标识等待的文件描述符中最大的 + 1;
fd_set是内核提供的一种数据结构,其本质是一张位图,记录着要关心的文件描述符。
-
参数二
readfds:是一个结构体,记录要关心读事件就绪的文件描述符; -
参数三
writefds:记录要关心写事件就绪的文件描述符; -
参数四
expectfds:记录要关心异常事件的文件描述符;
struct timeval也是内核提供的一种结构体,用于记录select要进行等待的事件:
cpp
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
当时间到达/有文件读写时间就绪就会进行返回。
- 参数五
timeval:标识select等待的时间,如果等待时间到了,还没有一个文件读写时间就绪select也会进行返回,传nullptr标识阻塞式的等待; - 返回值:一个整形,标识就绪的文件描述符的个数。
上面的fd_set是操作系统提供给我们的数据结构,我们不能直接对该数据结构进行操作,而应该使用操作系统提供的接口来进行操作:
void FD_ZERO(fd_set *set):将位图全部请零,用于初始化;void FD_SET(int fd , fd_set *set):将fd文件描述符添加到位图中;void FD_CLR(int fd , fd_set *set):将fd文件描述符从位图中移除;void FD_ISSET(int fd , fd_set *set):检查fd文件描述符是否在位图中。
如果有文件描述符就绪,操作系统怎么告诉我们是那些文件就绪了???
为了让操作系统能够通知我们,select接口的后4个参数被设计为输入输出型参数。
readfds输出来告诉,那些文件描述符的读事件已经就绪;writefds和expectfds也一样;timeval告诉我们,距离规定的返回时间还剩余多久。
select使用的是内核提供的现成的数据结构fd_set,因此这也就意味着其可以监视的文件描述符的数量是有限的,可以通过sizeof(fd_set)*8来计算出来。
2.2 select服务器实现
为了方便理解,我们实现一个简单的服务器,将用户发送过来的数据在前面添加一个server got a message后直接进行返回。
2.2.1 对网络套接字进行封装
首先我们先对网络套接字的接口进行封装:创建套接字,绑定,监听;关于这方面的知识可以查看之前的TCP相关内容,此时就直接贴实现方法:
cpp
const std::string defaultip_ = "0.0.0.0";
enum SockErr
{
SOCKET_Err,
BIND_Err,
};
class Sock
{
public:
Sock(uint16_t port)
: port_(port),
listensockfd_(-1)
{
}
void Socket()
{
listensockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensockfd_ < 0)
{
Log(Fatal) << "socket fail";
exit(SOCKET_Err);
}
Log(Info) << "socket sucess";
}
void Bind()
{
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port_);
inet_pton(AF_INET, defaultip_.c_str(), &server.sin_addr);
if (bind(listensockfd_, (struct sockaddr *)&server, sizeof(server)) < 0)
{
Log(Fatal) << "bind fail";
exit(BIND_Err);
}
Log(Info) << "bind sucess";
}
void Listen()
{
if (listen(listensockfd_, 10) < 0)
{
Log(Warning) << "listen fail";
}
Log(Info) << "listen sucess";
}
int Accept()
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
return fd;
}
int Accept(std::string& ip , uint16_t& port)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
port = ntohs(client.sin_port);
char bufferip[64];
inet_ntop(AF_INET , &client.sin_addr , bufferip , sizeof(bufferip) - 1);
ip = bufferip;
return fd;
}
int Get_fd()
{
return listensockfd_;
}
~Sock()
{
close(listensockfd_);
}
private:
uint16_t port_;
int listensockfd_;
};```
下面就来实现selectserver服务器:
2.2.2 构建出服务器类
首先就是构造出Selectserver类来对服务器进行管理:
- 首先需要一个
Sock对象,进行TCP通信; - 接着我们需要使用一个容器来存储所有要进行等待读写事件就绪的容器,此处为了简单我们直接使用一个数组来实现,该数组的大小就是
fd_set能够等待的文件个数; - 此处我们假设TCP接收到的就是完整报文,因此就不设置
writefds的位图了,理论上是要进行设置的,大家可以自行实现以下;
cpp
const int fds_num_max = sizeof(fd_set) * 8;
const int defaultfd = -1;
class Selectserver
{
public:
Selectserver(uint16_t port)
: _sock_ptr(new Sock(port))
{
for (int i = 0; i < fds_num_max; i++)
{
_fds_array[i] = defaultfd;
}
}
private:
std::shared_ptr<Sock> _sock_ptr;
int _fds_array[fds_num_max]; // 该数组用来存储select要进行等待的文件描述符,初始值为-1
};
下一步就是进行初始化:
2.2.3 进行初始化
初始化一共就分为4个步骤:
- 创建套接字;
- 进行绑定;
- 设置监听模式;
- 将套接字添加到
_fd_array数组中。
对于前三个步骤在前面我们已经进行封装过来,因此,此处可以直接进行调用。
- 对于第四个步骤来说:我们在与客户端建立连接的时候,不知道什么时候客户端来进行连接,因此也需要进行等待,而这一等待工作本质上是在等待
Sock指向的套接字文件,因此也应该使用select进行等待。
以下是具体实现:
cpp
void AddToArray(int fd)
{
int pos = 0;
for(; pos < fds_num_max && _fds_array[pos] != defaultfd ; pos++)
;
if(pos == fds_num_max)
{
// select已经到达监听极限了,不能再添加要进行监听的文件了
// 1. 关闭文件
// 2. 打印日志
close(fd);
Log(Warning) << "select is full";
}
else
{
// 1. 有位置直接进行添加
_fds_array[pos] = fd;
Log(Info) << "add a new fd : " << fd;
}
}
void Init()
{
// 1. 创建套接字
// 2. 绑定
// 3. 设置监听
// 4. 将套接字描述符加入到_fds_array数组中
_sock_ptr->Socket();
_sock_ptr->Bind();
_sock_ptr->Listen();
AddToArray(_sock_ptr->Get_fd());
}
2.2.4 获取要进行等待的fd_set对象
我们此处设计的select接口并不考虑writefds和expectfds,因此我们只需要实现初始化传入的readfds接口即可,我们需要有一个已经设置好了的fd_set,以及一个其中最大的文件描述符,因此此处使用一个pair作为返回值。
cpp
std::pair<fd_set , int> Get_readfds()
{
// 1. 对位图进行初始化
// 2. 循环遍历_fds_array数组,将要进行等待的文件描述符添加到位图中
int max_num = 0;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < fds_num_max; i++)
{
if (_fds_array[i] == -1)
continue;
FD_SET(_fds_array[i], &readfds);
max_num = std::max(max_num , _fds_array[i]);
}
return std::make_pair(readfds , max_num);
}
2.2.5 对读写事件就绪的文件进行处理
当select等待后,存在文件描述符就绪,就需要将这些文件描述符对应的数据拿上来。
而文件描述符又分为两种:
- 是Sock套接字文件描述符,要将已经建立好连接的文件描述符拿上来;
- 普通文件描述符,直接将输入缓冲区中的数据拿上来。
cpp
// 是套接字就绪
void Sockfd_Ready()
{
// 1. 将套接字中建立好的连接拿上来
// 2. 将拿上来的文件描述符加入到_fds_array中,等到客户端发送消息过来
int fd = _sock_ptr->Accept();
AddToArray(fd);
}
// fd表示文件描述符 , i表示在数组中的位置
void Normalfd_Ready(int fd , int i)
{
// 1. 读取文件描述符中的数据
// 2. 将数据简单处理后,进行返回(此处假设TCP接收的报文是完整的)
char inbuffer[1024];
int n = read(fd , inbuffer , sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0;
std::string ret = "server got a message : ";
ret += inbuffer;
write(fd , ret.c_str() , ret.size());
}
else if(n == 0)
{
// 对方已经关闭文件
// 1. 将在_fds_array中的对应位置设为-1表示已经被移除了,不需要再进行等待
// 2. 关闭文件描述符
_fds_array[i] = defaultfd;
close(fd);
}
else
{
// 出错了
Log(Error) << "read fail";
}
}
2.2.6 服务器主循环
- 进行select等待;
- 有文件描述符就绪,识别对应的文件描述符,将任务进行派发,看交给哪一个函数进行完成。
cpp
void Dispather(fd_set* fdreads)
{
int listensock = _sock_ptr->Get_fd();
for(int i = 0 ; i < fds_num_max ; i++)
{
if(_fds_array[i] == defaultfd || !FD_ISSET(_fds_array[i] , fdreads)) continue;
if(_fds_array[i] == listensock)
{
Sockfd_Ready();
}
else
{
Normalfd_Ready(_fds_array[i] , i);
}
}
}
void Run()
{
while (true)
{
auto [fdreads , max_num] = Get_readfds();
int n = select(max_num + 1 , &fdreads , nullptr , nullptr , nullptr);
if(n > 0)
{
// 有事件就绪, 进行任务的派发
Dispather(&fdreads);
}
else if(n == 0)
{
Log(Info) << "no file";
}
else
{
Log(Error) << "select fail";
}
}
}
以上就是整个selectserver类的实现了。
三. select 的优缺点
优点:
- 所有的等待交给
select来做,只要有读事件就绪就通知上层来将数据取走; - 多路转接,在单进程的情况下能够处理多个用户的请求;
缺点:
- 使用的是内核提供的数据结构
fd_set,等待的文件描述符的数量是有限的; - 输入输出型参数使用起来麻烦,并且每次进行
select的时候都要进行重新设置; - 要将
fd_set从用户层拷贝到内核中,又要拷贝回来,拷贝数据频繁; - 使用第三方数组对用户的fd进行管理,用户称需要进行多次遍历,内核在进行检测的时候也要进行多次遍历。
后续文章中我们将讲解select的替代方案:poll和epoll.