C++11重写muduo网络库——预备知识

一、阻塞、非阻塞、同步、异步

1、典型的一次I/O的两个阶段是什么?

1)数据准备 和 数据读写
我们作为服务器,接收客户端的请求,得先监听客户端有没有数据过来,这是一个状态,还有就是数据过来了该怎么去读写,这又是一个状态。

实际上,阻塞,非阻塞,同步,异步,分别是这两种状态下的体系。

2、网络I/O阶段1:数据准备

数据准备:根据系统IO操作的就绪状态,分为

  1. 阻塞 : 让调用I/O的线程进入阻塞状态 ,数据准备好了就唤醒
  2. 非阻塞: 不会改变线程的状态,通过返回值判断
1)阻塞

sockfd相当于就是系统的文件描述符,代表1个I/O,创建的时候默认是阻塞,当我调用1个阻塞I/O的话,如果sockfd上没有数据可读,这个recv不会返回,造成当前线程阻塞,等待sockfd上有数据到来。
如果返回了,就是有数据可读了,接下去就是数据读写了。返回的是最终读的数据的大小。一直等着。

2)非阻塞

如果我们在创建sockfd的时候设置是非阻塞,recv的体现是:如果sockfd上没有数据到来的话,recv直接返回回来,不会造成当前线程阻塞。sockfd没有数据准备好的话,不断的空转CPU。

3)阻塞和非阻塞的返回值(数据准备的返回值)

如果size==-1的话,表示错误:

  1. size==-1真的错误,是系统的内部错误,可能要close(sockfd)
  2. 如果size==-1&&errno==EAGAIN,表示正常的非阻塞返回,sockfd上没有网络事件发生

如果size!= -1,有两种情况

  1. 如果size= =0,表示网络对端关闭了连接,对端直接close(sockfd)
  2. 如果size>0,就是表示有数据过来了。

这2个错误号表示一样的情况

3、网络I/O阶段2:数据读写

数据读写:根据应用程序和内核的交互方式,分为两种

  1. 同步
  2. 异步
有2种同步和异步

I/O的同步和异步
应用层并发的同步和异步
两者相似。

我们来看看网络I/O的同步和异步:

1) I/O同步:

在应用程序上调用recv函数,这个sockfd我不管它工作在阻塞模式还是非阻塞模式,真的有数据准备好了之后(TCP的接收缓冲区有数据了,就是数据可读了),我们要读这个数据,这个buf是用户层自己定义的,recv就可以开始接收了,是应用程序卡在这里recv(),从内核的TCP接收缓冲区搬数据到应用层上的buf,在搬的过程中,因为size>0,这就表示从内核搬了多少字节的数据,我们就要访问buf了,没搬完之前,不会进入到下面的if语句。搬完了,recv才返回过来,看看size是多少,就是搬了多少数据,因此I/O同步是应用程序搬的数据

I/O同步的意思就是:当我调用网络I/O的接口,当I/O阶段1数据准备好之后,在数据读写的时候,应用层自己调用网络I/O接口自己去读写,都花在应用层上
recv和send是同步的I/O接口

2) I/O异步

当我请求内核的时候,我比较关心sockfd上的数据,远端如果发过来数据,我需要读sockfd上的数据,我有一个buf,到时候如果有数据来了,内核能不能帮忙把数据放到buf里面,我再给内核注册一个sigio信号,也就是说,对一个操作系统级别的异步的I/O接口来说,我先塞给内核一个sockfd,表示对这个sockfd上的事件感兴趣,如果sockfd上有数据可读的话,麻烦操作系统内核把数据搬到buf里面。

内核把内核缓冲区-sockfd对应的TCP接收缓冲区的数据搬到buf里面,搬完以后,通过信号sigio给应用程序通知一下。应用程序在这期间可以玩自己的了,做任何事清都可以。

当我们应用程序调用异步I/O接口的时候,我们就把sockfd,buf,sigio(通知方式,也可以通过回调,我们在这里用的是sigio)通过异步I/O接口都塞给了操作系统。应用程序做自己的任何事情都可以,当操作系统sigio通知你的时候,你看到的是buf的数据已经准备好了,应用程序不用搬,不用花应用程序的时间,不用像I/O同步一样一直阻塞等待recv或者空转。
C++11重写muduo网络库——预备知识_第1张图片

3)IO同步和IO异步的区别

异步,一定要记住这个词语:通知(异步最大的标识,是异步就有通知)
在同步I/O调用的时候,有数据准备好了,数据是应用程序自己花时间搬的,搬完以后,recv才返回,把数据从内核的接收缓冲区搬到用户的buf里面,耗的是应用程序的时间。

看操作系统有没有提供异步I/O接口让你调用。
异步I/O效率高哦!

