网络编程学习21--Reactor/Proactor

120 阅读12分钟

回调函数

编程可以分为系统编程和应用编程。系统编程可以看作是编写库,而应用编程是利用写好的库来编写具有某种功能的程序,即应用。库通常会留下一些接口,即API,供应用程序员使用。

当我们调用某些库函数时,库函数会要求应用先传给它一个函数,好在合适的时候调用,以完成任务。这个被传入的、之后被调用的函数就叫回调函数。

img

由上图可见,回调函数和应用处于同一抽象层,所以回调过程就是从高层调用底层,底层再回头调用高层的过程。而传入什么样的回调函数是在应用级别决定的

所以通过登记不同的回调函数,可以改变程序的行为,十分灵活。

事件驱动程序设计

设计思想:通过一个无限循环的事件分发线程(event loop)在后台运行,一旦用户产生了某种操作,事件分发线程会找到对应的事件回调函数并执行它。

比如说,在Web编程领域,Web界面上会放置各种界面元素,比如文本框、按钮等,可以给每一个元素设置一个回调函数,当进行操作时,对应元素的回调函数会被执行,完成某个计算或操作。

任何一个网络程序,所做的事情可以总结成以下几种:

  • read:从套接字接收数据
  • decode:对收到的数据进行解析
  • compute:根据解析后的内容,进行计算和处理
  • encode:将处理后的结果,按照约定的格式进行编码
  • send:通过套接字将结果发送出去

其中,和套接字最相关的是read和send

对于之前采用的几种支持多并发的网络编程模式,都有着一定的问题。

阻塞I/O + 多进程

通过fork创建子进程,为每个客户连接服务。但是,随着客户数量的增多,产生的子进程也越来越多,即使客户和服务器之间的交互较少,但是只要客户没有断开连接,子进程都不能销毁,需要一直存在,所以这种方式不仅处理效率不高,并且开销很大。

img

阻塞I/O + 多线程

由于线程比进程更轻量,所以这种方法,比上一种方法效率更高。如果不采用线程池的话,线程的频繁创建和销毁的开销也很大;如果采用了线程池的话,仍然无法解决空闲连接占用资源的问题,如果一个连接在一定时间内没有数据交互,这个连接还是要占用着当前的线程资源,直到这个连接消亡为止。

img

通过事件驱动模式,可以较好地解决高并发问题。

服务器程序通常需要处理三类事件:I/O事件、信号及定时事件。而对于这些事件,有着两种高效的事件处理模式:Reactor模式和Proactor模式。

其中,同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式,不过也可以通过同步I/O模型模拟出Proactor模式。

Reactor模式

Reactor模式即 "非阻塞I/O(non-blocking IO) + I/O多路复用(IO multiplexing)"。这种模式中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑。

 while(1) {
     int timeout_ms = max(1000, getNextTimedCallback());
     int retval = ::poll(fds, nfds, timeout_ms);
     if(retval < 0) {
         // 处理错误,回调用户的 error handler
     }
     else {
         // 处理到期的timers, 回调用户的timer handler
         if(retval > 0) {
             // 处理IO事件,回调用户的 IO event handler
         }
     }
 }

采用单线程

当我们采用单线程时,一个reactor线程上同时负责分发 acceptor 的事件(acceptor用于监听端口,看是否有新的连接)、已连接套接字的I/O事件。

img

采用多线程(reactor thread + worker threads)

用于应用程序的业务逻辑处理比较耗时,并且这些工作相对比较独立,如果采用单线程的话,会拖慢整个Reactor模式的执行效率。

所以可以将decode、compute、encode型工作放置到另外的线程池中,和Reactor线程解耦。让Reactor线程只处理I/O相关的工作,业务逻辑相关的工作被分成一个个小任务,并由线程池中的空闲线程来执行。当得到结果后,再交给Reactor线程,并由Reactor线程通过套接字发送出去。

不过,也可以让Reactor线程只监听文件描述符上是否有事件发生,有的话就通知给工作线程,而不做其他实质性的工作(比如read或者send) 。将接受新的连接、读写数据、以及处理客户请求都交给工作线程去完成。

这样的话,Reactor模式的工作流程大致是:

1)主线程(Reactor线程)往epoll内核事件表中注册socket上的读就绪事件,并调用epoll_wait()等待socket上有数据可读。

2)当socket上有数据可读时,主线程将该socket可读事件放入请求队列

3)睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。

4)主线程调用epoll_wait()等待socket可写。

5)当socket可写时,主线程将该socket可写事件放入请求队列

6)睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。

注意:当工作线程从请求队列中取出就绪事件后,会根据事件的类型来执行具体的操作(通过回调函数)。

img

非阻塞I/O + one loop per thread 模式

此模式是多线程服务器的常用编程模型

在 one loop per thread 模型下,程序里的每个I/O线程都有一个Reactor(或event loop),用来处理读写和定时事件

需要让哪个线程干活,就把timer或 IO channel(如TCP连接)注册到哪个线程的loop里即可。

对实时性有要求的connection可以单独用一个线程;数据量大的connection也可以单独占一个线程,并把数据处理任务分摊到另外几个计算线程中(通过线程池);其他次要的辅助性connections可以共享一个线程。

在此模式中,每个connection / acceptor 都会注册到某个event loop上,程序里会有多个event loop每个线程至多有一个event loop(上面的计算线程就没有 event loop)。

