目录
前言:
多路转接(也称为IO多路复用)是一种网络通信的手段或机制,它允许一个进程同时监听多个IO事件,并在任何一个IO事件准备就绪时进行处理。这种机制可以大大提高程序的并发性能和响应速度。以下是关于多路转接的详细解释:
- 定义:
- 多路转接是一种IO模型,它允许单个进程或线程同时监听多个文件描述符(如套接字、管道等)的状态变化,从而能够处理多个IO事件。
- 核心特点:
- 并发性:通过同时监听多个文件描述符,多路转接可以在单线程/进程的场景下实现并发处理。
- 非阻塞:在监听过程中,进程/线程不会被阻塞,直到有文件描述符就绪。
- 事件驱动:当某个文件描述符的状态发生变化(如可读、可写等)时,多路转接会触发相应的事件处理函数。
- 常见实现方式:
- select:Unix系统中最早引入的多路复用IO机制之一,它允许程序同时监听多个文件描述符上的IO事件。
- poll:与select类似,但提供了更灵活的监听方式,并且可以处理更多的文件描述符。
- epoll:Linux特有的IO多路复用机制,相比select和poll具有更高的性能和更好的扩展性。
- 应用场景:
- 当客户需要处理多个描述符(如交互式输入和网络套接字)时。
- 一个客户同时处理多个套接字时。
- 一个TCP服务器既要处理监听套接字,又要处理已连接套接字时。
- 一个服务器需要处理多种服务或协议时。
- 优势:
- 与多进程和多线程技术相比,IO多路复用技术的最大优势是系统开销小。系统不必创建和维护大量的进程/线程,从而大大减小了系统的开销。
- 工作原理:
- 使用IO多路转接函数(如select、poll、epoll)委托内核检测服务器端所有的文件描述符。
- 如果检测到已就绪的文件描述符,阻塞会解除,并将这些已就绪的文件描述符传出。
- 根据类型对传出的所有已就绪文件描述符进行判断,并做出不同的处理(如接受新连接、读取数据、发送数据等)。
总结来说,多路转接是一种高效的网络通信手段,它通过允许单个进程或线程同时监听多个IO事件,实现了在单线程/进程场景下的并发处理。这种机制在高性能网络服务器、并发编程等领域有着广泛的应用。
1.IO多路转接---select
1.1.接口认识
我们在1.4中提及了select通过监听大量的IO通道来实现高效的IO,select只负责IO过程中的等待,当等待结束,相应的条件就绪时,负责拷贝的函数就不需要进行等待而是直接完成IO。而select是系统提供的一个IO多路复用的系统调用。
- 事件:一个文件描述符上的事件,一般分为:读事件、写事件、异常事件。读事件就绪,表示可读,缓冲区存在数据。写事件就绪,表示可写,缓冲区有空间。异常事件,表示读取到文件描述符出现异常。
- 文件描述符的集合,为输入输出型参数,fd_set类型本质上是一张位图,比特位的位置表示文件描述符的值。当进行输入时:比特位0或者1表示是否关心相应事件。当输出时,内核告知用户那些fd上面的事件是否就绪。
- select的返回值有三种,大于0表示:select等待的文件描述符中,已经就绪的文件描述符的个数。等于0表示:select超时返回。小于0表示:出错了。
- 对于timeout,设置为{0,0}表示为非阻塞,设置为{5,0}表示非阻塞等待5s,设置为nullptr表示阻塞。
对文件描述符集合专门的调整函数!!!
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的全部位
1.2.select如何进行多路转接
该段代码为一个引入select多路转接的TCP服务器,里面包含的头文件socket.hpp和log.hpp的具体代码在select_server模块 - Gitee.com ,
#include <iostream>
#include <memory>
#include <sys/select.h>
#include "Log.hpp"
#include "socket.hpp"
const int default_backlog = 5;
const static int num = sizeof(fd_set) * 8;
class SelectServer
{
private:
void HanderAccept(const fd_set &set)
{
// 可能会处理多个文件描述符
for (size_t i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
{
continue;
}
// 合法的套接字
else
{
// 注意这里会出现:1.Accept新链接 2.read获取到新数据
int fd = _rfds_array[i]->GetSockFd();
// 判断fd是否在这个集合里
if (FD_ISSET(fd, &set))
{
// 表示 获取套接字就绪
if (fd == _listen_sock->GetSockFd())
{
lg.LogMessage(Info, "get a new link\n");
std::string client_ip;
uint16_t client_port;
NetWork::Socket *sock = _listen_sock->AcceptConnection(&client_ip, &client_port);
if (sock == nullptr)
{
lg.LogMessage(Error, "sock accept failed\n");
}
lg.LogMessage(Info, "connect client success, client info is %s:%d, fd = %d\n", client_ip.c_str(), client_port, sock->GetSockFd());
// 建立好了链接,现在不确定读、写事件是否就绪
// 所以我们需要对读、写进行select
// 此时我们需要将新增的套接字,交由select进行托管
int pos = 0;
for (; pos < num; pos++)
{
if (_rfds_array[pos] == nullptr)
{
_rfds_array[pos] = sock;
break;
}
}
if (pos == num)
{
// 服务器select托管套接字已达上限
sock->CloseSockFd();
delete sock;
lg.LogMessage(Warning, "server is full...\n");
}
}
// 表示 读取数据 就绪, 可以在这里进行IO
else
{
std::string buffer;
bool ret = _rfds_array[i]->Recv(&buffer, 1024);
if(ret == true)
{
std::cout<<"client message: "<<buffer<<std::endl;
buffer.clear();
}
else
{
// 差错处理 client文件描述符关闭 或者是 recv出现错误
lg.LogMessage(Error, "recv message failed\n");
_rfds_array[i]->CloseSockFd();
delete _rfds_array[i];
_rfds_array[i] = nullptr;
}
}
}
}
}
}
public:
SelectServer(int port) : _port(port), _listen_sock(new NetWork::TcpSocket()), _isrunning(false) {}
void InitServer()
{
_listen_sock->BuildListenSocketMethod(_port, default_backlog);
// 初始化话fd数组
for (size_t i = 0; i < num; i++)
{
_rfds_array[i] = nullptr;
}
_rfds_array[0] = _listen_sock.get(); // 获取到内部的指针
}
void Loop()
{
_isrunning = true;
while (_isrunning == true)
{
// accept本质上是一个读事件,所以我们不能先进行accept
// 而是先进行select监听所有的文件描述符,进行等待
fd_set rfds;
FD_ZERO(&rfds);
// 获取最大的fd然后作为select的参数
int max_fd = _listen_sock->GetSockFd();
for (size_t i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
{
continue;
}
else
{
// 当前文件描述符fd
int fd = _rfds_array[i]->GetSockFd();
// 需要将文件描述符添加到这个集合中,将新的套接字添加到rfds中
FD_SET(fd, &rfds);
if (max_fd < fd)
{
max_fd = fd;
}
}
}
struct timeval timeout = {5, 0};
int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
if (n > 0) // select成功
{
lg.LogMessage(Info, "select success, last time: %u.%u\n", timeout.tv_sec, timeout.tv_usec);
// 这里对响应的Accept进行处理
HanderAccept(rfds);
}
else if (n == 0) // select超时
{
lg.LogMessage(Info, "select timeout, last time: %u.%u\n", timeout.tv_sec, timeout.tv_usec);
}
else // select失败
{
lg.LogMessage(Error, "select error\n");
}
}
_isrunning = false;
}
private:
std::unique_ptr<NetWork::Socket> _listen_sock;
int _port;
bool _isrunning;
// select服务器需要通过数据结构维护文件描述符
NetWork::Socket *_rfds_array[num];
};
首先我们要知道select的使用逻辑:
- 我们在进行select之前,需要知道在网络通信中IO的场景有什么?一是:服务器、客户端在进行通信之前需要监听、并接收形成新的套接字,需要等待套接字Accept就绪,本质上也是等待读事件就绪。二是:在进行读取数据时,需要等待读事件就绪。
- 在知道了select需要等待套接字就绪,我们知道在网络通信中,一台服务器可以接收许多个套接字,也就是套接字可能在循环中不断就绪,所以我们需要动态的更新就绪的套接字,并写入到读集合。
- 现在我们结合1,2和TCP的listen套接字,首先对于listen套接字而言,他的select需要等待的是大量的accept获取到的新套接字就绪,而对于这些获取到的新套接字,我们又需要对他们将传来的数据进行等待读事件就绪。这里也体现在我们HandlerAccept函数中分了两种情况进行fd的操作
- 值得一提的是:select的本质就是内核帮我们遍历当前的所有套接字查询是否等待条件就绪,并且传入的集合为输入输出型参数,当某些套接字就绪时,对应比特位即变为1
- 另外select托管文件描述符的底层,就是操作系统对文件描述符数组进行遍历,检测关心的事件是否就绪
在理解了select的使用逻辑之后,我们开始对这个服务器进行解读!!!
- 在变量设计中我们可以看到我们用了指针数组来维护文件描述符,因为文件描述符是会动态增加的,并且后续需要我们进行select
- 外部在使用这个select服务器时,需要先调用InitServer进行初始化,重点是添加监听套接字,和把其他套接字置为空。
- 接着就是进行Loop循环来进行服务器的业务(这里我们暂时没有实现业务),在每次循环中我们都设置一个读集合rfds,并且读取当前套接字数组中的所有套接字,并通过系统提供的位操作接口写入导致这个集合中。然后通过select函数监听这些套接字是否就绪(分为两种:listen套接字等待其他套接字就绪,套接字等待数据就绪)
- 当我们select成功,表示某些套接字已经就绪,这时我们传入这个集合,因为这个集合在select后,如果某些套接字就绪,则对应比特位被设置为1,那么进入HandlerAccept函数时,如果是监听套接字就绪,那么就相应的进行添加其他套接字进入这个文件描述符数组中。反之即为其他套接字获取到了数据就绪,这时就进行数据的读取!!!
- 如此重复3,4的过程,这就完成的select多路转接的服务器
1.3.select多路转接的优缺点
看到这里大家会直呼牛逼!!!确实是很厉害,我们引入的select之后,对于当前的线程,我们可以一次性的、同时的监听大量的套接字,当某些套接字未就绪时,也不会出现以往单线程阻塞的场景。同理,当进行读写时,也不会进行IO的等待!!!
但是作为第一版的多路转接事件管理器,我们在代码中也能发现,我们需要经常的进行对数组的遍历,这也会造成资源的消耗,虽然select大大地提高的网络通信的效率,但是大量的遍历又降低了效率。
总而言之:select优点很出众,但同时缺点也无法忽视!!!
select优点:
- select只需要进行等待,并且可以同时等待大量的文件描述符,再进行IO时,十分高效
select缺点:
- 每次循环中我们都需要对select的参数进行重置,这部分会存在内存开销
- select需要不断地遍历管理文件描述符的数据结构,影响了整体服务器的效率
- 在每次select调用和返回时,内核需要拷贝位图,并对位图进行操作,也就是会不断地进行数据拷贝
- select在底层实现时,操作系统需要不断地遍历所有托管的文件描述符,检测关心的事件是否就绪,这里也会影响整体效率
- 位图的大小固定,即select托管的文件描述符的个数具有上限
2.IO多路转接---poll
2.1.接口认识
和select相同,poll也是系统提供的一个事件管理器,用来关系IO过程中的事件等待!!!但是poll的使用和select有较大的不同
- 当我们使用fds这个结构时,需要对这个结构体数组进行内容的初始化,并且这个数组是开辟在堆区,因此我们也可以通过nfds这个参数来托管任意个数的套接字,前提是系统有足够的空间。
- events、revents是短整型变量,当我们添加参数时,表示关心某个事件,我们通过或|操作符添加,当判断有没有这个事件,我们通过与&操作符判断。
- 值得一提的是:当我们传入这个rds对象时,我们只需要对event这个内置变量进行操作,revents是由操作系统进行操作的!!!
2.2.poll如何进行多路转接
其他模块的代码从poll多路转接模块- Gitee.com 获取!!!
#include <iostream>
#include <memory>
#include <poll.h>
#include "Log.hpp"
#include "socket.hpp"
const int default_backlog = 5;
class PollServer
{
private:
void HanderAccept()
{
// 可能会处理多个文件描述符
for (size_t i = 0; i < _num; i++)
{
if (_rfds[i].fd == -1)
{
continue;
}
// 合法的套接字
else
{
// 注意这里会出现:1.Accept新链接 2.read获取到新数据
int fd = _rfds[i].fd;
short revents = _rfds[i].revents;
if (revents & POLLIN)
{
// 表示 获取套接字就绪
if (fd == _listen_sock->GetSockFd())
{
lg.LogMessage(Info, "get a new link\n");
std::string client_ip;
uint16_t client_port;
NetWork::Socket *sock = _listen_sock->AcceptConnection(&client_ip, &client_port);
// 获取通信套接字失败
if (sock->GetSockFd() == -1)
{
lg.LogMessage(Error, "sock accept failed\n");
continue;
}
lg.LogMessage(Info, "connect client success, client info is %s:%d, fd = %d\n", client_ip.c_str(), client_port, sock->GetSockFd());
// 和select的本质逻辑一致
int pos = 0;
for (; pos < _num; pos++)
{
if (_rfds[pos].fd == -1)
{
_rfds[pos].fd = sock->GetSockFd();
_rfds[pos].events |= POLLIN;
break;
}
}
if (pos == _num)
{
// 服务器poll托管套接字已达上限
// 1.方式一:允许有上限大小
sock->CloseSockFd();
delete sock;
lg.LogMessage(Warning, "server is full...\n");
// 2.方式二:通过扩容实现无上限
}
}
// 表示 读取数据 就绪, 可以在这里进行IO
else
{
char buffer[1024];
ssize_t m = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (m > 0)
{
buffer[m] = 0;
std::cout << "client message: " << buffer << std::endl;
}
else if (m == 0)
{
lg.LogMessage(Info, "data recv ");
}
else
{
// 差错处理 client文件描述符关闭 或者是 recv出现错误
lg.LogMessage(Error, "recv message failed\n");
close(fd);
// 取消poll关心事件
_rfds[i].fd = -1;
_rfds[i].events = 0;
_rfds[i].revents = 0;
}
}
}
}
}
}
public:
PollServer(int port) : _port(port), _num(1024), _listen_sock(new NetWork::TcpSocket()), _isrunning(false) {}
~PollServer() { delete[] _rfds; }
void InitServer()
{
_listen_sock->BuildListenSocketMethod(_port, default_backlog);
_rfds = new struct pollfd[_num];
for (size_t i = 0; i < _num; i++)
{
_rfds[i].fd = -1;
// 将关心的内容设为空
_rfds[i].events = 0;
_rfds[i].revents = 0;
}
// 初始化时只有listen sock一个文件描述符
_rfds[0].fd = _listen_sock->GetSockFd();
// 关心事件events设置为读
_rfds[0].events |= POLLIN;
}
void Loop()
{
_isrunning = true;
while (_isrunning == true)
{
int timeout = 1000;
int n = poll(_rfds, _num, timeout);
if (n > 0) // poll成功
{
lg.LogMessage(Info, "poll success\n");
// 这里对响应的Accept进行处理
HanderAccept();
}
else if (n == 0) // poll超时
{
lg.LogMessage(Info, "poll timeout\n");
}
else // poll失败
{
lg.LogMessage(Error, "select error\n");
}
}
_isrunning = false;
}
private:
std::unique_ptr<NetWork::Socket> _listen_sock;
int _port;
bool _isrunning;
struct pollfd *_rfds;
int _num;
};
poll函数的使用逻辑:
- 首先对于poll函数而言,也是需要关心套接字就绪和某个套接字的读、写条件就绪的,那么天然的结构就需要和select相似。
- 不过因为poll把输入输出型参数分离,而是单纯使用输入性参数,进而不需要每次对参数进行重置,这里是直接通过rfds数组,下标的改变来区分不同的events和revents。
- 另外poll和select一致,底层本质上也是对套接字进行遍历访问,检测关心的事件是否就绪,但是poll就绪是系统对struct pollfd这个结构体的revents这个变量进行写入,然后我们在外部进行比较获取是否就绪。
接下来我们来解读一下poll多路转接服务器:
- 首先在变量设计中,我们依旧需要管理获取的大量的文件套接字,这时我们构建一个struct pollfd的指针数组来对这些套件字进行后续的访问
- 接着我们需要初始化这个指针数组的元素,并且将listen套接字关心的时间设置为读事件,再将其他的套接字设置为空(因为此时只有listen一个套接字)
- 接着循环时,我们直接调用poll函数即可,然后进入HanderAccept函数
- 如果revents&POLLIN事件成立,表示事件就绪,接着就是分析时套接字就绪还是套接字读取数据就绪,再进行相应的逻辑
值得一提的是:在套接字就绪的模块中,我们可以实现有上限的管理套接字的指针数组,也可实现动态扩容的管理套接字的数组!
当我们对比select和poll时,我们发现poll明显更加简洁,这时因为poll不需要像select一样每次进入Loop函数中循环对输入输出型参数进行重置,而且struct pollfd这个结构用户能够对关心的事件进行设置,然后由系统查询是否就绪通过revents返回!!!
2.3.poll多路转接优缺点
poll优点:
- poll可以等待多个文件描述符,能够单线程实现高并发,高效
- struct pollfd结构体的实现,输入、输出参数分离,不需要频繁对poll的参数进行分离(相比于select)
- poll关心的文件描述符支持无上限(具体根据操作系统的承载能力)
poll缺点:
- 还是存在用户和内核之间存在拷贝struct pollfd这些结构体(必要开销)
- poll函数底层实现还是需要遍历文件描述符,来获取到事件是否就绪