IO回忆录之怎样过目不忘(BIO/NIO/AIO/Netty)2017版-趣谈IO多路复用的本质
有热心的网友加我微信,时不时问我一些技术的或者学习技术的问题。有时候我回微信的时候都是半夜了。但是我很乐意解答他们的问题。因为这些年轻人都是很有上进心的,所以在我心里他们就是很优秀的,我愿意多和努力的人交朋友。我原来拿老公高中时复读过一年来开过玩笑。他却很平和而骄傲的回复说:“我是为了等你。” 眼里有一种赚翻了的表情。虽然我很感激我婆婆给了个好老公,但是生气的一点是婆婆从小说我老公脑子笨。我总跟老公说:“就是因为妈从小这么说你,你才从原本应该是天之骄子沦为一个苦逼程序员的。”但是毕业十年,他一直很努力,也很顾家。所以在我眼里他很优秀。有网友问我是怎样成长为技术大牛的。我回复说:“额~,你可不可以不问这么笼统的问题,都不知道怎么回答你。”过了几天他又问我:“你有没有看过的东西当时理解了然后又忘了。”我当时只是告诉他忘了就多看几遍,反复的看。但是他问的这个问题我一直都没有忘,我一直在想自己是怎么做的。因为通过第一个问题到第二个问题,可以看到他是很努力的在思考我说的话,对于认真的人,咱们也得认真。
本文有两条主线:一条是学习方法,怎样学过就能记住。另一条是实际项目中的IO处理问题,包括BIO,NIO,AIO,netty。旨在用学习具体知识的具体流程体现学习方法的形成过程。
对于技术的东西,我是这么过目不忘的(上篇文章已经提过,但是LZ觉得有必要强化一下,另外还加了一点):
☆ 积极的做项目
☆ 做过的东西反复琢磨
☆ 看完一本书后要再次梳理项目
☆ 手别懒,看到例子没一眼看明白就要动手实践
☆ 记录项目中潜在的问题每隔一段时间回来想一想
☆ 工匠精神:代码当成艺术品反复优化
☆ 沉住气把项目做深
☆ 好书要每隔一段时间就再看一遍
我在好几篇博客中都提到一个离线数据的小项目。因为这个项目我只有我一个人开发维护,所以我可以随时将可以优化并且放到线上,很有成就感。我做的是视频和音频部分,音频和音频的专辑是另一个小伙子做的,他当初把我的代码拷贝过去,因为数据量比我这边小很多,一直也没出什么问题,直到有一天他拿着代码来问我一个问题,我很无奈的说:“你可不可以拷贝一个新一点儿的版本,我都改的完全不是这个样子了。”
离线数据里有一个万一实时消息发送有问题,手动补发消息的接口,我直接用socket同步阻塞的方式(BIO)接收浏览器作为客户端,一直也没出什么问题,但是做过的东西要反复琢磨,所以我又分别使用了NIO,AIO,netty进行了尝试性改造。出于信息安全的考虑,本文不附带源码,简单例子可参考<实战Java高并发程序设计>第五章和<netty in action>。
由于这个手动服务一次也就是一两个人调用,比较改造后的性能,顶多看看浏览器里的请求响应时间,误差很大。对于这个最简单的RPC请求没什么可说的,最终都实现了,但是使用BIO,NIO,AIO由于返回响应我直接write到socket或者buffer里,没用什么工具,结果显示有浏览器兼容问题。netty由于内置了http协议的实现,不存在此问题。这里只比较原理的区别。
对于一个服务器端业务的开发者来说,如果这四者选,肯定要选netty。netty的jar包1MB多点,要嵌入手机啥的就算了,这是通信用的,放在客户端基本也没啥应用场景。
☆ netty使用简单,预置多种编解码器,支持多种主流协议。就像刚才说的,直接使用java api,我需要自己将read到的GET xxxx HTTP1.1 XXXXX这些数据自己解析出感兴趣的数据,返回客户端还要自己封装一个浏览器可读的响应。
☆ netty可定制,通过channelhander对通信框架进行灵活扩展。
☆ netty性能好,社区活跃,版本迭代周期短。
首先简单提一下概念。
BIO:同步阻塞式IO,服务器端与客户端三次握手简历连接后一个链路建立一个线程进行面向流的通信。这曾是jdk1.4前的唯一选择。在任何一端出现网络性能问题时都影响另一端,无法满足高并发高性能的需求。
NIO:同步非阻塞IO,以块的方式处理数据。采用多路复用Reactor模式。JDK1.4时引入。
AIO:异步非阻塞IO,基于unix事件驱动,不需要多路复用器对注册通道进行轮询,采用Proactor设计模式。JDK1.7时引入。
Netty是实现了NIO的一个流行框架,JBoss的。Apache的同类产品叫Mina。阿里云分布式文件系统TFS里用的就是Mina。我目前的项目中使用了netty作为底层实现的有阿里云的dubbo和ElasticSearch。我之所有要研究这个东西也是因为我要实现自己的搜索引擎先要调研已存在的产品。我之前项目中用过Solr,个人比较倾向于Solr。但是大家做了很多性能比较,ElasticSearch的并发能力确实要比Solr,究其原因,就是因为Solr底层还是用的Servlet容器,而ElasticSearch底层用的是Netty。
有人问:单看实现原理,显然AIO要比NIO高级,为什么Netty底层用NIO? Netty也曾经做过一些AIO的尝试性版本,但是性能效果不理想。AIO理念很好,但是有赖于底层操作系统的支持,操作系统目前的实现并没听上去那么有吸引力,是一匹刚孕育出来的黑马。其实AIO的性能上不去,也很好感性的理解。NIO相当于餐做好了自己去取,AIO相当于送餐上门。要吃饭的人是百万,千万级的,送餐员也就几百人。所以一般要吃到饭,是自己去取快呢,还是等着送的更快?目前的外卖流程不是很完善,所以时间上没想的那么靠谱,但是有优化空间,这就是AIO。
关于NIO的内部实现,大家可以参考我写的IO和socket编程。里面没有提到操作系统方面对于多路复用技术的三种常用机制:select,poll和epoll。三个的作用都是指示内核等待多个事件中的任何一个发生的时候或一定时间滞后被唤醒。区别是select函数文件描述符的数量有限制,poll函数没限制,epoll函数用一个描述符来管理多个描述符。
关于文件描述符,也就是句柄,想起一件事:好几年前用Solr做高并发压测的时候,我用了一台内存所剩很小的服务器做测试,出现了NIO超出句柄数错误。而查看了系统的最大句柄数设置,设置的很大,不可能用完。换了一台好点的服务器就没了这个问题。那是因为内存不够的时候,每次都要打开磁盘文件的数据进行搜索,而内存大点儿都走内存中的缓存了。内存数据也有句柄,但是访问磁盘速度慢,资源长时间不释放,新的请求又过来了,才是句柄超出的原因。
关于epoll,不得不提臭名昭著的epoll bug。它会导致Selector空轮询,最终导致CPU 100%。
关于多路复用,多唠叨两句。多路指的是多个网络连接,复用是复用同一个线程。目前解决各种并发问题的最大利器就是缓存。我用过Memecached和Redis。Memcached采用的是多线程,非阻塞IO复用的网络模型。Redis采用的是单线程的IO复用模型。既然单线程会严重影响整体吞吐量,因为CPU计算过程中,整个IO调用都是被阻塞的。那么为什么实际上大家普遍表示Redis的性能要优于Memcached?多线程模型可以发挥多核作用,但是引入了缓存一致性和锁的问题,带来了性能损耗。单线程可以将速度优势发挥到最大。想用多核,可以多开几个进程的嘛。话说nginx也是这样多进程单线程的模型。Netty可以定制,可以单线程IO复用,多线程IO复用,主从多线程IO复用。
Netty逻辑架构(从底层向上介绍)
1> Reactor通信调度层,由一系列辅助类组成,包括Reactor线程NioEventLoop以及其父类,NioSocketChannel以及父类,ByteBuffer及衍生Buffer,Unsafe及内部子类。
2> 职责链ChannelPipeLine,它负责调度事件在职责链中的传播,支持动态的编排职责链,职责链可以选择性的拦截自己关心的事件,对其它IO操作和事件忽略。
3> 业务逻辑编排层。
Netty实现上优势点:
☆ 零拷贝,又叫内存零拷贝,指CPU不需要为数据在内存之间的拷贝消耗资源。通常指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间而直接在内核空间中传输到网络的方式。
Netty的ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果大家自己动手写过NIO和AIO的程序,就会知道我们接触的网络传输数据是直接和ByteBuffer打交道的。在JAVA的API中,一个ByteBuffer读完数据后要flip一下,将当前操作位置设置为0.<Netty In Action>里详细介绍了Netty的ByteBuf怎样将这个缓冲区分成一段一段的,还可以压缩,将读的数据滑向一侧。而堆外内存的零拷贝,如果有JVM基础也很好理解。Java内存模型里提到每个线程都有自己的高速工作内存空间,而不是直接访问主内存。想不用工作内存,直接主内存可见,就要用volatile关键字修饰。所以堆内内存,走JVM就存在这个拷贝开销。
Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,而不用将几个小Buffer通过内存拷贝合并成一个大的Buffer.
Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。其实这个功能不是Netty特有的,Linux的sendfile函数和Java NIO的FileChannel的transferTo方法都实现了零拷贝,而Netty也通过FileRegion包装了NIO的transferTo方法。
☆ 内存池,刚才提到堆外内存可以实现零拷贝,那为啥java要设置自己的工作内存呢?快啊。堆外直接内存的分配和回收是一个非常 耗时的操作,有C或者C++基础的应该都能理解。刚才提到的ByteBuf就是采用内存池的,用来对缓存区复用。
基本一个比较底层的框架都会有自己特有的内存池技术。比如Memcached使用预分配的内存池方式,使用slab和带下不同的Chunk来管理内存。这样避免了上面提到的内存分配和回收的开销问题。
☆ 无锁化的串行设计。我在自己的博客中提到自己做项目会把业务划分的很清楚,尽量少的采用线程间通信。这就是一种无锁化的串行设计思想。共享资源的并发访问处理不当,会带来严重的锁竞争,最终会导致性能下降。
☆ 高效的并发编程。有些人在面试时很吃亏(老公,当你看到这篇文章的时候,说的就是你)。CPU直接操作磁盘型的。没有内存,很多东西临场发挥一时加载不上来。说是吃亏究其原因是总结的少,没建立好强大的索引。而好的程序员是体现在细节中的,为什么一说原理怎么看怎么觉得Memcached比Redis先进。而Mina的实现原理上也没有多少劣于Netty的地方。就是因为后者的开发者更为勤奋。比如CAS操作要比加锁性能更好,但是开发维护上要繁琐很多。
不论电影,电视,武侠小说还是实际生活中, 智商高的人最终会被情商高的人打败。精诚所至金石为开是真理。爱一个人会打开心灵的通道,不着一字便可沟通。我在你心底,你就会用我的方式去思考。技术的书,技术的文章,代码后面都隐藏着高人。爱一种技术,多看多想,技术能力和境界都能得到升华。
在《轻松搞懂5种IO模型》中,我发起了一个投票。
答案是【同步IO多路复用】。目前,60%的朋友答对了。原因这里解释一下。
同步和异步的概念区别
同步:线程自己去获取结果。(一个线程)
异步:线程自己不去获取结果,而由其他线程送结果。(至少两个线程)
异步执行如下图所示,除非不需要知道结果,否则一般会有一个回调方法。