不过,多线程程序中需要保证event loop的线程安全。因为需要允许一个线程往别的线程的loop里放东西时(比如IO线程收到一个新连接后,分配个一个IO线程处理),这个loop必须是线程安全的。

对于没有IO而只有计算任务的线程,使用event loop会很浪费,所以可以通过线程池来进行计算,具体可以是任务队列或生产者消费者队列。

主从Reactor模式

如果将连接和读写事件放在一个线程中处理,会互相影响,因为读写事件本身比较耗时,当一个读写事件处理时间太长,那么势必会影响下一个连接事件的处理,影响用户连接。

所以,可以在主Reactor线程中响应连接事件,在从Reactor线程中响应读写事件。而从Reactor的数量,可以根据CPU的核数来灵活设置。

通过主Reactor线程接受新连接,再将连接socket派发给子Reactor线程的方式,可以增加客户端连接的成功率和效率。

img

总结

综合以上,我们可以在主线程中通过Reactor模式响应建立连接事件,当有新的连接到来时,主线程就接受之并将新返回的连接socket分配给某个子Reactor线程,此后该新socket上的任何I/O操作都由该子Reactor线程来处理,由于该子Reactor线程里仍然有一个event loop,所以可以对多个已经建立的连接上的读写事件进行响应。而在从Reactor线程中,同样是将业务逻辑解耦,通过线程池,将业务分配给空闲的工作线程。(注意:这里的子Reactor线程只监听是否有事件发生,如果有的话,就将事件放入请求队列,交由工作线程完成)

其中,主线程向子线程派发socket的最简单方式,是往它和子线程之间的管道里写数据。当子线程检测到管道上有数据可读时,就分析是否是一个新的客户连接到来。如果是,则把该新的socket上的读写事件注册到自己的epoll内核事件表中。

阻塞/非阻塞 同步/异步

阻塞 I/O 发起的 read 请求,线程会被挂起,一直等到内核数据准备好,并把数据从内核区域拷贝到应用程序的缓冲区中,当拷贝过程完成,read 请求调用才返回。

img

非阻塞的 read 请求在数据未准备好的情况下立即返回,应用程序可以不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲,并完成这次 read 调用。(当数据准备好时,非阻塞的read仍然会有将数据从内核拷贝到应用程序的过程,此时可能所有字符都被拷贝完成,也可能只拷贝一部分数据)

img

注意:阻塞,非阻塞的read调用都是一个同步调用。在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,这个过程是在 read 函数中同步进行的如果内核实现的拷贝效率很差,read 调用就会在这个同步过程中消耗比较长的时间

真正的异步调用则不用担心这个问题,当我们发起 aio_read 之后,就立即返回内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。真正的异步调用不会等待将数据从内核空间拷贝到用户空间的过程,而同步调用会等待这一过程,即使是非阻塞调用也会。

即同步调用时,数据的返回时间点是预期的,只要函数调用返回,就得到数据;而异步是指得到数据的时间点是非预期的,异步调用后,函数直接返回,如果数据已经准备好,会由内核通知,但是数据准备好的具体时期是不知道的,要么回调,要么信号通知,都是被动的。

以去书店买书为例,阻塞 IO 是没书就等,不到不回家;非阻塞 IO 是没书就回家,然后明天再来(轮询);I/O多路复用就是书到了后,店家通知我,然后我去书店买书;异步 IO 是只去书店登记下我要买这本书,书到了也不用知道,书店会送到家(省去了去书店买书的过程)

img

Proactor模式

Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

异步I/O实现Proactor

1)主线程调用aio_read函数向内核注册某一socket上的读完成事件,并告诉内核用户读缓冲区的位置(只有这样内核才能将数据拷贝到用户缓冲区),以及读操作完成时如何通知应用程序(主线程)(这里的通知操作以信号为例)

2)主线程继续处理其他逻辑

3)当socket上的数据被读入用户缓冲区后,内核向应用程序发送一个信号,通知应用程序数据已经可用

4)应用程序收到信号后,通过信号处理函数选择工作线程来处理业务逻辑。工作线程处理完后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及操作完成时如何通知应用程序

5)主线程继续处理其他逻辑

6)当用户缓冲区的数据被写入socket后,内核向应用程序发送一个信号,通知应用程序数据已经发送完毕

7)应用程序收到信号后,通过信号处理函数选择工作线程进行善后处理

主线程上的epoll_wait只负责监听socket上的连接请求事件,对于socket上的读写事件,则是由内核进行通知,而不是通过epoll_wait进行检测。上述的将数据从内核空间拷贝到用户空间,和从用户空间拷贝到内核空间,都是由内核自己完成的。

同步I/O模拟Proactor

通过同步I/O也可以模拟Proactor,此时,需要在主线程中执行数据读写操作,读写完后,通知工作线程,工作线程相当于直接获得了数据读写的结果。

流程如下:

1)主线程往epoll内核事件表中注册socket上的读就绪事件,并调用epoll_wait等待有数据可读

2)当socket上有数据可读时,主线程从socket循环读取数据,直到所有数据读完,然后将读取的数据封装成一个请求对象并插入请求队列

3)睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表注册socket上的写就绪事件

4)主线程调用epoll_wait等待socket可写

5)当socket可写时,主线程往socket上写入服务器处理客户请求的结果

综上,在Proactor中,工作线程不负责读写数据,只负责处理相应的业务逻辑。