Building a pizzeria out of processes, threads and Unix sockets
SPVT-03
Plazza is a C++20 pizzeria simulation: one reception process, kitchens forked on demand, cook threads inside each kitchen, and Unix domain sockets gluing it together. Here is how the pieces fit and which trade-offs we made.
Plazza is a small C++20 project that simulates a pizzeria: you type orders in a shell, a reception dispatches pizzas to kitchens, and each kitchen is a separate process with its own cook threads. It is a compact way to practise fork, sockets, thread pools and mutexes in one program.
This post walks through the architecture, the wire protocol between processes, and the few places where the design is deliberately simple.
plazza-mirror
Shipped
A pizzeria simulation: reception, kitchens as child processes, cooks as threads, Unix domain sockets for IPC.
The reception is the parent process. It reads orders from stdin, turns each line into individual pizzas, and hands them to a KitchenManager. Every kitchen is a child process created with fork(), and every cook inside a kitchen is a thread.
C++20
CMake
Criterion
libuuid
POSIX sockets
std::thread
The source tree mirrors that split, with a small lib/ folder for the system-level building blocks.
tests/# Criterion tests for the lib and the managers
CMakeLists.txt
The binary takes three parameters: a multiplier applied to cooking times, the number of cooks per kitchen, and the stock refill period in milliseconds.
An order line follows a small grammar: a type, a size and a quantity, with several items separated by semicolons.
text
regina XXL x2; fantasia M x3; margarita S x1
The Waiter polls stdin with a 10 ms timeout so the main loop never blocks, splits the line on ;, validates each part, and pushes one Pizza per unit into a queue. A line like regina XXL x2 therefore becomes two queue entries. Typing status triggers a status broadcast instead.
There are four pizzas, each with its own recipe and base baking time. The multiplier scales the baking time.
Pizza
Ingredients
Base baking time
Margarita
dough, tomato, gruyere
1 s
Regina
dough, tomato, gruyere, ham, mushrooms
2 s
Americana
dough, tomato, gruyere, steak
2 s
Fantasia
dough, tomato, eggplant, goat cheese, chief love
4 s
Dispatching: round-robin first, spawn when everyone is full#
The reception loop is deliberately simple: tick the manager, read input, then drain the pending queue. The interesting part is sendPizza, which tries the existing kitchens in round-robin order and only forks a new one when nobody accepts.
Ask each kitchen in turn
The manager sends /cook followed by the serialized pizza to a kitchen, starting after the last kitchen that accepted.
Wait for a yes or a no
The kitchen answers true or false. The manager waits up to 200 ms for the reply, polling every 5 ms.
Spawn a new kitchen if all refuse
If every kitchen said false, the manager generates a UUID, forks a new kitchen on a socket named after it, and sends the pizza there.
A kitchen refuses a pizza in two cases: it already holds twice as many orders as it has cooks, or its stock cannot cover the recipe. Both checks happen on the kitchen side, so the reception stays free of any kitchen state.
src/kitchen/Kitchen.cppcpp
void Kitchen::_commandCook(UDS &client, const std::vector<std::string> &args){ if (args.size() < 2 || _cookManager.workload() >= _cookManager.max() * 2) { client.send("false"); return; } // ... deserialize the pizza ... auto ingredients = Pizza::ingredientsFor(pizza.type); if (!_stock.tryConsume(ingredients)) { client.send("false"); return; } client.send("true"); // ... submit the baking task to the cook pool ...}
Inside a kitchen, the cooks are a PoolThread: a fixed number of worker threads waiting on a condition variable for tasks. Baking a pizza is a task that sleeps for the scaled baking time and then prints a completion line.
The pool exposes two numbers that the kitchen reuses directly. remainingThreads() is the number of idle cooks, and workload() is the number of busy cooks plus queued tasks. The second one is exactly what the "2 x N pizzas in flight" rule compares against.
The stock is the other shared structure. Every ingredient starts at 5 units, and a refill timer adds one unit of each ingredient every refill period. tryConsume checks the whole recipe and deducts it inside a single lock, so a pizza never consumes half its ingredients. The lock is wrapped in a tiny MutexValue<T> helper that only exposes the value through withLock.
Kitchens and reception talk through a UDS class wrapping a non-blocking AF_UNIX stream socket. Its constructor does something neat: it first tries to connect to the path, and if nobody is listening it binds, listens and accepts a single connection instead. The same constructor therefore serves both ends of the link.
The parent never has to know which side is which. Process forks, the child builds its UDS on the socket path, and the parent then builds one on the same path. The messages are plain strings:
Message
Direction
Meaning
/cook <pizza>
reception to kitchen
Try to take this pizza
true / false
kitchen to reception
Accepted or refused
/status
reception to kitchen
Report cooks, orders and stock
/stop
reception to kitchen
Shut down
/exit
kitchen to reception
Idle for too long, closing
A pizza travels as exactly two bytes: its type and its size. The enum values were chosen as small non-zero numbers (1, 2, 4, 8 for types and 1 to 16 for sizes), so no byte is ever a NUL or a space. That matters because messages are NUL-terminated and split on spaces.
A kitchen runs a tight loop: handle a command, refill the stock when the timer fires, and check an inactivity timer. The inactivity timer is reset every time the kitchen accepts a pizza. After 5 seconds with nothing accepted, the kitchen sends /exit and stops.
On the reception side, tick() receives that message, removes the kitchen from its list and destroys the matching Process, whose destructor calls waitpid so no zombie is left behind. When a socket closes, recv() reports it as an /exit too, which keeps cleanup uniform. Pressing Ctrl+C sets a sig_atomic_t flag that ends the reception loop, and the manager's destructor sends /stop to every remaining kitchen.
Here is what a short session looks like. The lines come from the messages the code prints, so the exact interleaving will vary from run to run.
plazza
$ ./build/plazza 1 2 2000
margarita S x5
[reception] dispatching Margarita S
[kitchen 0] opening
[reception] dispatching Margarita S
[reception] dispatching Margarita S
[reception] dispatching Margarita S
[reception] dispatching Margarita S
[kitchen 1] opening
[kitchen 0] done: Margarita S
With two cooks, a kitchen holds at most four pizzas, so the fifth one makes the reception open a second kitchen.
The project builds two binaries from the same sources. plazza is the program, and binary_test links the same code against Criterion with tests for MutexValue, Stock, Timer, UDS, Process, PoolThread, Reception and KitchenManager. A GitHub Actions workflow builds the project, runs the tests, and on the main branch mirrors the repository to the school's remote.
The current version is intentionally compact, and a few things stand out as the next steps:
Replace the polling poll_recv with a proper readiness loop over all kitchen sockets.
Add length-prefixed framing to the socket protocol.
Make the status output a single aggregated report rather than one line per kitchen.
If you want to read the code, start with KitchenManager.cpp and Kitchen.cpp: together they are under 260 lines and contain the whole dispatching story.