1. 程式人生 > 實用技巧 >宋寶華:世上最好的共享記憶體(Linux共享記憶體最透徹的一篇)【轉】

宋寶華:世上最好的共享記憶體(Linux共享記憶體最透徹的一篇)【轉】

轉自:https://cloud.tencent.com/developer/article/1551288

共享單車、共享充電寶、共享雨傘,世間的共享有千萬種,而我獨愛共享記憶體。

早期的共享記憶體,著重於強調把同一片記憶體,map到多個程序的虛擬地址空間(在相應程序找到一個VMA區域),以便於CPU可以在各個程序訪問到這片記憶體。

現階段廣泛應用於多媒體、Graphics領域的共享記憶體方式,某種意義上不再強調對映到程序虛擬地址空間的概念(那無非是為了讓CPU訪問),而更強調以某種“控制代碼”的形式,讓大家知道某一片視訊、圖形影象資料的存在並可以藉助此“控制代碼”來跨程序引用這片記憶體,讓視訊encoder、decoder、

GPU等可以跨程序訪問記憶體。所以不同程序用的加速硬體其實是不同的,他們更在乎的是可以通過一個handle拿到這片記憶體,而不再特別在乎CPU訪問它的虛擬地址(當然仍然可以對映到程序的虛擬地址空間供CPU訪問)。

只要記憶體的拷貝(memcpy)仍然是一個佔據記憶體頻寬、CPU利用率的消耗大戶存在,共享記憶體作為Linux程序間通訊、計算機系統裡各個不同硬體元件通訊的最高效方法,都將持續繁榮。關於記憶體拷貝會大多程度地佔據CPU利用率,這個可以最簡單地嘗試拷貝1080P,幀率每秒60的電影畫面,我保證你的系統的CPU,蛋會疼地不行。

我早就想系統地寫一篇綜述Linux裡面各種共享記憶體方式的文章了,但是一直被帶娃這個事業牽絆,今日我決定頂著娃娃們的山呼海嘯,也要寫一篇文章不吐不快。

共享記憶體的方式有很多種,目前主流的方式仍然有:

共享記憶體的方式

1.基於傳統SYS V的共享記憶體;

2.基於POSIX mmap檔案對映實現共享記憶體;

3.通過memfd_create()和fd跨程序共享實現共享記憶體;

4.多媒體、圖形領域廣泛使用的基於dma-buf的共享記憶體。

SYS V共享記憶體

歷史悠久、年代久遠、API怪異,對應核心程式碼linux/ipc/shm.c,當你編譯核心的時候不選擇CONFIG_SYSVIPC,則不再具備此能力。

你在Linux敲ipcs命令看到的share memory就是這種共享記憶體:

下面寫一個最簡單的程式來看共享記憶體的寫端sw.c:

以及共享記憶體的讀端sr.c:

編譯和準備執行:

在此之前我們看一下系統的free:

下面執行sw和sr:

我們發現sr打印出來的和sw寫進去的是一致的。這個時候我們再看下free:

可以看到used顯著增大了(711632 -> 715908), shared顯著地增大了(2264 -> 6360),而cached這一列也顯著地增大326604->330716。

我們都知道cached這一列統計的是file-backed的檔案的page cache的大小。理論上,共享記憶體屬於匿名頁,但是由於這裡面有個非常特殊的tmpfs(/dev/shm指向/run/shm,/run/shm則mount為tmpfs):

所以可以看出tmpfs的東西其實真的是有點含混:我們可以理解它為file-backed的匿名頁(anonymous page),有點類似女聲中的周深。前面我們反覆強調,匿名頁是沒有檔案背景的,這樣當進行記憶體交換的時候,是與swap分割槽交換。磁碟檔案系統裡面的東西在記憶體的副本是file-backed的頁面,所以不存在與swap分割槽交換的問題。但是tmpfs裡面的東西,真的是在統計意義上統計到page cache了,但是它並沒有真實的磁碟背景,這又和你訪問磁碟檔案系統裡面的檔案產生的page cache有本質的區別。所以,它是真地有那麼一點misc的感覺,凡事都沒有絕對,唯有變化本身是不變的。

