Danh sách bài viết

Bài 292: std::process — Command Exec

Bài 292 của series Rust Cơ Bản — module std::process với Command giải quyết bài toán gọi tiến trình con: chạy git để query repo, gọi ffmpeg để encode video, exec curl để fetch dữ liệu. Bài này phân tích 3 cách chạy chính (output() capture stdout/stderr, status() chỉ lấy exit code, spawn() background trả về Child handle), cách điều khiển pipe stdin/stdout/stderr qua Stdio, set biến môi trường với env(), đổi working directory với current_dir(), và preview tokio::process cho async runtime.

10/06/2026
9 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu std::process::Command là builder pattern để cấu hình và chạy tiến trình con bằng Rust.
  • Phân biệt 3 cách chạy: output() chờ và capture, status() chờ và lấy exit code, spawn() chạy background trả về Child handle.
  • Đọc và xử lý Output: stdout, stderr (Vec<u8>), và status (ExitStatus).
  • Điều khiển pipe stdin/stdout/stderr qua Stdio::piped(), Stdio::inherit(), Stdio::null().
  • Quản lý vòng đời tiến trình con với Child::wait(), Child::kill(), Child::try_wait().
  • Set biến môi trường với env() và đổi working directory với current_dir().
  • Biết khi nào nên chuyển sang tokio::process cho async I/O.
2

Command::new(...).arg(...) Builder

std::process::Command là builder pattern: tạo bằng Command::new(program), chain method để cấu hình, kết thúc bằng một trong các method "chạy". Mỗi setter trả về &mut Command nên có thể nối chuỗi:

use std::process::Command;

let output = Command::new("git")
    .args(["log", "--oneline", "-n", "5"])
    .output()
    .expect("failed to run git");

println!("{}", String::from_utf8_lossy(&output.stdout));

Vài điểm cần để ý:

  • Command::new("git") — tên chương trình. Nếu là tên đơn (không có /), OS sẽ tìm theo biến môi trường PATH. Đường dẫn tuyệt đối (/usr/bin/git) thì gọi trực tiếp.
  • .arg("foo") thêm một argument; .args([...]) thêm nhiều cùng lúc. Mỗi argument là một string riêng — không phải dòng lệnh shell. Bạn không thể truyền "log --oneline" như một string duy nhất — phải tách ["log", "--oneline"].
  • Không có shell mặc định. Glob (*.txt), pipe (|), redirect (>) không được expand. Muốn dùng shell phải gọi Command::new("sh").args(["-c", "ls *.txt | wc -l"]) tường minh.

Quyết định không qua shell là cố ý — giảm rủi ro shell injection. Argument tách rõ ràng nghĩa là dữ liệu user nhập không trở thành code shell.

3

3 Cách Chạy: output, status, spawn

Sau khi cấu hình Command, có 3 method tận cùng — chọn cái nào phụ thuộc bạn cần gì.

output() — chờ tiến trình kết thúc, capture toàn bộ stdoutstderr vào buffer. Phù hợp khi cần đọc kết quả:

let out = Command::new("hostname").output()?;
let name = String::from_utf8_lossy(&out.stdout);
println!("Hostname: {}", name.trim());

status() — chờ tiến trình kết thúc, chỉ lấy exit code. stdoutstderr mặc định inherit (đổ thẳng ra terminal của parent). Phù hợp khi chỉ quan tâm thành công hay thất bại:

let status = Command::new("cargo")
    .args(["test", "--release"])
    .status()?;

if status.success() {
    println!("Test passed");
} else {
    eprintln!("Test failed with code {:?}", status.code());
}

spawn() — trả về Child handle ngay lập tức, không chờ. Tiến trình con chạy song song với parent. Phù hợp khi cần kiểm soát thủ công (đọc stdout streaming, kill khi quá thời gian, chạy nhiều process đồng thời):

let mut child = Command::new("sleep")
    .arg("30")
    .spawn()?;

println!("Spawned PID {}", child.id());
// ...làm việc khác ở parent...
let status = child.wait()?;
println!("Child exited: {:?}", status);

