高性能网络模型 Reactor Proactor

阅读数:91 评论数:0

跳转到新版页面

分类

架构学

正文

一、概述

1、BIO

当应用程序调用recvfrom系统调用时,该操作将导致进程阻塞。等待内核通过中断等一系列操作将网络数据拷贝到内核态(Socket缓冲区),再将内核态数据拷贝到用户态的内存这种方式只能通过一个线程来管理一个连接,一般需要采用多线程方式来处理请求。需要大量线程来维护网络连接,并且线程频繁进行上下文切换,导致性能低下。

2、NIO

非阻塞IO,用户进程调用recvfrom时,立即返回结果。不会阻塞。优点是:不像阻塞IO,调用接口会阻塞,可以使用一个线程来管理多个网络连接。缺点是:进程需要不断轮训调用内核接口,导致大量的系统调用和cpu资源。

3、IO多路复用

一次调用可以监听多个Socket连接,它可以看作是NIO的一种实现技术。

这时如何从服务的很多连接中快速找到有IO事件发生socket?Linux内核提供的IO多路复用方案是:select、poll、epoll。

应用层在内核的IO多路复用基础上进一步封装,出现了易于使用的两种高性能网络模型:Reactor模式和Proactor模式。

二、linux的socket

Socket是内核提供给应用层的一层抽象,能够通过使用简单的api来进行数据读写。在Linux系统中,一切皆文件。Socket也是一类文件,我们可以像使用普通文件一样,对其进行open、read、write、close等操作。程序是通过fd来访问对应的Socket。

1、socket对应的数据结构

一个socket对应主要包括了四元组(源ip、源port、目标ip、目标port)、接收队列、发送队列、等待队列(进程)。

2、数据包接收过程

(1)数据包进入网卡的接收队列

(2)网卡通过DMA机制将数据复制到内核缓冲区

(3)复制完成后向系统发送中断请求,内核会把数据包转换成skb格式,交由tcp/ip协议栈处理。

(4)协议栈一层一层进行拆包头处理,此处sk_buffer设计比较巧妙,为了性能,只是指针的移动。

(5)在传输层会去除源 IP、源端口、目的 IP、目的端口,获取到对应的socket,从而将数据拷贝到socket缓冲区。

(6)内核唤醒用户进程,用户进程就可以通过read从socket缓冲区读取数据。

三、Linux IO多路复用

在Linux系统中,一切皆文件。Socket也是一类文件,我们可以像使用普通文件一样,对其进行open、read、write、close等操作。程序是通过fd来访问对应的Socket。

1、select

int select(
    int nfds,                     // 监控的文件描述符集里最大文件描述符加1
    fd_set *readfds,              // 监控有读数据到达文件描述符集合,引用类型的参数
    fd_set *writefds,             // 监控写数据到达文件描述符集合,引用类型的参数
    fd_set *exceptfds,            // 监控异常发生达文件描述符集合,引用类型的参数
    struct timeval *timeout);     // 定时阻塞监控时间

readfds、writefds、errorfds 是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。

(1)fd_set

由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set 的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。当 select 返回 fd_set = 00010011 时,表示文件描述符 1、2、5 已经就绪。

(2)select的缺点

性能开销大

1、调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,select 执行完后,还需要将 fd_set 从内核空间拷贝回用户空间,高并发场景下这样的拷贝会消耗极大资源;(epoll 优化为不拷贝)

2、进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)

3、select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)

同时能够监听的文件描述符数量太少 受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 32 位操作系统是 1024,64 位是 2048。(poll、epoll 优化为适应链表方式)

(3)服务端使用select监控多个连接的C代码

#define MAXCLINE 5       // 连接队列中的个数
int fd[MAXCLINE];        // 连接的文件描述符队列
	
int main(void)
{
      sock_fd = socket(AF_INET,SOCK_STREAM,0)          // 建立主机间通信的 socket 结构体
      .....
      bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr);         // 绑定socket到当前服务器
      listen(sock_fd, 5);  // 监听 5 个TCP连接
	
      fd_set fdsr;         // bitmap类型的文件描述符集合,01100 表示第1、2位有数据到达
      int max;
	
      for(i = 0; i < 5; i++)
      {
          .....
          fd[i] = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);   // 跟 5 个客户端依次建立 TCP 连接,并将连接放入 fd 文件描述符队列
      }
	
      while(1)               // 循环监听连接上的数据是否到达
      {
        FD_ZERO(&fdsr);      // 对 fd_set 即 bitmap 类型进行复位,即全部重置为0
	
        for(i = 0; i < 5; i++)
        {
             FD_SET(fd[i], &fdsr);      // 将要监听的TCP连接对应的文件描述符所在的bitmap的位置置1,比如 0110010110 表示需要监听第 1、2、5、7、8个文件描述符对应的 TCP 连接
        }
	
        ret = select(max + 1, &fdsr, NULL, NULL, NULL);  // 调用select系统函数进入内核检查哪个连接的数据到达
	
        for(i=0;i<5;i++)
        {
            if(FD_ISSET(fd[i], &fdsr))      // fd_set中为1的位置表示的连接,意味着有数据到达,可以让用户进程读取
            {
                ret = recv(fd[i], buf,sizeof(buf), 0);
                ......
            }
        }
  }

