Loading

IO相关

IO相关

IO 介绍

IO的全称其实是:Input/Output的缩写

传统的 IO 大致可以分为 4种类型:

  • InputStream、OutputStream 基于字节操作的 IO

  • Writer、Reader 基于字符操作的 IO

  • File 基于磁盘操作的 IO

  • Socket 基于网络操作的 IO

IO都是依赖操作系统内核进行的,我们程序中的IO读写其实是由操作系统内核中的read&write两大系统调用。

那内核是如何进行IO交互的呢?

  1. 网卡收到经过网线传来的网络数据,并将网络数据写到内存中。

  2. 当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。

  3. 将内存中的网络数据写入到对应socket的接收缓冲区中。

  4. 当接收缓冲区的数据写好之后,应用程序开始进行数据处理。

基于磁盘操作的 IO

阻塞IO

阻塞IO要等待数据准备以及数据的拷贝

image

BIO

BIO全称是Blocking IO,是JDK1.4之前的传统IO模型,本身是同步阻塞模式

线程发起IO请求后,一直阻塞IO,直到缓冲区数据就绪后,再进入下一步操作。针对网络通信都是一请求一应答的方式,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,试想一下如果每个请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽。

非阻塞 I/O

非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好

image


为了解决这种傻乎乎轮询方式

为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?

I/O 多路复用

所谓 I/O 多路复用指的就是 select/poll/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知

I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)

GO中的多路复用

Golang的底层网络模型是基于epoll实现的

Go 是一门跨平台的编程语言,而不同平台针对特定的功能有不同的实现,这当然也包括了 I/O 多路复用技术,比如 Linux 里的 I/O 多路复用有 select、poll 和 epoll,而 freeBSD 或者 MacOS 里则是 kqueue,而 Windows 里则是基于异步 I/O 实现的 iocp,等等;因此,Go 为了实现底层 I/O 多路复用的跨平台,分别基于上述的这些不同平台的系统调用实现了多版本的 netpollers

Go netpoller 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定当在一个 netFD 上读写遇到 EAGAIN 错误时就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,直到这个 netFD 上再次发生读写事件才将此 goroutine 给 ready 激活重新运行。显然,在底层通知 goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。

Go将多路复用器的操作进行了抽象和适配:

  • 将新建多路复用器抽象为了netpollinit()

  • 将插入监听事件抽象为了netpollopen()

  • 将查询事件抽象为了netpoll()

  • 最终返回等待事件的协程列表

同时Network Poller是Runtime中抽象多路复用器的工具,可以自动检测多个Socket的状态,由垃圾回收器周期地驱动。在查询到Socket状态可用时,快速返回成功;在Socket状态不可用时,休眠等待。(详细的信息存储在PollDesc中)

image

通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。

如果没有事件发生,那么当前线程就会发生阻塞,这时 CPU 会切换其他线程执行任务,等内核发现有事件到来的时候,会唤醒阻塞在 I/O 多路复用接口的线程,然后用户可以进行后续的事件处理。

I/O 多路复用接口最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求

用户可以注册多个 socket,然后不断地调用 I/O 多路复用接口读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

image

实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。

NIO

NIO也叫Non-Blocking IO 是同步非阻塞的IO模型。

线程发起io请求后,立即返回(非阻塞io)。同步指的是必须等待IO缓冲区内的数据就绪,而非阻塞指的是,用户线程不原地等待IO缓冲区,可以先做一些其他操作,但是要定时轮询检查IO缓冲区数据是否就绪。

Java中的NIO 是new IO的意思。其实是NIO加上IO多路复用技术。普通的NIO是线程轮询查看一个IO缓冲区是否就绪,而Java中的new IO指的是线程轮询地去查看一堆IO缓冲区中哪些就绪,这是一种IO多路复用的思想。IO多路复用模型中,将检查IO数据是否就绪的任务,交给系统级别的select或epoll模型,由系统进行监控,减轻用户线程负担

Java NIO是应用层IO复用技术

NIO主要有buffer、channel、selector三种技术的整合,通过零拷贝的buffer取得数据,每一个客户端通过channel在selector(多路复用器)上进行注册。服务端不断轮询channel来获取客户端的信息。

channel上有connect,accept(阻塞)、read(可读)、write(可写)四种状态标识。根据标识来进行后续操作。所以一个服务端可接收无限多的channel。不需要新开一个线程。大大提升了性能。

NIO 是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。

  • 首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色;

  • 然后,创建一个 ServerSocketChannel,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求;

  • 为什么我们要明确配置非阻塞模式呢?

  • 这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常;

  • Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒;

image

异步 I/O

异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。

当我们发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:

image

在前面我们知道了,I/O 是分为两个过程的:

  1. 数据准备的过程

  2. 数据从内核空间拷贝到用户进程缓冲区的过程

阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。

异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。

AIO

AIO是真正意义上的异步非阻塞IO模型。

上述NIO实现中,需要用户线程定时轮询,去检查IO缓冲区数据是否就绪,占用应用程序线程资源,其实轮询相当于还是阻塞的,并非真正解放当前线程,因为它还是需要去查询哪些IO就绪。

而真正的理想的异步非阻塞IO应该让内核系统完成,用户线程只需要告诉内核,当缓冲区就绪后,通知我或者执行我交给你的回调函数。

AIO可以做到真正的异步的操作,但实现起来比较复杂,支持纯异步IO的操作系统非常少,目前也就windows是IOCP技术实现了,而在Linux上,底层还是是使用的epoll实现的。

image

epoll本身的机制及与select/poll的对比

  • epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

image

posted @ 2023-03-30 17:38  ydssx  阅读(10)  评论(0编辑  收藏  举报