超硬核,基於 mmap 和零拷貝實現高效的內存共享

一、簡介

MMAP

mmap 技術 是一種文件或其他對象映射到內存的技術。這種技術,讓用戶程序(用戶空間)直接訪問設備內存(內核空間),相比於在用戶空間和內核空間互相拷貝數據,效率更高。

什麼是零拷貝 (Zero-copy)?

零複製(英語:Zero-copy;也譯零拷貝)技術是指計算機執行操作時,CPU 不需要先將數據從某處內存複製到另一個特定區域。這種技術通常用於通過網絡傳輸文件時節省 CPU 週期和內存帶寬。

二、DMA

在 DMA 技術出現之前,應用程序與磁盤之間的 I/O 操作都是通過 cpu 的中斷完成的,如圖:

有了 DMA 技術以後:

DMA 控制器接過了將數據從磁盤控制器緩衝區拷貝到內核緩衝區的工作,解放了 cpu。

爲什麼要有 DMA 技術?

在沒有 DMA 技術前,I/O 的過程是這樣的:

爲了方便你理解,我畫了一副圖:

可以看到,整個數據的傳輸過程,都要需要 CPU 親自參與搬運數據的過程,而且這個過程,CPU 是不能做其他事情的。

簡單的搬運幾個字符數據那沒問題,但是如果我們用千兆網卡或者硬盤傳輸大量數據的時候,都用 CPU 來搬運的話,肯定忙不過來。

計算機科學家們發現了事情的嚴重性後,於是就發明了 DMA 技術,也就是直接內存訪問(Direct Memory Access) 技術。

什麼是 DMA 技術?簡單理解就是,在進行 I/O 設備和內存的數據傳輸的時候,數據搬運的工作全部交給 DMA 控制器,而 CPU 不再參與任何與數據搬運相關的事情,這樣 CPU 就可以去處理別的事務。

那使用 DMA 控制器進行數據傳輸的過程究竟是什麼樣的呢?下面我們來具體看看。

具體過程:

可以看到, 整個數據傳輸的過程,CPU 不再參與數據搬運的工作,而是全程由 DMA 完成,但是 CPU 在這個過程中也是必不可少的,因爲傳輸什麼數據,從哪裏傳輸到哪裏,都需要 CPU 來告訴 DMA 控制器。

早期 DMA 只存在在主板上,如今由於 I/O 設備越來越多,數據傳輸的需求也不盡相同,所以每個 I/O 設備裏面都有自己的 DMA 控制器。

傳統的文件傳輸有多糟糕?

如果服務端要提供文件傳輸的功能,我們能想到的最簡單的方式是:將磁盤上的文件讀取出來,然後通過網絡協議發送給客戶端。

傳統 I/O 的工作方式是,數據讀取和寫入是從用戶空間到內核空間來回複製,而內核空間的數據是通過操作系統層面的 I/O 接口從磁盤讀取或寫入。

代碼通常如下,一般會需要兩個系統調用:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

代碼很簡單,雖然就兩行代碼,但是這裏面發生了不少的事情。

首先,期間共發生了 4 次用戶態與內核態的上下文切換,因爲發生了兩次系統調用,一次是 read() ,一次是 write(),每次系統調用都得先從用戶態切換到內核態,等內核完成任務後,再從內核態切換回用戶態。

上下文切換到成本並不小,一次切換需要耗時幾十納秒到幾微秒,雖然時間看上去很短,但是在高併發的場景下,這類時間容易被累積和放大,從而影響系統的性能。

其次,還發生了 4 次數據拷貝,其中兩次是 DMA 的拷貝,另外兩次則是通過 CPU 拷貝的,下面說一下這個過程:

我們回過頭看這個文件傳輸的過程,我們只是搬運一份數據,結果卻搬運了 4 次,過多的數據拷貝無疑會消耗 CPU 資源,大大降低了系統性能。

這種簡單又傳統的文件傳輸方式,存在冗餘的上文切換和數據拷貝,在高併發系統裏是非常糟糕的,多了很多不必要的開銷,會嚴重影響系統性能。

所以,要想提高文件傳輸的性能,就需要減少「用戶態與內核態的上下文切換」和「內存拷貝」的次數。

如何優化文件傳輸的性能?

先來看看,如何減少「用戶態與內核態的上下文切換」的次數呢?

讀取磁盤數據的時候,之所以要發生上下文切換,這是因爲用戶空間沒有權限操作磁盤或網卡,內核的權限最高,這些操作設備的過程都需要交由操作系統內核來完成,所以一般要通過內核去完成某些任務的時候,就需要使用操作系統提供的系統調用函數。

