1. 同步、异步、阻塞与非阻塞
- 同步:所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态可以保持一致。
- 异步:所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
- 阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回。
- 非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。虽然表面上看非阻塞的方式可以明显的提高CPU的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的CPU执行时间能不能补偿系统的切换成本需要好好评估。
同步和阻塞的区别:对于同步调用来说,很多时候当前线程可能还是激活的,只是从逻辑上当前函数没有返回而已。此时,这个线程可能也会处理其他的消息。
- 如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做同步非阻塞;
- 如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做同步阻塞;
所以同步的实现方式会有两种:同步阻塞、同步非阻塞;同理,异步也会有两种实现:异步阻塞、异步非阻塞。对于阻塞调用来说,则当前线程就会被挂起等待当前函数返回。
2. 五种 IO 模型
2.1 同步阻塞 IO(Blocking IO)
在这个 IO 模型中,服务端线程使用 read() 函数执行一个系统调用,这会导致应用程序阻塞。这里 read() 函数阻塞在两个阶段:
- 数据从网卡拷贝到内核缓冲区。
- 数据从内核缓冲区拷贝到用户缓冲区。
优点:1. 能够及时返回数据,无延迟。2. 对内核开发者来说这是省事了。 缺点:如果客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
2.2 同步非阻塞 IO(Non-bloking IO)
在网络 IO 时候,非阻塞 IO 也会进行系统调用,检查数据是否准备好。与阻塞 IO 不一样,非阻塞 IO 系统调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好会返回 -1。进程在返回之后,可以干点别的事情,过一段时间再发起系统调用。重复上面的过程,循环往复的进行系统调用。
- 非阻塞 IO 的 read() 指的是数据未到达网卡或者到达网卡还没有拷贝到内核缓冲区之前,这个阶段是阻塞的。
- 当数据已经到达内核缓冲区,此时调用 read() 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区才能返回。
优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是『后台』可以有多个任务在同时执行)。 缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次 read 操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
2.3 IO 多路复用(IO multiplexing)
2.3.1 IO 多路复用是什么?
IO多路复用是指内核一旦发现进程指定的一个或者多个 IO 条件准备读取,它就通知该进程。IO 多路复用适用如下场合:
- 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用 I/O 复用。
- 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- 如果一个 TCP 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。
- 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
目前支持I/O多路复用的系统调用有 select,pselect,poll,epoll,I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,pselect,poll,epoll 本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
2.3.2 select
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:
可以看出 select 有三个缺点:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
整个 select 的流程图如下:
可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。
2.3.2 poll
poll 和 select 是非常相似的,poll 相对于 select 的优化仅仅在于解决了文件描述符不能超过 1024 个的限制,它没有最大连接数的限制,原因是它是基于链表来存储的。
select 和 poll 都需要在返回后,通过遍历文件描述符来获取已经就绪的 socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降,因此不适合高并发场景。
2.3.3 epoll
针对 select 面临的三个问题,epoll 进行了改进:
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
注:如果没有大量的 idle-connection 或者 dead-connection,epoll 的效率并不会比 select/poll 高很多,但是当遇到大量的 idle-connection,就会发现 epoll 的效率大大高于 select/poll。
2.3.4 select、poll、epoll 的区别
- 单个进程能打开的最大连接数:
- FD 剧增后带来的 IO 效率问题:
- 消息传递方式:
综上,在选择 select,poll,epoll 时要根据具体的使用场合以及这三种方式的自身特点:
- 表面上看 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下,select 和poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调。
- select 低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
2.4 信号驱动式 IO(Signal-Driven IO)
首先我们允许 Socket 进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
2.5 异步非阻塞 IO(Asynchronous IO)
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read
系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。
2.6 五种 IO 模型总结
3. Java 的 IO 机制
3.1 Java 中的 IO 流分类有哪几种?
- 按功能来分:输入流、输出流。
- 按类型来分:字节流、字符流。
字节流按 8 位传输,以字节为单位输入输出数据。字符流按 16 位传输(char 为 16 位),以字符为单位输入输出数据。
3.2 BIO、NIO、AIO 有什么区别?
3.2.1 BIO(Block IO)
Block IO,同步阻塞 IO,就是平常使用的传统 IO。它的特点是模式简单使用方便,并发处理能力低。同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。
3.2.2 NIO(Non-Blocking IO)
Non-Blocking IO,同步非阻塞 IO,是传统 IO 的升级,提供了 Channel、Selector、Buffer 等抽象,实现了多路复用。以 Socket 使用来说,多路复用器通过不断轮询各个连接的状态,只有在 Socket 有流可读或者可写时,应用程序才需要去处理它,在线程的使用上,就不需要一个连接就必须使用一个处理线程了,而是只是有效请求时(确实需要进行I/O处理时),才会使用一个线程去处理,这样就避免了BIO模型下大量线程处于阻塞等待状态的情景。
Channel:Channel 和 BIO 中的 Stream 差不多是一个等级的,只不过 Stream 是单向的,而 Channel 是双向的,既可以用来读也可以用来写。
Buffer:在进行读操作时,需要使用 Buffer 分配空间,然后将数据从 Channel 中读入 Buffer 中。对于 Channel 的写操作,也需要将现有数据写入 Buffer 中,然后将 Buffer 写入 Channel 中。
Selector:Selector 是 NIO 实现多路复用的基础。Selector 运行单线程处理多个 Channel,如果一个应用打开了多个 Channel,但每个连接的流量都很低,使用 Selector 就会很方便。
3.2.3 AIO (Asynchronous IO)
AIO 实现了异步非阻塞 IO,它和 NIO 的区别在于NIO 需要使用者线程不停地轮询 IO 对象,来确定是否有数据准备好。而 AIO 则是在数据准备好之后,才会通知数据使用者,这样使用者就不需要不停地轮询了。
3.3 序列化与反序列化
序列化是将对象转换成字节流的过程,而反序列化的是将字节流恢复成对象的过程。
序列化与序列化主要解决的是数据的一致性问题。简单来说,就是输入数据与输出数据是一样的。
3.4 什么是 Netty?
Netty
是一款提供异步的、事件驱动的网络应用程序框架和工具,是基于NIO
客户端、服务器端的编程框架。Netty
在NIO
的基础上,封装了 Java 原生的NIO API
,屏蔽了繁杂的编程细节,让开发者可以更加专注于业务逻辑的实现。
4. DMA 技术的发展
4.1 什么是 DMA?
什么是 DMA 技术?简单理解就是:在进行 I/O 传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。目前支持 DMA 的硬件包括:网卡、声卡、显卡、磁盘控制器等。
4.2 仅 CPU 方式的拷贝
- 当用户进程需要读取数据时,调用
read()
从用户态陷入内核态,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态。 - CPU 向磁盘发起 I/O 请求,然后返回,CPU 可以执行其他任务,磁盘收到之后开始准备数据。
- 磁盘控制器搬运数据到磁盘缓冲区之后,向 CPU 发起 I/O 中断。
- CPU 收到中断信号后,停下手头的工作,将磁盘缓冲区的数据一次一个字节地读进自己的寄存器(内核缓冲区),然后再把寄存器中的数据写入到内存(用户缓冲区),在这个数据传输的期间 CPU 是执行其他任务的。
- 完成之后 read() 返回,从内核态切换为用户态。
整个数据的传输都需要 CPU 亲自参与搬运数据的过程,而且在这个过程中 CPU 是不能做其他事情的。 简单地搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。这个事情是很严重的,因此 DMA 技术就诞生了。
4.3 CPU&DMA 方式
- 当用户进程需要读取数据时,调用
read()
从用户态陷入内核态,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态。 - CPU 收到请求后,将 I/O 请求发送 DMA,然后返回,CPU 可以执行其他任务。DMA 进一步将 I/O 请求发送给磁盘控制器,磁盘控制器收到之后开始准备数据。
- 磁盘控制器搬运数据到磁盘缓冲区之后,向 DMA 发起 I/O 中断。
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务。
- 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU。
- CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,完成之后
read()
返回,从内核态切换为用户态。
虽然整个数据传输全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。 无论从仅 CPU 方式和 DMA&CPU 方式,都存在多次冗余数据拷贝和内核态&用户态的切换。
5. 零拷贝技术的发展
5.1 传统的数据传输
代码通常如下,一般会需要两个系统调用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
- 首先,期间共发生了 4 次上下文切换,因为发生了两次系统调用,一次是
read()
,一次是write()
,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。 - 其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
传统模式下,我们只是搬运一份数据,结果却搬运了 4 次,涉及多次空间切换和数据冗余拷贝,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。 我们可以看到,传统的数据传输需要将数据从内核缓冲区复制到用户缓冲区,再从用户缓冲区复制到内核缓冲区。两次数据拷贝都需要CPU的参与,并且涉及用户态与内核态的多次切换,加重了CPU负担。而在文件传输的应用场景中,在用户空间我们并不会对数据『再加工』,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
5.2 零拷贝技术
目前来看,零拷贝技术的几个实现手段包括:mmap()+write()
、sendfile
、sendfile
+DMA收集、splice
等。
5.2.1 mmap()+write()
传统的数据传输是read() + write()
方式,read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用mmap()
替换read()
系统调用函数。mmap()
会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
buf = mmap(file, len);
write(sockfd, buf, len);
- 应用进程调用了
mmap()
后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区。 - 应用进程再调用
write()
,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据。 - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
通过使用
mmap()
来代替read()
,可以减少一次数据拷贝的过程。但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
5.2.2 sendfile()
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
。
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
- 首先,它可以替代前面的
read()
和write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。 - 其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换、1 次 CPU 拷贝、2 次 DMA 拷贝。如下图:
但这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步优化通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on
5.2.3 sendfile() + DMA
Linux 2.4 内核对 sendfile 系统调用进行优化,对于支持网卡支持 SG-DMA 技术的情况下, sendfile()
系统调用的过程发生了点变化,具体过程如下:
- 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里。
sendfile()
将内核缓冲区中对应的数据描述信息(文件描述符、地址偏移量等信息) 传到 socket缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区中,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,在这个过程中只进行了 2 次状态切换和 2 次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。 这种方式有 2 次状态切换、0 次CPU拷贝、2 次DMA拷贝,但是仍然无法对数据进行修改,并且需要硬件层面 SG-DMA 的支持,并且
sendfile()
只能将文件数据拷贝到 socket 描述符上,有一定的局限性。
5.2.4 splice()
splice()
系统调用是 Linux 在 2.6 版本引入的,其不需要硬件支持,并且不再限定于 socket 上,实现两个普通文件之间的数据零拷贝。splice()
系统调用可以在内核缓冲区和 socket 缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作。
splice()
也有一些局限,它的两个文件描述符参数中有一个必须是管道设备。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。
需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
5.3 PageCache 在零拷贝中的作用
在文件传输过程中,第一步都是需要先把磁盘文件数据拷贝到『内核缓冲区』里,这个『内核缓冲区』实际上是磁盘高速缓存(PageCache)。PageCache 主要用于缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。
比如,假设 read 方法每次只会读 32 KB
的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。
所以,PageCache 的优点主要是两个:
- 缓存最近被访问的数据;
- 预读功能;
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
- PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
- PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
5.4 大文件传输最好别用零拷贝
来看最初的例子,当调用read()
方法读取文件时,进程实际上会阻塞在read()
方法调用,因为要等待磁盘数据的返回,如下图:
对于阻塞的问题,可以用异步 I/O 来解决,如下图:
它把读操作分为两部分:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
直接 I/O 应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
- 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
- 传输小文件的时候,则使用「零拷贝技术」;
例如在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。