高性能服务器之IO多路复用

大耗子 2021年02月15日 27次浏览

1. BIO 的缺陷

  • BIO中的B 是 Blocking 的阻塞的意思
  • 作为服务端开发,使用ServerSocket 绑定端口号之后会监听该端口,等待accept事件,accept是会阻塞当前线程
  • 当我们收到accept事件的时候,程序就会拿到客户端与当前服务端连接的Socket
  • 针对这个socket我们可以进行读写,但是呢,这个socket读写都是会阻塞当前线程的。
  • 一般我们会有使用多线程方式进行c/s交互,但是这样很难做到C10K(比如说:1W个客户端就需要和服务端用1W个线程支持,这样的话CPU肯定就爆炸了,同时线程上下文切换也会把机器负载给拉飞。)

2. NIO 解决C10K问题

  • NIO提供了一套非阻塞的接口,这样就不需要我们为每一个c/s长连接保留一个单独的处理线程了。
  • 这个阻塞BIO之所以需要给每个socket长连接指定一个线程,就是因为它阻塞
  • 现在这个NIO API具有非阻塞的特性了,就可以用1个线程去检查n个socket
  • 然后我们需要把需要检查的socket注册到这个select.
  • 当选择器发现socket就绪了,某个socket就绪了。就会唤醒主线程,
  • 然后咱们可以通过select 获取就绪状态的socket 进行相应的处理。

3. select(…) 实现原理

  • 每次调用kernel 的 select函数,都会涉及到用户态/内核态的切换,还需要传递需要检查的socket集合,其实就是需要检查的fd (文件描述符)集合。

    • 因为咱的程序都是运行在linux或者unix操作系统上,这种操作系统,一切皆文件,socket也不例外,这里传递的fd其实就是文件系统中对应socket生成的文件描述符
  • 操作系统 这个select函数被调用以后,

    • 首先会去fd集合中去检查内存中socket套接字的状态,这个时间复杂度是O(N),然后检查完一遍之后,如果有就绪状态的socket,那么就会直接返回,不会阻塞当前线程。
    • 否则的话,那个就说明当前指定fd集合对应的socket没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket有数据之后,才唤醒线程。

select(…) 对监听socket有1024的大小限制

  • 这个是因为fd集合这个结构是一个bitmap位图的结构,这个位图结构就是一个长的二进制数,类似0101这种这个bitmap默认长度是1024个bit,想要修改长度非常麻烦,需要重新编译操作系统内核

  • 处于某种性能考虑,select函数做了两件事

    • 第一件事,跑到就绪状态的socket对应的fd文件中设置一个标记mask,表示这个fd对应的socket就绪了

    • 第二件事,返回select函数,对应的也就是唤醒线程,站在用户层面,他会收到一个int结果值,表示有几个socket处于就绪状态

      • 但是具体是哪个socket就绪,用户层是不知道的,所以接下来会是一个O(N)的系统调用,检查fd集合中每一个socket的就绪状态,其实就是检查文件系统中指定socket的文件描述符的状态,涉及到用户态和内核态的来回切换,如果bitmap再大,就非常耗费性能
  • 还有就是系统调用涉及到参数的数据拷贝,如果数据太庞大,他也不利于系统的调用速度

4. select(..) 深入问题

问题:select (…) 第一遍 O(N) 去检查未发现就绪的socket ,后续某个socket就绪后,select(…)是如何感知道的?是不断的轮询吗?

铺垫知识

  1. 操作系统调度
  • cpu同一时刻,它只能运行一个进程,操作系统做主要的任务就是系统调度,就是有n个进程,然后让这n个进程在cpu上切换进行
  • 未挂起的进程都在工作队列内,都有机会获取到cpu的执行权
  • 挂起的进程就会从这个工作队列里移除出去,反映到咱们用户层面就是线程阻塞了
  • linux系统线程其实就是轻量级进程
  1. 操作系统中断
  • 比如说,咱们用键盘打字,如果cpu正执行其他程序,一直不释放,那咱这个打字就也没法打了
  • 咱们都知道,不是这样的情况,因为就是有系统中断的存在,当按下一个键以后会给主板发送一个电流信号,主板感知到以后,它就会触发这个cpu中断、
  • 所以中断 其实就是让cpu给正在执行的进程先保留程序上下文,然后避让出cpu,给中断程序绕道
  • 中断程序就会拿到cpu的执行权限,进行相应代码的执行,比如说键盘的中断程序,就会执行输出的逻辑