而一次系統調用必然會發生 2 次上下文切換:首先從用戶態切換到內核態,當內核執行完任務後,再切換回用戶態交由進程代碼執行。

所以,要想減少上下文切換到次數,就要減少系統調用的次數。

再來看看,如何減少「數據拷貝」的次數?

在前面我們知道了,傳統的文件傳輸方式會歷經 4 次數據拷貝,而且這裏面,「從內核的讀緩衝區拷貝到用戶的緩衝區裏,再從用戶的緩衝區裏拷貝到 socket 的緩衝區裏」,這個過程是沒有必要的。

因爲文件傳輸的應用場景中,在用戶空間我們並不會對數據「再加工」,所以數據實際上可以不用搬運到用戶空間,因此用戶的緩衝區是沒有必要存在的。

三、零拷貝

零拷貝技術是另一個系統調用,Linux 中如 sendfile 命令。它減少了內存中用戶空間與內核空間數據的拷貝過程,使得 CPU 處理效率更高。

如何實現零拷貝?

零拷貝技術實現的方式通常有 2 種:

下面就談一談,它們是如何減少「上下文切換」和「數據拷貝」的次數。

3.1mmap + write

在前面我們知道,read() 系統調用的過程中會把內核緩衝區的數據拷貝到用戶的緩衝區裏,於是爲了減少這一步開銷,我們可以用 mmap() 替換 read() 系統調用函數。

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系統調用函數會直接把內核緩衝區裏的數據「映射」到用戶空間,這樣,操作系統內核與用戶空間就不需要再進行任何的數據拷貝操作。

具體過程如下:

應用進程調用了 mmap() 後,DMA 會把磁盤的數據拷貝到內核的緩衝區裏。接着,應用進程跟操作系統內核「共享」這個緩衝區;

應用進程再調用 write(),操作系統直接將內核緩衝區的數據拷貝到 socket 緩衝區中,這一切都發生在內核態,由 CPU 來搬運數據;

最後,把內核的 socket 緩衝區裏的數據,拷貝到網卡的緩衝區裏,這個過程是由 DMA 搬運的。

我們可以得知,通過使用 mmap() 來代替 read(), 可以減少一次數據拷貝的過程。

但這還不是最理想的零拷貝,因爲仍然需要通過 CPU 把內核緩衝區的數據拷貝到 socket 緩衝區裏,而且仍然需要 4 次上下文切換,因爲系統調用還是 2 次。

3.2sendfile

相比mmap來說,sendfile同樣減少了一次 CPU 拷貝,而且還減少了 2 次上下文切換。

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

sendfile 是 Linux2.1 內核版本後引入的一個系統調用函數,通過使用 sendfile 數據可以直接在內核空間進行傳輸,因此避免了用戶空間和內核空間的拷貝,同時由於使用 sendfile 替代了 read+write 從而節省了一次系統調用,也就是 2 次上下文切換。

整個過程發生了 2 次用戶態和內核態的上下文切換和 3 次拷貝,具體流程如下:

  1. 用戶進程通過sendfile()方法向操作系統發起調用,上下文從用戶態轉向內核態

  2. DMA 控制器把數據從硬盤中拷貝到讀緩衝區

  3. CPU 將讀緩衝區中數據拷貝到 socket 緩衝區

  4. DMA 控制器把數據從 socket 緩衝區拷貝到網卡,上下文從內核態切換回用戶態,sendfile調用返回

sendfile方法 IO 數據對用戶空間完全不可見,所以只能適用於完全不需要用戶空間處理的情況,比如靜態文件服務器。

這就是所謂的零拷貝(Zero-copy)技術,因爲我們沒有在內存層面去拷貝數據,也就是說全程沒有通過 CPU 來搬運數據,所有的數據都是通過 DMA 來進行傳輸的。

零拷貝技術的文件傳輸方式相比傳統文件傳輸的方式,減少了 2 次上下文切換和數據拷貝次數,只需要 2 次上下文切換和數據拷貝次數,就可以完成文件的傳輸,而且 2 次的數據拷貝過程,都不需要通過 CPU,2 次都是由 DMA 來搬運。

所以,總體來看,零拷貝技術可以把文件傳輸的性能提高至少一倍以上。

3.3 使用零拷貝技術的項目

事實上,Kafka 這個開源項目,就利用了「零拷貝」技術,從而大幅提升了 I/O 的吞吐率,這也是 Kafka 在處理海量數據爲什麼這麼快的原因之一。

如果你追溯 Kafka 文件傳輸的代碼,你會發現,最終它調用了 Java NIO 庫裏的 transferTo方法:

