文章目录
- 一、select方案和poll方案还存在的缺陷
- 二、epoll的认识
- 1.epoll的基本认识
- 2.epoll的原理
- 3.epoll函数接口
- 三、编写epoll服务器
- 四、epoll工作方式
- 1.LT模式
- 2.ET模式
一、select方案和poll方案还存在的缺陷
多路转接方案一开始是select方案,但是select方案缺点比较多,所以在select的基础上poll方案做了改进,使用起来比select确实好太多了。但poll方案依旧存在缺陷。
- select方案和poll方案都是基于对多个文件描述符进行遍历检测来识别事件的,当客户端连接数量多的时候,一定会引起遍历周期的增加,也就会导致效率相应地下降。
- 站在用户使用的角度,select方案和poll方案都需要用户自己维护一个数组结构,select方案要维护一个文件描述符的数组,poll方案也要维护一个结构体的数组,这样会让使用成本稍微比较大。
二、epoll的认识
1.epoll的基本认识
epoll是多路转接方案中,比select方案和poll方案更进一步改进的方案。epoll方案有3个相关的系统调用接口,分别是epoll_create
、epoll_ctl
、epoll_wait
。
2.epoll的原理
当网络有信息到来时,在硬件层面首先收到信息的是网卡设备,网卡收到信息之后会通过中断机制告诉CPU有数据到来了,CPU通过操作系统的内核拷贝函数将网卡的数据拷贝到内存。该拷贝函数允许传递一个回调函数,在创建epoll模型的时候,epoll会针对特定的一个或者多个文件描述符,设定对应的回调机制。当文件描述符的缓冲区中有数据到来的时候,就会进行回调。有了回调机制,epoll底层就不需要每次都对所有的文件描述符进行轮询检测,而是当有数据到来的时候会调用对应的回调函数,自动地会通知epoll有数据到来了。
在创建epoll模型的时候,操作系统还会为epoll创建一棵红黑树,这棵红黑树的节点里有两个很重要的结构,分别是int fd
和int events
,这个结构代表了用户想要关心的哪一个文件描述符上面的什么事件。这一棵红黑树就是用来保存用户输入给了操作系统哪些文件描述符的哪些事件。
除此之外,操作系统还会为epoll模型维护一条就绪队列,这个队列上的节点也有两个很重要的结构,分别是int fd
和int revents
,这个结构代表了操作系统想要告诉用户,在红黑树中哪些文件描述符上面的哪些事件已经就绪了。
现在epoll模型已经具有了回调机制、红黑树和就绪队列,我们再来理一下这整一个过程:当用户告诉操作系统它想关心哪个文件描述符的哪个事件是否就绪时,操作系统会为用户创建一个红黑树节点,填充对应的文件描述符和事件的节点信息,然后将该节点插入到红黑树当中,并且在底层注册回调信息。当网卡有数据到来时,网卡会通过中断机制给CPU发送光电信号,告诉CPU有数据到来了,CPU会让操作系统调用网卡驱动层的内核拷贝函数,将网卡内的数据拷贝上来,该函数拷贝完数据之后会调用回调函数,调用回调函数之后就会在红黑树中查找对应的节点,获取到就绪事件是什么以及就绪事件的文件描述符是什么,然后构建一个队列节点,填充文件描述符和就绪事件的节点信息,最后将该节点插入到就绪队列当中。自此就完成了epoll等待事件就绪的全过程。
所以,回调机制、红黑树、就绪队列统称为epoll模型。
3.epoll函数接口
epoll_create:
epoll_create函数用来帮我们创建一个epoll模型,在底层创建一棵空的红黑树和一个空的就绪队列,并建立回调机制。成功创建会返回一个文件描述符,失败了返回-1并且设置错误码。
int epoll_create(int size);
自从Linux2.6.8之后,epoll_create函数的size参数是被忽略的。创建成功返回的文件描述符在使用完之后必须用close函数关闭。
epoll_ctl:
epoll_ctl函数可以将指定的文件描述符以及该文件描述符需要等待的事件添加到创建好的epoll模型中,或者也可以对其进行修改和删除。简单来说该函数就是帮我们将需要等待的事件告诉内核,对应的相当于select的输入参数,和pollfd的events功能。底层其实就是在epoll模型的红黑树中增删改节点。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
在调用epoll_create成功之后会返回一个文件描述符,epoll_ctl函数的epfd参数就是填入该文件描述符。
op代表的是要执行的动作是什么,EPOLL_CTL_ADD
代表注册新的文件描述符到epfd中,简单理解就是向epoll模型的红黑树中插入一个节点。EPOLL_CTL_MOD
代表修改已经注册的文件描述符的监听事件,对应的就是向epoll模型的红黑树中修改一个节点。EPOLL_CTL_DEL
代表的是从epfd中删除一个文件描述符,对应的就是从epoll模型的红黑树中删除一个节点。
fd代表的就是要操作的文件描述符。
event是一个结构体指针,该结构体具体内容如下图所示,用来填充文件描述符以及该文件描述符需要关心的事件信息。
epoll_wait:
epoll_wait函数可以帮我们从epoll模型中提取已经就绪的事件。简单来说这个函数解决的是内核告诉用户哪些事件已经就绪了,对应的相当于select的输出参数,和pollfd的revents功能。底层其实就是在epoll模型的就绪队列中拿出节点,获取节点中的文件描述符和就绪事件。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
events也是一个结构体指针,实质上该参数要传入一个结构体数组,这个数组里存放的就是就绪队列中已经就绪的文件描述符及其对应的事件。
maxevents填入的是events的元素个数。
timeout填入的是等待时间,单位是毫秒。
三、编写epoll服务器
我们利用TCP套接字的接口,编写一个基于epoll多路转接方案的服务器,做一个简单的建立连接读取客户端数据的demo代码,演示一下epoll方案各种函数接口的使用。
Sock.hpp:
#pragma once
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <pthread.h>
#include <cerrno>
#include <cassert>
class Sock
{
public:
static const int gbacklog = 20;
static int Socket()
{
int listenSock = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock < 0)
{
exit(1);
}
int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listenSock;
}
static void Bind(int socket, uint16_t port)
{
struct sockaddr_in local; // 用户栈
memset(&local, 0, sizeof local);
local.sin_family = PF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
// 2.2 本地socket信息,写入sock_对应的内核区域
if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0)
{
exit(2);
}
}
static void Listen(int socket)
{
if (listen(socket, gbacklog) < 0)
{
exit(3);
}
}
static int Accept(int socket, std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int serviceSock = accept(socket, (struct sockaddr *)&peer, &len);
if (serviceSock < 0)
{
// 获取链接失败
return -1;
}
if(clientport) *clientport = ntohs(peer.sin_port);
if(clientip) *clientip = inet_ntoa(peer.sin_addr);
return serviceSock;
}
};
EpollServer.hpp:
#pragma once
#include <iostream>
#include <string>
#include <sys/epoll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "Sock.hpp"
using namespace std;
class EpollServer
{
public:
EpollServer(uint16_t port)
: _port(port), _listen_sock(-1), _epfd(-1)
{
}
~EpollServer()
{
if (_listen_sock != -1)
{
close(_listen_sock);
}
if (_epfd != -1)
{
close(_epfd);
}
}
void init()
{
// 获取监听套接字
_listen_sock = Sock::Socket();
// bind网络信息
Sock::Bind(_listen_sock, _port);
// 设置监听状态
Sock::Listen(_listen_sock);
// 创建epoll模型
// 该函数参数是随便填的,新的Linux内核已经忽略了该参数
// 这里是为了兼容老内核
_epfd = epoll_create(128);
if (_epfd < 0)
{
cerr << "epoll_create error" << endl;
exit(4);
}
}
void start()
{
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = _listen_sock;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listen_sock, &event);
struct epoll_event revents[_num];
int timeout = 10000;
while (true)
{
int fds_num = epoll_wait(_epfd, revents, _num, timeout);
switch (fds_num)
{
case 0:
cout << "time out..." << endl;
break;
case -1:
cerr << "epoll_wait error..." << endl;
break;
default:
handlerEvents(revents, fds_num);
break;
}
}
}
private:
void handlerEvents(struct epoll_event* revents, int num)
{
for(int i = 0; i < num; i++)
{
int fd = revents[i].data.fd;
int event = revents[i].events;
// 如果是监听套接字的事件就绪,则开始获取连接
if(fd == _listen_sock)
{
string client_ip;
uint16_t client_port;
int sock = Sock::Accept(_listen_sock, &client_ip, &client_port);
if(sock > 0)
{
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sock;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &event);
}
}
else
{
char buffer[1024];
ssize_t readRes = read(fd, buffer, sizeof(buffer) - 1);
if(readRes > 0)
{
buffer[readRes] = '\0';
cout << buffer << endl;
}
else if(readRes == 0)
{
cout << "客户端已经关闭了" << endl;
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
else
{
cerr << "read error" << endl;
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}
private:
int _listen_sock;
int _epfd;
uint16_t _port;
int _num = 256;
};
EpollServer.cc:
#include <iostream>
#include "EpollServer.hpp"
using namespace std;
int main()
{
EpollServer server(8080);
server.init();
server.start();
return 0;
}
四、epoll工作方式
1.LT模式
LT模式称为水平触发模式。水平触发指的是当我们让epoll等待的事件就绪时,操作系统会通知上层用户事件已经就绪了,如果此时上层用户没有理会操作系统这个提醒,还没有去拷贝数据,操作系统就会一直提醒用户,数据到来了快去取数据吧。所以水平触发强调的是当条件满足时就会一直触发提醒机制。
多路转接的三种方案,select方案、poll方案和epoll方案都是默认采用LT模式的,即只要底层有数据,操作系统就会一直通知你。这个场景是可以模拟出来的,当我们的服务器创建好监听套接字并且设置为监听状态时,就在等待连接的到来。一旦我们这时候连接上服务器,操作系统会一直给我们提醒,我们可以让连接到来的时候服务器输出一条消息代表操作系统的提醒,展示一下这个场景:
当我们启动服务器,在等待连接时,如果没有连接到来,epoll会帮我们等待,等待超时会提示:
此时我们可以用telnet工具连接我们的服务器,一旦连接到来了,就会看到服务器疯狂打印连接到来的提示:
LT模式可以让上层用户不用着急着把数据立马读走,即使暂时不读取数据,也不用担心底层不通知我们而导致数据丢失的问题。
2.ET模式
ET模式称为边缘触发。边缘触发指的是当我们等待事件就绪时,操作系统会通知上层用户事件就绪了,如果上层用户没有理会操作系统的提醒,操作系统也就不理会了,不会再进行第二次第三次的提醒了。等到下一个新事件又就绪的时候,操作系统又会来提醒一次。所以边缘触发强调的是当每一次发生变化的时候就会触发一次提醒机制。
ET模式倒逼着上层用户一旦接收到了通知,就必须将自己收到的数据从内核中全部读取完成,否则可能会有数据丢失的风险。
既然ET模式倒逼着我们必须在有数据到来的时候,接到通知就将数据全部读取上来,那么我们如何保证数据是否被全部读取上来呢?这就需要我们循环去调用read函数或者recv函数进行读取,直到读取出错,也就是说读取不到数据了,那就说明数据已经被读取完了。那么问题又来了,如果我们在读取的时候是阻塞式的读取,最后一次读取时一定就是没有数据可以被读取的时候,那么进程就会阻塞在read函数那里,直到有新的数据到来才会继续执行。所以这是不行的,ET模式下所有的文件描述符必须处于非阻塞模式。