Ch11.20: Processes and Pipes

中文(简体)

1. Why Processes Belong in the I/O Chapter

A new process does not get new kinds of I/O. It gets three inherited handles — standard input, standard output and standard error — plus whatever additional handles you choose to pass to it. That is the same file-shaped model the rest of this chapter has been using. Starting a process is therefore not a separate subsystem; it is a redirection problem with a lifetime attached.

fast_io exposes that model through native_process and native_pipe. The names are platform-neutral: on POSIX they resolve to the fork/exec implementation, on Windows to CreateProcess or the NT process API. The code you write does not change.

This page is about the shape of the API, not about every platform flag. The rule is the same one used everywhere else in this book: keep the child's handles explicit, close the ends you do not use, and let transmit drain a pipe instead of writing another read/write loop.

2. native_process: Spawn, Wait, Detach

The constructor takes the executable, its arguments, its environment, its standard-I/O redirection, and a process_mode bitmask. Every argument has a default, so the smallest useful program is a path and a 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 is move-only and owns the child handle. Its destructor waits for the child if nobody has waited yet, so a forgotten process does not become an unbounded zombie. wait consumes that ownership and returns the platform's wait status. On POSIX the status is the raw waitpid value, which is why the example uses WIFEXITED and WEXITSTATUS; on Windows the same field is already the exit code.

There are two ways to give up the wait. detach(child) releases the handle and lets the child keep running; kill(child, status) asks the platform to terminate it. kill is deliberately platform-shaped: on POSIX it sends a signal, so pass a posix_wait_status containing the signal number; on Windows it terminates the process with the supplied exit code.

3. Arguments and Environment

native_process_args is variadic and accepts anything fast_io can print. It handles the platform string conversion, so you do not build a char* array or a UTF-16 command line by hand:


::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()}};

By default the executable path is prepended to the argument list as argv[0]. If you need to choose argv[0] yourself, pass the args_with_argv0 tag:


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

An empty native_process_envs means “inherit the parent's environment”. A non-empty one is the complete environment for the child, not an addition to the parent's. If you want to extend the parent's environment, read what you need from the parent and pass the full set explicitly.

4. Redirection: The Three Standard Handles

The fourth constructor argument is a native_process_io with three fields: in, out and err. Each field accepts a fast_io device, a pipe, or posix_dev_null(). The last one is the platform-independent spelling for “discard this stream”, even on 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()}};

Redirection is established in the child before the program starts. The parent keeps its own handles; the child receives duplicates. This is why redirecting stdout to a file does not close the parent's terminal, and why a pipe can be read by the parent after the child exits.

5. native_pipe and iobuf_pipe

A pipe is a unidirectional byte stream with two ends. native_pipe is the unbuffered pair of native handles: in() is the read end and out() is the write end. iobuf_pipe wraps that pair in the same buffered I/O object used by iobuf_file; its .handle member is the underlying native_pipe.

Type Buffered Use it for
::fast_io::native_pipe No Byte movement with transmit, or when you need the raw handles for poll/select.
::fast_io::iobuf_pipe Yes print, scan, and line-oriented protocols. scan requires a buffered input, so this is the type to use when the pipe carries values rather than raw bytes.

The pipe object owns the whole pair, including the write end. When the read side sees end-of-file, that means every write end in every process has been closed. If the parent keeps its copy of the write end open, the read side will wait forever. This is the single most common pipe bug.

6. Capturing a Child's Output

The following program gives the child a null stdin, a pipe for stdout, and the parent's stderr. It closes the parent's copy of the pipe's write end, then uses transmit to drain the read end until the child exits and the pipe reaches end-of-file:


#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");
}

If you need both stdout and stderr, do not drain them one after the other unless you know the child's output is small. A child that fills the stderr pipe while the parent is blocked reading stdout will deadlock. Either drain both concurrently with threads or coroutines, redirect one of them to a file or posix_dev_null(), or use poll on the two read ends. Chapter 23 shows the asynchronous version of the same idea.

7. Pipes Are Not Message Queues

A pipe preserves bytes, not boundaries. Two writes may be read as one chunk, and one write may be read in pieces. On POSIX, a write of at most PIPE_BUF bytes is atomic with respect to other writers; larger writes are not. A protocol that needs records must define a length prefix, a delimiter, or a fixed-size header, and then use print/scan or an explicit framing layer on top.

EOF is also a property of the write ends, not of a message. If a process forks another child, every inherited write end must be closed before the reader can see EOF. This is a feature when you use it deliberately and a hang when you forget it.

On POSIX, writing to a pipe with no reader can raise SIGPIPE. A long-running server should decide whether to ignore that signal or handle it as a normal write error, and should not discover the default disposition by crashing.

8. Lifetime, Failure and Platform Notes

Key takeaways