@Overridepublic 
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { 
    return fileChannel.transferTo(position, count, socketChannel);
}

如果 Linux 系統支持 sendfile() 系統調用,那麼 transferTo() 實際上最後就會使用到 sendfile() 系統調用函數。

曾經有大佬專門寫過程序測試過,在同樣的硬件條件下,傳統文件傳輸和零拷拷貝文件傳輸的性能差異,你可以看到下面這張測試數據圖,使用了零拷貝能夠縮短 65% 的時間,大幅度提升了機器傳輸數據的吞吐量。

另外,Nginx 也支持零拷貝技術,一般默認是開啓零拷貝技術,這樣有利於提高文件傳輸的效率,是否開啓零拷貝技術的配置如下:

http {
...
    sendfile on
...
}

sendfile 配置的具體意思:

當然,要使用 sendfile,Linux 內核版本必須要 2.1 以上的版本。

3.4sendfile+DMA Scatter/Gather

Linux2.4 內核版本之後對sendfile做了進一步優化,通過引入新的硬件支持,這個方式叫做 DMA Scatter/Gather 分散 / 收集功能。

它將讀緩衝區中的數據描述信息 -- 內存地址和偏移量記錄到 socket 緩衝區,由 DMA 根據這些將數據從讀緩衝區拷貝到網卡,相比之前版本減少了一次 CPU 拷貝的過程

整個過程發生了 2 次用戶態和內核態的上下文切換和 2 次拷貝,其中更重要的是完全沒有 CPU 拷貝,具體流程如下:

  1. 用戶進程通過sendfile()方法向操作系統發起調用,上下文從用戶態轉向內核態

  2. DMA 控制器利用 scatter 把數據從硬盤中拷貝到讀緩衝區離散存儲

  3. CPU 把讀緩衝區中的文件描述符和數據長度發送到 socket 緩衝區

  4. DMA 控制器根據文件描述符和數據長度,使用 scatter/gather 把數據從內核緩衝區拷貝到網卡

  5. sendfile()調用返回,上下文從內核態切換回用戶態

DMA gathersendfile一樣數據對用戶空間不可見,而且需要硬件支持,同時輸入文件描述符只能是文件,但是過程中完全沒有 CPU 拷貝過程,極大提升了性能。

3.5 應用場景

對於文章開頭說的兩個場景:RocketMQ 和 Kafka 都使用到了零拷貝的技術。

對於 MQ 而言,無非就是生產者發送數據到 MQ 然後持久化到磁盤,之後消費者從 MQ 讀取數據。

對於 RocketMQ 來說這兩個步驟使用的是 mmap+write,而 Kafka 則是使用 mmap+write 持久化數據,發送數據使用 sendfile。

Kafka

Kafka 是一個分佈式發佈訂閱消息系統,它巧妙用到了這兩種技術。

MMAP 和零拷貝. png

數據的輸入(從網卡到磁盤)

用了 MMAP 打通用戶空間和內核空間,並將一部分內存映射到磁盤上的一段空間。
流程:data 從網卡過來,進入內核,再讀入到用戶空間的服務,服務處理後扔到 MMAP 中,內核將數據再拷貝到磁盤中。

數據的輸出(從磁盤到網卡)

若沒有零拷貝,用戶空間先調內核的 read 去讀磁盤中的文件,將磁盤數據存入用戶空間(data 從磁盤 -> 內核空間 -> 用戶空間);然後再調用內核的 write 方法,將數據發到網卡(data 從用戶空間 -> 內核空間 -> 網卡)。由於數據沒有在用戶態改變數據,所以造成了數據的流轉浪費。
內核有一個方法叫 sendfile(out_fd, in_fd, offset, size),用戶直接將命令發給內核,內核便可以直接將數據從磁盤經過內核發出到內存。

四、共享內存 mmap

內核和用戶空間,共享內存。數據 copy 到內核區後,只需要把地址共享給應用程序即可,無需再 copy 一次數據到用戶空間。

優點:

缺點:

應用:

kafka 生產者發送消息到 broker 的時候,broker 的網絡接收到數據後,copy 到 broker 的內核空間。然後通過 mmap 技術,broker 會修改消息頭,添加一些元數據。所以,寫入數據很快。當然順序 IO 也是關鍵技術。

函數原型:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

mmap 的內存即不在堆也不在棧上,是一塊獨立的空間。

4.1mmap()

mmap() 在調用進程的虛擬地址空間中創建一個新的映射。新映射的起始地址在 addr 中指定。length 參數指定映射的長度。