IO多路复用的本质
为了彻底理解IO多路复用是同步还是异步,咱们探究一下IO多路复用的本质。
I/O多路复用,复用的IO监听等待这条路。实际上就是用select/poll/epoll监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。
select/poll/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
对于每一个socket,一般都设置成为non-blocking,但是,整个用户的process其实是一直被阻塞的。只不过process是被select这个函数阻塞,而不是被socket IO给阻塞。

I/O多路复用的流程如上图所示:
(1)当用户进程调用了select,那么整个进程会被阻塞;
(2)而同时,内核会“监视”所有select负责的socket;
(3)当任何一个socket中的数据准备好了,select就会返回;
(4)这个时候用户进程再调用read/accept/write操作,做一些数据从内核拷贝到用户进程这样的事情。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
事实上,I/O 多路复用有时候性能比同步阻塞IO还更差一些。因为这里需要使用两个系统调用(select 和 recvfrom),而同步阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接。所以,如果处理的连接数不是很高的话,可能延迟还更大。
总结
打个比方:行军打仗讲究粮草先行。诸葛亮比较牛,他打仗只带少量粮草,其他靠敌军送。这天他又派了暗探去查看敌军粮草的守卫情况。如果敌人守备松懈,则趁机偷粮。如果这个暗探只偷一袋粮食,那效率最高的是不是他看到敌军守备松懈就直接进去偷粮(同步阻塞IO)?但是他要偷的是十万大军的粮食,那他就要先回去汇报一声:“守备松懈啦”。然后百人小分队一起去把粮草偷出来(I/O 多路复用)。当然啦,以诸葛亮的一贯作风而言,最后他还得放一把火。
暗探在同步阻塞模式下,打探敌情也是他,偷粮也是他。在诸葛亮团队中,暗探在打探敌情时最终暗探是第一个获取到结果的。暗探在偷粮时也是第一个自己知道结果的。(同步)
暗探在I/O 多路复用模式下,打探敌情也是他,偷粮是百人小分队。在诸葛亮团队中,暗探在打探敌情时最终执行者暗探是第一个获取到结果的。百人小分队在偷粮时也是百人小分队自己先知道结果的。(同步)
综上,IO多路复用是同步的。
轻松搞懂5种IO模型
同步阻塞IO、同步非阻塞IO、IO多路复用、异步阻塞IO、异步非阻塞IO,这五种IO模型有没有朋友记过多次了,但是总是记不住?那是因为没有理解本质。5年前我记住了,到现在发现记忆和区分仍然很清晰,今天把理解方法介绍给大家。
首先,大家先思考一个问题:
IO操作其实主要为了读和写。本文以读数据做说明。
当程序调用read方法时,会切换到系统内核来完成真正的读取。而读取又分为等待数据和复制数据两个阶段。如下图所示:

