Reactor与Rroactor网络模型

Reactor与Proactor

一、网络I/O多路复用
  1. socket

    ​ 客户端与服务端在网络中通信可以使用socket,它是进程见通信中常用的方式,可以在不同主机间进行通讯;

    ​ 服务端先要调用socket()函数,创建网络协议为IPv4传输协议为TCP的socket,然后调用bind()函数给socket绑定一个IP地址和通讯端口;接着调用listen()函数进行监听,此时对应TCP状态为listen,(可以使用netstate命令查看对应端口是否被监听);服务端进入监听状态后通过调用accept()函数从内核获取客户端连接,如果没有客户端连接就会阻塞等待客户端连接的到来;

    ​ 客户端创建好socket后,调用connect()函数发起连接,连接时要知名服务端IP和端口;在TCP连接过程中,服务端的内核中会为创建两个队列:

    • TCP半连接队列,还未完成三次握手的连接,此时服务端处于syn_rcvd状态;
    • TCP全连接队列,已经完成了三次握手的连接,此时服务端盖处于established状态;

    当TCP全连接队列不为空时,服务端的accept()函数会从内核中的TCP全连接队列中拿出一个socket返回应用程序,后继续使用这个socket进行数据传输(服务端监听的socket和数据传输的socket是连个不同的socket);建立连接后,客户端和服务端可以通过read()和write()函数来进行读写数据;

在这里插入图片描述
​ 上面的TCP Socket调用的是最简单的一对一的通讯方式,使用的是同步阻塞的方式;这种方式在多用户调用时是无法满足的,对于IPv4下,客户端IP最大IP是2的32次方,端口数最大时2 的16次方,所以服务端单机最大TCP连接数约为2的48次方;但是Linux先单个进程的文件描述符数量是有限的,一般为1024;

  • 多进程模型

​ 基于原来的阻塞网络I/O,可以使用多进程来满足处理多个客户端的请求;服务端的主进程负责监听客户端的连接,发现客户端完成连接,accept()函数就会返回一个已连接的socket,这时可以通过fock()函数创建一个子进程,实际就是把父进程所有东西复制一份,包括文件描述符、内存地址空间、程序计数器、代码等;根据函数返回值判断是父进程还是子进程,返回值是0,则为子进程,返回值是其他值则为父进程;

​ 父进程只关心监听的socket,不需要关心已连接的socket;而子进程不关心监听socke,只关心已连接socket,子进程来处理客户服务;

在这里插入图片描述
​ 当子进程退出时,内核中还保留着一些该进程的信息,会占用内存,需要做好回收工作,不然会出现僵尸进程;僵尸进程多了也会影响性能;子进程退出时可以调用wait()和waitpid()函数来回收子进程资源;

  • 多线程模型

    ​ 由于进程上下问切换(虚拟内存、栈、全局变量等用户空间资源,内核最堆栈、寄存器等内核资源)比较消耗CPU资源,故可以采用多线程来处理用户请求;同一个进程空间里的线程可以共享部分资源,包括文件描述符、进程空间、代码、全局数据、堆、共享库等,线程上下文切换只需要保存线程的自由数据、寄存器等不共享的数据;

    ​ 当客户端与服务器连接后,可以创建线程,将已连接的socket的文件描述符传递给线程函数,直接在线程里同客户端进进程通信;同时为了减少线程的创建销毁浪费系统资源,可以使用线程池来处理;将已连接的socket方入一个队列中,线程池负责从对列中取出已连接的socket进程处理;为避免线程竞争,操作队列时要枷锁;

在这里插入图片描述
2. select/poll

​ 每个请求分配一个线程发方式在高并发的场景是不能满足的,所以可以采用I/O多路复用的技术;内核提供的select/poll/epoll等系统调用,进程可以通过一个系统调用函数从内核中获取多个事件(在获取事件时,把所有连接即文件描述符传递给内核,再由内核返回产生的事件连接,然后再在用户态中做出相应的处理);

  • select

​ select实现I/O多路复用的方式是将已连接的socket放到一个文件描述符集合中,然后调用select函数将文件描述符集合拷贝到内核中,由内核来检查网络事件是否产生,通过便利文件描述符集合的方式;当有事件产生时,socket标记为可读或可写,再将整个文件描述符集合拷贝到用户态空间中,用户态还需要遍历整合文件描述符集合找到活跃的socket,再对其处理;

​ 所以在上述过程中出现了2次拷贝文件描述符和2次遍历文件表述符;select使用固定长度的BitMap来表示文件描述符,而且文件描述符存在数量的限制,linux中最大为1024,只能监听0~1023的文件描述符;

  • poll