linux的aio_read,aio_write就是典型的linux给我们提供的异步I/O接口。
就是aio_read需要的参数
就是应用程序在调用异步I/O接口需要传的参数,也就是给内核传的参数
C++11重写muduo网络库——预备知识_第2张图片

Node.js基于异步非阻塞模式下的高性能服务器

在处理 IO 的时候,阻塞和非阻塞都是同步 IO。只有使用了特殊的 API 才是异步IO

C++11重写muduo网络库——预备知识_第3张图片

epoll是同步的I/O
epoll_wait在调用的时候,我们传参数以后,最后一个参数的timeout,如果不自定义时间,相当于工作在阻塞状态,有事件发生,会返回发生事件的event,我们从event上读,如果event对应的事件是它在sockfd上有可读数据,我们读,调用recv,当然是我们应用程序自己读了。
我们如果有设置timeout超时时间后,我们也得检查有没有发生事件event,没有的话,我们继续循环。

4)业务层面上的同步和异步

同步就是 A操作等待B操作做完事情,得到返回值,继续处理。
异步就是A操作告诉B操作它感兴趣的事件以及通知方式,A操作继续执行自己的业务逻辑,等B监听到相应事件发生后,B会通知A,A开始相应的数据操作处理逻辑。

4、阻塞、非阻塞、同步和异步区分总结(重要)

1、IO层面的同步和异步、阻塞和非阻塞

阻塞,非阻塞,同步,异步描述的都是I/O的一些状态,一个典型的网络I/O包含2个阶段:数据准备(数据就绪)和数据读写,
比如说recv,传一个sockfd,buf,buf的大小,数据就绪就是远端有没有数据过来,就是内核相应的sockfd对应的TCP接收缓冲区是否有数据可读,当sockfd,当I/O工作在阻塞模式下的话,当我们调用recv的时候,如果数据没有就绪,recv是会阻塞当前的线程的。如果这个sockfd是工作在非阻塞模式下的话,当我们去调用系统I/O接口recv的时候,recv会直接返回的,我们都是这么判断,如果size==-1的话,表示错误
1、真的错误,是系统的内部错误,可能要close(sockfd)
2、如果size==-1&&errno==EAGAIN,表示正常的非阻塞返回,sockfd上没有网络事件发生。

如果size= =0,表示网络对端关闭了连接,对端直接close(sockfd)
如果size>0,就是表示有数据过来了。

如果是同步I/O,数据就绪的时候,远端有数据过来了,数据准备好了,开始进行读写了,应用程序调用recv这个接口,recv会继续,相当于应用程序花自己的时间,把数据从内核的TCP接收缓冲区拷贝到给recv传的buf(应用程序的缓冲区),拷贝数据的过程中,应用程序是一直等待数据拷贝完成后,recv才返回,应用程序才自己向后走。

如果是异步I/O的话,调用系统给我们提供异步I/O接口的时候,我们要传入sockfd(对应一个TCP接收缓冲区,从远端接收数据的),buf(如果有数据,要把内核缓冲区的数据搬到应用程序的缓冲区中)和通知方式(通过信号或者回调,告诉操作系统异步I/O,到时候内核负责监听sockfd上是否有数据可读,有的话把数据从内核的TCP缓冲区搬到应用程序传的buf上,内核最后通过应用程序告知他的通知方式来通知应用程序,应用程序就可以处理这些事情,而且数据已经拿到手了。

2、业务层面进行描述同步和异步

一个典型的网络IO接口调用,分为两个阶段,分别是“数据就绪”和“数据读写”,数据就绪阶段分为阻塞和非阻塞,表现得结果就是,阻塞当前线程或是直接返回。

同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);

异步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时通知的方式,A就可以处理其它逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。

同步阻塞 int size = recv(fd, buf, 1024, 0)
同步非阻塞 int size = recv(fd, buf, 1024, 0)
异步阻塞 (不合理,A本来就可以做其他事情,没有必要阻塞等待B做完事情给A通知,这是在浪费线程A的能力)
异步非阻塞(Node.js)

二、Unix/Linux上的五种I/O模型

1、Unix/Linux上的五种I/O模型

I/O模式是操作系统上任何的I/O,内存I/O,磁盘I/O,网络I/O都可以应用。
我们主要考虑网络I/O。

linux上的五种I/O模型分别是

  • 阻塞 blocking
  • 非阻塞 non-blocking
  • IO复用(IO multiplexing)
  • 信号驱动(signal-driven)(linux特有)
  • 异步(asynchronous)

1)阻塞 blocking

C++11重写muduo网络库——预备知识_第4张图片

同步的阻塞的I/O模型,效率不高。

