I/O

本贴最后更新于 716 天前,其中的信息可能已经渤澥桑田

1.网络包接收过程

2bb5f351c7880793bae16576d4c30835.png

1.内核

  1. 数据到达网卡,网卡通过 DMA 搬运数据到内核 DMA 缓冲区
  2. 网卡向 cpu 发起硬中断,cpu 回调中断程序,创建 sk_buffer
  3. 网卡中断程序向内核发起软中断,通知内核网络数据到达
  4. 内核网络协议栈处理数据头,找到对应 socket,拷贝数据到对应的 socket 缓冲区

2.应用

  1. 程序调用内核的 read 函数读取 socket 缓冲区,
  2. 如果 socket 缓冲区没有数据,则程序阻塞(BIO)
  3. 当 socket 接受缓冲区有数据时,将内核空间的数据拷贝至用户空间,系统调用 read 返回,

3.性能开销

  • 应用程序通过 系统调用用户态 转为 内核态 的开销以及系统调用 返回 时从 内核态 转为 用户态 的开销。
  • 网络数据从 内核空间 通过 CPU拷贝用户空间 的开销。
  • 内核线程 ksoftirqd 响应 软中断 的开销,主要为数据包解析以及数据拷贝到 socket 缓冲区。
  • CPU 响应 硬中断 的开销,主要为 DMA 缓冲区数据拷贝到 sk_buffer。
  • DMA拷贝 网络数据包到 内存 中的开销。

2.网络包发送流程

b807da4b27460d516ee585391f8a71ad.png

1.内核

  1. 程序调用系统 send 函数,用户态切换成内核态,根据 fd 找到 socket,发送数据封装到 msghdr 结构体
  2. 创建内核数据结构 sk_buffer,拷贝 msghdr 数据到 sk_buffer,添加到 socket 发送队列队尾
  3. 发送时拷贝 sk_buffer(tcp 防丢包),进行数据封装填充
  4. 循环从发送队列中去除 sk_buffer,依次调用网卡发送数据
  5. 发送完毕向 cpu 触发硬中断,硬中断中触发软中断释放 sk_buffer 副本以及网卡发送队列

2.性能开销

  1. send 函数用户态切换成内核态, 发送完毕从内核态切换成用户态开销
  2. 网卡发送完毕后出发 cpu 硬中断,以及在硬中断中出发软中断
  3. 内存拷贝开销
    1. 发送数据拷贝至 sk_buffer
    2. sk_buffer 的副本拷贝
    3. 发送数据过大时,会进行分片拷贝成多个小的 sk_buffer

3. 阻塞、非阻塞、同步、异步

同步与异步关注的是通信机制, 简单来说就是调用方与服务方之间的协同机制

同步: 调用方 A 发起调用后,服务方 B 在处理完调用方法之前不会返回,即 AB 是串行执行的

异步: 调用方 A 发起调用后,服务方 B 会立刻返回,并在处理完调用方法后通过回调等方式通知 A , 即 AB 步调可以不一致

不同于同步与异步, 阻塞与非阻塞关注的是一方的状态

阻塞: 阻塞当前线程(进入循环或者挂起线程),使线程不能处理其他事

非阻塞: 不阻塞当前线程,线程仍然可以处理其他事

2fad6d77871567aea41aac4bec6e7510.png

整个流程分为两个阶段:

  1. 数据准备阶段: 数据从网卡拷贝到内存再到 socket 数据缓冲区
  2. 数据拷贝阶段: 内核空间(socket 数据缓冲区)拷贝到用户空间

1.阻塞

1b89fc3f60514395c6b89b84b0093e0f.png

第一阶段和第二阶段都会 阻塞等待

2.非阻塞

4fe3ca17ee37ca2b238400325cf04d0f.png

第一阶段 不会等待,但是在第二阶段还是会 等待

3.同步

b22456778dcb01a6d692e9efe0c0724a.png

同步模式 在数据准备好后,是由 用户线程内核态 来执行 第二阶段。所以应用程序会在第二阶段发生 阻塞,直到数据从 内核空间 拷贝到 用户空间,系统调用才会返回。

