Concurrency Programming (9): BlockingQueue and Channels — Atomicity, Visibility, and Ordering
From producer–consumer handoffs to Java BlockingQueue, Go channels, and CPython queue.Queue, this article examines the atomicity and memory-ordering guarantees of message passing.

Table of Contents
- 0. From Shared Memory to Message Passing
- 1. A Short History of Message Passing
- 2. What Do Message-Passing APIs Guarantee in Each Language?
- 3. Next: How Is Message Handoff Implemented?
0. From Shared Memory to Message Passing
The opening article introduced two common ways for concurrent execution units to exchange data and coordinate work:
- Shared Memory: execution units access shared state and coordinate through synchronization.
- Message Passing: execution units communicate by explicitly sending and receiving messages.
The preceding articles followed Shared Memory, showing how Mutexes, atomics, and ReadWriteLock provide Atomicity, Visibility, and Ordering.
We now turn to Message Passing. Before examining individual languages, it helps to see how the idea developed.
1. A Short History of Message Passing
1.1 Dijkstra: Cooperation Through a Shared Buffer
In 1968, Dijkstra’s Cooperating Sequential Processes explored coordination between sequential processes. One example uses a Producer and a Consumer exchanging data through a shared buffer, with Semaphores controlling access.
The Consumer waits when the buffer is empty; with a bounded buffer, the Producer must also wait when it is full.
1.2 RC 4000: Messaging Through the Operating System
In 1969, Per Brinch Hansen designed the RC 4000 Multiprogramming System around a system nucleus that supported interprocess communication through messages. His 1970 paper The Nucleus of a Multiprogramming System describes that design.
Unlike the shared-buffer example, the RC 4000 nucleus exposes explicit message operations:
send message: send a message to a specified process.wait message: wait for and receive a message.send answer: send a reply to the sender.wait answer: wait for the reply.
The nucleus manages message-buffer allocation and queues messages for their recipients.
Applied to a Producer and Consumer:
The Producer submits work with send message. The Consumer receives it using wait message and can then reply via send answer; the Producer retrieves the reply through wait answer.
If the Consumer has not begun receiving, the Producer may still complete its send, provided the system has enough message-buffer resources.
1.3 Actor Model: Asynchronous Messages and Local State
In 1973, Carl Hewitt, Peter Bishop, and Richard Steiger presented A Universal Modular ACTOR Formalism for Artificial Intelligence. In the Actor Model, each Actor owns its state and interacts with others through asynchronous messages. Incoming messages trigger the Actor’s behavior.
For a Producer–Consumer scenario, a Buffer Actor could accept work from the Producer and forward it to the Consumer:
This Buffer Actor and its Mailbox illustrate one possible design; they are not mandatory components of the Actor Model.
1.4 CSP: Synchronization Through Communication
Tony Hoare introduced Communicating Sequential Processes in 1978.
CSP describes programs as sequential Processes that communicate through explicit input and output operations. Its characteristic interaction is a rendezvous: a send and its matching receive must meet for communication to complete. A Process in this model is an abstract execution unit, not necessarily an operating-system process.
In the Producer–Consumer example, the Producer cannot hand over a task until the Consumer participates in the communication:
The Rendezvous is a logical communication event, not a central buffer. If the Consumer is not ready, the Producer waits until the operations can pair.
1.5 How Do These Approaches Differ?
These four approaches do not replace one another. They explore communication and cooperation between execution units in different contexts: Dijkstra studied process cooperation and synchronization; RC 4000 provided operating-system messaging; the Actor Model and CSP are more comprehensive models of concurrent computation.
| Approach | Intermediate Mechanism | Data Exchange | Primary Concern | If the Consumer Is Not Ready |
|---|---|---|---|---|
| Dijkstra | Shared Buffer | Direct access to shared storage | Synchronization and process cooperation | Buffered: write while space remains; wait when full; handoff can be asynchronous |
| RC 4000 | System Message Queue | Operating-system messaging interface | OS communication mechanism | Buffered: write while space remains; wait when full; handoff can be asynchronous |
| Actor Model | Actor | Messages sent to Actors | Actor identity, state, and behavior | Buffered: write while space remains; wait when full; handoff can be asynchronous |
| CSP | Rendezvous | Paired Send / Receive | Communication events and execution synchronization | Unbuffered: wait until the receiver is ready; handoff is synchronous |
1.6 Back to Message Passing
Message Passing lets execution units exchange data and coordinate explicitly through send and receive operations. Communication can be buffered or unbuffered: a buffer can temporarily hold messages, allowing the sender and receiver to complete the handoff asynchronously; without a buffer, messages cannot be stored, so both sides must be ready for a synchronous handoff.
We will examine Java BlockingQueue, Go channels, and CPython queue.Queue through three questions:
- Atomicity: what makes an individual message handoff safe?
- Visibility: can a receiver observe the data prepared before sending?
- Ordering: what synchronization order is established between sending and receiving?
2. What Do Message-Passing APIs Guarantee in Each Language?
2.1 Java: BlockingQueue
2.1.1 Which Queue Implementations Does Java Provide?
Java’s Queue interface represents a collection of elements awaiting processing. Common implementations can be grouped by whether they support concurrent access and blocking operations:
| Category | Representative Implementations | Supports Concurrent Access | Provides Blocking Operations |
|---|---|---|---|
| Ordinary Queue | ArrayDeque, PriorityQueue |
No | No |
| Concurrent Non-blocking Queue | ConcurrentLinkedQueue |
Yes | No; poll() immediately returns null when empty |
| BlockingQueue | ArrayBlockingQueue and others |
Yes | Yes; for example, take() waits for an item, and put() waits for space when a bounded queue is full |
Some representative BlockingQueue implementations:
| Implementation | Storage Structure | Capacity | Retrieval Order | Blocking Conditions |
|---|---|---|---|---|
ArrayBlockingQueue |
Array | Fixed at construction | FIFO | put() waits when full; take() waits when empty |
LinkedBlockingQueue |
Linked nodes | Optional limit at construction; defaults to Integer.MAX_VALUE |
FIFO | put() waits when full; take() waits when empty |
SynchronousQueue |
No element storage | 0 | Direct pairing between sender and receiver | put() waits for a receiver; take() waits for a sender |
PriorityBlockingQueue |
Heap | Unbounded | By priority; FIFO is not guaranteed for equal priorities | put() does not wait for space; take() waits when empty |
DelayQueue |
Priority heap | Unbounded | Earliest expiration first | put() does not wait for space; take() waits when empty or when no item has expired |
LinkedTransferQueue |
Linked nodes | Unbounded | FIFO | put() does not wait for space; take() waits when empty; transfer() waits until the element is received |
We will use the typical implementation ArrayBlockingQueue.
Its two relevant operations are:
put(e)inserts an element, waiting if the queue is full.take()removes and returns an element, waiting if the queue is empty.
2.1.2 Two Concurrency Guarantees of BlockingQueue
The JDK 25 BlockingQueue documentation states:
“All queuing methods achieve their effects atomically.”
This provides Atomicity. Each enqueue or dequeue is atomic, so concurrent operations do not corrupt queue state.
The same documentation specifies a memory-consistency effect:
Actions before placing an object into a
BlockingQueuehappen-before actions after another thread accesses or removes that object.
This establishes both Visibility and Ordering.
For Visibility, writes performed before a Producer enqueues a message are visible to the Consumer that retrieves it.
For Ordering, actions before sending, the message handoff, and actions after receiving establish a cross-thread memory order through the transitivity of happens-before.
We will reuse the opening article’s count++ example as we progress through SPSC, MPSC, and MPMC:
class Increment {
int delta;
}
BlockingQueue<Increment> queue = new ArrayBlockingQueue<>(2);
2.1.3 SPSC: Single Producer, Single Consumer
// Producer thread
Increment message = new Increment();
message.delta = 1;
queue.put(message);
// Consumer thread
int count = 0;
Increment received = queue.take();
count += received.delta; // count == 1
The relevant execution order is:
Producer Consumer
message.delta = 1
│
│ program order
▼
queue.put(message) ───── handoff ───────► queue.take() returns message
│
│ program order
▼
read received.delta == 1
count = 1
A successful put() inserts a message; take() removes and returns it. That demonstrates the Atomicity of each queue operation, although no competing Producers or Consumers exist yet.
delta is an ordinary field: neither volatile nor an atomic type is needed. Provided no other thread modifies this message after enqueueing it, the Producer’s write happens-before the Consumer’s read. The Consumer therefore sees 1, as required by Visibility and Ordering.
2.1.4 MPSC: Multiple Producers, Single Consumer
// Producers A and B each execute this in their own thread
Increment message = new Increment();
message.delta = 1;
queue.put(message);
// The sole Consumer thread
int count = 0;
for (int i = 0; i < 2; i++) {
count += queue.take().delta;
}
// After both messages have been submitted and processed: count == 2
One possible interleaving:
Producer A Producer B Consumer
put(A: +1)
put(B: +1)
take() -> +1
count: 0 -> 1
take() -> +1
count: 1 -> 2
Atomicity: concurrent Producers cannot overwrite one another’s successfully enqueued messages. If both put() calls complete, the Consumer can retrieve two messages.
Visibility / Ordering: the happens-before guarantee holds independently for each message. Nothing predetermines which Producer enqueues first.
count += delta is not an atomic instruction. It is safe from competing Consumer updates here because only one Consumer modifies count.
2.1.5 MPMC: Multiple Producers, Multiple Consumers
Now add another Consumer. The two Producers still enqueue one +1 message each, but Consumers A and B call take() concurrently:
// Producers A and B each send a distinct Increment instance
Increment message = new Increment();
message.delta = 1;
queue.put(message);
// Consumers A and B each execute once
Increment received = queue.take();
int delta = received.delta;
For example:
Producer A Producer B Consumer A Consumer B
put(A: +1)
put(B: +1)
take() -> A
take() -> B
Atomicity: a successful take() removes one element. Two Consumers cannot both remove the same enqueued message. A and B identify the messages, not fixed assignments to Consumers.
Visibility / Ordering: whichever Consumer receives a message can rely on its Producer’s preceding writes. FIFO dequeue order does not imply that Consumers finish their business logic in that order.
There is an important boundary. If both Consumers later update the same ordinary counter:
// Counter shared by both Consumers
class Counter { int count; }
Counter sharedCounter = new Counter(); // Created before starting them
// Each Consumer executes this in its own thread
int delta = queue.take().delta;
sharedCounter.count += delta;
Both messages can be removed safely while the final count is still 1 because of a lost update. BlockingQueue protects the atomicity of message handoff, not arbitrary business operations after the handoff.
2.1.6 Blocking and FIFO
All three scenarios also inherit two behaviors of ArrayBlockingQueue:
- Blocking:
take()waits when the queue is empty;put()waits when its capacity of2is exhausted. - FIFO: items leave in their actual enqueue order. Concurrent Producers have no predetermined insertion order, and multiple Consumers need not finish processing in dequeue order.
2.2 Go: Channels
Unlike Java, Go does not primarily expose a family of standard concurrent queues. It provides channels as a language construct for communication between Goroutines.
2.2.1 What Kinds of Channels Does Go Provide?
The Go Language Specification distinguishes channels by buffering:
| Type | Declaration | Behavior |
|---|---|---|
| Unbuffered | make(chan int) |
No stored messages; sends and receives pair directly |
| Buffered | make(chan int, 2) |
Fixed-capacity buffer; a sender can enqueue before a receiver is ready |
Channel types can also constrain direction:
| Direction | Type | Allowed Operations |
|---|---|---|
| Bidirectional | chan T |
Send and receive |
| Send-only | chan<- T |
Send only |
| Receive-only | <-chan T |
Receive only |
Direction and buffering are independent: a buffered channel can be passed to a function as chan<- T if that function should only send.
We will use a buffered channel with capacity 2:
type Increment struct {
delta int
}
jobs := make(chan Increment, 2)
The two operations we need are:
ch <- valuesends a value, waiting when the buffer is full.value := <-chreceives a value, waiting when the buffer is empty.
2.2.2 Channel Communication and Synchronization Rules
The Go Language Specification allows any number of Goroutines to send to or receive from one channel without additional synchronization.
That rule provides Atomicity at the communication boundary: concurrent Sends and Receives exchange messages safely. Each successful Receive corresponds to one sent message, which cannot be received again by another Consumer.
The Go Memory Model, Channel Communication also states:
“A send on a channel is synchronized before the completion of the corresponding receive from that channel.”
This establishes Visibility and Ordering.
For Visibility, writes completed before the Producer’s Send are visible to the Consumer after its matching Receive.
For Ordering, actions before sending, the channel’s Send / Receive, and actions after receiving establish a happens-before relationship through synchronized-before and program order.
As with Java, the examples reuse the opening article’s count++ example, building from SPSC through MPSC to MPMC.
2.2.3 SPSC: Single Producer, Single Consumer
// Producer Goroutine
message := Increment{delta: 1}
jobs <- message
// Consumer Goroutine
count := 0
received := <-jobs
count += received.delta // count == 1
The handoff establishes this order:
Producer Consumer
message.delta = 1
│
│ sequenced-before
▼
jobs <- message ─────── handoff ────────► received := <-jobs completes
│
│ sequenced-before
▼
read received.delta == 1
count = 1
A successful Send delivers one value to a successful Receive. No competing senders or receivers are involved yet.
delta is an ordinary field. Here, the channel carries an Increment value, and the send is synchronized-before the corresponding receive completes. The Consumer can therefore read 1, satisfying Visibility and Ordering.
2.2.4 MPSC: Multiple Producers, Single Consumer
// Producers A and B each execute once in a separate Goroutine
jobs <- Increment{delta: 1}
// The sole Consumer Goroutine
count := 0
for i := 0; i < 2; i++ {
received := <-jobs
count += received.delta
}
// After both messages have been sent and processed: count == 2
One possible interleaving:
Producer A Producer B Consumer
send(A: +1)
send(B: +1)
receive -> +1
count: 0 -> 1
receive -> +1
count: 1 -> 2
Atomicity: concurrent Sends cannot overwrite successfully sent messages; two successful Sends can be matched by two Receives.
Visibility / Ordering: each message independently establishes a Send/Receive synchronization relationship. Neither Producer is guaranteed to send first.
2.2.5 MPMC: Multiple Producers, Multiple Consumers
// Producers A and B each execute once in their own Goroutine
jobs <- Increment{delta: 1}
// Consumers A and B each execute once in their own Goroutine
received := <-jobs
delta := received.delta
For example:
Producer A Producer B Consumer A Consumer B
send(A: +1)
send(B: +1)
receive -> A
receive -> B
Atomicity: each successful Receive obtains the value of one matching Send; the same message is not delivered to two Consumers. A and B label messages, not predetermined receivers.
Visibility / Ordering: the receiving Consumer gets the synchronization guarantees associated with that message, but reception order does not determine processing-completion order.
What if both Consumers then modify a shared counter?
// Incorrect: two Consumers update the same ordinary counter
received := <-jobs
sharedCounter.count += received.delta
Even though both Receives succeed, concurrent writes to sharedCounter.count introduce a data race and can lose an update. Channels protect message handoff; they do not automatically protect shared state after receiving.
2.2.6 Buffered vs. Unbuffered Channels and FIFO
The previous examples use a buffered channel with capacity 2:
- Waiting: Receive waits when the buffer is empty; Send waits when it is full.
- FIFO: values are received in the channel’s send order. The order of competing Producers is not predetermined, nor is the completion order of multiple Consumers.
Go also supports unbuffered channels:
jobs := make(chan Increment)
With no storage, the Send cannot complete until a corresponding Receive participates. The Go Memory Model provides an additional rule: the matching Receive is synchronized-before the unbuffered Send completes. A buffered Send, in contrast, can finish before the receiver starts.
The next article will examine how the Go runtime parks Goroutines waiting for communication and resumes them when the conditions are met.
2.3 CPython: queue.Queue
Unlike Go, Python does not offer channels as language syntax. Instead, its standard library offers queue mechanisms for different concurrency models.
2.3.1 Which Queues Does Python Provide?
Python provides different queues for different execution units:
| Module | Representative Types | Communicating Units | Waiting Behavior |
|---|---|---|---|
queue |
Queue, SimpleQueue and others |
Threads within one process | Blocks the calling thread |
asyncio |
asyncio.Queue |
Asynchronous Tasks within one event loop | Suspends the calling Task, allowing other Tasks to run |
multiprocessing |
multiprocessing.Queue |
Separate processes | Blocks the caller |
queue provides thread-safe queues. asyncio.Queue is designed for asynchronous Tasks and is not thread-safe. multiprocessing.Queue supports interprocess communication and serializes the objects it transfers.
Our scope is CPython Threads, so we will focus on the standard library’s queue module. The Python documentation lists several implementations:
| Implementation | Capacity | Retrieval Order | Default Blocking Conditions | Task-Completion Tracking |
|---|---|---|---|---|
queue.Queue |
Configurable; unbounded by default | FIFO | put() waits when full; get() waits when empty |
Supported |
queue.LifoQueue |
Configurable; unbounded by default | LIFO | put() waits when full; get() waits when empty |
Supported |
queue.PriorityQueue |
Configurable; unbounded by default | Lowest-valued entry first | put() waits when full; get() waits when empty |
Supported |
queue.SimpleQueue |
Unbounded | FIFO | put() does not wait for space; get() waits when empty |
Not supported |
Task-completion tracking uses task_done() to mark a task as processed and join() to wait until all enqueued tasks have been processed.
Note: In Python,
maxsize <= 0means the buffer has no preset capacity limit; in Go, a channel with capacity 0 has no buffer.
We will use queue.Queue, whose two primary operations are:
| Operation | Meaning | Default Waiting Condition |
|---|---|---|
put(item) |
Inserts an item | Waits for space when full |
get() |
Removes and returns an item | Waits for an item when empty |
Both operations block by default. Use block=False for an immediate attempt or timeout to limit the wait. Failure to insert or retrieve an item raises queue.Full or queue.Empty, respectively.
2.3.2 Thread Safety and Synchronization in Queue
The Python queue documentation states:
“The
Queueclass in this module implements all the required locking semantics.”
This supports Atomicity at the queue boundary. Multiple threads may call put() and get() concurrently; internal locking protects queue state, so two Consumers cannot remove the same enqueued item.
For Visibility and Ordering, the Producer prepares a message before put(), and the Consumer reads that message after get() returns. CPython coordinates the handoff using the queue’s internal locks and condition variables, making the prepared data available to the receiving thread.
We will reuse the opening article’s count++ example, building from SPSC through MPSC to MPMC:
from dataclasses import dataclass
from queue import Queue
@dataclass
class Increment:
delta: int
jobs = Queue(maxsize=2)
2.3.3 SPSC: Single Producer, Single Consumer
One Producer sends +1; one Consumer receives the message and updates count:
# Producer thread
message = Increment(delta=1)
jobs.put(message)
# Consumer thread
count = 0
received = jobs.get()
count += received.delta # count == 1
The relevant handoff is:
Producer Consumer
message.delta = 1
│
▼
jobs.put(message) ── internal queue sync ─► jobs.get() returns message
│
▼
read received.delta == 1
count = 1
A successful put() inserts a message; get() removes and returns it. At this point there is no contention among multiple Producers or Consumers.
delta is an ordinary attribute. Provided no thread continues mutating this message after it is enqueued, the Consumer can read the value 1 following the queue handoff. This comes from Queue synchronization, not from a blanket guarantee that the GIL protects every shared object.
Unlike the earlier Shared Memory example, no separate shared ready flag is needed: get() waits for a message if the queue is empty.
2.3.4 MPSC: Multiple Producers, Single Consumer
Add a second Producer. Each submits a +1 message; the only Consumer accumulates both:
# Producers A and B each execute this in a separate thread
jobs.put(Increment(delta=1))
# The sole Consumer thread
count = 0
for _ in range(2):
received = jobs.get()
count += received.delta
# After both messages have been submitted and processed: count == 2
One possible interleaving:
Producer A Producer B Consumer
put(A: +1)
put(B: +1)
get() -> +1
count: 0 -> 1
get() -> +1
count: 1 -> 2
Atomicity: the two Producers can enqueue concurrently without overwriting each other’s successfully enqueued items; the Consumer can retrieve both.
Visibility / Ordering: each message is handed off through the queue’s synchronization. The order in which concurrent Producers enqueue is not predetermined.
count += received.delta remains a compound operation, but only the sole Consumer modifies count.
2.3.5 MPMC: Multiple Producers, Multiple Consumers
Now let two Consumers call get() concurrently while two Producers each enqueue +1:
# Producers A and B each run once in their own thread
jobs.put(Increment(delta=1))
# Consumers A and B each run once in their own thread
received = jobs.get()
delta = received.delta
For example:
Producer A Producer B Consumer A Consumer B
put(A: +1)
put(B: +1)
get() -> A
get() -> B
Atomicity: a successful get() removes one item; two Consumers cannot both remove the same enqueued item. A and B only distinguish the messages, not predetermined assignments.
Visibility / Ordering: whichever Consumer gets a message can read the data prepared before enqueueing it through the queue’s thread synchronization. Dequeue order still says nothing about when the Consumers finish their work.
If both Consumers subsequently update the same shared counter:
# Incorrect: both Consumers share the same counter
counter = {"value": 0} # Shared before starting either Consumer
# Each Consumer executes this
received = jobs.get()
counter["value"] += received.delta
Correctly dequeuing both messages does not guarantee that two counter["value"] += 1 operations cannot lose an update. Do not rely on the GIL to make this compound operation atomically safe; that assumption is especially unsafe with free-threaded CPython.
2.3.6 Blocking, FIFO, and Task Completion
The three scenarios also share these queue.Queue behaviors:
- Blocking: with a capacity of
2,put()waits by default if the queue is full;get()waits if it is empty. Amaxsizeof0means no capacity limit, not a Go-style unbuffered rendezvous. - FIFO: items are removed in actual enqueue order. Concurrent Producers have no predetermined arrival order; multiple Consumers need not finish processing in dequeue order.
- Task completion:
get()reports only that a task has been removed.task_done()marks processing complete, whilejoin()waits until the unfinished-task count reaches zero. Receiving and finishing are different events.
The next article will examine how CPython implements this behavior with Locks and Condition variables.
3. Next: How Is Message Handoff Implemented?
At the language level, Java BlockingQueue and Go channels provide explicit communication and memory-synchronization rules. CPython queue.Queue provides safe handoff through its standard library’s thread synchronization.
Next, we will trace Java ArrayBlockingQueue, Go channels, and CPython queue.Queue through synchronization primitives, runtime implementations, the operating system, and ultimately the CPU.
Discussion
Sign in with GitHub to comment. Discussions are stored as GitHub Issues.View on GitHub