1.网络包接收过程
1.内核
- 数据到达网卡,网卡通过 DMA 搬运数据到内核 DMA 缓冲区
- 网卡向 cpu 发起硬中断,cpu 回调中断程序,创建 sk_buffer
- 网卡中断程序向内核发起软中断,通知内核网络数据到达
- 内核网络协议栈处理数据头,找到对应 socket,拷贝数据到对应的 socket 缓冲区
2.应用
- 程序调用内核的 read 函数读取 socket 缓冲区,
- 如果 socket 缓冲区没有数据,则程序阻塞(BIO)
- 当 socket 接受缓冲区有数据时,将内核空间的数据拷贝至用户空间,系统调用 read 返回,
3.性能开销
- 应用程序通过
系统调用
从用户态
转为内核态
的开销以及系统调用返回
时从内核态
转为用户态
的开销。 - 网络数据从
内核空间
通过CPU拷贝
到用户空间
的开销。 - 内核线程
ksoftirqd
响应软中断
的开销,主要为数据包解析以及数据拷贝到 socket 缓冲区。 CPU
响应硬中断
的开销,主要为 DMA 缓冲区数据拷贝到 sk_buffer。DMA拷贝
网络数据包到内存
中的开销。
2.网络包发送流程
1.内核
- 程序调用系统 send 函数,用户态切换成内核态,根据 fd 找到 socket,发送数据封装到
msghdr
结构体 - 创建内核数据结构 sk_buffer,拷贝 msghdr 数据到 sk_buffer,添加到 socket 发送队列队尾
- 发送时拷贝 sk_buffer(tcp 防丢包),进行数据封装填充
- 循环从发送队列中去除 sk_buffer,依次调用网卡发送数据
- 发送完毕向 cpu 触发硬中断,硬中断中触发软中断释放 sk_buffer 副本以及网卡发送队列
2.性能开销
- send 函数用户态切换成内核态, 发送完毕从内核态切换成用户态开销
- 网卡发送完毕后出发 cpu 硬中断,以及在硬中断中出发软中断
- 内存拷贝开销
- 发送数据拷贝至 sk_buffer
- sk_buffer 的副本拷贝
- 发送数据过大时,会进行分片拷贝成多个小的 sk_buffer
3. 阻塞、非阻塞、同步、异步
同步与异步关注的是通信机制, 简单来说就是调用方与服务方之间的协同机制
同步: 调用方 A 发起调用后,服务方 B 在处理完调用方法之前不会返回,即 AB 是串行执行的
异步: 调用方 A 发起调用后,服务方 B 会立刻返回,并在处理完调用方法后通过回调等方式通知 A , 即 AB 步调可以不一致
不同于同步与异步, 阻塞与非阻塞关注的是一方的状态
阻塞: 阻塞当前线程(进入循环或者挂起线程),使线程不能处理其他事
非阻塞: 不阻塞当前线程,线程仍然可以处理其他事
整个流程分为两个阶段:
- 数据准备阶段: 数据从网卡拷贝到内存再到 socket 数据缓冲区
- 数据拷贝阶段: 内核空间(socket 数据缓冲区)拷贝到用户空间
1.阻塞
第一阶段和第二阶段都会 阻塞等待
2.非阻塞
第一阶段 不会等待
,但是在第二阶段还是会 等待
。
3.同步
同步模式
在数据准备好后,是由 用户线程
的 内核态
来执行 第二阶段
。所以应用程序会在第二阶段发生 阻塞
,直到数据从 内核空间
拷贝到 用户空间
,系统调用才会返回。
Linux 下的 epoll
和 Mac 下的 kqueue
都属于 同步 IO
。
4.异步
异步模式
下是由 内核
来执行第二阶段的数据拷贝操作,当 内核
执行完第二阶段,会通知用户线程 IO 操作已经完成,并将数据回调给用户线程。所以在 异步模式
下 数据准备阶段
和 数据拷贝阶段
均是由 内核
来完成,不会对应用程序造成任何阻塞。
4.五种 IO 模型
4.1 阻塞 IO(BIO)
阻塞读
调用系统 read
函数,用户线程从用户态切换为内核态,查看 socket 缓冲区是否有数据
- socket 缓冲区有数据, 将数据从内核空间拷贝到用户空间,系统 IO 调用返回
- socket 缓冲区无数据,用户线程让出 CPU,进入
阻塞状态
。当数据到达Socket
接收缓冲区后,内核唤醒阻塞状态
中的用户线程进入就绪状态
,随后经过 CPU 的调度获取到CPU quota
进入运行状态
,将内核空间的数据拷贝到用户空间,随后系统调用返回。
阻塞写
调用系统 send
函数,用户线程从用户态切换为内核态,将发送数据从用户空间拷贝到内核空间中的 Socket
发送缓冲区中。
Socket
发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket
缓冲区,执行发送流程后返回Socket
发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出 CPU,进入阻塞状态
,直到Socket
发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。
阻塞 IO 模型
- 每个请求单独线程处理
- 并发量增大则服务端瞬间会创建大量线程,造成资源浪费
- 创建好线程后,若长期处于空闲时间,则线程们一直阻塞
- 线程切换开销大
适用场景
- 链接少
- 并发低
- 重型操作
4.2 非阻塞 IO(NIO)
用尽量少的线程去处理更多的连接
非阻塞读
调用系统 read
函数,用户线程从用户态切换为内核态,查看 socket 缓冲区是否有数据
- socket 缓冲区有数据, 将数据从内核空间拷贝到用户空间,系统 IO 调用返回
- socket 缓冲区无数据,系统调用立马返回,线程
不会阻塞
,也不让出cpu
,而是会继续轮训
直到Socket
接收缓冲区中有数据为止。
非阻塞写
调用系统 send
函数,用户线程从用户态切换为内核态,将发送数据从用户空间拷贝到内核空间中的 Socket
发送缓冲区中。
Socket
发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket
缓冲区,执行发送流程后返回Socket
发送缓冲区空间不够,无法容纳下全部发送数据时,能写多少写多少
,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮训
尝试将剩下的数据
写入发送缓冲区中。
非阻塞 IO 模型
利用一个线程或者很少的线程 ,去 不断地轮询
每个 Socket
的接收缓冲区是否有数据到达,如果没有数据,不必阻塞
线程,而是接着去 轮询
下一个 Socket
接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则 继续轮询
其他的 Socket
接收缓冲区。
适用场景
在 非阻塞IO模型
下,需要用户线程去 不断地
发起 系统调用
去轮训 Socket
接收缓冲区,这就需要用户线程不断地从 用户态
切换到 内核态
,内核态
切换到 用户态
。
单纯的 非阻塞IO
模型还是无法适用于高并发的场景
4.3 IO 多路复用
- 多路 :我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的
多路
指的就是我们需要处理的众多连接。 - 复用 :核心需求要求我们使用
尽可能少的线程
,尽可能少的系统开销
去处理尽可能多
的连接(多路
),那么这里的复用
指的就是用有限的资源
,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型
中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型
中,多个连接可以复用
这一个独立的线程去处理这多个连接上的读写。
非阻塞 IO 中其实实现了多路复用,只不过是用户态去不断轮询,会导致用户态与内核态频繁切换,浪费资源
我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在 用户空间
频繁的去使用系统调用来轮询所带来的性能开销。
select
select
系统调用将 轮询
的操作交给了 内核
来帮助我们完成,从而避免了在 用户空间
不断的发起轮询所带来的的系统性能开销。
- 首先用户线程在发起
select
系统调用的时候会阻塞
在select
系统调用上。此时,用户线程从用户态
切换到了内核态
完成了一次上下文切换
- 用户线程将需要监听的
Socket
对应的文件描述符fd
数组通过select
系统调用传递给内核。此时,用户线程将用户空间
中的文件描述符fd
数组拷贝
到内核空间
。 - 当用户线程调用完
select
后开始进入阻塞状态
,内核
开始轮询遍历fd
数组,查看fd
对应的Socket
接收缓冲区中是否有数据到来。如果有数据到来,则将fd
对应BitMap
的值设置为1
。如果没有数据到来,则保持值为0
。 - 内核遍历一遍
fd
数组后,如果发现有些fd
上有 IO 数据到来,则将修改后的fd
数组返回给用户线程。此时,会将fd
数组从内核空间
拷贝到用户空间
。 - 当内核将修改后的
fd
数组返回给用户线程后,用户线程解除阻塞
,由用户线程开始遍历fd
数组然后找出fd
数组中值为1
的Socket
文件描述符。最后对这些Socket
发起系统调用读取数据。 - 由于内核在遍历的过程中已经修改了
fd
数组,所以在用户线程遍历完fd
数组后获取到IO就绪
的Socket
后,就需要重置
fd 数组,并重新调用select
传入重置后的fd
数组,让内核发起新的一轮遍历轮询
这里的文件描述符数组 其实是一个 BitMap
,BitMap
下标为 文件描述符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 现有问题:
- 每次都需要全量传入 fd 集合,导致大量 fd 在用户空间与内核空间频繁复制
- 内核不会通知具体
IO就绪
的socket
,只是在这些IO就绪
的 socket 上打好标记,所以当select
系统调用返回时,在用户空间
还是需要完整遍历
一遍socket
文件描述符集合来获取具体IO就绪
的socket
。 - 在
内核空间
中也是通过遍历的方式来得到IO就绪
的socket
。 - 红黑树
epoll 内部使用红黑树来保存所有监听的 socket,添加和查找复杂度为 O(logn) ,节点中每个数字为 socket 的句柄
- 就绪队列
当 socket 从网络中获取到数据后,会发生通知给 epoll,epoll 会将当前 socket 添加到就绪队列中,并且唤醒等待中的进程(也就是调用 epoll_wait
的进程)。
当进程被唤醒后,就会从就绪队列中,把就绪的 socket 复制到用户提供的数组中。
当 epoll_wait
返回后,用户就可以从 events
数组中获取到就绪的 socket,并可对其进行读写操作。
- 常用方法
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
4.5 异步 IO(AIO)
4.6 总结
java 中的 NIO 其实是多路复用 IO 模型,与 linux 中的 NIO(非阻塞 IO)存在异同点
Blocking IO 主要是里面的方法操作步骤都是同步的,而 Non-Blocking IO 则不是。它是通过注册事件来触发对应的 handler 来执行。比起 thread per connection 的方式,它能更好的利用资源。因为回调的机制对于异步的通信方式来说减少了轮询或者其它强制同步机制的开销,效率算是比较理想的
NIO 中的非阻塞主要体现在
- read 和 write 都是立刻返回的,不像 bio 中可能会阻塞,因为 nio 中 read 和 write 针对的都是 buffer, bio 中则是针对流,效率较低
- 事件线程是阻塞的,即调用了 epoll_wait, 但是事件处理线程是非阻塞的
5.用户空间的 IO 线程模型
5.1 Reactor
Reactor
是利用 NIO
对 IO线程
进行不同的分工:
- 使用前边我们提到的
IO多路复用模型
比如select,poll,epoll,kqueue
,进行 IO 事件的注册和监听。 - 将监听到
就绪的IO事件
分发dispatch
到各个具体的处理Handler
中进行相应的IO事件处理
。
通过 IO多路复用技术
就可以不断的监听 IO事件
,不断的分发 dispatch
,就像一个 反应堆
一样,看起来像不断的产生 IO事件
,因此我们称这种模式为 Reactor
模型。
单 Reactor 单线程
- 单
Reactor
意味着只有一个epoll
对象,用来监听所有的事件,比如连接事件
,读写事件
。 单线程
意味着只有一个线程来执行epoll_wait
获取IO就绪
的Socket
,然后对这些就绪的Socket
执行读写,以及后边的业务处理也依然是这个线程。
单 Reactor 多线程
单reactor
是只有一个epoll
对象来监听所有的IO事件
,一个线程来调用epoll_wait
获取IO就绪
的Socket
- 但是当
IO就绪事件
产生时,这些IO事件
对应处理的业务Handler
,我们是通过线程池来执行。这样相比单Reactor单线程
模型提高了执行效率,充分发挥了多核 CPU 的优势。
主从 Reactor 多线程
- 我们由原来的
单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多线程模型
Reactor
在netty
中是以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核数 * 2
。SubReactorGroup
中的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调用
。所以PipeLine
中ChannelHandler
中执行的逻辑不能耗时太长,尽量将耗时的业务逻辑处理放入单独的业务线程池中处理,否则会影响其他连接的IO读写
,从而近一步影响整个服务程序的IO吞吐
。
- 当
IO请求
在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的ChannelHandlerContext
引用将响应数据在PipeLine
中反向传播,最终写回给客户端。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于