Case Study: IPC (Parent/Child) Over Unnamed Pipe#

Pipe Creation#

Pipe

  • Buffer of limited capacity

  • Unidirectional communication channel

  • Two ends: read and write

  • ⟶ described by file descriptors (read_end and write_end in the remainder code)

../../../../../../../_images/creation.svg
#include <unistd.h>
#include <stdio.h>

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    [[maybe_unused]] int read_end = pipe_ends[0];
    [[maybe_unused]] int write_end = pipe_ends[1];

    return 0;
}

Child Creation#

  • Process has two file descriptors

  • At fork(), these are inherited by the child

../../../../../../../_images/fork.svg
#include <unistd.h>
#include <stdio.h>

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    [[maybe_unused]] int read_end = pipe_ends[0];
    [[maybe_unused]] int write_end = pipe_ends[1];

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {                                    // <-- child
        // ...
    }
    else {                                             // <-- parent
        // ...
    }

    return 0;
}

Close Unneeded Ends#

  • In our program, communication direction will be from child to parent

  • ⟶ Parent only reads, child only writes

  • Unneeded file descriptors; parent closes write_end, child closes read_end

../../../../../../../_images/close-unneeded.svg
#include <unistd.h>
#include <stdio.h>

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    int read_end = pipe_ends[0];
    int write_end = pipe_ends[1];

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        close(read_end);                               // <-- unneeded
    }
    else {
        close(write_end);                              // <-- unneeded
    }

    return 0;
}

Implementing Infinite Data Flow#

  • Child infinitely writes into the pipe’s write_end

  • Parent infinitely reads from read_end

../../../../../../../_images/flow-infinite.svg
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <print>

struct data
{
    int sequence_number;
};

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    int read_end = pipe_ends[0];
    int write_end = pipe_ends[1];

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        close(read_end);

        // ******                                      <-- produce data once a second
        int cur_seq = 0;
        while (true) {
            data data = { cur_seq++ };
            ssize_t nwritten = write(write_end, &data, sizeof(data));
            if (nwritten == -1) {
                perror("write");
                exit(1);
            }
            sleep(1);
        }
    }
    else {
        close(write_end);

        // ******                                      <-- consume data
        while (true) {
            data data;
            ssize_t nread = read(read_end, &data, sizeof(data));
            if (nread == -1) {
                perror("read");
                exit(1);
            }
            std::println("parent received: {}", data.sequence_number);
        }
    }

    return 0;
}

Finite Data Flow#

  • Child only produces, say, 5 items

  • How to notify parent, over the pipe, that no data will come anymore?

  • ⟶ Child closes write_end

  • ⟶ Parent then sees an end-of-file condition (read() returns 0)

Note

Technically, the child does not need to close write_end. This is done by the system, implicitly, when the child calls exit().

../../../../../../../_images/flow-finite.svg
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <print>

struct data
{
    int sequence_number;
};

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    int read_end = pipe_ends[0];
    int write_end = pipe_ends[1];

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        close(read_end);

        int cur_seq = 0;
        for (int i=0; i<5; i++) {                      // <-- produce only 5 items
            data data = { cur_seq++ };
            ssize_t nwritten = write(write_end, &data, sizeof(data));
            if (nwritten == -1) {
                perror("write");
                exit(1);
            }
            sleep(1);
        }
        
        close(write_end);                              // <-- end-of-file for consumer
        exit(0);                                       // <-- terminate (don't leak into unconditional code)
    }
    else {
        close(write_end);

        while (true) {
            data data;
            ssize_t nread = read(read_end, &data, sizeof(data));
            if (nread == -1) {
                perror("read");
                exit(1);
            }
            if (nread == 0)  {                         // <-- end-of-file seen
                std::println("seen EOF");
                break;
            }
            std::println("parent received: {}", data.sequence_number);
        }

        close(read_end);
        exit(0);
    }

    return 0;
}

Unneeded ⟶ Harmful!#

Yes, closing unneeded file descriptors is mandatory!

  • What if parent hadn’t closed its write-end file descriptor?

  • ⟶ Pipe’s write-end is referenced twice: once from parent and once from child

  • ⟶ When child closes its write_end, the pipe’s in-kernel write-end is still referenced by the parent

  • ⟶ Pipe is not shutdown

../../../../../../../_images/flow-harmful.svg
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <print>

struct data
{
    int sequence_number;
};

int main()
{
    int pipe_ends[2];
    if (pipe(pipe_ends) == -1) {
        perror("pipe");
        return 1;
    }

    int read_end = pipe_ends[0];
    int write_end = pipe_ends[1];

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        close(read_end);

        int cur_seq = 0;
        for (int i=0; i<5; i++) {
            data data = { cur_seq++ };
            ssize_t nwritten = write(write_end, &data, sizeof(data));
            if (nwritten == -1) {
                perror("write");
                exit(1);
            }
            sleep(1);
        }
        
        close(write_end);
        exit(0);
    }
    else {
        // close(write_end);                           // <-- NOT closing -> pinned

        while (true) {
            data data;
            ssize_t nread = read(read_end, &data, sizeof(data));
            if (nread == -1) {
                perror("read");
                exit(1);
            }
            if (nread == 0)  {
                std::println("seen EOF");
                break;
            }
            std::println("parent received: {}", data.sequence_number);
        }

        close(read_end);
        exit(0);
    }

    return 0;
}

What If Receiver (Parent) Terminates?#

  • A process that writes to a pipe (or a socket, for that matter) whose receiver end is closed is delivered a SIGPIPE

  • ⟶ Default action: terminate

This means that the child will terminate at its next write() operation.