Welcome to 9ilk's Code World

(๑•́ ₃ •̀๑) 个人主页: 9ilk
(๑•́ ₃ •̀๑) 文章专栏: Linux
本篇博客主要是对Linux下多路转接技术select/poll/epoll的梳理总结。
多路转接,也叫多路复用,是一种能对多个描述符进行等待的手段,进而通知上层哪些fd已经就绪,本质是一种对IO事件就绪的通知机制。常见的多路转接方式有select、poll、epoll。
select
cpp
NAME
select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing
SYNOPSIS
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数说明
int nfds
该参数代表等待的多个fd中,fd的最大值+1。
struct timeval* timeout
该参数代表了应用层的三种等待方式:
-
阻塞等待:该参数设置为NULL,等待多个fd的IO事件时,没有一个fd就绪就一直阻塞,如果存在一个fd就绪,select就会返回。
-
timeout方式:通过设置结构体参数,代表超时时间,比如(5,0)就是tv_sec为5,tv_usec为0,代表的是当从前时间开始,5s之内阻塞等待,超时之后立即返回。这个参数还是一个输入输出型参数,当select返回时,这个参数表示剩余多少时间。
cpp
struct timeval {
time_t tv_sec; /* seconds */
useconds_t tv_usec; /* microseconds */
};
The corresponding argument for pselect() has the following type:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
- 非阻塞等待:将timeval设置为(0,0)就是非阻塞等待,即等待多个fd,没有一个就绪则立即返回,如果有就绪的也是立即返回,因此需要进行轮询。
返回值
- n > 0 :表示就绪的fd个数
- n < 0 :-1表示select等待失败,比如其中一个fd已经被close了,但你还交给select监控IO事件
- n == 0 :如果你设置的不是阻塞IO,则该返回值表示超时了,底层没有fd就绪,也没有出错
fd_set
fd_set是个集合数据类型,是OS提供给用户的数据类型,readfds、writefds、exceptfds分别表示读文件描述符集、写文件描述符集、异常文件描述符集。这个结构本质是个位图 ,比特位位置表示文件描述符,比特位内容表示的是是否关心该fd的某个事件。
我们把fd交给select,一般是让他帮我们干以下三件事:
- 关心读事件->底层的文件描述符是否可读->接收缓冲区是否有数据。
- 关心写事件->底层fd是否可写->发送缓冲区是否有空间。
- 关心异常事件:fd是否出现异常,比如fd是一个错误的文件描述符。
正因为关心这三类,所以参数有三个fd_set,比如你的fd只关心读,fd添加到read_fdset当中,如果你既关心读又关心写,那你可以既添加到write_fdset也可以添加到write_fdset;如果你想先读再写,你可以先扔到read_fdset,读取完毕再扔到writefdset,因此这三个参数本质是可以把指定fd单个/同时/选择的设置进某个集合里,让select关心该fd特定的事件!
cpp
typedef __kernel_fd_set fd_set;
typedef struct {
unsigned long fds_bits [__FDSET_LONGS];
} __kernel_fd_set;
#undef __NFDBITS
#define __NFDBITS (8 * sizeof(unsigned long))
#undef __FD_SETSIZE
#define __FD_SETSIZE 1024
#undef __FDSET_LONGS
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)
我们可以看到fd_set本质上就是一个内部封装了固定长度的unsigned long数组,也就说fd_set是一种具体的数据类型,内部不是指针或柔性数组成员,这注定了这是固定大小,换句话话,select能管理的fd个数是有上限的!
由于能管理的fd个数是有限的,因此select适用于小型应用,更大型可以考虑使用poll、epoll。这个上限 = 数组大小*8 = 总共bit位数量 = fd数
cpp
fd_set fds
sizeof(fds)*8
对于select这几个事件集,它们是当做输出输入型参数的,以read_fds为例子:
(1)输入的时候:用户告诉内核,你要帮我关心位图中被设置了的fd上的读事件
(2)输出的时候:内核告诉用户,你让我关心的read_fds中,哪些fd的读事件已经就绪了,此时对就绪的fd进行读取,读取的时候一定不会被阻塞,因为内核告诉我们读事件已经就绪了。
需要注意的是:对于某个fd,如果在输入的时候你没有设置进行位图中,当你就绪的时候,输出的fd集中是没有该fd的,你没有设置进输入集,OS认为你并不关心。
既然需要提交给系统输入集,那不可避免要对位图操作,在系统中,封装了几个能批量化对位图操作的方法:
cpp
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);
- FD_SET:将指定fd添加到集合
- FD_ZERO:将指定fd集清空
- FD_CLR:将指定fd从集合中移除
- FD_ISSET:判断指定fd是否在集合中
Select Server
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include "Socket.hpp"
using namespace std;
using namespace SocketModule;
using namespace LogModule;
#define NUM sizeof(fd_set) * 8
const int gdefaultfd = -1;
class SelectServer
{
public:
SelectServer(int port)
: _port(port),
_listen_socket(make_unique<TcpSocket>()),
_isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocket(_port); // 创建套接字
}
void loop()
{
}
~SelectServer()
{
}
private:
int _port;
unique_ptr<Socket> _listen_socket;
bool _isrunning;
};
在开始启动服务器时,我们需要把监听套接字listen_sockfd给加入输入集中,对于它来说,它只关心读事件就绪,而IO = 读 + 拷贝,我们应该让select帮我们等,select告诉我们有链接就绪,再从特定fd中读取,因此我们需要将监听套接字添加到select内部管理,在获取新链接的时候就不会阻塞:
cpp
void loop()
{
_isrunning = true;
fd_set rfds;
while (_isrunning)
{
// 清空 rfds
FD_ZERO(&rfds);
// 将listensockfd添加到rfds中
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {12, 0};
// 不能让accpet来阻塞检测连接到来 而应该让select负责
int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out..." << endl;
break;
case -1:
perror("select");
break;
default: // 有事件就绪
cout << "有事件就绪..." << endl;
//..
break;
}
}
_isrunning = false;
}
但是这样存在的问题是:当有新链接到来,正常情况下你应该把链接获取,如果没获取该fd就一直处于读事件就绪状态,而每次select都要关心监听套接字,你不获取上来,每次select都能检测到连接,一直通知你直到你处理该读事件,因此就绪之后我们需要及时处理新链接,未来除了要监管监听套接字之外,我们还可能要监管IO套接字,因此我们统一封装一个函数HandlerEvents来处理:
cpp
void HandlerEvents(fd set &rfds)
{
//判断listensockfd,是否在rfds中!
if(FD_ISSET(_listen_socket->Fd(),&rfds))
{
InetAddr client;
//listensockfd就绪了!获取新连接不就好了吗?
int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
if(newfd<0)
return;
else
{
std::cout<<"获得了一个新的链接:"<<newfd <<" c)lientinfo:"<<client.Addr()<<std::endl;
break;
}
}
}
void loop()
{
_isrunning = true;
fd_set rfds;
while (_isrunning)
{
// 清空 rfds
FD_ZERO(&rfds);
// 将listensockfd添加到rfds中
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {12, 0};
// 不能让accpet来阻塞检测连接到来 而应该让select负责
int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out..." << endl;
break;
case -1:
perror("select");
break;
default: // 有事件就绪
cout << "有事件就绪..." << endl;
HandlerEvents(rfds);
//..
break;
}
}
_isrunning = false;
}
和之前阻塞IO不同的是,执行到accept时,并不会出现阻塞,因为select已经告诉我们监听套接字读事件就绪了,不会被阻塞,用户处理事件时,执行的就只是IO中的拷贝。在获取到新链接之后,不能直接读,因为客户端可能只是刚连上你,数据还没发送,那么就需要服务器先等后拷,该事件什么就绪,我们并不清楚,因此我们需要把新链接newfd也交给select进行托管,让他帮我们关心newfd上的读事件。
select参数中的fd_set是用来进行用户和内核之间的数据传递的,只要调用select,我们之前输入时传给select的rfds就可能被修改了,比如开始时关心1、2、3、4、5,就绪的时候只有1就绪了,其他的fd对应的bit位被置为0,下次调用时,这些没就绪的fd也还要继续被关心的,这就要求select每次调用过后,都要独自输入参数进行重新设置,timeout也是要重新设置,否则会返回剩余时间,下一次传给select的超时时间就不对了,所以代码中我们将read_fds是在循环体外定义的,而对其的位图操作是在循环体内进行的。
但我们要考虑一个问题是,我们在获取新链接之后,需要将newfd添加到select中管理,问题是在退出HandlerEvent之后,select已经调用结束了,我们如何在下次循环时设置进输入集中呢?这就需要我们要有个设置源,存储了你目前关心的fd有哪些,有了这个设置源,我们就可以保存历史上所有的文件描述符,方便多次添加到fd_set中,这个设置源我们叫辅助数组,每次循环时,根据这个辅助数组设置输入集告诉select关系哪些fd上的读事件,由于select能管理的fd是存在上限的,因此我们不用关心数组遍历的时间复杂度问题:
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include "Socket.hpp"
using namespace std;
using namespace SocketModule;
using namespace LogModule;
#define NUM sizeof(fd_set) * 8
const int gdefaultfd = -1;
class SelectServer
{
public:
SelectServer(int port)
: _port(port),
_listen_socket(make_unique<TcpSocket>()),
_isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocket(_port); // 创建套接字
for (int i = 0; i < NUM; i++) // 初始化辅助数组
_fd_array[i] = -1;
_fd_array[0] = _listen_socket->Fd(); // 监听套接字先设置进辅助数组
}
void loop()
{
_isrunning = true;
fd_set rfds;
while (_isrunning)
{
// 清空 rfds
FD_ZERO(&rfds);
// 将listensockfd添加到rfds中
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {12, 0};
int maxfd = gdefaultfd;
// 判断辅助数组中合法的fd
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
FD_SET(_fd_array[i], &rfds); // 合法fd设置进要关心的读fd集,
// 更新最大值
if (maxfd < _fd_array[i])
maxfd = _fd_array[i];
}
// 不能让accpet来阻塞检测连接到来 而应该让select负责
int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out..." << endl;
break;
case -1:
perror("select");
break;
default: // 有事件就绪
cout << "有事件就绪..." << endl;
HandlerEvents(rfds); //任务派发
break;
}
}
_isrunning = false;
}
~SelectServer()
{
}
private:
int _port;
unique_ptr<Socket> _listen_socket;
bool _isrunning;
int _fd_array[NUM]; // 辅助数组
};
有了辅助数组,newfd托管给select就很简单了,将newfd添加到辅助数组即可,HandlerEvents返回的时候会重新进入循环,根据辅助数组将newfd设置进输入集中:
cpp
void HandlerEvents(fd set &rfds)
{
//判断listensockfd,是否在rfds中!
if(FD_ISSET(_listen_socket->Fd(),&rfds))
{
InetAddr client;
//listensockfd就绪了!获取新连接不就好了吗?
int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
if(newfd<0)
return;
else
{
cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
// recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
int pos = -1;
for (int j = 0; j < NUM; j++)
{
if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
{
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了...\n";
close(newfd);
}
else
_fd_array[pos] = newfd;
}
}
}
此时select返回的输出集中,就绪的fd不仅有监听套接字,也可能有IO套接字,但是这些fd只能是辅助数组的子集,因此在处理就绪事件时,我们只能遍历辅助数组,拿着每一个合法的fd(即fd_array[i] != gdefaultfd),如果该fd在输出集中,则说明该fd是就绪的,根据已知的监听套接字判断是什么类型的套接字进行不同处理:
cpp
void HandlerEvents(fd set &rfds)
{
//判断listensockfd,是否在rfds中!
if(FD_ISSET(_listen_socket->Fd(),&rfds))
{
InetAddr client;
//listensockfd就绪了!获取新连接不就好了吗?
int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
if(newfd<0)
return;
else
{
cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
// recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
int pos = -1;
for (int j = 0; j < NUM; j++)
{
if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
{
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了...\n";
close(newfd);
}
else
_fd_array[pos] = newfd;
}
}
//IO套接字
else
{
if (FD_ISSET(_fd_array[i], &rfds))
{
// 合法&&就绪普通fd
char buffer[1024];
ssize_t n = ::recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0); // 不会阻塞 因为已经就绪
if (n > 0)
{
buffer[n] = 0;
cout << "client# " << buffer << endl;
string message = "echo# ";
message += buffer;
send(_fd_array[i], message.c_str(), message.size(), 0); // 回显 这里先不关心写事件就绪 TODO
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[i];
close(_fd_array[i]);
_fd_array[i] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
}
else
{
LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[i];
close(_fd_array[i]);
_fd_array[i] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
}
}
}
}
未来一个IO套接字,不一定是只关心读事件,还可能关心写事件、异常事件,只要再设置两个辅助数组即可。到目前为止,HandlerEvent本质做的工作其实就是把已经就绪的sockfd进行不同处理,因此我们可以单独将监听套接字划分到Accept模块处理,IO套接字划分到Recv模块处理,这其实就是事件的派发(Dispatcher),select的工作就很明显了,也就是等+通知(派发sockfd到不同模块):
cpp
void loop()
{
_isrunning = true;
fd_set rfds;
while (_isrunning)
{
// 清空 rfds
FD_ZERO(&rfds);
// 将listensockfd添加到rfds中
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {12, 0};
int maxfd = gdefaultfd;
// 判断辅助数组中合法的fd
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
FD_SET(_fd_array[i], &rfds); // 合法fd设置进要关心的读fd集,
// 更新最大值
if (maxfd < _fd_array[i])
maxfd = _fd_array[i];
}
// 不能让accpet来阻塞检测连接到来 而应该让select负责
int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out..." << endl;
break;
case -1:
perror("select");
break;
default: // 有事件就绪
cout << "有事件就绪..." << endl;
Dispacher(rfds); //任务派发
break;
}
}
_isrunning = false;
}
void Dispacher(fd_set &rfds) // rfds不仅仅是监听套接字就绪 还有普通IO
{
// 我们还是要判断listensockfd是否在rfds中,即我们要关心的
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
if (_fd_array[i] == _listen_socket->Fd())
{
if (FD_ISSET(_listen_socket->Fd(), &rfds))
{
Accept();
}
}
else
{
if (FD_ISSET(_fd_array[i], &rfds))
{
Recev(i);
}
}
}
}
void Accept()
{
InetAddr client;
// 此时listensockfd就绪 此时获取新连接不会阻塞
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0)
return;
else
{
cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
// recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
int pos = -1;
for (int j = 0; j < NUM; j++)
{
if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
{
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了...\n";
close(newfd);
}
else
_fd_array[pos] = newfd;
}
}
void Recev(int who)
{
// 合法&&就绪普通fd
char buffer[1024];
ssize_t n = ::recv(_fd_array[who], buffer, sizeof(buffer) - 1, 0); // 不会阻塞 因为已经就绪
if (n > 0)
{
buffer[n] = 0;
cout << "client# " << buffer << endl;
string message = "echo# ";
message += buffer;
send(_fd_array[who], message.c_str(), message.size(), 0); // 回显 这里先不关心写事件就绪 TODO
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[who];
close(_fd_array[who]);
_fd_array[who] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
}
else
{
LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[who];
close(_fd_array[who]);
_fd_array[who] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
}
}
总结
select的特点
- select可以监控的fd数量是有上限的,取决于sizeof(fd_set)的值
- select的fd集是个输入输出型参数,而且将三种事件分离,用户输入时根据需要设置自己所关心的fd和对应关心的事件,输出时返回的是关心的fd中哪些fd就绪了该事件
- select需要一个辅助数组,保存监控的fd,一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断,二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描amay的同时取得fd最大值maxfd,用于select的第一个参数。
select缺点
- 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd这个开销在fd很多时也很大,fd就绪时,用户态还需要再遍历一次,找出哪些fd就绪
- select支持的文件描述符数量太小,但是注意这是它的设计导致的(固定位图),而不是因为进程或系统能力有限
poll
为什么会存在poll?
- select的输入输出参数是一个位图,导致参数每次都要重置,而poll就帮我们分离了输入输出参数,不需要重置
- poll管理的fd个数是"没有上限"的
cpp
NAME
poll, ppoll - wait for some event on a file descriptor
SYNOPSIS
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明
timeout
- timeout == 0 :非阻塞等待
- timeout < 0:阻塞式等待
- timeout > 0:设置超时时间,毫秒级
返回值
- n > 0:有fd就绪
- n == 0:超时
- n < 0:poll失败,设置errno
struct pollfd * fds && nfds_t nfds
这两个参数组合起来相当于是一个数组,第一个参数表示数组的起始地址,第二个参数代表的是这个数组的元素个数。
cpp
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
通过这个fd数组,poll一次可以等多个描述符的IO事件,调用poll时,用户告诉内核要关心哪个fd上的哪些事件,poll返回时返回的是你关心的fd的子集,内核告诉用户你要关心的哪些fd上面的哪些事件已经就绪了,因此poll和select一样也是一个多个fd的IO事件等待机制,从而达到事件派发的目的。
而对于struct pollfd这个结构体:
- fd代表poll要关心的是哪个fd
- short events:代表用户对该fd要关心的是哪些事件,它和fd组合表示传递给内核要关心哪些fd上的是哪些事件
- short revents:就绪时,表示的是哪些事件就绪了
也就是说,poll是输入输出信息分离的,此时调用poll时就不用频繁地对参数进行重置,而且fd是只读的不会被修改。那怎么理解事件呢?之前select是提供三种事件,也就是三个集合数据结构,那在这里如何理解呢?
cpp
/* These are specified by iBCS2 */
#define POLLIN 0x0001
#define POLLPRI 0x0002
#define POLLOUT 0x0004
#define POLLERR 0x0008
#define POLLHUP 0x0010
#define POLLNVAL 0x0020
/* The rest seem to be more-or-less nonstandard. Check them! */
#define POLLRDNORM 0x0040
#define POLLRDBAND 0x0080
#ifndef POLLWRNORM
#define POLLWRNORM 0x0100
#endif
#ifndef POLLWRBAND
#define POLLWRBAND 0x0200
#endif
#ifndef POLLMSG
#define POLLMSG 0x0400
#endif
#ifndef POLLREMOVE
#define POLLREMOVE 0x1000
#endif
#ifndef POLLRDHUP
#define POLLRDHUP 0x2000
上面的就是poll提供的事件,它们其实就是宏,本质是只有一个bit位互不重复的宏,因此我们的events、revents是位图结构,关心什么事件进行位操作设置就行,但是这里系统没有提供对应操作,需要我们自己进行位操作,同时将来我们还需要一个struct pollfd数组表示poll关心的fd集,达到poll同时等待多个fd的目的,在poll中是会对传入的数组元素进行校验的,比如struct pollfd结构中传入的fd为-1,同时events是0,表示不关心该fd,也不关心其任何事件,因此poll会直接忽略掉该pollfd结果,不会监听其任何事件,也不会报错。
Q:那又该如何理解,poll传递的fd没有上限呢?
select传递的fd存在上限,是因为select传递的数据结构类型fd_set是固定位图,而poll没有写死,而是由用户自己决定如何设置nfds,而且你还可以对struct pollfd数组动态扩容,也就是说,数组的大小此时不由poll本身设计决定,而是由用户和计算机配置决定 ,进程所能打开的fd上限与我这个接口无关,即接口设计上没有上限。
Poll Server
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include<poll.h>
#include "Socket.hpp"
#include<cstring>
#include<errno.h>
using namespace std;
using namespace SocketModule;
using namespace LogModule;
const int gdefaultfd = -1;
#define MAX 1024 //4096就poll出错
// 最开始的时候,tcpserver只有一个listensockfd
class PollServer
{
public:
PollServer(int port)
: _port(port),
_listen_socket(std::make_unique<TcpSocket>()),
_isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocket(_port);
for(int i = 0;i < MAX; i++)
{
_fds[i].fd = gdefaultfd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
// 先把唯一的一个fd添加到poll中
_fds[0].fd = _listen_socket->Fd();
_fds[0].events = POLLIN;
// _fds[0].revents;
}
void loop()
{
int timeout = 5000;
_isrunning = true;
while (_isrunning)
{
// 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测
// 用户告诉内核,你要帮我关心&rfds,读事件啊!!
int num = 0;
// for(int i = 0;i < MAX; i++)
// {
// if(_fds[i].fd != gdefaultfd) num++;
// }
int n = poll(_fds,MAX, timeout); // 通知上层的任务!
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
sleep(2);
perror("Poll");
break;
default:
// 有事件就绪了
// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
std::cout << "有事件就绪啦..., timeout: " << std::endl;
Dispatcher(); // 把已经就绪的sockfd,派发给指定的模块
TestFd();
break;
}
}
_isrunning = false;
}
void Accepter() // 回调函数呢?
{
InetAddr client;
// listensockfd就绪了!获取新连接不就好了吗?
int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
if (newfd < 0)
return;
else
{
std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
// recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪
// 怎么把新的newfd托管给poll?让poll帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!
int pos = -1;
for (int j = 0; j < MAX; j++)
{
if (_fds[j].fd == gdefaultfd)
{
pos = j;
break;
}
}
if (pos == -1)
{
// _fds进行自动扩容
LOG(LogLevel::ERROR) << "服务器已经满了...";
close(newfd);
}
else
{
_fds[pos].fd = newfd;
_fds[pos].events = POLLIN;
}
}
}
void Recver(int who) // 回调函数?
{
// 合法的,就绪的,普通的fd
// 这里的recv,对不对啊!不完善!必须得有协议!
char buffer[1024];
ssize_t n = recv(_fds[who].fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
// 把读到的信息,在回显会去
std::string message = "echo# ";
message += buffer;
send(_fds[who].fd, message.c_str(), message.size(), 0); // bug
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
else
{
LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " <<_fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
}
void Dispatcher() // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
{
for (int i = 0; i < MAX; i++)
{
if (_fds[i].fd == gdefaultfd)
continue;
// 文件描述符,先得是合法的
if (_fds[i].fd == _listen_socket->Fd()) // 该fd是listenfd
{
if (_fds[i].revents & POLLIN)
{
Accepter(); // 连接的获取
}
}
else
{
if (_fds[i].revents & POLLIN)
{
Recver(i); // IO的处理
}
// else if(_fds[i].revents & POLLOUT)
// {
// // wirte
// }
}
}
}
void TestFd()
{
std::cout << "pollfd: ";
for(int i = 0; i < MAX; i++)
{
if(_fds[i].fd == gdefaultfd)
continue;
std::cout << _fds[i].fd << "[" << Events2Str(_fds[i].events) << "] ";
}
std::cout << "\n";
}
std::string Events2Str(short events)
{
std::string s = ((events & POLLIN) ? "EPOLLIN" : "");
s += ((events & POLLOUT) ? "POLLOUT" : "");
return s;
}
~PollServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
struct pollfd _fds[MAX];
// struct pollfd *_fds; // malloc
};
总结
poll特点
- poll使用struct pollfd结构将输入输出参数分离,该结构包含了某个fd要监视的event和就绪的revents,接口使用比select更方便
- poll也是需要一个数组,但是这个数组支持动态扩容
- poll在接口设计上并没有最大数量限制,但是受限于计算机配置,数量过大之后性能也是会下降
poll缺点
当poll中监听的文件描述符数目增多时:
- 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符
- 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降
epoll
epoll也是负责多个fd的IO事件等待机制,从而达到事件派发的目的,本质上和select、poll一样是一种就绪事件的通知机制。它是在2.5.44内核中被引进的,man手册上介绍说,它是为处理大批量句柄而做了改进的poll,被公认为是Linux 2.6下性能最好的多路I/O就绪通知方法。
cpp
#include <sys/epoll.h>
int epoll_create(int size);
int epoll _createl(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll event *event);
int epoll_wait(int epfd, struct epoll event *events,int maxevents,int timeout);
接口说明
int epoll_create(int size);
- 作用:在内核创建一个epoll模型
- 返回值:表示epoll模型的文件描述符
- int size:这个参数实际上是被忽略的,可以随便填写,但是必须大于0
int epoll_ctl(int epfd, int op, int fd, struct epoll event *event);
- 作用:主要功能是在指定的epoll模型中,添加(或修改、删除操作)fd进而关心事件,未来调用epoll_wait之前,它能提前帮我们告诉内核我们的需求(epoll在功能上进行分离)
- op:表示要对epoll模型做什么操作
bash
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
- fd:代表是哪一个文件描述符
- struct epoll event *event:代表关心该fd的哪些事件
- 返回值:操作成功返回0
int epoll_wait(int epfd, struct epoll event *events,int maxevents,int timeout)
- epfd:代表epoll模型的fd
- timeout:同poll
- 返回值:同poll
- 剩余参数组合起来表示一个数组,只不过它是个输出型参数,events表示的是输出数组的起始地址,mexevents代表的是数组的元素个数,这个输出型参数就是内核告诉用户你历史上关心的fd的哪些事件已经就绪
从上面的接口来看,epoll把接口做了分离,即输入用一个接口,输入用另一个接口,同时参数也做了分离。
cpp
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 */
};
struct epoll_event内部其实也是封装了一个位图结构events代表事件类型,在epoll_ctl中events表示输入集,在epoll_wait中表示输出集。epoll_data_t是个联合体类型,里面包含了相关属性,其中就有fd。对于events来说,可以是以下几个宏的集合:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
- EPOLLOUT:表示对应的文件描述符可以写
- EPOLLPR:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
- EPOLLERR:表示对应的文件描述符发生错误
- EPOLLHUP:表示对应的文件描述符被挂断
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是且对于水平触发(LevelTriggered)来说的
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还不需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
epoll工作原理