内核空间就是网络接收到远端发送过来数据的tcp接收缓冲区。
应用进程调用read,系统调用,是系统的I/O接口。
在应用进程看来,是一直阻塞住,数据是否就绪、数据就绪后的从内核空间向用户空间的拷贝整个过程,应用程序时间都要在read上花费完,一直阻塞住。应用程序调用read这个I/O接口,内核把这一系列事情做完以后,然后再唤醒当前这个应用线程,可以起来处理数据了。
这就是同步的阻塞I/O了。

2)非阻塞 non-blocking

同步非阻塞模型
C++11重写muduo网络库——预备知识_第5张图片

当应用进程调用read之前,用setsockopt这个系统的方法把用socket创建的sockfd设置成EAGAIN,在数据未就绪状态下,它是不断的返回。通过返回值的判断,size=-1&&EAGAIN,表示正常的返回,内核的数据未就绪。如果数据准备好以后,数据从内核空间TCP接收缓冲区拷贝到用户空间的buf,花费的还是应用程序的时间。同步非阻塞模型。

3)IO复用(IO multiplexing)

C++11重写muduo网络库——预备知识_第6张图片

同步的过程

默认的select/poll/epoll都是可以设置timeout超时时间的。如果不设置, 就是事件未发生,调用I/O复用接口的进程线程是阻塞住的。
设置超时时间,就是可以工作在非阻塞模式下,同样去检测返回值是不是EAGAIN,是错误返回还是正常非阻塞的返回。
如果数据准备好了以后,返回可读的fd,返回发生事件的event,接下来对发生事件的event调用accept或者read,上图中是发现是已建立连接的读写事件,发起系统调用,整个的数据从内核tcp接收缓冲区拷贝到用户空间的buf,还是要耗费应用程序的时间,还是同步的过程。
数据的未就绪到就绪调用了I/O复用接口,就绪以后根据具体的发生事件fd,进行相应的I/O接口的调用,好处是在一个线程里面,调用一个I/O复用接口,可以监听很多很多的套接字(高并发),当多个套接字有数据可读的话,I/O复用会给应用程序返回可读或者可写的socket列表,然后应用程序根据I/O复用返回的这些fd进行相应的读写操作。

4)信号驱动(signal-driven)(linux特有)

C++11重写muduo网络库——预备知识_第7张图片

内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查(就是相当于非阻塞模式,或者是epoll设置了timeout),减少了系统API的调用次数(这就是说,如果你是非阻塞,或者是设置了timeout的I/O复用的话,当你检查返回值=-1&&EAGAIN,会再次调动read或者I/O复用接口来检查内核的数据,是否从未就绪到就绪了)提高了效率。

数据未就绪到数据就绪的过程中,应用进程是在继续执行,完全放飞自我了,可以做任何事清,它调用相应的方法注册SIGIO信号处理程序,相当于协商了通知的方式,相当于回调操作,给内核注册以后,内核就给应用进程返回“知道了”,知道应用进程对什么事件感兴趣了,知道事件发生时该怎么通知应用进程。等待数据的过程就是一个异步的过程。

但是应用进程在读或者写的过程中,不能放飞自我,调用read去读这个数据,是同步的,数据从内核TCP缓冲区拷贝到用户的buf,应用进程是要花费时间的,数据搬完了,应用进程才能进行向下执行。

5)异步(asynchronous)

C++11重写muduo网络库——预备知识_第8张图片

基于操作系统级别的异步非阻塞I/O

典型的异步非阻塞状态,Node.js采用的网络IO模型。

整个过程从数据就绪到数据拷贝,不耗费应用程序的时间。应用进程继续执行的,可以去做其他事情。
内核处理好之后,通知应用程序“好了,该处理这个了”。读写事件发生了,该读该写的数据都写到buf了。

2、好的网络服务器设计

设计良好的网络服务器
在这个多核时代,服务端网络编程如何选择线程模型呢?
赞同libev作者的观点:one loop per thread is usually a good model,这样多线程服务端编程的问题就转换为如何设计一个高效且易于使用的event loop,然后每个线程run一个event loop就行了(当然线程间的同步、互斥少不了,还有其它的耗时事件需要起另外的线程来做)。

event loop 是 non-blocking (非阻塞)网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO-multiplexing (I/O复用)一起使用,原因有两点:

  1. 一般,我们注册的都是非阻塞的socket
    没有人真的会用轮询 (busy-pooling) 来检查某个 非阻塞 IO 操作是否完成,这样太浪费CPU资源了。
  2. IO复用 一般不能和 阻塞 IO 用在一起,因为 阻塞 IO 中
    read()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket上的 IO 事件了。(阻塞影响IO复用)

所以,当我们提到 非阻塞 的时候,实际上指的是 非阻塞 + IO-复用,单用其中任何一个都没有办法很好的实现功能。