Linux 下的 epoll 和 Mac 下的 kqueue 都属于 同步 IO

4.异步

1ef7d42e2ef09d524926038ad05cdebd.png

异步模式 下是由 内核 来执行第二阶段的数据拷贝操作,当 内核 执行完第二阶段,会通知用户线程 IO 操作已经完成,并将数据回调给用户线程。所以在 异步模式数据准备阶段数据拷贝阶段 均是由 内核 来完成,不会对应用程序造成任何阻塞。

4.五种 IO 模型

4.1 阻塞 IO(BIO)

1b89fc3f60514395c6b89b84b0093e0f.png

阻塞读

调用系统 read 函数,用户线程从用户态切换为内核态,查看 socket 缓冲区是否有数据

  1. socket 缓冲区有数据, 将数据从内核空间拷贝到用户空间,系统 IO 调用返回
  2. socket 缓冲区无数据,用户线程让出 CPU,进入 阻塞状态。当数据到达 Socket 接收缓冲区后,内核唤醒 阻塞状态 中的用户线程进入 就绪状态,随后经过 CPU 的调度获取到 CPU quota 进入 运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回。
阻塞写

调用系统 send 函数,用户线程从用户态切换为内核态,将发送数据从用户空间拷贝到内核空间中的 Socket 发送缓冲区中。

  1. Socket 发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入 Socket 缓冲区,执行发送流程后返回
  2. Socket 发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出 CPU,进入 阻塞状态,直到 Socket 发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。
阻塞 IO 模型
  1. 每个请求单独线程处理
  2. 并发量增大则服务端瞬间会创建大量线程,造成资源浪费
  3. 创建好线程后,若长期处于空闲时间,则线程们一直阻塞
  4. 线程切换开销大
适用场景
  1. 链接少
  2. 并发低
  3. 重型操作

4.2 非阻塞 IO(NIO)

4fe3ca17ee37ca2b238400325cf04d0f.png

用尽量少的线程去处理更多的连接

非阻塞读

调用系统 read 函数,用户线程从用户态切换为内核态,查看 socket 缓冲区是否有数据

  1. socket 缓冲区有数据, 将数据从内核空间拷贝到用户空间,系统 IO 调用返回
  2. socket 缓冲区无数据,系统调用立马返回,线程 不会阻塞,也 不让出cpu,而是会继续 轮训 直到 Socket 接收缓冲区中有数据为止。
非阻塞写

调用系统 send 函数,用户线程从用户态切换为内核态,将发送数据从用户空间拷贝到内核空间中的 Socket 发送缓冲区中。

  1. Socket 发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入 Socket 缓冲区,执行发送流程后返回
  2. Socket 发送缓冲区空间不够,无法容纳下全部发送数据时,能写多少写多少,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的 轮训 尝试将 剩下的数据 写入发送缓冲区中。
非阻塞 IO 模型

a1f0be37b1e7dcb677a851db784e0ae0.png

利用一个线程或者很少的线程 ,去 不断地轮询 每个 Socket 的接收缓冲区是否有数据到达,如果没有数据,不必阻塞 线程,而是接着去 轮询 下一个 Socket 接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则 继续轮询 其他的 Socket 接收缓冲区。

适用场景

非阻塞IO模型 下,需要用户线程去 不断地 发起 系统调用 去轮训 Socket 接收缓冲区,这就需要用户线程不断地从 用户态 切换到 内核态内核态 切换到 用户态

单纯的 非阻塞IO 模型还是无法适用于高并发的场景

4.3 IO 多路复用

8c51b0c8322d3026fb7bc80740179ec4.png

  1. 多路 :我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的 多路 指的就是我们需要处理的众多连接。
  2. 复用 :核心需求要求我们使用 尽可能少的线程尽可能少的系统开销 去处理 尽可能多 的连接(多路),那么这里的 复用 指的就是用 有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在 阻塞IO模型 中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了 IO多路复用模型 中,多个连接可以 复用 这一个独立的线程去处理这多个连接上的读写。

