到目前为止,几乎所有人都听说过Linux下所谓的零拷贝功能,但我经常遇到对这个主题没有充分了解的人。因此,我决定撰写一些文章,深入研究这个问题,希望能够揭开这个有用的特征。在本文中,我们从用户模式应用程序的角度来看零拷贝,因此故意省略了内核级别的详细信息。
什么是零拷贝?
为了更好地理解问题的解决方案,我们首先需要了解问题本身。让我们来看一下网络服务器的简单过程所涉及的内容,该过程通过网络将存储在文件中的数据提供给客户端。这是一些示例代码:
~~~
read(file,tmp_buf,len);
write(socket,tmp_buf,len);
~~~
看起来很简单;你会认为只有那两个系统调用没有太多的开销。实际上,这不可能是事实。在这两个调用之后,数据已被复制至少四次,并且几乎已经执行了多个用户/内核上下文切换。(实际上这个过程要复杂得多,但我想保持简单)。为了更好地了解所涉及的过程,请查看图1.顶部显示上下文切换,底部显示复制操作。

图1.两个示例系统调用中的复制
第一步:读取系统调用导致从用户模式到内核模式的上下文切换。第一个副本由DMA引擎执行,DMA引擎从磁盘读取文件内容并将它们存储到内核地址空间缓冲区中。
第二步:将数据从内核缓冲区复制到用户缓冲区,并返回读取系统调用。从调用返回导致从内核切换到用户模式的上下文。现在数据存储在用户地址空间缓冲区中,它可以再次开始。
第三步:写系统调用导致从用户模式到内核模式的上下文切换。执行第三个副本以再次将数据放入内核地址空间缓冲区。但是这一次,数据被放入一个不同的缓冲区,一个与套接字相关的缓冲区。
第四步:写入系统调用返回,创建第四个上下文切换。独立和异步地,当DMA引擎将数据从内核缓冲区传递到协议引擎时,会发生第四个副本。你可能会问自己,“你是什么意思独立和异步?在呼叫返回之前是不是传输了数据?“呼叫返回,实际上并不保证传输;它甚至不能保证传输的开始。它只是意味着以太网驱动程序在其队列中有自由描述符并已接受我们的数据进行传输。在我们之前可能有许多数据包排队。除非驱动程序/硬件实现优先级环或队列,否则数据以先进先出的方式传输。(图1中的分叉DMA副本说明了最后一个副本可以延迟的事实)。
正如您所看到的,实际上并不需要进行大量的数据复制。可以消除一些重复,以减少开销并提高性能。作为驱动程序开发人员,我使用具有一些非常高级功能的硬件。某些硬件可以完全绕过主存储器并将数据直接传输到另一个设备。此功能消除了系统内存中的副本,并且是一件好事,但并非所有硬件都支持它。还存在必须为网络重新打包来自磁盘的数据的问题,这引入了一些复杂性。为了消除开销,我们可以从消除内核和用户缓冲区之间的一些复制开始。
消除副本的一种方法是跳过调用read而不是调用mmap。例如:
~~~
tmp_buf = mmap(file,len);
write(socket,tmp_buf,len);
~~~
为了更好地了解所涉及的过程,请参见图2.上下文切换保持不变。

