JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Share by Communicating: Message Passing

Every problem in this rung so far has assumed threads share one block of memory and fight over it with locks. Message passing flips the picture: keep your data to yourself, and coordinate by sending each other notes down a channel. It is the only way to talk across machines, and a surprisingly calm way to talk within one.

Two philosophies of talking

Everything in this rung so far has rested on one assumption: two or more threads see the same block of memory, and the whole drama is about keeping them from trampling it. The semaphore, the mutex, the compare-and-swap loop from guide 3 are all referees for that one shared playground. The pattern even has a name: communicate by sharing. You leave a value in shared memory, and the way I learn what you did is by reading that same memory. The lock is the etiquette that keeps us from reading it half-written.

Message passing is the opposite philosophy, captured in a single slogan worth memorizing: do not communicate by sharing memory; instead, share memory by communicating. Each thread or process keeps its own data strictly to itself. When two of them need to coordinate, they do not touch each other's variables at all — one packages up a copy of what it wants to convey and sends it as a message; the other receives that copy. Nobody ever points at the same byte, so there is simply no shared playground left to fight over.

Notice what this buys you. Most of the agony of the earlier guides — the race condition where two updates interleave, the lost update, the forgotten lock — comes from two flows of control mutating one cell at the same time. If they never share a cell, that entire class of bug cannot occur. You have not made concurrency easy; you have moved the hard part somewhere far easier to see, into the explicit act of sending and receiving.

Send, receive, and the only two primitives

A message-passing system needs exactly two operations: send(destination, message) hands a message off, and receive(source, message) waits for one to arrive. That is the whole interface. Underneath, this is just a disciplined form of inter-process communication — the same machinery the kernel offers for pipes, message queues, and sockets — dressed up as a clean send/receive pair. The interesting design question is one tiny detail: what does send do if the receiver is not ready yet?

There are two honest answers, and they feel completely different to program with. In synchronous (also called blocking, or rendezvous) message passing, send waits until the receiver actually takes the message; the two flows meet at a single instant, hand the message over, and only then walk on. In asynchronous message passing, send drops the message into a buffer and returns immediately, so the sender races ahead and the receiver catches up later. Synchronous gives you a built-in acknowledgement — when send returns, you know it was received — at the cost of making the sender wait. Asynchronous keeps the sender free but needs somewhere to hold messages in flight, and that buffer can fill up.

Channels, and the producer-consumer problem reborn

Modern languages give send/receive a friendly face called a channel — a typed conduit you can write into one end and read from the other, like a one-way pipe between threads. Go built its whole concurrency story around channels and lightweight goroutines; Erlang routes everything through process mailboxes; Rust ships channels in its standard library. The channel is the seam: producers do not know who consumes, consumers do not know who produces, and neither side ever holds a lock you can see. Here is the classic producer-consumer pattern rewritten with no shared variable and no explicit lock — just a channel.

  channel ch        // a typed conduit; capacity = small bounded buffer

  producer():
      loop:
          item = make_item()
          send(ch, item)      // blocks if the channel buffer is full

  consumer():
      loop:
          item = receive(ch)  // blocks if the channel is empty
          use(item)

  -- no shared variable, no lock(), no wait(S)/signal(S) anywhere --
  -- the channel itself enforces: empty -> consumer waits
  --                              full  -> producer waits
The producer-consumer problem from guide 1, expressed as message passing. Compare it to the semaphore version: the wait/signal pairs and the shared buffer have vanished into the channel, which is impossible to use without synchronizing correctly.

Read that again and feel what changed. In the semaphore version, correctness depended on every thread remembering to call wait and signal in the right order; forget one and the whole thing silently corrupts. Here the synchronization is not a discipline you must remember — it is baked into the channel, which physically cannot deliver an item to a consumer before a producer sends it. The hard-won lesson of guide 1, that a semaphore only protects you if everyone uses it correctly, is sidestepped: there is no separate lock to misuse, because the channel and the data travel together.

Across machines: the same idea, no choice involved

Within one computer, message passing is a tasteful choice you can make or decline. Across two computers, it stops being a choice. Two machines do not share memory at all — there is no common address space for a lock to live in, no shared cell to compare-and-swap. The only thing they can do is send each other bytes over a wire. So every distributed system, from a web browser talking to a server to a cluster of databases, is fundamentally a message-passing system, whether its authors think of it that way or not.

A popular way to dress this up is the remote procedure call (RPC): you call what looks like an ordinary function, and the system quietly turns the call into a request message, ships it to another machine, runs the real function there, and ships the result back. To do that it must marshal your arguments — flatten your in-memory objects into a stream of bytes that can travel and be rebuilt on the far side, since a pointer that means something in your address space is meaningless in another. RPC is comfortable precisely because it hides the messages. Be honest about the disguise, though.

Honest trade-offs, and where it fits

Message passing is not a free lunch, and it is not automatically better than shared memory — only different. Its biggest cost is the copy: shared memory hands you a pointer to data already in place, whereas a message is copied from sender to receiver, which for large payloads is real work. Some systems soften this by passing ownership instead of copying (Rust moves a value so only one thread can ever touch it), or by sharing the page under copy-on-write so the duplicate is made only if someone writes. But the mental model stays clean: you reason as if every message were a private copy.

And message passing does not magically banish every concurrency hazard. Two threads can still deadlock over channels: A blocks sending to B while B blocks sending to A, and both wait forever — the same four conditions you learned, just with channels as the resources instead of locks. The win is not that the dangers vanish; it is that the remaining dangers live in a small, visible set of send and receive calls you can point at, rather than spread invisibly across every access to shared memory.

Where does it fit beside the other tools? A coroutine passing values through a channel is often the most readable way to express a pipeline of stages. Shared-memory locks still win when the data is genuinely huge and copying it would dominate. And the next guide closes this rung by asking the uncomfortable question that hangs over all of these approaches — locks, lock-free, message passing alike: why are concurrency bugs so fiendishly hard to catch, when each individual mechanism seems so reasonable on its own?