非阻塞 IO 中其实实现了多路复用,只不过是用户态去不断轮询,会导致用户态与内核态频繁切换,浪费资源

我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在 用户空间 频繁的去使用系统调用来轮询所带来的性能开销。

select

8f1f3c7b23a88b7758b9ebb70854ec05.png

select 系统调用将 轮询 的操作交给了 内核 来帮助我们完成,从而避免了在 用户空间 不断的发起轮询所带来的的系统性能开销。

  • 首先用户线程在发起 select 系统调用的时候会 阻塞select 系统调用上。此时,用户线程从 用户态 切换到了 内核态 完成了一次 上下文切换
  • 用户线程将需要监听的 Socket 对应的文件描述符 fd 数组通过 select 系统调用传递给内核。此时,用户线程将 用户空间 中的文件描述符 fd 数组 拷贝内核空间
  • 当用户线程调用完 select 后开始进入 阻塞状态内核 开始轮询遍历 fd 数组,查看 fd 对应的 Socket 接收缓冲区中是否有数据到来。如果有数据到来,则将 fd 对应 BitMap 的值设置为 1。如果没有数据到来,则保持值为 0
  • 内核遍历一遍 fd 数组后,如果发现有些 fd 上有 IO 数据到来,则将修改后的 fd 数组返回给用户线程。此时,会将 fd 数组从 内核空间 拷贝到 用户空间
  • 当内核将修改后的 fd 数组返回给用户线程后,用户线程解除 阻塞,由用户线程开始遍历 fd 数组然后找出 fd 数组中值为 1Socket 文件描述符。最后对这些 Socket 发起系统调用读取数据。
  • 由于内核在遍历的过程中已经修改了 fd 数组,所以在用户线程遍历完 fd 数组后获取到 IO就绪Socket 后,就需要 重置 fd 数组,并重新调用 select 传入重置后的 fd 数组,让内核发起新的一轮遍历轮询

这里的文件描述符数组 其实是一个 BitMapBitMap 下标为 文件描述符fd,下标对应的值为:1 表示该 fd 上有读写事件,0 表示该 fd 上没有读写事件。

性能开销

  • 发生 2 次上下文 切换
  • 发生 2 次文件描述符集合的 拷贝
  • 虽然由原来在 用户空间 发起轮询 优化成了内核空间 发起轮询但 select 不会告诉用户线程到底是哪些 Socket 上发生了 IO就绪 事件,只是对 IO就绪Socket 作了标记,用户线程依然要 遍历 文件描述符集合去查找具体 IO就绪Socket。时间复杂度依然为 O(n)
  • 内核 会对原始的 文件描述符集合 进行修改。导致每次在用户空间重新发起 select 调用时,都需要对 文件描述符集合 进行 重置
  • BitMap 结构的文件描述符集合,长度为固定的 1024,所以只能监听 0~1023 的文件描述符。
  • select 系统调用 不是线程安全的。
  • 并发量增大,开销线性增长,只适合 1000 个左右的并发
poll

poll 与 select 原理之一,主要变化点为修改了 1024 个文件描述符的限制,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)

epoll

select 和 poll 现有问题:

  1. 每次都需要全量传入 fd 集合,导致大量 fd 在用户空间与内核空间频繁复制
  2. 内核不会通知具体 IO就绪socket,只是在这些 IO就绪 的 socket 上打好标记,所以当 select 系统调用返回时,在 用户空间 还是需要 完整遍历 一遍 socket 文件描述符集合来获取具体 IO就绪socket
  3. 内核空间 中也是通过遍历的方式来得到 IO就绪socket
  4. 红黑树

da90cb043b59a9d4bf06424bd19c9b78.png

epoll 内部使用红黑树来保存所有监听的 socket,添加和查找复杂度为 O(logn) ,节点中每个数字为 socket 的句柄

  1. 就绪队列

e413f9afdab692b69c8ec601ce49d1a0.png

当 socket 从网络中获取到数据后,会发生通知给 epoll,epoll 会将当前 socket 添加到就绪队列中,并且唤醒等待中的进程(也就是调用 epoll_wait 的进程)。