​ poll和select很相似,都是使用线性结构存储线程关注的socket集合,因此都需要遍历(时间复杂度为O(n))文件描述符和拷贝文件描述符,随着并发数量的增加,性能损耗呈指数级增长;

  1. epoll

epoll通过两个方面很好的解决了select/poll的问题;

  • epoll在内核中使用了红黑树来跟踪进程中所有待检测的文件描述符,把需要监控的socket通过epool_ctl()函数加入到内核的红黑树中,红黑数的查询和杉树的时间复杂度为O(log n),不需要像select/poll每次操作都把整个socket集合拷贝到内核中,只需要将一个待检测的socket加入内核,减少了内核和用户空间的的频繁的拷贝和内存分配;

  • epoll采用事件驱动的机制(回调),内核中维护一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数,内核将其加入就绪队列中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符,提高检测效率;
    在这里插入图片描述
    epoll支持两种事件触发的模式,边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT);

  • 边缘触发:当被监控的socket有可读事件发生时,服务器会从epoll_wait中唤醒一次,即使内有调用read函数从内核读数据,也只被唤醒一次,因此要保证程序一次性将内核缓冲去的数据读完;

  • 水平触发:当被监听的socket有可读的事件发生时,服务器会不断的从epoll_wait冲唤醒,直到内核缓冲区中的数据被read函数读完才结束;

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换;select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式;使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用;

二、Reactor(非阻塞同步网络)

Reactor模式即I/O多路复用监听事件,收到事件后根据事件类型分配(Dispatch)给某个进程/线程处理;主要有Reactor和处理资源池两个核心部件;

  • Reactor负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,read->业务逻辑->send;

Reactor通长包含一下三种模式:

  1. 单 Reactor 单进程 / 线程;
    在这里插入图片描述
    进程中有Reactor、Acceptor、Handler三个对象:
  • Reactor对象用于监听分发事件;
  • Acceptor对象用于获取连接;
  • Handler对象用于处理业务;

上图中select、accept、read、send都是系统调用,dispatch和业务处理是需要完成的操作,dispatch是分发事件;

reactor对象通过select(I/O多路复用接口)监听事件,收到事件后通过dispatch进行分发,根据事件类型分发给Acceptor对象和Handler对象;如果是连接建立的事件,则交由Acceptor对象进行处理,Acceptor对象会通过accept方法获取连接,并创建一个Handler对象处理后续的事件响应;如果不是连接建立事件,则交由当前连接对应的Handler对象处理;Handler对象通过read->业务处理->send的流程来完成完整的业务流程。

该方案只有一个进程,不存在线程资源竞争问题,但是也有两个缺点;

  • 只有一个进行,无法发挥多核CPU的性能;
  • Handler对象处理业务时,整个进程无法处理其他连接的事件,如果业务处理事件好事较长,就会造成响应延迟的问题;

所该方案不适用于计算密集从的场景,只是用与业务处理非常快的场景;redis就是采用该方案,redis业务都在内存中完成,响应速度快;

  1. 单 Reactor 多线程 / 进程;
    在这里插入图片描述
  • Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;

该方案优势在于能够充分利用多核CPU的性能;但是一个Reactor对象承担所有的事件的监听和响应,而且只能在主线程中运行,在瞬间并发高的场景中,容易造成性能瓶颈;

  1. 多 Reactor 多线程 / 进程;
    在这里插入图片描述
  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

该方案其实比单Reactor多线程的方案还要简单:主线程和子线程分工明确,主线程只负责接收新的连接,子线程负责完成后续的业务处理;主线程只需要把新连接传递给子线程,子线程无需返回数据,直接可以将处理结果返回给客户端;

Netty和Memcache都是采用该方案设计的,以及Nginx

三、Proactor(异步网络模型)

阻塞、非阻塞、同步、异步I/O

  • 阻塞I/O:当用户执行read时,线程会被阻塞,一直等到内核数据主备好,并把数据从内核缓冲去拷贝到应用程序的缓冲区,拷贝完成后,read才会返回;

    阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程
    在这里插入图片描述

  • 非阻塞I/O:read请求在数据为做好准备的情况下立刻返回,可继续往下执行,此时应用程序不断轮询内核,直到数据主备好,内核京数据拷贝到应用程序缓冲区,read调用才可以获取到结果;
    在这里插入图片描述

最后一次read调用获取数据的过程,是一个同步的过程,是需要等到的过程;

如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间;

  • 异步I/O:「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作
在这里插入图片描述

Proactor采用的就是异步I/O模型,所以被称为异步网络模型;

Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。

Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
在这里插入图片描述

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
  • Handler 完成业务处理;

可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。

而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案

总结
  • 第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis 采用的是单 Reactor 单进程的方案。

  • 第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

  • 第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。

Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。

因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件

相关连接:https://www.zhihu.com/question/26943938/answer/1856426252

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值