跳轉到

4.2 Unix domain socket 與共享記憶體#

本機 IPC 的兩個極端#

程序間通訊(IPC)的候選方案很多:管線(2.3 節)、訊號(2.2 節)、訊息佇列(System V, 1983 / POSIX mq)、共享記憶體(shm)、Unix domain socket(UDS,BSD 4.2, 1983)。性能與易用性的兩端是:

  • UDS:語法幾乎與 TCP 相同,但走核心內部路徑,不走網路協定棧(無 TCP/IP header、無 checksum、無擁塞控制),本機延遲顯著更低。
  • 共享記憶體:零拷貝的極致——兩個程序的虛擬位址映射到同一塊物理頁,讀寫就是記憶體存取,快到沒有「系統呼叫開銷」可言;代價是同步完全自理。

D-Bus(2003,Red Hat 為桌面程序通訊設計)底層就是 UDS;Docker daemon 與 containerd(2016 起)之間也是 UDS;PostgreSQL、Redis、Chrome 多程序架構全都用 UDS——因為它還有一個殺手級功能:可以傳遞 fd 本身(後述)。

Unix domain socket#

use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};

fn main() -> std::io::Result<()> {
    let _ = std::fs::remove_file("/tmp/echo.sock");   // bind 前要清掉舊檔(檔案系統即命名空間)
    let listener = UnixListener::bind("/tmp/echo.sock")?;
    for stream in listener.incoming() {
        let mut stream = stream?;
        std::thread::spawn(move || {
            let mut buf = [0u8; 1024];
            loop {
                match stream.read(&mut buf) {
                    Ok(0) | Err(_) => break,
                    Ok(n) => { stream.write_all(&buf[..n]).ok(); }
                }
            }
        });
    }
    Ok(())
}

UDS 位址就是檔案系統路徑(另一種是「抽象命名空間」,Linux 特有,名稱不落盤)。這帶來 Unix 權限模型的免費紅利:誰能連,由檔案權限決定——chmod 660 /tmp/echo.sock 就完成了 TCP 需要應用層認證才能做到的事。Docker 把 /var/run/docker.sock 設為 root 群組可寫,就是用檔案權限當存取控制。

傳遞 fd:SCM_RIGHTS#

UDS 獨有的魔法:透過輔助訊息(ancillary data)把一個 fd 從程序 A「傳」給程序 B——核心把 A 的開檔描述複製進 B 的 fd 表。這是 fd-passing 認證架構(1980 年代 acc 分散式系統思想)的基礎,也是 systemd socket activation、無 root 特權代理監聽埠的原理。

// 用 libc 傳遞 fd(核心機制 SCM_RIGHTS,Linux 2.4 起)
unsafe fn send_fd(sock_fd: i32, fd_to_send: i32) -> i32 {
    let mut iov = libc::iovec {
        iov_base: b"x".as_ptr() as *mut libc::c_void,
        iov_len: 1,
    };
    let mut cmsg_buf = [0u8; 64];
    let hdr_size = libc::CMSG_SPACE(sizeof_cmsghdr()) as usize;
    let msg = libc::msghdr {
        msg_name: std::ptr::null_mut(),
        msg_namelen: 0,
        msg_iov: &mut iov,
        msg_iovlen: 1,
        msg_control: cmsg_buf.as_mut_ptr() as *mut libc::c_void,
        msg_controllen: hdr_size,
        msg_flags: 0,
    };
    let cmsg = libc::CMSG_FIRSTHDR(&msg);
    (*cmsg).cmsg_level = libc::SOL_SOCKET;
    (*cmsg).cmsg_type = libc::SCM_RIGHTS;
    (*cmsg).cmsg_len = libc::CMSG_LEN(sizeof_cmsghdr()) as usize;
    std::ptr::copy_nonoverlapping(
        &fd_to_send as *const i32 as *const u8,
        libc::CMSG_DATA(cmsg), std::mem::size_of::<i32>());
    libc::sendmsg(sock_fd, &msg, 0)
}