也可以通過ipcs找到新建立的SYS V共享記憶體:

POSIX共享記憶體

我對POSIX shm_open()、mmap () API系列的共享記憶體的喜愛,遠遠超過SYS V 100倍。原諒我就是一個懶惰的人,我就是討厭ftok、shmget、shmat、shmdt這樣的API。

上面的程式如果用POSIX的寫法,可以簡化成寫端psw.c:

讀端:

編譯和執行:

這樣我們會在/dev/shm/、/run/shm下面看到一個檔案:

坦白講,mmap、munmap這樣的API讓我找到了回家的感覺,剛入行做Linux的時候,寫好framebuffer驅動後,就是把/dev/fb0 mmap到使用者空間來操作,所以mmap這樣的 API,真的是特別親切,像親人一樣。

當然,如果你不喜歡shm_open()這個API,你也可以用常規的open來開啟檔案,然後進行mmap。關鍵的是mmap,wikipedia如是說:

mmap

In computing, mmap(2) is a POSIX-compliant Unix system call that maps files or devices into memory. It is a method of memory-mapped file I/O. It implements demand paging, because file contents are not read from disk directly and initially do not use physical RAM at all. The actual reads from disk are performed in a "lazy" manner, after a specific location is accessed. After the memory is no longer needed, it is important to munmap(2) the pointers to it. Protection information can be managed using mprotect(2), and special treatment can be enforced using madvise(2).

POSIX的共享記憶體,仍然符合我們前面說的tmpfs的特點,在運行了sw,sr後,再執行psw和psr,我們發現free命令再次戲劇性變化:

請將這個free命令的結果與前2次的free結果的各個欄位進行對照:

第3次比第2次的cached大了這麼多?是因為我編寫這篇文章邊在訪問磁盤裡面的檔案,當然POSIX的這個共享記憶體本身也導致cached增大了。

memfd_create

如果說POSIX的mmap讓我找到回家的感覺,那麼memfd_create()則是萬般驚豔。見過這種API,才知道什麼叫天生尤物——而且是尤物中的尤物,它完全屬於那種讓碼農第一眼看到就會兩眼充血,恨不得眼珠子奪眶而出貼到它身上去的那種API;一般人見到它第一次,都會忽略了它的長相,因為它的身材實在太火辣太搶眼了。

先不要浮想聯翩,在所有的所有開始之前,我們要先提一下跨程序分享fd(檔案描述符,對應我們很多時候說的“控制代碼”)這個重要的概念。

眾所周知,Linux的fd屬於一個程序級別的東西。進入每個程序的/proc/pid/fd可以看到它的fd的列表:

這個程序的0,1,2和那個程序的0,1,2不是一回事。

某年某月的某一天,人們發現,一個程序其實想訪問另外一個程序的fd。當然,這只是目的不是手段。比如程序A有2個fd指向2片記憶體,如果程序B可以拿到這2個fd,其實就可以透過這2個fd訪問到這2片記憶體。這個fd某種意義上充當了一箇中間媒介的作用。有人說,那還不簡單嗎,如果程序A:

fd = open();

open()如果返回100,把這個100告訴程序B不就可以了嗎,程序B訪問這個100就可以了。這說明你還是沒搞明白fd是一個程序內部的東西,是不能跨程序的概念。你的100和我的100,不是一個東西。這些基本的東西你搞不明白,你搞別的都是白搭。

Linux提供一個特殊的方法,可以把一個程序的fd甩鍋、踢皮球給另外一個程序(其實“甩鍋”這個詞用在這裡不合適,因為“甩鍋”是一種推卸,而fd的傳遞是一種分享)。我特碼一直想把我的bug甩(分)鍋(享)出去,卻發現總是被人把bug甩鍋過來。

那麼如何甩(分)鍋(享)fd呢?

Linux裡面的甩鍋需要藉助cmsg,用於在socket上傳遞控制訊息(也稱Ancillary data),使用SCM_RIGHTS,程序可以透過UNIX Socket把一個或者多個fd(file descriptor)傳遞給另外一個程序。