如果 addr 爲空,則內核選擇創建映射的地址;這是創建新映射的最可移植方法。如果 addr 不爲空,則內核將其作爲一個提示,提示將映射放置在何處;在 Linux 上,映射將在附近的頁面邊界處創建。新映射的地址作爲調用的結果返回。

文件映射的內容(與匿名映射相反;參見下面的 MAP_MAP_ANONYMOUS)使用文件描述符 fd 所引用的文件(或其他對象)中從偏移量 offset 開始的 length 字節進行初始化。offset 必須是 sysconf(_SC_PAGE_SIZE) 返回的頁面大小的倍數。

prot 參數描述了映射所需的內存保護(不得與文件的打開模式衝突)。它是 PROT_NONE 或以下一個或多個標誌的位 OR:

flags 參數確定映射的更新是否對映射相同區域的其他進程可見,以及更新是否傳遞到基礎文件。通過在標誌中包含以下值中的一個來確定此行爲:

此外,以下值中的零個或多個可以在 flag 中進行 “或” 運算:

返回值:

成功後,mmap() 返回指向映射區域的指針。錯誤時,返回值 MAP_FAILED(即,(void*)-1),並設置 errno 以指示錯誤原因。

4.2munmap()

munmap() 系統調用刪除指定地址範圍的映射,並導致對該範圍內地址的進一步引用生成無效內存引用。當進程終止時,區域也會自動取消映射。另一方面,關閉文件描述符不會取消區域映射。

地址 addr 必須是頁面大小的倍數(但長度不必是)。包含指定範圍一部分的所有頁面均未映射,對這些頁面的後續引用將生成 SIGSEGV。如果指示的範圍不包含任何映射頁,則不是錯誤。

返回值:

成功時,munmap() 返回 0。失敗時,它返回 - 1,errno 被設置爲指示錯誤原因(可能是 EINVAL)。

錯誤代碼

使用映射區域可產生以下信號:

流程

(1)打開文件
(2)取文件大小
(3)把文件映射成虛擬內存
(4)通過對內存的讀寫來實現對文件的讀寫
(5)卸載映射
(6)關閉文件

示例代碼:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define handle_error(msg) \
    do { perror(msg); exit(EXIT_FAILURE); } while (0)

