Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 20 – Systems programming with C++

POSIX in C++: files, pipes and child processes

Use open, read, write and close through an RAII descriptor in C++, turn errno into std::error_code, and run programs whose output comes back through a pipe.

  • Advanced
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 9 of 25 minutes free
Free: A descriptor is a small number with exactly one owner · errno becomes an error code that compares on every system · A child runs the program, and a pipe brings back its output · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 16 minutes.

What you will learn

  • Wrap each POSIX file descriptor in a move-only owner that closes it
  • Convert errno into a std::error_code and compare it with std::errc
  • Capture a child program's output with fork, exec, a pipe and waitpid
  • Choose between fork and exec, posix_spawn, popen and std::system
  • Reproduce and fix short writes, EINTR, leaked descriptors and wait-order deadlocks

Before you start

What you’ll learn · Before you start

What you will learn

  • Wrap each POSIX file descriptor in a move-only owner that closes it
  • Convert errno into a std::error_code and compare it with std::errc
  • Capture a child program's output with fork, exec, a pipe and waitpid
  • Choose between fork and exec, posix_spawn, popen and std::system
  • Reproduce and fix short writes, EINTR, leaked descriptors and wait-order deadlocks

Before you start

On this page

A program that starts echo and keeps the 19 bytes it prints needs pipe, fork, dup2, execvp, read and waitpid, and one forgotten close makes it wait forever. Below <fstream>, POSIX, the standard that macOS and Linux implement, gives C++ file descriptor: A small non-negative integer that a process uses to name an open file, pipe or socket. open() returns the lowest number not in use, and read(), write() and close() take it as their first argument., small integers that name an open file, pipe: A one-way channel in the kernel with a write end and a read end, each a file descriptor. Bytes come out of the read end in the order they went in, and read() returns 0 once the pipe is empty and every write end is closed. or socket, and child processes, started with fork and exec, whose output a pipe brings back.

Calls such as open, read and fork return -1 and set errno: The number a failed C or POSIX call leaves behind to say why it failed, such as ENOENT for a file that does not exist. The return value says that the call failed; errno, read right away, says why. when they fail, and std::error_code turns that into an error you can compare and print. Three habits keep this safe on macOS and Linux: every descriptor has one owner that closes it, every read and write is ready to move fewer bytes than it asked for, and every pipe end a process does not use is closed.

A descriptor is a small number with exactly one owner

copy.cpp copies a 10,000-byte file with open, read, write and close. Each descriptor it opens belongs to a FileDescriptor from fd.hpp, which is move-only, like std::unique_ptr, and closes the descriptor in its destructor:

fd.hpp and copy.cpp

C++ header · fd.hpp

// fd.hpp: a POSIX file descriptor with exactly one owner, and the small helpers that POSIX code needs.
#pragma once
#include <cerrno>
#include <cstddef>
#include <expected>
#include <span>
#include <system_error>
#include <utility>

#include <fcntl.h>
#include <unistd.h>

// errno as a portable std::error_code, read right after the call that failed.
inline std::error_code last_error() { return {errno, std::generic_category()}; }

class FileDescriptor {
public:
    FileDescriptor() = default;
    explicit FileDescriptor(int fd) noexcept : fd_(fd) {}
    FileDescriptor(FileDescriptor&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) reset(std::exchange(other.fd_, -1));
        return *this;
    }
    ~FileDescriptor() { reset(); }

    int get() const noexcept { return fd_; }
    explicit operator bool() const noexcept { return fd_ >= 0; }
    void reset(int fd = -1) noexcept {
        if (fd_ >= 0) ::close(fd_); // called once: after EINTR, Linux has already closed fd_ and may reuse the number
        fd_ = fd;
    }

private:
    int fd_ = -1; // -1: owns nothing
};

// open(2) with O_CLOEXEC, so the descriptor never leaks into programs started later.
inline std::expected<FileDescriptor, std::error_code> open_file(const char* path, int flags, mode_t mode = 0644) {
    int fd = ::open(path, flags | O_CLOEXEC, mode);
    if (fd < 0) return std::unexpected(last_error());
    return FileDescriptor(fd);
}

// write(2) may write fewer bytes than asked: keep going until everything is written.
inline std::error_code write_all(int fd, std::span<const std::byte> data) {
    while (!data.empty()) {
        ssize_t n = ::write(fd, data.data(), data.size());
        if (n < 0) {
            if (errno == EINTR) continue; // interrupted by a signal before writing anything: try again
            return last_error();
        }
        data = data.subspan(static_cast<std::size_t>(n));
    }
    return {};
}