比如下面的這個函式,可以透過socket把fds指向的n個fd傳送給另外一個程序:

而另外一個程序,則可以透過如下函式接受這個fd:

那麼問題來了,如果在程序A中有一個檔案的fd是100,傳送給程序B後,它還是100嗎?不能這麼簡單地理解,fd本身是一個程序級別的概念,每個程序有自己的fd的列表,比如程序B收到程序A的fd的時候,程序B自身fd空間裡面自己的前面200個fd都已經被佔用了,那麼程序B接受到的fd就可能是201。數字本身在Linux的fd裡面真地是一點都不重要,除了幾個特殊的0,1,2這樣的數字外。同樣的,如果你把 cat /proc/interrupts 顯示出的中斷號就看成是硬體裡面的中斷偏移號碼(比如ARM GIC裡某號硬體中斷),你會發現,這個關係整個是一個瞎扯。

知道了甩鍋API,那麼重要的是,當它與memfd_create()結合的時候,我們準備甩出去的fd是怎麼來?它是memfd_create()的返回值。

memfd_create()這個函式的玄妙之處在於它會返回一個“匿名”記憶體“檔案”的fd,而它本身並沒有實體的檔案系統路徑,其典型用法如下:

我們透過memfd_create()建立了一個“檔案”,但是它實際對映到一片記憶體,而且在/xxx/yyy/zzz這樣的檔案系統下沒有路徑!沒有路徑!沒有路徑!

所以,當你在Linux裡面程式設計的時候,碰到這樣的場景:需要一個fd,當成檔案一樣操作,但是又不需要真實地位於檔案系統,那麼,就請立即使用memfd_create()吧,它的manual page是這樣描述的:

memfd_create

memfd_create() creates an anonymous file and returns a file descriptor that refers to it. The file behaves like a regular file, and so can be modified, truncated, memory-mapped, and so on. However, unlike a regular file, it lives in RAM and has a volatile backing storage.

重點理解其中的regular這個單詞。它的行動像一個regular的檔案,但是它的背景卻不regular。

那麼,它和前面我們說的透過UNIX Socket甩鍋fd又有什麼關係呢?memfd_create()得到了fd,它在行為上類似規則的fd,所以也可以透過socket來進行甩鍋,這樣A程序相當於把一片與fd對應的記憶體,分享給了程序B。

下面的程式碼程序A通過memfd_create()建立了2片4MB的記憶體,並且透過socket(路徑/tmp/fd-pass.socket)傳送給程序B這2片記憶體對應的fd:

下面的程式碼程序B透過相同的socket接受這2片記憶體對應的fd,之後通過read()讀取每個檔案的前256個位元組並列印:

上述程式碼參考了:

https://openforums.wordpress.com/2016/08/07/open-file-descriptor-passing-over-unix-domain-sockets/

上述的程式碼中,程序B是在進行read(fds[i], buffer, sizeof(buffer)),這體現了基於fd進行操作的regular特點。當然,如果是共享記憶體,現實的程式碼肯定還是多半會是mmap:

mmap(NULL, SIZE, PROT_READ, MAP_SHARED, fd, 0);

那麼,透過socket傳送memfd_create() fd來進行程序間共享記憶體這種方法,它究竟驚豔在哪裡?

我認為首要的驚豔之處在於程式設計模型的驚豔。API簡單、靈活、通用。程序之間想共享幾片記憶體共享幾片記憶體,想怎麼共享怎麼共享,想共享給誰共享給誰,無非是多了幾個fd和socket的傳遞過程。比如,我從網際網路上面收到了jpeg的視訊碼流,一幀幀的畫面,程序A可以建立多片buffer來快取畫面,然後就可以透過把每片buffer對應的fd,遞交給另外的程序去解碼等。Avenue to Jane(大道至簡),簡單的才是最好的!