(sizeof_cmsghdr 為 std::mem::size_of::<libc::cmsghdr>();接收端用 recvmsg 對稱取回。)這段 unsafe 很長,恰好示範 Rust 生態的分工:nix crate 提供 sendmsg/RecvMsg 的安全包裝,實務上直接用它,讓這類程式碼只出現在庫的底層。

共享記憶體#

POSIX shm(shm_open + mmap,POSIX.1b, 1993)與 System V shm(1970 年代 System III 遺產)並存;Linux 也有匿名 MAP_SHARED|MAP_ANONYMOUS。流程:

  1. shm_open("/demo", O_CREAT|O_RDWR, 0666) → 在 /dev/shm/demo 建立物件,得到 fd
  2. ftruncate(fd, size) 設定大小
  3. mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0) → 各自程序拿到指標
use std::sync::atomic::{AtomicU64, Ordering};

const SIZE: usize = 4096;

fn main() -> std::io::Result<()> {
    let name = b"/rust_demo_shm\0";
    let fd = unsafe {
        libc::shm_open(name.as_ptr() as *const _, libc::O_CREAT | libc::O_RDWR, 0o666)
    };
    assert!(fd >= 0, "{}", std::io::Error::last_os_error());
    unsafe { libc::ftruncate(fd, SIZE as i64) };

    let ptr = unsafe {
        libc::mmap(std::ptr::null_mut(), SIZE,
                   libc::PROT_READ | libc::PROT_WRITE,
                   libc::MAP_SHARED, fd, 0)
    };
    assert_ne!(ptr, libc::MAP_FAILED);

    // 把共享頁開頭當成 AtomicU64 計數器
    let counter = unsafe { &*(ptr as *const AtomicU64) };

    let args: Vec<String> = std::env::args().collect();
    match args.get(1).map(|s| s.as_str()) {
        Some("writer") => loop {
            let v = counter.fetch_add(1, Ordering::SeqCst);
            println!("wrote {v}");
            std::thread::sleep(std::time::Duration::from_secs(1));
        },
        _ => loop {
            println!("read = {}", counter.load(Ordering::SeqCst));
            std::thread::sleep(std::time::Duration::from_secs(1));
        },
    }
}

執行:

$ cargo run -- reader   # 終端 1
$ cargo run -- writer   # 終端 2,reader 立即看到數字

共享記憶體的正確性:Rust 型別系統的角色#

共享記憶體最恐怖之處是兩個程序、同一塊記憶體、快取一致性靠你自己的同步。經典錯誤:

struct shared { int ready; char data[100]; };
// writer: 把 data 寫好 → ready = 1
// reader: 看到 ready == 1 → 讀 data

在多核 CPU 上這段是未定義的:compiler 與 CPU 都可能重排(store buffer、寫合併),reader 可能看到 ready==1 但 data 還是舊的。需要 memory barrier。Rust 的答案仍是顯式原子:把 ready 定義為 AtomicU64,用 Release(writer)/Acquire(reader)配對建立 happens-before 邊緣——與 3.1 節執行緒間的規則完全一致,只是這裡跨的是程序。而 &*(ptr as *const AtomicU64) 這個 unsafe 轉換的安全性依賴「對齊、初始化、無 aliasing 可變引用」三個不變量,正是應該集中註解的 unsafe 邊界(1.3 節)。

共享頁裡放 Mutex(跨程序)則需要 pthread_mutexattr_setpshared(PTHREAD_PROCESS_SHARED)——Linux futex 機制(2003 年核心 2.6 引入)在程序間同樣可用。

何時用哪個#

需求 選擇
程序間請求/回應、權限控制 UDS(簡單、安全)
大量資料高速傳輸(影音幀、行情) shm + 原子/鎖同步
傳遞開啟的資源 UDS + SCM_RIGHTS
單向資料流 管線即可

習題#

  1. 給 UDS 伺服器加上 chmod 權限控制,實驗不同使用者連線行為。
  2. 用 nix crate 重寫 fd-passing,比較 unsafe 行數。
  3. 在 shm 範例中把 Ordering::SeqCst 降級為 Relaxed,並說明(或用計數器實驗)會看到什麼錯誤現象。