【Linux高级IO】Linux多路转接:深入探索poll与epoll的奥秘
📝个人主页🌹:Eternity._
⏩收录专栏⏪:Linux “ 登神长阶 ”
🌹🌹期待您的关注 🌹🌹
❀ Linux高级IO
- 多路转接:poll
- poll函数接口
- poll优缺点
- 多路转接:epoll
- epoll的相关系统调用
- epoll工作原理
- epoll的优点
- epoll工作方式
- 理解ET模式和非阻塞文件描述符
- 总结
前言:在现代的Linux网络编程中,高效地管理多个并发连接是服务器性能优化的核心挑战之一。为了应对这一挑战,Linux操作系统提供了多种I/O多路复用技术,其中poll和epoll作为两种重要的机制,在提升系统资源利用率和处理效率方面发挥着关键作用。
poll
作为早期的一种多路转接方案,解决了select函数中文件描述符数量有限和每次调用都需要重新设置的问题。然而,随着网络技术的发展和服务器负载的不断增加,poll在某些场景下也显露出了性能瓶颈。此时,epoll作为Linux 2.6内核引入的一种更为高效的I/O多路复用机制,凭借其出色的性能和灵活性,逐渐成为高性能服务器应用的首选。
epoll
不仅克服了poll和select的诸多限制,如文件描述符数量的限制和每次调用时的效率问题,还引入了边缘触发(Edge Triggered)的工作模式,进一步提高了应用程序的响应速度和系统资源的利用率。通过只通知那些真正发生了I/O事件的文件描述符,epoll显著减少了不必要的上下文切换和CPU资源的浪费。
让我们携手踏上这段探索之旅,一同揭开Linux高级I/O的神秘面纱。
多路转接:poll
在Linux系统中,多路转接技术是一种重要的I/O处理机制,它允许单个线程同时监控多个文件描述符(例如套接字)上的事件,从而有效地管理多个并发连接。由于poll与之前所提到过的select有许多相似之处,所以我们对于poll将只进行简单的介绍
poll函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数说明:
- fds是一个poll函数监听的结构列表,每一个元素中,包含了三部分内容:文件描述符、监听的事件集合、 返回的事件集合
- nfds表示fds数组的长度
- timeout表示poll函数的超时时间,单位是毫秒(ms)
events和revents的取值:
返回结果:
- 返回值小于0,表示出错
- 返回值等于0,表示poll函数等待超时
- 返回值大于0,表示poll由于监听的文件描述符就绪而返回
注意:poll中socket就绪条件和select是一样的
poll_server.hpp:
#pragma once
#include <iostream>
#include <string>
#include <poll.h>
#include "Log.hpp"
#include "Socket.hpp"
using namespace Net_Work;
const static int gdefaultport = 8888;
const static int gbacklog = 8;
const int gnum = 1024;
class PollServer
{
private:
void HandlerEvent()
{
for (int i = 0; i < _num; i++)
{
if (_rfds[i].fd == -1)
continue;
// 合法的fd
// 读事件分两种,一类是新链接的到来,一类是新数据的到来
int fd = _rfds[i].fd;
short revent = _rfds[i].revents;
if (revent & POLLIN)
{
// 读事件就绪 -> 新链接的到来
if (fd == _listensock->GetSocket())
{
lg.LogMessage(Info, "get a new link\n");
std::string clientip;
uint16_t clientport;
// 这里不会阻塞,因为select已经检测到listensock就绪了
int sock = _listensock->AcceptConnection(&clientip, &clientport);
if (!sock)
{
lg.LogMessage(Error, "accept error\n");
continue;
}
lg.LogMessage(Info, "get a client, client info is# %s:%d, fd:%d\n", clientip.c_str(), clientport, sock);
// 获取成功了,但是我们不能直接读写,底层的数据不确定是否就绪
// 新链接fd到来时,要把新链接fd交给select托管 --- 只需要添加到数组_rfds_array中即可
int pos = 0;
for (; pos < _num; pos++)
{
if (_rfds[pos].fd == -1)
{
_rfds[pos].fd = sock;
break;
}
}
if (pos == _num)
{
// 1. 扩容
// 2. 关闭
close(sock);
lg.LogMessage(Warning, "server is full ... !\n");
}
}
// 新数据的到来
else
{
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer-1), 0);
if(n)
{
buffer[n] = 0;
lg.LogMessage(Info, "client say# %s\n", buffer);
std::string message = "你好,";
message += buffer;
send(fd, message.c_str(), message.size(), 0);
}
else
{
lg.LogMessage(Warning, "client quit, maybe close or error, close fd: %d\n", fd);
close(fd);
// 取消对poll的关心
_rfds[i].fd = -1;
_rfds[i].events = 0;
_rfds[i].revents = 0;
}
}
}
}
}
public:
PollServer(int port = gdefaultport)
: _port(port)
, _listensock(new TcpSocket())
,_isrunning(false)
,_num(gnum)
{
}
void InitServer()
{
_listensock->BuildListenSocketMethod(_port, gbacklog);
_rfds = new struct pollfd[_num];
for(int i = 0; i < _num; i++)
{
_rfds[i].fd = -1;
_rfds[i].events = 0;
_rfds[i].revents = 0;
}
// 刚开始时,只有一个文件描述符listensock
_rfds[0].fd = _listensock->GetSocket();
_rfds[0].events |= POLLIN;
}
void Loop()
{
_isrunning = true;
while (_isrunning)
{
// 定义时间
int timeout = 1000;
// rfds本质是一个输入输出型参数,rfds是在select调用返回的时候,不断被修改,所以每次都要重置
// PrintDebug();
int n = poll(_rfds, _num, timeout);
switch (n)
{
case 0:
lg.LogMessage(Info, "poll timeout ... \n");
break;
case -1:
lg.LogMessage(Error, "poll error !!! \n");
default:
lg.LogMessage(Info, "poll success, begin event handler\n");
HandlerEvent();
break;
}
}
_isrunning = false;
}
void stop()
{
_isrunning = false;
}
~PollServer()
{
delete []_rfds;
}
private:
std::unique_ptr<Socket> _listensock;
int _port;
bool _isrunning;
struct pollfd *_rfds;
int _num;
};
poll代码完整示例
poll优缺点
poll的优点:
- 可以等待多个fd,效率高
- 输入,输出参数分离,events和revents,不用在频繁的对poll参数进行重置
- poll关心的fd没有上限
poll的缺点:
- 用户到内核空间,要有数据拷贝 — 必要开销
- poll应用层,要遍历。在内核层面,遍历检测,关心的fd是否有对应的事件就绪
poll作为Linux中的多路转接技术之一,在处理多个并发连接时具有一定的优势。然而,随着网络技术的发展和服务器负载的不断增加,poll在某些场景下可能无法满足高性能的需求。因此,在实际应用中需要根据具体场景选择合适的I/O多路复用技术。
多路转接:epoll
epoll是Linux下多路复用I/O接口select/poll的增强版本,旨在提高程序在大量并发连接中只有少量活跃情况下的系统CPU利用率。按照man手册的说法:是为处理大批量句柄而作了改进的poll,但其实epoll和poll还是有很大差别的
epoll的相关系统调用
epoll 有3个相关的系统调用:
epoll_create
epoll_ctl
epoll_wait
epoll_create:
int epoll_create(int size);
epoll_create的功能是创建一个epoll的句柄,自从linux2.6.8之后,size参数是被忽略的,注意用完之后, 必须调用close()关闭
epoll_ctl:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型
- 第一个参数是epoll_create()的返回值(epoll的句柄)
- 第二个参数表示动作,用三个宏来表示
- 第三个参数是需要监听的fd
- 第四个参数是告诉内核需要监听什么事
第二个参数的取值:
EPOLL_CTL_ADD
:注册新的fd到epfd中EPOLL_CTL_MOD
:修改已经注册的fd的监听事件EPOLL_CTL_DEL
:从epfd中删除一个fd
struct epoll_event结构如下:
events可以是以下几个宏的集合:
宏 | 含义 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来); |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的 |
EPOLLONESHOT | 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里 |
epoll_wait:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_wait的功能是收集在epoll监控的事件中已经发送的事件
- 参数events是分配好的epoll_event结构体数组
- epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)
- maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size
- 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞)
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败
epoll工作原理
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关
关于epoll的使用其实就只有三步:
- 调用epoll_create创建一个epoll句柄
- 调用epoll_ctl,将要监控的文件描述符进行注册
- 调用epoll_wait,等待文件描述符就绪
epoll完整代码示例
epoll的优点
- 接口使用方便: 虽然拆分成了三个函数,反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,同时做到了输入输出参数分离开
- 数据拷贝轻量: 只在合适的时候调用
EPOLL_CTL_ADD
将文件描述符结构拷贝到内核中,这个操作并不频繁(select/poll都是每次循环都要进行拷贝)- 事件回调机制: 避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
- 没有数量限制: 文件描述符数目无上限
注意:有人说,epoll中使用了内存映射机制,这种说法是不准确的,我们定义的struct epoll_event是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的
我们来看看内存映射机制是什么:
- 内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样的额外性能开销
epoll工作方式
我们来举个生活中例子:你在网上网购了一袋零食,快递员送到你家楼下的时候,这时候就有可能出现两种方式:
- 新来的快递员小王,在楼下叫你下楼取快递,你可能觉得他好欺负,就没有下楼,小王就一直在楼下喊你,一遍,两遍,三遍 (水平触发)
- 后面换了快递员小赵来给你送快递,在楼下叫了你一次,你没下来,他也没惯着你,就离开去送下一个快递了(边缘触发)
epoll有2种工作方式:
- 水平触发(Level Triggered简称LT)
- 边缘触发(Edge Triggered简称ET)
水平触发Level Triggered 工作模式:
epoll默认状态下就是LT工作模式
,当epoll检测到socket上事件就绪的时候,可以不立刻进行处理或者只处理一部分
- 当我们写入2K数据时,第一次调用epoll_wait可能只读了1K数据,缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回
- 支持阻塞读写和非阻塞读写
边缘触发Edge Triggered工作模式:
将socket添加到epoll描述符的时候使用EPOLLET标志,epoll进入ET工作模式
- 当epoll检测到socket上事件就绪时,必须立刻处理.=
- 如上面的例子,虽然只读了1K的数据,缓冲区还剩1K的数据,在第二次调用 epoll_wait 的时候,epoll_wait 不会再返回了
- ET模式下,文件描述符上的事件就绪后,只有一次处理机会
- ET的性能比LT性能更高( epoll_wait 返回的次数少了很多),Nginx默认采用ET模式使用epoll
- 只支持非阻塞的读写
select和poll其实也是工作在LT模式下,epoll既可以支持LT,也可以支持ET
对比LT和ET:
- LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把
所有的数据都处理完.- 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到
每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.- 另一方面, ET 的代码复杂程度更高了
理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞
- 假设这样的场景:服务器接受到一个10k的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个10k请求
- 如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话,剩下的9k数据就会待在缓冲区中
- 此时由于 epoll 是ET模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait 才能返回
到这里初见端倪:
- 服务器只读到1k个数据,要10k读完才会给客户端返回响应数据
- 客户端要读到服务器的响应
- 客户端发送了下一个请求,epoll_wait 才会返回, 才能去读缓冲区中剩余的数据
为了解决上述问题(阻塞read不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来,如果是LT没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪
总结
随着我们对Linux中的多路转接机制,特别是poll和epoll的深入探讨,这段学习之旅已接近尾声。从最初的概念理解,到深入的工作原理分析,再到实际应用中的性能考量,我们一步步揭开了poll和epoll的神秘面纱。
epoll以其高效的处理能力和扩展性成为了高性能网络编程的首选。epoll通过减少不必要的系统调用和内存拷贝,以及利用内核级的回调机制,显著提高了I/O事件的处理效率。特别是在处理大量并发连接时,epoll的性能优势尤为明显。
每一次的学习都是一次自我提升的机会,每一次的实践都是向更高目标迈进的步伐。愿我们在技术的道路上越走越远,共创更加辉煌的未来!
希望本文能够为你提供有益的参考和启示,让我们一起在编程的道路上不断前行!
谢谢大家支持本篇到这里就结束了,祝大家天天开心!