JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

共享記憶體:最快的行程間通訊

管道、具名管道與訊息佇列,都把你的資料透過核心抄一份過去。共享記憶體做的事更奇怪、也更快:它把同一塊實體分頁同時對映進兩個位址空間,於是一個行程寫下的東西,對另一個行程而言就「直接在那裡」了——沒有抄寫、沒有系統呼叫、資料路徑上沒有核心。那份速度有它的帳單,而這篇對速度與帳單都誠實。

為什麼其他 IPC 工具比看起來慢

在這個章節裡你目前建起的一切,搬資料的方式都是透過核心抄一份。當你 write() 進一條匿名管道或一個訊息佇列時,那些位元組從你的緩衝區出發、跨過系統呼叫邊界進到核心記憶體、停在那裡,然後在讀者 read() 時,再被抄第二次出去到讀者的緩衝區。每一則訊息要抄兩次、要進核心兩趟。對一個聊天程式或一條 shell 管線而言,那便宜得讓人看不見。但對兩個每秒要鏟六十次、每次一百萬位元組像素的行程而言,那些抄寫與系統呼叫成本就開始成為主宰。

共享記憶體把中間人徹底拿掉。一旦你有了虛擬記憶體那一章的底子,這個想法聽起來幾乎像作弊:核心不在兩個各自私有的位址空間之間抄位元組,而是安排讓一塊實體記憶體區域,同時出現在兩個行程的位址空間裡。設定完成之後,沒有抄寫、也沒有系統呼叫——行程 A 在它的指標處存一個值,而行程 B 透過它自己的指標去讀,就直接看見了,因為兩個指標最終都轉譯到同一塊實體分頁。這正是為什麼,一個位元組對一個位元組地算,共享記憶體是作業系統提供的最快 IPC 機制。

同一個機制,兩張臉:把同一塊分頁對映兩次

要看出這為什麼有可能成立,回想一下虛擬記憶體那一章。每個行程都有自己的虛擬位址空間,而分頁表就是把每個虛擬分頁對映到一個實體分頁的東西。正常情況下,行程 A 的分頁 0x4000 與行程 B 的分頁 0x4000,指向不同的實體框——那份隔離正是虛擬記憶體的全部用意。共享記憶體,就是當你刻意在一個受控的點上打破那份隔離時所發生的事:你請求核心把 A 裡的一個分頁表項以及 B 裡的一個分頁表項,指向同一塊實體框

兩邊的位址不必相同。那塊共享區域在 A 裡可能落在 0x7f00_1000、在 B 裡落在 0x5500_a000;要緊的是兩段虛擬範圍,透過各自獨立的分頁表,往下都轉譯到同一組共享的實體框。這也正是為什麼你絕不能把一個原始指標存進共享記憶體裡:一個在 A 裡有效的位址,在 B 裡毫無意義。如果兩個行程需要在區域內把資料結構串起來,它們存的是相對於對映起點的偏移量,而不是絕對指標。這是一個經典、令人痛的第一個臭蟲,而它的解法是一種紀律,不是一個旗標。

要設定這件事,POSIX 有兩條路徑,而它們共用同一個最後步驟。較舊的 System V 家族用 shmget()、shmat() 與 shmdt()。現代、對檔案描述符友善的路徑,用 shm_open() 建立一個具名的共享記憶體物件、用 ftruncate() 給它一個大小,然後用 mmap() 把它對映進你的位址空間。不論哪一條,真正建立起共享對映的那個動作是 mmap()(或 shmat()),而它把一個指向區域起點的指標交回給你。從那個指標起,它就是普通的記憶體了——你用單純的 C 去讀它、寫它,不再有任何系統呼叫。

一個完整、有檢查錯誤的範例

這是現代 POSIX 的食譜,濃縮在一支小程式裡:建立一個具名物件、給它大小、把它對映、然後把一個計數器寫進去。第二支程式(或一個 fork 出來的子行程)只要開啟同一個名字並對映它,就會讀到我們剛剛寫的東西,一字不差。注意每一個可能失敗的呼叫都被檢查了——shm_open()、ftruncate() 與 mmap() 各有自己的失敗回傳值,而一支忽略它們的程式,會在離真正錯誤很遠的地方崩潰。mmap() 尤其不是在失敗時回傳 NULL;它回傳特殊值 MAP_FAILED,所以你必須拿它去比對,而不是拿 0。

#include <sys/mman.h>   /* shm_open, mmap */
#include <sys/stat.h>   /* mode constants  */
#include <fcntl.h>      /* O_* flags       */
#include <unistd.h>     /* ftruncate, close */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define SHM_NAME "/demo_counter"
#define SHM_SIZE 4096          /* one page is plenty */