回到最开始的问题

  • 这个select函数,它第一遍轮询,没有发现就绪状态的socket的话,它就会把当前进程保留给需要检查的socket等待队列中

  • socket 结构 有三块核心区域,分别就是读缓存,写缓存还有这个等待队列

  • 这个 select 函数,它会把当前进程保留到每个需要检查的socket 的等待队列中,就会把当前进程从工作队列里面移除了,移除了之后其实就是挂起了当前线程,然后这个select 函数也就不会再运行了

  • 下一个阶段,假设我们客户端往当前服务器发送了数据,数据通过网线到网卡,网卡再到DMA硬件的这种方式直接将数据写到内存里面,然后整个过程,CPU是不参与的

  • 当传输完成以后,它就会触发网络数据传输完毕的中断程序,这个中断程序它会把cpu正在执行的进程给顶掉,然后

    cpu就会执行咱这个中断程序的逻辑

    • 对应的逻辑是:根据内存中的数据包,然后分析出来数据包是哪个socket的数据,
    • 同时tcp/ip它又是保证传输的时候是有端口号的,然后根据端口号就能找到对用的socket实例,找到socket实例以后,就会把数据导入到socket读缓冲里面
    • 导入完成以后,它就开始去检查socket等待队列,看是不是有等待者,如果有等待者的话,就会把等待者移动到工作队列里面去,中断程序到这一步就执行完了
    • 这样咱们的进程就又回到了工作队列,又有机会获取到cpu时间片了
  • 然后当前进程执行的select函数再次检查,就会发现这个就绪的socket了,就会给就绪的socketfd文件描述符打标记,然后select函数就执行完了,返回到用户层面就涉及到内核态和用户态的转换,后面的事情就是轮询检查每一个socket的fd是否被打了标记,然后就是处理被打了标记的socket就ok了

5. poll() 和 select()区别

  • 传参不一样
    • **select 用的是bitmap ,**它表示需要检查的socket集合
    • **poll 使用的是 链表结构,**表示需要检查的socket集合(主要是为了解决socket监听长度超过1024的socket的限制)

6. epoll 的 产生背景

  • select 和 poll 的共有缺陷

    • 第一个缺陷:select和poll函数,这两系统函数每次调用都需要我们提供给它所有的需要监听的socket文件描述符集合,而且主线程是死循环调用select/poll函数的,这里面涉及到用户空间数据到内核空间拷贝的过程

      • 咱们需要监听的socket集合,数据变化非常小
  • 每次就一到两个socket_fd需要更改,但是没有办法,因为select和poll函数,只是一个很单纯的函数

    • 它在kernel层面,不会保留任何的数据信息,所以说每次调用都进行了数据拷贝

    • 第二个缺陷:select 和 poll 函数它的返回值都是int整型值,只能代表有几个socket就绪或者有错误了,它没办法表示具体是哪个socket就绪了

      • 这就导致了程序被唤醒以后,还需要新的一轮系统调用去检查哪个socket是就绪状态的,然后再进行socket数据处理逻辑,这里走了不少弯路(同时还存在用户态和内核态的切换,这样缺陷就更严重了)epoll 就是为了解决这两个问题

7. epoll (…) 实现原理

  • epoll 函数

    在内核空间内,它有一个对应的数据结构去存储一些数据,这个数据结构其实就是eventpoll对象

    • 这个eventpoll 可以通过一个系统函数epoll_create()函数去创建的
  • 创建完成之后,系统函数返回一个eventpoll对象的id,相当于我们在内核空间开辟了一小块空间,并且我们也知道这块空间的位置