我们知道,对于网卡来说,OS通过网卡驱动来操作网卡,在操作系统之上是各种的系统调用接口供用户使用,网卡也是个外设,当网卡中有数据到来时,就会触发硬件中断执行对应中断向量表中的向量表,将数据从网卡中获取上来。
在之前select和poll进行多路复用的时候,对于遍历这个操作,本质上就是去查看这个文件描述符对应的某个资源和数据是否就绪,如果没有就绪就让其继续进行阻塞等待,此时这个进程就会被挂起到等待队列当中,而在底层操作系统进行定期唤醒和调度的时候,轮到该进程,就继续对其文件描述符进行检测,这也就意味着,操作系统需要主动去检查这个内容到底有没有就绪,需要进行轮询检测。
如果像select、poll这样需要定期遍历文件描述符表,检测对应套接字的接收队列是否存在数据,没有就阻塞挂起,这样的效率是比较低的。因此操作系统提供了一种底层回调机制,当数据自底向上交付时,网络部分的代码就会自动执行这样的回调,这个回调默认是空的,将来在OS中,就可以把这个回调设置指向上层中的某一个方法,比如可以注册称一旦数据到了,向上交付到TCP的接收缓冲区,此时OS会给目标进程发送信号SIGIO,此时上层进程就可以在合适时机进行处理,这其实就是信号驱动式IO的原理。
当创建epoll模型时,首先会在底层创建一棵红黑树结构,默认这棵红黑树的节点数量为空,后续可以通过某种方式让底层往红黑树中添加节点,比如我现在要关心的是3号描述符的EPOLLIN事件,此时OS要:
-
往红黑树中新增节点,这个节点包含了文件描述符和对应关心事件
-
创建就绪队列,未来EPOLLIN事件就绪时,就绪队列就会产生一个节点,即4号描述符和EPOLLIN事件的节点。
所以这棵红黑树表达的含义是用户告诉内核,需要关心哪些fd上的哪些事件,其中红黑树的一个节点代表的是一个文件描述符的若干个事件。而这个就绪队列表示内核告诉用户哪些fd上的哪些事件已经就绪了。
回过头来看,epoll_ctl的EPOLL_ADD、EPOLL_MOD、EPOLL_DEL这几个操作本质是在对红黑树进行操作,epoll_wait本质是从就绪队列中把已经就绪的节点获取上来。
现在的问题就是,整个系统怎么知道当前有文件描述符事件就绪呢?其实在进行epoll_ctl的时候除了要设置节点对应的fd和事件之外,其实还有注册该fd的底层回调,当网卡有数据自底向上解包,拿到对应struct sock指针后,放到对应的TCP缓冲区,此时就会调用该回调,它要做的是比如判断4号描述符上对应的事件类型,然后判断是否有事件就绪,有则将对应的事件就绪节点插入到就绪队列中,此时一个节点级别的迁移,即从红黑树->就绪队列,是由底层回调机制自动驱动完成的,我们把红黑树+就绪队列+底层回调这一套叫epoll模型。换句话说,epoll_create其实是在底层创建一个红黑树、就绪队列、维护回调机制。
Q:在epoll中,这棵红黑树相当于select、poll中的什么?
对于epoll来说,这棵红黑树相当于是辅助数组,表示的是用户告诉内核,要关心哪些fd上的哪些事件。我们可以从源码中验证一下,是否存在红黑树和对应节点,博主查看的是Linux 2.6.38版本:
我们可以看到每一个epoll模型其实对应的是一个eventpoll结构体,内部封装了红黑树和就绪队列

