在 Kafka
的数据传输过程中,零拷贝为数据传输的高性能扮演着至关重要的角色,今天就让我们来揭开零拷贝的神秘面纱。
一、数据读写
1. 传统方式
首先让我们看一下不做任何特殊处理的数据流读取方式,以文件下载请求为例,其大致流程如下:
- 用户端发送
read()
请求读取数据; CPU
在接收到该请求后向磁盘发出IO
请求;- 磁盘接收到
CPU
发出的IO
请求后读取数据并存入磁盘缓存区,并在完成后向CPU
发送响应; CPU
接收到响应后读取磁盘缓存区数据至Page Cache
;- 读取至
Page Cache
后需要再次拷贝至用户缓存区;
了解大致流程之后你可能会疑惑,为什么数据读取至磁盘缓冲区之后 CPU
不直接拷贝至用户缓存区,而是要先拷贝到 Page Cache
?原因其实也很简单,因为用户缓存区权限相对较低,无法直接与磁盘交互,必须通过内核层进行中转,而 Page Cache
则是内核态的一种缓存优化方式。
2. DMA技术
在上述的流程中可以看到,在整个数据的拷贝过程中 CPU
都占据着主导地位,即干了所有的重活并一直处于阻塞状态,在此期间 CPU
将无法处理其它任务。很显然这种方式相对较为低效,因此 DMA(Direct Memory Access)
应运而生,它接替了 CPU
在 IO
操作中大部分繁重搬运任务,而 CPU
的核心任务则为确定的需要传输的数据以及数据的去向。
同样以之前的
read()
读取为例,与之前的不同的区别如下:
CPU
接收到IO
请求后转发至DMA
,由其向磁盘发出IO
请求;- 磁盘读取完成后通知
DMA
执行拷贝,此过程CPU
不参与搬运任务;
可以看到以一次读取为例,CPU
的拷贝频次由 2
次降为 1
次,而通常读取伴随着写入操作,也就意味着一次完整的读写操作就能让 CPU
闲置出 2
两次搬运操作从而执行其它任务。
3. 数据缓冲
上述提到的两种方式中都涉及到一个概念——内核缓冲区 (Page Cache)
,我们都知道内存的读取效率远超于磁盘。因此,若能够在内存中操作磁盘数据效率将大大提升,而 Page Cache
则是磁盘数据在内存中的暂存空间。
通过 Page Cache
将磁盘上的数据缓存在内存中,系统可以避免频繁地访问慢速的磁盘,从而减少了 I/O
操作的次数,加快了数据的访问速度。当进程再次访问相同的数据时,系统可以直接从 Page Cache
中读取,而不必再次从磁盘中加载,从而节省了大量的时间。
Page Cache
是一种按需加载的缓存机制,它会根据系统内存的可用情况来管理缓存。当系统内存不足时,操作系统会根据一定的策略来释放 Page Cache
中的部分数据,以确保系统的正常运行。
二、模式优化
1. 内存映射
虽然 DMA
技术降低了 CPU
负载压力,让其能够执行更多的系统任务,但针对于 IO
操作读写任务而言,整体流程上的搬运工作并没有减轻,一次完成的读写操作仍需要搬运 4
次。因此,想要提高 IO
流程的效率,需要的是减少重复数据拷贝工作。
由于用户层与内核、磁盘之间的权限问题,导致同一份数据在此期间执行了多次搬运拷贝,以之前的提到的图示为例,如果用户层能够直接读取内核层数据,那么 CPU
即可省去将数据由内核层拷贝至用户层这一操作。
而 mmap(Memory-mapped file support)
内存映射则正是实现了这一点,其将内核缓冲区里的数据映射到用户层,从而实现在用户层即可访问内核层数据,也就节省了一次 CPU
数据拷贝。
2. sendfile
在 linux
中提供了专门的文件传输函数 sendfile()
可用于替代 read()
与 write()
操作。该函数能够直接实现两个内核态的数据拷贝,从而减少了 1
次用户态层级切换,在 Java
的 NIO
中也涉及到该函数的封装调用,后面会详细介绍。
在 Nginx
的配置文件中经常可以看到下述配置,其即代表使用 sendfile
执行传输。
http {
sendfile on;
}
三、Java实现
1. NIO接口
在上述提到 linux
中提供了 sendfile()
替换了 read()
和 write()
从而减少状态的切换,在 NIO
中的 FileChannel
正利用了这一方式提高了传输效率。
在 FileChannelImpl
的 transferTo()
方法中正是封装利用这一特性执行数据的传输,通过下述图示了解其类的层级结构。
transferTo()
方法的内部实现逻辑此处不具体展开描述,感兴趣的朋友可以自行查看 JDk
源码,这里以简洁的图示描述其执行调用路径。
2. 源码解读
NIO
的 transferTo()
方法调用执行最终定格在 transferTo0()
方法,可以看到该方法为 native
关键字声明,表明其为原生 c++
方式实现。
为了查看其具体内容,我们需要查看 openjdk
的源码,此版本为开源版本,而日常使用的并不开源也就无法查看具体 native
实现逻辑,具体的实现代码内容链接:c++源码实现。
这里为了更方便查看我截图部分关键代码,可以看到在 Linux
系统下其正是通过调用 sendfile()
实现。
四、大文件传输
1. 直接IO
之前提到了 Page Cache
技术通过内存方式提高了数据读取效率,但对于大文件的场景下,显然此方式不太合适。若在传输的大文件时仍采取 Page Cache
暂存,显然内存空间是及其有限的且不论能否完整存入,其还可能触发空间的回收导致其他任务缓存的小文件内容遭到回收。
因此,对于大文件的数据读取传输,更为高效的方式即通过 直接IO
将磁盘缓冲区文件直接拷贝至用户缓存区。而针对大文件的直接 IO
方式通常使用异步进行,当完成数据到用户缓冲区的拷贝后通知用户读取数据,在此之前 CPU
可执行其它进程任务。
参考链接