Quy tắc nhớ: cần dữ liệu → output; chỉ cần kết quả pass/fail → status; cần kiểm soát chi tiết → spawn.

4

Output: stdout, stderr, status

Method output() trả về std::io::Result<Output>. Output là struct đơn giản:

pub struct Output {
    pub status: ExitStatus,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
}

Lưu ý stdoutstderrVec<u8> — bytes thô, không đảm bảo UTF-8. Có 2 cách chuyển sang String:

// Lossy: byte không hợp lệ thay bằng U+FFFD, không bao giờ fail
let s = String::from_utf8_lossy(&output.stdout);

// Strict: fail nếu không hợp lệ UTF-8
let s = String::from_utf8(output.stdout)?;

Dùng from_utf8_lossy khi không chắc encoding (output của ls, log, …). Dùng from_utf8 khi format đã spec UTF-8 (vd git log --format=%H).

Kiểm tra status:

let out = Command::new("grep").args(["needle", "haystack.txt"]).output()?;

if out.status.success() {
    println!("Found: {}", String::from_utf8_lossy(&out.stdout));
} else {
    // grep exit code 1 nghĩa là không tìm thấy (không phải lỗi thật)
    match out.status.code() {
        Some(1) => println!("Not found"),
        Some(code) => eprintln!("grep error {}: {}", code, String::from_utf8_lossy(&out.stderr)),
        None => eprintln!("grep killed by signal"),
    }
}

status.code() trả Option<i32>: Some(code) nếu tiến trình tự exit, None nếu bị OS signal kill (vd SIGTERM). Trên Unix có thể dùng trait ExitStatusExt để lấy signal number cụ thể.

5

Stdio Pipe: piped, inherit, null

Mỗi tiến trình con có 3 stream chuẩn: stdin, stdout, stderr. Mặc định output() dùng piped() cho cả 3, còn status()/spawn() dùng inherit(). Bạn override qua method .stdin(), .stdout(), .stderr() với enum Stdio:

  • Stdio::piped() — tạo OS pipe, parent đọc/ghi qua handle trên Child.
  • Stdio::inherit() — dùng chung stream của parent (output đổ thẳng terminal).
  • Stdio::null() — chuyển hướng vào /dev/null (Unix) hoặc NUL (Windows), bỏ qua hoàn toàn.

Ví dụ ghi dữ liệu vào stdin của tiến trình con — pipe input vào wc -w để đếm từ:

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

let mut child = Command::new("wc")
    .arg("-w")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()?;

// Lấy handle stdin, ghi dữ liệu, drop để close pipe
child.stdin.take().unwrap().write_all(b"hello world from rust")?;

let out = child.wait_with_output()?;
println!("Word count: {}", String::from_utf8_lossy(&out.stdout).trim());

Hai chi tiết quan trọng: (1) phải take() stdin ra khỏi Child rồi drop để pipe đóng — nếu không, wc chờ EOF mãi mãi và hệ thống deadlock; (2) dùng wait_with_output() để gộp wait + đọc stdout còn lại, an toàn hơn gọi tay wait() trước rồi đọc sau.

Dùng Stdio::null() khi muốn "fire and forget" — chạy background daemon, không quan tâm output:

Command::new("rust-analyzer")
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .spawn()?;
6

Child::wait, Child::kill

spawn() trả về Child — handle để điều khiển tiến trình con. Các method chính:

  • child.id() -> u32 — PID, dùng cho log hoặc gửi signal ngoài.
  • child.wait() -> io::Result<ExitStatus> — block tới khi tiến trình kết thúc.
  • child.try_wait() -> io::Result<Option<ExitStatus>> — non-blocking poll. Ok(None) nghĩa là vẫn đang chạy.
  • child.kill() -> io::Result<()> — gửi SIGKILL (Unix) hoặc TerminateProcess (Windows). Sau kill vẫn nên gọi wait() để thu hồi zombie process.
  • child.wait_with_output() — consume Child, đóng stdin, đọc hết stdout/stderr, trả Output.

