目录
Linux中五种IO模型(阻塞、非阻塞、多路转接、信号驱动、异步IO)中,只有前三种比较常用,后两种不太常用,所以下面只学习前三种IO模型
首先IO = 等 + 数据拷贝,在网络通信时大部分时间都在等,等IO类事件就绪,一旦就绪了,我们就可以从内核拷贝到用户,所以我们为了提高IO效率,也就是为了减少等的比重,也就是让我们单位时间内,拷贝的数据量变得更多,IO的效率也就更高了
同步异步IO的区别其实就是:是否有参与到 等 + 拷贝 的这个流程中来
对于读来讲:底层有数据
对于写来讲:底层有空间
就叫做IO类事件就绪
非阻塞读取
说起阻塞读取,我们最常见的就是0号文件描述符标准输入了,如下所示:
此时运行代码:
我若是不输入内容,就会一直阻塞式等待,这就是阻塞,输入一行内容就打印一行内容:
如果想设置文件描述符为非阻塞式等待,需要用到fcntl函数:
fcntl函数
fcntl就是对文件描述符进行指定命令的操作,如果失败返回值是-1
而我们设置文件描述符为非阻塞,也并不想影响其他的文件描述符选项 ,所以第三个参数是在原有的基础上,按位或新的标记位
获取标志位,第二个参数填F_GETFL
设置标志位,第二个参数填F_SETFL,后面是可变参数,想设置什么就往后面加
此时将函数改为如下所示:
运行结果如下:
当没有数据时,就会打印"当前0号fd数据没有就绪, 请重复试试"
当输入数据时,就会打印输入的数据
这就叫做非阻塞读取
非阻塞读取的意义就是,当我们的线程走到这里时:
如果返回的错误码是:EWOULDBLOCK或是EAGAIN,就表示并不是出错了,只是数据还没有就绪
如果是EINTR,就表示被其他信号锁中断了,也并不是出错
所以如果发现返回的信号是上述的两种情况,就continue继续,并不是出错
非阻塞读取就可以让当前进程做其他的事情,而阻塞读取就只能被挂起,什么也干不了
I/O多路转接之select
多路转接是给我们提供更高效等的方案,一次等待多个文件描述符,下面是select的主要工作:
- 帮用户进行一次等待多个文件sock
- 当哪些文件sock就绪了,select就要通知用户,对应就绪的sock有哪些,然后用户再调用recv/recvfrom/read等进行数据读取
select函数
select函数的函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select需要包含头文件:sys/select.h
函数参数:
nfds:需要监视的文件描述符中,最大的文件描述符值+1
readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪
writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪
exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪
timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间
参数timeout的取值:
timeout的类型struct timeval是一个结构体
其中tv_sec是秒,表示可以获取到秒级别的时间戳
tv_usec是微秒,表示可以获取到微秒级别的时间戳
struct timeval的使用如下所示,gettimeofday的第一个参数就是struct timeval类型的:
结果为:
timeout作为输入型参数的含义:
select等待多个fd,等待策略可以选择:
①阻塞式 nullptr
②非阻塞式 {0, 0}
③可以设置timeout时间,时间内阻塞,时间到,立马返回 {5, 0}
设置为nullptr,表示阻塞式
设置为{0, 0},表示非阻塞式
设置为{5, 0},表示5秒内阻塞,时间到后立马返回
timeout作为输出型参数的含义:
其中第三点,传入{5, 0}表示5秒内阻塞,但是在等待时间内假设过了2秒,有fd就绪,此时就可以表示它的输出性,此时这个参数中保存的就是3秒,表示距离下次timeout还剩多长时间
函数返回值:
表示就绪的fd的个数,至少只要有一个fd数据就绪 或 空间就绪,就可以进行返回了
fd_set结构
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的个数是有上限的
而fd_set的大小为1024byte,所以一个select服务器同时能监管的文件描述符总数是1024:
结果为:
之所以*8是因为,sizeof算的是字节数,字节数*8才是比特位数
第二、三、四个参数的类型都是fd_set,下面具体说明第二个参数fd_set *readfds,其他两个参数以此类推即可
参数readfds:
a.输入时:用户告诉内核,我的比特位中,比特位的位置表示文件描述符值,比特位的内容表示是否关心
例如:0000 1010,就代表关心1和3号文件描述符的读事件
b.输出时:内核告诉用户,我是OS,用户你让我关心的多个fd有结果了,比特位的位置表示文件描述符值,比特位的内容表示是否就绪
例如:0000 1000,就代表3号文件描述符的读事件已经就绪了,所以后续用户可以直接读取3号文件描述符,而不会被阻塞
由上述对于输入输出型参数readfds的解释,我们可以得出以下结论:
①用户和内核都会修改同一个位图结构
②这个参数用一次之后,一定需要进行重新设定
同样的道理,writefds和exceptfds与readfds的含义是一样的,都是通过位图置0置1,使得内核和用户相互传递信息
select的模拟实现
select的模拟实现中只完成读取功能,写入和异常不做处理,在模拟实现epoll中会写完整
select的一般编写代码的模式:
需要有一个第三方的数组,用来保存所有合法的fd
不断循环:
while(true)
{
遍历数组,更新出最大值
遍历数组,添加所有需要关心的fd到_fd_set位图中
调用select事件检测
遍历数组,找到就绪的事件,根据就绪事件,完成对应的动作
①Accepter ②Recver
}
select的模拟实现代码如下,具体细节都在代码中的注释中有体现:
Sock.hpp:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <ctype.h>
#include "Log.hpp"
class Sock
{
private:
const static int gbacklog = 20;
public:
Sock()
{}
// 创建套接字
static int Socket()
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
// logMessage(FATAL, "create socket error,%d:%s", errno, strerror(errno));
exit(2);
}
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
// 文件描述符012默认打开,所以再创建就是3
// logMessage(NORMAL, "create socket success, listensock: %d", listensock);
return listensock;
}
// bind
static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
// bind 文件 + 网络
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr); // 4字节ip->转为网络
// binf失败
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
// logMessage(FATAL, "bind error,%d:%s", errno, strerror(errno));
exit(3);
}
}
//监听
static void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
// 监听失败
// logMessage(FATAL, "listen error,%d:%s", errno, strerror(errno));
exit(4);
}
// logMessage(NORMAL, "init server success");
}
//获取连接(server端)
static int Accept(int listensock, std::string *ip, uint16_t *port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
// 获取连接失败
// logMessage(ERROR, "accept error,%d : %s", errno, strerror(errno));
return -1;
}
//拿到客户端的IP和port
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return serversock;
}
//连接函数(client端,发起连接请求)
static bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);//主机->网络
server.sin_addr.s_addr = inet_addr(server_ip.c_str());//inet_addr->点分十进制->4字节IP
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
else return false;
}
~Sock()
{}
};
Log.hpp:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <ctype.h>
#include "Log.hpp"
class Sock
{
private:
const static int gbacklog = 20;
public:
Sock()
{}
// 创建套接字
static int Socket()
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
// logMessage(FATAL, "create socket error,%d:%s", errno, strerror(errno));
exit(2);
}
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
// 文件描述符012默认打开,所以再创建就是3
// logMessage(NORMAL, "create socket success, listensock: %d", listensock);
return listensock;
}
// bind
static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
// bind 文件 + 网络
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr); // 4字节ip->转为网络
// binf失败
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
// logMessage(FATAL, "bind error,%d:%s", errno, strerror(errno));
exit(3);
}
}
//监听
static void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
// 监听失败
// logMessage(FATAL, "listen error,%d:%s", errno, strerror(errno));
exit(4);
}
// logMessage(NORMAL, "init server success");
}
//获取连接(server端)
static int Accept(int listensock, std::string *ip, uint16_t *port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
// 获取连接失败
// logMessage(ERROR, "accept error,%d : %s", errno, strerror(errno));
return -1;
}
//拿到客户端的IP和port
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return serversock;
}
//连接函数(client端,发起连接请求)
static bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);//主机->网络
server.sin_addr.s_addr = inet_addr(server_ip.c_str());//inet_addr->点分十进制->4字节IP
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
else return false;
}
~Sock()
{}
};
main.cc
#include "selectServer.hpp"
#include <memory>
int main()
{
unique_ptr<selectServer> svr(new selectServer());
svr->Start();
return 0;
}
selectServer.hpp
#ifndef __SELECT_SVR_H__
#define __SELECT_SVR_H__
#include <sys/select.h>
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include "Sock.hpp"
#include "Log.hpp"
using namespace std;
#define BITS 8
#define NUM (sizeof(fd_set) * BITS)
#define FD_NONE -1
class selectServer
{
public:
selectServer(const uint16_t& port = 8080):_port(port)
{
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
logMessage(DEBUG, "create base socket success");
// 将 _fd_array 数组初始化为-1
// 再将 _fd_array[0] = _listensock
for(int i = 0; i < NUM; i++) _fd_array[i] = FD_NONE;
_fd_array[0] = _listensock;
}
void Start()
{
while(true)
{
// struct timeval timeout = {0, 0};
// 将_listensock添加到读文件描述符集rdfds中
// FD_SET(_listensock, &rdfds);
// nfds: 添加到select中的sock越来越多,所以每一次都会变化
// 二三四个参数:输入输出型参数,每一次都是不一样的,所以每一次都要重新添加
// timeout:输入输出型参数,每一次都要重置
// 所以需要将合法的文件描述符保存起来,存到 _fd_array数组中
DebugPrint();
fd_set rdfds;
FD_ZERO(&rdfds);
int maxfd = _listensock;
for(int i = 0; i < NUM; i++)
{
if(_fd_array[i] == FD_NONE) continue;
FD_SET(_fd_array[i], &rdfds);
if(maxfd < _fd_array[i]) maxfd = _fd_array[i];
}
int n = select(maxfd+1, &rdfds, nullptr, nullptr, nullptr);
switch(n)
{
case 0:
logMessage(DEBUG, "time out...");
break;
case -1:
logMessage(WARNING, "select error: %d : %s", errno, strerror(errno));
break;
default:
logMessage(DEBUG, "get a new link event...");
HandlerEvent(rdfds);
break;
}
}
}
~selectServer()
{
if(_listensock >= 0)
close(_listensock);
}
private:
void HandlerEvent(const fd_set& rdfds)
{
for(int i = 0; i < NUM; i++)
{
// 去掉不合法的fd
if(_fd_array[i] == FD_NONE) continue;
// 合法了不一定就绪,只有在文件描述符集 rdfds 中,才表示就绪
if(FD_ISSET(_fd_array[i], &rdfds))
{
// 读事件就绪,连接事件到来(_listensock)
if(_fd_array[i] == _listensock) Accepter();
// 读事件就绪,INPUT事件到来: read / recv
else Recver(i);
}
}
}
void Accepter()
{
string clientip;
uint16_t clientport = 0;
// 表示listensock上面的读事件就绪了,可以读取了,此时不会阻塞
int sock = Sock::Accept(_listensock, &clientip, &clientport);
if(sock < 0)
{
logMessage(WARNING, "accept error");
return;
}
logMessage(DEBUG, "get a new link success : [%s:%d] : %d", clientip.c_str(), clientport, sock);
// 这里不能直接read/recv,因为不能确定sock上的数据什么到来,此时就有可能会被阻塞
// 所以这里也让select帮我们检测sock上是否有新的数据,如果有再通知我,此时就不会被阻塞了
// 直接将 sock 放入数组 _fd_array 中即可
int pos = 1;
for(; pos < NUM; pos++)
{
if(_fd_array[pos] == FD_NONE) break;
}
if(pos == NUM)
{
// 文件描述符集已经满了
logMessage(WARNING, "select server already full, close: %d", sock);
close(sock);
}
else
{
// 找到了文件描述符集中值为 FD_NONE 的位置
_fd_array[pos] = sock;
}
}
void Recver(int i)
{
logMessage(DEBUG, "message in, get IO event: %d", _fd_array[i]);
char buffer[1024];
int n = recv(_fd_array[i], buffer, sizeof(buffer)-1, 0);
if(n > 0)
{
buffer[n] = 0;
logMessage(DEBUG, "client[%d]# %s", _fd_array[i], buffer);
}
else if(n == 0)
{
logMessage(DEBUG, "client[%d] quit, me too...", _fd_array[i]);
// 1. 关闭不需要的描述符
close(_fd_array[i]);
// 2. 数组对应位置置为FD_NONE,select也不需要关心了
_fd_array[i] = FD_NONE;
}
else
{
logMessage(WARNING, "%d sock recv error, %d : %s", _fd_array[i], errno, strerror(errno));
// 1. 关闭不需要的描述符
close(_fd_array[i]);
// 2. 数组对应位置置为FD_NONE,select也不需要关心了
_fd_array[i] = FD_NONE;
}
}
void DebugPrint()
{
cout << "_fd_array[]: ";
for(int i = 0; i < NUM; i++)
{
if(_fd_array[i] == FD_NONE) continue;
cout << _fd_array[i] << " ";
}
cout << endl;
}
private:
uint16_t _port;
int _listensock;
int _fd_array[NUM]; // 数组保存合法的fd
};
#endif
select的优缺点
优点:
①相比于之前效率比较高,因为在单位时间内等的比重大大减少了,单位时间内任何文件描述符就绪的概率比之前大
②应用场景:有大量的连接,但是只有少量是活跃的,省资源
缺点:
①为了维护第三方数组,select服务器会充满大量数组
②每一次都要对select输出参数进行重新设定
③能够同时管理的fd的个数是有上限的(1024)
④因为几乎每一个参数都是输入输出型的,所以select一定会频繁的进行用户到内核、内核到用户的数据拷贝
⑤编码比较复杂
I/O多路转接之poll
下面的poll和epoll都是针对于select的缺点进步的
poll主要针对了select的如下两个缺点:
①输入输出参数一体
②管理fd个数是有上限
poll针对于这两个缺点做以改进
poll与select一样,也是只负责等
用户告诉内核:你要帮我关心哪些fd上的哪些事件
内核告诉用户:哪些fd已经就绪了
poll函数的第一个和第二个参数就用于解决上述的两个问题,与select的输入输出参数不同的是,select的输入输出参数是位图结构,所以需要改动
而poll的参数是结构体,结构体中可以有很多成员,有些成员是解决第一个问题,有些成员是解决第二个问题的
poll函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数:
fds:struct pollfd的结构体的起始地址,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合
nfds:表示fds数组的长度
timeout:表示poll函数在多长时间后返回,单位是毫秒(ms)
参数timeout的取值:
大于0:每隔多少秒,timeout一次
0:以非阻塞的方式等
-1:以阻塞的方式等
函数返回值:
大于0:是多少就表示有多少个文件描述符就绪
等于0:说明超时了
小于0:表示函数调用失败,同时错误码会被设置
struct pollfd结构体
fd表示文件描述符
events:需要监视该文件描述符上的哪些事件
revents:poll函数返回时告知用户该文件描述符上的哪些事件已经就绪
poll中,区分读、写、异常的方式是给events设置进POLLIN、POLLOUT、POLLERR就可以了
其中POLLIN、POLLOUT是宏
poll函数的使用示例
简易使用示例代码:
#include <iostream>
#include <poll.h>
#include <cstdio>
#include <unistd.h>
using namespace std;
int main()
{
struct pollfd poll_fd;
poll_fd.fd = 0;
// 关心读事件就绪
poll_fd.events = POLLIN;
while(true)
{
int ret = poll(&poll_fd, 1, 1000);
if(ret < 0)
{
perror("poll");
continue;
}
else if(ret == 0)
{
cout << "poll timeout..." << endl;
continue;
}
// 表示成功返回
if(poll_fd.revents == POLLIN)
{
cout << "poll event ready!" << endl;
char buff[1024] = {0};
read(0, buff ,sizeof(buff)-1);
cout << "stdin: " << buff << endl;
}
}
return 0;
}
运行结果:
可以看到,在示例代码中,在while死循环中,让poll关注读事件,如果返回值小于等于0,表示没有就绪,此时继续循环
如果返回值大于0,表示就绪了,此时判断revents是否是POLLIN,如果是就表示读事件就绪,调用read接口读取内容,并打印出来
poll与select的模拟实现的代码非常相似,并且是在select的基础上做以改进,比select的视线更简单
poll的模拟实现
同样的Sock.hpp和Log.hpp与select一样,makefile和main.cc也是相差不大,下面只体现pollServer.hpp的代码实现:
#ifndef __POLL_SVR_H__
#define __POLL_SVR_H__
#include <poll.h>
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include "Sock.hpp"
#include "Log.hpp"
using namespace std;
#define FD_NONE -1
class PollServer
{
public:
static const int nfds = 100;
public:
PollServer(const uint16_t& port = 8080):_port(port), _nfds(nfds)
{
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
logMessage(DEBUG, "create base socket success");
_fds = new struct pollfd[_nfds];
// 初始化 struct pollfd结构体
for(int i = 0; i < _nfds; i++)
{
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
// 将_listensock 填入0号位置
_fds[0].fd = _listensock;
_fds[0].events = POLLIN;
_timeout = 1000;
}
void Start()
{
while(true)
{
DebugPrint();
int n = poll(_fds, _nfds, _timeout);
switch(n)
{
case 0:
logMessage(DEBUG, "time out...");
break;
case -1:
logMessage(WARNING, "poll error: %d : %s", errno, strerror(errno));
break;
default:
HandlerEvent();
break;
}
}
}
~PollServer()
{
if(_listensock >= 0)
close(_listensock);
if(_fds)
delete []_fds;
}
private:
void HandlerEvent()
{
for(int i = 0; i < _nfds; i++)
{
// 去掉不合法的fd
if(_fds[i].fd == FD_NONE) continue;
// _fds[i].revents 中如果存在POLLIN,就说明读事件就绪
if(_fds[i].revents & POLLIN)
{
// 读事件就绪,连接事件到来(_listensock)
if(_fds[i].fd == _listensock) Accepter();
// 读事件就绪,INPUT事件到来: read / recv
else Recver(i);
}
}
}
void Accepter()
{
string clientip;
uint16_t clientport = 0;
// 表示listensock上面的读事件就绪了,可以读取了,此时不会阻塞
int sock = Sock::Accept(_listensock, &clientip, &clientport);
if(sock < 0)
{
logMessage(WARNING, "accept error");
return;
}
logMessage(DEBUG, "get a new link success : [%s:%d] : %d", clientip.c_str(), clientport, sock);
int pos = 1;
for(; pos < _nfds; pos++)
{
if(_fds[pos].fd == FD_NONE) break;
}
if(pos == _nfds)
{
logMessage(WARNING, "poll server already full, close: %d", sock);
close(sock);
}
else
{
_fds[pos].fd = sock;
_fds[pos].events = POLLIN;
}
}
void Recver(int i)
{
logMessage(DEBUG, "message in, get IO event: %d", _fds[i].fd);
char buffer[1024];
int n = recv(_fds[i].fd, buffer, sizeof(buffer)-1, 0);
if(n > 0)
{
buffer[n] = 0;
logMessage(DEBUG, "client[%d]# %s", _fds[i].fd, buffer);
}
else if(n == 0)
{
logMessage(DEBUG, "client[%d] quit, me too...", _fds[i].fd);
// 1. 关闭不需要的描述符
close(_fds[i].fd);
// 2. _fds对应位置置为FD_NONE,poll也不需要关心了
_fds[i].fd = FD_NONE;
_fds[i].events = 0;
}
else
{
logMessage(WARNING, "%d sock recv error, %d : %s", _fds[i].fd, errno, strerror(errno));
// 1. 关闭不需要的描述符
close(_fds[i].fd);
// 2. _fds对应位置置为FD_NONE,poll也不需要关心了
_fds[i].fd = FD_NONE;
_fds[i].events = 0;
}
}
void DebugPrint()
{
cout << "_fd_array[]: ";
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 _listensock;
// poll的三个参数都设为成员变量
struct pollfd* _fds;
int _nfds;
int _timeout;
};
#endif
poll的优缺点
优点:
①相比于之前效率比较高,同样在单位时间内等的比重大大减少了,单位时间内任何文件描述符就绪的概率比之前大
②应用场景:有大量的连接,但是只有少量是活跃的,同样节省资源
③输入输出参数是分离,不需要每次都进行重置
④poll参数级别,没有可以管理的fd的上限
缺点:
①poll服务器依旧需要不少的遍历,在用户层检测时间就绪,在内核检测fd就绪
②poll需要内核到用户的拷贝
③poll代码编写也比较复杂,但是相比于select容易一些
I/O多路转接之epoll
epoll也是系统提供的一个多路转接接口
epoll的三个系统调用
epoll_create函数
epoll_create函数用于创建一个epoll模型:
int epoll_create(int size);
参数:
size参数一般是被忽略的,但size的值必须设置为大于0的值,至于为什么不废弃,是因为需要向前向后兼容
返回值:
epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置
epoll_ctl函数
epoll_ctl函数用于向指定的epoll模型进行操作:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd:指定的epoll模型(epoll_create的返回值)
op:表示具体的动作,用三个宏来表示(增加、删除、修改)
fd:需要监视的文件描述符
event:需要监视该文件描述符上的哪些事件
op的取值:
EPOLL_CTL_ADD
:增加EPOLL_CTL_MOD
:修改EPOLL_CTL_DEL
:删除
返回值:
函数调用成功返回0,调用失败返回-1,同时错误码会被设置
epoll_wait函数
epoll_wait函数用于收集监视的事件中已经就绪的事件
int epoll_wait(int epfd, struct epoll_event *events,int maxevents,int timeout)
参数:
在特定的epfd中,获取已经就绪的事件,maxevents是events数组中的元素个数
events是输出型参数,内核告诉用户的已经就绪的事件
timeout与poll中的timeout是一样的含义
大于0:每隔多少秒,timeout一次
0:以非阻塞的方式等
-1:以阻塞的方式等
结构体epoll_event
有两个成员,分别是events和data
events是哪个事件就绪,例如EPOLLIN
data是一个联合体,每次只使用一个成员,这里我们使用fd成员
返回值:
返回已经就绪的文件描述符的个数
epoll返回的时候,会将就绪的event按照顺序放入events数组中,即从0下标开始,一共有返回值个
如果底层就绪的sock非常多,events装不下,那么一次拿不完就下一次再拿
epoll的工作原理
在学习epoll的工作原理之前,先想想前面所学习的select和poll的共识:
①无论是select和poll,都是需要用户自己维护一个数组,来进行保存特定的fd与特定的事件
②select和poll都需要遍历
③select和poll的工作模式:
a. 用户告诉内核,需要你帮我关心哪些文件描述符上的哪些事件
b. 内核告诉用户,哪些文件描述符上的哪些事件已经就绪
epoll模型:
当某一进程调用epoll_create函数时,Linux内核会创建一个epoll模型
红黑树与就绪队列:
红黑树:本质就是用户告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作
就绪队列:本质就是内核告诉用户,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件
红黑树需要key值,而这里的fd就是一个天然的key值
回调机制:
对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担
而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中
当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理
下面是总结的epoll的五个细节:
①文件描述符可以作为红黑树天然的key值
②用户只需要设置关系,获取结果即可,不用关心任何对fd和event的管理细节
③epoll高效是因为:
第一、底层是用的红黑树管理的,之前是数组管理的,所以增删改的效率高
第二、之前文件描述符是否就绪需要操作系统遍历,现在只需要就绪以后回调即可,所以操作系统并不需要浪费精力在文件描述符的时间监测上
第三、以前想要获取就绪的文件描述符,依旧需要操作系统遍历,现在epoll有就绪队列,只需要调用epoll_wait从就绪队列中获取就绪结点即可,可以以O(1)的方式直接监测队列是否有数据
④epoll底层只要有fd就绪了,OS会自己构建节点,插入到就绪队列中,上层只需要不断地从就绪队列中将数据拿走,就完成了获取就绪事件的任务
(生产者消费者模型,就绪队列本质是共享资源,epoll保证所有epoll接口是线程安全的,也就是进行了加锁)
⑤如果底层没有就绪事件,那么上层只能阻塞等待
epoll的模拟实现
同样只实现读取
main.cc:
#include <memory>
#include "EpollServer.hpp"
using namespace std;
void Print(string request)
{
cout << "change: " << request << endl;
}
int main()
{
unique_ptr<EpollServer> svr(new EpollServer(Print));
svr->Start();
return 0;
}
Epoll.hpp(封装的三个epoll的函数):
#include <iostream>
#include <sys/epoll.h>
class Epoll
{
public:
static const int gsize = 256; // 随便一个大于0的数都可以
public:
static int CreateEpoll()
{
int epfd = epoll_create(gsize);
if(epfd > 0) return epfd;
exit(5);
}
static bool CtlEpoll(int epfd, int op, int sock, uint32_t events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = sock;
int n = epoll_ctl(epfd, op, sock, &ev);
return n == 0;
}
static int EpollWait(int epfd, struct epoll_event *events, int maxevents, int timeout)
{
return epoll_wait(epfd, events, maxevents, timeout);
}
};
EpollServer.hpp:
#ifndef __EPOLL_SERVER_H__
#define __EPOLL_SERVER_H__
#include <iostream>
#include <sys/epoll.h>
#include <string>
#include <functional>
#include <cassert>
#include <unistd.h>
#include "Sock.hpp"
#include "Log.hpp"
#include "Epoll.hpp"
using namespace std;
class EpollServer
{
public:
using func_t = function<void(string)>;
static const int gmax = 100;
public:
EpollServer(func_t HandlerRequest, const uint16_t& port = 8080)
:_port(port), _maxevent(gmax), _HandlerRequest(HandlerRequest)
{
// 0. 申请对应的空间
_events = new struct epoll_event[_maxevent];
// 1. 创建listensock
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
// 2. 创建epoll模型
_epfd = Epoll::CreateEpoll();
logMessage(DEBUG, "init success, listensock: %d, epfd: %d", _listensock, _epfd);
// 将listensock先添加到epoll中,让epoll帮我们管理起来
if(!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, _listensock, EPOLLIN)) exit(6);
logMessage(DEBUG, "add listensock to epoll success.");
}
void Accepter(int listensock)
{
string clientip;
uint16_t clientport;
int sock = Sock::Accept(listensock, &clientip, &clientport);
if(sock < 0)
{
logMessage(WARNING, "accept error");
return;
}
// 将新的sock,添加给epoll
if(!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, sock, EPOLLIN)) return;
logMessage(DEBUG, "add new sock : %d to epoll success", sock);
}
void Recver(int sock)
{
char buffer[1024];
ssize_t n = recv(sock, buffer, sizeof(buffer)-1, 0);
if(n > 0)
{
// 假设读到了完整的报文
buffer[n] = 0;
// 处理数据
_HandlerRequest(buffer);
}
else if(n == 0)
{
// 1. 先在epoll模型中去掉对 sock 的关心
bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(res);
(void)res;
// 2. 再close文件
close(sock);
logMessage(NORMAL, "client %d quit, me too...", sock);
}
else
{
// 1. 先在epoll模型中去掉对 sock 的关心
bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(res);
(void)res;
// 2. 再close文件
close(sock);
logMessage(NORMAL, "client recv %d error, close error sock", sock);
}
}
void HandlerEvents(int n)
{
assert(n > 0);
for(int i = 0; i < n; i++)
{
uint32_t revents = _events[i].events;
int sock = _events[i].data.fd;
// 读事件就绪
if(revents & EPOLLIN)
{
if(sock == _listensock) Accepter(_listensock); // listensock就绪
else Recver(sock); // 一般的sock就绪 -- read
}
}
}
// 循环一次的函数
void LoopOnce(int timeout)
{
int n = Epoll::EpollWait(_epfd, _events, _maxevent, timeout);
switch(n)
{
case 0:
logMessage(DEBUG, "timeout...");
break;
case -1:
logMessage(WARNING, "epoll wait error: %s", strerror(errno));
break;
default:
// 等待成功
logMessage(DEBUG, "get a event");
HandlerEvents(n);
break;
}
}
void Start()
{
int timeout = -1;
while(true)
{
LoopOnce(timeout);
}
}
~EpollServer()
{
if(_listensock >= 0) close(_listensock);
if(_epfd >= 0) close(_epfd);
if(_events) delete[] _events;
}
private:
int _listensock;
uint16_t _port;
int _epfd; // epoll模型
struct epoll_event* _events; // 结构体数组
int _maxevent; // _events的容量
func_t _HandlerRequest; // Recver函数中的处理方法
};
#endif
epoll的优点
①接口使用简单,使用起来比较高效,不用每次循环设置关注的描述符
②数据拷贝轻量,只需要在合适的时候调用EPOLL_CTL_ADD,将文件描述符拷贝到内核中,操作并不频繁(select/poll每次都要循环拷贝)
③事件回调机制,使用回调的方式将就绪的文件描述符加入就绪队列中,调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O(1),因为本质只需要判断就绪队列是否为空即可
④没有数量限制,文件描述符数量无上限
epoll的工作方式
epoll有两种工作方式,分别是水平触发(LT)和边缘触发(ET)工作模式
水平触发 (LT)
由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪
select/poll/epoll默认状态下就是LT工作模式
支持阻塞读写和非阻塞读写
边缘触发 (ET)
只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户
只支持非阻塞的读写
之所以ET模式下只支持非阻塞,原因是:
ET的工作模式中,如果底层有数据就绪了,上层必须一次将数据读完,倒逼着程序员一次将接收缓冲区的数据取走,所以必须一直循环读,而循环读到最后一次时,必然会阻塞,从而导致程序挂起,为了避免这个问题,就必须将套接字设置为非阻塞的
对比LT和ET
ET模式更高效,理由如下:
①更少的返回次数
②ET模式会倒逼着程序员一次将接收缓冲区的数据取走,应用层尽快的取走了缓冲区的数据,那么在单位时间内,该模式下工作的服务器,就可以在一定程度上,给发送方发送一个更大的接收窗口,所以对方就有更大的滑动窗口,可以一次向我们发送更多的数据,提高IO吞吐
LT是epoll的默认行为
使用ET能够减少epoll触发的次数,但是代价就是倒逼着程序员一次就绪过程就把所有的数据都处理完
在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的
ET的代码比较复杂一些
IO多路转接之select、poll、epoll到此结束啦