什么时候用异步
如果一个动作需要相对较长的时间等待,即大部分时间消耗在这里,那么可以利用这段时间做点别的事情。
如果这个动作时间很短,没有必要用这段时间做别的事情了。因为我们做另一件事和回来再做刚才正在做的事情的时候,在行为和思维上都需要存档(比如写代码被人打断商量事情,回来之后忘了写到哪里了,得想一会儿)。
计算机也是这样,线程切换需要保存上下文,这些开销也很大,所以不要盲目认为异步就很强大。
异步并非完全友好,因为出现状况了不会立即知道(比如用洗衣机洗衣服,异步操作,可以去看个电视打个游戏,这时候如果洗衣机漏水了,或者出现其他状况停止运行了,不会立即知道,可能导致最后花费的时间更长)。
阻塞与非阻塞
阻塞是blocking,java程序中的线程常处于阻塞状态,但是同步不是这个概念。
同步(不是指多线程的synchronized),指步骤需要一步步完成的,像常规代码一样一行行执行(异步可以在没执行完当前代码时,执行下一段代码)。
相对于阻塞,同步的程序线程是出于running状态的。
线程出于blocking状态基本可以看做是睡眠的,它是被动的,什么都没做,只有一只等待一个信号产生才被唤醒(BIO程序发起read操作时,线程阻塞是由JVM和OS配合完成的,此时java获取线程状态依然是running,但它确实已被阻塞)。
处于running状态下的线程是活跃的,可以做很多事情。
IO与阻塞
服务器上传统IO发起读请求之后会被挂起,但很多系统接收IO返回结果的时间比实际运行时间还要长,甚至长很多,即睡眠时间大于干活时间。这时可以考虑,有些线程是不需要被挂起的,线程是服务器资源,不希望它大部分时间在睡眠,而是希望它干活。
如果线程去干活,IO返回怎么办?可以用一个单独线程来监视IO返回状态(该操作很简单,只是获取状态而已,一个线程足够)。如果发现IO准备好,可以进行操作,会尝试用某种方式处理(将这些请求当做任务交个一个线程池来处理)。
java的nio用SocketChannel代替Socket,它是非阻塞IO,通过selector进行系统调用,每次调用获得事件处理列表,然后进行处理。
不过这样,中间有一个将数据从kernel拷贝到进程的过程是同步的,而且这个动作需要程序自己处理,即时间占用本身的线程时间。
相当于虽然你告诉我货来了,但是我得自己去取货,我们希望送货上门(异步模型)。
select模型
由一个数组管理,每注册一个事件,就需要占用一个数组的位置。
每次系统请求,都会循环遍历整个数组,看看是否有可以处理的事件,没有则睡眠,直到超时或者被事件触发唤醒后重新遍历,性能很差。
数组存储有宽度限制,32位机器是1024,64位是2048。
poll模型
poll模型和select类似,只是把数组换成了链表,这样就没有了宽度限制,即注册的事件数量没有限制。
epoll模型
基于时间回调机制,回调时直接通知线程,无需用某种方式来查看状态(不用轮训查看),通过mmap映射内存实现,不用做内存拷贝。
BIO
最古老的通信方式。应用程序通过System call发请求给kernel,由kernel进行网络通信。如果应用程序发起的系统调用是读操作,在内核准备好数据之前,这个线程将被挂起(即阻塞,blocking),一直等待下去,直到有返回的数据在kernel中准备好,或者设置了timeout,超时被唤醒。
read操作两个阶段:
1.等待IO返回数据,该阶段速度取决于IO请求的目标返回数据的速度(比如网络IO,请求solr,首先是solr查询搜索需要的时间,然后是数据通过网络传输到本机的时间)。此时数据准备好了。
2.准备好的数据首先被填充到内核缓冲区,然后从内核将数据向进程内部拷贝(copy data from kernel to user),即数据->kernel->进程。
NIO
非阻塞IO最大区别是,发起第一次System call请求后,线程没有被阻塞,但是它没有做别的事,而是不断做System call。这样似乎在空耗CPU,还不如一直挂起等待返回,至少会让出CPU资源。
其实不然,因为每次System call只是看看数据有没有准备好,通常该操作时间很短暂,只需要一个线程就可以完成对很多事件的监听。这样其他线程就可以去做别的事情了,只需某个线程定期做检测,设置好检测频率就可以达到高效且节约资源。
NIO的selector
在套接字上提供selector,选择机制,当它发起select时会阻塞,等待至少一个准备好的事件返回,然后通过selectedKeys获取事件列表set,元素是selectKey,与每个具体通道相关。
selectNow():不会阻塞,如果没有任何事件准备好,返回0,该方法不断发起System call,与select相比,它本身就是非阻塞的。但是并不代表这是最好的使用方式。
select(long):而已设置阻塞超时时间,同样,到超时时间依然没有任何准备好的事件,返回0。
weakup():当发生select阻塞后,可以调用该方法唤醒selector,当然是由另一个线程来调用的。
长连接与短连接
长连接
如果一个系统要承载很多相对长的连接的并发请求(例如很多终端要下载文件或者看视频),由于BIO处理过程会发生线程阻塞,所以每一个IO都需要一个线程在等待数据返回。
从线程角度,需要处理完一个请求,再处理下一个请求,而每个请求时间可能都比较长,这时候会有许多新的请求可能得不到响应(系统并发量低)。
短连接
服务器端和客户端需要相应的套接字来监听是否有发来数据,但是监听者不知道什么时候会有数据发送过来,在BIO中等待数据时,线程被挂起了,也就是线程会休眠。
第一种解决办法:给予足够多的线程,因为当线程等待IO的时候是会释放CPU资源的,所以这个时间范围内,设置足够多的线程来达到资源的充分利用。尤其现代计算机内存资源充足,对于大部分短连接请求,这是一种方便简单的选择。
长连接的终端可能很多,它们之间会频繁交互,服务器会给外部开很多的连接。由于这些连接是打开的,在BIO中需要相应的线程来读取相应的请求(因为请求随时可以打过来,所以需要在socket上发起等待事件,但是短连接通常新的请求过来在很短事件内是同一个socket,超过一段时间,会创建一个新的socket来处理)。
NIO的好处与使用场景
系统有①大量时间在做IO,而且这些IO通常需要一个②相对较长时间的连接交互,如果程序设计的不好,线程通常会被无端浪费掉。
NIO是非阻塞的,基于事件注册在Kernel中,只有事件真正触发,才会真正让线程去处理IO的返回值,理论上,线程的利用率提高了。
场景:
①IO密集型,即一个请求中有大量反复的IO操作,尤其是基于长连接的频繁交互。
②应用场景是业务相对简单,或者说是无状态的服务模型。如果业务复杂,会使代码在事件处理过程中保持上下文信息,如果上下文信息十分复杂,会导致代码十分复杂。
NIO的缺陷
①需要一个线程不断去检测,每次检测状态需要将准备好的事件从Kernel拷贝到进程中(Kernel->进程)。
每次注册事件时又需要将其从进程拷贝到Kernel中(进程->Kernel),期间的多次拷贝开销很大。
②NIO在Kernel准备好数据之后,就交给线程来处理了,线程需要用read将数据从Kernel拷贝到程序的ByteBuffer中。虽然内存拷贝时间短,但是我们希望这部分工作交给非进程资源来处理。
③selector不支持FileChannel的非阻塞化。
JDK1.7开始支持AIO,有一些相应变化和改进。
NIO2.0——AIO VS NIO1.0/BIO
NIO1.0最少需要一个线程去获取事件信息(专门线程负责检测),而且有许多拷贝(数据准备好的话从Kernel到进程,注册时间从进程到Kernel)。
如果用快递比喻:
我们需要在知道快递来了之后,自己去取快递(Kernel和进程的互相考别)。现在我们希望送货上门服务。
BIO:需要到物流中转站去等货,且不能离开中转站。货没到,就别想做其他事情。(线程阻塞在这里)
NIO:每天去检测一下货物是否到了。该动作很简单,可以让小区派个人去,或者找个朋友帮忙去。如果有货物就带回来,或者告诉你,让你自己去取。(专门的线程去检测事件,数据是否准备好,准备好了,从Kernel拷贝到进程)
AIO:货到的时候送货上门,即去拿货的路途虽然不长,但是由别人帮你承担了。
AIO相比NIO,某种意义上更加提高了资源利用率,但是仅仅相对于进程而言,对整个服务器还得看具体情况。因为进程不想做的事情,Kernel帮做了。
AIO适用于IO密集型系统。
BIO也并非没有价值,因为它使得交互更加的简单。
AIO
AIO中,IO的处理已经交给Kernel了,线程都只做业务相关的事情,几乎不需要关注IO了,但是写代码会复杂一些。
因为,首先程序无需等待IO返回值(注册事件到Kernel之后不等结果,立即返回)。那么问题来了,如何根据返回值做进一步处理。
①回调:这样需要保存当时的上下文信息,用于结果返回时的回调。需要实现CompletionHandler,然后将这个实体传递给响应的API,就会在事件触发时(即数据准备好时)自动回调。
②Future:Future有一个isDone方法用于判断数据是否准备好,或者用get阻塞获得结果。isDone类似于NIO中利用一个专门线程判断事件是否触发。get类似于BIO的阻塞模式。换句话说,他们虽然是AIO模型,但是貌似没有达到某种目的。所以用回调更加好一些。
AIO举例——传文件
具体写法:
①AsynchronousFileChannel readChannel = AsynchronousFileChannel.open("xxx")//读取文件的管道
②FileChannel writeChannel = new FileOutputStream("xxx").getChannel();//拷贝到本地的文件管道
③ByteBuffer buffer = ByteBuffer.allocate(1024);//分配ByteBuffer空间
④XXXCompletionHandler completion = new XXXCompletionHandler(buffer, readChannel);// 实现CompletionHandler用于回调
⑤readChannel.read(buffer,01,writeChannel,completion);//真正开始读文件,把数据加载到buffer中,并在读完之后回调completion
⑥XXXCompletionHandler的completed(Integer result, FileChannel) //这里FileChannel是writeFileChannel,调用write方法把读取的文件内容写到本地。然后用readChannel继续去读取文件内容,然后下次调用completed的时候继续写文件内容,知道该文件所有内容都读写完毕。
总结:
在AsynchronousFileChannel的read方法中传入一个CompletionHandler的实现,用于回调。
方法:read(ByteBuffer dst,long position,A attachment,CompletionHandler<Integer,? super A> handler);
这里CompletionHandler的completed方法的result必须是Integer的,得到读取的内容长度。read的第三个参数即void completed(V result, A attachment)方法的第二个参数。
我们写一个XXXCompletionHandler的实现,用构造方法将AsynchronousFileChannel传入,AsynchronousFileChannel读文件时把内容传到ByteBuffer中,在completed中将completed的内容再写到本地。
AIO比BIO的优势
使用AIO读取并不代表更快,不能用AIO读取一个大文件和BIO进行比较。
AIO的目的在于在IO的过程中去做别的事情,即在并发时,更少的资源可以做更多的事情(通过非阻塞解放原先阻塞的线程,虽然阻塞的线程不消耗CPU,但是不能再做任何事情了,CPU没充分利用起来),而不是看IO过程中谁跑的更快。而且AIO也做不到让IO更快,要做到这个,只能靠硬件上提高磁盘或者网络本身的速度,这些IO模型只是调度IO的机制而已。
AIO的注册回调和递归
递归调用是在一个线程中完成的,即同一个线程的“栈”一直被占用和延长。
异步IO不是这样的,程序是将事件交给内核与JVM本身去管理,然后业务线程就开始做其他事情了。
唯一沾边的是,上下文信息在注册时,一起带入保存的对象中,以便于回调的时候继续使用。回调的时候会启用其他线程来处理,而非当前线程,所以它不是递归调用,也不会有StackOverFlow。
这里的上下文信息的保存不是由内核来完成的。内核保存的仅仅是在内核调度时,当一个线程阻塞后,脱离CPU调度需要保存的上下文信息,也就是线程的运行栈空间依然保持,这里本身运行的线程会结束,私有栈空间已经被释放,回调时的处理线程通常是随机的。
其实这个开销也是不小的,所以没有必要测试AIO和BIO的速度。
程序中我们需要自己保存的上下文信息,比如context中会保存dubbo请求的requestId,出发到达,这些是我们需要自己保存的,比如用本地LoadingCache保存。
回调的处理
回调时,如果没有任何的线程池,自然会生成一个新的线程来处理。
如果想要使用线程池,可以使用AsynchronousFileChannel的带ExecutorService,写入这个参数后,会通过这个线程池来调度回调操作任务(线程池通常在频繁调度中为了控制资源平衡而存在)。
AIO的实现
可以通过epoll实现。事件通知基于Kernel所提供的IO机制来完成,而JVM的职责是将“送货上门”的任务按照指定代码来执行,通常java在同一个端口上只会绑定一个进程,因此epoll的回调也不会导致惊群问题。
不用epoll也可以变相实现,既然有JVM,自然就一层管理平台,任何一层管理都是抽象出来的模型,加入内核中只有类似epoll的功能,那么JVM可以承担起任务分派的职责。简而言之,JVM会不断从内核拿到事件列表,如果有准备好的事件,就放入线程池队列中。