网络编程: 高级IO与多路转接select,poll,epoll的使用与介绍
- 前言
- 一.五种IO模型
- 1.IO的本质
- 2.五种IO模型
- 1.五种IO模型
- 2.同步IO与异步IO
- 3.IO效率
- 二.非阻塞IO
- 1.系统调用介绍
- 2.验证代码
- 三.select多路转接
- 1.系统调用接口
- 2.写代码 : 基于select的TCP服务器
- 1.封装的Socket接口
- 2.开始写代码
- 1.普通的echo服务器
- 2.设计一下如何改进多线程的方案
- 3.代码
- 3.演示
- 3.select的缺点
- 4.select的代码
- 四.poll
- 1.系统调用接口
- 2.第三方数组的选择: 智能指针的回顾
- 3.代码
- 1.框架
- 2.代码
- 4.poll的优缺点
- 五.epoll
- 1.系统调用
- 2.站在系统角度理解epoll的系统调用
- 3.LT和ET
- 4.小总结
- 5.封装epoll的系统调用接口
- 6.epoll的使用
- 7.epoll的优点
- 六.补充: Log.hpp
- 1.C语言版本
- 2.C++版本
前言
注意 : 高级IO当中只关心文件IO/网络IO,因为五种IO模型就是针对于fd相关操作而由大佬发明/归纳出来的,都是为了提高效率/适用于不同的场景
我们学习Linux的过程当中也见到过其他的等待+处理的方式 :
wait/waitpid,lock,pthread_cond_wait,信号量的P操作…它们也都需要等待,但是跟fd无关,所以我们不考虑
其实仔细想想(我们不考虑wait/waitpid,它们可以根据信号那里的方法来解决等待问题):
需要进行lock,pthread_cond_wait,信号量的P操作…这些操作时,往往意味着对应的执行流无需执行其他任务,
因此降低为了它们占用CPU的时长,最好的方式就是让他们阻塞,所以才没有针对于这些操作提出对应的"IO"模型,来减少等待,因为没意义
换言之,多线程这里的等待是为了互斥与同步,而不是单纯的等待读/写事件就绪
一.五种IO模型
1.IO的本质
- IO = 等+拷贝
进行文件操作和网络通信时,如果对应条件不满足(我们称为事件没有就绪),那么我们默认就会进行阻塞等待
比如 :
read,recv,recvfrom
: 典型的读事件没有就绪时就会等待,就绪之后把数据从文件级缓冲区/TCP/UDP的接收缓冲区拷贝到char数组当中write,send
: 典型的写事件没有就绪时就会等待,就绪之后把数据从char数组当中拷贝到文件级缓冲区/TCP的发送缓冲区/UDP直接给sk_buff内核数据结构(sendto一般无需等待,极端情况下需要等待)accept
: accept本质上是去OS维护的全连接队列当中取连接来进行通信,如果没有连接,那么就需要等待哦,而且accept跟listen套接字紧密相关,所以accept也可以看作一种"读IO"
2.五种IO模型
大佬们为了提高IO效率,提出了五种IO模型
1.五种IO模型
- 如何提高IO效率呢 ? -> 单位时间内,减少等的比重
- [1] 阻塞IO : 对应事件没有就绪则一直等待
- [2] 非阻塞轮询式IO : 对应事件没有就绪时立即返回,可以先执行其他任务,一段时间后再次进行非阻塞IO操作
- [3] 信号驱动IO : 对应事件就绪之后,由OS向进程发送信号通知进程进行IO操作
- [4] 多路复用/多路转接IO : 多路转接可以同时等待多个文件描述符的就绪状态,并通知进行对应的IO操作
- [5] 异步IO : 由OS在对应事件就绪之后,进行IO操作,完成之后通知对应进程
2.同步IO与异步IO
首先要说明 : 信号驱动IO属于同步IO还是异步IO是有争议的,而且到现在还没有达成统一,我们在这里认为信号驱动IO也属于同步IO
- 同步IO就是 : 只要对应进程/线程需要参与IO过程(无论是等还是拷贝),它就是同步IO
- 异步IO就是 : 对应进程/线程 发起IO操作,但是自己不参与(无论是等还是拷贝),它就是异步IO
因此 阻塞IO,非阻塞轮询式IO,多路转接IO,信号驱动IO都属于同步IO,而异步IO就是异步IO
对于信号驱动IO,它不用自己等待,但是拷贝操作还是必须由它自己去做
这里就有一个问题了 :
信号驱动IO不用自己等待,可是拷贝操作还是要由自己做,而拷贝操作必须/只能在对应事件就绪之后(等待完毕之后)才能进行啊
因此如果认为 : 信号驱动IO也是要由进程等待的啊,只不过不是它自己等待而已,此时信号驱动IO就像是一种同步IO
如果认为 : 信号驱动IO不是由进程自己等待,而是对应事件就绪之后由OS通知进程,在这段时间当中,进程/线程想执行什么任务就执行什么任务,此时信号驱动IO就像是一种异步IO(毕竟信号是异步的嘛)
信号驱动IO由于信号是异步的,所以具有异步IO的特点,但是拷贝操作要由自己完成,因此需要参与IO过程,又具有同步IO的特点… 我们就认为它是同步IO
信号的异步性并不意味着整个I/O操作也是异步的,因为数据拷贝这一关键步骤仍然需要进程同步地参与
3.IO效率
- 阻塞IO,非阻塞IO,信号驱动IO : 它们的区别就是
等的方式不同
而已 : - 阻塞IO
在等期间不做任何事情
,就只是在对应资源的等待队列当中等待资源就绪,就绪之后才会被调度到CPU的运行队列当中 - 非阻塞IO和信号驱动IO
在等期间可以做其他事情
- 只不过非阻塞IO需要主动在轮询时调用非阻塞IO接口来检查对应资源是否就绪
- 而信号驱动IO是由OS发送信号通知进程/线程来进行拷贝操作
我们这里只关注IO操作的效率 !!
-
无论你等待的方式如何,该等多长时间还是等多长时间,因此IO效率并未提高,只不过是等待期间能否/如何做其他任务而已
-
而它们三者跟多路复用/多路转接IO的区别是 :
-
多路转接可以同时等待多个文件描述符的就绪状态,将多次等待的时间进行重叠,
-
单拿出具体每一个等待,该等多长时间还是等多长时间,只不过多路转接将IO的等待由原本的串行改为了并行,
-
因此可以提前等待,因此就绪的时刻就会提前,所以多路转接在整体而言是减少了整体IO操作当中等的比重,从而提高了IO效率
-
就像是开多线程去分别等待对应的文件描述符的就绪状态,只不过多路转接无需创建额外的线程,减少了线程创建,调度,切换的消耗
-
多路转接通过提高IO操作的并行性,从而提高了整体的IO效率
-
我们只关注IO操作的效率,在不考虑对多个文件描述符对应的IO操作进行异步IO交由OS负责时,
-
多路复用可以同时等待多个文件描述符的就绪状态,因此这种情况下多路转接的效率是高于异步IO的
我们之前用的接口大多数默认都是阻塞IO : read,write,send,recv,recvfrom,accept… 80%的使用IO的场景都是阻塞IO
我们重点看多路转接,因为它效率高
二.非阻塞IO
1.系统调用介绍
对于文件IO: 我们可以open时设置O_NONBLOCK选项,来用非阻塞状态打开对应文件,返回一个非阻塞状态的fd
对于网络IO:
我们介绍一个统一且一劳永逸的方法 :
2.验证代码
下面还是写个代码才清楚
- 普通阻塞式IO
- 非阻塞IO
要包头文件: fcntl.h
使用errno来区分
下面我们开始介绍今天的主角: 多路转接
多路转接的OS提供的系统调用接口共有三个: select,poll,epoll
它们是按照顺序发明的,因此功能等等愈加完善,用法也愈加好用,我们先介绍select
三.select多路转接
- select只负责等待,不负责拷贝,拷贝操作需要由我们自行使用read,write,send,recv等等接口来进行拷贝
- 因为select等待成功之后才通知我们,也就是说只要select通知我们,就意味着对应文件描述符的资源就绪了,
因此我们的IO操作时就不需要等待了,而是直接拷贝即可
1.系统调用接口
注意: fd_set是一个具体的固定大小的类型,因此位图结构当中的比特位是有限的,因此能够等待的fd的个数也是有限的
这一点也是select不好的地方
说这么多,还是不如写一下更清楚,理论要跟实践相结合嘛
2.写代码 : 基于select的TCP服务器
- 我们想用一个模板方法类设计模式封装的Socket接口来封装一下原生套接字的方法,一起感受一下这个设计模式的魅力
- select和poll不是我们本篇博客的重点,因此我们就不把它们完善化了,只关心读事件,而且不解决粘包问题,只实现一个简单的echo服务器,用telnet充当客户端了
- 别担心,我们在后面用epoll实现reactor模式的时候会解决粘包问题,并且实现一个简单的英译汉,汉译英服务器和客户端
- 我们先把注意力放在select,pool和epoll接口的学习和使用上,能够实现多路转接即可
1.封装的Socket接口
下面这份代码对于大家而言并不难理解,毕竟都学到多路转接了,一个多态+套接字封装肯定没问题啊
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdlib>
#include "Log.hpp"
#include <cerrno>
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
const int default_backlog = 5;
// 会话层
// 我自己取地址
#define Conv(sock_info) ((sockaddr *)&sock_info)
enum
{UsageErr = 0,SocketErr,BindErr,ListenErr,ReadErr
};
// 模板方法类设计模式
// 子类当中我们已经维护了socketfd,所以几乎所有接口都无需传入socketfd
class Socket
{
public:virtual int GetSocket() = 0;virtual void SetSocket(int socketfd) = 0;virtual Socket *AcceptSocket(string &src_ip, uint16_t &src_port) = 0; // server接收,无需数据,但是需要返回一个新的套接字fdvirtual void SendMessage(const string &message) = 0; // 发送数据,直接给我字符串即可virtual int RecvMessage(string &buf, int size) = 0; // 接收数据,需要传入sizevirtual void debug() = 0;virtual ~Socket(){}protected:virtual int CreateSocket() = 0;virtual void BindSocket(uint16_t port) = 0; // server才需要,而且server只需要port这一个数据virtual void ListenSocket(int backlog = default_backlog) = 0; // 监听,只需要backlog这一个数据即可virtual bool ConnectSocket(const string &ip, uint16_t port) = 0; // client连接,需要传入server的ip和portpublic:// 创建监听套接字,serverint CreateListenSocket(uint16_t port, int backlog = default_backlog){int socketfd = CreateSocket();BindSocket(port);ListenSocket(backlog);return socketfd;}// 创建连接套接字,clientint CreateConnectSocket(const string &ip, uint16_t port){int socketfd = CreateSocket();ConnectSocket(ip, port);return socketfd;}
};class TcpSocket : public Socket
{
public:TcpSocket() = default;TcpSocket(int socketfd) : _socketfd(socketfd) {}virtual ~TcpSocket() override{if (_socketfd != -1)close(_socketfd);}virtual int GetSocket(){return _socketfd;}virtual int CreateSocket() override{_socketfd = socket(AF_INET, SOCK_STREAM, 0);if (_socketfd < 0){lg.LogMessageKeep(Fatal, "create socketfd fail, errno : %d , strerror : %s\n", errno, strerror(errno));exit(SocketErr);}return _socketfd;}virtual void SetSocket(int socketfd) override{_socketfd = socketfd;}virtual void BindSocket(uint16_t port) override{struct sockaddr_in sock_info;memset(&sock_info, 0, sizeof(sock_info));sock_info.sin_family = AF_INET;sock_info.sin_addr.s_addr = INADDR_ANY;sock_info.sin_port = htons(port);int n = bind(_socketfd, Conv(sock_info), sizeof(sock_info));if (n < 0){lg.LogMessageKeep(Fatal, "server bind fail, errno :%d ,strerror: %s\n", errno, strerror(errno));exit(BindErr);}cout << "bind success" << endl;}virtual void ListenSocket(int backlog) override{int n = listen(_socketfd, backlog);if (n < 0){lg.LogMessageKeep(Fatal, "server listen fail, errno :%d ,strerror: %s\n", errno, strerror(errno));exit(ListenErr);}cout << "listen success" << endl;}virtual TcpSocket *AcceptSocket(string &src_ip, uint16_t &src_port) override{struct sockaddr_in src_addr;socklen_t src_len = sizeof(src_addr);int socketfd = accept(_socketfd, Conv(src_addr), &src_len);src_ip = inet_ntoa(src_addr.sin_addr);src_port = ntohs(src_addr.sin_port);cout << "accept success" << endl;return new TcpSocket(socketfd);}virtual bool ConnectSocket(const string &ip, uint16_t port) override{struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(ip.c_str());server_addr.sin_port = htons(port);int n = connect(_socketfd, Conv(server_addr), sizeof(server_addr));if (n < 0)return false;cout << "connect success" << endl;return true;}virtual void SendMessage(const string &message) override{send(_socketfd, message.c_str(), message.size(), 0);}virtual int RecvMessage(string &buf, int size) override{char buffer[size];ssize_t n = recv(_socketfd, buffer, size - 1, 0);if (n > 0){buffer[n] = '\0';buf += string(buffer);}return n;}virtual void debug() override{cout << "socketfd: " << _socketfd << endl;}private:int _socketfd = -1;
};
2.开始写代码
我们先快速搞出一份普通的echo服务器,体会一下单/多线程的劣势,以及大佬为何要发明多路转接
1.普通的echo服务器
#include <iostream>
#include <memory>
#include <sys/select.h>
using namespace std;
#include "Socket.hpp"
#include <vector>// 记住一个原则 : 所有的fd都有可能需要等待,因此等待全交给select负责
const int defaultRecvSize = 1024;
class Select_server
{
public:Select_server(uint16_t port) : _port(port), _listensock(new TcpSocket){}// 创建监听套件字并进行监听void Init(){_listensock->CreateListenSocket(_port);}// 开始不断accept请求void Start(){while (true){string srcip;uint16_t srcport;shared_ptr<Socket> newsock(_listensock->AcceptSocket(srcip, srcport));Routine(newsock); // 这里往往是要开多线程跑的,因为单线程的话,在给一个用户提供服务时就无法给其他用户提供服务了}}private:void Routine(shared_ptr<Socket> newsock){// 死循环给用户提供服务,直到用户关闭连接时,我们才会退出while (true){string buf;int n = newsock->RecvMessage(buf, defaultRecvSize - 1);if (n > 0){buf[n] = '\0';lg.LogMessageKeep(Info, "recv messsage## %s\n", buf.c_str());// 此时buf就是读到的数据,将该数据echo回client即可newsock->SendMessage(buf);}else if (n == 0){// 说明client方关闭写,因此我们无需再从client读数据了,下面只需要向client发数据就行了// 当client关闭读时,通常情况下我们就可以关闭连接了//这里因为我们读完数据即可就会发给client,因此这里client不写了,我们也就不读了,也就不会给client发数据了,因此我们直接关闭连接即可lg.LogMessageKeep(Info, "client exit, i will disconnect with him\n");break;}else{// 说明写出现了异常,打印一下日志,断开连接lg.LogMessageKeep(Error, "recv message fail , errno: %d, strerror: %s\n", errno, strerror(errno));break;}}}private:uint16_t _port;shared_ptr<Socket> _listensock;
};
一个很基础的echo服务器搞出来,下面我们看一下单线程的劣势
可以看出,单线程时,server一次只能给一个用户提供服务,大家仔细一点观察会发现上一个client断开连接之后,下一个client的服务就能立刻响应,
可是我们的server当时明明是在给上一个client提供服务啊,它怎么能够拿到下一个client已经发送过的数据呢??
道理很简单,下一个client发送的数据放到了tcp的接收缓冲区当中,应用层想拿的时候拿就行了
如果给用户提供服务的Routine函数当中不打while(true)的话:
用户只要发一条消息,服务就结束了
这哪行,用户就没有体验了…
改多线程也很简单
加上两行代码就行
为了方便观察多线程,我们改一下给用户返回的消息
假设我是一个非常大的互联网公司,我手下的某个软件,最高情况下,全球有5亿用户同时访问,
难不成我要总共开5亿个多线程来为每个用户提供服务,5亿啊… 不现实
就算我通过反向代理服务器把业务负载均衡式部署到不同的服务器上,不同的服务器上面再开多进程,每个进程内部在开多线程
层层分解,但是执行流/线程总数依然少不了5亿啊,就算能搞定,代价也很高
而且我们知道: 线程一定越多越好吗?并不是,尽管线程切换是更加轻量的, 但是多线程需要维护同步和互斥,
加锁就会导致多线程执行临界区代码由并行变为串行,极端情况下效率还不如多进程
而且多线程+异常+网络+线程的同步和互斥,调试是相当复杂的…
总体而言,这个方案有待改进,一味使用多线程,代价是相当高的,因此多路转接应运而生
2.设计一下如何改进多线程的方案
下面我们站在大佬的角度来想一下, 这个多线程方案该如何改进呢?
我们搞多线程,是因为单线程的情况下,无法为多个用户同时提供服务,如果我们能为多个用户同时提供服务,不就不用开多线程了吗?
换言之,如果搞不定这个需求,那么多线程就真的无可替代了
我们一起看一下,这个死局,到底该怎么破?
我们先介绍select这个服务员,这个服务员的特点是:
- 优点: 一次性可以等待多个fd
- 最大缺点: fd_set参数属于输入输出型参数,每次返回时都会修改我们所设置的fd_set,因此我们每次使用时都要重置fd_set这个参数
监听套接字要不要交给服务员呢?
当然要啦,因为accept本质也是IO嘛
因为IO的本质就是等+拷贝, 服务的本质不也是等+提供服务嘛
select每次提供等待时, 要求我们给他三份清单(读清单,写清单,异常清单), 上面列出我们想要等待哪个fd的对应事件
一旦对应事件发生,select就会返回给我们给他的那个清单,上面列出了哪些fd的事件已经就绪
正因为如此,所以我们不能指望select保存我们要等待的用户/fd,而是要自己用一个数据结构来保存对应的fd
(这个数据结构通常是数组/vector)
因为给select清单时要用
3.代码
accept尽管也是读事件,但是它的处理方式是: 把对应的fd添加到我们的用户数组当中
而其他套接字的读事件就是做饭
因此我们就能写出这样的框架来
记住一个原则 : 所有的fd都有可能需要读等待,因此读等待全交给select负责
select还要我们告诉他清单上fd的最大值,因为他要遍历进程fd表,查看对应fd的状态
其实select就是一个系统调用接口而已,用起来熟了就都是套路
3.演示
让我们看一下select能否同时给多个用户提供服务
为了证明我们是单线程,打印一下线程ID
完美,的确能够一次性等待多个线程,这是select最大/唯一的优点
3.select的缺点
从代码当中体会
文字:
4.select的代码
#include <iostream>
#include <memory>
#include <sys/select.h>
using namespace std;
#include "Socket.hpp"
#include <vector>const int Default_Select_Array_Size = sizeof(fd_set) * 8; // 一个字节->8个比特位,因此select最多监听的fd总数为sizeof(fd_set) * 8// 记住一个原则 : 所有的fd都有可能需要等待,因此等待全交给select负责class Select_server
{
public:Select_server(uint16_t port) : _port(port), _listensock(new TcpSocket){Select_arr.resize(Default_Select_Array_Size);}// 创建监听套件字并进行监听void Init(){_listensock->CreateListenSocket(_port);}// 开始不断accept请求void Start(){int listenfd = _listensock->GetSocket();Select_arr[listenfd] = _listensock; // 先把listen套接字保存起来fd_set fset; // 读清单while (true){FD_ZERO(&fset);int maxfd = 0;for (int i = 0; i < Default_Select_Array_Size; i++){if (Select_arr[i].get()){FD_SET(i, &fset); // 填写清单maxfd = i;}}// struct timeval timeout;// timeout.tv_sec = 0;// timeout.tv_usec = 0;// 把清单给selectint num = select(maxfd + 1, &fset, nullptr, nullptr, nullptr); // timeout设置为nullptr,代表阻塞等待if (num > 0){lg.LogMessageKeep(Info, "select 等待成功, num: %d\n", num);Routine(num, fset); // select等待成功,通知厨师}else if (num == 0){lg.LogMessageKeep(Info, "超时");}else{lg.LogMessageKeep(Error, "select error, errno: %d , strerror : %s\n", errno, strerror(errno));}}}private:void Routine(int n, fd_set &fset){for (int i = 0, j = 0; i < Default_Select_Array_Size; i++){if (!FD_ISSET(i, &fset))continue;j++;// accept读事件if (i == _listensock->GetSocket()){string src_ip;uint16_t src_port;shared_ptr<Socket> sp(_listensock->AcceptSocket(src_ip, src_port));// accept成功之后把Select_arr当中的相应位置填充好int newsockfd = sp->GetSocket();Select_arr[newsockfd] = sp;lg.LogMessageKeep(Info, "accept success , newsockfd: %d\n", newsockfd);}// 普通读事件else{string buf;int num = Select_arr[i]->RecvMessage(buf, 1024);if (num > 0){lg.LogMessageKeep(Info, "read success , message# %s\n", buf.c_str());string threadid = std::__cxx11::to_string(pthread_self());Select_arr[i]->SendMessage(threadid + " " + buf); // 返回给用户响应}else if (num == 0){lg.LogMessageKeep(Info, "client exit, i will disconnect\n");Select_arr[i].reset(); // 断开连接,取消服务}else{lg.LogMessageKeep(Error, "recv message error, i will disconnect\n");Select_arr[i].reset(); // 断开连接,取消服务}}}}private:uint16_t _port;shared_ptr<Socket> _listensock;vector<shared_ptr<Socket>> Select_arr;
};
因为是清单是位图,所以我们采用哈希的直接定址法(下标就是fd)来组织这个第三方数组
四.poll
1.系统调用接口
poll服务员水平更高一些,它让我们给他提供一个大清单(struct pollfd), 上面的信息是 fd, 小清单: 你想监听哪些事件,还有一个空的小清单
调用poll时,我们填好fd和events即可, 返回时我们只需要看revents和fd即可
其他的逻辑跟select雷同,我们也需要维护一个第三方数组,用来记录要提供服务的客户
只不过我们不需要每次重置我们的events,因此使用起来比select更方便
注意: poll的清单不是位图结构组织的,而是一个可以动态扩容的指针
2.第三方数组的选择: 智能指针的回顾
- shared_ptr<struct pollfd*> _fds?
因为它保存的是一个二级指针,这个二级指针指向的是一个一级指针,这个一级指针是管理我们new出来的struct pollfd* 这段连续区间的指针,因此想要使用就需要我们单独使用定制删除器
(倒是也不难,这么写就行: )
pollfd *pfd = new pollfd[10];
for (int i = 0; i < 10; i++)
{pfd[i].fd = -1;pfd[i].events = pfd[i].revents = 0;
}
auto f = [](pollfd **pfd)
{delete[] (*pfd);
};
shared_ptr<pollfd *> _fds = shared_ptr<pollfd *>(&pfd, f);
for (int i = 0; i < 10; i++)
{cout << (*_fds.get())[i].fd << " " << (*_fds.get())[i].events << " " << (*_fds.get())[i].revents << endl;
}值得注意的是_fds.get()是一个二级指针,我们不能对他[i],否则就野指针了,需要先解引用,取出一级指针(数组首元素地址),然后再去[]拿到pollfd,然后.
- shared_ptr<struct pollfd[]> _fds
它保存的是一个一级指针,这个一级指针就是管理我们new出来的struct pollfd* 这段连续区间的指针
shared_ptr<struct pollfd[]> _fds;_fds = shared_ptr<struct pollfd[]>(new struct pollfd[10]);for (int i = 0; i < 10; i++){_fds[i].fd = -1;_fds[i].events = _fds[i].revents = 0;}for (int i = 0; i < 10; i++){cout << _fds[i].fd << " " << _fds[i].events << " " << _fds[i].revents << endl;}_fds是一个一级指针,直接[i]取pollfd即可
- 其实直接用vector即可
poll传参的时候第一个参数传& vec[0]即可…
反正这三种方法大家随意,我都试过了,我就用第三种vector了,还是vector好啊…
注意法一和法二不要用make_shared,因为:
法一:
1. shared_ptr<struct pollfd[]> _fds指向的是一个数组本身,管理的是一个连续内存,而shared_ptr对它的删除器进行了偏特化,删除时是使用delete [] 而不是delete本身
2. make_shared传参时是采用shared_ptr<T> make_shared (Args&&... args)进行传参的,主要用于单个对象的分配和初始化法二:
make_shared不支持定制删除器,只有构造才支持
3.代码
1.框架
2.代码
代码比select简单多了,直接给了,用vector大家一看就懂
#include <iostream>
#include <memory>
#include <sys/select.h>
using namespace std;
#include "Socket.hpp"
#include <vector>
#include <poll.h>class Poll_server
{
public:Poll_server(uint16_t port) : _port(port), _listensock(new TcpSocket){}// 创建监听套件字并进行监听void Init(){_listensock->CreateListenSocket(_port);}// 开始不断accept请求void Start(){int listenfd = _listensock->GetSocket();set_pollfd_pollin(listenfd);while (true){int num = poll(&_fds[0], _fds.size(), -1);if (num > 0){lg.LogMessageKeep(Info, "poll 等待成功, num: %d\n", num);Routine(num);}else if (num == 0){lg.LogMessageKeep(Info, "超时");}else{lg.LogMessageKeep(Error, "poll error, errno: %d , strerror : %s\n", errno, strerror(errno));}}}private:// 这里为了方便设置监听,所有提取抽离出来一个set_pollfd_pollin函数void set_pollfd_pollin(int fd){if (!_invalids.empty()){int i = _invalids.back() - '0';_invalids.pop_back();_fds[i].fd = fd;_fds[i].events = POLLIN;_fds[i].revents = 0;}else{// 到这里代表需要新增了_fds.push_back(pollfd({fd, POLLIN, 0}));}lg.LogMessageKeep(Info, "成功将fd添加到_fds中: fd: %d\n", fd);}// 跟select的Routine几乎雷同void Routine(int n){for (int i = 0; i < _fds.size(); i++){if (_fds[i].fd == -1)continue;if (_fds[i].revents & POLLIN){int fd = _fds[i].fd;// accept读事件if (fd == _listensock->GetSocket()){string src_ip;uint16_t src_port;Socket *sp = _listensock->AcceptSocket(src_ip, src_port);// accept成功之后把Select_arr当中的相应位置填充好int newsockfd = sp->GetSocket();set_pollfd_pollin(newsockfd);lg.LogMessageKeep(Info, "accept success , newsockfd: %d\n", newsockfd);}// 普通读事件else{char buf[1024] = {0};// cout << "我要读数据啦 " << endl;int num = recv(fd, buf, sizeof(buf) - 1, 0);if (num > 0){buf[num] = '\0';lg.LogMessageKeep(Info, "read success , message# %s\n", buf);send(fd, buf, num, 0);}else{close(_fds[i].fd);_fds[i].fd = -1;_fds[i].events = 0;_fds[i].revents = 0;_invalids += i + '0';if (num == 0)lg.LogMessageKeep(Info, "client exit, i will disconnect\n");elselg.LogMessageKeep(Error, "recv message error, i will disconnect\n");}}}}}private:uint16_t _port;shared_ptr<Socket> _listensock;vector<pollfd> _fds;string _invalids;
};
演示:
4.poll的优缺点
poll其实使用起来相比于select已经挺不错了,但是总归效率还是不够好,大佬们当然是能优化则优化啦,因此设计出来了epoll
五.epoll
1.系统调用
events可以是以下几个宏的集合:
- EPOLLIN :
- 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT :
- 表示对应的文件描述符可以写;
- EPOLLPRI :
- 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR :
- 表示对应的文件描述符发生错误;
- EPOLLHUP :
- 表示对应的文件描述符被挂断;
- EPOLLET :
- 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:
- 只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话,
- 需要再次把这个socket加入到EPOLL队列里
2.站在系统角度理解epoll的系统调用
单凭这个事件驱动+红黑树+就绪队列就已经秒杀select和epoll了,
相当于一个非常精明能干的服务员,等待清单自己维护,我们不用维护第三方数组,直接告诉他一声就行, 而且能让用户主动去跟他发起服务请求…逆天啊
后面还有更强的特点(ET模式)
3.LT和ET
=========================================================================================================
=========================================================================================================
epoll的默认模式是LT模式,想要搞成ET模式,需要给events设置选项EPOLLET
大家可以这么去理解ET模式
ET模式就相当于 qq当中的特别关心, 只会提示一次,而且我们必须一次把数据全读上来,
而我们的进程/线程非常忙也非常注重效率,每天都要处理很多请求,读,写,异常而IO时等是比较慢的,因此使用epoll关心某个fd就相当于给某个联系人设置特别关心,
一旦消息到了,那么进程/线程就会收到通知, 就能够立刻给用户提供服务,从而提高效率
其实LT模式下如果程序员也能一次性把数据全都读完,那么LT模式的效率跟ET就没有差别了
可是LT对程序员没有强制约束力,所以ET这种带有强制约束力的模式就更加高效了
4.小总结
- 系统调用接口
- epoll_create创建一个epoll句柄
- epoll_ctl对监听fd以及对应监听事件类型进行注册
- epoll_wait等待获取对应的监听fd
- 设置关心,通知用户,wait的理解
- 设置epoll关心的方式就是将给红黑树插入对应的节点, 同理修改/删除关心就是修改/删除对应的红黑树上的节点
- epoll通知用户的方式就是将对应节点从红黑树转移到就绪队列当中
- 用户wait的本质就是将就绪队列当中的数据拷贝到用户空间当中,然后对应节点就会从就绪队列转移回红黑树当中
- LT实现通知多次的方式
用户对应fd的数据只要还有或者fd的状态发生了变化,那就会把对应节点继续从红黑树移动回就绪队列,只要对应节点一直在就绪队列,就相当于一直给用户发送通知 - ET实现只通知一次的方式
用户对应fd的数据只要不是少变多或者fd的状态没有发生变化,就不会把对应节点从红黑树移动回就绪队列,因此只给用户发送一次通知
5.封装epoll的系统调用接口
#pragma once
#include <sys/epoll.h>
#include <vector>
#include <string>
using namespace std;class Epoller
{
public:// 默认阻塞式等待Epoller(int timeout = -1) : _timeout(timeout){_epfd = epoll_create(1);}void add_Epoll(int fd, bool in = false, bool out = false, bool except = false){int i = _events_arr.size();if (!_invalids.empty()){i = _invalids.back() - '0';_invalids.pop_back();}else_events_arr.push_back(epoll_event());_events_arr[i].events = (in ? EPOLLIN : 0) | (out ? EPOLLOUT : 0) | (except ? EPOLLERR | EPOLLHUP : 0);_events_arr[i].data.fd = fd;epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &_events_arr[i]);}void removefromEpoll(int fd){for (int i = 0; i < _events_arr.size(); i++){if (_events_arr[i].data.fd == fd){_events_arr[i].data.fd = -1;_events_arr[i].events = 0;_invalids.push_back(i); // 删除的时候添加到invalids当中break;}}epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);}int wait(){return epoll_wait(_epfd, &_events_arr[0], _events_arr.size(), _timeout);}int getfd(int index){return _events_arr[index].data.fd;}uint32_t getevent(int index){return _events_arr[index].events;}private:int _epfd;vector<struct epoll_event> _events_arr;string _invalids;int _timeout;
};
6.epoll的使用
有了epoller之后,epoll使用起来简直不要太爽,比select和poll好用多了,直接给代码了,没啥难的
#pragma once
#include "Socket.hpp"
#include "Log.hpp"
#include <memory>
#include <vector>
#include "Epoller.hpp"class EpollServer
{
public:EpollServer(uint16_t port) : _port(port), _listensock(make_shared<TcpSocket>()){_listensock->CreateListenSocket(port);_epoll.add_Epoll(_listensock->GetSocket(), true);}void loop(){while (true){int n = _epoll.wait();if (n > 0){lg.LogMessageKeep(Info, "epoll wait success, n: %d\n", n);handler(n);}else{lg.LogMessageKeep(Error, "epoll wait error, errno: %d, strerror: %s\n", errno, strerror(errno));}}}private:void handler(int n){for (int i = 0; i < n; i++){int fd = _epoll.getfd(i);uint32_t event = _epoll.getevent(i);if (event & EPOLLIN){// 监听套接字if (fd == _listensock->GetSocket()){string src_ip;uint16_t src_port;Socket *newsock = _listensock->AcceptSocket(src_ip, src_port);_epoll.add_Epoll(newsock->GetSocket(), true);lg.LogMessageKeep(Info, "accept success , newsockfd: %d\n", newsock->GetSocket());}// 普通读事件else{char buf[1024]; // BUG// 1.数据包粘包没有解决// 2.如果本次读到的报文不完整,本来应该下次读取时拼接式读取,以拼接为完整报文// 但是呢.. 这里的recv是覆盖式写入,因此就会导致报文无法完整// 所以需要我们在reactor的时候来解决这个问题int num = recv(fd, buf, sizeof(buf) - 1, 0);if (num > 0){buf[num] = 0;lg.LogMessageKeep(Info, "recv message## %s\n", buf);send(fd, buf, num, 0); // 发回去}else{if (num == 0)lg.LogMessageKeep(Info, "client exit, i will disconnect...\n");elselg.LogMessageKeep(Error, "recv message fail!!!\n");// 取消关心,并close对应的fd_epoll.removefromEpoll(fd);close(fd);}}}}}uint16_t _port;Epoller _epoll;shared_ptr<Socket> _listensock;
};
演示:
7.epoll的优点
多路转接的介绍就这些, 但是我们之前一直在回避一个问题: read的数据粘包,序列反序列化,写事件和异常事件怎么处理?
这些问题我们在reactor模型的博客当中重点介绍
在那里我们会一步步的想方设法解决问题,提出方案修改代码,当我们走完那条路时,我们写出来的东西就是传说中的reactor模型
就像是我们之前学https协议原理时,我们顺着那个路走到头,就是最终版本的CA证书加持版本
只不过reactor这条路比较长,篇幅比较多,而且我们还会扩展主从reactor模型,代码量都是1000多行,所以我们单独写篇博客了就
以上就是高级IO与多路转接select,poll,epoll的使用与介绍的全部内容,希望能对大家有所帮助!!!
六.补充: Log.hpp
1.C语言版本
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unordered_map>
#include <stdarg.h> //va_start的头文件
using namespace std;enum LogLevel // 日志等级
{Debug = 0, // 调试信息Info, // 普通信息Warning, // 报警信息Error, // 错误信息Fatal // 致命信息
};enum LogStyle // 日志打印风格
{Screen, // 显示器打印One_file, // 往同一个打印Class_File, // 分类进行打印
};const string base_filename = "log.";
const string default_dirname = "log";
const LogStyle default_style = Screen;class Log
{
public:Log() : _filename(base_filename), _style(Screen) {mkdir(default_dirname.c_str(), 0775);}void ChangeStyle(LogStyle style){_style=style;}string getLocalTime(){time_t t = time(nullptr);struct tm *tm = localtime(&t);char time_str[128];snprintf(time_str, sizeof(time_str), "%d-%d-%d %d:%d:%d",tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec);return time_str;}// 当前level:日志等级,format:格式,...是类似于printf的方式进行传入void LogMessageKeep(LogLevel level, const char *format, ...) // 类C的一个日志接口{if (_levelMap.count(level) == 0){cout << "level not exist, please check your entry if correct" << endl;return;}// int vsnprintf(char *str, size_t size, const char *format, va_list ap)// 左半部分: 日志等级,当前时间,进程PID 右半部分:信息char leftbuffer[1024] = {'\0'}, rightbuffer[1024] = {'\0'};va_list args; // 可变参数列表va_start(args, format); // 让args指向可变参数列表vsnprintf(rightbuffer, sizeof(rightbuffer), format, args); // 让vsnprintf帮我写va_end(args); // 释放argsstring level_str = _levelMap[level], cur_time_str = getLocalTime(), id_str = to_string(getpid());snprintf(leftbuffer, sizeof(leftbuffer), "[%s] [%s] [%s] ", level_str.c_str(), cur_time_str.c_str(), id_str.c_str());string log_str(leftbuffer);log_str += rightbuffer;Write_to_log(level_str, log_str);}private:void Write_to_One_File(const string &log_name, const string &message){int fd = open(log_name.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);if (fd < 0){cout<<log_name<<endl;return;}write(fd, message.c_str(), message.size());close(fd);}void Write_to_Class_File(const string &level_str, const string &message){string logname = default_dirname + "/" + _filename + level_str;Write_to_One_File(logname, message);}void Write_to_log(const string &level_str, const string &message){switch (_style){case Screen:cout << message;break;case One_file:Write_to_Class_File("all", message);break;case Class_File:Write_to_Class_File(level_str, message);break;default:break;}}private:string _filename;LogStyle _style; // 打印风格,默认是往显示器打印static unordered_map<LogLevel, string> _levelMap;
};unordered_map<LogLevel, string> Log::_levelMap = {{Debug, "Debug"}, {Info, "Info"}, {Warning, "Warning"}, {Error, "Error"}, {Fatal, "Fatal"}};Log lg;class Conf
{
public:Conf(){lg.ChangeStyle(Screen);}
};Conf conf;
C语言版本利用了可变参数列表的知识,snprintf,vsnprintf等等函数
2.C++版本
#pragma once
#include <iostream>
#include <fstream> //ofstream不是一个单独的头文件,而只是一个类
#include <sstream>
#include <sys/stat.h> //mkdir头文件
#include <sys/types.h> //mkdir头文件#include <unordered_map>namespace ns_helper
{class TimeHelper{public:static std::string timeStamp(){time_t time_stamp = time(nullptr);struct tm *local_tm = localtime(&time_stamp);std::string year = std::to_string(local_tm->tm_year + 1900);std::string month = std::to_string(local_tm->tm_mon + 1);std::string day = std::to_string(local_tm->tm_mday);std::string hour = std::to_string(local_tm->tm_hour);std::string minute = std::to_string(local_tm->tm_min);std::string second = std::to_string(local_tm->tm_sec);std::ostringstream oss; // 要往oss当中写数据,所以用ostringstreamoss << year << "-" << month << "-" << day << " " << hour << ":" << minute << ":" << second;return oss.str();}};
}const std::string log_dir = "./log";
const std::string log_base_name = "./log/log";enum LogLevel
{DEBUG, // 调试日志(修复问题,编写调试代码时使用)INFO, // 普通信息日志(服务运行时打印服务消息)WARNING, // 告警日志(代表服务时出现了一些问题,建议修复对应问题)ERROR, // 错误日志(代表服务器出现大问题,但是服务依然可以正常运行,需要立刻修复)FATAL // 致命日志(代表服务器出现大问题导致服务无法正常运行,需要立刻马上抓紧修复)
};enum LogStyle
{Screen, // 把日志打印到显示器上Whole, // 把日志打印到同一个文件当中,不做分类Classify // 把日志按照日志等级进行分类并打印
};class Log
{
public:Log() : _style(Screen){// 目录文件的默认权限775,为了防止unmask掩码的干扰,我们将它清零umask(0);mkdir(log_dir.c_str(), 0775);}std::ostream &log(LogLevel level, const std::string &level_str, const std::string &filename, int line){// 0. 添加线程tidstd::string message = "[" + std::__cxx11::to_string(pthread_self()) + "]" + " ";// 1. 添加日志等级message += "[" + level_str + "]" + " ";// 2. 添加文件名message += "[" + filename + "]" + " ";// 3. 添加所在行message += "[" + std::to_string(line) + "]" + " ";// 4. 添加时间戳message += "[" + ns_helper::TimeHelper::timeStamp() + "]" + " ";if (_style == Screen){// 6. 打印并返回std::cout << message;return std::cout;}else{// 5.如果LogStyle不是Screen : 传入LogLevel调用函数(GetStream[private成员函数]) 拿到对应文件名std::string log_file = getFileName(level);static std::ofstream _ofs; // 懒汉,调用我这个函数,我才实例化(C++11之后局部静态变量的定义是线程安全的)// 用ofstream打开对应文件 : 以std::ios::out | std::ios::app的方式打开if (_ofs.is_open())_ofs.close(); // 先关闭已经打开的文件 // 先关闭已经打开的文件(不关的话后面的文件打不开,不关就是BUG)_ofs.open(log_file, std::ios::out | std::ios::app); // app: 追加写if (!_ofs.is_open()){std::cout << "keep log to file error, return cout ... , message: " << message;return std::cout;}// 6. 打印并返回_ofs << message;return _ofs; // 我们的ofs是静态成员变量,所以不怕引用返回}// 6. 打印并返回(注意: 我们不要加endl因为显示器是行刷新机制,遇到换行就刷新缓冲区,我们要把缓冲区的刷新交由调用方管理,因为我们要实现<<的链式调用)}void setStyle(LogStyle style){_style = style;}LogStyle getStyle() const{return _style;}private:std::mutex mtx;std::string getFileName(LogLevel level) const{// 整个懒汉
#ifndef SWITCHstatic std::unordered_map<LogLevel, std::string> _level_map = {{DEBUG, log_base_name + ".debug"}, {INFO, log_base_name + ".info"}, {WARNING, log_base_name + ".warning"}, {ERROR, log_base_name + ".error"}, {FATAL, log_base_name + ".fatal"}};
#endif// 整个打印if (_style == Whole)return log_base_name + ".all";// 分类打印else{
#ifdef SWITCHswitch (level){case DEBUG:return log_base_name + ".debug";case INFO:return log_base_name + ".info";case WARNING:return log_base_name + ".warning";case ERROR:return log_base_name + ".error";case FATAL:return log_base_name + ".fatal";}// 啥等级也不是return log_base_name + ".unknown";
#elseif (_level_map.count(level))return _level_map[level];// 啥等级也不是return log_base_name + ".unknown";
#endif}}LogStyle _style;// 静态成员变量 : 类内声明
};static Log lg_screen;
#define LOG_SCREEN(log_level) lg_screen.log(log_level, #log_level, __FILE__, __LINE__)static Log lg_whole;
#define LOG_WHOLE(log_level) lg_whole.log(log_level, #log_level, __FILE__, __LINE__)static Log lg_classify;
#define LOG_CLASSIFY(log_level) lg_classify.log(log_level, #log_level, __FILE__, __LINE__)
// 给一个对象的构造函数当中配置lg_whole和lg_classifyclass Conf
{
public:Conf(){lg_screen.setStyle(Screen);lg_whole.setStyle(Whole);lg_classify.setStyle(Classify);}
};
static Conf conf;
C++版本利用了宏,#预处理符号,ostream是ofstream的父类这些知识
大家想用哪个用哪个