目录
一、select
1.1 select初识
1.2 select函数
1.3 scoket就绪条件
1.4 select基本工作流程
1.5 select服务器
1.6 select的优点
1.7 select的缺点
1.8 select的适用场景
二、poll
2.1 poll函数
2.2 poll服务器
2.3 poll的优点 && 缺点
三、epoll
3.1 epoll初识
3.2 epoll相关系统调用
3.3 epoll工作原理
3.4 epoll服务器
3.5 epoll的优点
3.6 epoll的工作方式
一、select
1.1 select初识
select是系统提供的一个多路转接接口
- select系统调用可以让程序同时监视多个文件描述符的上的事件是否就绪
- select核心工作就是等,当监视的文件描述符中有一个或多个事件就绪时,select才会成功返回并将对应文件描述符的就绪事件告知调用者
1.2 select函数
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监视的文件描述符中,最大的文件描述符值 + 1
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已就绪
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已就绪
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已就绪
- timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间
参数timeout的取值:
- NULL/nullptr:select调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪
- 0:selec调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回
- 特定的时间值:select调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回
返回值说明:
- 若函数调用成功,则返回事件就绪的文件描述符个数
- 若timeout时间耗尽,则返回0
- 若函数调用失败,则返回-1,同时错误码被设置
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的全部位
timeval结构
传入select函数的最后一个参数timeout,是一个指向timeval结构的指针。timeval结构用于描述一段时间长度,该结构中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微秒
1.3 scoket就绪条件
读就绪
- socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于0
- socket TCP通信中,对端关闭连接,此时对该socket读,则返回0
- 监听socket上有新的连接请求
- socket上有未处理的错误
写就绪
- socket内核中,发送缓冲区中的可用字节数,大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0
- socket的写操作被关闭(close或者shutdown),此时进行写操作,会触发SIGPIPE信号
- socket使用非阻塞connect连接成功或失败之后
- socket上有未读取的错误
异常就绪
- socket上收到带外数据
注意:带外数据和TCP的紧急模式相关,TCP报头中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据
1.4 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.5 select服务器
Socket类
编写一个Socket类,对套接字相关的接口进行一定封装,为了让外部能直接调用Socket类中封装的函数,于是将部分函数定义成静态成员函数
//网络套接字封装
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"
class Socket
{
const static int gbacklog = 15;
public://服务端客户端通用
static int SocketCreate() {
int SocketFd = socket(AF_INET, SOCK_STREAM, 0);
if(SocketFd < 0) {
LogMessage(FATAL, "socket create fail, %d:%s", errno, strerror(errno));
exit(1);
}
LogMessage(NORMAL, "socket create success, SocketFd:%d", SocketFd);
return SocketFd;
}
public://服务端专用
static void Bind(int listenSocketFd, uint16_t serverPort, std::string serverIp = "0.0.0.0") {
struct sockaddr_in local;
memset(&local, '\0', sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(serverPort);
inet_pton(AF_INET, serverIp.c_str(), &local.sin_addr);
if(bind(listenSocketFd, (struct sockaddr*)&local, sizeof local) < 0) {
LogMessage(FATAL, "bind fail, %d:%s", errno, strerror(errno));
exit(2);
}
LogMessage(NORMAL, "bind success, serverPort:%d", serverPort);
}
static void Listen(int listenSocketFd) {
if(listen(listenSocketFd, gbacklog) < 0) {
LogMessage(FATAL, "listen fail, %d:%s", errno, strerror(errno));
exit(3);
}
LogMessage(NORMAL, "listen success");
}
static int Accept(int listenSocketFd, std::string* clientIp, uint16_t* clientPort) {
struct sockaddr_in client;
socklen_t length = sizeof client;
int serviceSocketFd = accept(listenSocketFd, (struct sockaddr*)&client, &length);
if(serviceSocketFd < 0) {
LogMessage(ERROR, "accept fail, %d:%s", errno, strerror(errno));
exit(4);
}
if(clientIp != nullptr) *clientIp = inet_ntoa(client.sin_addr);
if(clientPort != nullptr) *clientPort = ntohs(client.sin_port);
return serviceSocketFd;
}
public://客户端专用
bool Connect(int clientSocketFd, std::string& serverIp, uint16_t& serverPort) {
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(serverIp.c_str());
server.sin_port = htons(serverPort);
if(connect(clientSocketFd, (struct sockaddr*)&server, sizeof server) == 0) return true;
else return false;
}
public:
Socket() {}
~Socket() {}
};
SelectServer类
#ifndef __SELECT_SVR_H__
#define __SELECT_SVR_H__
#include <iostream>
#include <string>
#include <sys/select.h>
#include "Socket.hpp"
#include "Log.hpp"
#include <unistd.h>
#include <cstring>
#include <cerrno>
#include <sys/time.h>
using namespace std;
#define BITS 8
#define NUM (sizeof(fd_set) * BITS)
#define FD_NONE -1
// 只完成读取,写入和异常不做处理
class SelectServer
{
public:
SelectServer(const uint16_t &port = 9090) : _port(port)
{
_listenSocketFd = Socket::SocketCreate();
Socket::Bind(_listenSocketFd, _port);
Socket::Listen(_listenSocketFd);
LogMessage(DEBUG, "create base socket success");
_fd_array[0] = _listenSocketFd;
for (int i = 1; i < NUM; ++i)
_fd_array[i] = FD_NONE;
}
~SelectServer()
{
if (_listenSocketFd > 0)
close(_listenSocketFd);
}
public:
void Start()
{
while (true)
{
DebugPrint();
fd_set readfds;
FD_ZERO(&readfds);
int maxFd = _listenSocketFd;
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == FD_NONE)
continue;
else
FD_SET(_fd_array[i], &readfds);
if (maxFd < _fd_array[i]) maxFd = _fd_array[i];
}
int number = select(maxFd + 1, &readfds, nullptr, nullptr, nullptr);
switch (number)
{
case 0:
LogMessage(DEBUG, "%s", "Time Out ...");
break;
case -1:
LogMessage(WARNING, "Select Fail: %d : %s", errno, strerror(errno));
break;
default:
LogMessage(DEBUG, "Get a event");
HandlerEvent(readfds);
break;
}
}
}
private:
void Accepter()
{
string clientIp;
uint16_t clientPort = 0;
int socketfd = Socket::Accept(_listenSocketFd, &clientIp, &clientPort);
if (socketfd < 0)
{
LogMessage(ERROR, "accept error");
return;
}
LogMessage(DEBUG, "Get a link success : [%s : %d] , socketFd : %d", clientIp.c_str(), clientPort, socketfd);
int pos = 1;
for (; pos < NUM; ++pos)
if (_fd_array[pos] == FD_NONE) break;
if (pos == NUM) { // 满了
LogMessage(ERROR, "%s:%d", "SelectServer already full, close:", socketfd);
close(socketfd);
}
else { // 找到空位置
_fd_array[pos] = socketfd;
}
}
void Recver(int i)
{
LogMessage(DEBUG, "message in , get IO event:%d", _fd_array[i]);
char buffer[1024];
int num = recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0);
if(num > 0) {
buffer[num] = 0;
LogMessage(DEBUG, "client[%d]#%s", _fd_array[i], buffer);
}
else if(num == 0) {
LogMessage(DEBUG, "client[%d] link close, me too...", _fd_array[i]);
close(_fd_array[i]);
_fd_array[i] = FD_NONE;
}
else {
LogMessage(WARNING, "%d recv error, %d : %s", _fd_array[i], errno, strerror(errno));
close(_fd_array[i]);
_fd_array[i] = FD_NONE;
}
}
void HandlerEvent(const fd_set &readfds)
{
for (int i = 0; i < NUM; ++i)
{
// 去掉不合法的fd
if (_fd_array[i] == FD_NONE) continue;
// 判断是否就绪
if (FD_ISSET(_fd_array[i], &readfds))
{
if (i == 0 && _fd_array[i] == _listenSocketFd) Accepter(); //链接事件
else Recver(i);// 读事件
}
}
}
void DebugPrint()
{
cout << "_fd_array[]:";
for (int i = 0; i < NUM; ++i) {
if (_fd_array[i] != FD_NONE) cout << _fd_array[i] << " ";
}
cout << endl;
}
private:
uint16_t _port;
int _listenSocketFd;
int _fd_array[NUM];
};
#endif
- 当调用accept函数从底层获取上来连接后,不能立即调用read函数读取该连接中的数据,因为此时新连接中的数据可能并没就绪,若直接调用read函数可能阻塞,应该将这个等待过程交给select函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到_fd_array数组中即可,当该连接的读事件就绪时再进行数据读取
- 添加文件描述符到fd_array数组中,本质就是遍历fd_array数组,找到一个没有被使用的位置将该文件描述符添加进去。但有可能_fd_array数组中全部的位置都已被占用,那么文件描述符就会添加失败,此时就只能将刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了
select服务器测试
使用telnet工具连接服务器,此时通过telnet向服务器发送的数据就能够被服务器读到并且打印输出了
虽然SelectServer仅是一个单进程、单线程服务器,但却可以同时为多个客户端提供服务,因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从_fd_array数组中清除
存在的问题
- select服务器若要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为"等"和"拷贝"两步,也应将"等"的这个过程交给select函数,因此在每次调用select函数之前,除了需要重新设置readfds还需要重新设置writefds,并且还需要一个数组来保存需被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时才能够调用write函数向客户端发送数据
- 没有定制协议。代码中读取数据时并没有按照某种规则进行读取,可能造成粘包问题,根本原因就是没有定制协议。如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头中的Content-Length属性得知正文的长度,最终就能够读取到一个完整的HTTP报文,HTTP协议通过这种方式避免了粘包问题
- 没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组buffer中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区中,当读取到一个完整的报文后再让服务器进行处理。此外,若服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用write函数发送给客户端,应该先存储到一个输出缓冲区中,因为响应数据可能很庞大,无法一次发送完毕,可能需要进行分批发送
综上所述,本博客中的SelectServer仅仅是一个Demo,用于理解select函数的使用
1.6 select的优点
- 可以同时等待多个文件描述符,且只负责等待,实际的IO操作由accept、read、write等接口完成,保证接口在进行IO操作时不会被阻塞
- select同时等待多个文件描述符,因此可以将"等"的时间重叠,提高IO效率
上述优点也是所有多路转接接口的优点
1.7 select的缺点
- 每次调用select,都需手动设置fd集合,从接口使用角度来说也非常不便
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select可监控的文件描述符数量太少
select可监控的文件描述符个数
调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构,fd_set结构本质是一个位图,用一个bit位来标记一个文件描述符,因此select可监控的文件描述符个数取决于fd_set类型的bit位个数
#include <iostream>
#include <sys/types.h>
using namespace std;
int main()
{
cout << sizeof(fd_set)* 8 << endl;//1字节 8bit位
return 0;
}
运行代码后可以发现,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.8 select的适用场景
多路转接接口select、poll和epoll,需在一定的场景下使用,若场景不适宜,可能会适得其反
- 多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率。
- 对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的
多连接中只有少量连接是比较活跃的,如聊天工具,登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪
多连接中大部分连接都很活跃,如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了
二、poll
2.1 poll函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合
- nfds:表示fds数组的长度
- timeout:表示poll函数的超时时间,单位是毫秒(ms)
参数timeout的取值:
- -1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪
- 0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回
- 特定的时间值:poll调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后poll进行超时返回
返回值说明:
- 若函数调用成功,则返回有事件就绪的文件描述符个数
- 若timeout时间耗尽,则返回0
- 若函数调用失败,则返回-1,同时错误码被设置
poll调用失败时,错误码可能被设置为:
- EFAULT:fds数组不包含在调用程序的地址空间中
- EINTR:此调用被信号所中断
- EINVAL:nfds值超过RLIMIT_NOFILE值
- ENOMEM:核心内存不足
struct pollfd结构
- fd:特定的文件描述符,若设置为负值则忽略events字段并且revents字段返回0
- events:需要监视该文件描述符上的哪些事件
- revents:poll函数返回时告知用户该文件描述符上的哪些事件已经就绪
events和revents的取值:
这些值都以宏的方式定义,二进制序列中有且只有一个bit位是1,且为1的bit位各不相同
- 在调用poll函数之前,可以通过或运算符将要监视的事件添加到events成员中
- 在poll函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪
2.2 poll服务器
poll的工作流程和select基本类似,下面也实现一个简单poll服务器,只读取客户端发来的数据并进行打印
PollServer类
#ifndef __POLL_SVR_H__
#define __POLL_SVR_H__
#include <iostream>
#include <string>
#include <poll.h>
#include "Socket.hpp"
#include "Log.hpp"
#include <unistd.h>
#include <cstring>
#include <cerrno>
using namespace std;
#define FD_NONE -1
// 只完成读取,写入和异常不做处理
class PollServer
{
public:
PollServer(const nfds_t nfds, const uint16_t &port = 9090) : _port(port), _nfds(nfds), _fds(nullptr)
{
_listenSocketFd = Socket::SocketCreate();
Socket::Bind(_listenSocketFd, _port);
Socket::Listen(_listenSocketFd);
LogMessage(DEBUG, "create base socket success");
_fds = new struct pollfd[_nfds];
_fds[0].fd = _listenSocketFd;
_fds[0].events = POLLIN;
for(int i = 1; i < _nfds; ++i) {
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
_timeout = 1000;
}
~PollServer() {
if (_listenSocketFd > 0) close(_listenSocketFd);
if (_fds != nullptr) delete[] _fds;
}
public:
void Start()
{
while (true)
{
DebugPrint();
int number = poll(_fds, _nfds, _timeout);
switch (number)
{
case 0:
LogMessage(DEBUG, "%s", "Time Out ...");
break;
case -1:
LogMessage(WARNING, "Poll Fail: %d : %s", errno, strerror(errno));
break;
default:
HandlerEvent();
break;
}
}
}
private:
void Accepter()
{
string clientIp;
uint16_t clientPort = 0;
int socketfd = Socket::Accept(_listenSocketFd, &clientIp, &clientPort);
if (socketfd < 0)
{
LogMessage(ERROR, "accept error");
return;
}
LogMessage(DEBUG, "Get a link success : [%s : %d] , socketFd : %d", clientIp.c_str(), clientPort, socketfd);
int pos = 1;
for (; pos < _nfds; ++pos)
if (_fds[pos].fd == FD_NONE) break;
if (pos == _nfds) { // 满了
//可以进行自动扩容
LogMessage(ERROR, "%s:%d", "PollServer already full, close:", socketfd);
close(socketfd);
}
else { // 找到空位置
_fds[pos].fd = socketfd;
_fds[pos].events = POLLIN;
}
}
void Recver(int i)
{
LogMessage(DEBUG, "message in , get IO event:%d", _fds[i].fd);
char buffer[1024];
int num = recv(_fds[i].fd, buffer, sizeof(buffer) - 1, 0);
if(num > 0) {
buffer[num] = 0;
LogMessage(DEBUG, "client[%d]#%s", _fds[i].fd, buffer);
}
else if(num == 0) {
LogMessage(DEBUG, "client[%d] link close, me too...", _fds[i].fd);
close(_fds[i].fd);
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
else {
LogMessage(WARNING, "%d recv error, %d : %s", _fds[i].fd, errno, strerror(errno));
close(_fds[i].fd);
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
}
void HandlerEvent()
{
for (int i = 0; i < _nfds; ++i)
{
// 去掉不合法的fd
if (_fds[i].fd == FD_NONE) continue;
// 判断是否就绪
if (_fds[i].revents & POLLIN)
{
if (_fds[i].fd == _listenSocketFd) Accepter(); //链接事件
else Recver(i);// 读事件
}
}
}
void DebugPrint()
{
cout << "fds[]:";
for(int i = 0; i < _nfds; ++i) {
if(_fds[i].fd == FD_NONE) continue;
cout << _fds[i].fd << " ";
}
cout << endl;
}
private:
uint16_t _port;
int _listenSocketFd;
struct pollfd* _fds;
nfds_t _nfds = 100;
int _timeout;
};
#endif
_fds数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到fds数组时,可能会因为fds数组已满而添加失败,这时poll服务器只能将刚刚获取上来的连接对应的套接字进行关闭
poll服务器测试
在调用poll函数时,将timeout的值设置成1000,因此运行服务器后每隔1000毫秒没有客户端发来连接请求,那么服务器就会超时返回
用telnet工具连接poll服务器后,poll函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号等信息,此时客户端发来的数据也能成功被poll服务器收到并进行打印输出
poll服务器也是单进程、单线程服务器,同样可以为多个客户端服务
当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从_fds数组中清除
2.3 poll的优点 && 缺点
优点
- struct pollfd结构中包含了events和revents,相当于将select的输入输出型参数进行分离,因此在每次调用poll之前,不需像select一样重新对参数进行设置
- poll可监控的文件描述符数量没有限制
- poll也可以同时等待多个文件描述符,提高IO效率
说明一下:
- 虽然代码中将_fds数组的元素个数定义为100,但_fds数组的大小可以增大,poll函数能监视多少文件描述符由poll函数的第二个参数决定
- 而fd_set类型只有1024个bit位,因此select函数最多只能监视1024个文件描述符
缺点
- 和select函数一样,当poll返回后,需要遍历_fds数组来获取就绪的文件描述符
- 每次调用poll,都需将大量struct pollfd结构从用户态拷贝到内核态,这个开销会随着poll监视的文件描述符数目增多而增大
- 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
三、epoll
3.1 epoll初识
epoll是系统提供的一个多路转接接口
- epoll系统调用也可以让程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同
- epoll在命名上比poll多了一个e,可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll
- epoll在2.5.44内核中被引进,几乎具备了select和poll所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法
3.2 epoll相关系统调用
epoll_create函数
int epoll_create(int size);
- 参数size:自Linux2.6.8后,size参数是被忽略的,但size的值必须设置为大于0的值
- 返回值:epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码被设置
注意: 当不再使用时,须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源
epoll_ctl函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
- epfd:epoll_create函数的返回值(epoll句柄)
- op:表示具体的动作,用三个宏来表示
- fd:需要监视的文件描述符
- event:需要监视该文件描述符上的哪些事件
第二个参数op的取值有以下三种:
- EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中
- EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件
- EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符
返回值:函数调用成功返回0,调用失败返回-1,同时错误码会被设置
第四个参数对应的struct epoll_event结构如下:
struct epoll_event结构中有两个成员,第一个成员events表示的是需监视的事件,第二个成员data为联合体结构,一般选择使用该结构中的fd,表示需要监听的文件描述符
events的常用取值如下:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
- EPOLLOUT:表示对应的文件描述符可以写
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(表示有带外数据到来)
- EPOLLERR:表示对应的文件描述符发送错误
- EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了
- EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,若还需继续监听该文件描述符,需重新将该文件描述符添加到epoll模型中
这些取值是以宏的方式定义,二进制序列中有且只有一个bit位是1,且为1的bit位是各不相同的
epoll_wait函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
- epfd:epoll_create函数的返回值(epoll句柄),用于指定epoll模型
- events:内核会将已就绪的事件拷贝到events数组中(不能是空指针,内核只负责将就绪事件拷贝到该数组,不会在用户态中分配内存)
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值
- timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)
参数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工作原理
红黑树 && 就绪队列
当某一进程调用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将该节点从红黑树中删除
回调机制
所有添加到红黑树中的事件,都与设备(网卡)驱动程序建立回调方法,该回调方法在内核中被称为ep_poll_callback
- 对于select和poll而言,操作系统在监视多个文件描述符上的事件是否就绪时,需让操作系统主动对这多个文件描述符进行轮询检测,这会增加操作系统的负担
- 对于epoll而言,操作系统不需主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应回调方法,将就绪的事件添加到就绪队列中
- 当用户调用epoll_wait函数获取就绪事件时,只需关注底层就绪队列是否为空,若不为空则将就绪队列中的就绪事件拷贝给用户
- 采用回调机制最大的好处:不再需要操作系统主动对就绪事件进行检测,当事件就绪时会自动调用对应的回调函数进行处理
注意:
- 只有添加到红黑树中的事件才会与底层建立回调方法,因此只有红黑树中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列
- 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列中插入节点,而上层也会不断调用epoll_wait函数从就绪队列中获取节点,即典型的生产者消费者模型
- 由于就绪队列可能被多个执行流同时访问,因此必须要使用互斥锁进行保护,eventpoll结构中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的
- eventpoll结构中的wq(wait queue)即等待队列,当多个执行流想同时访问同一个epoll模型时,就需在该等待队列下进行等待
3.4 epoll服务器
Epoll类
将epoll相关的系统调用进行封装,便于后续使用
#include <iostream>
#include <sys/epoll.h>
#include <cstdlib>
using namespace std;
class Epoll
{
public:
static const int gsize = 256;
public:
static int EpollCreate()
{
int epollFd = epoll_create(gsize);
if(epollFd > 0) return epollFd;
exit(5);//创建失败直接终止
}
static bool EpollCtl(int epollFd, int op, int socketFd, uint32_t events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = socketFd;
int num = epoll_ctl(epollFd, op, socketFd, &ev);
return num == 0;
}
static int EpollWait(int epollFd, struct epoll_event* revs, int num, int timeout) {
return epoll_wait(epollFd, revs, num, timeout);
}
};
EpollServer类
#ifndef __EPOLL_SERVER_HPP__
#define __EPOLL_SERVER_HPP__
#include <iostream>
#include <string>
#include <functional>
#include <cassert>
#include <unistd.h>
#include "Epoll.hpp"
#include "Log.hpp"
#include "Socket.hpp"
using namespace std;
namespace ns_epoll
{
class EpollServer
{
using func_t = function<void(string)>;
public:
EpollServer(func_t handler, const uint16_t& port = 9090):_port(port), _revsNum(64),_handlerRequest(handler) {
//申请空间
_revs = new struct epoll_event[_revsNum];
//创建监听套接字
_listenSocketFd = Socket::SocketCreate();
Socket::Bind(_listenSocketFd, _port);
Socket::Listen(_listenSocketFd);
//创建epoll模型
_epollFd = Epoll::EpollCreate();
LogMessage(DEBUG, "init success, listenSocketFd : %d, epollFd : %d", _listenSocketFd, _epollFd);
//将监听套接字添加到epoll中
if(Epoll::EpollCtl(_epollFd, EPOLL_CTL_ADD, _listenSocketFd, EPOLLIN))
LogMessage(DEBUG, "Add listenSocketFd to epoll success");
else exit(6);
}
~EpollServer() {
if(_listenSocketFd >= 0) close(_listenSocketFd);
if( _epollFd >= 0) close(_epollFd);
if(_revs != nullptr) delete[] _revs;
}
public:
void Start()
{
int timeout = -1;
while(true)
{
LoopOnce(timeout);
}
}
public:
void LoopOnce(int timeout) {
int num = Epoll::EpollWait(_epollFd, _revs, _revsNum, timeout);
switch (num)
{
case 0:
LogMessage(DEBUG, "Time Out...");
break;
case -1:
LogMessage(WARNING, "epoll wait error: %s", strerror(errno));
break;
default:
LogMessage(DEBUG, "Get a event");
HandlerEvents(num);
break;
}
}
void HandlerEvents(int number)
{
assert(number);
for(int i = 0; i < number; ++i)
{
uint32_t revent = _revs[i].events;
int socketFd = _revs[i].data.fd;
if(revent & EPOLLIN) //读事件就绪
{
if(socketFd == _listenSocketFd) Accetper(_listenSocketFd);
else Recver(socketFd);
}
}
}
void Accetper(int listenSocketFd)
{
string clientIp;
uint16_t clientPort;
int socketFd = Socket::Accept(listenSocketFd, &clientIp, &clientPort);
if(socketFd < 0) {
LogMessage(WARNING, "Accept error");
return;
}
if(!Epoll::EpollCtl(_epollFd, EPOLL_CTL_ADD, socketFd, EPOLLIN)) return;
LogMessage(DEBUG, "Add new link : %d to epoll success", socketFd);
}
void Recver(int socketFd)
{
char buffer[10240];
ssize_t n = recv(socketFd, buffer, sizeof(buffer) - 1, 0);
if(n > 0) {
buffer[n] = 0;
_handlerRequest(buffer);
}
else if(n == 0) {
LogMessage(NORMAL, "client %d close link, me too...", socketFd);
bool ret = Epoll::EpollCtl(_epollFd, EPOLL_CTL_DEL, socketFd, 0);
assert(ret);
close(socketFd);
}
else {
LogMessage(NORMAL, "client %d recv error, close error socketFd", socketFd);
bool ret = Epoll::EpollCtl(_epollFd, EPOLL_CTL_DEL, socketFd, 0);
assert(ret);
close(socketFd);
}
}
private:
int _listenSocketFd;
int _epollFd;
uint16_t _port;
struct epoll_event* _revs;
int _revsNum;
func_t _handlerRequest;
};
}
#endif
epoll服务器测试
编写epoll服务器在调用epoll_wait函数时,将timeout的值设置成了-1,因此运行服务器后若没有客户端发来连接请求,那么服务器就会调用epoll_wait函数后阻塞等待
使用telnet工具连接epoll服务器后,epoll服务器调用的epoll_wait函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能成功被epoll服务器收到并进行打印输出
该epoll服务器同样为单进程、单线程服务器,但可以为多个客户端提供服务
使用 ls /proc/PID/fd 命令,查看当前epoll服务器的文件描述符的使用情况。文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,3号文件描述符对应的是监听套接字,4号文件描述符对应epoll句柄,5号和6号文件描述符分别对应访问服务器的两个客户端
当服务器端检测到客户端退出后,也会关闭对应连接,此时epoll服务器对应的5号和6号文件描述符就关闭了
3.5 epoll的优点
- 接口使用方便:拆分成了三个函数,使用起来更方便高效,不至于冗杂
- 数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不进行不必要的拷贝操作
- 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已就绪,检测是否有文件描述符就绪的时间复杂度是O(1),因为本质只需要判断就绪队列是否为空即可
- 没有数量限制:监视的文件描述符数目无上限,只要内存允许,可一直向红黑树中新增节点
注意:
- 有的博客中说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列中的数据,避免了内存拷贝的额外性能开销
- 这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据
- 因此用户要获取内核中的数据,势必还是要将内核的数据拷贝到用户空间
与select和poll的不同之处
- 在使用select和poll时,都需借助第三方数组来维护历史上的文件描述符以及需要监视的事件,第三方数组由用户自行维护,对该数组的增删改操作都需要用户进行
- 使用epoll时,不需要用户维护第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可
- 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户
3.6 epoll的工作方式
水平触发(LT,Level Triggered)
- 只要底层有事件就绪,epoll就会一直通知用户
- 类似于数字电路中的高电平触发一样,只要一直处于高电平,则会一直触发
epoll默认状态下就是LT工作模式
- 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
- select和poll其实就是工作是LT模式下的
- 支持阻塞读写和非阻塞读写
边缘触发(ET,Edge Triggered)
- 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户
- 类似于数字电路中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发
若要将epoll改为ET工作模式,则需在添加事件时设置EPOLLET选项
- 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,且全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就丢失了
- ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的
- 只支持非阻塞的读写
ET工作模式下如何进行读写
因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入
- 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已读取完毕了
- 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,若再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞
- 在这里阻塞是非常严重的,就比如博客写的服务器都是单进程的服务器,若recv被阻塞住,并且此后该数据再也不就绪,那么就相当于服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态
- 调用send函数写数据时也是同样的道理,需循环调用send函数进行数据的写入,且必须将对应的文件描述符设置为非阻塞状态
强调:ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的
对比LT和ET
- 在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但若在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的
- ET的编程难度比LT更高