Pattern timeout phổ biến — chờ tối đa N giây rồi kill:

use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

let mut child = Command::new("ffmpeg")
    .args(["-i", "in.mp4", "out.webm"])
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .spawn()?;

let deadline = Instant::now() + Duration::from_secs(60);

loop {
    match child.try_wait()? {
        Some(status) => {
            println!("Finished: {:?}", status);
            break;
        }
        None if Instant::now() > deadline => {
            eprintln!("Timeout, killing process");
            child.kill()?;
            child.wait()?;  // thu zombie
            break;
        }
        None => thread::sleep(Duration::from_millis(200)),
    }
}

Lưu ý: Child không tự kill khi drop. Nếu bạn quên wait() hoặc kill(), tiến trình con tiếp tục chạy ngầm sau khi parent exit — có thể là điều bạn muốn (daemon) hoặc không (resource leak). Tự quản lý vòng đời tường minh.

7

env() Và current_dir()

Mặc định tiến trình con thừa kế toàn bộ biến môi trường và working directory của parent. Override khi cần qua các method:

let output = Command::new("./build.sh")
    .current_dir("/tmp/workspace")
    .env("CARGO_TARGET_DIR", "/tmp/target")
    .env("RUST_LOG", "debug")
    .env_remove("HTTP_PROXY")
    .output()?;
  • env(key, value) — set hoặc override một biến.
  • envs(iter) — set hàng loạt từ HashMap hoặc array tuple.
  • env_remove(key) — bỏ một biến khỏi child env (vẫn còn ở parent).
  • env_clear() — wipe toàn bộ, chỉ giữ biến set sau đó. Hữu ích khi muốn môi trường sạch tuyệt đối (vd test isolation, sandbox).
  • current_dir(path) — đổi working directory của tiến trình con. Path có thể tương đối (so với CWD của parent) hoặc tuyệt đối.

Lưu ý current_dir() chỉ ảnh hưởng tiến trình con — CWD của parent không đổi. Đây là khác biệt với std::env::set_current_dir() (đổi CWD của chính process hiện tại, ảnh hưởng mọi thao tác file tiếp theo của parent).

Pattern thực tế: chạy git ở repo khác:

let out = Command::new("git")
    .current_dir("/path/to/other/repo")
    .args(["rev-parse", "HEAD"])
    .output()?;

let commit = String::from_utf8_lossy(&out.stdout);
println!("HEAD: {}", commit.trim());
8

Preview tokio::process Async

std::process là blocking — wait(), output(), đọc/ghi pipe đều dừng cả thread. Trong async runtime (axum, actix), điều này phá vỡ scheduler. Giải pháp: tokio::process::Command, API gần như identical nhưng trả về Future:

use tokio::process::Command;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let out = Command::new("git")
        .args(["log", "--oneline", "-n", "3"])
        .output()
        .await?;

    println!("{}", String::from_utf8_lossy(&out.stdout));
    Ok(())
}

Khác biệt chính: thêm .await, và pipe stdin/stdout là tokio::io::AsyncRead/AsyncWrite — read/write không block runtime. Khi viết web service mà cần exec subprocess, dùng tokio::process, đừng gọi std::process bên trong async fn.

9

