前言

什么是零拷贝技术?

首先计算机不存在什么真的零拷贝技术,这点是确认的。

零拷贝值得是减少多余的拷贝的意思。

正文

首先如果我们要传输文件是怎么处理的呢?

当需要从磁盘读取数据到内存时,‌CPU会发出指令通知硬盘控制器进行读取操作。‌
此后,‌CPU可以执行其他任务,‌而不需要持续参与数据的读取过程。‌这个过程利用了直接内存访问(‌DMA)‌技术,‌允许硬件设备(‌如硬盘)‌直接访问系统内存,‌从而实现了数据的快速传输。‌

具体来说,‌DMA控制器负责管理内存和硬盘之间的数据传输,‌当数据传输完成时,‌DMA控制器会向CPU发出中断信号,‌通知数据已经准备好。‌

CPU收到中断信号后,‌会将数据从内核空间拷贝到用户空间,‌完成整个数据读取过程。‌在这个过程中,‌CPU的大部分时间用于处理其他任务,‌而不是直接参与数据的物理传输。‌
此外,‌这种数据传输方式提高了系统的整体效率,‌因为CPU可以在等待数据传输完成的时间段内执行其他任务,‌而不是被绑定在数据传输上。‌

这种技术是现代计算机系统中提高性能的一种重要手段
操作系统中的内核空间和用户空间是指操作系统中划分出来的两个不同的内存区域。

内核空间是操作系统核心的运行区域,具有较高的权限,可以直接访问硬件资源和执行关键操作;

用户空间是给应用程序执行的区域,权限较低,不能直接访问硬件,必须通过系统调用访问内核功能。这种分隔提高了系统的安全性和稳定性。
内核空间和用户空间是通过硬件和操作系统的协作来实现的。操作系统通过使用特殊的机制(如分页机制)将整个内存地址空间划分为内核空间和用户空间。

举个例子,假设整个内存地址空间是0到4GB,操作系统可以将0到2GB的地址空间分配给内核空间,而将2GB到4GB的地址空间分配给用户空间。这样,内核空间和用户空间在地址空间上是相互独立的。

当应用程序在用户空间执行时,如果需要访问硬件资源或执行特权指令,就需要通过系统调用切换到内核空间,让操作系统代表应用程序执行必要的操作,然后再返回用户空间。这种划分和切换机制有助于保护系统的稳定性和安全性。
DMA技术是Direct Memory Access的缩写,允许外部设备直接访问计算机内存数据,减轻了CPU的负担。具体实现原理是CPU发出DMA请求,外部设备将数据传输到内存,减少了CPU在数据传输过程中的干预,提高了数据传输效率。

也就是是通过dma(direct memory access)那么cpu只是发送一个指令,就能让其他的硬件进行工作了,这时候它就可以去做其他事情了。

那么通过dma读取的数据是归哪个进程管理呢?那肯定是归内核进程管理,也就是内存在内核态。

正常操作如上:

  1. 磁盘到内存缓冲区
  2. 内存缓冲区拷贝到用户缓冲区
  3. 用户缓冲区到socket缓存区
  4. socket缓冲区到网卡

这里面就经过4步骤。

数据拷贝次数:2 次 DMA 拷贝,2 次 CPU 拷贝
CPU 切换次数:4 次用户态和内核态的切换

正常情况是这么做的。

那么为什么要这么做呢?

首先拷贝到内核态,这个肯定是要的,先到内存然后再发出去,这个肯定无法避免的。

那么为什么要内核态的缓冲区拷贝到内核态呢? 这是因为用户态无法直接读取到内核态的内存,权限受限了。

那如果让用户态进程直接读取内核态内存呢?这个就很不安全了,因为也就意味着,一个进程可以读取另外一个进程的操作数据了,很危险。

那就没有一点办法吗?

在操作系统中,我们知道进程的通讯之一就是共享内存,这样就可以实现。

共享内存的实现也就是内存做映射。

这样就可以了,这样就保全了安全问题了,用户进程也不会瞎访问了。

然后就变成了这样了。

看起来相当nice。

这时候就是2 次 DMA 拷贝,1 次 CPU 拷贝。

CPU 切换次数:4 次用户态和内核态的切换

这里说下为什么是4次用户态到内核态。

首先在用户态发起读取数据的指令,这个时候是第一次,切换到了内核态。

当读取完毕后,然后切换到用户态,这是第二次。

用户态发起将内存写入到socket缓存区,这时候就是第三次,切换到来了内核态。

然后当内核态发送完毕,这个时候切换到用户态,这是第四次。

在C#中,你可以使用
MemoryMappedFile
类来实现类似于Unix/Linux中
mmap
(内存映射)的功能。

这个类允许你直接在内存中映射文件,从而实现对文件内容的高效访问。

你可以使用
MemoryMappedFile.CreateFromFile
方法来创建一个内存映射文件。记得在使用完成后释放资源以避免内存泄漏。

写入:

using System;
using System.IO.MemoryMappedFiles;
 
class Program
{
    static void Main()
    {
        // 创建或打开一个内存映射文件
        using (var mmf = MemoryMappedFile.CreateOrOpen("TestMemoryMappedFile", 1024))
        {
            // 获取内存映射文件的一个视图
            var viewAccessor = mmf.CreateViewAccessor(0, 1024);
 
            // 使用 Marshal 写入字符串
            var str = "Hello, MemoryMappedFile!";
            var bytes = System.Text.Encoding.UTF8.GetBytes(str);
            viewAccessor.WriteArray(0, bytes, 0, bytes.Length);
        }
    }
}

