一、同步和异步、阻塞和非阻塞
1.概念
1)同步。在发出一个功能调用时,在没有得到结果之前,该调用就不返回。
2)异步。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。 同步存在等待,异步执行不存在等待。A叫B去吃饭吧,A等着和B一起去,就是同步;A说完我们去吃饭吧,自己先走了,这就是异步;
3)阻塞。指调用结果返回之前,当前线程会被挂起。该任务线程生命周期状态不为Runnable。
4)非阻塞。指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。任务的发起线程仍然在运行。一般通过轮询的方式检查。
5)总结。同步和异步指的是线程执行任务的方式;阻塞非阻塞指的是任务的发起线程是否仍然在运行。
2.同步和异步的优缺点
1)同步代码简单、直观,但是往往意味着阻塞,限制了系统的吞吐率。
2)异步有利于提高系统吞吐率,但是需要更为复杂的代码和更多的资源投入。
二、Java 的 I/O 类库的基本架构
Java 的 I/O 操作类在包 java.io 下,大概有将近 80 个类,但是这些类大概可以分成四组,分别是:
1.根据传输数据的数据格式
1)基于字节操作的 I/O 接口:InputStream 和 OutputStream
2)基于字符操作的 I/O 接口:Writer 和 Reader
2.根据传输数据的方式
1)基于磁盘操作的 I/O 接口:File
2)基于网络操作的 I/O 接口:Socket
三、磁盘 I/O 工作机制
1.通过文件操作磁盘上数据
数据在磁盘的唯一最小描述就是文件,应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的一个最小单元。
Java 中的 File 并不代表一个真实存在的文件对象,当指定一个路径描述符时,它会返回一个代表这个路径相关联的一个虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。FileInputStream 类都是操作一个文件的接口,在创建一个 FileInputStream 对象时,会创建一个 FileDescriptor 对象,这个对象真正代表一个存在的文件对象的描述,通过 getFD() 方法获取与底层操作系统关联的fd。FileDescriptor.sync() 将操作系统缓存中的数据强制刷新到物理磁盘中。
四、Java Socket 的工作机制
Socket 描述计算机之间完成相互通信一种抽象功能。下图是典型的基于 Socket 的通信的场景:
1.通过socket进行通信
主机 A 的应用程序要和主机 B 的应用程序通信,必须通过 Socket 建立连接:1)建立 Socket 连接需要利用底层TCP/IP 协议通过TCP或UDP的端口号来指定通信的应用程序。2)建立 TCP 连接需要利用 底层IP 协议根据 IP 地址来找到目标主机;3)通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路。
2.建立socket通信链路并通过socket传输数据
1)客户端要与服务端通信,客户端先创建一个 Socket实例,操作系统为 Socket 分配一个本地端口号,并创建一个包含本地和远程地址与端口号的套接字数据结构;创建 Socket 实例需要进行TCP的三次握手,完成后,Socket 实例对象才创建完成,否则将抛出 IOException 错误。
2)服务端将创建一个 ServerSocket ,创建ServerSocket要指定一个没有被占用的端口号,操作系统为 ServerSocket 创建一个包含指定监听端口和地址的通配符的底层数据结构;当调用 accept() 方法时,进入阻塞状态,等待客户端的请求。当一个新的请求到来时,为这个连接创建一个新的包含请求源地址和端口的套接字数据结构,等到与客户端的三次握手完成后,服务端的Socket实例才完成创建并返回。ServerSocket所关联的列表中每个数据结构,都代表与一个客户端的建立的 TCP 连接。
3)通过Socket传输数据。当连接建立成功,服务端和客户端都会拥有一个 Socket,每个Socket通过InputStream和OutputStream来交换数据。网络 I/O 都是以字节流传输的。
当Socket对象创建时,操作系统会为InputStream和OutputStream分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到OutputStream对应的SendQ队列中,当队列填满时,数据将被发送到接收端InputStream的RecvQ队列中,如果这时RecvQ已经满了,那么OutputStream的write方法将会阻塞直到RecvQ队列有足够的空间容纳SendQ发送的数据。缓存区的大小以及写入端和读取端的速度非常影响这个连接的数据传输效率。
五、linux操作系统五种IO模型
Linux的内核对一个文件的读写操作会返回一个file descriptor(fd,文件描述符)。 对一个socket的读写返回socketfd(socket描述符),fd指向内核中包含文件路径、数据区等属性的一个结构体。
1.阻塞I/O模型
1)阻塞I/O模型机制:所有文件操作都是阻塞的。以socket为例:应用进程调用recvfrom函数接收socket消息,直到数据包到达且被复制到应用进程的缓冲区中或者发生错误才返回,应用进程从调用recvfrom开始到返回的整段时间内都是被阻塞的。
2)recv、recvfrom、recvmsg函数:linux内核函数。1.用来接收socket消息;2.recvfrom和recvmsg可以用在已经建立连接或没有建立连接的socket;3.如果个没有消息可读这三个函数会阻塞直到有数据;或者设置为非阻塞,返回-1,同时设置errno为EWOULDBLOCK。
2.非阻塞I/O模型
1)非阻塞I/O模型机制:文件操作是非阻塞的。以socket为例:应用进程中调用recvfrom函数接收socket消息,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,非阻塞I/O模型一般通过轮询检查这个状态,当检查到数据包到达后再复制到应用进程的缓冲区中。
3.I/O复用(事件驱动)模型
一个时刻处理多个文件的读写操作(fd),在linux中,select、poll、epoll都是IO复用机制。
1)select机制的基本思路:1.注册socket到FD_SET集合。2.select()轮循检测是否有socket就绪。3.循环检查所有注册的socket是否select()返回就绪。select本质上是通过设置或者检查存放fd标志位的数据集合来进行下一步处理。缺点:1. I/O线程需要不断的轮询套接字集合状态,浪费大量CPU资源。2. 不适合管理大量客户端连接。可监视的fd数量有限,32位机默认是1024个,64位机默认是20483. 性能比较低下,要进行大量查找和拷贝存放大量fd的数据结构。
2)poll机制基本思路:1.设置包含了fd,关注的事件,发生的事件的pollfd结构。2.poll轮循检测就绪Socket。3.循环所有的pollfd结构的发生的事件检查是否发生事件。优点:对select对比摆脱了FD_SET.SIZE大小的限制,基于链表存储的;缺点:与select基本一样。poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
3)epoll机制基本思路:1.epoll把用户关心的fd上的事件放在内核里的一个事件表中,无须向select和poll那样每次调用都要重复传入fd集合事件集。2.epoll使用一组函数来完成:epoll_create创建fd、epoll_ctl操作内核事件表、epoll_wait在一段时间内等待一组fd上的事件,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。3.采用回调的方式,一旦有注册的fd就绪,将触发回调函数,该回调函数将就绪的fd和事件拷贝到用户空间。
4.信号驱动I/O模型
1)信号驱动I/O模型思路:首先开启socket信号驱动I/O功能,并通过系统调用sigaction执行一个非阻塞的信号处理函数。当数据准备就绪时,就为该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom来读取数据。
2)sinaction函数:改变进程接收到指定信号的行动。参数:signum要操作的信号、act要设置的对信号的新处理方式、oldact原来对信号的处理方式。
5.异步I/O
1)异步I/O思路:应用进程告知内核启动某个操作,并让内核在包括将数据从内核复制到应用进程的缓冲区的整个操作完成后通知应用进程。
2)与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已经完成。
六、windows操作系统IO模型
windows提供了一些I/O 模型帮助应用程序以同步或者异步方式在一个或者多个套接字上管理I/O。大体上,这样的I/O 模型共有6 种。
1.阻塞模型
2.选择(select)模型(非阻塞)
1)选择模型思路:通过一个fd_set集合管理套接字,轮询fd_set集合,在满足套接字需求后,通知套接字。让套接字进行工作。
2)缺点:1. I/O线程需要不断的轮询套接字集合状态,浪费大量CPU资源。2. 不适合管理大量客户端连接。3. 性能比较低下,要进行大量查找和拷贝。
3.WSAAsyncSelect 模型(非阻塞)
1)AsyncSelect非阻塞模型思路:WSAAsyncSelect函数会自动将Socket设置为非阻塞模式,并且把发生在该Socket上且关注的事件,以Windows消息的形式发送到指定的窗口,然后在传统的消息处理函数中处理这些事件。
2) 缺点:1. 网络事件以消息的形式进行通知,性能相对比较低下。2. 不适合管理大量客户端连接。
4.WSAEventSelect 模型(非阻塞)
类似WSAAsynSelect模型,但最主要的区别是网络事件发生时会被发送到一个事件对象句柄,而不是发送到一个窗口。
1)EventSelect 非阻塞模型使用步骤如下:
1. 创建事件对象来接收网络事件,它具有两种工作状态:已传信(signaled)和未传信(nonsignaled)以及两种工作模式:人工重设(manual reset)和自动重设(auto reset)。默认为未传信的工作状态和人工重设工作模式。
2.将事件对象与套接字关联,同时注册事件,使事件对象的工作状态从未传信转变未已传信。
3.I/O处理后,设置事件对象为未传信。
4.等待网络事件来触发事件句柄的工作状态。
2)缺点:1.不适合管理大量客户端连接。采用线程池可以解决大量客户端连接管理的问题,但是同时带来线程上下文切换的开销和同步的开销,一般如果连接数不是太多(<250)建议采用该模型。
5.重叠(overlapped)模型
重叠结构:执行I/O请求的时间与线程执行其他任务的时间是重叠的,Windows里所有的异步通信都是基于它的,完成端口也不例外。
1)基本设计思想:重叠I/O 模型提供了更好的系统性能。允许应用程序使用重叠数据结构一次投递一个或者多个异步I/O 请求(即所谓的重叠I/O)。提交的I/O 请求完成之后,与之关联的重叠数据结构中的事件对象受信,应用程序便可使用WSAGetOverlappedResult 函数获取重叠操作结果。
2)缺点:和WSAEventSelect模型相似,且相对较复杂。
6.完成端口(completion port)模型(异步)
完成端口:我们在接到系统的通知的时候,其实网络操作已经完成了,就是比如说在系统通知我们的时候,来自于网络上的数据已经接收完毕了。
1)使用完成端口基本流程:
1.创建一个完成端口,把它的句柄保存好。
2.创建工作者线程用来和客户端进行通信,根据系统中处理器数量,确定工作者线程数量;
3.接收连入的Socket连接,有两种实现方式:一是启动一个独立的线程,专门用来accept客户端的连接请求;二是用性能更高更好的异步请求。
4.将新连入的客户端Socket与完成端口绑定在一起。
5.在Socket上提交一个网络请求,系统执行接收数据的操作。6. Worker线程扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,再继续投递下一个网络通信的请求,如此循环。
2)优点:当应用程序必须一次管理多个套接字时,完成端口模型提供了最好的系统性能。这个模型也提供了最好的伸缩性,它非常适合用来处理上百、上千个套接字。IOCP 技术广泛应用于各种类型的高性能服务器,如Apache 等。
七、NIO 的工作机制
1.BIO 阻塞 I/O
不管是磁盘 I/O 还是网络 I/O,数据在写入 OutputStream 或者从 InputStream 读取时都有可能会阻塞。当前的网络 I/O 的一些优化方法:如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其它线程工作;还有为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本。
2.NIO非阻塞I/O
1)两个核心概念Channel和Selector。NIO 引入Channel通信信道、Selector选择器、Buffer缓冲器。
1.调用Selector的静态工厂创建一个选择器。
2.创建一个服务端的Channel绑定到一个Socket对象,并注册到选择器上,把Channel 设置为非阻塞模式。
3.调用Selector的selectedKeys方法检查已经注册在这个选择器上的所有Channel 是否有关注的事件发生。
4.如果有某个事件发生时,返回SelectionKey,通过这个对象取得Channel就可以读取通信的数据。5.读写的数据使用的是可以控制的Buffer缓冲器。
通过 Channel 获取的 I/O 数据首先要经过操作系统的Socket缓冲区RecvQ 或者 SendQ 队列再将数据复制到Buffer中,从操作系统缓冲区到用户缓冲区复制数据比较耗性能,使用直接内存ByteBuffer.allocateDirector(size)的方式直接操作操作系统缓冲区。
2)Buffer的四个元素。Buffer缓冲器可以理解为一个数组,它通过几个变量来保存数据的当前位置状态。
1.capacity 缓冲区数组的总长度;
2.position下一个要操作的数据元素的位置;
3.limit 缓冲区数组中不可操作的下一个元素的位置,limit<=capacity;
4.mark用于记录当前position的前一个位置或者默认是0。
在实际操作数据时它们有如下关系图:
3)Buffer的工作方式。初始状态如图1所示:
1.通过 ByteBuffer.allocate(11) 方法创建一个 11 个 byte 的数组缓冲区,position 的位置为 0,capacity 和 limit 默认都是数组长度。
2.写入 5 个字节时位置变化如图2所示:position 的位置为 5。
3.将缓冲区的 5 个字节数据写入 Channel 通信信道,需要调用 byteBuffer.flip() 方法,数组的状态变化如图3所示:position 的位置为 0,limit 的位置为 5。
4.这时底层操作系统就可以从缓冲区中正确读取这 5 个字节数据发送出去。
5.下一次写数据之前调 clear() 方法,缓冲区的索引状态又回到初始位置。6.调用 mark() 时,它将记录当前 position 的前一个位置,当我们调用 reset 时,position 将恢复 mark 记录下来的值。
3.Netty
Netty是一个高性能、异步事件驱动的NIO框架,它提供了对TCP、UDP和文件传输的支持,作为一个异步NIO框架,Netty的所有IO操作都是异步非阻塞的,通过Future-Listener机制,用户可以方便的主动获取或者通过通知机制获得IO操作结果。
1.高性能的Netty
1)异步非阻塞通信。
1.当需要同时处理多个客户端接入请求时,利用多线程或者IO多路复用技术进行处理。
2.IO多路复用技术通过把多个IO的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。
3.与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
4.Java NIO的核心类库多路复用器Selector是基于epoll的多路复用技术实现。
2)零拷贝。Netty的“零拷贝”主要体现在如下三个方面:
1. Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。
2.Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。
3. Netty的文件传输采用了transferTo方法,直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
3)内存池。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区重用机制。
4)高效的Reactor线程模型。1.单线程Reactor模式。2.Reactor多线程模型。3.主从Reactor多线程模型。
5)无锁化的串行设计理念。为了尽可能提升性能,Netty采用了串行无锁化设计,在IO线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
Netty的串行化设计工作原理图如下:
Netty的NioEventLoop读取到消息之后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。
2.高效的Reactor线程模型
1)BIO通信模型。
一请求一应答通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。
缺点:缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,由于线程是Java虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
改进:为了改进一线程一连接模型,后来又演进出一种通过线程池或者消息队列实现1个或者多个线程处理N个客户端的模型,由于它的底层通信机制依然使用同步阻塞I/O。
2)单线程Reactor模式。指所有的IO操作都在同一个NIO线程上面完成的,IO处理线程是单线程的。NIO线程的职责是: 1.作为NIO服务端,接收客户端的TCP连接;2.作为NIO客户端,向通信端发起TCP连接;3.读取通信对端的请求或应答消息;4.向通信对端发送消息请求或应答消息。
Reactor单线程模型图如下所示:
1.Reactor模式使用的是同步非阻塞IO(NIO),所有的IO操作都不会导致阻塞;
2.理论上一个线程可以独立的处理所有的IO操作。
3.对于一些小容量的应用场景下,可以使用单线程模型;
4.高负载、大并发的应用场景不适用原因如下:
(1)一个NIO线程处理成千上万的链路,性能无法支撑,即使CPU的负荷达到100%;
(2)当NIO线程负载过重,处理性能就会变慢,导致大量客户端连接超时然后重发请求,导致更多堆积未处理的请求,成为性能瓶颈。
(3)可靠性低,只有一个NIO线程,万一线程假死或则进入死循环,就完全不可用了,这是不能接受的。
3)Reactor多线程模型。Reactor多线程模型与单线程模型最大的区别在于,IO处理线程不再是一个线程,而是一组NIO处理线程。原理如下图所示:
Reactor多线程模型的特点如下:
(1)有一个专门的NIO线程Acceptor线程用于接收客户端的TCP连接请求。
(2)读写等操作由一个专门的线程池负责,这些NIO线程就负责读取、解码、编码、发送。
(3)一个NIO线程可以同时处理N个链路,但是一个链路只对应一个NIO线程。
Reactor多线程模型可以满足绝大多数的场景,除了一些个别的特殊场景,不适用场景:比如一个NIO线程负责处理客户所有的连接请求,但是如果连接请求中包含认证的需求(安全认证),在百万级别的场景下,就存在性能问题了,因为认证本身就要消耗CPU,为了解决这种情景下的性能问题,产生了第三种线程模型:Reactor主从线程模型。
4)主从Reactor多线程模型。主从Reactor线程模型的特点是:
1.服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO的线程池。
2.Acceptor接收到客户端TCP连接请求并处理完成后(可能包含接入认证),
3.再将新创建的SocketChannel注册到IO线程池(sub reactor)的某个IO处理线程上并处理编解码和读写工作。
4.Acceptor线程池仅负责客户端的连接与认证,一旦链路连接成功,就将链路注册到后端的sub Reactor的IO线程池中。 线程模型图如下:
利用主从Reactor模型可以解决服务端监听线程无法有效处理所有客户连接的性能不足问题,这也是netty推荐使用的线程模型。
3.高效的并发编程
Netty的高效并发编程主要体现在如下几点:
1) volatile的大量、正确使用;
2) CAS和原子类的广泛使用;
3) 线程安全容器的使用;
4) 通过读写锁提升并发性能。
4.高性能的序列化框架
影响序列化性能的关键因素总结如下:
1) 序列化后的码流大小(网络带宽的占用);
2) 序列化&反序列化的性能(CPU资源占用);
3) 是否支持跨语言(异构系统的对接和开发语言切换)。
Netty默认提供了对Google Protobuf的支持,通过扩展Netty的编解码接口,用户可以实现其它的高性能序列化框架,例如Thrift的压缩二进制编解码框架。