目录
一、select
1.1、select概念
select 是系统提供的一个多路转接的接口,可以用来实现多路复用输入 / 输出模型。
- select 系统调用可以让程序同时监视多个文件描述符上的状态变化。
- select 核心工作就是等,当监视的文件描述符中有一个或多个事件就绪时,也就是直到被监视的文件描述符有一个或多个发生了状态改变,select 才会成功返回并将对应文件描述符的就绪事件告知调用者。
1.2、select 函数原型
参数解释
- nfds:需要监视的文件描述符中,最大的文件描述符值 +1。
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- timeout:输入输出型参数,调用时由用户设置 select 的等待时间,返回时表示 timeout 的剩余时间。
参数 timeout 的取值
- NULL / nullptr:select 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:select 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select 检测后都会立即返回。
- 特定的时间值:select 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上一直没有事件就绪,则在该时间后 select 进行超时返回。
返回值说明
- 若函数调用成功,则返回事件就绪的文件描述符个数。
- 若 timeout 时间耗尽,则返回 0。
- 若函数调用失败,则返回 -1,同时错误码被设置。
只要有一个 fd 数据就绪或空间就绪,就可以进行返回了。
错误码
select 调用失败时,错误码可能被设置为:
- EBADF:文件描述符为无效的或该文件已关闭。
- EINTR:此调用被信号所中断。
- EINVAL:参数 nfds 为负值。
- ENOMEM:核心内存不足。
fd_set 结构
fd_set 结构与 sigset_t 结构类似,其实这个结构就是一个整数数组,更严格的说 fd_set 本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
调用 select 函数之前就需用 fd_set 结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对 fd_set 类型的位图进行各种操作。
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
注意:fd_set 是一个固定大小的位图,直接决定了 select 能同时关心的 fd 的个数是有上限的。
timeval 结构
传入 select 函数的最后一个参数 timeout,是一个指向 timeval 结构的指针。timeval 结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0。该结构中包含两个成员,其中 tv_sec 表示的是秒,tv_usec 表示的是微秒
select 等待多个 fd,等待策略可以选择:
- 阻塞式(nullptr)
- 非阻塞式({0, 0})
- 可以设置 timeout 时间,时间内阻塞,时间到了就立马返回({5, 0})
如果在 timeout 时间内有 fd 就绪呢?
此时,timeout 表示距离下一次 timeout 还剩多长时间(输出的含义)。
a. 函数返回值
- 执行成功则返回文件描述符状态已改变的个数。
- 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回
- 当有错误发生时则返回 -1,错误原因存于 errno,此时参数 readfds,writefds,exceptfds 和 timeout 的值变成不可预测。
b. 错误值
- EBADF:文件描述词为无效的或该文件已关闭。
- EINTR:此调用被信号所中断
- EINVAL:参数 n 为负值。
- ENOMEM:核心内存不足。
1.3、理解 select 执行过程
理解 select 模型的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。
- 执行 fd_set set; FD_ZERO(&set); 则 set 用位表示是 0000,0000。
- 若 fd=5,执行 FD_SET(fd,&set); 后 set 变为 0001,0000(第 5 位置为 1)。
- 若再加入 fd=2,fd=1,则 set 变为 0001,0011。
- 执行 select(6,&set,0,0,0) 阻塞等待。
- 若 fd=1,fd=2 上都发生可读事件,则 select 返回,此时 set 变为 0000,0011。
注意 :没有事件发生的 fd=5 被清空。
1.4、select就绪条件
1.4.1、读就绪
- socket 内核中,接收缓冲区中的字节数,大于等于低水位标记 SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于 0。
- socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0。
- 监听 socket 上有新的连接请求。
- socket 上有未处理的错误。
如何看待 listensock?
获取新连接,依旧把它看作成 IO 事件,input 事件。
如果没有连接到来呢?
发生阻塞。
1.4.2、写就绪
- socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0。
- socket 的写操作被关闭(close 或者 shutdown),如果此时进行写操作的话,会触发 SIGPIPE 信号。
- socket 使用非阻塞 connect 连接成功或失败之后,socket 上有未读取的错误。
1.4.3、异常就绪(了解)
socket 上收到带外数据。
注意:带外数据和 TCP 的紧急模式相关,TCP 报头中的 URG 标志位和 16 位紧急指针搭配使用,就能够发送/接收带外数据。
1.5、select 基本工作流程
若要实现一个简单的 select 服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么该 select 服务器的工作流程如下:
- 先初始化服务器,完成套接字的创建、绑定和监听。
- 定义一个 _fd_array 数组用于保存监听套接字和已经与客户端建立连接的套接字,初始化时就可将监听套接字添加到 _fd_array 数组中。
- 然后服务器开始循环调用 select 函数,检测读事件是否就绪,若就绪则执行对应操作。
- 每次调用 select 函数之前,都需要定义一个读文件描述符集 readfds,并将 _fd_array 中的文件描述符依次设置进 readfds 中,表示让 select 监视这些文件描述符的读事件是否就绪。
- 当 select 检测到数据就绪时会将读事件就绪的文件描述符设置进 readfds 中,此时就能够得知哪些文件描述符的读事件就绪,并对这些文件描述符进行对应操作。
- 若读事件就绪的是监听套接字,则调用 accept 函数从底层全连接队列获取已建立的连接,并将该连接对应的套接字添加到 _fd_array 数组中。
- 若读事件就绪的是与客户端建立连接的套接字,则调用 read 函数读取客户端发来的数据并进行打印输出。
- 服务器与客户端建立连接的套接字读事件就绪,也可能是客户端将连接关闭了,此时服务器应该调用 close 关闭该套接字,并将该套接字从 _fd_array 数组中清除,不需要再监视该文件描述符的读事件了。
注意:
- 传入 select 函数的 readfds、writefds 和 exceptfds 都是输入输出型参数。当 select 函数返回时这些参数中的值已经被修改了,因此每次调用 select 函数时都需对其进行重新设置,timeout 也是如此。
- 因为每次调用 select 函数之前都需要对 readfds 进行重新设置,所以需要定义一个 _fd_array 数组保存与客户端已经建立的若干连接和监听套接字,实际 _fd_array 数组中的文件描述符就是需要让 select 监视读事件的文件描述符。
- select 服务器只是读取客户端发来的数据,因此只需要让 select 监视特定文件描述符的读事件,若要同时让 select 监视特定文件描述符的读事件和写事件,则需要分别定义 readfds 和 writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用 select 函数前对 readfds 和 writefds 进行重新设置。
- 由于调用 select 时还需要传入被监视的文件描述符中最大文件描述符值 +1,因此每次在遍历 _fd_array 对 readfds 进行重新设置时,还需要记录最大文件描述符值。
1.6、select服务器
1.6.1、Sock.hpp
编写一个 Socket 类,对套接字相关的接口进行一定封装,为了让外部能直接调用 Socket 类中封装的函数,于是将部分函数定义成静态成员函数。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define Convert(addrptr) ((struct sockaddr *)addrptr)
namespace Net_Work
{
const static int defaultsockfd = -1;
const int backlog = 5;
enum
{
SocketError = 1,
BindError,
ListenError,
};
// 封装一个基类,Socket接口类
// 设计模式:模版方法类
class Socket
{
public:
virtual ~Socket() {}
virtual void CreateSocketOrDie() = 0;
virtual void BindSocketOrDie(uint16_t port) = 0;
virtual void ListenSocketOrDie(int backlog) = 0;
virtual Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) = 0;
virtual bool ConnectServer(std::string &serverip, uint16_t serverport) = 0;
virtual int GetSockFd() = 0;
virtual void SetSockFd(int sockfd) = 0;
virtual void CloseSocket() = 0;
virtual bool Recv(std::string *buffer, int size) = 0;
virtual void Send(std::string &send_str) = 0;
virtual void ReUseAddr() = 0;
// TODO
public:
void BuildListenSocketMethod(uint16_t port, int backlog)
{
CreateSocketOrDie();
ReUseAddr();
BindSocketOrDie(port);
ListenSocketOrDie(backlog);
}
bool BuildConnectSocketMethod(std::string &serverip, uint16_t serverport)
{
CreateSocketOrDie();
return ConnectServer(serverip, serverport);
}
void BuildNormalSocketMethod(int sockfd)
{
SetSockFd(sockfd);
}
};
class TcpSocket : public Socket
{
public:
TcpSocket(int sockfd = defaultsockfd) : _sockfd(sockfd)
{
}
~TcpSocket()
{
}
void CreateSocketOrDie() override
{
_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
exit(SocketError);
}
void BindSocketOrDie(uint16_t port) override
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port);
int n = ::bind(_sockfd, Convert(&local), sizeof(local));
if (n < 0)
exit(BindError);
}
void ListenSocketOrDie(int backlog) override
{
int n = ::listen(_sockfd, backlog);
if (n < 0)
exit(ListenError);
}
Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) override
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int newsockfd = ::accept(_sockfd, Convert(&peer), &len);
if (newsockfd < 0)
return nullptr;
*peerport = ntohs(peer.sin_port);
*peerip = inet_ntoa(peer.sin_addr);
Socket *s = new TcpSocket(newsockfd);
return s;
}
bool ConnectServer(std::string &serverip, uint16_t serverport) override
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(serverip.c_str());
server.sin_port = htons(serverport);
int n = ::connect(_sockfd, Convert(&server), sizeof(server));
if (n == 0)
return true;
else
return false;
}
int GetSockFd() override
{
return _sockfd;
}
void SetSockFd(int sockfd) override
{
_sockfd = sockfd;
}
void CloseSocket() override
{
if (_sockfd > defaultsockfd)
::close(_sockfd);
}
bool Recv(std::string *buffer, int size) override
{
char inbuffer[size];
ssize_t n = recv(_sockfd, inbuffer, size-1, 0);
if(n > 0)
{
inbuffer[n] = 0;
*buffer += inbuffer; // 故意拼接的
return true;
}
else if(n == 0) return false;
else return false;
}
void Send(std::string &send_str) override
{
// 多路转接我们在统一说
send(_sockfd, send_str.c_str(), send_str.size(), 0);
}
void ReUseAddr() override
{
int opt = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
private:
int _sockfd;
};
}
1.6.2、selectServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/select.h>
#include <memory>
#include "Log.hpp"
#include "Socket.hpp"
using namespace Net_Work;
const static int gdefaultport = 8888;
const static int gbacklog = 8;
const static int num = sizeof(fd_set) * 8;
class SelectServer
{
private:
void HandlerEvent(fd_set &rfds)
{
for (int i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
continue;
// 合法的sockfd
// 读事件分两类,一类是新连接到来。 一类是新数据到来
int fd = _rfds_array[i]->GetSockFd();
if (FD_ISSET(fd, &rfds))
{
// 读事件就绪
if (fd == _listensock->GetSockFd()) //表示有新的连接要来
{
lg.LogMessage(Info, "get a new link\n");
// 获取连接
std::string clientip;
uint16_t clientport;
// 不会阻塞!!,因为select已经检测到了listensock已经就绪了
Socket *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->GetSockFd());
// 这里已经获取连接成功了,接下来怎么办???
// read?write?绝对不能!!!read 底层数据是否就绪时不确定的!谁清楚fd上面是否有读事件呢?select!
// 新链接fd到来的时候,要把新的fd, 想办法交给select托管 -- 只需要添加到数组_rfds_array中即可
int pos = 0;
for (; pos < num; pos++)
{
if (_rfds_array[pos] == nullptr)
{
_rfds_array[pos] = sock;
lg.LogMessage(Info, "get a new link, fd is : %d\n", sock->GetSockFd());
break;
}
}
if (pos == num)
{
sock->CloseSocket();
delete sock;
lg.LogMessage(Warning, "server is full...!\n");
}
}
else
{
// 普通的读事件就绪
// 读数据是有问题的
// 这一次读取不会被卡住吗?
std::string buffer;
bool res = _rfds_array[i]->Recv(&buffer, 1024);
if (res)
{
lg.LogMessage(Info, "client say# %s\n", buffer.c_str());
buffer += ":你好呀,少年";
_rfds_array[i]->Send(buffer);
buffer.clear();
}
else
{
lg.LogMessage(Warning, "client quit, maybe close or error, close fd : %d\n", _rfds_array[i]->GetSockFd());
_rfds_array[i]->CloseSocket();
delete _rfds_array[i];
_rfds_array[i] = nullptr;
}
}
}
}
}
public:
SelectServer(int port = gdefaultport) : _port(port), _listensock(new TcpSocket()), _isrunning(false)
{
}
void InitServer()
{
_listensock->BuildListenSocketMethod(_port, gbacklog);
for (int i = 0; i < num; i++)
{
_rfds_array[i] = nullptr;
}
_rfds_array[0] = _listensock.get();
}
void Loop()
{
_isrunning = true;
while (_isrunning)
{
// 我们能不能直接accept新连接呢?不能!所有的fd,都要交给select. listensock上面新连接,相当于读事件,有新连接,就等价于有新数据到来
// 首先不能直接accept,而是将listensock交给select。因为只有select有资格知道有没有IO事件就绪
// 故意放在循环内部
// 遍历数组,1. 找最大的fd 2. 合法的fd添加到rfds集合中
fd_set rfds;
FD_ZERO(&rfds);
int max_fd = _listensock->GetSockFd();
for (int i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
{
continue;
}
else
{
int fd = _rfds_array[i]->GetSockFd();
FD_SET(fd, &rfds); // 添加所有合法fd到rfds集合中
if (max_fd < fd) // 更新最大fd
{
max_fd = fd;
}
}
}
// 定义时间
struct timeval timeout = {0, 0};
// rfds本质是一个输入输出型参数,rfds是在select调用返回的时候,不断被修改,所以,每次都要重置
PrintDebug();
int n = select(max_fd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr);
switch (n)
{
case 0:
lg.LogMessage(Info, "select timeout..., last time: %u.%u\n", timeout.tv_sec, timeout.tv_usec);
break;
case -1:
lg.LogMessage(Error, "select error!!!\n");
break;
default:
// 正常的就绪的fd
lg.LogMessage(Info, "select success, begin event handler, last time: %u.%u\n", timeout.tv_sec, timeout.tv_usec);
HandlerEvent(rfds); // _rfds_array: 3,4,5,6,7,8,9,10 -> rfds: 4,5,6
break;
}
}
_isrunning = false;
}
void Stop()
{
_isrunning = false;
}
void PrintDebug()
{
std::cout << "current select rfds list is : ";
for (int i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
continue;
else
std::cout << _rfds_array[i]->GetSockFd() << " ";
}
std::cout << std::endl;
}
~SelectServer()
{
}
private:
std::unique_ptr<Socket> _listensock;
int _port;
int _isrunning;
// select 服务器要被正确设计,需要程序员定义数据结构,来把所有的fd管理起来,往往是数组!
Socket *_rfds_array[num];
};
当调用 accept 函数从底层获取上来连接后,不能立即调用 read 函数读取该连接中的数据。因为此时新连接中的数据可能并没有就绪,如果直接调用 read 函数可能发生阻塞,应该将这个等待过程交给 select 函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到 _fd_array 数组中即可,当该连接的读事件就绪时再进行数据读取。
添加文件描述符到 fd_array 数组中,本质就是遍历 fd_array 数组,找到一个没有被使用的位置将该文件描述符添加进去。但有可能 _fd_array 数组中全部的位置都已被占用,那么文件描述符就会添加失败,此时就只能将刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了。
1.6.3、select 服务器测试
使用 telnet 工具连接服务器,此时通过 telnet 向服务器发送的数据就能够被服务器读到并且打印输出了。
虽然 selectServer 仅是一个单进程、单线程服务器,但却可以同时为多个客户端提供服务。因为 select 函数调用后,会告知 select 服务器是哪个客户端对应的连接事件就绪,此时 select 服务器就可以读取对应客户端发来的数据,读取完后又会调用 select 函数等待某个客户端连接的读事件就绪。
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fd_array 数组中清除。
1.6.4、存在的问题
1、select 服务器如果要向客户端发送数据的话,不能直接调用 write 函数。因为调用 write 函数时,实际上也分为 “等” 和 “拷贝” 两步,也应该将 “等” 的这个过程交给 select 函数,因此在每次调用 select 函数之前,除了需要重新设置 readfds,也要重新设置 writefds,并且还需要一个数组来保存需被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时,才能够调用 write 函数向客户端发送数据。
2、没有定制协议。代码中读取数据时并没有按照某种规则进行读取,可能造成粘包问题,根本原因就是没有定制协议。比如,HTTP 协议规定在读取底层数据时读取到空行就表明读完了一个 HTTP 报头,此时再根据 HTTP 报头中的 Content-Length 属性得知正文的长度,最终就能够读取到一个完整的 HTTP 报文,HTTP 协议通过这种方式避免了粘包问题。
3、没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组 buffer 中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区中,当读取到一个完整的报文后再让服务器进行处理。此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用 write 函数发送给客户端,而是应该先存储到一个输出缓冲区中,因为响应数据可能很庞大,无法一次发送完毕,所以可能需要进行分批发送。
1.7、select 的优点
- 可以同时等待多个文件描述符,且只负责等待(有大量的连接,但只有少量是活跃的,节省资源),实际的 IO 操作由 accept、read、write 等接口完成,保证接口在进行 IO 操作时不会被阻塞。
- select 同时等待多个文件描述符,因此可以将 “等” 的时间重叠,提高 IO 效率。
注意:上述优点也是所有多路转接接口的优点。
1.8、select 的缺点
- 每次调用 select,都需手动设置 fd 集合,从接口使用角度来说也非常不便。
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
- 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
- select 可监控的文件描述符数量太少。
-
为了维护第三方数组,select 服务器会充满大量的遍历操作。OS 底层帮我们关心 fd 的时候,也要进行遍历。
- 每一次都要对 select 输出参数进行重新设定。
- select 能够同时管理的 fd 的个数是有上限的。
- 因为几乎每一个参数都是输入输出型的,也就决定了 select 一定会频繁的进行用户到内核、内核到用户的参数数据拷贝。
- 编码比较复杂。
select 可监控的文件描述符个数
调用 select 函数时传入的 readfds、writefds 以及 exceptfds 都是 fd_set结构,fd_set 结构本质是一个位图,用一个 bit 位来标记一个文件描述符,因此 select 可监控的文件描述符个数取决于 fd_set 类型的 bit 位个数。
运行代码后可以发现,select可监控的文件描述符个数为 1024。
一个进程能打开的文件描述符个数
进程控制块 task_struct 中有一个 files 指针,该指针指向一个 struct files_struct 结构,进程的文件描述符表 fd_array 就存储在该结构中,其中文件描述符表 fd_array 的大小定义为 NR_OPEN_DEFAULT,NR_OPEN_DEFAULT 的值实际就是 32。
但不意味着一个进程最多只能打开 32 个文件描述符,进程能打开的文件描述符个数是可以扩展的,通过 ulimit -a 命令可以看到进程能打开的文件描述符上限。
select 可监控的文件描述符个数是 1024,除去监听套接字,那么最多只能连接 1023 个客户端。
1.9、select 的适用场景
多路转接接口 select、poll 和 epoll,需在一定的场景下使用,如果场景不适宜,可能会适得其反。
多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也意味着几乎所有的连接在进行 IO 操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高 IO 效率。
对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。
多连接中只有少量连接是比较活跃的,如聊天工具,登录 QQ 后大部分时间其实是没有聊天的,此时服务器端不可能调用一个 read 函数阻塞等待读事件就绪。
多连接中大部分连接都很活跃,如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。
二、poll
2.1、poll的接口
参数解释
- fds:一个 poll 函数监视的结构列表,每一个元素都包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
- nfds:表示 fds 数组的长度。
- timeout:表示 poll 函数的超时时间,单位是毫秒(ms)。
参数 timeout 的取值
- -1:poll 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:poll 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll 检测后都会立即返回。
- 特定的时间值:poll 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 poll 进行超时返回。
返回值说明
- 若函数调用成功,则返回有事件就绪的文件描述符个数。
- 若 timeout 时间耗尽,表示超时,则返回 0,表示 poll 以非阻塞方式等待。
- 若函数调用失败,则返回 -1,poll 要以阻塞方式等待,同时错误码被设置。
错误码
poll 调用失败时,错误码可能被设置为:
- EFAULT:fds 数组不包含在调用程序的地址空间中
- EINTR:此调用被信号所中断
- EINVAL:nfds 值超过 RLIMIT_NOFILE值
- ENOMEM:核心内存不足
2.2、struct pollfd 结构
参数说明
- fd:特定的文件描述符,若设置为负值则忽略 events 字段并且 revents 字段返回 0。
- events:需要监视该文件描述符上的哪些事件。
- revents:poll 函数返回时告知用户该文件描述符上的哪些事件已经就绪。
events 和 revents 的取值
这些值都以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位各不相同。
- 在调用 poll 函数之前,可以通过或运算符将要监视的事件添加到events成员中
- 在 poll 函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪
返回结果
- 返回值小于 0,表示出错。
- 返回值等于 0,表示 poll 函数等待超时。
- 返回值大于 0,表示 poll 由于监听的文件描述符就绪而返回。
2.3、poll 服务器
poll 的工作流程和 select 基本类似,下面也实现一个简单 poll 服务器,只读取客户端发来的数据并进行打印。
2.3.1、pollServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <poll.h>
#include <memory>
#include "Log.hpp"
#include "Socket.hpp"
using namespace Net_Work;
using namespace std;
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;
}
int fd = _rfds[i].fd;
short revents = _rfds[i].revents;
if(revents & POLLIN)
{
if(fd==_listensock->GetSockFd())
{
//新连接来了
lg.LogMessage(Info,"get a new link\n");
string clientip;
uint16_t clientport;
//不会阻塞!!! , 因为poll已经检测到了listensock已经就绪了
int sock = _listensock->AcceptConnection(&clientip,&clientport);
if(sock==-1)
{
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);
// 这里已经获取连接成功了,接下来怎么办???
// read?write?绝对不能!!!read 底层数据是否就绪时不确定的!谁清楚fd上面是否有读事件呢?select!
// 新链接fd到来的时候,要把新的fd, 想办法交给select托管 -- 只需要添加到数组_rfds_array中即可
int pos = 0 ;
for(;pos<_num;pos++)
{
if(_rfds[pos].fd==-1)
{
_rfds[pos].fd=sock;
_rfds[pos].events=POLLIN;
lg.LogMessage(Info,"get a new link,fd is : %d\n",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>0)
{
buffer[n]=0;
lg.LogMessage(Info,"client say# %s\n",buffer);
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);
_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]; //初始化数组,数组内容是结构体pollfd
for(int i=0;i<_num;i++)
{
_rfds[i].fd=-1;
_rfds[i].events=0;
_rfds[i].revents=0;
}
//最开始的时候只有一个文件描述符,listensock
_rfds[0].fd=_listensock->GetSockFd();
_rfds[0].events |= POLLIN; //关心读事件
}
void Loop()
{
_isrunning=true;
while(_isrunning)
{
int timeout=1000;
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");
break;
default:
//正在就绪的fd
lg.LogMessage(Info,"select success begin event handler\n");
HandlerEvent();
break;
}
}
_isrunning=false;
}
void Stop()
{
_isrunning=false;
}
void PrintDebug()
{
cout<<"current poll fd list is : ";
for(int i=0;i<_num;i++)
{
if(_rfds[i].fd==-1)
{
continue;
}
else
{
cout<<_rfds[i].fd<<" ";
}
}
cout<<endl;
}
~PollServer()
{
delete[] _rfds;
}
private:
unique_ptr<Socket> _listensock;
int _num;
int _port;
int _isrunning;
struct pollfd *_rfds;
};
_fds 数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到 fds 数组时,可能会因为 fds 数组已满而添加失败,这时 poll 服务器只能将刚刚获取上来的连接对应的套接字进行关闭。
2.3.2、poll服务器测试
在调用 poll 函数时,将 timeout 的值设置成 1000,因此运行服务器后每隔 1000 毫秒没有客户端发来连接请求,那么服务器就会超时返回。
用 telnet 工具连接 poll 服务器后,poll 函数在检测到监听套接字的读事件就绪后就会调用 accept 获取建立好的连接,并打印输出客户端的 IP 和端口号等信息,此时客户端发来的数据也能成功被 poll 服务器收到并进行打印输出。
poll 服务器也是单进程、单线程服务器,同样可以为多个客户端服务。
当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fds 数组中清除。
2.4、poll 的优点
- struct pollfd 结构中包含了 events 和 revents,相当于将 select 的输入输出型参数进行分离,因此在每次调用 poll 之前,不需像 select 一样重新对参数进行设置,接口使用比 select 方便。
- poll 可监控的文件描述符数量没有限制(但是数量过大后性能也是会下降)。
- poll 也可以同时等待多个文件描述符,提高 IO 效率。
- 有大量的连接,但只有少量是活跃的,节省资源。
说明:
- 虽然代码中将 _fds 数组的元素个数定义为 100,但 _fds 数组的大小可以增大,poll 函数能监视多少文件描述符由 poll 函数的第二个参数决定。
- 而 fd_set 类型只有 1024 个 bit 位,因此 select 函数最多只能监视 1024 个文件描述符。
2.5、poll 的缺点
poll 中监听的文件描述符数目增多时:
- 和 select 函数一样,当 poll 返回后,需要遍历 _fds 数组来获取就绪的文件描述符(轮询pollfd来获取就绪的描述符)。
- 每次调用 poll 都需将大量 struct pollfd 结构从用户态拷贝到内核态,这个开销会随着 poll 监视的文件描述符数目增多而增大。
-
同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
- 同时每次调用 poll 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
- poll 的代码也比较复杂,但比 select 容易一些。
三、epoll
3.1、了解epoll
按照 man 手册的说法:是为处理大批量句柄而作了改进的 poll。
epoll 是系统提供的一个多路转接接口。
- epoll 系统调用也可以让程序同时监视多个文件描述符上的事件是否就绪,与 select 和 poll 的定位是一样的,适用场景也相同。
- epoll 在命名上比 poll 多了一个 e,可以理解成是 extend,epoll 就是为了同时处理大量文件描述符而改进的 poll。
- epoll 在 2.5.44 内核中被引进,几乎具备了 select 和 poll 所有优点,它被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。
3.2、epoll 的相关系统调用
epoll 有 3 个相关的系统调用。
3.2.1、epoll_create 函数
- 参数 size:自 Linux2.6.8 后,size 参数是被忽略的,但 size 的值必须设置为大于 0 的值。
- 返回值:epoll 模型创建成功返回其对应的文件描述符,否则返回 -1,同时错误码被设置。用完之后,必须调用 close() 关闭。
注意: 当不再使用时,须调用 close 函数关闭 epoll 模型对应的文件描述符,当所有引用 epoll 实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
3.2.2、epoll_ctl 函数
它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
参数说明:
- epfd:epoll_create 函数的返回值(epoll 的句柄)
- op:表示具体的动作,用三个宏来表示
- fd:需要监视的文件描述符 fd
- event:需要监视该文件描述符上的哪些事件
第二个参数 op 的取值有以下三种:
- EPOLL_CTL_ADD:注册新的文件描述符 fd 到指定的 epoll 模型 epfd 中。
- EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
- EPOLL_CTL_DEL:从 epoll 模型 epfd 中删除指定的文件描述符 fd。
返回值:
函数调用成功返回 0,调用失败返回 -1,同时错误码会被设置
第四个参数对应的 struct epoll_event 结构如下:
struct epoll_event 结构中有两个成员,第一个成员 events 表示的是需监视的事件,第二个成员 data 为联合体结构,一般选择使用该结构中的 fd,表示需要监听的文件描述符。
events 可以是以下几个宏的集合(常用取值)如下:
- EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(表示应该有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发送错误。
- EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
- EPOLLET:将 epoll 的工作方式设置为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需继续监听该文件描述符 socket 的话,需重新将该文件描述符添加到 EPOLL 队列里。
这些取值是以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位是各不相同的。
3.2.3、epoll_wait 函数
参数说明:
- epfd:epoll_create 函数的返回值(epoll 句柄),用于指定 epoll 模型。
- events:内核会将已就绪的事件拷贝到 events 数组中(不能是空指针,内核只负责将就绪事件拷贝到该数组,不会在用户态中分配内存)。
- maxevents:events 数组中的元素个数,该值不能大于创建 epoll 模型时传入的 size 值。
- timeout:表示 epoll_wait 函数的超时时间,单位是毫秒(ms),0 会立即返回,-1 是永久阻塞。
参数 timeout 的取值:
- -1:epoll_wait 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:epoll_wait 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait 检测后都会立即返回。
- 特定的时间值:epoll_wait 调用后在特定的时间内阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 epoll_wait 超时返回。
返回值:
- 若函数调用成功,则返回有事件就绪的文件描述符个数。
- 若 timeout 时间耗尽,则返回 0。
- 若函数调用失败,则返回 -1,同时错误码会被设置。
epoll_wait 调用失败时,错误码可能被设置为:
- EBADF:传入的 epoll 模型对应的文件描述符无效。
- EFAULT:events 指向的数组空间无法通过写入权限访问。
- EINTR:此调用被信号所中断。
- EINVAL:epfd 不是一个 epoll 模型对应的文件描述符,或传入的 maxevents 值小于等于 0。
3.3、epoll工作原理
3.3.1、红黑树 && 就绪队列
当某一进程调用 epoll_create 函数,Linux 内核会创建一个 eventpoll 结构体,即 epoll 模型,eventpoll 结构体中的成员 rbr、rdlist 与 epoll 的使用方式密切相关。
struct eventpoll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
struct rb_root rbr;
//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
...
}
- epoll 模型中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用 epll_ctl 函数就是在对这颗红黑树进行增删改操作。
- epoll 模型中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已就绪,调用 epoll_wait 函数就是从就绪队列中获取已就绪的事件。
在 epoll 中,对于每一个事件都有一个对应的 epitem 结构体,红黑树和就绪队列中的节点分别是基于 epitem 结构中的 rbn 成员和 rdllink 成员的,epitem 结构中的成员 ffd 记录的是指定的文件描述符值,event 成员记录的就是该文件描述符对应的事件。
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
- 对于 epitem 结构中 rbn 成员而言,ffd 与 event 的含义是:需要监视 ffd 上的 event 事件是否就绪。
- 对于 epitem 结构中的 rdlink 成员而言,ffd 与 event 的含义是:ffd 上的 event 事件已就绪。
注意:红黑树是一种二叉搜索树,必须有键值 key,文件描述符就可以天然的作为红黑树 key 值调用 epoll_ctl 向红黑树中新增节点时,若设置了 EPOLLONESHOT 选项,监听完这次事件后,若还需继续监听该文件描述符则需重新将其添加到 epoll 模型中,本质就是当设置了 EPOLLONESHOT 选项的事件就绪时,操作系统会自动将其从红黑树中删除。
若调用 epoll_ctl 向红黑树中新增节点时没设置 EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用 epoll_ctl 将该节点从红黑树中删除。
3.3.2、回调机制
所有添加到红黑树中的事件,都与设备(网卡)驱动程序建立回调方法,该回调方法在内核中被称为 ep_poll_callback。
- 对于 select 和 poll 而言,操作系统在监视多个文件描述符上的事件是否就绪时,需让操作系统主动对这多个文件描述符进行轮询检测,这会增加操作系统的负担。
- 对于 epoll 而言,操作系统不需主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应回调方法,将就绪的事件添加到就绪队列中。
- 当用户调用 epoll_wait 函数获取就绪事件时,只需关注底层就绪队列是否为空,若不为空则将就绪队列中的就绪事件拷贝给用户。
- 采用回调机制最大的好处:不再需要操作系统主动对就绪事件进行检测,当事件就绪时会自动调用对应的回调函数进行处理。
注意:只有添加到红黑树中的事件才会与底层建立回调方法,因此只有红黑树中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列。
当不断有监视的事件就绪时,会不断调用回调方法向就绪队列中插入节点,而上层也会不断调用 epoll_wait 函数从就绪队列中获取节点,即典型的生产者消费者模型。
由于就绪队列可能被多个执行流同时访问,因此必须要使用互斥锁进行保护,eventpoll 结构中的 lock和 mtx 就是用于保护临界资源的,因此epoll本身是线程安全的 eventpoll 结构中的 wq(wait queue)即等待队列,当多个执行流想同时访问同一个 epoll 模型时,就需在该等待队列下进行等待。
3.4、epoll服务器
3.4.1、epollserver.hpp
#pragma once
#include <iostream>
#include <string>
#include <poll.h>
#include <memory>
#include "Log.hpp"
#include "Epoll.hpp"
#include "Socket.hpp"
using namespace Net_Work;
using namespace EpollerModule;
using namespace std;
const static int gbacklog = 32;
const static int gmanevents = 64;
string EventToString(uint16_t events)
{
string info;
if (events & EPOLLIN)
info += "EPOLLIN";
if (events & EPOLLOUT)
info += "EPOLLOUT";
if (events & EPOLLET)
info += "EPOLLET";
return info;
}
class EpollServer
{
public:
EpollServer(int port) : _port(port), _listensock(new TcpSocket()), _epoller(new Epoller()),_isrunning(false)
{
}
bool InitServer()
{
//1、创建listensock套接字
_listensock->BuildListenSocketMethod((uint16_t)_port, gbacklog);
lg.LogMessage(Info,"init sock success , listensock is : %d\n",_listensock->GetSockFd());
//2、创建epoll模型
_epoller->InitEpoller();
lg.LogMessage(Info,"init epoll success\n");
//3、将listensock添加到epoll中(将fd放入到红黑树中)
_epoller->AddEvent(_listensock->GetSockFd(),EPOLLIN);
return true;
}
bool Accepter(string *peerip,uint16_t*peerport)
{
//监听事件就绪
int sockfd = _listensock->AcceptConnection(peerip,peerport);
if(sockfd<0)
{
lg.LogMessage(Warning,"accept error");
return false;
}
//获取连接成功
_epoller->AddEvent(sockfd,EPOLLIN);
}
int Recver(int sockfd,string *out)
{
//真实情况是。每个sockfd都要有自己的输入输出缓冲区!!!
//其实是要对sockfd进行封装,要不然无法把底层fd和山城关系关联起来!
char buffer[1024];
int n = recv(sockfd,buffer,sizeof(buffer),0);
if(n>0)
{
buffer[n]=0;
*out=buffer;
}
return n;
}
void HandlerEvent(int n)
{
lg.LogMessage(Debug,"%d 个 events 已经就绪了\n",n);
for(int i=0;i<n;i++)
{
lg.LogMessage(Debug,"ready fd : %d, Event is : %s\n",_revs[i].data.fd,EventToString(_revs[i].events).c_str());
int sockfd=_revs[i].data.fd;
uint32_t events=_revs[i].events;
if(events & EPOLLIN)
{
//读事件分两种:1、listen 2、normal sockfd
if(sockfd==_listensock->GetSockFd()) //listensock连接
{
string clientip;
uint16_t clientport;
if(!Accepter(&clientip,&clientport))
{
continue;
}
lg.LogMessage(Info,"accept client success , client[%s:%d]\n",clientip.c_str(),clientport);
}
else /// 普通的读事件就绪
{
string message;
int n = Recver(sockfd,&message);
if(n>0)
{
cout<<"client# "<<message<<endl;
message.resize(message.size()-strlen("\r\n"));
string echo_message="echo message: "+message + "\r\n";
echo_message+="我是帅哥";
send(sockfd,echo_message.c_str(),echo_message.size(),0);
}
else
{
if(n==0)
{
lg.LogMessage(Info,"client %d close\n",sockfd);
}
else
{
lg.LogMessage(Info,"client recv %d error\n",sockfd);
}
_epoller->DelEvent(sockfd);
close(sockfd);
}
}
}
}
}
void Loop()
{
_isrunning = true;
while (_isrunning)
{
_epoller->DebugFdList();
int timeout = -1; //方便测试(阻塞方案)
int n=_epoller->Wait(_revs,gmanevents,timeout);
switch (n)
{
case 0:
lg.LogMessage(Debug, "epoll timeout...\n");
break;
case -1:
lg.LogMessage(Error, "epoll wait failed!\n");
break;
default:
// 正在就绪的fd
lg.LogMessage(Info, "event happend...\n");
HandlerEvent(n);
break;
}
}
_isrunning = false;
}
void Stop()
{
_isrunning=false;
}
~EpollServer()
{
}
private:
unique_ptr<Socket> _listensock;
unique_ptr<Epoller> _epoller;
int _port;
int _isrunning;
struct epoll_event _revs[gmanevents];
};
3.4.2、epoll.hpp
#include <iostream>
#include <string>
#include <cstring>
#include <set>
#include <unistd.h>
#include "Log.hpp"
#include <sys/epoll.h>
const static int defaultepfd = -1;
const static int size = 128;
namespace EpollerModule
{
class Epoller
{
public:
Epoller() : _epfd(defaultepfd)
{
}
void InitEpoller()
{
_epfd = epoll_create(size);
if (defaultepfd == _epfd)
{
lg.LogMessage(Fatal, "epoll_create error, %s : %d\n", strerror(errno), errno);
}
lg.LogMessage(Info, "epoll_create success, epfd: %d\n", _epfd);
}
void AddEvent(int sockfd, uint16_t events)
{
fd_list.insert(sockfd);
struct epoll_event ev;
ev.events = events;
ev.data.fd = sockfd;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
lg.LogMessage(Error, "epoll_ctl add error , %s : %d", strerror(errno), errno);
}
}
void DelEvent(int sockfd)
{
fd_list.erase(sockfd);
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (n < 0)
{
lg.LogMessage(Error, "epoll_ctl del error , %s : %d", strerror(errno), errno);
}
}
void DebugFdList()
{
cout << "fd list is: ";
for (auto &fd : fd_list)
{
cout << fd << " ";
}
cout << endl;
}
int Wait(struct epoll_event *revs, int maxevents, int timeout)
{
int n = epoll_wait(_epfd, revs, maxevents, timeout);
return n;
}
~Epoller()
{
if (_epfd >= 0)
{
close(_epfd);
}
lg.LogMessage(Info, "epoll close success\n");
}
private:
int _epfd;
set<int> fd_list;
};
}
3.5、epoll 服务器测试
编写 epoll 服务器在调用 epoll_wait 函数时,将 timeout 的值设置成了 -1,因此运行服务器后若没有客户端发来连接请求,那么服务器就会调用 epoll_wait 函数后阻塞等待。
使用 telnet 工具连接 epoll 服务器后,epoll 服务器调用的 epoll_wait 函数在检测到监听套接字的读事件就绪后就会调用 accept 获取建立好的连接,并打印输出客户端的 IP 和端口号,此时客户端发来的数据也能成功被 epoll 服务器收到并进行打印输出。
该 epoll 服务器同样为单进程、单线程服务器,但可以为多个客户端提供服务。当服务器端检测到客户端退出后,也会关闭对应连接,此时 epoll 服务器对应的 5 号文件描述符就关闭了。
使用 ls /proc/PID/fd 命令,查看当前epoll服务器的文件描述符的使用情况。文件描述符 0、1、2 是默认打开的,分别对应的是标准输入、标准输出和标准错误,3 号文件描述符对应的是监听套接字,4 号文件描述符对应 epoll 句柄,5 号和 6 号文件描述符分别对应访问服务器的两个客户端。
3.6、epoll 的优点(对比 select )
- 接口使用方便:拆分成了三个函数,使用起来更方便高效,不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开, 不至于冗杂。
- 数据拷贝轻量:只在新增监视事件的时候调用 epoll_ctl 将数据从用户拷贝到内核中,而 select 和 poll 每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用 epoll_wait 获取就绪事件时,只会拷贝就绪的事件,不进行不必要的拷贝操作。
-
事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用 epoll_wait 时直接访问就绪队列就知道哪些文件描述符已就绪,检测是否有文件描述符就绪的时间复杂度是 O(1),因为本质只需要判断就绪队列是否为空即可,即使文件描述符数目很多, 效率也不会受到影响。
- 没有数量限制:监视的文件描述符数目无上限,只要内存允许,可一直向红黑树中新增节点。
注意:网上有的博客中说 epoll 中使用了内存映射机制,内核可以直接将底层就绪队列通过 mmap 的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列中的数据,避免了内存拷贝的额外性能开销。❌这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。我们定义的 struct epoll_event 是我们在用户空间中分配好的内存,因此用户要获取内核中的数据,势必还是要将内核的数据拷贝到用户空间。
与 select 和 poll 的不同之处
- 在使用 select 和 poll 时,都需借助第三方数组来维护历史上的文件描述符以及需要监视的事件,第三方数组由用户自行维护,对该数组的增删改操作都需要用户进行。
- 使用 epoll 时,不需要用户维护第三方数组,epoll 底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用 epoll_ctl 让内核对该红黑树进行对应的操作即可。
-
在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select 和 poll 将这两件事情都交给了同一个函数来完成,而 epoll 在接口层面上就将这两件事进行了分离,epoll 通过调用 epoll_ctl 完成用户告知内核,通过调用 epoll_wait 完成内核告知用户
3.7、epoll 的工作方式
epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)。
3.7.1、水平触发 LT
- 只要底层有事件就绪,epoll 就会一直通知用户。
- 类似于数字电路中的高电平触发一样,只要一直处于高电平,则会一直触发。
epoll 默认状态下就是 LT 工作模式。
- 由于在 LT 工作模式下,只要底层有事件就绪就会一直通知用户,因此当 epoll 检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次 epoll 还会通知用户事件就绪。
- select 和 poll 其实就是工作是 LT 模式下的。
- 支持阻塞读写和非阻塞读写。
3.7.2、边缘触发 ET
如果在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,epoll 进入 ET 工作模式。
- 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll 才会通知用户。
- 类似于数字电路中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。
若要将 epoll 改为 ET 工作模式,则需在添加事件时设置 EPOLLET 选项。
- 由于在 ET 工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当 epoll 检测到底层读事件就绪时,必须立即进行处理,且全部处理完毕,因为有可能此后底层再也没有事件就绪,那么 epoll 就再也不会通知用户进行事件处理,此时没有处理完的数据就丢失了
- ET 工作模式下 epoll 通知用户的次数一般比 LT 少,并且每次都将缓冲区中全部事件处理完成,从而提高网络吞吐量,因此 ET 的性能一般比 LT 性能更高,Nginx 就是默认采用 ET 模式使用 epoll 的。
- 只支持非阻塞的读写。
select 和 poll 其实也是工作在 LT 模式下, epoll 既可以支持 LT, 也可以支持 ET。
ET 工作模式下如何进行读写
因为在 ET 工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了
因此读数据时必须循环调用 recv 函数进行读取,写数据时必须循环调用 send 函数进行写入。
- 当底层读事件就绪时,循环调用 recv 函数进行读取,直到某次调用 recv 读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已读取完毕了。
- 但有可能最后一次调用 recv 读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,若再调用 recv 函数进行读取,那么 recv 就会因为底层没有数据而被阻塞。
- 在这里阻塞是非常严重的,就比如博客写的服务器都是单进程的服务器,若 recv 被阻塞住,并且此后该数据再也不就绪,那么就相当于服务器挂掉了,因此在 ET 工作模式下循环调用 recv 函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
- 调用 send 函数写数据时也是同样的道理,需循环调用 send 函数进行数据的写入,且必须将对应的文件描述符设置为非阻塞状态。
注意:ET 工作模式下,recv 和 send 操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的
对比 LT 和 ET
- LT 是 epoll 的默认行为。使用 ET 能够减少 epoll 触发的次数,但是需要在一次响应就绪过程中就把所有的数据都处理完。应用层尽快的取走了缓冲区中的数据,那么在单位时间内,该模式下工作的服务器就可以在一定程度上给发送方发送一个更大的接收窗口,所以对方就有更大的滑动窗口,一次向我们发送更多的数据,从而提高了 IO 吞吐。
- 在 ET 模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比 LT 更高效,但若在 LT 模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实 LT 和 ET 的性能也是一样的。
- ET 的编程难度比 LT 更高。
理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll 需要将文件描述设置为非阻塞。这个不是接口上的要求,而是 “工程实践” 上的要求。
假设这样的场景:服务器接受到一个 10k 的请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个 10k 请求。
如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中。
此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait 才能返回。
但是,服务器只读到 1k 个数据,要 10k 读完才会给客户端返回响应数据。客户端要读到服务器的响应,才会发送下一个请求。客户端发送了下一个请求,epoll_wait 才会返回,才能去读缓冲区中剩余的数据。
所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来,而如果是 LT 没这个问题。只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。
3.8、epoll 的使用场景
epoll 的高性能是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。
对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。
如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型。