网络系统

I/O的阻塞/同步

阻塞I/O

当用户程序执行read,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read才会返回。

非阻塞I/O

非阻塞的read请求在数据未准备好的情况下立即返回,可以继续向下执行程序,应用程序会不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序的缓冲区,read调用才可以获得结果。

但无论read和send是阻塞还是非阻塞,都是同步调用。因为在read调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,即是一个同步过程,如果拷贝效率低,那么同步等待时间长。

异步调用是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都无需等待。发起aio_read后,立即返回。内核自动完成数据的拷贝和准备工作,并在完成后通知应用程序,不需要应用程序主动轮询。

I/O多路复用

select/poll/epoll内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。

在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求。

select/poll

select

select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件发生。检查的方式就是通过遍历文件描述符集合,当检查到有事件产生后,将此Socket标记为可读或者可写,接着把整个文件描述符集合拷贝回用户态里,然后用户态还需要通过遍历的方式找到可读或可写的Socket,然后再对其处理。

因此,select这种方式,需要进行2次遍历文件描述符集合,一次是在内核态中,一次是在用户态中,另外还会发生2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select使用固定长度的BitsMap,表示文件描述符集合,且支持的文件描述符的个数是有限制的,在Linux系统中,由内核的FD_SETSIZE限制,默认最大值为1024,只能监听0-1023的文件描述符。

poll

poll不使用BitsMap来存储文件描述符集合,而是用动态数组,以链表形式来组织,突破了select的文件描述符的个数限制,虽然仍受到系统文件描述符的限制。

但是poll和select并没有太大的本质区别,都是使用「线性结构」存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

如下是epoll的基础用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}

epoll通过两个方面,解决了select/poll的问题。

  • epoll在内核中使用红黑树来跟踪进程中所有待检测的文件描述符,把需要监控的Socket通过epoll_ctl()函数加入到内核中的红黑树里(红黑树的增删改时间复杂度为O(logn))。而select/poll内核里没有类似epoll红黑树这种能保存所有待检测Socket的数据结构,因此每次操作时select/poll都需要传入整个Socket集合到内核中。epoll依靠红黑树,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll使用事件驱动机制,内核里维护了一个链表来记录就绪事件,当某个Socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符个数,不需要像select/poll那样轮询扫描整个Socket集合,提高了检测效率。

epoll相关的接口如下图:

epollInterface

epoll_wait()返回时,对于就绪事件,epoll并不是使用共享内存的方式,而是调用_put_user函数,将数据从内核拷贝到用户空间。

边缘触发和水平触发

epoll支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。

  • 边缘触发模式下,当被监控的Socket描述符上有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此程序要保证一次性将内核缓冲区的数据读取完;
  • 水平触发模式下,当被监控的Socket描述符上有可读事件发生时,服务器端会不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有数据需要读取。

select/poll只有水平触发模式,epoll默认是水平触发模式,可设置为边缘触发模式。

Reactor和Proactor

Reactor

为了方便基于I/O多路复用进行编程开发,部分开发者基于面向对象的思想,对I/O多路复用作了一层封装,使得使用者可以不必考虑底层网络API的细节,只需要关注应用代码的编写,即Reactor模式,也叫Dispatcher模式。

I/O多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程。

Reactor模式主要由Reactor和处理资源池这两个核心部分组成:

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

由此,理论上就有四种方案可选择:

  • 单Reactor单进程/线程
  • 单Reactor多进程/线程
  • 多Reactor单进程/线程
  • 多Reactor多进程/线程

其中,「多Reactor单进程/线程」方案相比「单Reactor单进程/线程」,既复杂又没有性能优势,没有实际应用。

单Reactor单进程/线程

在Java中,一般实现的是单Reactor单线程的方案,因为Java程序是跑在JVM这个进程上的,只是其中一个线程而已。而在C中,实现的是单Reactor单进程的方案。单Reactor单进程的方案如图所示:

singleReactor

进程中包含Reactor、Acceptor、Handler三个对象。

  • Reactor对象的作用是监听和分发事件
  • Acceptor对象的作用是获取连接
  • Handler对象的作用是处理业务

对象中的select、accept、read、send是系统调用函数,dispatch和业务处理是需要完成的操作。

方案详细过程为:

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

由于全部工作都在同一个进程内完成,因此「单Reactor单进程」的方案不需要考虑进程间通信,也不用担心多进程竞争。

但是:

  • 因为只有一个进程,无法充分利用多核CPU的性能
  • Handler在处理业务时,整个进程是无法处理其他连接的事件的,若业务耗时较长,就会导致响应的延迟较大

综上,「单Reactor单进程」的方案不适用于计算密集型的场景,只适用于业务处理较快速的场景

单Reactor多进程/线程

为了解决「单Reactor单进程/线程」的缺点,提出了「单Reactor多进程/线程」的方案。

事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。因此实际应用也看不到单Reactor多进程的模式。

multiReactor

方案详细过程为:

  • Reactor对象通过select监听事件,收到事件后通过dispatch分发,具体分给Acceptor还是Handler取决于事件类型
  • 若是建立连接的事件,则交给Acceptor处理,其会通过accept方法获取连接,并创建一个Handler对象处理后续响应事件
  • 若不是建立连接的事件,则交给当前连接对应的Handler对象进行响应
  • Handler对象不再负责业务处理,只负责数据的收发,Handler通过read获取数据后,发给子线程里的Processor对象处理
  • Processor对象进行业务处理,将结果发送给Handler,由Handler通过send返回给client

单Reator多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。

例如,子线程完成业务处理后,要把结果传递给主线程的Handler进行发送,这里涉及共享数据的竞争。

要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

「单 Reactor」的模式有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

多Reactor多进程/线程

为了解决单Reactor的局限,提出了多Reactor多进程/线程的方案。

multiReactors

方案详细过程为:

  • 主线程中的Main Reactor对象通过select监控建立连接事件,收到事件后通过Acceptor对象中的accept方法获取连接,将新的连接分配给某个子线程
  • 子线程中的Sub Reactor对象将Main Reactor对象分配的连接加入select继续监听,并创建一个Handler用于处理连接的响应
  • 若有新事件发生,Sub Reactor对象会调用当前连接对应的Handler对象来进行响应
  • Handler对象通过read->业务处理->send的流程来完成整个方案

这样的话,好处在于:

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

Netty和Memcache都采用了该方案。

Proactor

Reactor是非阻塞同步网络模式,而Proactor是异步网络模式

因此,Reactor可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些I/O事件,这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。

proactor

Proactor模式的工作流程为:

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