跳轉到

2.3 管線、檔案描述器與 std::os::unix::io#

一切皆檔案:fd 是 Unix 的通用介面#

1970 年代的 Unix 給了世界一個統一抽象:檔案描述器(file descriptor)——一個小整數,代表開啟的資源。0/1/2 固定是 stdin/stdout/stderr(shell 的慣例由 1970 年代初確立),之後 open 出的檔案、socket、pipe 依序佔用號碼。所有讀寫都是同一組系統呼叫:

let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };

不管是檔案、管線還是 socket,read/write 通吃——這正是 std::io::Read/Write trait 的原型。Rust 的 trait 設計(2014-2015 年定稿)直接繼承了這個 Unix 哲學:對介面編程,而不是對型別編程。

管線:shell 的 | 是怎麼來的#

管線由 Douglas McIlroy 於 1964 年構思(「把程式像軟體軟管一樣接起來」)、1973 年由 Ken Thompson 在 Version 3 Unix 實作,是 Unix「組合小工具」哲學的基石:

$ ps aux | grep rust | wc -l

| 的本質:shell 先 pipe(fds) 建立一對 fd(fds[0] 讀端、fds[1] 寫端),再 fork 兩個子程序,分別用 dup2 把讀端接到自己的 stdin、寫端接到自己的 stdout,最後 exec。

用 Rust 手寫一次 ps | wc:

use std::process::{Command, Stdio};

fn main() -> std::io::Result<()> {
    let mut fds = [0i32; 2];
    unsafe { libc::pipe(fds.as_mut_ptr()) };

    // 用 unsafe libc 版本太囉嗦,以下用 std 的等價寫法演示概念,
    // 括號內標註對應 syscall:
    let mut wc = Command::new("wc")          // fork + exec
        .stdin(Stdio::null())
        .spawn()?;
    // 真正的 libc 版本(觀念驗證):
    unsafe {
        libc::dup2(fds[0], 0);               // 把讀端接到 wc 的 stdin
        libc::close(fds[1]);                 // 關掉 wc 那側多餘的寫端
    }
    let mut ps = Command::new("ps")
        .arg("aux")
        .stdout(Stdio::from(unsafe {OwnedFdWrite}))  // 概念示意
        .spawn()?;
    for mut c in [&mut wc, &mut ps] { c.wait()?; }
    Ok(())
}

完整可編譯的 libc 版本:

use std::ffi::CString;

unsafe fn run(argv0: &str, args: &[&str], stdin_fd: i32, stdout_fd: i32) -> i32 {
    let pid = libc::fork();
    if pid == 0 {
        if stdin_fd != 0  { libc::dup2(stdin_fd, 0);  libc::close(stdin_fd); }
        if stdout_fd != 1 { libc::dup2(stdout_fd, 1); libc::close(stdout_fd); }
        let c = |s: &str| CString::new(s).unwrap();
        let mut argv: Vec<*const i8> = args.iter()
            .map(|a| c(a).into_raw() as *const i8).collect();
        let prog0 = c(argv0);
        argv.insert(0, prog0.as_ptr() as *const i8);
        argv.push(std::ptr::null());
        libc::execvp(prog0.as_ptr(), argv.as_ptr());
        libc::_exit(127);
    }
    pid
}

fn main() {
    unsafe {
        let mut fds = [0i32; 2];
        libc::pipe(fds.as_mut_ptr());
        let ps = run("ps", &["aux"], 0, fds[1]);   // ps 的 stdout 接管線寫端
        libc::close(fds[1]);                        // 父程序關寫端!
        let wc = run("wc", &["-l"], fds[0], 1);     // wc 的 stdin 接讀端
        libc::close(fds[0]);
        let mut st = 0;
        libc::waitpid(ps, &mut st, 0);
        libc::waitpid(wc, &mut st, 0);
    }
}

忘記 close 多餘的寫端是管線第一殺手:若 ps 結束後寫端仍有任何拷貝存在(例如父程序還握著),wc 的 read 永遠不會回傳 0,程式掛起。這種「資源所有權散落各處」的錯誤,正是 Rust 要解決的。

std::os::unix::io:把 fd 變成有所有權的值#

2015 年 Rust 1.0 就提供了 std::os::unix::io 模組,核心三件套(1.66 起進一步有了 OwnedFd/BorrowedFd):

  • RawFd(= i32):無所有權的裸 fd
  • OwnedFd:擁有 fd,drop 時自動 close
  • BorrowedFd<'a>:借用的 fd,生命週期由型別標註
use std::os::unix::io::{FromRawFd, OwnedFd};

// 把 libc 的 fd 包成會自動關閉的 Rust 型別
let raw = unsafe { libc::open(b"/etc/hostname\0".as_ptr() as *const _, 0) };
let owned = unsafe { OwnedFd::from_raw_fd(raw) };
// ...使用...
// 離開作用域時自動 close(owned),不可能忘記

unsafe 只有一行(fd 的來源),之後所有移動、傳參、存進結構體都在安全世界:fd 不會被複製成兩份(OwnedFd 不可 Copy)、不會雙重關閉、不會遺漏。這是把「fd 即資源」的 Unix 慣例直接編碼進型別系統。C 裡 fd 是裸整數,一個 int 拷貝出去就再也追蹤不了誰該 close;Rust 裡 OwnedFd 的移動語意(1.2 節)使「唯一擁有者」在編譯期成立。

雙向管線與 /proc 觀察#

程序之間還可透過 socketpair 得到雙向管道(本質是 AF_UNIX SOCK_STREAM,第 4.2 節詳述)。觀察工具:

$ ls -l /proc/self/fd          # 看自己程序的 fd 表
$ lsof -p $(pidof mydaemon)    # 看 daemon 開了哪些 fd
$ strace -e trace=pipe,dup2,close,read,write ./pipe_demo

fd 洩漏偵測慣例:daemon 啟動後 fd 數量應穩定,若 /proc/self/fd 持續增長即有洩漏——在 Rust 中這類洩漏幾乎都源自忘記 wait 子程序或手寫 unsafe,safe API(Stdio::piped() 等)已把正確的 close 時序封裝好:

use std::process::{Command, Stdio};

let mut child = Command::new("sort")
    .stdin(Stdio::piped())          // 自動建立管線
    .stdout(Stdio::inherit())
    .spawn()?;

child.stdin.take().unwrap()         // take() 保證只取一次
    .write_all(b"banana\napple\n")?;
// drop stdin → 寫端關閉 → sort 的 read 回 0 → sort 正常結束

Option<ChildStdin>::take() 是所有權思想的又一示範:stdin 拿走後 Option 變 None,再拿一次拿不到——用型別表達「一次性資源」。

習題#

  1. 把 libc 版管線程式改寫成三段:ps aux | grep rust | wc -l(需要兩條管線)。
  2. 故意移除 libc::close(fds[1]) 那行,觀察程式掛住;用 strace 證明 wc 卡在 read(0, ...)。
  3. 用 /proc/self/fd 寫一個小工具:讀取指定 pid 的 fd 表並列出每個 fd 指向的資源。