2、poll

和 select 类似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而非 select 的 fd_set 结构。

struct pollfd {
    int fd;           // 要监听的文件描述符
    short events;     // 要监听的事件
    short revents;    // 文件描述符fd上实际发生的事件
};

管理多个描述符也是进行轮询,根据描述符的状态进行处理,但 poll 无最大文件描述符数量的限制因其基于链表存储

select 和 poll 在内部机制方面并没有太大的差异。相比于 select 机制,poll 只是取消了最大监控文件描述符数限制,并没有从根本上解决 select 存在的问题。

3、epoll

epoll 是对 select 和 poll 的改进,解决了“性能开销大”和“文件描述符数量少”这两个缺点,是性能最高的多路复用实现方式,能支持的并发量也是最大。

(1)特点

使用红黑树存储一份文件描述符集合,每个文件描述符只需在添加时传入一次,无需用户每次都重新传入;—— 解决了 select 中 fd_set 重复拷贝到内核的问题
通过异步 IO 事件找到就绪的文件描述符,而不是通过轮询的方式;
使用队列存储就绪的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历;

(2)epoll的基本用法

int main(void)
{
      struct epoll_event events[5];
      int epfd = epoll_create(10);         // 创建一个 epoll 对象
      ......
      for(i = 0; i < 5; i++)
      {
          static struct epoll_event ev;
          .....
          ev.data.fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
          ev.events = EPOLLIN;
          epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);  // 向 epoll 对象中添加要管理的连接
      }
	
      while(1)
      {
         nfds = epoll_wait(epfd, events, 5, 10000);   // 等待其管理的连接上的 IO 事件
	
         for(i=0; i<nfds; i++)
         {
             ......
	
             read(events[i].data.fd, buff, MAXBUF)
         }
  }
}

(3)epoll_create创建eventpoll

epoll_create 函数会创建一个 struct eventpoll 的内核对象,类似 socket,把它关联到当前进程的已打开文件列表中。

struct eventpoll {
 wait_queue_head_t wq;      // 等待队列链表,存放阻塞的进程
	
 struct list_head rdllist;  // 数据就绪的文件描述符都会放到这里

 struct rb_root rbr;        // 红黑树,管理用户进程下添加进来的所有 socket 连接
	
        ......
}

(4)epoll_ctl

主要负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里。

epitem 主要包含两个字段,分别存放 socket fd 即连接的文件描述符,和所属的 eventpoll 对象的指针;

(5)epoll_wait

epoll_wait 函数的动作比较简单,检查 eventpoll 对象的就绪的连接 rdllist 上是否有数据到达,如果没有就把当前的进程描述符添加到一个等待队列项里,加入到 eventpoll 的进程等待队列里,然后阻塞当前进程,等待数据到达时通过回调函数被唤醒。

当 eventpoll 监控的连接上有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:

1

socket 的数据接收队列有数据到达,会通过进程等待队列的回调函数 ep_poll_callback 找到红黑树中的节点 epitem;

2 ep_poll_callback 函数将有数据到达的 epitem 添加到 eventpoll 对象的就绪队列 rdllist 中;
3 ep_poll_callback 函数检查 eventpoll 对象的进程等待队列上是否有等待项,通过回调函数 default_wake_func 唤醒这个进程,进行数据的处理;
4 当进程醒来后,继续从 epoll_wait 时暂停的代码继续执行,把 rdlist 中就绪的事件返回给用户进程,让用户进程调用 recv 把已经到达内核 socket 等待队列的数据拷贝到用户空间使用。

四、Reactor模式

Reactor模式,用于把多个请求分发给多个处理器,基本思想是“分而治之+事件驱动”。

1、单reactor单线程

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

(1)Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;

(2)如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;

(3)如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。

但是,这种方案存在 2 个缺点:

1 因为只有一个进程,无法充分利用 多核 CPU 的性能
2 Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟

Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

2、单Reactor多线程

与单Reactor单线程的区别:

1 Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
2 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;

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

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

3、多Reactor多线程

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

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:

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

Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。

五、Proactor模式 AIO(Asynchronous-Non-Blocking-IO)

Reactor 在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
Proactor 在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

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

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




相关推荐

一、代理模式 为另一个对象提供一个替身或占位符以控制对这个对象的访问。被代理的对象可以是远程的对象、创建开销大的对象或需要安全控制的对象。 与适配器模式的区别,适配器模式主要改变所考虑对象的接口,而

一、概述 linux内核是一种微内核,使用模块化设计思想,许多功能都可以编译为一个模块,并且支持动态的加载和卸载。 1、内核代码组织 arch目录 不同平台体系相关代码 block目录 块驱