读取:

using System;
using System.IO.MemoryMappedFiles;
 
class Program
{
    static void Main()
    {
        // 打开一个已经存在的内存映射文件
        using (var mmf = MemoryMappedFile.OpenExisting("TestMemoryMappedFile"))
        {
            // 获取内存映射文件的一个只读视图
            var viewAccessor = mmf.CreateViewAccessor(0, 1024);
 
            // 使用 Marshal 读取字符串
            byte[] bytes = new byte[1024];
            viewAccessor.ReadArray(0, bytes, 0, bytes.Length);
            var str = System.Text.Encoding.UTF8.GetString(bytes).Trim('\0');
            Console.WriteLine(str);
        }
    }
}

这个时候人们就会想啊,是不是不用cpu切换这么多次,直接由内存缓存区直接到socket缓冲区,不用在通知我内核态的进程了。

也就是说,用户态发出的指令是这样的,就硬盘的文件传给某个socket,而不是分为两个步骤了。

在 Linux 2.1 内核版本中,引入了一个系统调用方法:sendfile。

当调用 sendfile() 时,DMA 将磁盘数据复制到内核缓冲区 kernel buffer;然后将内核中的 kernel buffer 直接拷贝到 socket buffer(这里只拷贝数据的位置和长度);最后利用 DMA 将 socket buffer 通过网卡传输给客户端。

这个时候就是:

这时候就是2 次 DMA 拷贝,1 次 CPU 拷贝(极少),几乎可以不计。

CPU 切换次数: 2次用户态和内核态的切换.

图形还是这样,只是这次发送的指令比较长,需要内核进程直接做两步:

C# 中对 Linux 的 sendfile 进行支持可以使用 P/Invoke 来调用系统调用。下面是一个简单的示例代码,演示如何在 C# 中调用 Linux 的 sendfile 函数:
using System;
using System.IO;
using System.Runtime.InteropServices;

public static class LinuxSendfile
{
    // 导入 Linux 的 sendfile 函数
    [DllImport("libc", SetLastError = true)]
    public static extern long sendfile(int out_fd, int in_fd, IntPtr offset, ulong count);

    public static void SendFile(int destination, int source, long offset, long count)
    {
        IntPtr offPtr = IntPtr.Zero;
        if (offset > 0)
        {
            offPtr = Marshal.AllocHGlobal(sizeof(long));
            Marshal.WriteInt64(offPtr, offset);
        }

        sendfile(destination, source, offPtr, (ulong) count);

        if (offPtr != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(offPtr);
        }
    }
}

class Program
{
    static void Main()
    {
        int sourceFd = open("source.txt", O_RDONLY);
        int destFd = open("destination.txt", O_WRONLY);

        if (sourceFd == -1 || destFd == -1)
        {
            return;
        }

        LinuxSendfile.SendFile(destFd, sourceFd, 0, 1024);

        close(sourceFd);
        close(destFd);
    }

    // Linux 系统调用需要用到的常量和函数
    const int O_RDONLY = 0;
    const int O_WRONLY = 1;

    [DllImport("libc", SetLastError = true)]
    public static extern int open(string fileName, int mode);

    [DllImport("libc", SetLastError = true)]
    public static extern int close(int fd);
}

这个示例展示了如何在 C# 中使用 P/Invoke 调用 Linux 的 sendfile 函数,并实现文件的传输。请确保正确设置文件的读写权限和处理异常情况。

大概我们知道原理就行,到时候直接用库就行。

这样做倒是可以的呢,但是呢?还有一个问题,那就是相当于读取了一次磁盘文件,然后拷贝缓存数据的位置和长度,然后再去网卡dma读取。

这里有一个地方就是,dma读取到内存中是连续的,也就是串行读取,那么人们就会想能不能dma直接多个地方读取呢?

在 Linux 2.4 内核版本中,对 sendfile 系统方法做了优化升级,引入 SG-DMA 技术,需要 DMA 控制器支持。

其实就是对 DMA 拷贝加入了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点来实现数据拷贝,可以多省去一次 CPU 拷贝。

整个拷贝过程,可以用如下流程图来描述!

SG-DMA 是 Scatter-Gather Direct Memory Access 的缩写,是一种用于数据传输的技术,允许将来自多个不连续内存区域的数据收集到一个连续内存区域中,或将一个连续内存区域的数据分散传送到多个不连续内存区域。这种技术常用于高性能计算和网络数据传输等场景中。

这样呢?就可以将磁盘中的数据放入到内存的不同位置,然后网卡dma可以直接读取成连续的,就是传输速度快了呗。

有人就问了,为啥不直接映射呢?没法映射啊,要知道映射其实是cpu的虚拟,这里映射完直接走dma,不走cpu,无法映射。

那还有没有进步空间呢? 这里好像是必须得硬件支持。

在 Linux 2.6.17 内核版本中,引入了 splice 系统调用方法,和 sendfile 方法不同的是,splice 不需要硬件支持。

它将数据从磁盘读取到 OS 内核缓冲区后,内核缓冲区和 socket 缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作。

一旦有个管道两者之间就可以通讯了。

Linux 系统 splice 拷贝流程,从上图可以得出如下结论:

数据拷贝次数:2 次 DMA 拷贝,0 次 CPU 拷贝
CPU 切换次数:2 次用户态和内核态的切换

建立管道的目的就是可以不停的读不停的写,比普通的sendfile可能要快(也不一定,维护管道要成本),因为sendfile是写完了后然后才拷贝给socket存储区进行读,管道连续不断。

简单的自我理解。

标签: none

添加新评论