作者:@阿亮joy.
专栏:《学会Linux》
座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
IO 操作等于 “等” + “数据拷贝”,而 select 函数就是帮助用户进行一次等待多个文件描述符。当文件描述符就绪了,select 函数就会通知用户就绪的文件有哪些,然后用户调用 read / recvfrom / recv 进行 “数据拷贝”。
select 函数是一个 I / O 多路复用函数,它可以同时监视多个文件描述符的可读、可写或异常状态,从而允许程序等待任何一个文件描述符准备好进行读写操作。
select 函数的基本原理是,将需要监视的文件描述符集合放在一个 fd_set 数据结构中,然后调用 select 函数等待这些文件描述符的状态变化。select 函数会一直阻塞直到文件描述符集合中有一个或多个文件描述符准备好进行读写操作或者发生错误。
select函数的函数原型如下:
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数说明:
认识 struct time 类型
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微妙
};
#include
#include
#include
#include
#include
using namespace std;
int main()
{
while (true)
{
cout << "time: " << (unsigned long)time(nullptr) << endl;
struct timeval tv = {0, 0};
int n = gettimeofday(&tv, nullptr);
cout << "gettimeofday: " << tv.tv_sec << "." << tv.tv_usec << endl;
sleep(1);
}
return 0;
}
fd_set 类型的定义
其实这个结构就是一个整数数组,更严格的说,是一个位图,使用位图中对应的位来表示要监视的文件描述符。
#include
#include
int main()
{
std::cout << sizeof(fd_set) * 8 << std::endl;
return 0;
}
说明:可监控的文件描述符个数取决与 sizeof(fd_set) 的值,其中 sizeof 求的是字节数,所以要乘上 8 表示比特位的数目。因此,我的服务器支持的最大文件描述符数是 1024,不同的服务器可能会有点不一样。
修改 fd_set 位图结构的函数
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
理解 readfds,writefds 和 exceptfds 参数同理
读就绪
写就绪
异常就绪
如果想要通过 select 来编写一个网络服务器,那么网络服务器的功能是将客户端发送过的数据进行打印接口,那么这个网络服务器的编写流程应该是这样的:
日志和套接字组件
// Log.hpp
#pragma once
#include
#include
#include
#include
#include
// 日志等级
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define LOGFILE "./ThreadPool.log"
const char* levelMap[] =
{
"DEBUG",
"NORMAL",
"WARNING",
"ERROR",
"FATAL"
};
void logMessage(int level, const char* format, ...)
{
// 只有定义了DEBUG_SHOW,才会打印debug信息
// 利用命令行来定义即可,如-D DEBUG_SHOW
#ifndef DEBUG_SHOW
if(level == DEBUG) return;
#endif
char stdBuffer[1024]; // 标准部分
time_t timestamp = time(nullptr);
// struct tm *localtime = localtime(×tamp);
snprintf(stdBuffer, sizeof stdBuffer, "[%s] [%ld] ", levelMap[level], timestamp);
char logBuffer[1024]; // 自定义部分
va_list args; // va_list就是char*的别名
va_start(args, format); // va_start是宏函数,让args指向参数列表的第一个位置
// vprintf(format, args); // 以format形式向显示器上打印参数列表
vsnprintf(logBuffer, sizeof logBuffer, format, args);
va_end(args); // va_end将args弄成nullptr
// FILE* fp = fopen(LOGFILE, "a");
printf("%s%s\n", stdBuffer, logBuffer);
// fprintf(fp, "%s%s\n", stdBuffer, logBuffer); // 向文件中写入日志信息
// fclose(fp);
}
// Sock.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
class Sock
{
private:
// listen的第二个参数是底层全连接队列的长度,其数值为listen的第二个参数+1
const static int gbackLog = 10;
public:
Sock()
{}
// 创建套接字
static int Socket()
{
int listenSock = socket(AF_INET, SOCK_STREAM, 0);
if(listen < 0) exit(2);
int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 复用IP地址和端口号
return listenSock;
}
// 绑定IP地址和端口号
static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) exit(3);
}
static void Listen(int sock)
{
if(listen(sock, gbackLog) < 0)
exit(4);
}
// 一般经验
// const std::string &: 输入型参数
// std::string *: 输出型参数
// std::string &: 输入输出型参数
// 获取连接
static int Accept(int listenSock, std::string* ip, uint16_t* port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serviceSock = accept(listenSock, (struct sockaddr*)&src, &len);
if(serviceSock < 0) return -1;
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return serviceSock;
}
// 发起连接
static bool Connect(int sock, const std::string& serverIp, uint16_t& serverPort)
{
struct sockaddr_in server;
memset(&server, 0, sizeof server);
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
server.sin_addr.s_addr = inet_addr(serverIp.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof server) == 0) return true;
else return false;
}
// 将sock设置为非阻塞
static bool SetNonBlock(int sock)
{
int fl = fcntl(sock, F_GETFL);
if(fl < 0) return false;
fcntl(sock, F_SETFL, fl | O_NONBLOCK);
return true;
}
~Sock()
{}
};
select 服务器
// SelectServer.hpp
#ifndef __SELECT_SERVER_H__
#define __SELECT_SERVER_H__
#include
#include
#include "Log.hpp"
#include "Sock.hpp"
using namespace std;
#define BITS 8
#define NUM (sizeof(fd_set) * BITS) // 监测文件描述符的最多个数
#define FD_NONE -1 // FD_NONE表示该文件描述符不需要关心
// select只关心读取时间,不关心写入事件和异常事件
// select一开始只关心listensock,随着连接的增多,select关心的文件描述符会越来越多
// 如果看待listensock?listensock是用来获取新连接的,我们依旧把它看作IO事件,input事件
// 如果新连接没有到来,那么直接调用accept会被阻塞住,所以需要让select关心listensock的读事件
class SelectServer
{
public:
SelectServer(const uint16_t& port = 8080)
: _port(port)
{
_listenSock = Sock::Socket();
Sock::Bind(_listenSock, _port); // 绑定IP地址和端口号
Sock::Listen(_listenSock); // 将_listenSock设置为监听套接字
logMessage(DEBUG, "Create ListenSock Success!");
// _fdArray数组中存储的都是要求select帮我们关心的文件描述符
// FD_NONE为无效的文件描述符
for(int i = 0; i < NUM; ++i) _fdArray[i] = FD_NONE;
// 约定: _fdArray[0] = _listenSock
_fdArray[0] = _listenSock;
}
void Start()
{
while(true)
{
Debug();
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = _listenSock; // select需要关心的最大文件描述符
// 设置select需要关心的文件描述符
for(int i = 0; i < NUM; ++i)
{
// 无效的文件描述符直接跳过即可
if(_fdArray[i] == FD_NONE) continue;
else
{
FD_SET(_fdArray[i], &rfds); // 让select关心_fdArray[i]的读事件
if(_fdArray[i] > maxfd) maxfd = _fdArray[i]; // 更新最大文件描述符
}
}
// 前五秒阻塞等待,如果文件描述符的读事件还没有就绪,就从select中返回
// struct timeval timeout = {5, 0};
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
// int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case -1:
logMessage(WARNING, "Select Error %d %s", errno, strerror(errno));
break;
case 0:
// 文件描述符的读事件未就绪,此时可以去处理其他事情
logMessage(DEBUG, "Read Event Not Ready");
break;
default:
// 读事件就绪
logMessage(DEBUG, "Read Event Ready");
// 读事件就绪时,就需要处理该事件,不然的话
// 内核就会一直通知你读事件就绪了
HandlerEvents(rfds);
break;
}
}
}
~SelectServer()
{
if(_listenSock >= 0) close(_listenSock);
}
private:
void HandlerEvents(const fd_set& rfds)
{
for(int i = 0; i < NUM; ++i)
{
// 跳过不合法的文件描述符
if(_fdArray[i] == FD_NONE) continue;
// 合法的文件描述符不一定就绪,在位图结构中的文件描述符才是就绪的
if(FD_ISSET(_fdArray[i], &rfds))
{
// 1、读事件就绪,新连接到来,需要调用Accepter
// 2、读事件就绪,数据到来,需要调用Recver
if(_fdArray[i] == _listenSock)
Accepter();
else
Recver(i);
}
}
}
void Accepter()
{
string clientIp;
uint16_t clientPort = 0;
// 此时获取新连接不会被阻塞住
// 获取到新连接后不应该立即对sock进行读取操作,因为有可能会被阻塞住
int sock = Sock::Accept(_listenSock, &clientIp, &clientPort);
if(sock < 0)
{
logMessage(WARNING, "Accepter Error");
return;
}
logMessage(NORMAL, "Accept A New Link Success: [%s:%d] fd:%d", clientIp.c_str(), clientPort, sock);
// 找到一个合法的位置,将获取的新连接放到_fdArray数组中
int pos = 1;
for(; pos < NUM; ++pos)
{
if(_fdArray[pos] == FD_NONE) break;
}
// NUM就是select能够检测文件描述符数目的上限,如果pos
// 等于NUM,则说明现在select检测的文件描述符已经有NUM个
// 了,无法对新获取的连接进行监测了,所以只能将该连接关闭掉
if(pos == NUM)
{
logMessage(WARNING, "SelectServer Already Full, Close: %d", sock);
close(sock);
}
else
_fdArray[pos] = sock;
}
void Recver(int pos)
{
// 此时调用read/recv等函数就不会被阻塞住了
logMessage(NORMAL, "Data Arrives on %d File Descriptor", _fdArray[pos]);
char buffer[1024];
// 这里是有bug的,因为使用TCP协议进行通信,没有进行协议定制
// 无法保证此次读取的数据就是一个完整的报文,而不是多个报文或半个报文
int n = read(_fdArray[pos], buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
logMessage(NORMAL, "Client[%d]# %s", _fdArray[pos], buffer);
}
else if(n == 0)
{
// 客户端关闭连接,服务端也要关闭连接
logMessage(NORMAL, "Client[%d] Quit, Me Too", _fdArray[pos]);
// 先关闭文件描述符,然后不再让select关心该文件描述符
close(_fdArray[pos]);
_fdArray[pos] = FD_NONE;
}
else
{
logMessage(WARNING, "%d File Descriptor Read Error %d:%s", _fdArray[pos], errno, strerror(errno));
// 先关闭文件描述符,然后不再让select关心该文件描述符
close(_fdArray[pos]);
_fdArray[pos] = FD_NONE;
}
}
// 打印select所关心的文件描述符
void Debug()
{
cout << "_fd_array[]: ";
for(int i = 0; i < NUM; i++)
{
if(_fdArray[i] == FD_NONE) continue;
cout << _fdArray[i] << " ";
}
cout << endl;
}
private:
uint16_t _port;
int _listenSock;
// _fdArray数组中保存的是select需要关心的文件描述符
int _fdArray[NUM];
};
#endif
select 服务器说明:
Read Event Ready
或者Read Event Not Ready
,因此需要对就绪的读事件进行处理和重新设置 timeout 参数。select 服务器测试
由于没有编写客户端,所以我们通过 telnet 工具来充当客户端,并向服务端发送消息,此时服务器就可以将客户端发送过来的数据进行打印了。
尽管 select 服务器是单进程的服务器,但是它也可以并发地为多个客户端进行服务。其根本原因就是 select 帮助我们检测哪些文件描述符的读事件就绪了,当 select 函数返回时,就可以直接处理这些读事件,并不会被阻塞注。
当前 select 服务器存在的一些问题
当前的 select 服务器实际上还存在着一些问题:
select 服务器的优点
以上的优点,所以的多路转接接口都拥有。
select 服务器的缺点
使用多路转接接口 select / poll / epoll 编写出来的网络服务器需要在一定的应用场景下使用,如果不这样的话,效率可能会反而下降。
使用多路转接接口最常见的场景就是 QQ、WeChat 等聊天软件,因为这些软件都是会存在大量的连接但是不会有很多连接是活跃的,因此可以使用多路转接接口来进行服务器的编写。如果采取多线程方案的话,会销毁系统大量的时间资源和空间资源。
poll 函数也是用于等待多个文件描述符的多路转接接口,它与 select 函数的主要区别是使用结构体数组来代替文件描述符集合,可以手动地调整数组的大小,从而避免了文件描述符数量受限的问题。
poll 函数的原型如下:
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
pollfd 结构体
pollfd 结构体定义如下:
struct pollfd
{
int fd; // 文件描述符
short events; // 等待的事件类型
short revents; // 实际发生的事件类型
};
poll 与 select 相比的优点是:poll 将要监测的时间和实际发生的时间两个参数分开了,不像 select 用一个参数来同时表示要监测的时间和实践发生的事件,因此调用 poll 函数之前不需要对这两个参数重新进行设定。
events 和 revents 的取值
poll 测试代码:使用poll监控标准输入
#include
#include
#include
int main()
{
struct pollfd poll_fd;
poll_fd.fd = 0;
poll_fd.events = POLLIN;
for (;;)
{
int ret = poll(&poll_fd, 1, 1000);
if (ret < 0)
{
perror("poll");
continue;
}
if (ret == 0)
{
printf("poll timeout\n");
continue;
}
if (poll_fd.revents & POLLIN)
{
char buf[1024] = {0};
read(0, buf, sizeof(buf) - 1);
printf("stdin:%s", buf);
}
}
}
注:当 timeout 为负数时,poll 函数会进行阻塞等待。
#ifndef __POLL_SERVER_H__
#define __POLL_SERVER_H__
#include
#include
#include "Log.hpp"
#include "Sock.hpp"
using namespace std;
#define FD_NONE -1 // FD_NONE表示该文件描述符不需要关心
class PollServer
{
private:
static const int defaultnfds = 100;
static const int defaulttimeout = 1000;
public:
PollServer(const uint16_t& port = 8080, int nfds = defaultnfds, int timeout = defaulttimeout)
: _port(port)
, _nfds(nfds)
, _timeout(timeout)
{
_listenSock = Sock::Socket();
Sock::Bind(_listenSock, _port); // 绑定IP地址和端口号
Sock::Listen(_listenSock); // 将_listenSock设置为监听套接字
logMessage(DEBUG, "Create ListenSock Success!");
_fds = new struct pollfd[_nfds];
for(int i = 0; i < _nfds; ++i)
{
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
// 约定:监听套接字放在下标为0的位置
// 关心监听套接字的读事件
_fds[0].fd = _listenSock;
_fds[0].events = POLLIN;
}
void Start()
{
while(true)
{
int n = poll(_fds, _nfds, _timeout);
switch (n)
{
case -1:
logMessage(WARNING, "Select Error %d %s", errno, strerror(errno));
break;
case 0:
// 文件描述符的读事件未就绪,此时可以去处理其他事情
logMessage(DEBUG, "Read Event Not Ready, Time Out");
break;
default:
// 读事件就绪
logMessage(DEBUG, "Read Event Ready");
HandlerEvents();
break;
}
}
}
~PollServer()
{
if(_listenSock >= 0) close(_listenSock);
if(_fds != nullptr) delete[] _fds;
}
private:
void HandlerEvents()
{
for(int i = 0; i < _nfds; ++i)
{
// 跳过不合法的文件描述符
if(_fds[i].fd == FD_NONE) continue;
// 合法的文件描述符不一定就绪,在位图结构中的文件描述符才是就绪的
if(_fds[i].revents & POLLIN)
{
// 1、读事件就绪,新连接到来,需要调用Accepter
// 2、读事件就绪,数据到来,需要调用Recver
if(_fds[i].fd == _listenSock)
Accepter();
else
Recver(i);
}
}
}
void Accepter()
{
string clientIp;
uint16_t clientPort = 0;
// 此时获取新连接不会被阻塞住
int sock = Sock::Accept(_listenSock, &clientIp, &clientPort);
if(sock < 0)
{
logMessage(WARNING, "Accepter Error");
return;
}
logMessage(NORMAL, "Accept A New Link Success: [%s:%d] fd:%d", clientIp.c_str(), clientPort, sock);
// 找到一个合法的位置,将获取的新连接放到_fds数组中
int pos = 1;
for(; pos < _nfds; ++pos)
{
if(_fds[pos].fd == FD_NONE) break;
}
// poll所能监测的文件描述符是没有上限的,但是数组是有上限的
// 当poll监测的文件描述符超过数组的上限时,可以进行扩容,也
// 可以不进行扩容,取决于具体的应用场景。如果不进行扩容,直接
// 将该连接关闭即可
if(pos == _nfds)
{
logMessage(WARNING, "SelectServer Already Full, Close: %d", sock);
close(sock);
}
else
{
_fds[pos].fd = sock;
// 如果想要文件描述符的写时间,可以按位或上POLLOUT
// 增加对写事件的关心,那么就要对写事件进行一定的处理
_fds[pos].events = POLLIN;
}
}
void Recver(int pos)
{
// 此时调用read/recv等函数就不会被阻塞住了
logMessage(NORMAL, "Data Arrives on %d File Descriptor", _fds[pos].fd);
char buffer[1024];
// 这里是有bug的,因为使用TCP协议进行通信,没有进行协议定制
// 无法保证此次读取的数据就是一个完整的报文,而不是多个报文或半个报文
int n = read(_fds[pos].fd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
logMessage(NORMAL, "Client[%d]# %s", _fds[pos].fd, buffer);
}
else if(n == 0)
{
// 客户端关闭连接,服务端也要关闭连接
logMessage(NORMAL, "Client[%d] Quit, Me Too", _fds[pos].fd);
// 先关闭文件描述符,然后不再让poll关心该文件描述符
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = 0;
}
else
{
logMessage(WARNING, "%d File Descriptor Read Error %d:%s", _fds[pos].fd, errno, strerror(errno));
// 先关闭文件描述符,然后不再让poll关心该文件描述符
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = 0;
}
}
private:
uint16_t _port;
int _listenSock;
struct pollfd* _fds;
int _nfds; // struct pollfd数组中元素的个数
int _timeout;
};
#endif
poll 服务器说明:
Test.cc
#include "PollServer.hpp"
#include
int main()
{
unique_ptr<PollServer> svr(new PollServer);
svr->Start();
return 0;
}
尽管 poll 服务器也是一个单进程的服务器,但是它也能够高并发地处理多个客户端的请求。
优点
缺点
poll 中检测的文件描述符数目增多时
按照 man 手册的说法: 是为处理大批量句柄而作了改进的 poll。它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44),它几乎具备了之前所说的一切优点,其效率远高于 select 和 poll 函数,被公认为 Linux2.6 下性能最好的多路 I / O 就绪通知方法。
注:句柄(Handle)通常是一个唯一的标识符,用于标识操作系统内核中的某个对象,如文件、管道、套接字等。句柄可以用于对对象进行操作,例如读写文件、传输数据等。句柄通常是一个整数值,可以看作是操作系统内部的指针或引用,它指向了对象在内存中的位置或者描述对象的数据结构。
epoll 实际上提供了三个系统调用:epoll_create、epoll_ctl 和 epoll_wait。
epoll_create
int epoll_create(int size);
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
认识 struct epoll_event 结构
struct epoll_event 是 epoll 事件的数据结构,用于描述一个被监测的文件描述符上发生的事件,其结构如下:
// 联合体
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
其中,events 用于描述被监测文件描述符上的事件集合,包括:
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
select 和 poll 的工作原理
网卡接收到了数据,操作系统是如何得知的呢?
操作系统可以通过两种方式得知网卡有数据到来:
中断向量表
中断向量表是一个存储系统中断处理程序入口地址的表格。它是操作系统内核用于响应系统中断的重要数据结构之一。
当系统发生中断时(例如,外部设备发送了一个信号或者出现了一个硬件故障),中断控制器硬件会将中断号(或中断向量)发送到 CPU。CPU 将中断号作为索引,通过访问中断向量表来获取相应中断处理程序的入口地址。操作系统内核根据获取的入口地址调用相应的中断处理程序。
在 x86 架构的计算机中,中断向量表的大小是固定的,为256个条目,每个条目对应一个中断号。前 32 个条目用于CPU内部异常处理和软件中断,而后 224 个条目用于外部设备的中断处理。
中断向量表中的每个条目包含两部分信息:中断处理程序的入口地址和处理程序的特权级别。由于操作系统内核需要访问特权级别较高的硬件资源,所以中断处理程序通常也需要在内核态下运行。因此,处理程序的特权级别必须与内核的特权级别匹配,否则处理程序将无法正常运行。
中断向量表的地址通常被保存在一个专用的寄存器中,例如 x86 架构的 IDTR 寄存器。当操作系统启动时,它会初始化中断向量表,并将其地址加载到 IDTR 寄存器中,以便 CPU 能够访问该表。
查看中断号
/proc/interrupts
是一个特殊的文件,在 Linux 系统中用于展示当前系统中各种中断的状态信息。它可以显示中断号、中断名称、中断发生的次数以及中断对应的 CPU 的编号等信息。
调用 epoll_create 函数时,操作系统会在底层做些什么呢?
调用 epoll_create 函数创建 epoll 模型时,操作系统会在底层做两件事情。
调用 epoll_create 函数时,操作系统会在底层创建一棵红黑树,该红黑树用来保存用户要求内核关心的文件描述符和事件,用户不需要再应用层再用数组来维护这些信息了。红黑树的作用就是将被监测的文件描述符按照其值的大小进行排序,并保证其能够高效地进行查找、插入、删除等操作。
调用 epoll_create 函数时,操作系统会在底层创建一个就绪队列(本质是双向链表),该就绪队列保存的是就绪的文件描述符和其对应的时间。当用户程序调用 epoll_wait 函数时,内核会将链表上的 epoll_event 结构体复制到用户空间,供用户程序使用。这样,在 O(k) 的时间复杂度内(k 表示就绪的文件描述符数),用户程序就可以获取到所有就绪的文件描述符及其对应的事件了。
struct eventpoll
{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
struct epitem
{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
有关 epoll 工作原理的细节
epoll 相关接口的封装
#pragma once
#include
#include
#include
class Epoll
{
private:
const static int defaultSize = 256;
public:
// 创建epoll模型
static int CreateEpoll()
{
int epfd = epoll_create(defaultSize);
if(epfd <= 0) exit(5);
return epfd;
}
static bool CtlEpoll(int epfd, int op, int sock, uint32_t events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = sock; // ev.data是一个联合体,使用其中的文件描述符字段即可
int ret = epoll_ctl(epfd, op, sock, &ev);
return ret == 0;
}
static int WaitEpoll(int epfd, struct epoll_event* readyEvents, int maxEvents, int timeout)
{
// 如果底层就绪的事件有很多,readyEvents承装不下,可以分多次来拿取底层就绪的事件
// epoll_wait的返回值是底层就绪的事件的个数,readyEvents是输出型参数
// epoll_wait函数返回时,会将底层就绪的事件按照顺序放入到readyEvents
// 数组中,一个有返回值个就绪的事件
return epoll_wait(epfd, readyEvents, maxEvents, timeout);
}
};
EpollServer 的设计
#ifndef __EPOLL_SERVER_HPP__
#define __EPOLL_SERVER_HPP__
#include
#include
#include "Epoll.hpp"
#include "Log.hpp"
#include "Sock.hpp"
using func_t = std::function<void(std::string)>;
class EpollServer
{
private:
const static uint16_t defaultPort = 8080; // 将服务器的端口号默认为8080
const static int defaultMaxEvents = 64; // 默认一次接收就绪事件的上限为64
public:
EpollServer(func_t callBack, const uint16_t port = defaultPort, const int maxEvents = defaultMaxEvents)
: _port(port)
, _maxEvents(maxEvents)
, _callBack(callBack)
{
// 1. 创建监听套接字
_listenSock = Sock::Socket();
Sock::Bind(_listenSock, _port);
Sock::Listen(_listenSock);
// 2. 创建epoll模型
_epfd = Epoll::CreateEpoll();
// 3. 将_listenSock添加到epoll模型中
logMessage(DEBUG, "Init Server Success! _listenSock:%d _epfd:%d", _listenSock, _epfd); // 3 4
if(!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, _listenSock, EPOLLIN))
exit(6); // 只关心读事件
// 4. 申请保存就绪事件的空间
_readyEvents = new struct epoll_event[_maxEvents];
logMessage(DEBUG, "Add _listenSock To Epoll Success");
}
~EpollServer()
{
if(_listenSock >= 0) close(_listenSock);
if(_epfd >= 0) close(_epfd);
if(_readyEvents) delete[] _readyEvents;
}
void Start()
{
// int timeout = -1; // 阻塞等待
// int timeout = 0; // 非阻塞等待
int timeout = 3000; // 没事件就绪时,每隔3秒timeout一次
while(true)
{
LoopOnce(timeout); // 循环一次
}
}
private:
void LoopOnce(int timeout)
{
int n = Epoll::WaitEpoll(_epfd, _readyEvents, _maxEvents, timeout);
// if(n == _maxEvents) // 如果获取上来的事件个数等于上限,可以选择扩容
switch(n)
{
case -1:
logMessage(WARNING, "WaitEpoll Error: %s", strerror(errno));
break;
case 0:
logMessage(DEBUG, "WaitEpoll Timeout");
break;
default:
// 事件就绪,需要处理事件
logMessage(NORMAL, "WaitEpoll Success");
HandlerEvents(n);
break;
}
}
void HandlerEvents(int n)
{
assert(n > 0); // 增强健壮性
for(int i = 0; i < n; ++i)
{
uint32_t revents = _readyEvents[i].events;
int sock = _readyEvents[i].data.fd;
if(revents & EPOLLIN)
{
if(sock == _listenSock)
{
Accepter(_listenSock);
}
else
{
Recver(sock);
}
}
if(revents & EPOLLOUT)
{
// TODO 写事件就绪将会在Reactor模式中处理
}
}
}
void Accepter(int listenSock)
{
std::string clientIp;
uint16_t clientPort;
int sock = Sock::Accept(listenSock, &clientIp, &clientPort);
if(sock < 0)
{
logMessage(WARNING, "Accept Error!");
return;
}
// 获取连接成功不能立即调用read等接口,因为底层数据可能没有就绪而导致进程被挂起
if(!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, sock, EPOLLIN)) return;
logMessage(NORMAL, "Add New Sock %d To Epoll Success", sock);
}
void Recver(int sock)
{
char buffer[1024];
int n = read(sock, buffer, sizeof(buffer) - 1);
if(n > 0)
{
// 假设这里获取到的数据就是一个完整的报文
// 但实际上不一定是一个完整的报文,需要通过
// 定制协议来保证,在Reactor模式中统一讲解
buffer[n] = 0;
_callBack(buffer); // 将获取到的数据交给实际的业务进行处理,实现一定程度的解耦
}
else if(n == 0)
{
// 先让epoll不要关心该文件描述符了,然后才能关闭该文件描述符
// 如果先关闭文件描述符的话,epoll将认为该文件描述符是无效的
// 去除对文件描述符的关心,不需要设置时间
bool ret = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(ret);
(void)ret;
close(sock);
logMessage(NORMAL, "Client Quit, Me Too");
}
else
{
bool ret = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(ret);
(void)ret;
close(sock);
logMessage(WARNING, "Read Error, Close Sock: %d", sock);
}
}
private:
uint16_t _port;
int _listenSock;
int _epfd;
struct epoll_event* _readyEvents; // 用于接收就绪事件的集合
int _maxEvents; // 一次接收就绪事件的上限
func_t _callBack; // 处理业务逻辑的回调函数
};
#endif
相关说明:
业务逻辑处理
#include "EpollServer.hpp"
#include
// 业务逻辑处理:将小写字母转为大写字母
void callBack(std::string request)
{
for(int i = 0; i < request.size(); ++i)
{
if(islower(request[i]))
{
request[i] = toupper(request[i]);
}
}
std::cout << "callBack: " << request << std::endl;
}
int main()
{
std::unique_ptr<EpollServer> svr(new EpollServer(callBack));
svr->Start();
return 0;
}
功能测试
将 epoll 服务器与业务逻辑处理解耦的好处就是:业务逻辑处理可以千变万化,但是 epoll 服务可以一成不变地接收连接和数据。
注:网上有些博客说,epoll 中使用了内存映射机制(内存映射机制:内核直接将就绪队列通过 mmap 的方式映射到用户态,避免了拷贝内存这样的额外性能开销)。这种说法是不准确的,我们定义的 struct epoll_event 是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的。
与 poll 模式的事件宏相比,epoll 模式新增了一个事件宏 EPOLLET,即边缘触发模式(Edge Trigger,ET),我们称默认的模式为水平触发模式(Level Trigger,LT)。这种模式的区别在于:
这两个词汇来自电学术语,我们可以将 fd 上有数据的状态认为是高电平状态,将没数据的状态认为是低电平状态,将 fd 可写状态认为是高电平状态,将 fd 不可写状态认为是低电平状态。那么水平模式的触发条件是处于高电平,而边缘模式的触发条件是新来的一次电信号将当前状态变为高电平状态。
为什么 ET 模式一般会比 LT 模式高效呢?
在 LT模式下,当文件描述符的状态发生变化时,epoll_wait 函数会立即返回并通知应用程序。如果应用程序没有处理完这个文件描述符的事件,那么 epoll_wait 函数会再次返回这个文件描述符的事件,直到应用程序处理完它。
在 ET 模式下,只有在文件描述符状态发生变化时,epoll_wait 函数才会返回。如果应用程序没有处理完该事件,epoll_wait 函数将不会再次返回该事件。
ET 模式会要求程序员一次就把底层的数据取走,这样就可以避免过多的系统调用和数据拷贝,提高 IO 的效率。而 LT 模式是如果没有将底层的数据取完,那么 epoll_wait 会一直告诉用户底层数据没有取完,进而出现多次调用 epoll_wait 的情况。但是当 LT 模式也一次将底层数据取完,这时 LT 模式的效率和 ET 模式的效率是一样的。因此,ET 模式一般会比 LT 模式高效。
注:ET 模式会倒逼程序员尽快将接收缓冲区中的数据全部取走,应用层能够尽快地将缓冲区的数据取走,那么在单位时间内,该模式下工作的服务器就可以在一定程度上给发送方同步一个更大的接收窗口,所以对方就可以有更大的滑动窗口,就能向我们发送更多的数据,提高 IO 吞吐量。
为什么 ET 模式需要将文件描述符设置为非阻塞呢?
ET 模式要求应用层在下一次调用 epoll_wait 前就将底层的数据读取完,而一次就要将数据读取完就必须要一直循环读取。那么在最后一次读取完毕时,我们必须还有进行下一次读取(因为无法确认是否读取完)。如果此时文件描述符没有被设置成非阻塞,那么此次读取必定会导致进程被挂起,而进程被挂起在多路转接中是一定不被允许的。为了避免这个问题,所以在 ET 模式下工作的服务器都需要将文件描述符设置成非阻塞,只要一直循环读取到出错并且错误码为 EWOULDBLOCK(EAGAIN)就表明底层数据已经读取完了。
LT 模式是 epoll 的默认模式,使用 ET 模式能够减少 epoll 触发的次数,但是代价就是强逼着程序员在一次响应就绪过程中就把所有的数据都处理完。
相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比 LT 模式更高效一些。但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。
另一方面,ET 模式的代码复杂程度更高了。
epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。
例如:典型的一个需要处理上万个客户端的服务器和各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。
如果只是系统内部的服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型。
本篇博客主要讲解了多路转接之 select、poll 和 epoll,分析了它们的函数原型、优缺点和应用场景等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!❣️