当进程被唤醒后,就会从就绪队列中,把就绪的 socket 复制到用户提供的数组中。

epoll_wait 返回后,用户就可以从 events 数组中获取到就绪的 socket,并可对其进行读写操作。

  1. 常用方法

1)调用 epoll_create()建立一个 epoll 对象(在 epoll 文件系统中为这个句柄对象分配资源)

2)调用 epoll_ctl 向 epoll 对象中添加这 100 万个连接的套接字

3)调用 epoll_wait 收集发生的事件的连接

epoll 增加优化:

  • epoll 在内核中通过 红黑树 管理海量的连接,所以在调用 epoll_wait 获取 IO就绪 的 socket 时,不需要传入监听的 socket 文件描述符。从而避免了海量的文件描述符集合在 用户空间内核空间 中来回复制。

select,poll 每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。

  • epoll 仅会通知 IO就绪 的 socket。避免了在用户空间遍历的开销。

select,poll 只会在 IO就绪 的 socket 上打好标记,依然是全量返回,所以在用户空间还需要用户程序在一次遍历全量集合找出具体 IO就绪 的 socket。

  • epoll 通过在 socket 的等待队列上注册回调函数 ep_poll_callback 通知用户程序 IO就绪 的 socket。避免了在内核中轮询的开销。

大部分情况下 socket 上并不总是 IO活跃 的,在面对海量连接的情况下,select,poll 采用内核轮询的方式获取 IO活跃 的 socket,无疑是性能低下的核心原因。

4.4 信号驱动 IO

59005f3cc3ce8c2282b979ce30f7cc2c.png

4.5 异步 IO(AIO)

a488022a0a9abed4d6cb472ce229b654.png

4.6 总结

image.png

image.png

image.png

image.png

image.png

image.png

image.png

java 中的 NIO 其实是多路复用 IO 模型,与 linux 中的 NIO(非阻塞 IO)存在异同点

Blocking IO 主要是里面的方法操作步骤都是同步的,而 Non-Blocking IO 则不是。它是通过注册事件来触发对应的 handler 来执行。比起 thread per connection 的方式,它能更好的利用资源。因为回调的机制对于异步的通信方式来说减少了轮询或者其它强制同步机制的开销,效率算是比较理想的

NIO 中的非阻塞主要体现在

  1. read 和 write 都是立刻返回的,不像 bio 中可能会阻塞,因为 nio 中 read 和 write 针对的都是 buffer, bio 中则是针对流,效率较低
  2. 事件线程是阻塞的,即调用了 epoll_wait, 但是事件处理线程是非阻塞的

5.用户空间的 IO 线程模型

5.1 Reactor

Reactor 是利用 NIOIO线程 进行不同的分工:

  • 使用前边我们提到的 IO多路复用模型 比如 select,poll,epoll,kqueue,进行 IO 事件的注册和监听。
  • 将监听到 就绪的IO事件 分发 dispatch 到各个具体的处理 Handler 中进行相应的 IO事件处理

通过 IO多路复用技术 就可以不断的监听 IO事件,不断的分发 dispatch,就像一个 反应堆 一样,看起来像不断的产生 IO事件,因此我们称这种模式为 Reactor 模型。

单 Reactor 单线程

image.png

  • Reactor 意味着只有一个 epoll 对象,用来监听所有的事件,比如 连接事件读写事件
  • 单线程 意味着只有一个线程来执行 epoll_wait 获取 IO就绪Socket,然后对这些就绪的 Socket 执行读写,以及后边的业务处理也依然是这个线程。
单 Reactor 多线程

image.png

  • 单reactor 是只有一个 epoll 对象来监听所有的 IO事件,一个线程来调用 epoll_wait 获取 IO就绪Socket
  • 但是当 IO就绪事件 产生时,这些 IO事件 对应处理的业务 Handler,我们是通过线程池来执行。这样相比 单Reactor单线程 模型提高了执行效率,充分发挥了多核 CPU 的优势。
