网络基础知识梳理和Muduo库使用
文章目录
- 网络基础知识梳理和Muduo库使用
- 1.知识储备
- 2.阻塞、非阻塞、同步、异步
- 我的总结
- 3.Unix/Linux上的五种IO模型
- 0.铺垫
- 1.阻塞IO(blocking)
- 2.非阻塞IO(non-blocking)
- 3.IO复用(IO multiplexing)
- 4.信号驱动(Signal-driven)[Linux特有]
- 5.异步IO(asynchronous)
- 4.好的网络服务器设计
- epoll+fork 不如 epoll+pthread ?
- 5.Reactor模型
- 6.IO复用接口总结
- 1.select和poll的缺点
- 2.epoll原理以及优势
- 1.LT模式(水平触发,默认模式)
- 2.ET模式(边缘触发)
- 3.muduo采用的是LT
- 7.moduo网络库编程
- 1.muduo源码编译安装
- 2.muduo网络库服务器编程
网络基础知识梳理和Muduo库使用
1.知识储备
- TPC协议和UDP协议
- TCP编程和UDP编程步骤
- IO复用接口编程select、poll、epoll编程
- Linux的多线程编程pthread、进程和线程模型,Cpp20标准加入了协程的支持,更轻量级的线程,完全支持用户态的支持,在golang里面已经内置到语言级了。
2.阻塞、非阻塞、同步、异步
一个典型网络IO的两个阶段:数据准备 和 数据读写
网络IO阶段一:数据准备/就绪(TCP接收缓冲区)
- 阻塞:调用IO方法的线程进入阻塞状态
- 非阻塞:不会改变线程的状态,通过返回值判断
常见的IO接口:size_t recv(int sockfd,void* buf,size_t len,int flags);
int size=recv(sockfd,buf,1024,0);
//这个sockfd就是系统文件描述符,就是一个IO,默认是阻塞的,如果scokfd上没有数据可读,recv不会返回,一直阻塞,直到等到这个sockfd上有数据可读//非阻塞,while循环,cpu空转,直到数据准备好/*
size==-1 && errno==EAGAIN 正常的非阻塞返回,sockfd上无网络事件
size==0 网络对端关闭了连接close(fd)
size>0 recv从sockfd上接收到网络事件,读到数据
*/
网络IO阶段二:数据读写
- IO的同步:【用户自己调用】recv将TCP接收缓冲区中的数据搬到用户层定义的buf中,花的都是自己的时间!
- IO的异步:当用户请求内核的时候,用户关心sockfd上的数据,【用户】请求【OS】当TCP接收缓冲区数据就绪后,将数据拷入用户的buf缓冲区中,同时注册一个sigio信号【or 回调函数】,之后应用程序玩自己的了!当OS拷贝完成,通过sigio通知【异步程序最大的标识】,用户看到的是buf的数据已经准备好了!
- 在【处理IO,读/写/等待】的时候,阻塞和非阻塞都是同步IO,只有使用了【特殊的API,内核需要提供相关API,不提供就没有!】才是异步IO
/*
Linux提供的同步IO接口 recv,send
*/
char buf[1024]={0};
/*recv数据准备好了以后,OS会将该sockfd对于的TCP接收缓冲区不断的把数据往应用层的buf里面搬,通过recv的返回值给用户这里就叫做IO的同步,是用户自己去TCP缓冲区拿取数据
*/
int size=recv(sockfd,buf,1024,0);
if(size >0){buf//处理
}
/*
Linux提供的异步IO接口 aio_read,aio_write
*/
aio_read(aiocb,...);
aio_write(aiocb,...);struct aiocb{int aio_fildes; //sockfdoff_t aio_offset;volatile void* aio_buf;//用户空间的buf,输出型参数size_t aio_nbytes;int aio_reqprio;struct sigevent aio_sigevent;//通知方式,信号int aio_lio_opcode;
};
Node.js:基于异步非阻塞模式下的高性能服务器!任何操作都是传入一个数据和一个回调!典型的异步方式的编程。
业务层面的一个逻辑处理,是同步 还是 异步 ???
- 同步:A操作等待B操作做完事情,得到返回值,继续处理。
- 异步:A操作告诉B操作它感兴趣的事件以及通知方式,A操作继续执行自己的业务逻辑,等B监听到相应的事件发生后,B会通知A,A开始相应的数据/操作处理逻辑。
我的总结
阻塞,非阻塞,同步,异步描述的都是IO的状态,一次网络IO通常分为两个阶段,分为数据就绪和数据读写!
比如系统提供的IO接口recv/read,传入sockfd,buf大小,数据是否就绪就是去看内核中的TCP接收缓冲区有无数据可读。
当工作在阻塞状态下的recv,当内核中的TCP接收缓冲区中没有数据可读就阻塞住,如果是非阻塞状态下的recv,则通过判断返回值观察数据是否就绪。一般设计成while循环,让cpu不停的空转,直到数据准备好。
当数据就绪,则进入IO的第二阶段:数据读写阶段,如果是应用层自己调用recv,花费自己的时间,将TCP接收缓冲区的数据拷贝进用户传给接口的buf中,用户等待recv拷贝完成后才返回,这就叫同步。
如果是异步IO,当用户去调用异步IO接口,不会像调用同步IO那么简单,异步IO接口通常需要用户传入sockfd,buf,【通知方式】,通知方式一般是信号或者回调,让OS负责监听TCP缓冲区上是否有数据可读,这个时候用户就可以去做自己的事情了!如果有数据可读,OS会将TCP缓冲区上的数据拷贝到用户传入的buf中,操作完成后OS通过信号或者回调函数通知用户操作已完成。
3.Unix/Linux上的五种IO模型
0.铺垫
一个典型的网络IO接口调用,分为两个阶段,分别为“数据就绪”和“数据读写”,数据就绪阶段分为阻塞和非阻塞,表现的结果就是,阻塞当前线程还是直接返回。
同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);异步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),向B传入请求事件以及事件发生时通知的方式,A就可以处理其他逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。
- 同步阻塞:int size = recv(fd,buf,1024,0); 数据准备好才返回【阻塞】,准备好后,用户再自己去TCP缓冲区拷贝数据到用户buf【同步】
- 同步非阻塞:int size = recv(fd,buf,1024,0); 立刻得到返回值【非阻塞】,需要判断返回值才能直到数据有没有准备好。准备好了之后还是用户自己去TCP缓冲区拷贝数据【同步】。
- 异步阻塞:理论上有,但不合理!A向B提出请求后【异步】,A还要【阻塞着等待B的通知,因为阻塞是数据准备好才返回】,完全是在浪费线程A处理事件的能力。也就是说,在这种工作模式下,A告诉B某任务后,A啥也不干就死等,直到数据准备好 && B将数据从TCP缓冲区中拷贝到用户buf中后,A才继续。
- 异步非阻塞:用户调用异步接口【异步】,传入参数后,就啥也不管了【非阻塞】。内核完成后所有任务后告诉用户。
1.阻塞IO(blocking)
用户调用read/recv,便开始了等待,一直的等待。默认的sockfd是阻塞的,并没有通过setsockopt接口将sockfd设置为非阻塞sockfd,则应用程序调用read时,数据未准备好就阻塞住。等待数据【IO阶段1】,数据从内核空间拷贝到用户空间【IO阶段2】,做完IO两阶段事务后,唤醒进程/线程。
这种模型效率不高,但是编程简单。
2.非阻塞IO(non-blocking)
调用read/recv之前,调用setsockopt接口将sockfd设置为非阻塞,于是进入非阻塞IO,在内核数据未准备好 -> 数据准备好拷贝数据这个过程,用户调用read/recv会马上拿到返回值,用户对拿到的返回值进行判断。当数据准备好后,数据从内核空间拷贝到用户空间,占用的还是用户的时间,所以非阻塞IO也是一种同步IO过程!
3.IO复用(IO multiplexing)
进程阻塞于select/poll/epoll,通过timeout参数设置超时时间也可以让进程/线程工作在非阻塞条件下。也是同步的过程!IO复用和非阻塞IO有什么区别呢?在非阻塞IO中,我们是通过循环检查等待数据就绪的,而在IO复用中,我们通过调用IO多路复用接口select/poll/epoll来完成这件事,多路复用接口会将可读的fd返回。一个线程通过调用IO复用接口可以监听很多很多的sockfd,不像前面的一个线程只能处理一个sockfd。注意I/O复用优化的是等待数据的时间。当多个sockfd可读/可写,IO复用接口会将这些可读/可写的sockfd组织成列表进行返回。
4.信号驱动(Signal-driven)[Linux特有]
数据未就绪 -> 数据就绪,应用线程完全放飞自我,可以去做自己的事情!而前面的IO方式因为没有提前协商通知方式,所以只能阻塞住或者不断的去查询是否准备好。而信号驱动用户向内核注册sigaction信号,内核立刻返回ok,告诉用户你可以放飞自我了,想去做什么就去做吧!在整个数据等待的过程中,应用进程毫不关心,在IO的一阶段是异步的!当数据就绪后,通过注册好的信号给用户通知。但是将数据从内核空间拷贝到用户空间,这里还是得用户自己去调用read/recv接口去获取,所以在第二阶段是同步的!所以信号驱动是异步非阻塞+同步。
5.异步IO(asynchronous)
异步的非阻塞IO是最典型的!整个流程完全不需要关心,用户调用相关的接口后,IO的一阶段和二阶段全部由内核去完成。拷贝完成后,内核通过事先注册的信号通知用户,用户此时对数据进行相关的处理即可!异步IO是效率最高的,但同时编程也最为复杂,出事概率大!
典型的异步非阻塞IO模型【Node.js】
4.好的网络服务器设计
在多核时代,服务器网络编程如何选择线程模型呢?赞同live作者的观点:one loop per thread is usually a good model,这样多线程服务端的编程问题就转换为如何设计一个高效且易于使用的event loop,然后每个线程run一个event loop就行了(当然线程间的同步、互斥少不了,还有其他的耗时事件需要另外的线程来做)
event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO-multiplexing 一起使用,原因由两点:
- 没有人真的会用轮询(busy-pooling)来检查某个non-blocking IO 操作是否完成,这样太浪费CPU资源了!
- IO-multiplex一般不能和blocking IO用在一起,因为blocking IO中的read()/write()/accept()/connect() 都有可能会阻塞当前线程【因为sockfd是阻塞的!】,IO复用接口给用户返回的sockfd都是阻塞的!用户万一被阻塞在了这个sockfd上【用户需要调用read去读取该sockfd】,epoll_wait()就等不到线程去运行它了,那该线程就没有办法处理其他socket上的IO事件了。
所以,当我们提到non-blocking的时候,实际上指的是non-blocking + IO-multiplexing,通过IO复用操作非阻塞sockfd,单用其中任何一个都没有办法很好的实现功能。
epoll+fork 不如 epoll+pthread ?
强大的nginx服务器采用了epoll+fork模型作为网络模块的架构设计,实现了简单好用的负载算法,使各个fork网络进行不会忙的越忙、闲的越闲,并且通过引入一把乐观锁解决了该模型导致的服务器惊群线程,功能十分强大!
5.Reactor模型
The reactor design pattern is an event handling pattern for handling service requestsdelivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers.
四个重要组件:Event事件、Reactor反应堆、Demultiplex事件分发器、Evanthandler事件处理器,下面是一个Single-Reactor模型:
Event 就是 fd+事件类型/读/写
- 把事件注册到反应堆上(将Event和对应的Handler给Reactor反应堆),即反应堆Reactor存储了事件和处理的集合,反应堆维护了Event和对应的Handler
- 反应堆本质就是一个epoll模型,通过Epoll add/mod/del Event向事件分发器中添加事件,启动事件分发器,多路复用开启。
- 事件分发器Demultiplex,开启事件循环epoll_wait【阻塞】,监听新用户的连接或者已连接用户的读写事件,如果epoll_wait监听到有新的事件产生,事件分发器返回发生事件的Event给Reactor,因为Reactor中注册过Event对应的Handler。
- Reactor调用Event对应的事件处理器EventHandler【一般通过map表存储】,处理事件【read->decode->compute->encode->send】
muduo库其实是一个Multiple Reactors模型,模型结构如下:
相当于:mainReactor和subReactor 把reactor和demultiplex合一了。,下面的【read-decode->compute->encode->send 】就是对应响应事件的处理!这张图意思就是我们开启多线程,每个线程上都运行一个Reactor模型!
6.IO复用接口总结
1.select和poll的缺点
select的缺点:
- 单个进程能够监听的文件描述符的数量存在最大限制,通常是1024,虽然可以更改,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差!(Linux内核中:#define __FD_SETSIZE 1024)【select的最大缺陷】
- 内核/用户空间内存拷贝问题,select需要复制大量的文件描述符【fd】,产生巨大的开销
- select采用位数组,用户空间和内核空间都要不断的遍历位数组才能知道哪些事件【fd】响应。
- select触发的方式是水平触发【LT】,应用程序如果没有完成对一个已经就绪文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。【select,poll,工作在LT模式下的epoll都有这个问题!】
相比select模型,poll是使用链表保存文件描述符,因此没有了监听文件数量的限制,但是其他三个缺点依然存在。
- 轮询开销:与 select 类似,返回后需遍历整个
pollfd
数组判断哪些 fd 就绪,时间复杂度 O (n)。 - 内核遍历检测:内核底层仍需遍历所有 fd 检查就绪状态,效率未根本提升。
- 用户态 / 内核态拷贝:每次调用需将 fd 数组从用户态拷贝到内核态,fd 较多时开销显著。
举例:以select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接,除了进程间上下文切换的时间消耗外,从内核/用户空间大量的fd结构内存拷贝,数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100级别的并发访问,是一个很难完成的任务。【除非是协程】
2.epoll原理以及优势
epoll的实现机制与select/poll机制完全不同,他们的缺点在epoll上不复存在。select/poll一般只能处理几千的并发连接。epoll通过在Linux内核中申请一个简易的文件系统。把原先的select/poll调用分为以下3个部分:
epoll_create
:在内核创建 epoll 实例(红黑树 + 就绪队列),返回管理该实例的 fd。epoll_ctl
:增删改红黑树节点(存储 fd 及关注事件),内核维护而非用户态数组。epoll_wait
:从就绪队列获取就绪事件,仅返回有事件的 fd,避免无效遍历。
epoll高效的原因如下:
- 数据结构优化:红黑树管理待检测 fd(O (log n) 增删),就绪队列存储就绪 fd(O (1) 获取)。
- 事件驱动:内核通过回调机制直接将就绪 fd 加入队列,无需遍历所有 fd,避免 select/poll 的 “盲目扫描”。
- 单次拷贝:仅拷贝就绪事件到用户态,非全量 fd,减少数据传输开销。
epoll_create在内核中创建的eventpoll结构如下:
struct eventpoll{//.../*红黑树的根节点,这棵树存储的是所有添加到epoll中需要监听的事件*/struct rb_root rbt;/*双链表【就绪队列】中则存放将要通过epoll_wait返回给用户的满足条件的事件*/struct list_head rdlist;//...
};
1.LT模式(水平触发,默认模式)
内核数据没被读完,就会一直上报数据。
- 触发条件:只要 fd 对应的读 / 写缓冲区有数据(未读完)或可写,就持续通知。
- 特点:类似 select/poll,允许分多次处理数据,编码简单,适合通用场景。
- 示例:recv 返回 > 0 时,可继续读取直到缓冲区空或阻塞(阻塞需谨慎)。
2.ET模式(边缘触发)
内核数据只上报一次。
- 触发条件:仅当 fd 状态发生变化(如缓冲区从无数据到有数据,或可写状态首次出现)时通知一次。
- 特点:强制要求一次性读尽数据(循环读至
EAGAIN
),fd 必须设为非阻塞,避免阻塞当前线程。 - 优势:减少重复通知,适合高并发、高吞吐量场景(如 Nginx),但编码复杂度高。
3.muduo采用的是LT
- 不会丢失数据或者消息
- 应用没有读取完数据,内核是会不断上报的
- 低延迟处理
- 每次读数据只需要一次系统调用,照顾了多个连接的公平性,不会因为某个连接上的数量量过大而影响其他连接处理消息。
- 跨平台处理
- 某些平台不支持ET模式下epoll模型,使用LT模式可以更好的跨平台处理。
7.moduo网络库编程
1.muduo源码编译安装
- 下载muduo源码
git clone https://github.com/chenshuo/muduo.git
- 安装boost开发库
- 安装cmake
- 修改CMakeLists.txt,注释掉单元测试,耽误时间
- 执行build.sh
./build.sh
- 再次
./build.sh
,显示100%完成即可。 - 再输入./build.sh install命令进行muduo库安装
- 去muduo-master同级目录下的build目录下的release-install-cpp11文件夹下将inlcude(头文件)和lib(库文件)目录下的文件拷贝到系统目录下,
mv muduo/ /usr/include/
,回到lib目录下,执行mv * /usr/local/lib/
,拷贝完成以后使用muduo库编写C++网络程序,不用在指定头文件和lib库文件路径信息了,因为g++会自动从/usr/include和/usr/local/lib路径下寻找所需要的文件。
g++ test.cpp -lmuduo_net -lmuduo_base -lpthread -std=c++11
,编译运行即可
2.muduo网络库服务器编程
muduo网络库给用户提供了两个主要的类
-
TcpServer:用于编写服务器程序的
-
TcpClient:用于编写客户端程序的
封装 epoll+线程池
好处:能够把网络I/O的代码【网络库封装】和业务代码区分开
网络库对外提供:用户的连接和断开,用户的可读写事件
基于muduo网络库开发服务器程序
- 组合TcpServer对象
- 创建EventLoop事件循环对象的指针
- 明确TcpServer构造函数需要什么参数,输出ChatServer的构造函数
- 在当前服务器类的构造函数当中,注册处理连接的回调函数和处理读写事件的回调函数
- 设置合适的服务端线程数量,muduo库会自己分配I/O线程和worker线程
/*
muduo网络库给用户提供了两个主要的类
TcpServer:用于编写服务器程序的
TcpClient:用于编写客户端程序的封装:epoll+线程池
好处:能够把网络I/O的代码【网络库封装】和业务代码区分开
网络库对外提供:用户的连接和断开,用户的可读写事件*/#include <muduo/net/TcpServer.h>
#include <muduo/net/EventLoop.h>
#include <iostream>
#include <functional>
#include <string>
using namespace std;
using namespace placeholders;
// 展开命名空间
using namespace muduo;
using namespace muduo::net;/*基于muduo网络库开发服务器程序
1.组合TcpServer对象
2.创建EventLoop事件循环对象的指针
3.明确TcpServer构造函数需要什么参数,输出ChatServer的构造函数
4.在当前服务器类的构造函数当中,注册处理连接的回调函数和处理读写事件的回调函数
5.设置合适的服务端线程数量,muduo库会自己分配I/O线程和worker线程
*/
class ChatServer
{
public:ChatServer(EventLoop *loop, // 事件循环const InetAddress &listenAddr, // Ip+Portconst string &nameArg) // 服务器的名字: _server(loop, listenAddr, nameArg), _loop(loop){// 给服务器注册用户的连接的创建和断开回调// 我知道断开连接怎么做,但是我不知道什么时候断开!所以需要回调!// 我们的方法除了this,还有一个参数,所以写_1_server.setConnectionCallback(std::bind(&ChatServer::onConnection, this, _1));// 给服务器注册用户读写事件回调_server.setMessageCallback(std::bind(&ChatServer::onMessage, this, _1, _2, _3));// 设置服务器端的线程数量 1个I/O线程 3个worker线程_server.setThreadNum(4);}// 开启事件循环void start(){_server.start();}private:// 专门处理用户的连接创建和断开 epoll listenfd accept// 用户只要写回调就行,其余muduo管理了!void onConnection(const TcpConnectionPtr &conn){if (conn->connected()) // 连接成功{// 对端的cout << conn->peerAddress().toIpPort() << " -> " << conn->localAddress().toIpPort() << "state:online" << endl;}else{cout << conn->peerAddress().toIpPort() << " -> " << conn->localAddress().toIpPort() << "state:offline" << endl;conn->shutdown(); // close(fd)//_loop->quit(); //服务器退出}}// 专门处理用户的读写事件void onMessage(const TcpConnectionPtr &conn, // 连接Buffer *buffer, // 缓冲区Timestamp time) // 接收到数据的时间信息{string buf = buffer->retrieveAllAsString();cout << "recv data:" << buf << " time" << time.toString() << endl;// 收到啥就发啥conn->send(buf);}TcpServer _server; // #1EventLoop *_loop; // #2 epoll
};int main()
{EventLoop loop; // epollInetAddress addr("127.0.0.1", 8080); // 需要改的地方ChatServer server(&loop, addr, "CharServer"); // 可能需要改的地方server.start(); // listen epoll_ctl => epollloop.loop(); // epoll_wait以阻塞方式等待新用户连接,已连接用户的读写事件等return 0;
}