int main(void) {
    /* 1. create (or open) the named shared object */
    int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0600);
    if (fd == -1) { perror("shm_open"); return 1; }

    /* 2. give the object a real size */
    if (ftruncate(fd, SHM_SIZE) == -1) { perror("ftruncate"); return 1; }

    /* 3. map it into our address space */
    void *base = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    if (base == MAP_FAILED) { perror("mmap"); return 1; }

    close(fd);             /* the mapping survives the closed fd */

    /* 4. now it is just memory: write through the pointer */
    unsigned long *counter = base;
    *counter = 0;
    for (int i = 0; i < 5; i++) (*counter)++;
    printf("counter is now %lu\n", *counter);   /* prints 5 */

    munmap(base, SHM_SIZE);
    shm_unlink(SHM_NAME); /* remove the name when truly done */
    return 0;
}

/* build:  gcc -O2 -Wall main.c -lrt  &&  ./a.out */
MAP_SHARED 是讓寫入對其他對映者可見的那個旗標——它的反面 MAP_PRIVATE,給的是一份寫入時複製的副本,別人都看不見。在 Linux 上你用 -lrt 連結以取得 shm_open()。關掉那個檔案描述符並不會解除區域的對映;對映會一直活到 munmap() 或行程結束為止。

共享記憶體不替你做的那部分

現在是誠實、難的那一半。上面那個範例之所以行得通,只因為只有一個行程去碰那個計數器。當兩個行程在毫無協調之下寫同一塊區域的那一瞬間,你就回到了同步那一章裡一模一樣的失敗:(counter)++ 不是一個不可分割的步驟,而是三個——載入、加一、存回——而兩個行程可以交錯,使得一次遞增被無聲地吞掉。這是一個資料競爭,而作業系統不會警告你。位元組是共享的;輪流*不是。共享記憶體就是赤裸的共用可變狀態,而赤裸的共用可變狀態,是一個正等著一個能觸發它的排程到來的臭蟲。

所以共享記憶體幾乎從不單獨使用。它總是搭配一個兩個行程都看得見的同步原語——最常見的是一個具名號誌(sem_open()),或一個放在共享區域自身裡頭的「行程共享」互斥鎖。一個號誌跨越行程邊界的運作方式,和它跨越執行緒時一模一樣:你在碰資料前 wait、在碰完後 post,於是一次只有一個行程身處臨界區。這個模式和執行緒的情況完全相同;只是範圍從一個位址空間擴大到了兩個。

還有一個會讓人意外的、誠實的缺口。執行緒那一章裡一個普通的、行程私有的互斥鎖,預設情況下在共享記憶體裡不會運作——一個 pthread 互斥鎖必須在建立時設好 PTHREAD_PROCESS_SHARED 屬性,否則它只在建立它的那一個行程內有效。而記憶體的可見性也有它自己的細微處:核心 0 的一次寫入,在沒有正確的屏障下,不保證對核心 1 立刻可見。好消息是你幾乎從不需要直接去推敲那件事:一個正確使用的號誌或行程共享互斥鎖,本身就已經包含了記憶體屏障,所以只要你在每一次存取外圍都上鎖,順序就會自己照顧好自己。

把它湊起來,以及它落在哪裡

這是一個典型共享記憶體通道完整的一生,從頭到尾,好讓這些零件扣進同一張圖裡。

  1. 一個行程建立物件:用帶 O_CREAT 的 shm_open(),再用 ftruncate() 把它的大小設成多少位元組。
  2. 每個行程把它對映:帶 MAP_SHARED 的 mmap() 回傳一個指向自己位址空間的指標(不同行程之間,位址可能不同)。
  3. 它們講好一個版面:比方說偏移量 0 處有一個結構,裝著一個長度與一個緩衝區——在區域內部,用偏移量、絕不用絕對指標。
  4. 對共享資料的每一次讀或寫,都被包在 sem_wait()/sem_post()(或一個行程共享互斥鎖的 lock/unlock)裡頭,於是一次只有一個行程在裡面。
  5. 關閉時每個行程呼叫 munmap();真正最後完成的那個呼叫 shm_unlink() 來移除名字,否則那個物件會一直賴到重開機。

退一步,掂量這個取捨。共享記憶體是 IPC 工具箱裡最快的工具,因為設定完之後,核心完全退出了資料路徑——沒有抄寫、每則訊息沒有系統呼叫。你為那份速度付出的代價,是核心過去替你扛的那兩份責任:同步(號誌或共享互斥鎖由你提供)與版面紀律(用偏移量、不用指標;講好的結構)。當純粹的吞吐量要緊、而那些行程又在同一台機器上時——影格、大型資料集、高頻狀態——就伸手拿它。反過來,當你想讓核心替你處理順序、訊息邊界與流量控制,而抄寫又不是你的瓶頸時,就伸手拿一條管道、一個訊息佇列,或一個 Unix 網域通訊端

有一條線貫穿你在這個章節碰過的一切:一條管道、一個具名管道、一個訊號、共享記憶體——每一個都是對「兩個各自獨立的行程,要怎麼碰到同一份資訊?」的不同回答。共享記憶體是最信任你、也最不保護你的那個回答。最後一篇,等待許多事物:select 與 poll,會收束這個章節,去處理真實 IPC 程式的另一半——不是怎麼搬資料,而是怎麼有效率地同時等好幾個通道,又不至於把一顆 CPU 燒在空轉上。