// A pipe whose two ends close themselves in programs started by exec. macOS has no pipe2(), so the flag is set
// right after pipe(); on Linux, pipe2(ends, O_CLOEXEC) does both in one step.
struct Pipe {
    FileDescriptor read_end, write_end;
};
inline std::expected<Pipe, std::error_code> make_pipe() {
    int ends[2];
    if (::pipe(ends) != 0) return std::unexpected(last_error());
    Pipe p{FileDescriptor(ends[0]), FileDescriptor(ends[1])};
    if (::fcntl(ends[0], F_SETFD, FD_CLOEXEC) != 0 || ::fcntl(ends[1], F_SETFD, FD_CLOEXEC) != 0)
        return std::unexpected(last_error());
    return p;
}

C++ · copy.cpp

// copy.cpp: copies a file with open, read, write and close, and reports failures as std::error_code.
#include "fd.hpp"

#include <array>
#include <format>
#include <print>
#include <string>
#include <vector>

// What one copy did, for the report: the two descriptors and every value that read() returned.
struct CopyLog {
    int from_fd = -1, to_fd = -1;
    std::vector<ssize_t> reads;
    std::size_t copied = 0;
};

std::error_code copy_file(const char* from, const char* to, CopyLog& log) {
    auto in = open_file(from, O_RDONLY);
    if (!in) return in.error();
    auto out = open_file(to, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (!out) return out.error();
    log.from_fd = in->get();
    log.to_fd = out->get();
    std::array<std::byte, 4096> buffer;
    for (;;) {
        ssize_t n = ::read(in->get(), buffer.data(), buffer.size());
        log.reads.push_back(n);
        if (n == 0) return {}; // end of file: both descriptors close as in and out go out of scope
        if (n < 0) {
            if (errno == EINTR) continue;
            return last_error();
        }
        if (auto e = write_all(out->get(), std::span(buffer).first(static_cast<std::size_t>(n)))) return e;
        log.copied += static_cast<std::size_t>(n);
    }
}

void report(const char* from, const char* to) {
    CopyLog log;
    std::error_code e = copy_file(from, to, log);
    if (e) {
        std::println("{} -> {}: {} (errno {})", from, to, e.message(), e.value());
        return;
    }
    std::println("{} -> {}: copied {} bytes", from, to, log.copied);
    std::println("  descriptors {} and {}; read() returned {}", log.from_fd, log.to_fd, log.reads);
}

int main() {
    std::string text;
    for (int i = 1; i <= 1000; ++i) text += std::format("line {:04}\n", i); // 10 bytes per line
    {
        auto f = open_file("notes.txt", O_WRONLY | O_CREAT | O_TRUNC);
        if (!f || write_all(f->get(), std::as_bytes(std::span(text)))) return 1;
    }
    report("notes.txt", "copy.txt");
    report("missing.txt", "copy.txt");
    report("notes.txt", "."); // "." is a directory
    CopyLog log;
    std::println("missing.txt is errc::no_such_file_or_directory: {}",
                 copy_file("missing.txt", "x.txt", log) == std::errc::no_such_file_or_directory);
}
A copy, a missing file and a directory, on both libraries Python · copy_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined copy.cpp -o copy && ./copy
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined copy.cpp -o copy-gcc && ./copy-gcc
cmp notes.txt copy.txt && echo "copy.txt is identical to notes.txt"
""")

Output

$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined copy.cpp -o copy && ./copy
notes.txt -> copy.txt: copied 10000 bytes
  descriptors 3 and 4; read() returned [4096, 4096, 1808, 0]
missing.txt -> copy.txt: No such file or directory (errno 2)
notes.txt -> .: Is a directory (errno 21)
missing.txt is errc::no_such_file_or_directory: true
$ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined copy.cpp -o copy-gcc && ./copy-gcc
notes.txt -> copy.txt: copied 10000 bytes
  descriptors 3 and 4; read() returned [4096, 4096, 1808, 0]
missing.txt -> copy.txt: No such file or directory (errno 2)
notes.txt -> .: Is a directory (errno 21)
missing.txt is errc::no_such_file_or_directory: true
$ cmp notes.txt copy.txt && echo "copy.txt is identical to notes.txt"
copy.txt is identical to notes.txt

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copy_session.py

open gave out 3 and 4, the lowest numbers not in use. read returns the number of bytes it read, 0 at the end of the file, or -1 with errno set, and write may write fewer bytes than asked, so both run in loops that also call again when a signal interrupts them (EINTR). Step through the copy and watch the descriptors and the offsets:

Interactive · step by step One copy, two descriptors, four reads Step through copy_file on notes.txt. The left column is the program, the right one the two files and their offsets.
One copy, two descriptors, four reads, step 9 of 9. Showing in: FileDescriptor (closed 3), notes.txt (offset 10,000, the end), buffer (read() returned 0), out: FileDescriptor (closed 4), copy.txt (10,000 bytes).read()write_all()in: FileDescriptorclosed 3notes.txtoffset 10,000, the endbufferread() returned 0out: FileDescriptorclosed 4copy.txt10,000 bytes

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. Before the copy. main has written notes.txt through descriptor 3 and closed it again, so only 0, 1 and 2, the standard streams, are open. Neither FileDescriptor owns anything yet: each holds -1.
  2. open notes.txt. open_file("notes.txt", O_RDONLY) gets 3, the lowest number not in use, and in takes it. The open file description behind it starts at offset 0.
  3. open copy.txt. O_CREAT | O_TRUNC creates copy.txt, or empties it if it is there, and out takes the next number, 4.
  4. First read: 4,096. read(3, …) fills the 4,096-byte buffer and returns 4,096, the first value the run printed. The offset of notes.txt moves to 4,096.
  5. write_all: 4,096. write_all(4, …) writes the 4,096 bytes into copy.txt. It calls write again whenever one call moves fewer bytes than were left.
  6. Second read: 4,096. The second read returns 4,096 again and moves the offset to 8,192. write_all copies those bytes as well.
  7. A short read: 1,808. Only 1,808 bytes are left, so the third read returns 1,808: fewer than it asked for, and not an error. After write_all, copy.txt holds all 10,000 bytes.
  8. End of file: 0. The fourth read returns 0, because the offset is at the end of the file. copy_file returns an empty std::error_code, which means success.
  9. Two destructors, two closes. Leaving copy_file destroys out first, which closes 4, and then in, which closes 3: local objects are destroyed in the reverse order of their construction. Each early return closes what was opened the same way.

A descriptor is only a number. It refers to an open file description: The kernel's record of one opening of a file: the current offset and the flags given to open(). Descriptors made by dup2() or fork() share one description; two calls to open() make two. in the kernel, which holds the file’s offset and the flags given to open. Descriptors made by dup2 and fork share one description, and so one offset; two calls to open make two. open_file also passes O_CLOEXEC, which sets the close-on-exec: A flag on a file descriptor, FD_CLOEXEC, that makes exec close it, so the program started next never inherits it. O_CLOEXEC sets the flag when open() creates the descriptor. flag: a program started with exec never inherits the descriptor.

A descriptor wrapper that can be copied

struct Fd { int fd; ~Fd() { ::close(fd); } }; can be copied, and then two objects close one number; the second close may hit a file opened since. Declaring the move operations deletes the copies, as in FileDescriptor, and std::exchange hands the number over.

Checkpoint

Check: A descriptor and its one owner

  1. Question 1 of 2 copy.cpp reads with a 4,096-byte buffer. If notes.txt held 9,000 bytes instead of 10,000, what would the third read() return?

    Type a number.

    A hint

    Subtract what the first two reads moved from the size of the file.

    Show the answer: Question 1 of 2

    Answer: 808 bytes

    The first two reads fill the buffer, 4,096 bytes each, 8,192 in all. 808 bytes are left, so the third read returns 808, and the fourth returns 0 at the end of the file. A short read like this is not an error.

  2. Question 2 of 2 This wrapper closes its descriptor in its destructor. What goes wrong once b is made from a?

    Read the code, then choose one answer.

    struct Fd {
        int fd;
        ~Fd() { ::close(fd); }
    };
    
    Fd a{::open("notes.txt", O_RDONLY)};
    Fd b = a;
    Show the answer: Question 2 of 2

    Answer: Both objects close the same number, and the second close may close a file that was opened in the meantime

    A descriptor needs exactly one owner. A move-only wrapper such as FileDescriptor takes the number with std::exchange and leaves -1 behind, and declaring its move operations deletes the copies.

Chmod Calculator The 0644 passed to open() is the new file's mode, less any bits the umask clears: owner read and write, everyone else read only.

errno becomes an error code that compares on every system

The two failed copies came back as std::error_code values: a failed call returns -1 and leaves a number in errno, which last_error() wraps at once as std::error_code(errno, std::generic_category()). Follow the missing file, stage by stage, from the failed open to the lines the run printed:

Interactive · stage by stage From a failed open() to a printed error Follow missing.txt through copy.cpp, one stage at a time, from the -1 that open() returns to the lines the run printed.
From a failed open() to a printed error, step 6 of 6. Showing open() returns -1, errno is 2: ENOENT, last_error() wraps it, Returned, not thrown, message() and value(), Compared with std::errc. In focus: Compared with std::errc.open() returns -1missing.txt does not existerrno is 2: ENOENTleft by the failed calllast_error() wraps itgeneric_category()Returned, not throwninside std::unexpectedmessage() and value()No such file or directoryCompared with std::errctrue on every system

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The call fails. open_file("missing.txt", O_RDONLY) calls open, which returns -1 because no file has that name.
  2. errno says why. The failed call has left 2 in errno, the number of ENOENT, "no such file or directory". The -1 says only that the call failed.
  3. Wrapped at once. last_error() turns errno into std::error_code(errno, std::generic_category()) straight away, before another call can change it.
  4. Returned, not thrown. open_file returns the code inside std::unexpected, and copy_file passes it up as its own result. Nothing is thrown, and nothing is printed yet.
  5. Printed. report prints the code's message(), the system's text, and its value(), the number: "No such file or directory (errno 2)".
  6. Compared. == std::errc::no_such_file_or_directory printed true. The condition names the error, so the same test holds on a system that gives it another number.

message() gave the system’s text and value() the number: 2 for ENOENT, the missing file, and 21 for EISDIR, the directory. The comparison with std::errc::no_such_file_or_directory printed true, and it holds on every system, because POSIX fixes the names of the error numbers but not their values. Read errno straight after the failure, before any other call: a later call may change it, even one that succeeds.

Checkpoint

Check: errno as a std::error_code

  1. Question 1 of 2 When the write fails, what can the error_code that this function returns get wrong?

    Read the code, then choose one answer.

    std::error_code save(int fd, std::span<const std::byte> data) {
        if (::write(fd, data.data(), data.size()) < 0) {
            std::println(stderr, "save failed");
            return {errno, std::generic_category()};
        }
        return {};
    }
    Show the answer: Question 1 of 2

    Answer: The reason: println runs before errno is read, and it may change errno

    -1 says that the call failed and errno says why, but only until the next call. Wrap errno at once, as last_error() does, and print afterwards.

  2. Question 2 of 2 A function returns std::error_code e for a file that could not be opened. Which test means "the file does not exist" on every system?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: e == std::errc::no_such_file_or_directory

    Compare an error_code with a std::errc condition. The number and the text both come from the system; the condition means the same everywhere, and the copy run printed true for it.

A child runs the program, and a pipe brings back its output

run_and_capture in capture.cpp runs a program in a child process and returns its status and its standard output. Four children show the four outcomes:

capture.cpp: run_and_capture C++ · capture.cpp
// capture.cpp: runs a program in a child process and collects what it writes to standard output, through a pipe.
#include "fd.hpp"

#include <print>
#include <string>
#include <system_error>
#include <vector>

#include <sys/wait.h>

struct Result {
    int status; // as waitpid reports it: decode with WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG
    std::string output;
};

Result run_and_capture(std::vector<const char*> argv) {
    auto pipe = make_pipe();
    if (!pipe) throw std::system_error(pipe.error(), "pipe");
    argv.push_back(nullptr); // exec wants a null-terminated array
    pid_t pid = ::fork();
    if (pid < 0) throw std::system_error(last_error(), "fork");
    if (pid == 0) {
        // The child, a copy of this process. dup2 makes descriptor 1, its standard output, refer to the pipe; the
        // copy it makes has no close-on-exec flag, so it is the only end of the pipe the new program keeps.
        ::dup2(pipe->write_end.get(), STDOUT_FILENO);
        ::execvp(argv[0], const_cast<char* const*>(argv.data()));
        ::_exit(127); // only reached if exec failed; 127 is what shells use for "command not found"
    }
    // The parent. Its own write end must go, or read() would wait for this process to write and never see the end.
    pipe->write_end.reset();
    std::string output;
    char buffer[4096];
    for (;;) {
        ssize_t n = ::read(pipe->read_end.get(), buffer, sizeof buffer);
        if (n > 0) output.append(buffer, static_cast<std::size_t>(n));
        else if (n == 0) break; // end of file: every write end is closed, so the child has finished writing
        else if (errno != EINTR) throw std::system_error(last_error(), "read");
    }
    int status = 0;
    while (::waitpid(pid, &status, 0) < 0) // reaps the child, so it does not stay behind as a zombie
        if (errno != EINTR) throw std::system_error(last_error(), "waitpid");
    return {status, std::move(output)};
}

void show(std::vector<const char*> argv) {
    Result r = run_and_capture(argv);
    std::string how = WIFEXITED(r.status)     ? std::format("exited with {}", WEXITSTATUS(r.status))
                      : WIFSIGNALED(r.status) ? std::format("killed by signal {}", WTERMSIG(r.status))
                                              : "stopped";
    std::println("{}: {}, {} bytes {:?}", argv[0], how, r.output.size(), r.output);
}

int main() {
    show({"echo", "hello from a child"});
    show({"sh", "-c", "echo one; echo two; exit 3"});
    show({"no-such-program"});
    show({"sh", "-c", "kill -TERM $$"});
}
Four children: output, an exit code, a missing program, a signal Python · capture_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined capture.cpp -o capture && ./capture
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined capture.cpp -o capture-gcc && ./capture-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined capture.cpp -o capture && ./capture
echo: exited with 0, 19 bytes "hello from a child\n"
sh: exited with 3, 8 bytes "one\ntwo\n"
no-such-program: exited with 127, 0 bytes ""
sh: killed by signal 15, 0 bytes ""
$ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined capture.cpp -o capture-gcc && ./capture-gcc
echo: exited with 0, 19 bytes "hello from a child\n"
sh: exited with 3, 8 bytes "one\ntwo\n"
no-such-program: exited with 127, 0 bytes ""
sh: killed by signal 15, 0 bytes ""

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 capture_session.py

fork returns twice, once in each process: the child gets 0, and the parent gets the new child’s process ID. The child points its standard output at the pipe with dup2 and becomes echo with execvp, which searches PATH. The parent closes its own copy of the write end, reads until read returns 0, and only then calls waitpid. The diagram shows where each end of the pipe ends up:

A child writes into a pipe through its standard output; the parent reads the pipe until end of file, then waits for the child.Parent: capturekeeps the read endcloses its write end after forkPipe in the kernelholds up to 64 KiBwrite end → read endChild: echo, after execdescriptor 1 (standard output)is the write end, made by dup2write()read() returns 0once every write end is closedfork(), then waitpid()

Capturing a child's output through a pipe

Text description of the diagram

The diagram shows three boxes from top to bottom: the parent at the top, the child below it and the pipe at the bottom, joined by three arrows that go round in a loop.

  1. The parent, capture, keeps only the read end of the pipe: it closed its own copy of the write end right after fork, or it would never see end of file.
  2. A dashed arrow from the parent down to the child is labelled "fork(), then waitpid()": the parent created the child with fork and, after reading everything, collects its exit status with waitpid.
  3. The child is the program started with exec, here echo. Its descriptor 1, standard output, is the write end of the pipe, because dup2 copied the write end onto descriptor 1 before exec. An arrow labelled write() goes from the child down to the pipe.
  4. The pipe lives in the kernel and holds up to 64 KiB; when it is full, the writer waits. An arrow from the pipe back up to the parent says that read() returns 0, end of file, once every write end is closed.

Step through the first child, the echo that wrote 19 bytes, call by call:

Interactive · message by message Capturing echo: ten calls and one pipe Step through the first child of the run, the echo that wrote 19 bytes. The columns are the parent, the pipe in the kernel and the child.
Capturing echo: ten calls and one pipe, step 10 of 10. Messages so far: Parent: capture to Child: fork(); Child to Pipe in the kernel: 19 bytes; Pipe in the kernel to Parent: capture: 19 bytes; Pipe in the kernel to Parent: capture: read() returns 0; Parent: capture to Child: waitpid().Parent: captureexited with 0, 19 bytesPipe in the kernelemptyChildgonefork()19 bytes19 bytesread() returns 0waitpid()

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. pipe(). make_pipe() creates the pipe and marks both of its ends close-on-exec. The parent holds the read end and the write end.
  2. fork(). fork() copies the parent into a child, both ends of the pipe included, and returns in both processes: the child sees 0, the parent sees the child's process ID.
  3. dup2(). The child makes descriptor 1, its standard output, refer to the write end. The copy that dup2 makes has no close-on-exec flag.
  4. execvp("echo"). execvp searches PATH and replaces the child's program with echo. exec closes every close-on-exec descriptor, so echo keeps only descriptor 1 on the pipe.
  5. The parent's write end goes. The parent resets its own write end. From now on echo is the only writer; without this close, the parent's read could never reach the end of the file.
  6. echo writes 19 bytes. echo writes "hello from a child" and a newline, 19 bytes, into the pipe.
  7. read() returns 19. The parent's read returns the 19 bytes, and run_and_capture adds them to its output.
  8. echo exits. echo exits with status 0. Its descriptor 1 closes, and that was the last write end of the pipe. Until the parent waits for it, the child is a zombie.
  9. read() returns 0. The next read returns 0: the pipe is empty and no writer is left, so the output is complete. Only now does the parent wait.
  10. waitpid(). waitpid collects the status and removes the zombie. WIFEXITED is true and WEXITSTATUS gives 0, the first line of the run with its 19 bytes.

The status needs macros to read: WIFEXITED and WEXITSTATUS gave 3 for exit 3, and WIFSIGNALED and WTERMSIG gave signal 15, SIGTERM. A missing program ends the child in _exit(127), the shells’ code for “command not found”, because only a failed exec returns. A child that has finished but has not been waited for stays in the process table as a zombie process: A child process that has finished but whose exit status its parent has not collected yet with wait() or waitpid(). It keeps its entry in the process table until the parent waits for it., so every fork needs its waitpid.

Key idea

Close the pipe ends you do not use, read until read returns 0, then call waitpid, which collects the status and removes the zombie.

Checkpoint

Check: Capturing a child's output

  1. Question 1 of 2 After this fork, the parent reads the pipe until read() returns 0. What happens if the marked line is removed?

    Read the code, then choose one answer.

    int ends[2];
    pipe(ends);
    pid_t pid = fork();
    if (pid == 0) { dup2(ends[1], 1); execlp("echo", "echo", "hi", (char*)nullptr); _exit(127); }
    close(ends[1]);   // <- this line
    while (read(ends[0], buf, sizeof buf) > 0) { /* ... */ }
    waitpid(pid, &status, 0);
    Show the answer: Question 1 of 2

    Answer: The parent reads "hi" and then waits forever, because it still holds a write end itself

    End of file needs every write end closed, the parent's own copy included. Without the close, the parent waits for itself, so the loop never ends and waitpid is never reached.

  2. Question 2 of 2 waitpid filled status for a child that called exit(3). Which expression gives 3?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: WIFEXITED(status) ? WEXITSTATUS(status) : -1

    The status packs several facts into one int. WIFEXITED says the child ended normally, and only then does WEXITSTATUS give its exit code; WIFSIGNALED and WTERMSIG cover a child killed by a signal.

Three other ways to start a program, and when each fits

Three other calls do more of the work, each at a cost:

Call Through a shell The output comes back Waits
posix_spawn, posix_spawnp No Through file actions such as dup2 When you call waitpid
popen, then pclose Yes, /bin/sh As a FILE* opened with "r" In pclose, which returns the status
std::system Yes Never: it captures nothing Until the command ends

popen works in one direction, as POSIX and Linux define it: "r" reads what the command prints, and "w" writes to its standard input. macOS also accepts "r+", one stream that does both. On Windows, CreateProcess starts a program, and pipes come from CreatePipe.

Keep text from users away from the shell

popen and std::system hand their command to /bin/sh, which reads spaces, quotes and semicolons in a file name as part of the command. Pass an array of arguments to execvp or posix_spawnp, and each one reaches the program as it is.

Checkpoint

Check: Choosing how to start a program

  1. One question A service runs a converter on a file name that a user typed, and needs what the converter prints. Which call fits best?

    Choose one answer.

    Show the answer: One question

    Answer: posix_spawnp, with the name as one argument and a file action that puts standard output on a pipe

    Text from a user must reach the program as one argument, never through a shell. fork and exec, or posix_spawnp, take an array of arguments, and a pipe brings the output back.

Check yourself

Practise the ideas above on new cases, one question at a time, with the idea you know least asked first.

Practice that adapts to you

Practise this lesson

13 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 A program has only descriptors 0, 1 and 2 open. It opens a.txt, then b.txt, closes the descriptor of a.txt and opens c.txt. Which descriptor does c.txt get?

    Type a number.

    A hint

    Which number is the lowest one not in use when c.txt is opened?

    Show the answer: Question 1 of 13

    Answer: 3

    open hands out the lowest number not in use: a.txt gets 3 and b.txt 4. Closing 3 makes it the lowest unused number again, so c.txt gets 3.

  2. Question 2 of 13 Why is FileDescriptor move-only?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: So that exactly one object closes each descriptor

    One descriptor, one owner, one close: the move operations transfer the number with std::exchange, and declaring them deletes the copies.

  3. Question 3 of 13 A parent opens log.txt, writes 10 bytes and forks. The child then writes 5 bytes through the descriptor it inherited. Where do the child's bytes go?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: At offset 10, after the parent's bytes

    Descriptors made by fork and dup2 share one open file description, offset included. Two opens make two descriptions, each with its own offset.

  4. Question 4 of 13 open() returned -1. Where is the reason it failed?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: In errno, which the failed call set

    POSIX calls report failure with -1 and the reason in errno, which is why last_error() reads errno right after the call and keeps it in a std::error_code.

  5. Question 5 of 13 Why wrap errno in std::error_code(errno, std::generic_category()) straight after a failed call?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The value is kept before another call changes errno, and it can be compared with std::errc conditions and printed with message()

    errno is overwritten by later library calls, so it must be read at once. The error_code keeps the value with its category, compares portably with std::errc::no_such_file_or_directory and friends, and gives the system's text through message().

  6. Question 6 of 13 What does this print on macOS or Linux when missing.txt does not exist?

    Read the code, then choose one answer.

    auto f = open_file("missing.txt", O_RDONLY);
    if (!f)
        std::println("{} {}", f.error().value(),
                     f.error() == std::errc::no_such_file_or_directory);
    Show the answer: Question 6 of 13

    Answer: 2 true

    value() is the errno number, 2 on macOS and Linux, and the comparison with the std::errc condition is true on every system, which is why portable code tests the condition.

  7. Question 7 of 13 read() on the read end of a pipe returns 0. What does that mean?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: End of file: every write end of the pipe is closed, so no more data can arrive

    A read on an empty pipe waits while any write end is open. It returns 0 only when there is no data and no process holds a write end any more. An error is -1 with errno set.

  8. Question 8 of 13 What exit status did run_and_capture report for a program that does not exist?

    Type a number.

    Show the answer: Question 8 of 13

    Answer: 127

    execvp failed in the child, so the child called _exit(127), the code that shells also use for "command not found". A program that really exits with 127 looks the same, which is why careful code reports exec failures separately.

  9. Question 9 of 13 A server forks a child for every request and never calls waitpid. What is left after 10,000 finished requests?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: 10,000 zombies: finished children whose entries stay in the process table

    Every fork needs its waitpid: waiting collects the status and removes the entry. A server that never waits fills the process table until fork starts to fail.

  10. Question 10 of 13 A child was killed by SIGTERM. Which expression gives 15?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: WIFSIGNALED(status) ? WTERMSIG(status) : 0

    Read the status with the macros, one pair for each way a child can end: WIFEXITED with WEXITSTATUS, and WIFSIGNALED with WTERMSIG.

  11. Question 11 of 13 In the child, the call to execvp returns. What does that mean?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: It failed: a successful exec never returns, because the program that called it has been replaced

    exec replaces the program of the process that calls it. Only a failure comes back, and then the child ends with _exit(127), the code shells use for "command not found".

  12. Question 12 of 13 What does std::system give back?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: A status: it captures none of the command's output

    std::system runs a command through the shell and waits for it, and it captures nothing. It is the right tool only when no output is needed and the command holds no text from users.

  13. Question 13 of 13 With popen(cmd, "r") a program reads a command's output. Can it also write the command's input?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: No: a stream opened with "r" only reads; for both, make two pipes and start the program with fork and exec or posix_spawn

    popen covers one direction through the shell, as POSIX and Linux define it; macOS also accepts "r+" for both. A child that is fed and read at the same time on every system needs two pipes, set up with fork and exec or posix_spawn.

Short transfers, EINTR and leaked descriptors, reproduced Pro: opens with a Pro, Premium or Ultimate pass

Code that works in a quick test fails under load when a call moves fewer bytes than asked, when a signal interrupts it, or when a descriptor leaks into another program. Each case is reproduced here on both compilers, with its fix.

In this part:

  • A write that stops at 65,536 bytes, measured
  • Two writers, one log file: 1,000 of 2,000 lines lost
  • EINTR reproduced: one read, interrupted and then restarted
  • A leaked descriptor that keeps a pipe open
  • Generic or system category, and how two error codes compare

3 diagrams · 10 runnable examples (C++, C++ (cloexec_leak.cpp), C++ (pipe2_probe.cpp), Python) · about 15 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Running children without deadlocks: wait order, exec errors and an exercise Pro: opens with a Pro, Premium or Ultimate pass

A parent and a child that wait for each other hang forever, and a program that cannot be started can look exactly like one that failed. This part reproduces the deadlock, tells the two failures apart, and ends with an exercise that feeds a child while reading from it.

In this part:

  • The wait-first deadlock, reproduced with a 1-second deadline
  • Telling “could not start” apart from “exited with 127”
  • Your turn: feed a child and read it at the same time

2 diagrams · 6 runnable examples (C++, C++ header, Python) · about 9 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What do the return values 0 and -1 of read() mean, and when should the call simply be made again?

    read() returns the number of bytes it placed in the buffer, which may be fewer than asked for: a pipe or socket returns what has arrived, and a file near its end returns what is left. 0 means end of file: a regular file has no more bytes, or every write end of a pipe or the other side of a socket is closed. -1 means an error, and errno says which. Two errors are not real failures. EINTR means a signal arrived before anything was read, so the call is made again. EAGAIN, or EWOULDBLOCK, on a non-blocking descriptor means nothing is there yet, so the program waits, for example with poll(), and then reads again. Every other errno value is a failure to report, best as a std::error_code built with std::generic_category().

  2. Mid to senior

    A C++ service runs a command-line converter and reads its output through a pipe. It works for small files but hangs forever on large ones, and both processes stay alive. What is the likely bug, how do you confirm it, and how do you fix it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    After a restart, a server cannot bind its port and reports "Address already in use". The old server process is gone, but a helper it started hours earlier is still running. Explain what holds the port and fix the server.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    Design the run_command() function of a C++ build tool that starts thousands of compiler processes from a multithreaded parent: arguments, environment, working directory, output capture, time limits and errors. Would you use fork and exec or posix_spawn, and why?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A file descriptor is a small number that open hands out lowest first: give each one a move-only owner that closes it, and open with O_CLOEXEC.
  • read and write may move fewer bytes than asked, and read returns 0 at the end of the file, so both run in loops that also call again after EINTR.
  • Wrap errno at once in a std::error_code of std::generic_category(), and compare it with std::errc.
  • Run a program with fork and exec, close the pipe ends you do not use, read until end of file, then call waitpid.
  • Give programs an array of arguments: popen and std::system go through a shell, which must never see text from users.

Terms in this lesson

close-on-exec
A flag on a file descriptor, FD_CLOEXEC, that makes exec close it, so the program started next never inherits it. O_CLOEXEC sets the flag when open() creates the descriptor.
errno
The number a failed C or POSIX call leaves behind to say why it failed, such as ENOENT for a file that does not exist. The return value says that the call failed; errno, read right away, says why.
file descriptor
A small non-negative integer that a process uses to name an open file, pipe or socket. open() returns the lowest number not in use, and read(), write() and close() take it as their first argument.
open file description
The kernel's record of one opening of a file: the current offset and the flags given to open(). Descriptors made by dup2() or fork() share one description; two calls to open() make two.
pipe
A one-way channel in the kernel with a write end and a read end, each a file descriptor. Bytes come out of the read end in the order they went in, and read() returns 0 once the pipe is empty and every write end is closed.
zombie process
A child process that has finished but whose exit status its parent has not collected yet with wait() or waitpid(). It keeps its entry in the process table until the parent waits for it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.