网络通讯组件操作系统发展史
第一版:BIO
BIO为同步阻塞IO,blocking queue的简写,也就是说多线程情况下只有一个线程操作内核的queue,当前线程操作完queue后,才能给下一个线程操作;
问题
在BIO下,一个连接就对应一个线程,如果连接特别多的情况下,就会有特别多的线程,很费线程;在早期的时候,世界上的计算机还很少,网站也少,会上网的人更是寥寥无几,并发最高的时候也就几十上百个,所以当并发量不高的情况下,BIO也够用了;
第二版:NIO
Non-blocking IO的简写,同步非阻塞IO,内核发生了变化,app访问内核的缓冲区时不会阻塞,但是返回值需要用户自己判断;如果连接数特别多的i情况下,就需要应用程序不停遍历,一个个进行状态的判断,询问是否有数据到达;
PS: NIO有2个种类
- Non-blocking IO 指的是非阻塞IO
- new IO ,是指java.nio包,这两者不是同一类的东西,千万不要混淆;
问题
NIO 每次都需要应用程序自己去调用每一条管道来判断是否有数据返回,这样无形中就多了很多次系统调用,而里面有一些系统调用不是必须要的,所以,为了解决这个问题,就衍生出了一种新的技术:多路复用。如:1000个socket链接,有一个来数据了,需要系统调用1000次。
第三版:多路复用
NIO同一时间只能访问一个文件描述符,而这些操作都是在用户态进行操作的,这样一来,就需要应用程序不停的循环遍历,占用了资源,效率也非常低下,所以衍生出了一个新技术,多路复用,多路复用其实仅调用1次系统嗲用,将所有scoket的文件描述符交给内核,由内核来进行遍历;如:1000个socket链接,有一个来数据了,仅需要系统调用1次。
select
select可以同时监听多个连接,但是一个select监听的路数量有限制(一般是1024个),如果有2048个请求,就得用2个select,每次调用select时都会在内核态中遍历这些文件描述符,来判断连接状态,看看是否有数据返回;
poll
poll和selelct的机制是一样的,poll是selelct的升级版。最主要的区别就是poll监听的路数量没有限制;其他的基本都一样;
问题
- 每次都要重新传递fds(文件描述符),需要在用户空间和内核空间拷贝这份数据。
- 每次内核被调用之后,都会触发一个遍历fds的全量复杂度。1000个链接,1个来数据了,虽然只需要一次系统调用,但是还是要在内核循环1000次。
第四版:Epoll
epoll可以有效规避遍历,它和select/poll最大的区别是 epoll不需要去遍历文件描述符,它在内核态使用中断机制就已经将就绪的连接给筛选出来了。
epoll主要有三个系统调用方法,分别是:
- epoll_create : 创建小本本,调用后会返回一个文件描述符;这个文件描述符在内部是一个红黑树
- epoll_ctl:在刚刚创建的小本本上添加socket的连接信息,可以添加多个;添加的连接会加到红黑树里面;
- epoll_wait :有消息了吗? 调用此方法后会进入阻塞,等待返回数据,会返回一个就绪链表,这个链表里面的连接都是已经有结果的了,epoll_wait返回的就是这个就绪链表;
epoll没有去遍历红黑树,那它怎么知道哪些连接已经就绪了呢?
在内部使用了内核的中断机制,当树中某个一个连接发送请求给网卡之后,网卡就会去请求了,当对方发送数据过来之后,网卡会触发中断机制,发送一个中断信号给内核,内核收到这个信号之后会先去更新文件描述符的buffer缓冲区,把网卡的数据复制到buffer缓冲区,然后在将数据复制到就绪队列;
redis、netty等的底层就是用epoll多路复用器,所以它的速度非常快;
网络通讯组件操作系统发展史规律总结
除了操作系统的代码本身更加卓越,其执行比以前更节省CPU资源以外,用户态可感知到的主要为:操作系统尽可能地减少了用户态代码完成一项任务所需要的调用系统调用的次数。
举一反三
同样的规律不光适用于网络调用,也适用于操作系统的各个系统调用,如:Java程序员最常使用的一个系统调用 Futex,Java里面的AQS、Unsafe里面的park(),unPark(),synchronized 等最终底层调用的系统调用都是 Futex。
Futex
简介:futex 全称为Fast User-space Mutex,是Linux 2.5.7 内核引入的锁原语,不同于其他进程间通信IPC原语(如信号量Semaphore、信号Signal和各种锁pthread_mutex_lock),futex更轻量级、快速,一般应用开发人员可能很少用到,但可基于futex实现各类读写锁、屏障(barriers)和信号机制等。
相关背景
在Linux的早期版本(内核Linux 2.5.7 版本以前),进程间通信(Inter-Process Communication,IPC)沿用的是传统Unix系统和System V 的IPC,如信号量(Semaphores)和Socket 等,这些IPC 均基于系统调用(System Call)。这类方法的缺点是当系统竞争度较低时,每次都进行系统调用,会造成较大系统开销。
原理和做法
用户程序每次调用IPC机制都会产生系统调用,程序发生用户态和内核态的切换,futex 的基本思想是竞争态总是很少发生的,只有在竞争态才需要进入内核,否则在用户态即可完成。
futex的两个目标是:
1)尽量避免系统调用;
2)避免不必要的上下文切换(导致的TLB失效等)。
具体而言,任务获取一个futex 将发起带锁的减指令,并验证数值结果值是否为0(加上了锁),如果成功则可继续执行程序,失败(为已经占用的锁继续加锁)则任务在内核被阻塞。为相同futex 变量的加锁的任务被阻塞后放在同一个队列,解锁任务通过减少变量(只有一个加锁且锁队列为空)或进入内核从锁队列唤醒任务。
现在Java中的信号量也是使用 futex 实现的,而不是操作系统的信号量系统调用。
网络通讯组件—还能再快一点吗?
关于减少系统调用次数,可以减少到0吗:
- 第一种思路:所有代码执行在用户空间,从来不进入到内核态,这种思路比较激进,会让操作系统的隔离性变差,并丢失部分操作系统本身的功能,会直接影响到其他应用程序的运行,但是部分特殊场景下可以节省大量的CPU资源,如:DPDK结合LVS将LVS性能提升10倍左右。(不过未来这一部分场景中的很多应该会逐渐被eBPF所取代,eBPF即是第三种减少系统调用的方法)
- 第二种思路:要执行的代码执行在内核空间,不进入用户态。将一部分安全的用户态代码放入内核态执行,即为eBPF技术(被誉为操作系统近50年来最大的改动),facebook的基于 eBPF 的开源负载均衡器 katran 性能达到 dpvs 同一级别,同时又不丢失操作系统的隔离性和部分操作系统本身的功能。
DPDK
传统网络链路
DPDK网络链路
DPDK 效果有多好(10倍性能提升)
eBPF
将用户的代码,放到内核态去执行(被誉为操作系统仅50年来最大的变革)。
eBPF VS DPDK
功能对比:
- 更优雅的实现
- 更强的扩展性
- 不丢失操作系统的其他功能
- 不影响操作系统的隔离型
性能对比:
XDP基于eBPF实现的网络转发工具。
网络通讯服务框架性能优化实战
RestClient
RestClient 简介
ESA RestClient 是一个基于 Netty 的全链路异步事件驱动的高性能轻量级的HTTP客户端。
final RestClient client = RestClient.ofDefault(); //快速创建RestClient,各项配置均为默认配置。
//如果用户想自定义一些配置,则可以使用RestClient.create()来进行自定义配置。
client.post("http://127.0.0.1:8081/")
.entity("Hello Server") //设置请求体
.execute() //执行请求逻辑
.thenAccept((response)-> { //异步处理响应
try {
System.out.println(response.bodyToEntity(String.class)); //调用response.bodyToEntity(Class TargetClass)来 Decode 响应,
//TargetClass为期望的响应类型
} catch (Throwable e) {
e.printStackTrace();
}
});
功能特性
- Http1/H2/H2cUpgrade/Https
- Encode 与 EncodeAdvice
- Decode 与 DecodeAdvice
- RestInterceptor
- 大文件发送
- 请求级别读超时
- 请求级别重试
- 请求级别重定向
- 100-expect-continue
- Multipart
- Metrics
- more …
架构
NettyTransceiver
NettyTransceiver 是 RestClient与其底层框架Neety之间连接的桥梁,在介绍其之前,需要一些预备知识,我们先来简单介绍一下这些预备知识:
Channel & ChannelPool &ChannelPools
Channel : Channel是Netty网络操作抽象类,它聚合了一组功能,包括但不限于网络的读、写,客户端发起连接、主动关闭连接,链路关闭,获得通信双方的网络地址等。它也包含了Netty框架相关的一些功能,包括获取该Channel的EventLoop,获取缓冲分配器ByteBufAllocator和pipeline等。
ChannelPool:ChannelPool用于缓存Channel,它允许获取和释放Channel,并充当这些Channel的池,从而达到复用Channel的目的。在RestClient中,每一个Server host对应一个ChannelPool。
ChannelPools:ChannelPools用于缓存ChannelPool。在RestClient中,当一个Server host长时间没有被访问时,其所对应的ChannelPool将会被视作缓存过期,从而被回收资源。
EventLoop & EventLoopGroup
EventLoop:EventLoop在Netty中被用来运行任务来处理在Channel的生命周期内发生的事件。在RestClient中,一个EventLoop对应了一个线程。
EventLoopGroup:EventLoopGroup为一组EventLoop,其保证将多个任务尽可能地均匀地分配在多个EventLoop上。
Epoll
Epoll是Linux内核的可扩展I/O事件通知机制,包含下面这三个系统调用。
int epoll_create(int size);
在内核中创建epoll实例并返回一个epoll文件描述符(对应上图中 EpollEventLoop中的epollFD)。 在最初的实现中,调用者通过 size 参数告知内核需要监听的文件描述符数量。如果监听的文件描述符数量超过 size, 则内核会自动扩容。而现在 size 已经没有这种语义了,但是调用者调用时 size 依然必须大于 0,以保证后向兼容性。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
向 epfd 对应的内核epoll 实例添加、修改或删除对 fd 上事件 event 的监听。op 可以为 EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL 分别对应的是添加新的事件,修改文件描述符上监听的事件类型,从实例上删除一个事件。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
当 timeout 为 0 时,epoll_wait 永远会立即返回。而 timeout 为 -1 时,epoll_wait 会一直阻塞直到任一已注册的事件变为就绪。当 timeout 为一正整数时,epoll 会阻塞直到计时 timeout 毫秒终了或已注册的事件变为就绪。因为内核调度延迟,阻塞的时间可能会略微超过 timeout 毫秒。
Epoll运作流程:
- 进程先通过调用epoll_create来创建一个epoll文件描述符(对应上图中 EpollEventLoop中的epollFD)。epoll通过mmap开辟一块共享空间,该共享空间中包含一个红黑树和一个链表(对应上图epollFD中对应的Shared space)。
- 进程调用epoll的epoll_ctl add,把新来的链接的文件描述符放入红黑树中。
- 当红黑树中的fd有数据到了,就把它放入一个链表中并维护该数据可写还是可读。
- 上层用户空间(通过epoll
_
wait)从链表中取出所有fd,然后对其进行读写数据。
NettyTransceiver初始化
当RestClient刚完成初始化时,NettyTransceiver也刚完成初始化,其初始化主要包含下面两部分:
- 初始化 ChannelPools,刚初始化的ChannelPools为空,其内部不含有任何ChannelPool。
- 初始化EpoolEventLoopGroup,EpoolEventLoopGroup包含多个EpoolEventLoop。每个EpoolEventLoop都包含下面这三个部分:
- executor:真正执行任务的线程。
- taskQueue:任务队列,用户要执行的任务将被加入到该队列中,然后再被executor执行。
- epollFD:epoll的文件描述符,在EpoolEventLoop创建时,调用epoll_create来创建一个epoll的共享空间,其对应的文件描述符就是epollFD。
NettyTransceiver发送请求
当第一次发送请求时:NettyTransceiver将会为该Server host创建一个ChannelPool(如上图中的ChannelPool1),并缓存到channelPools中(默认10分钟内该Server host没有请求则视为缓存过期,其对应的ChannelPool将被从channelPools中删除)。ChannelPool在初始化时,主要包含下面两部分:
- 初始化channelDeque,用于缓存channel,获取channel就是从channelDeque中拿出一个channel。
- 在Event
LoopGroup中选定一个Event
Loop作为executor,该executor用来执行获取连接等操作。之所以ChannelPool需要一个固定的executor来执行获取连接等操作,是为了避免出现多个线程同时获取连接的情况,从而不需要对获取连接的操作进行加锁。 ChannelPool初始化完成后,则将由executor从ChannelPool中获取channel,初次获取时,由于ChannelPool中还没有channel,则将初始化第一个channel,channel的初始化步骤主要包含下面几步: - 创建连接,将连接封装为channel。
- 将channel对应的连接通过epoll_ctl add方法加入到EpollEventLoopGroup中的一个EpollEventLoop的epollFD对应的共享空间的红黑树中。
- 将channel放到对应ChannelPool的channelDeque中。 初始化channel完成后,executor则将初始化好的channel返回,由绑定该channel的EpollEventLoop(即初始化channel第二步中所选定的EpollEventLoop)继续执行发送请求数据的任务。
NettyTransceiver接收响应
当服务端发送响应时,操作系统将把epollFD对应的共享空间中红黑树中连接的fd移动到链表中,这时当EpollEventLoop调用epoll_wait命令时,将会获取到准备好的fd,从而获取到准备好的channel,最终通过读取并解码 channel 中的数据来完成response的解析。
线程模型
线程模型的进一步优化
上面的线程模型为我们当前版本的线程模型,也是Netty自带连接池的线程模型。但是这种线程模型的性能一定是最高的吗?
这个问题的答案应该是否定的,因为尽管 ChannelPool 中指定一个 EventLoop 作为 executor 来执行获取 Channel 的操作可以使得 获取Channel 的过程无多线程争抢,但是却引入了下面这两个问题:
- 获取Channel到Channel.write()之间大概率会进行一次EventLoop 切换 (有可能会将 获取Channel 与 Channel.write() 分配到同一个EventLoop ,如果分配到同一个EventLoop,则不需要进行EventLoop 切换 ,所以这里说大概率会切换),这次切换是有一定的性能成本的。
- EventLoopGroup中的 EventLoop任务分配不均匀。因为channelPool中获取连接的那个 EventLoop在获取连接的同时还要处理数据的收发,比其他EventLoop多做一些工作,该EventLoop也成为了性能瓶颈点。在我们实际测试当中,也的确发现有一个EventLoop的线程CPU利用率较其它EventLoop更高一些。 那么更优越的线程模型是怎样的呢?通过上面的分析,我们觉得它应该要满足下面两点:
- 获取Channel 到 Channel.write() 之间无线程切换。
- 每个EventLoop的任务分配均匀。 基于我们的需求,我们可以得出最佳的结构模型与线程模型应该为下面这种:
优化后的结构模型:
如上图所示:一个 ChannelPool 由多个 ChildChannelPool 构成(个数 = IO线程个数),一个ChildChannelPool与一个 EventLoopGroup绑定,该EventLoopGroup仅含有一个 EventLoop (即一个ChildChannelPool对应一个EventLoop)。
优化后的线程模型:
如上图所示:先在业务线程中执行一些操作并获取 ChannelPool ,及选取一个 ChildChannelPool (选取的实现类似于 EventLoopGroup.next()实现,其保证了ChildChannelPool 的均匀分配),然后通过 ChildChannelPool来获取 Channel (该过程在ChildChannelPool 对应的 EventLoop中执行),然后调用Channel.write() (该过程也在ChildChannelPool 对应的 EventLoop 中执行) 。
上述过程巧妙的达成了我们一开始所需要的高性能线程模型的两点:
- 获取Channel 到 Channel.write() 之间无线程切换 —— 由于ChildChannelPool 中的EventLoopGroup 仅有一个EventLoop ,其创建的Channel 也只能绑定该EventLoop ,因此获取Channel 与Channel.write()都只能在该EventLoop 种执行,从而没有了线程切换。
- 每 个 EventLoop任务分配均匀 —— 由于ChildChannelPool 是被均匀地从 ChannelPool 中获取的(该过程与EventLoopGroup.next() 的过程类似),而一个ChildChannelPool 刚好对应了一个EventLoop ,从而使得请求任务被均匀分配。 实践中我们也通过一个Demo进行了测试:发现采用上面这种线程模型与结构模型,使得RestClient的性能在当前版本的基础上又提升了20%左右。预计下个版本中RestClient将会提供上面这种线程模型与结构模型。
Restlight 性能优化
Restlight 简介
ESA Restlight是基于Netty实现的一个面向云原生的高性能,轻量级的Web开发框架。
性能优化记录
通过wrk发现新版1.0.0性能低下
0.1.1:
1.0.0:
使用Profiler 方法调用火焰图分析
发现 dowrite 与 doEnd 方法占用了过多的CPU,且相比0.1.1版本,1.0.0版本多调用了一次 doEnd 方法。
0.1.1:
1.0.0:
通过调用栈定位到问题代码
使用wireshark抓包发现,这样写会导致一个响应,却发送了两个TCP包。
优化代码
优化后的性能
性能提升90%左右。
更深层的问题
为什么将两个write 系统调用 合并成一个,tps几乎提升了 90%,看起来所有的资源消耗貌似都在了 write
这个系统调用上,按理来说 write 和 recv 应该差不多平分CPU,但是为什么我们最终的测试结果是这样呢?
要回答这个问题,我们需要看一下网络包发包和收包,操作系统做了什么事情?
- 接收数据时,大多的操作都在 软中断 中进行,即在 软中断内核线程 中执行,这部分 不算到容器中,更不算到进程中。
- 发送数据时,大多数操作都是在进程中运行,这部分消耗的CPU会算到进程中,即也会算到容器中。
文章来源:https://www.toymoban.com/news/detail-434655.html
在物理机上,软中断实际上是以内核线程的方式运行的,每个 CPU 都对应一个软中断内核线程,这个软中断
内核线程就叫做 ksoftirad/CPU 编号。这个软中断的CPU 消耗并不计算到容器的CPU消耗中去,所以测试时
在容器中看到的现象是,仅这个应用就占了400%的CPU,旦软中断CPU消耗为0。(这个现象在物理机中是不可
能的,因为物理机中还有软中断的线程会消耗CPU,这也是为什么在容器中测试到的TPS 要比在物理机上测
试的TPS还要高,因为容器中没有将软中断的消耗计入CPU消耗中)。
文章来源地址https://www.toymoban.com/news/detail-434655.html
到了这里,关于网络通讯组件性能优化之路的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!