图2.调用mmap
第一步:mmap系统调用导致文件内容被DMA引擎复制到内核缓冲区。然后与用户进程共享缓冲区,而不在内核和用户存储空间之间执行任何复制。
第二步:写入系统调用使内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区中。
第三步:第三个副本发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。
通过使用mmap而不是read,我们减少了内核复制数据量的一半。当传输大量数据时,这会产生相当好的结果。然而,这种改进并非没有代价;使用mmap + write方法时存在隐藏的陷阱。当内存映射文件然后调用write而另一个进程截断同一文件时,您将陷入其中一个。您的写入系统调用将被总线错误信号SIGBUS中断,因为您执行了错误的内存访问。该信号的默认行为是终止进程并转储核心 - 而不是网络服务器最理想的操作。有两种方法可以解决这个问题。
第一种方法是为SIGBUS信号安装信号处理程序,然后在处理程序中简单地调用return。通过这样做,write系统调用返回它在被中断之前写入的字节数并且errno设置为成功。让我指出,这将是一个糟糕的解决方案,一个治疗症状,而不是问题的原因。因为SIGBUS发出信号表明该过程严重错误,我不鼓励将其作为解决方案。
第二个解决方案涉及内核中的文件租用(在Microsoft Windows中称为“机会锁定”)。这是解决此问题的正确方法。通过在文件描述符上使用租用,您可以在特定文件上使用内核。然后,您可以从内核请求读/写租约。当另一个进程试图截断您正在传输的文件时,内核会向您发送一个实时信号RT\_SIGNAL\_LEASE信号。它告诉您内核正在破坏该文件的写入或读取租约。您的写入调用在程序访问无效地址之前被中断,并被SIGBUS信号杀死。写调用的返回值是中断前写入的字节数,errno将设置为成功。下面是一些示例代码,展示了如何从内核获得租约:
~~~
if(fcntl(fd,F_SETSIG,RT_SIGNAL_LEASE)== -1){
perror(“内核租约设置信号”);
返回-1;
}
/ * l_type可以是F_RDLCK F_WRLCK * /
if(fcntl(fd,F_SETLEASE,l_type)){
perror(“内核租约集类型”);
返回-1;
}
~~~
您应该在获取文件之前获得租约,并在完成后中断租约。这是通过使用租约类型F\_UNLCK调用fcntl F\_SETLEASE来实现的。
发送文件
在内核版本2.1中,引入了sendfile系统调用以简化通过网络和两个本地文件之间的数据传输。sendfile的引入不仅减少了数据复制,还减少了上下文切换。像这样使用它:
~~~
sendfile(socket,file,len);
~~~
为了更好地了解所涉及的过程,请查看图3

图3.用Sendfile替换读写
第一步:sendfile系统调用导致文件内容被DMA引擎复制到内核缓冲区。然后,内核将数据复制到与套接字关联的内核缓冲区中。
第二步:第三个副本发生在DMA引擎将数据从内核套接字缓冲区传递到协议引擎时。
您可能想知道如果另一个进程截断我们使用sendfile系统调用传输的文件会发生什么。如果我们没有注册任何信号处理程序,sendfile调用只会返回它在被中断之前传输的字节数,并且errno将被设置为成功。
但是,如果我们在调用sendfile之前从文件内核获得租约,则行为和返回状态完全相同。我们还在sendfile调用返回之前获得RT\_SIGNAL\_LEASE信号。
到目前为止,我们已经能够避免让内核生成多个副本,但我们仍然只留下一个副本。这可以避免吗?当然,在硬件的帮助下。为了消除内核完成的所有数据复制,我们需要一个支持收集操作的网络接口。这仅仅意味着等待传输的数据不需要在连续的存储器中;它可以分散在各种存储位置。在内核版本2.4中,修改了套接字缓冲区描述符以适应这些要求 - 在Linux下称为零拷贝。这种方法不仅减少了多个上下文切换,还消除了处理器完成的数据复制。对于用户级应用程序,没有任何更改,因此代码仍然如下所示:
~~~
sendfile(socket,file,len);
~~~
为了更好地了解所涉及的过程,请查看图4。