memfd_create()的另外一個驚豔之處在於支援“封印”(sealing,就是你玩遊戲的時候的封印),sealing這個單詞本身的意思是封條,在這個場景下,我更願意把它翻譯為“封印”。中國傳說中的封印,多是採用如五行、太極、八卦等手段,並可有例如符咒、法器等物品的輔助。現指對某個單位施加一種力量,使其無法正常使用某些能力的本領(常出現於玄幻及神魔類作品,遊戲中)。我這一生,最喜歡玩的遊戲就是《仙劍奇俠傳》和《軒轅劍——天之痕》,不知道是否暴露年齡了。

採用memfd_create()的場景下,我們同樣可以用某種法器,來控制共享記憶體的shrink、grow和write。最初的設想可以詳見File Sealing & memfd_create()這篇文章:

https://lwn.net/Articles/591108/

我們如果在共享記憶體上施加了這樣的封印,則可以限制對此片區域的ftruncate、write等動作,並建立某種意義上程序之間的相互信任,這是不是很拉風?

還記得鎮壓孫悟空的五行山頂的封印嗎?還記得孫悟空的緊箍咒嗎?還記得悟空每次離開師傅的時候在師傅周圍畫的一個圈嗎?

封印

* SEAL_SHRINK: If set, the inode size cannot be reduced * SEAL_GROW: If set, the inode size cannot be increased * SEAL_WRITE: If set, the file content cannot be modified

File Sealing & memfd_create()文中舉到的一個典型使用場景是,如果graphics client把它與graphics compoistor共享的記憶體交給compoistor去render,compoistor必須保證可以拿到這片記憶體。這裡面的風險是client可能透過ftruncate()把這個memory shrink小,這樣compositor就拿不到完整的buffer,會造成crash。所以compositor只願意接受含有SEAL_SHRINK封印的fd,如果沒有,對不起,我們不能一起去西天取經。

在支援memfd_create()後,我們應儘可能地使用這種方式來替代傳統的POSIX和SYS V,基本它也是一個趨勢,比如我們在wayland相關專案中能看到這樣的patch:

dma_buf

dma_buf定義

The DMABUF framework provides a generic method for sharing buffers between multiple devices. Device drivers that support DMABUF can export a DMA buffer to userspace as a file descriptor (known as the exporter role), import a DMA buffer from userspace using a file descriptor previously exported for a different or the same device (known as the importer role), or both.

簡單地來說,dma_buf可以實現buffer在多個裝置的共享,應用可以把一片底層驅動A的buffer匯出到使用者空間成為一個fd,也可以把fd匯入到底層驅動 B。當然,如果進行mmap()得到虛擬地址,CPU也是可以在使用者空間訪問到已經獲得使用者空間虛擬地址的底層buffer的。

上圖中,程序A訪問裝置A並獲得其使用的buffer的fd,之後通過socket把fd傳送給程序B,而後程序B匯入fd到裝置B,B獲得對裝置A中的buffer的共享訪問。如果CPU也需要在使用者態訪問這片buffer,則進行了mmap()動作。

為什麼我們要共享DMA buffer?想象一個場景:你要把你的螢幕framebuffer的內容透過gstreamer多媒體元件的服務,變成h264的視訊碼流,廣播到網路上面,變成流媒體播放。在這個場景中,我們就想盡一切可能的避免記憶體拷貝。

技術上,管理framebuffer的驅動可以把這片buffer在底層實現為dma_buf,然後graphics compositor給這片buffer映射出來一個fd,之後透過socket傳送fd 把這篇記憶體交給gstreamer相關的程序,如果gstreamer相關的“color space硬體轉換”元件、“H264編碼硬體元件”可以透過收到的fd還原出這些dma_buf的地址,則可以進行直接的加速操作了。比如color space透過接收到的fd1還原出framebuffer的地址,然後把轉化的結果放到另外一片dma_buf,之後fd2對應這片YUV buffer被共享給h264編碼器,h264編碼器又透過fd2還原出YUV buffer的地址。