而在epoll中,每一个红黑树节点其实就是对应一个epitem结构体:


Q:具体是如何将红黑树节点迁移到就绪队列?
实际上内核中一个数据结构节点,并不是只能属于一个数据结构,而是可以通过一些链接字段,通过指针移动就能完成节点的被激活状态:rbn负责把它挂在红黑树,rdllink 负责把它挂在双向循环链表

Q:红黑树是有key值的,新增红黑树节点时谁作为键值呢?
在红黑树中,键值就是文件描述符本身,通过fd值作为比较关键字,可以保证同一个epoll模型中不会重复插入同一个fd。
Q:如何理解epoll模型,为什么epoll_create返回的是一个文件描述符


Linux下一切皆文件,当调用epoll_create时,其实是分配inode+dentry,创建一个新的struct file,返回创建epoll模型(红黑树+就绪队列),然后通过private_data指针将struct file和epoll模型关联,后续操作时,就可以通过fd找到struct file,再通过private_data字段拿到epoll模型进行操作了,实现了一切皆文件的统一。
Q:epoll为什么高效
-
内核无需定期轮询遍历:用户只需要注册节点,事件就绪由底层回调机制驱动,就绪之后就会移动到就绪队列。
-
O(1)检测就绪:epoll_wait只需要看就绪队列是否为空就能判断是否有fd就绪,队列空就进行睡眠,队列不为空就一定是有就绪的
-
没有拷贝冗余:对于select、poll即使关心的fd没有就绪,也会从内核态拷贝到用户态,而epoll是直接把已经就绪的fd拷贝到数组中,返回值为n,则数组中0~n-1就都是就绪的。
epoll Server
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include<poll.h>
#include <sys/epoll.h>
#include "Socket.hpp"
#include<cstring>
#include<errno.h>
using namespace std;
using namespace SocketModule;
using namespace LogModule;
const int gdefaultfd = -1;
#define MAX 1024 //4096就poll出错
// 最开始的时候,tcpserver只有一个listensockfd
class EpollServer
{
static const int rev_num = 64;
public:
EpollServer(int port)
: _port(port),
_epfd(gdefaultfd),
_listen_socket(std::make_unique<TcpSocket>()),
_isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocket(_port);
//1.创建epoll模型
_epfd = ::epoll_create(256);
if(_epfd < 0)
{
LOG(LogLevel::ERROR) << "epoll_create error\n";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::DEBUG) << "epoll_create success: " << _epfd << "\n";
//2.至少要先将listen套接字添加到epoll模型
struct epoll_event ev;
ev.events = EPOLLIN; //关心事件
ev.data.fd = _listen_socket->Fd();
int n = ::epoll_ctl(_epfd,EPOLL_CTL_ADD,_listen_socket->Fd(),&ev); //??为什么要有第三个参数,第四个参数中不是有了?
if(n < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
exit(EPOLL_CTL_ERR);
}
LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
}
void loop()
{
int timeout = 5000;
_isrunning = true;
while (_isrunning)
{
// 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测
// 用户告诉内核,你要帮我关心&rfds,读事件啊!!
int n = epoll_wait(_epfd,_revs,rev_num , timeout); // 通知上层的任务!
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
sleep(1);
perror("epoll error");
break;
default:
// 有事件就绪了
// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
std::cout << "有事件就绪啦..., timeout: " << std::endl;
Dispatcher(n); // 把已经就绪的sockfd,派发给指定的模块
break;
}
}
_isrunning = false;
}
void Accepter() // 回调函数呢?
{
InetAddr client;
// listensockfd就绪了!获取新连接不就好了吗?
int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
if (newfd < 0)
return;
else
{
std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
// recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪
// 怎么把新的newfd托管给epoll?让epoll帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = newfd;
int n = epoll_ctl(_epfd,EPOLL_CTL_ADD,newfd,&ev);//设置新fd进epoll模型(红黑树)
if(n < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
exit(EPOLL_CTL_ERR);
}
LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
}
}
void Recver(int fd) // 回调函数?
{
// 合法的,就绪的,普通的fd
// 这里的recv,对不对啊!不完善!必须得有协议!
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
// 把读到的信息,在回显会去
std::string message = "echo# ";
message += buffer;
send(fd, message.c_str(), message.size(), 0); // bug
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << fd;
//close(_fds[who].fd);
//对于客户端退出的fd需要从红黑树移除
int m = epoll_ctl(_epfd,EPOLL_CTL_DEL,fd,nullptr);
if(m < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error\n";
return;
}
LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
close(fd);//注意移除前提是合法的fd,所以需要最后才close
}
else
{
LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " << fd;
//对于客户端退出的fd需要从红黑树移除
int m = epoll_ctl(_epfd,EPOLL_CTL_DEL,fd,nullptr);
if(m < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error\n";
return;
}
LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
close(fd);//注意移除前提是合法的fd,所以需要最后才close
}
}
void Dispatcher(int num) // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
{
for (int i = 0; i < num; i++)
{
int events = _revs[i].events;
int fd = _revs[i].data.fd;
if(fd == _listen_socket->Fd())
{
if(events & EPOLLIN) //判断就绪类型
Accepter();
}
else //普通文件描述符就绪
{
if(events & EPOLLIN)
{
Recver(fd);
}
else
{
//写事件就绪
}
}
}
}
std::string Events2Str(short events)
{
std::string s = ((events & POLLIN) ? "EPOLLIN" : "");
s += ((events & POLLOUT) ? "POLLOUT" : "");
return s;
}
~EpollServer()
{
_listen_socket->Close();
if(_epfd >= 0) //关闭epoll模型
::close(_epfd);
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
int _epfd;//epoll模型绑定的fd
struct epoll_event _revs[rev_num];//就绪事件集合 输出型参数
// struct pollfd _fds[MAX];
// struct pollfd *_fds; // malloc
};
需要注意的是:
-
对于输出数组,如果内核中epoll模型的就绪队列中有128个fd就绪了,但是你设置的输出数组大小为64,此时它的数组中只能是64个fd,其他的到下一次处理。
-
将fd从epoll删除时,必须先保证fd合法,所以要先移除再close,这与select和poll是不同的,否则可能造成内存或资源泄漏,毕竟直接close之后,可能会丢失文件描述符与epoll的连接,导致一些epoll模型内部资源不能正确释放。
-
与select、poll不同的是,在进行派发时,不再需要判断是否是合法fd,可以直接判断就绪类型。
select/poll/epoll对比
前面我们讨论了select、poll和epoll三组I/O复用系统调用,这3组系 统调用都能同时监听多个文件描述符。它们将等待由timeout参数指定 的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返 回值是就绪的文件描述符的数量。返回0表示没有事件发生。现在我们 从事件集、最大支持文件描述符数、工作模式和具体实现等四个方面 进一步比较它们的异同,以明确在实际应用中应该选择使用哪个(或 哪些)。
这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符 上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。 select的参数类型fd_set没有将文件描述符和事件绑定,它仅仅是一个文 件描述符集合,因此select需要提供3个这种类型的参数来分别传入和输 出可读、可写及异常等事件。这一方面使得select不能处理更多类型的 事件,另一方面由于内核对fd_set集合的在线修改,应用程序下次调用 select前不得不重置这3个fd_set集合。
poll的参数类型pollfd则多少"聪 明"一些。它把文件描述符和事件都定义其中,任何事件都被统一处 理,从而使得编程接口简洁得多。并且内核每次修改的是pollfd结构体 的revents成员,而events成员保持不变,因此下次调用poll时应用程序 无须重置pollfd类型的事件集参数。由于每次select和poll调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程 序索引就绪文件描述符的时间复杂度为O(n)。
epoll则采用与select和 poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件 表,并提供了一个独立的系统调用epoll_ctl来控制往其中添加、删除、 修改事件。这样,每次epoll_wait调用都直接从该内核事件表中取得用 户注册的事件,而无须反复从用户空间读入这些事件。epoll_wait系统 调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文 件描述符的时间复杂度达到O(1)。
poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少个文 件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述 符数目,即65535(cat/proc/sys/fs/file-max)。而select允许监听的最大 文件描述符数量通常有限制。虽然用户可以修改这个限制,但这可能 导致不可预期的后果。
从实现原理上来说,select和poll采用的都是轮询的方式,即每次调 用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返 回给用户程序,因此它们检测就绪事件的算法的时间复杂度是 O(n)。epoll_wait则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对 应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事 件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描 述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。但是,当活动连接比较多的时候,epoll_wait的效率未必比select和poll 高,因为此时回调函数被触发得过于频繁。所以epoll_wait适用于连接数量多,但活动连接较少的情况。