先说下eventpoll 的数据结构:三块重要的区域

  • 一块是存放需要监听的socket_fd描述符列表
  • 另一块就是就绪列表,存放就绪状态的socket信息
  • eventpoll 还有一块空间是eventpoll等待队列,这个等待队列保存的就是调用epoll_wait的进程
  • 另外呢还提供了两个函数,一个是epoll_ctl函数,一个是epoll_wait函数
  • 其中存放的socket集合信息采用的是红黑树的数据结构,socket集合信息经常用增删改查的,这种红黑树再适合不过了,保持了时间复杂度为O(logN)

epoll_ctl()

  • 它可以根据eventpoll-id去增删改内核空间上eventpoll 对象的检查列表(socket信息)

**epoll_wait() **

  • 它主要的参数是eventpoll-id 表示此次系统调用需要检测的socket_fd集合,是eventpoll 中已经指定好的那些socket信息

  • epoll_wait 默认情况下会阻塞系统的调用线程,直到eventpoll 对象中关联的某个或者某些个socket就绪以后,epoll_wait函数才会返回

  • 返回值是Int类型的

    • 返回0,表示没有就绪的socket
    • 返回大于0,表示有几个就绪的socket
    • 返回-1表示异常

8. eventpoll 对象就绪列表的维护

select函数调用的流程:

  • socket对象有三块区域
    • 读缓冲区
    • 写缓冲区
    • 等待队列
  • select函数调用的时候会把当前进程从工作队列里面拿出来
  • 然后把进程引用追加到当前进程关注的每一个socket对象的等待队列中
  • 然后当socket连接的客户端发送完数据之后,数据还是通过硬件DMA的方式把数据写入到内存,然后相应的硬件向CPU发出中断信号,CPU就会让出当前进程位置去执行网络数据就绪的中断程序,
  • 这个中断程序就会把内存中的网络数据写入到对应的socket读缓冲区里面,之后把这个socket等待队列中的进程全部移动到工作队列中,再然后select函数返回

epoll函数流程非常相似

  • 当我们调用系统函数epoll_ ctl时候,比如我们新添加一个需要关注的socket,其实内核程序会把当前的eventpoll对象追加到这个socket的等待队列里头
  • 然后当socket连接的客户端发送完数据之后,数据还是通过硬件DMA的方式把数据写入到内存,然后相应的硬件向CPU发出中断信号,CPU就会让出当前进程位置去执行网络数据就绪的中断程序,
  • 这个中断程序就会把内存中的网络数据写入到对应的socket读缓冲区里面,然后它发现这个socket的等待队列里头不是进程,而是一个eventpoll对象的引用
  • 这个时候呢,他就会根据这个eventpoll对象的引用,将当前socket的引用追加到eventpoll的就绪链表的末尾eventpoll 还有一块空间是eventpoll 的等待队列,这个等待队列保存的就是调用epoll_wait的进程)
  • 然后,当中断程序把socket的引用追加到就绪列表的末尾之后,就继续检查eventpoll对象的等待队列,如果有进程,就会把进程转移到工作队列中
  • 转移完毕之后,进程就有获取到CPU执行的时间片了,然后就是调用epoll_wait 函数,他这个函数就返回到用户层面了

总结:

  • eventpoll对象等待队列里面,它有调用epoll_wait(,,,)函数进去的进程
  • 然后再把这个进程,从这个eventpoll的等待队列里面迁移到工作队列里面

9. epoll_wait() 获取就绪的socket

epoll_wait() 返回值是Int类型的

  • 返回0,表示没有就绪的socket
  • 返回大于0,表示有几个就绪的socket
  • 返回-1表示异常

那么获取就绪的socket是怎么实现的呢?

  • epoll_wait 函数,调用的时候会传入一个epoll_event事件数组指针
  • epoll_wait 函数正常返回之前,会把就绪的socket事件信息拷贝到这个数组指针里头
  • 这样返回到上层程序,就能通过这个数组拿到就绪列表

10. epoll_wait 可不可以设置成非阻塞的

  • 默认epoll_wait 是阻塞的
  • 它有一个参数,表示阻塞时间的长度,如果这个参数设置为0,表示这个epoll_wait 是一个非阻塞调用的
  • 每次调用都会去检查就绪列表