主从 Reactor 多线程

image.png

  • 我们由原来的 单Reactor 变为了 多Reactor主Reactor 用来优先 专门 做优先级最高的事情,也就是处理 连接事件,对应的处理 Handler 就是图中的 acceptor
  • 当创建好连接,建立好对应的 socket 后,在 acceptor 中将要监听的 read事件 注册到 从Reactor 中,由 从Reactor 来监听 socket 上的 读写 事件。

5.2 Proactor

等待后续更新.....

5.3 Reactor 与 Proactor 对比

等待后续更新....

5.4Netty 的 IO 模型

netty 中 IO 模型为 Reactor 模型,三种类型都涉及,单主要使用的是 主从Reactor多线程模型

67229e4487d82c220fa28f2a3f6d3d62.png

  • Reactornetty 中是以 group 的形式出现的,netty 中将 Reactor 分为两组,一组是 MainReactorGroup 也就是我们在编码中常常看到的 EventLoopGroup bossGroup,另一组是 SubReactorGroup 也就是我们在编码中常常看到的 EventLoopGroup workerGroup
  • MainReactorGroup 中通常只有一个 Reactor,专门负责做最重要的事情,也就是监听连接 accept 事件。当有连接事件产生时,在对应的处理 handler acceptor 中创建初始化相应的 NioSocketChannel(代表一个 Socket连接)。然后以 负载均衡 的方式在 SubReactorGroup 中选取一个 Reactor,注册上去,监听 Read事件

MainReactorGroup 中只有一个 Reactor 的原因是,通常我们服务端程序只会 绑定监听 一个端口,如果要 绑定监听 多个端口,就会配置多个 Reactor

  • SubReactorGroup 中有多个 Reactor,具体 Reactor 的个数可以由系统参数 -D io.netty.eventLoopThreads 指定。默认的 Reactor 的个数为 CPU核数 * 2SubReactorGroup 中的 Reactor 主要负责监听 读写事件,每一个 Reactor 负责监听一组 socket连接。将全量的连接 分摊 在多个 Reactor 中。
  • 一个 Reactor 分配一个 IO线程,这个 IO线程 负责从 Reactor 中获取 IO就绪事件,执行 IO调用获取IO数据,执行 PipeLine

Socket连接 在创建后就被 固定的分配 给一个 Reactor,所以一个 Socket连接 也只会被一个固定的 IO线程 执行,每个 Socket连接 分配一个独立的 PipeLine 实例,用来编排这个 Socket连接 上的 IO处理逻辑。这种 无锁串行化 的设计的目的是为了防止多线程并发执行同一个 socket 连接上的 IO逻辑处理,防止出现 线程安全问题。同时使系统吞吐量达到最大化

由于每个 Reactor 中只有一个 IO线程,这个 IO线程 既要执行 IO活跃Socket连接 对应的 PipeLine 中的 ChannelHandler,又要从 Reactor 中获取 IO就绪事件,执行 IO调用。所以 PipeLineChannelHandler 中执行的逻辑不能耗时太长,尽量将耗时的业务逻辑处理放入单独的业务线程池中处理,否则会影响其他连接的 IO读写,从而近一步影响整个服务程序的 IO吞吐

  • IO请求 在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的 ChannelHandlerContext 引用将响应数据在 PipeLine 中反向传播,最终写回给客户端。

文章参考: https://mp.weixin.qq.com/s/Ylf_ClsjIWJf8nTUQuwMoA

  • 网络
    128 引用 • 177 回帖 • 3 关注
  • NIO
    15 引用 • 26 回帖 • 1 关注
6 操作
AshShawn 在 2022-04-13 10:07:57 更新了该帖
AshShawn 在 2022-04-09 20:49:33 更新了该帖
AshShawn 在 2022-04-09 16:59:57 更新了该帖
AshShawn 在 2022-04-08 22:36:22 更新了该帖 AshShawn 在 2022-04-08 21:53:53 更新了该帖 AshShawn 在 2022-04-07 23:55:05 更新了该帖

相关帖子

回帖
I/O

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...