图4.支持收集的硬件可以从多个内存位置组装数据,从而消除了另一个副本。
第一步:sendfile系统调用导致文件内容被DMA引擎复制到内核缓冲区。
第二步:没有数据被复制到套接字缓冲区。相反,只有具有关于数据的下落和长度信息的描述符被附加到套接字缓冲区。DMA引擎将数据直接从内核缓冲区传递到协议引擎,从而消除了剩余的最终副本。
因为数据实际上仍然是从磁盘复制到内存,从内存复制到线路,所以有些人可能会认为这不是真正的零拷贝。但是,从操作系统的角度来看,这是零拷贝,因为内核缓冲区之间的数据不会重复。使用零拷贝时,除了复制避免之外,还可以获得其他性能优势,例如更少的上下文切换,更少的CPU数据高速缓存污染以及无CPU校验和计算。
- c语言
- 基础知识
- 变量和常量
- 宏定义和预处理
- 随机数
- register变量
- errno全局变量
- 静态变量
- 类型
- 数组
- 类型转换
- vs中c4996错误
- 数据类型和长度
- 二进制数,八进制数和十六进制数
- 位域
- typedef定义类型
- 函数和编译
- 函数调用惯例
- 函数进栈和出栈
- 函数
- 编译
- sizeof
- main函数接收参数
- 宏函数
- 目标文件和可执行文件有什么
- 强符号和弱符号
- 什么是链接
- 符号
- 强引用和弱引用
- 字符串处理函数
- sscanf
- 查找子字符串
- 字符串指针
- qt
- MFC
- 指针
- 简介
- 指针详解
- 案例
- 指针数组
- 偏移量
- 间接赋值
- 易错点
- 二级指针
- 结构体指针
- 字节对齐
- 函数指针
- 指针例子
- main接收用户输入
- 内存布局
- 内存分区
- 空间开辟和释放
- 堆空间操作字符串
- 内存处理函数
- 内存分页
- 内存模型
- 栈
- 栈溢出攻击
- 内存泄露
- 大小端存储法
- 寄存器
- 结构体
- 共用体
- 枚举
- 文件操作
- 文件到底是什么
- 文件打开和关闭
- 文件的顺序读写
- 文件的随机读写
- 文件复制
- FILE和缓冲区
- 文件大小
- 插入,删除,更改文件内容
- typeid
- 内部链接和外部链接
- 动态库
- 调试器
- 调试的概念
- vs调试
- 多文件编程
- extern关键字
- 头文件规范
- 标准库以及标准头文件
- 头文件只包含一次
- static
- 多线程
- 简介
- 创建线程threads.h
- 创建线程pthread
- gdb
- 简介
- mac使用gdb
- setjump和longjump
- 零拷贝
- gc
- 调试器原理
- c++
- c++简介
- c++对c的扩展
- ::作用域运算符
- 名字控制
- cpp对c的增强
- const
- 变量定义数组
- 尽量以const替换#define
- 引用
- 内联函数
- 函数默认参数
- 函数占位参数
- 函数重载
- extern "C"
- 类和对象
- 类封装
- 构造和析构
- 深浅拷贝
- explicit关键字
- 动态对象创建
- 静态成员
- 对象模型
- this
- 友元
- 单例
- 继承
- 多态
- 运算符重载
- 赋值重载
- 指针运算符(*,->)重载
- 前置和后置++
- 左移<<运算符重载
- 函数调用符重载
- 总结
- bool重载
- 模板
- 简介
- 普通函数和模板函数调用
- 模板的局限性
- 类模板
- 复数的模板类
- 类模板作为参数
- 类模板继承
- 类模板类内和类外实现
- 类模板和友元函数
- 类模板实现数组
- 类型转换
- 异常
- 异常基本语法
- 异常的接口声明
- 异常的栈解旋
- 异常的多态
- 标准异常库
- 自定义异常
- io
- 流的概念和类库结构
- 标准io流
- 标准输入流
- 标准输出流
- 文件读写
- STL
- 简介
- string容器
- vector容器
- deque容器
- stack容器
- queue容器
- list容器
- set/multiset容器
- map/multimap容器
- pair对组
- 深浅拷贝问题
- 使用时机
- 常用算法
- 函数对象
- 谓词
- 内建函数对象
- 函数对象适配器
- 空间适配器
- 常用遍历算法
- 查找算法
- 排序算法
- 拷贝和替换算法
- 算术生成算法
- 集合算法
- gcc
- GDB
- makefile
- visualstudio
- VisualAssistX
- 各种插件
- utf8编码
- 制作安装项目
- 编译模式
- 内存对齐
- 快捷键
- 自动补全
- 查看c++类内存布局
- FFmpeg
- ffmpeg架构
- 命令的基本格式
- 分解与复用
- 处理原始数据
- 录屏和音
- 滤镜
- 水印
- 音视频的拼接与裁剪
- 视频图片转换
- 直播
- ffplay
- 常见问题
- 多媒体文件处理
- ffmpeg代码结构
- 日志系统
- 处理流数据
- linux
- 系统调用
- 常用IO函数
- 文件操作函数
- 文件描述符复制
- 目录相关操作
- 时间相关函数
- 进程
- valgrind
- 进程通信
- 信号
- 信号产生函数
- 信号集
- 信号捕捉
- SIGCHLD信号
- 不可重入函数和可重入函数
- 进程组
- 会话
- 守护进程
- 线程
- 线程属性
- 互斥锁
- 读写锁
- 条件变量
- 信号量
- 网络
- 分层模型
- 协议格式
- TCP协议
- socket
- socket概念
- 网络字节序
- ip地址转换函数
- sockaddr数据结构
- 网络套接字函数
- socket模型创建流程图
- socket函数
- bind函数
- listen函数
- accept函数
- connect函数
- C/S模型-TCP
- 出错处理封装函数
- 多进程并发服务器
- 多线程并发服务器
- 多路I/O复用服务器
- select
- poll
- epoll
- epoll事件
- epoll例子
- epoll反应堆思想
- udp
- socket IPC(本地套接字domain)
- 其他常用函数
- libevent
- libevent简介