写在前面,拜读《Netty、Redis、Zookeeper高并发实战》的笔记以及思考
高性能IO底层原理
IO读写的基础原理
用户程序进行IO的读写,依赖于底层的IO读写。都会用到read&write两大系统调用,不通操作系统中,IO读写系统调用的名称可能不完全相同,功能基本一致。在用户程序中,无论是Socket的IO还是文件IO操作都属于上层应用开发。
read系统调用:并不是直接从物理设备把数据读取到内存中,是把数据从内核缓冲区复制到进程缓冲区
write系统调用:也不是直接把数据写入到物理设备,是把数据从进程缓冲区复制到内核缓冲区
即上层应用无论是调用操作系统的read还是调用操作系统的write,都会涉及到缓冲区。
上层应用的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。
read&write两大系统调用,都不负责数据在内核缓冲区和磁盘之间的交换。底层的读写交换是由操作系统内核来完成的。
内核缓冲区与进程缓冲区
缓冲区的目的是为了减少频繁与设备之间的物理交换。设备的直接读写,涉及操作系统的中断。
当发生系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少这种底层系统的时间损耗,性能损耗,就出行了内存缓冲区。
有了内存缓冲区后,上层应用使用read系统调用时,仅仅是把数据从内核缓冲区复制到上层应用的缓冲区(进程缓冲区);上层应用使用write系统调用时,只需要把数据从进程缓冲区复制到内核缓冲区。底层操作会对内核缓冲区进行监控,等待缓冲区达到一定数量时,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,这种机制提示了系统的性能。以至于什么时候中断(读中断,写中断),由操作系统的内核决定,用户程序不需要关心。
从数量上来说,Linux系统中,操作系统内核只有一个内核缓冲区。
每个用户程序(进程),有自己的缓冲区,叫做进程缓冲区。
所以用户程序的IO读写程序,在大多数情况下,没有进行实际的IO操作,而是在进程缓冲区和内核缓冲区直接之间进行的数据交换。
典型的系统调用流程
图示 系统调用read&write流程
例如read系统调用的输入流程
-
等待数据就绪
-
从内核向进程复制数据。
如果read是一个socket。那么具体流程为:
-
等待数据从网络中到达网卡,当等待的分组到达时,数据会被复制到内核中的某个缓冲区。
(这个操作有操作系统完成,用户程序无感知)
-
把数据从内核缓冲区复制到应用进程缓冲区
-
从客户端和服务器端的角度来理解,即一次socket的请求和响应如下
- 客户端请求: linux通过网卡读取到客户端的请求数据,将数据读取到内核缓冲区
- 服务器端获取请求数据:通过read系统调用,从linux内核缓冲区读取数据,再送入Java进程缓冲区
- 服务器端业务处理:Java进程在自己的用户空间处理客户端的请求
- 服务器端返回数据:Java进程处理完成,构建好响应数据。通过write系统调用。将这些数据从用户缓存区写入内核缓冲区
- 发送至客户端:linux内核通过网络IO,将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议,会将数据发送给目标客户端。
四种主要的IO模型
-
同步阻塞IO (Blocking IO)
阻塞IO,指需要内核IO操作彻底完成后,才返回到用户空间执行用户的操作。
阻塞指的是用户空间程序的执行状态,传统的IO模型都是同步阻塞IO。
Java中默认创建的socket都是阻塞的。
-
同步非阻塞IO (Non-blocking IO)
非阻塞IO,指的是用户程序不需要等待内核IO操作彻底完成,可以立即返回执行用户操作,即处于非阻塞的状态。
非阻塞IO要求socket被设置为NONBLOCK
用户程序需要不断的进行IO系统调用,轮询数据是否已经准备好,制度完成IO系统调用为止。
- 优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回,用户线程不会阻塞,实时性比较好。
- 缺点:不断地轮询内核,占用大量的CPU时间,效率低下。
阻塞和非阻塞
阻塞是指用户空间(调用线程)一直等待,非阻塞指用户空间(调用线程)拿到内核返回的状态值就返回自己的空间。
-
IO多路复用(IO Multiplexing)
避免同步非阻塞IO模型中轮询等待的问题,可以采用IO多路复用模型。
在IO多路复用中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的操作系统调用为select/epoll系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内存能过将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行对应IO系统调用。
在IO多路复用模型中通过select/epoll系统调用,单个应用程序的进程,可以不断的轮询成百上千个的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可执行的读写操作。
例如发起一个多路复用IO的read读操作系统调用
-
选择器注册
先将read操作的目标socket网络连接,提前注册到select/epoll选择器中(Java中对应Selector类)
-
就绪状态的轮询
通过选择器的查询方法,查询所有注册过的socket连接的就绪状态。当其中任何一个socket连接数据准备好了,内核缓冲区有数据(就绪)了,内核就会将该socket连接维护为就绪状态。
通过查询的系统调用,内核会返回一个就绪的socket列表。当用户程序执行select方法,整个线程就会阻塞掉
-
用户线程发起read调用
用户线程获取到就绪状态的socket列表后,依靠socket连接发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内存缓冲区复制到用户缓冲区。
-
复制完成
复制完成后,内核返回结果,用户线程接触阻塞状态。用户线程获取到了数据,继续向下执行。
IO多路复用模型的特点
-
操作系统的内核必须能够提供多路分离的系统调用select/epoll。
-
多路复用也需要轮询,负责select/epoll查询调用的线程,需要不断的进行select/epoll轮询,进而
获取到就绪的socket连接。
-
优点: 与一个线程维护一个连接的阻塞IO模式相比,优势在于一个选择器查询的线程可以同时处理成千上万个连接(Connection)。系统不必创建,维护大量线程。减小系统开销。
-
缺点: 本质上,select/epoll 系统调用也是阻塞式的,属于同步IO。需要等待读写事件就绪后,由系统调用本身进行读写。
-
-
异步IO (Asynchronous IO)
彻底解决线程的阻塞,需要引入异步IO模型。
异步IO,指的是用户空间与内核空间的调用方式反过来,用户空间的线程编程被动接受者,而内核空间成为主动调用者。
类似于Java中比较典型的回调模式,用户空间的线程向内核空间注册了各种IO时间的回调函数,由内核去主动调用。
异步IO模型的流程
- 当用户线程发起了read系统调用,立刻返回做其他事情,用户线程不会阻塞。
- 内核开始准备数据,当数据准备好将数据从内核缓冲区复制到用户缓存区(用户空间的内存)
- 内核会给用户线程发送一个信号(Signal)或回调用户线程注册的回调接口,通知用户线程read操作完成了。(所以有时也被称为信号驱动IO)
- 用户线程读取用户缓冲区的数据,完成后续的业务操作。
特点
应用程序只需要进行事件的注册与接收,其他工作留给了操作系统。因此需要底层内核提供支持。
注意
理论上,异步IO是真正的异步输入输出,吞吐量高于IO多路复用模型。
但目前,Windows系统通过IOCP实现了真正的异步IO。在Linux系统下,异步IO模型在2.6版本才引入,
目前并不完善,底层实现仍使用epoll,与多路复用相同。netty框架使用的就是IO多路复用模型,而不是异步IO模型。
实战配置
文件句柄(文件描述符)
linux系统中,文件可分为:普通文件,目录文件,链接文件和设备文件。
文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引。是一个非负整数(通常是小整数),用于指代被打开的文件。所有的IO系统调用,包括socket的读写调用都是通过文件描述符完成的。
文件句柄数不够用时候,即单个进程打开的文件句柄数量,超过了系统配置的上限值,就会发出“Socket/File: Can’t open so many files”错误提示。对于高并发,高负债的应用,就必须调整这个系统参数。以适应处理并发大量连接的应用场景。