前言:在现代的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的神秘面纱。
在Linux系统中,多路转接技术是一种重要的I/O处理机制,它允许单个线程同时监控多个文件描述符(例如套接字)上的事件,从而有效地管理多个并发连接。由于poll与之前所提到过的select有许多相似之处,所以我们对于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 */
};
参数说明:
events和revents的取值:
返回结果:
注意: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作为Linux中的多路转接技术之一,在处理多个并发连接时具有一定的优势。然而,随着网络技术的发展和服务器负载的不断增加,poll在某些场景下可能无法满足高性能的需求。因此,在实际应用中需要根据具体场景选择合适的I/O多路复用技术。
epoll是Linux下多路复用I/O接口select/poll的增强版本,旨在提高程序在大量并发连接中只有少量活跃情况下的系统CPU利用率。按照man手册的说法:是为处理大批量句柄而作了改进的poll,但其实epoll和poll还是有很大差别的
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_CTL_ADD
:注册新的fd到epfd中EPOLL_CTL_MOD
:修改已经注册的fd的监听事件EPOLL_CTL_DEL
:从epfd中删除一个fdstruct 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监控的事件中已经发送的事件
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关
关于epoll的使用其实就只有三步:
EPOLL_CTL_ADD
将文件描述符结构拷贝到内核中,这个操作并不频繁(select/poll都是每次循环都要进行拷贝)注意:有人说,epoll中使用了内存映射机制,这种说法是不准确的,我们定义的struct epoll_event是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的
我们来看看内存映射机制是什么:
我们来举个生活中例子:你在网上网购了一袋零食,快递员送到你家楼下的时候,这时候就有可能出现两种方式:
epoll有2种工作方式:
水平触发Level Triggered 工作模式:
epoll默认状态下就是LT工作模式
,当epoll检测到socket上事件就绪的时候,可以不立刻进行处理或者只处理一部分
边缘触发Edge Triggered工作模式:
将socket添加到epoll描述符的时候使用EPOLLET标志,epoll进入ET工作模式
select和poll其实也是工作在LT模式下,epoll既可以支持LT,也可以支持ET
对比LT和ET:
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞
到这里初见端倪:
为了解决上述问题(阻塞read不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来,如果是LT没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪
随着我们对Linux中的多路转接机制,特别是poll和epoll的深入探讨,这段学习之旅已接近尾声。从最初的概念理解,到深入的工作原理分析,再到实际应用中的性能考量,我们一步步揭开了poll和epoll的神秘面纱。
epoll以其高效的处理能力和扩展性成为了高性能网络编程的首选。epoll通过减少不必要的系统调用和内存拷贝,以及利用内核级的回调机制,显著提高了I/O事件的处理效率。特别是在处理大量并发连接时,epoll的性能优势尤为明显。
每一次的学习都是一次自我提升的机会,每一次的实践都是向更高目标迈进的步伐。愿我们在技术的道路上越走越远,共创更加辉煌的未来!