Communication between programs (in any languages) with named pipes

Imagine you are building a system to monitor the temperature in a warehouse. You have multiple sensors scattered throughout the warehouse that are constantly measuring the temperature, and you want to display the temperature data on a dashboard. One way you could do this is by using the publish-subscribe pattern: each sensor would publish its temperature data to a message broker, which would then forward the data to the dashboard program. The dashboard program would be subscribed to the message broker and would receive the temperature data when it is forwarded.

    graph LR 
        A[Publisher] --> B[Message broker]
        B --> C[Subscriber] 

This allows the sensor programs and the dashboard program to operate independently and asynchronously, making the system more efficient and scalable. This pattern is common in distributed systems, where the publisher and subscriber may be running on different machines.

Now, what if you want to implement this pattern in a local system, where the publisher and subscriber are running on the same machine?

Local communication with named pipes

It is not counterintuitive to use a message broker in this case. In fact, it is pretty common to use a message broker in a local system. Especially when the publisher and subscriber are written in different languages, it is easier to use a message broker to communicate between them. In this case, the message broker can be implemented using a named pipe.

Named pipe can be created using the mkfifo command:

$ mkfifo /tmp/mypipe

The /tmp/mypipe pipe will be created. Note that it looks like a file, but it is actually not a file: it is a pseudo-file. We can find pseudo-files in unix systems like /dev/null, /proc, or /sys (more example).

Consider following example, where left pane is the publisher and right pane is the subscriber. The publisher writes to the named pipe (using echo) and the subscriber reads from the named pipe (using cat).

Named pipe is a standard, and most of programming languages support it, so we can use it programmaticaly.

Next, we will look at how to implement a publisher and subscriber using Go and Python. Named pipe will serve as the message broker in this case.

Objective: implement the publisher in Go and the subscriber in Python. The publisher can accept user input and write it to the named pipe. The subscriber can read from the named pipe and print the message to the console. When the user enters the word "exit", the subscriber should end its process.

Go program as the publisher

We will use Go as the publisher. A publisher writes messages to a named pipe and a subscriber that reads messages from the same named pipe. To begin, let's take a look at the Go program that acts as the publisher. This program prompts the user for a message and then writes the message to a named pipe:

package main

import (
	"bufio"
	"fmt"
	"os"
	"syscall"
)

const pipeName = "/tmp/mypipe"

func WriteMessage(message, pipeName string) error {
	// check if the named pipe exists. If not, create it. Otherwise, assume
	// it's already created beforehand.
	_, err := os.Stat(pipeName)
	if err != nil {
		if os.IsNotExist(err) {
			err = syscall.Mkfifo(pipeName, 0666)
			if err != nil {
				return err
			}
		} else {
			return err
		}
	}

	// open the named pipe for writing
	pipe, err := os.OpenFile(pipeName, os.O_WRONLY, 0666)
	if err != nil {
		panic(err)
	}
	defer pipe.Close()

	// write the message to the named pipe
	_, err = pipe.WriteString(message)

	return err
}

func main() {
	for {
		// Get user input
		fmt.Print("Enter message to send: ")
		reader := bufio.NewReader(os.Stdin)
		message, _ := reader.ReadString('\n')
		message = message[:len(message)-1] // trim trailing newline

		// send the message to the named pipe
		if err := WriteMessage(message, pipeName); err != nil {
			panic(err)
		}
	}
}

The WriteMessage function is responsible for writing the message to the named pipe. It first checks if the named pipe exists and creates it if it does not. It then opens the named pipe for writing and writes the message to it using the WriteString method.

In the main function, the program enters an infinite loop that prompts the user for a message and then writes the message to the named pipe using the WriteMessage function. The program will continue to run until it is interrupted or terminated.

Python program as the subscriber

Now, let's take a look at the Python program that acts as the subscriber. This program reads messages from the named pipe and processes them:

PIPE_NAME = "/tmp/mypipe"

def handle_message(message: str) -> None:
    if message == "exit":
        print("Exiting...")
        exit(0)
    print(f"Received: {data}")

if __name__ == "__main__":
    while True:
        with open(PIPE_NAME, "r") as pipe:
            # Read data from the pipe
            data = pipe.read().strip()

            # Print the data from the pipe
            if data:
                handle_message(data)

This is the result:

Named pipes vs regular files

You might ask, "why should I use named pipes instead of regular files?"

Regular files are slower than named pipes for interprocess communication because they are not optimized for messaging purpose. When data is written to a regular file, it is stored on the file system and the file system must update its metadata to reflect the new data. This can be slower than using a named pipe's buffer, especially if the data is being written to the file system frequently.

In addition, regular files have a more complex API than named pipes, as they can be opened for reading, writing, or both. This can make them slower to use in certain situations, as more code may be required to perform file operations.