通信——IO模型

320 阅读4分钟

什么是IO

计算机内存和外部设备之间交换数据的过程.

当用户线程发起read/write请求时,io会分为两个步骤:

  • 操作系统内核会将外部设备(网卡,磁盘)的数据拷贝到内核空间(只有内核能访问)
  • 操作系统内核会将内核空间的数据拷贝到用户空间

因为有了这两步,所以才会有不同的io模型,每种模型实现这两步的方式不一样。

通信模型分类

所谓的IO模型本质只是不同的调度策略,核心TCP的那一套没有发生改变。

另外Linux系统中一切皆文件,所以数据通道也是文件描述符fd。

分类

阻塞的概念: 调用方法后不会立即返回,会挂起线程,等待唤醒然后继续往下执行。

  • BIO:同步阻塞,用户调用read后,用户线程会阻塞,并让出CPU,等到上面两步全部完成,唤醒用户线程,继续往下执行。
  • NIO:从Java层面说是New IO ,从底层上看就是NON-Blocking IO,accpet()和read()都立即返回,成功就返回一个非null值。
  • 多路复用:用户线程调用select后,用户线程会阻塞等待,然后内核会在数据拷贝到用户空间后唤醒用户线程,并且将所有完成了的数据通道都告知用户线程;本质是使用有限的线程去处理多个连接
  • AIO:异步非阻塞,用户线程向内核注册一个监听器,等到内核完成那两步之后,通过监听器回调通知给用户线程。整个步骤都不会阻塞用户线程

实现原理

首先网络编程的必须步骤:

Socket server = new Socket();
 server.bind(new InetSocketAddress("127.0.0.1",8080));
 Socket socket = server.accept();
  1. 创建socket
  2. 将socket绑定到端口上面
  3. 执行accept,并在此处阻塞

在linux底层分别是create_socket() -> bind()->listen()->accept()

BIO

传统的网络模型,基本是每连接每线程/进程 的模式,主线程阻塞式accept,然后将fd交给线程池去进行read/write,但是同样也是阻塞的,所以效率比较低下,在连接数不多或者并发不高的场景下可以使用,因此BIO会存在C10k的问题。 默认情况下linux系统的每个进程的fd(文件描述符,就是socket)个数只有1024, 可以修改。

  • 优点:简单,易理解,适用于并发不高的场景
  • 缺点:C10k问题

BIO在服务端模式下没那么适用,但是对于客户端模式就没那么多问题,所以常见于客户端的网络编程。

NIO 非阻塞

这里说的是底层的Non-Blocking IO,通过fd设置为非阻塞,那么accept和read/write都是会直接返回,所以常用方式是循环执行。 此方式虽然不会阻塞线程,但是由于每次accept和read都是系统调用,因此会出现用户线程和内核线程的切换,正常来说一次accept和read都会经历两次线程切换,因而此种方式产生多次无效的线程切换,增加了损耗。

  • 优点: 规避了C10k问题
  • 缺点: 会出现很多无效的系统调用,从而浪费性能

多路复用

多路复用的出现也是为了解决NIO的问题,本质是通过一次系统调用从而将就绪的fd返回,避免了无效的系统调用,从整体上减少线程切换的损耗。

select

操作系统提供的函数,应用系统将accept到的socket添加到系统的fd数组里面,然后系统去遍历这个fd数组并返回那些文件描述符可以进行读写。

  • 优点:解决了NIO无意义的系统调用,每次都会返回有效的fd。
  • 缺点:
    • 因为是fd数组,所以有1024个数限制,
    • 需要用户copy给内核,在高并发场景下会有复制损耗
    • select内核还是在全量遍历

poll

和select很类似,只是把fd数组换成了链表,从而解决了1024的限制,但是其他并没有改变。

epoll

多路复用的最终实现,解决了上面select和poll的一些缺点。

epoll提供了3个函数:epoll_create,epoll_ctl,epoll_wait

create: 创建一个epoll实例(selector的一个实现)

ctl:向epoll添加,删除需要监听的fd和对应的事件

wait: 类似select()调用,会阻塞,返回就绪的fd,但是底层使用的红黑树来存储这些fd,并且是通过事件来通知epoll哪些fd就绪了,不需要遍历了。

需要注意的是不管是BIO,NIO还是多路复用都是同步IO模型,因为还需应用程序自己去读取IO,这个过程是阻塞的