强大的nginx服务器采用了epoll+fork模型作为网络模块的架构设计,实现了简单好用的负载算法,使各个fork网络进程不会忙的越忙、闲的越闲,并且通过引入一把乐观锁解决了该模型导致的服务器惊群现象,功能十分强大。

三、Reactor模型

反应器设计模式是用于处理服务请求的事件处理模式由一个或多个输入并发地交付给服务处理程序。服务处理程序然后将传入的请求解复用,并将它们同步地分发到相关的请求处理程序。
C++11重写muduo网络库——预备知识_第9张图片

重要组件:

  • Event事件
  • Reactor反应堆
  • Demultiplex事件分发器
  • Evanthandler事件处理器

在整个的基于Reactor模型的网络服务请在交互的时候,首先,我们把事件注册到反应堆上,也就是说,应用程序对这个事件感兴趣,请求反应堆帮忙来监听它所感兴趣的事件,并且在这个事件发生的时候调用相应的预置的回调Handler,向反应堆注册事件,就是把这个事件event和对应的handler(一个event对应一个Evanthandler都给到reactor反应堆。
反应堆存储着事件event事件以及事件处理的集合,可以添加更多选项,比如说,事件添加的时间,事件响应的时间等等。
reactor反应堆根据epoll_ctrl对事件分发器进行事件的设置,调整,添加新的事件,修改已有的事件,删除添加的事件,然后启动反应堆,反应堆的后端就会驱动事件分发器的启动,多路复用,就是在开启epoll__wait,因为事件分发器在开启epoll_wait之前,reactor已经调用相应的方法把应用程序注册过来的事件进行添加,调整,删除,多路复用开启,epoll_wai开始,整个服务器处于阻塞的状态等待新用户的连接,或者是已连接用户的读写事件。
如果epoll_wait有监听到新的事件的产生,那么多路复用分发器就会把这个分发的事件给反应堆返回,因为事件发生了,在reactor模型里,就要调用事件的处理器。
多路复用分发器在监听到事件分发的时候,不知道事件对应的处理器在哪,都是在反应堆维护的,所以就把分发的事件返回给反应堆。
reactor就会找到event对应的Evanthandler(一般用map表),调用具体的event handler,来读取read用户的请求,然后进行解码,因为在通信的时候数据是有序列化反序列化的,然后进行处理,计算,然后进行打包,编码,也就是数据的序列化,send发送到网络上给用户响应。

eventhandler: 读取用户事件的请求read,反序列化(解码),业务逻辑处理计算,对处理的结果编码序列化,send给用户返回一个响应。
Event事件:事件的fd,读或者写,或者其他的信息

muduo库的Multiple Reactors模型如下

C++11重写muduo网络库——预备知识_第10张图片

在这里边,相当于是:图中所谓的反应堆reactor,实际上都是代表着Demultiplex事件分发器,由他们监听具体事件的发生,调用相应事件的回调,

reactor主要存储的是事件和事件的处理器,仅此而已。
但是这张图上的reactor实际上是reactor和Demultiplex事件分发器的合二为一。
多路事件分发器想象成epoll,mainreactor相当于I/O线程的eventloop,主要做的是新用户的连接accept,listen fd有事件写入,就accept取出已连接用户的fd,然后派送到具体的工作线程上。
subreactor线程专门做已连接用户的读写事件处理
如果再有耗时的I/O操作,比如说传输文件,就单独再起一个线程去处理耗时的I/O操作

四、epoll

1、select的缺点

1、单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024
2、内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构(文件描述符),产生巨大的开销
3、select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
4、select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在
以select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的句柄结构内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务。

2、epoll原理以及优势

epoll的实现机制与select/poll机制完全不同,它们的缺点在epoll上不复存在。
设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完成后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树,磁盘IO消耗低,效率很高)。把原先的select/poll调用分成以下3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的fd资源

如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除事件。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接

epoll_create在内核上创建的eventpoll结构如下:
C++11重写muduo网络库——预备知识_第11张图片

3、LT模式

(水平触发)
内核数据没被读完,就会一直上报数据。

4、ET模式

(从不可读到可读,或者从不可写到可写)
内核数据只上报一次。

5、muduo采用的是LT

  • 不会丢失数据或者消息
    应用没有读取完数据,内核是会不断上报的
  • 低延迟处理
    每次读数据只需要一次系统调用;照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其他连接处理消息
  • 跨平台处理
    像select一样可以跨平台使用

6、ET与LT抗压对比

libevent是一个C语言写的网络库,底层也是事件驱动,统一事件源的epoll,采用的是ET模式,从压测的结果来看,在单线程下,muduo库和libevent库的性能差不了多少,在多线程情况下,muduo库的吞吐量大致可以达到libevent的近2倍。

你可能感兴趣的:(C++11重写muduo网络库,网络,c++,linux,服务器)