這裡面的核心點就是fd只是充當了一個“控制代碼”,使用者程序和裝置驅動透過fd最終尋找到底層的dma_buf,實現buffer在程序和硬體加速元件之間的zero-copy,這裡面唯一進行了exchange的就是fd。

再比如,如果把方向反過來,gstreamer從網路上收到了視訊流,把它透過一系列動作轉換為一片RGB的buffer,那麼這片RGB的buffer最終還要在graphics compositor裡面渲染到螢幕上,我們也需要透過dma_buf實現記憶體在video的decoder相關元件與GPU元件的共享。

Linux核心的V4L2驅動(encoder、decoder多采用此種驅動)、DRM(Direct Rendering Manager,framebuffer/GPU相關)等都支援dma_buf。比如在DRM之上,程序可以透過

int drmPrimeHandleToFD(int fd,
uint32_t handle,
uint32_t flags,
int * prime_fd 
)

獲得底層framebuffer對應的fd。如果這個fd被分享給gstreamer相關程序的video的color space轉換,而color space轉換硬體元件又被實現為一個V4L2驅動,則我們可以透過V4L2提供的如下介面,將這片buffer提供給V4L2驅動供其匯入:

如果是multi plane的話,則需要匯入多個fd:

相關細節可以參考這個文件:

https://linuxtv.org/downloads/v4l-dvb-apis/uapi/v4l/dmabuf.html

一切都是檔案!不是檔案創造條件也要把它變成檔案!這就是Linux的世界觀。是不是檔案不重要,關鍵是你得覺得它是個檔案。在dma_buf的場景下,fd這個東西,純粹就是個"控制代碼",方便大家通過這麼一個fd能夠對應到最終硬體需要訪問的buffer。所以,透過fd的分享和傳遞,實際實現跨程序、跨裝置(包括CPU)的記憶體共享。

如果說前面的SYS V、POSIX、memfd_create()更加強調記憶體在程序間的共享,那麼dma_buf則更加強調記憶體在裝置間的共享,它未必需要跨程序。比如:

有的童鞋說,為嘛在一個程序裡面裝置A和B共享記憶體還需要fd來倒騰一遍呢?我直接裝置A驅動弄個全域性變數存buffer的實體地址,裝置B的驅動訪問這個全域性變數不就好了嗎?我只能說,你對Linux核心的只提供機制不提供策略,以及軟體工程每個模組各司其責,高內聚和低耦合的理解,還停留在裸奔的階段。在沒有dma_buf等類似機制的情況下,如果使用者空間仍然負責構建策略並連線裝置A和B,人們為了追求程式碼的乾淨,往往要進行這樣的記憶體拷貝:

dma_buf的支援依賴於驅動層是否實現了相關的callbacks。比如在v4l2驅動中,v4l2驅動支援把dma_buf匯出(前面講了v4l2也支援dma_buf的匯入,關鍵看資料方向),它的程式碼體現在:

drivers/media/common/videobuf2/videobuf2-dma-contig.c中的:

其中的vb2_dc_dmabuf_ops是一個struct dma_buf_ops,它含有多個成員函式:

當用戶call VIDIOC_EXPBUF這個IOCTL的時候,可以把dma_buf轉化為fd:

int ioctl(int fd, VIDIOC_EXPBUF, struct v4l2_exportbuffer *argp);

對應著驅動層的程式碼則會呼叫dma_buf_fd():

應用程式可以通過如下方式拿到底層的dma_buf的fd:

dma_buf的匯入側裝置驅動,則會用到如下這些API:

dma_buf_attach()
dma_buf_map_attachment()
dma_buf_unmap_attachment()
dma_buf_detach()

下面這張表,是筆者對這幾種共享記憶體方式總的歸納:

落花滿天蔽月光,借一杯附薦鳳台上。

全劇終

本文分享自微信公眾號 -Linux閱碼場(LinuxDev),作者:宋寶華

原文出處及轉載資訊見文內詳細說明,如有侵權,請聯絡[email protected]刪除。

原始發表時間:2019-12-09

本文參與騰訊雲自媒體分享計劃,歡迎正在閱讀的你也加入,一起分享。