計算機常識——零複製

敖毛毛發表於2024-11-25

前言

什麼是零複製技術?

首先計算機不存在什麼真的零複製技術,這點是確認的。

零複製值得是減少多餘的複製的意思。

正文

首先如果我們要傳輸檔案是怎麼處理的呢?

當需要從磁碟讀取資料到記憶體時,‌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 函式,並實現檔案的傳輸。請確保正確設定檔案的讀寫許可權和處理異常情況。

大概我們知道原理就行,到時候直接用庫就行。

那麼這個時候還是沒有達到0複製這個效果啊,還是有一次cpu複製。

在 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 儲存區進行讀,管道連續不斷。

簡單梳理一下概念。

相關文章