同步阻塞IO

如上图所示,用户线程发起一个read请求,会切换到内核空间。这时候如果没有数据过来,则用户线程和对应的内核线程什么都做不了,一直等到有数据进来,并且完成了内核态的数据复制才继续返回用户空间继续执行。这整个过程是阻塞的。
同步非阻塞IO

如上图所示,用户线程发起一个read请求,会切换到内核空间。这时候如果没有数据过来,则直接返回。它可以再次轮循发起read,如果某一次发现有数据过来,则等待完成了内核态的数据复制才继续返回用户空间继续执行。
这个过程中,没有数据时是非阻塞的。有数据时是阻塞的,被称为非阻塞IO。这种方式涉及多次内核切换,某些情况下反而会影响性能。之前业界发生过一个由阻塞切换成非阻塞,流量高峰时性能不足引起的重大故障。
IO多路复用

如上图所示,用户线程发起一个select请求,会切换到内核空间。这时候如果没有数据过来,则阻塞直到有数据时返回给用户线程。用户线程收到有数据的消息,发起read操作同步等待直到完成内核态的数据复制才继续返回用户空间继续执行。
这时候,是不是有朋友冒出来一个问题:似乎看不到多路复用的优势啊。似乎阻塞才是最佳选择啊。
上面都是以最简单的例子来介绍的,下面来看一个复杂一些的阻塞IO。

