前言
本来今天想去当当买几本杂书读读的,精挑细选后发现购物车已经有500+的书了,瞬间扑灭了我买书的热情,贵还不说,关键是一想起上次搬家时扔了一大堆书之后,莫名的开始心痛,觉得买的这些书最终可能都难逃被我处决的厄运。最终我还是选择了微信读书,本着白嫖的原则,这里就贴上一个自己的推广链接哈哈。
我就废话少说开始今天的正文吧。8点多到家的时候就开始读这本《高并发实战》读了两章,总体感觉这本书前面的基础知识讲的还挺透彻的,对于我这种没有高并发实战经验的小白来说打好这种底层原理的一些基础知识的根基还是挺重要的,所以我打算把这本书读完,顺便写一下笔记,记录一些重点的知识,也方便以后阅读查看。
IO读写的基础原理
大家知道操作系统中,用户程序IO读写依赖的是底层的IO读写也就是read&write两大系统调用。当然我们都知道,read系统调用并不是直接从物理设备把数据读入到内存,而write系统调用也不是直接把数据写入到内存中,他们都会涉及到一个东西叫缓冲区。
换句话说就是调用操作系统的read,是把数据从内核缓冲区复制到进程缓冲区;调用操作系统的write是把数据从进程缓冲区复制到内核缓冲区。
结论:IO操作不是物理设备级别的读写,而是缓存的复制。read&write,都不负责数据在内核缓冲区和物理设备之间的交换,底层的读写由操作系统的内核完成。
内核缓冲区和进程缓冲区
缓冲区的目的是为了减少频繁的和设备进行物理交换。外部设备的直接读写涉及到操作系统的中断(什么是中断?系统中断需要保存之前的进程数据和状态等信息,中断结束之后还需要恢复之前的进程数据和状态信息),为了减少这种底层的消耗,就出现了缓冲区。
所以有了内存缓冲区,上层在使用read系统调用时,就把数据从内核缓冲区复制到上层应用缓冲区也就是进程缓冲区;使用write系统调用时,就只需要把数据从进程缓冲区复制到内核缓冲区中。而底层有专门负责内核缓冲监控的部门,当缓冲区的内容到达一定的数量后,在执行IO设备的中断处理,集中执行操作,这样就提升了性能。当然什么时候执行中断是操作系统负责的用户不用关心。
典型的系统调用流程
以read系统调用为例,有两个阶段:
- 等待数据准备完成
- 从内核向进程复制数据
如果read是一个socket,那么流程如下
- 第一个阶段:等待数据从网络到达网卡。当等待的分组到达时,会被复制到内核中的某个缓冲区。这是操作系统自己完成的。
- 第二个阶段:把数据从缓冲区内核复制到应用进程缓冲区。
如果是在JAVA服务器端完成一次socket那么流程如下
- 客户端请求,Linux通过网卡读取客户端的请求数据,将数据放入内核缓冲区。
- 获取请求数据,Java服务器通过read系统调用,从Linux内核缓冲区中读取数据再送入Java进程缓冲区。
- 服务端业务处理:java服务器在自己的用户空间中处理客户端请求。
- 服务端返回数据:Java服务器完成处理后,构建好的数据从用户缓冲区,写入内核缓冲区。这里就是使用write系统调用。
- 发生给客户端:Linux内核通过网络IO,将内核数据写入网卡,网卡通过底层的通讯协议发送给目标客户端。
四种主要的IO模型
同步阻塞IO(Blocking IO)
阻塞IO:内核IO操作完成后,才返回到用户,执行用户的操作。阻塞就是用户程序的执行状态。传统的IO模型都是同步阻塞IO模型,当然java默认创建的socket都是阻塞的。
同步IO:是一种用户空间与内核空间IO的发起方式。同步IO就是用户空间的线程是主动发起IO请求的一方,内核是被动接收方;异步IO则是反过来。
优点:开发简单,在阻塞等待数据期间,用户线程挂起。阻塞期间用户线程基本不会占用CPU。
缺点:一般情况每个连接都会是一个单独的线程;也就是一个线程维护一个IO连接。再小并发的场景下没什么问题,但是高并发需要大量的线程维护IO连接,内存消耗巨大。所以IO阻塞模型高并发场景不可用。
同步非阻塞IO(Non-blocking IO)
非阻塞IO:用户不需要等待内核IO操作完成就可以返回用户空间执行操作,就是非阻塞状态,此时内核会立刻返回给用户状态值。
通俗的将,阻塞就是用户一直等待,不能干别的。非阻塞就是用户拿到内核状态值就回到自己的空间,IO可以干就干,不可以干就去干别的事。
值得一提的是这里的NIO并非Java中的NIO。java的NIO对应的是IO多路复用。
NIO模型开始IO系统调用有两种情况
- 内核缓冲区中没有数据,系统调用立即返回调用失败。
- 内存缓冲区中有数据,是阻塞的,知道数据从内核到进程缓冲,复制完成,系统调用返回成功,应用进程开始处理用户空间缓村数据。
特点:应用程序线程需要不断的进行IO系统调用,轮询是否准备好,如果没准备好继续轮询,直到完成IO系统调用。
同步非阻塞优点:每次发起IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会被阻塞,实时性较好。
同步非阻塞缺点:不断的轮询,占用CPU,效率低。
总体来说高并发场景下同步非阻塞IO也是不可用的。
IO多路复用(IO Multiplexing)
也就是经典的Reactor反应器设计模式。它能够避免同步非阻塞IO模型不停轮询的问题。
引入了新的系统调用,select/epoll他的作用就是查询IO的就绪状态。
通过select/epoll,一个进程可以监视多个文件描述符,一旦某个描述符就绪(内核缓冲区可读/可写)内核就能将状态返回给应用程序。随后应用程序根据状态,进行相应的IO系统调用。
流程如下:
- 选择注册器。首先将要Read操作的目标socket网络连接,提前注册到select/epoll选择器中,java中对应选择器类是Selector类,然后就可以开启整个IO多路复用模型的轮询流程。
- 就绪状态的轮询。查询注册过的所有socket连接的就绪状态。准备好了就将这个socket加入就绪列表中。(用户调用select查询就进入阻塞)
- 用户获取就绪状态的列表后,根据socket发起read系统调用,用户线程阻塞,内核复制数据到用户缓冲区。
- 复制完成返回数据,用户解除阻塞,开始执行。
特点:涉及两种系统调用一种是IO操作,另一种是就绪查询(select),他和NIO模型相似,也要轮询。
优点:一个选择器查询线程可以同时处理成千上万个连接。系统不必创建大量线程,也不用维护。
Java的NIO就是IO多路复用模型。linux系统上使用epoll系统调用。
缺点:本质上select/epoll系统调用是阻塞式的,属于同步IO。都需要在读写事件就绪后,由系统调用本身辅助,也就是这个读写过程是阻塞的。
怎么彻底的解除线程的阻塞,就需要使用异步IO模型。
异步IO(Asynchronous IO)
异步IO指的是内核空间成了主动调用者,用户线程变成被动接收者。
AIO的流程:用户通过系统调用,向内核注册某个IO操作。内核在整个IO操作(数据准备、数据恢复)完成后,通知用户程序,用户程序执行后续操作。
异步IO模型中,内核处理数据过程中用户进程是不需要阻塞的。
举个例子:
- 当用户线程发起了read系统调用,立刻就可以开始去做其他的事,用户线程不阻塞。
- 内核就开始了IO的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存)
- 内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
- 用户线程读取用户缓冲区的数据,完成后续的业务操作。
特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的IO操作完成的事件。
缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。就目前而言,Windows系统下通过IOCP实现了真正的异步IO。而在Linux系统下,异步IO模型在2.6版本才引入,目前并不完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显的优势。大多数的高并发服务器端的程序,一般都是基于Linux系统的。因而,目前这类高并发网络应用程序的开发,大多采用IO多路复用模型。大名鼎鼎的Netty框架,使用的就是IO多路复用模型,而不是异步IO模型。
结束语
读到这里可能你会觉的我这篇文章有些繁琐,或许有些概念笔记我可能真的没有做到筛检到极致简略。
我想说的是,这篇文章的原则就是希望从最基础的开始分析,不单单是对几个知识点做个解释。当然在书中笔者也强调了,这一章是理论知识比较繁琐,但是一定要弄懂。
后面的学习笔记基本上我也是跟着作者学习,等我学完这本书,对整体的知识有了完整的认知后,也会试着用自己的语言去总结概括这些枯燥的知识点。
最后如果文章你发现了文章问题记得联系我,当然如果你有更好的文章也可以发给我,互相学习。