• java特种兵读书笔记(4-3)——java通信之IO与通信调度方式


    什么时候用异步


    如果一个动作需要相对较长的时间等待,即大部分时间消耗在这里,那么可以利用这段时间做点别的事情。

    如果这个动作时间很短,没有必要用这段时间做别的事情了。因为我们做另一件事和回来再做刚才正在做的事情的时候,在行为和思维上都需要存档(比如写代码被人打断商量事情,回来之后忘了写到哪里了,得想一会儿)。

    计算机也是这样,线程切换需要保存上下文,这些开销也很大,所以不要盲目认为异步就很强大。

    异步并非完全友好,因为出现状况了不会立即知道(比如用洗衣机洗衣服,异步操作,可以去看个电视打个游戏,这时候如果洗衣机漏水了,或者出现其他状况停止运行了,不会立即知道,可能导致最后花费的时间更长)。

    阻塞与非阻塞


    阻塞是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会不断从内核拿到事件列表,如果有准备好的事件,就放入线程池队列中。

  • 相关阅读:
    buuctf-misc-[BSidesSF2019]zippy1
    SQL之join的简单用法
    Java二叉搜索树
    在线流程图和思维导图开发技术详解(三)
    2022精选最新金融银行面试真题——附带答案
    C#中抽象方法与虚方法的区别详解及示例
    NX二次开发-ufusr和ufsta等用户入口使用说明
    51单片机定时器基础知识
    图像语义分割 FCN图像分割网络详解
    [李宏毅老师深度学习视频] BERT介绍
  • 原文地址:https://blog.csdn.net/xocupid/article/details/125601850