int main(int argc, char *argv[])
{
    char *addr;
    int fd;
    struct stat sb;
    off_t offset, pa_offset;
    size_t length;
    ssize_t s;

    if (argc < 3 || argc > 4) {
        fprintf(stderr, "%s file offset [length]\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    fd = open(argv[1], O_RDONLY);
    if (fd == -1)
        handle_error("open");

    if (fstat(fd, &sb) == -1)           /* To obtain file size */
        handle_error("fstat");

    offset = atoi(argv[2]);
    pa_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);
        /* offset for mmap() must be page aligned */
    if (offset >= sb.st_size) {
        fprintf(stderr, "offset is past end of file\n");
        exit(EXIT_FAILURE);
    }

    if (argc == 4) {
        length = atoi(argv[3]);
        if (offset + length > sb.st_size)
            length = sb.st_size - offset;
                /* Can't display bytes past end of file */

    } else {    /* No length arg ==> display to end of file */
        length = sb.st_size - offset;
    }

    addr = mmap(NULL, length + offset - pa_offset, PROT_READ,
                MAP_PRIVATE, fd, pa_offset);
    if (addr == MAP_FAILED)
        handle_error("mmap");

    s = write(STDOUT_FILENO, addr + offset - pa_offset, length);
    if (s != length) {
        if (s == -1)
            handle_error("write");

        fprintf(stderr, "partial write");
        exit(EXIT_FAILURE);
    }

    exit(EXIT_SUCCESS);
}

shm * 接口

共享內存就是允許兩個不相關的進程訪問同一個內存塊。共享內存是在兩個正在運行的進程之間共享和傳遞數據的一種非常有效的方式。進程可以將同一段共享內存連接到它們自己的地址空間中,所有進程都可以訪問共享內存中的地址。而如果某個進程向共享內存寫入數據,所做的改動將立即影響到可以訪問同一段共享內存的任何其他進程。

共享內存並未提供同步機制,也就是說,在第一個進程結束對共享內存的寫操作之前,並無自動機制可以阻止第二個進程開始對它進行讀取。所以,通常需要用其他的機制來同步對共享內存的訪問,例如信號量。

shmget()

創建共享內存。函數原型:

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

描述:

shmget() 返回與參數 key 的值關聯的 System V 共享內存段的標識符。如果 key 的值爲 IPC_PRIVATE 或 key 不是 IPC_PRIVATE,不存在與 key 對應的共享內存段,並且在 shmflg 中指定了 IPC_CREAT,則會創建一個大小等於 size 值的新共享內存段(向上舍入爲 PAGE_SIZE 的倍數)。

如果 shmflg 同時指定 IPC_CREAT 和 IPC_ EXCL,並且 key 已經存在共享內存段,則 shmget() 將失敗,錯誤號設置爲 EEXIST。【這類似於 open() 的組合 O_CREAT|O_EXCL 的效果。】

值 shmflg 由以下組成:

除上述標誌外,shmflg 的最低有效 9 位指定授予所有者、組和其他人的權限。這些位的格式和含義與 open() 的模式參數相同。目前,系統不使用執行權限。

返回值:

成功後,將返回有效的共享內存標識符。出現錯誤時,返回 - 1,並設置 errno 以指示錯誤。

錯誤:

失敗時,錯誤號設置爲以下之一:

hmat()

啓動對該共享內存的訪問,並把共享內存連接到當前進程的地址空間,函數原型:

#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);

描述:

shmat() 將由 shmid 標識的 System V 共享內存段附加到調用進程的地址空間。附加地址由 shmaddr 根據以下標準之一指定:

除了 SHM_RND,還可以在 shmflg 位掩碼參數中指定以下標誌:

呼叫進程的 brk() 值不被附加改變。該段將在進程退出時自動分離。同一段可以作爲讀寫段附加在進程的地址空間中,並且可以多次附加。

成功的 shmat() 調用更新與共享內存段相關聯的 shmid_ds 結構的成員【參見 shmctl()】,如下所示:

返回值:

成功時,shmat() 返回附加共享內存段的地址;錯誤時,返回(void*)-1,並設置 errno 以指示錯誤原因。

錯誤:

當 shmat() 失敗時,errno 設置爲以下之一:

shmdt()

將共享內存從當前進程中分離。注意,將共享內存分離並不是刪除它,只是使該共享內存對當前進程不再可用。函數原型:

#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

描述:

shmdt() 將位於 shmaddr 指定地址的共享內存段從調用進程的地址空間中分離。要分離的段當前附加的 shmaddr 必須等於附加的 shmat() 調用返回的值。

參數 shmaddr 是 shmat() 函數返回的地址指針。

在成功調用 shmdt() 時,系統更新與共享內存段關聯的 shmid_ds 結構的成員,如下所示:

返回值:

成功時,shmdt() 返回 0;在出現錯誤時,返回 - 1,並設置 errno 以指示錯誤原因。

錯誤:

當 shmdt() 失敗時,errno 設置如下:

shmctl()

控制共享內存。函數原型:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

描述:

shmctl() 對系統 V 共享內存段執行 cmd 指定的控制操作,該段的標識符在 shmid 中給出。

buf 參數是指向 shmid_ds 結構的指針,如下:

struct shmid_ds {
	   struct ipc_perm shm_perm;    /* Ownership and permissions */
	   size_t          shm_segsz;   /* Size of segment (bytes) */
	   time_t          shm_atime;   /* Last attach time */
	   time_t          shm_dtime;   /* Last detach time */
	   time_t          shm_ctime;   /* Last change time */
	   pid_t           shm_cpid;    /* PID of creator */
	   pid_t           shm_lpid;    /* PID of last shmat(2)/shmdt(2) */
	   shmatt_t        shm_nattch;  /* No. of current attaches */
	   ...
};

ipc_perm 結構定義如下:

struct ipc_perm {
    key_t          __key;    /* Key supplied to shmget(2) */
    uid_t          uid;      /* Effective UID of owner */
    gid_t          gid;      /* Effective GID of owner */
    uid_t          cuid;     /* Effective UID of creator */
    gid_t          cgid;     /* Effective GID of creator */
    unsigned short mode;     /* Permissions + SHM_DEST and
                                SHM_LOCKED flags */
    unsigned short __seq;    /* Sequence number */
};

返回值:

成功的 IPC_INFO 或 SHM_INFO 操作將返回內核內部數組中記錄所有共享內存段信息的最高使用項的索引。(此信息可與重複的 SHM_STAT 操作一起使用,以獲得有關係統上所有共享內存段的信息。)成功的 SHM_STAT 操作返回其索引在 shmid 中給出的共享內存段標識符。其他操作成功時返回 0。

出現錯誤時,返回 - 1,並適當設置 errno。

流程

共享內存,可以大大加快對文件或設備的讀寫操作。共享內存的方式有 mmap 和 shmget 、 shmat。

所謂的零拷貝,就是不需要 CPU 的參與,而不是其他的意思,mmap 內部其實是一個 DMA 技術。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KZtgsJz5YMODoSa97GH9rA