ET模式和LT模式
epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效工作模式。
如何理解LT和ET
假设有个快递员叫做小王和小李,他负责校内的快递派发工作:
- 小王:小王给小张同学派发10个包裹,小王打电话让小张下来取,小张游戏开了,下楼拿了4个之后又上去继续打游戏,此时还没取完,小王就继续打电话,直到小张取完为止。
这个过程中,小王给小张打电话本质是对就绪事件的通知,他的策略是只要我的快递车里有你的快递,就要一直进行通知,直到你取走,这种默认其实就是LT 模式,水平触发模式的特点是只要底层有数据,就一直通知,直到你取完为止。
- 小李:给小张派发快递时,只打一次电话让他下来取,不管取没取完,或者小王来不来取,通知一次就直接走了。
小李的派发策略是当新数据到来时,只通知一次,这里的新数据到了指的是从无都有和从有到多,底层数据增多时只通知一次 ,这种模式我们叫ET模式,即边缘触发模式。
在上面的例子中,小张相当于是用户层,取快递相当于是调用接口recv,快递员的快递车相当于是某个套接字的接收缓冲区,小王和小李相当于是epoll模型,小王是LT模式,小李是ET模式,打电话相当于是事件通知,之前select时候,我们发现新链接读事件就绪,不处理新链接就会一直通知,这是因为,poll、select的默认工作模式就是LT!
Q:LT是怎么做到的/原理是什么
一旦有数据就绪,节点就会被激活,一直在就绪队列中,除非你把数据取完,否则该节点一直在就绪队列中,epoll_wait检测队列发现该节点一直存在,也就一直通知。
Q:ET是怎么做到的/原理是什么
当数据到来时,节点被激活,放到就绪队列中,用户只要调用epoll_wait,取走就绪节点,该节点里面从就绪队列中移除,除非又有数据从网络传递到我的接收缓冲区中,否则不再通知,即使数据没有取完。
Q:ET vs LT,谁更高效
普遍共识下,认为ET模式更高效,因为ET不做重复通知,一旦通知,就必须把数据取走,这个机制倒逼程序员在ET模式下必须把数据取走,这也意味着将来在底层可以给对方通知一个更大的接收窗口,这样就能提供对方的滑动窗口概率,发送效率整体提供,本质是提高了IO带宽,提高IO效率。
但是这样做也是有代价的,在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,,不让这个就绪被重复提示的话,,其实性能也是一样的, 另一方面,**ET 的代码复杂程度更高了,**因为一旦读取数据,就必须把本次数据取完,这就要求必须循环读取,因为如果是阻塞读取的话,read可能被信号打断,不能保证一次就把所有数据都读取出来,此时服务器没有读取到完整数据,就不能给客户端应答,客户端也就不能发送下一个请求使epoll_wait返回,服务器就没有得到通知不能去读缓冲区中的剩余数据,从而导致服务器阻塞。那怎么保证循环读取的时候本轮数据取完了?
-
实际读取到的数据个数 < 期望的数据个数
-
阻塞住了就说明读取完了(但是不敢阻塞这会导致其他链接饿死,必须保证在ET模式下将fd读取的时候设置为非阻塞,直到返回EAGAIN,代表本轮数据读取完毕)
Q:为什么ET模式下要求所有fd,都必须是非阻塞的,而LT没有这个要求
ET模式只通知一次要求程序员必须每次把对应fd上数据取完,这就需要循环取,那就要求非阻塞等,不然可能导致服务器阻塞
Q:LT模式,为什么没有说明这些呢?
既能阻塞也能非阻塞,LT模式一旦数据就绪可以直接读,但只建议读一次,因为不怕,哪怕只读一次能保证这次不会被阻塞,下一次还有就绪还会通知我
Q:LT+非阻塞+循环读取不就相当于是ET,那为什么ET更高效
此时效率上确实不会存在本质区别,但是ET是有强制性要求的,LT模式的非阻塞+循环是程序员自己驱动的,不是每个程序员都是可以这样编码的,LT是满足基本使用的,LT模式常见应用场景就是基于阻塞+非循环读取,ET是满足高吞吐量设计的,未来根据应用场景选择LT还是ET,如果对IO要求比较高一般选择ET,但是编码复杂度高,而LT模式设计简单,主要是为了读取之后尽快处理,可以大部分时间边读变处理,即使一次没有读到完整请求。
epoll使用场景
epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。这种场景把所有fd聚合起来,epoll既帮我们监听所有fd,又处理活跃的链接,用最少的进程线程资源处理,这就是多路转接
例如,典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。
如果只是系统内部,服务器和服务器之间进行通信, 只有少数的几个连接,这种情况下用epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种IO模型