如上图所示,用户线程发起一个read请求,会切换到内核空间。这时候又有另外一个连接请求过来。这个线程不会立即影响这个连接请求,而是一直等到有数据进来,并且完成了内核态的数据复制才继续返回用户空间继续执行。处理完第一个连接的所有read操作之后,才会响应新的连接。新连接从accept(netty中建立连接的函数)到read都是同步阻塞的,每次只能处理一个连接的事件。

如上图所示,多路复用时,用户程序发起一个select操作,会返回一批事件,有read、write、accept(netty中建立连接的事件)。这时候,该等的时间select操作都已经做了。这时候,用户线程可以用新的线程(worker线程)直接去建立连接、复制数据。
异步非阻塞IO
这里就要明确IO模型中,同步和异步的概念了。
同步:线程自己去获取结果。(一个线程)
异步:线程自己不去获取结果,而由其他线程送结果。(至少两个线程)

如上图所示,异步是通过回调来完成的。用户程序发起read操作只是去通知操作系统我在等待数据。另外一个线程等待数据复制完成回调read方法返回结果。异步IO从实现上是基于操作系统信号驱动的,也叫信号驱动IO。
异步阻塞IO和异步非阻塞IO又有什么区别呢?看上面的过程,异步read操作去通知完操作系统肯定是直接返回的,也就是肯定是非阻塞的。其实根本没有异步阻塞这种说法,纯属误传。
总结
最近发现,为了把一件事情讲清楚,要写的字越来越多。因为写的过程中会引出一些额外层面的问题需要解释。两者没有分离好反而不好理解。
我就想出来现在的办法,先在一篇文章中阐述一件事,同时抛出来一个问题让大家思考。然后另起一篇把问题讲透。就像本周的《HTTP状态码1XX深入理解》和《【答案公布】客户端与服务端通信时,所有的http状态码是否都是服务端返回的?》。自己觉得这种方式更加清晰,大家觉得如何呢?

浙公网安备 33010602011771号