Tổng Kết

  • std::process::Command là builder pattern: new + chain setter + một method chạy tận cùng.
  • Argument tách từng phần qua .arg() / .args() — không đi qua shell, không bị shell injection.
  • 3 cách chạy: output() chờ + capture stdout/stderr, status() chờ + exit code, spawn() background trả về Child.
  • Output chứa status, stdout, stderr (kiểu Vec<u8>) — dùng String::from_utf8_lossy để hiển thị an toàn.
  • Stdio::piped() / inherit() / null() điều khiển 3 stream stdin/stdout/stderr độc lập.
  • Child::wait() block, try_wait() non-blocking, kill() gửi SIGKILL — luôn nhớ wait() sau kill() để thu zombie.
  • env(), env_remove(), env_clear(), current_dir() cấu hình môi trường tiến trình con; không ảnh hưởng parent.
  • Trong async runtime, dùng tokio::process::Command thay vì std::process để tránh block executor.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Vì sao Command::new("ls").arg("*.txt").output() không list được file .txt như shell? Làm sao để fix mà vẫn dùng std::process?
  2. Phân biệt output(), status(), spawn(): mỗi cái chờ hay không, capture stdout hay không, return type là gì?
  3. Vì sao khi pipe data vào stdin của child cần take() và drop handle? Điều gì xảy ra nếu giữ handle stdin sống mà gọi child.wait()?
  4. Bạn cần chạy cargo build trong directory /projects/myapp với biến môi trường RUST_LOG=trace, capture cả stdout và stderr. Viết code Rust thực hiện.
  5. Khác biệt giữa Stdio::inherit()Stdio::piped()? Khi nào nên dùng cái nào?
  6. Trong async function (vd axum handler), bạn cần exec ffmpeg để convert video. Dùng std::process::Command trực tiếp có vấn đề gì? Cách đúng là gì?
Đáp án
  1. std::process::Command không qua shell, nên glob pattern *.txt không được expand — ls nhận literal string "*.txt" và báo "no such file". Fix: gọi shell tường minh: Command::new("sh").args(["-c", "ls *.txt"]). Nhưng cách an toàn hơn (tránh shell injection) là dùng crate glob để expand pattern bên Rust, rồi pass danh sách file cụ thể qua .args().
  2. output(): block chờ tiến trình kết thúc, capture cả stdout và stderr vào buffer, return io::Result<Output>. status(): block chờ, stdout/stderr inherit ra terminal parent (không capture), return io::Result<ExitStatus>. spawn(): không block, return ngay io::Result<Child> — child chạy song song với parent, caller tự quản lý wait/kill.
  3. Tiến trình như wc, cat, sort đọc stdin tới khi nhận EOF. EOF được gửi khi đầu ghi của pipe đóng — tức là khi handle stdin trong Rust bị drop. Nếu giữ handle sống và gọi child.wait(), child chờ EOF mãi (chưa đóng pipe), parent chờ child exit — deadlock. Pattern đúng: child.stdin.take().unwrap().write_all(...)? rồi để handle drop cuối scope, hoặc dùng wait_with_output() tự xử lý đúng thứ tự.
  4. use std::process::Command;
    
    let out = Command::new("cargo")
        .arg("build")
        .current_dir("/projects/myapp")
        .env("RUST_LOG", "trace")
        .output()?;
    
    println!("stdout: {}", String::from_utf8_lossy(&out.stdout));
    eprintln!("stderr: {}", String::from_utf8_lossy(&out.stderr));
    println!("exit: {:?}", out.status.code());
  5. Stdio::inherit() chia sẻ stream của parent — output của child đổ thẳng terminal/log của parent, không qua buffer. Dùng khi muốn user thấy output realtime (CLI tool wrap command khác, build script). Stdio::piped() tạo OS pipe — parent đọc/ghi qua handle ChildStdout/ChildStdin. Dùng khi cần programmatic access (parse output, feed input từ Rust). Mặc định output() dùng piped, status() dùng inherit.
  6. std::process::Command blocking — .output().await sẽ không compile (không phải Future), còn .output() không có await sẽ block toàn bộ thread của tokio runtime, freeze các task khác đang chạy trên cùng worker. Cách đúng: dùng tokio::process::Command — API gần giống nhưng output(), wait(), đọc pipe đều async, không chặn executor. Trong handler axum hoặc tokio task, luôn dùng tokio::process.
11

Bài Tiếp Theo

Bài 293: std::time — Duration & Instant — bài tiếp theo của Group 36 Standard Library. Tìm hiểu Duration::from_secs/from_millis/from_micros/from_nanos để đại diện khoảng thời gian, Instant::now() để đo elapsed time chính xác cao (monotonic clock), hiệu giữa hai Instant trả về Duration, và khác biệt với SystemTime dùng cho wall clock (kém chính xác hơn, có thể nhảy ngược khi NTP sync).