第11.20章: 进程与管道

English

1. 为什么进程属于 I/O 这一章

新进程并不会得到新的 I/O 种类。它得到三个继承来的句柄——标准输入、标准输出和标准错误—— 以及你选择额外传进去的句柄。这和本章一直在用的“文件形状”模型是同一个模型。 所以启动进程不是一个独立的子系统,而是一个带生命周期的重定向问题。

fast_io 通过 native_process 和 native_pipe 暴露这个模型。名字是平台中立的:在 POSIX 上它们落到 fork/exec 实现,在 Windows 上落到 CreateProcess 或 NT 进程 API。你写的代码不变。

本页讲的是 API 的形状,不是每个平台的全部标志。规则和本书其他地方一样: 把子进程的句柄显式写出来,关掉不用的那一端,并且用 transmit 排空管道,而不是再手写一个 read/write 循环。

2. native_process:创建、等待、分离

构造函数接收可执行文件、参数、环境、标准 I/O 重定向,以及一个 process_mode 位掩码。每个参数都有默认值,所以最小可用的程序就是 一个路径加一次 wait:


#include <fast_io.h>

int main()
{
    ::fast_io::native_process child{
        "child.exe",
        ::fast_io::native_process_args{},
        {},
        {.in = ::fast_io::in(), .out = ::fast_io::out(), .err = ::fast_io::err()},
        ::fast_io::process_mode::none};

    auto status{::fast_io::wait(child)};

#if defined(_WIN32)
    ::fast_io::io::println("exit code: ", ::fast_io::wait_status_to_int(status));
#else
    if (WIFEXITED(status.wait_loc))
    {
        ::fast_io::io::println("exit code: ", WEXITSTATUS(status.wait_loc));
    }
#endif
}

native_process 只能移动,拥有子进程句柄。如果没人等待,它的析构函数会等待子进程, 所以忘记处理的进程不会变成无限增长的僵尸进程。wait 消耗这份所有权并返回平台的等待状态。 在 POSIX 上这个状态是原始的 waitpid 值,所以示例用 WIFEXITED 和 WEXITSTATUS;在 Windows 上同一个字段已经是退出码。

有两种放弃等待的方式。detach(child) 释放句柄,让子进程继续运行; kill(child, status) 请求平台终止它。kill 刻意保持平台形状: 在 POSIX 上它发送信号,所以传入装有信号编号的 posix_wait_status; 在 Windows 上它用给定的退出码终止进程。

3. 参数与环境

native_process_args 是可变参数的,接受任何 fast_io 能打印的东西。它负责平台字符串转换,所以你不需要手写 char* 数组 或 UTF-16 命令行:


::fast_io::native_process child{
    "child.exe",
    ::fast_io::native_process_args{
        "plain", 42, 3.5, u8"utf-8", L"wide", U"utf-32"},
    ::fast_io::native_process_envs{
        "MODE=test", "LEVEL=2"},
    {.in = ::fast_io::in(), .out = ::fast_io::out(), .err = ::fast_io::err()}};

默认情况下,可执行文件路径会作为 argv[0] 前置到参数表里。 如果你需要自己决定 argv[0],传入 args_with_argv0 标签:


::fast_io::native_process child{
    "child.exe",
    ::fast_io::args_with_argv0,
    ::fast_io::native_process_args{"my-name", "--verbose"}};

空的 native_process_envs 表示“继承父进程的环境”。 非空则它是子进程的完整环境,而不是在父环境上追加。如果你想扩展父环境, 需要先从父进程读出需要的内容,再显式传入完整集合。

4. 重定向:三个标准句柄

构造函数的第四个参数是一个 native_process_io,有三个字段: in、out 和 err。每个字段接受一个 fast_io 设备、一个管道,或者 posix_dev_null()。 最后一个即使在 Windows 上也是“丢弃这个流”的平台无关写法。


// Child inherits the terminal for stdout/stderr, and reads nothing.
::fast_io::native_process quiet{
    "child.exe",
    {},
    {},
    {.in = ::fast_io::posix_dev_null(),
     .out = ::fast_io::out(),
     .err = ::fast_io::err()}};

重定向是在程序启动之前、在子进程里建立的。父进程保留自己的句柄;子进程收到的是副本。 这就是为什么把 stdout 重定向到文件不会关闭父进程的终端, 也是为什么子进程退出后父进程仍然可以从管道里读数据。

5. native_pipe 与 iobuf_pipe

管道是带两个端的单向字节流。native_pipe 是不带缓冲的原生句柄对: in() 是读端,out() 是写端。iobuf_pipe 把这对句柄包进和 iobuf_file 相同的带缓冲 I/O 对象里; 它的 .handle 成员就是底层的 native_pipe。

类型 带缓冲 用来做什么
::fast_io::native_pipe 否 配合 transmit 搬字节,或需要原始句柄做 poll/select 时。
::fast_io::iobuf_pipe 是 print、scan 和面向行的协议。 scan 要求带缓冲的输入,所以管道传的是“值”而不是原始字节时用它。

管道对象拥有整对句柄,包括写端。读端看到文件结束时,意思是所有进程里的 每一个写端都已关闭。如果父进程保留着自己的写端副本,读端就会永远等下去。 这是最常见的管道 bug。

6. 捕获子进程的输出

下面的程序给子进程空的标准输入、一个 stdout 管道,以及父进程的 stderr。 它关闭父进程这一侧的写端,然后用 transmit 排空读端, 直到子进程退出、管道到达文件结束:


#include <fast_io.h>
#include <fast_io_device.h>

int main()
{
    ::fast_io::iobuf_pipe child_out;

    ::fast_io::native_process child{
        "child.exe",
        ::fast_io::native_process_args{},
        {},
        {.in = ::fast_io::posix_dev_null(),
         .out = child_out.handle,
         .err = ::fast_io::err()},
        ::fast_io::process_mode::none};

    // The parent must not keep the write end open.
    child_out.handle.out().close();

    // Byte movement, not formatting: no temporary vector, no read/write loop.
    ::fast_io::io::transmit(child_out, ::fast_io::out());

    auto status{::fast_io::wait(child)};
    ::fast_io::io::println(::fast_io::err(), "child finished");
}

如果你需要同时处理 stdout 和 stderr,不要在一个输出很小、你并不确定的情况下 依次排空它们。子进程把 stderr 管道写满、而父进程阻塞在读 stdout 上时,就会死锁。 要么用线程或协程并发排空两者,要么把其中一个重定向到文件或 posix_dev_null(),要么对两个读端使用 poll。 第23章展示了同一件事的异步版本。

7. 管道不是消息队列

管道保留的是字节,不是边界。两次写可能被一次读出来,一次写也可能被分成几段读。 在 POSIX 上,一次不超过 PIPE_BUF 字节的写相对于其他写者是原子的; 更大的写不是。需要记录边界的协议必须定义长度前缀、分隔符或固定大小的头部, 然后在上面用 print/scan 或显式的分帧层。

文件结束也是写端的属性,不是某条消息的属性。如果一个进程又 fork 了子进程, 所有继承出去的写端都必须关闭,读者才能看到文件结束。你有意利用它是特性, 忘记它就是挂起。

在 POSIX 上,向没有读者的管道写可能触发 SIGPIPE。 长期运行的服务应该决定是忽略这个信号,还是把它当作普通的写错误处理, 而不是靠崩溃来发现默认处置。

8. 生命周期、失败与平台说明

要点