There are a few ways we can accomplish inter-thread communication in Julia. In this article, we will look into two ways - via channel and via wait & notify.
Via Channel...
Here's an implementation of the Producer-Consumer problem in Julia using two threads. The producer creates and puts it in a channel, whereas the consumer takes it from the channel. The producer and consumer run in two different threads.
using Base.Threads
ch = Channel{Int}(10)
@sync begin
# Producer (runs on a thread)
Threads.@spawn begin
for i in 1:5
println("Producing $i on thread $(threadid())")
put!(ch, i)
sleep(0.5)
end
close(ch)
end
# Consumer (runs on another thread)
Threads.@spawn begin
for val in ch
println("Consuming $val on thread $(threadid())")
end
end
end
Let's try to dissect the above code.
The Key Idea: Channels are Iterable Streams
In Julia, a Channel is not just a queue—it implements the iteration protocol.
So when we write:
for val in ch
println("Consuming $val on thread $(threadid())")
end
this is conceptually equivalent to:
while true
val = take!(ch) # blocks if empty
println("Consuming $val on thread $(threadid())")
end
…but with one crucial addition:
The loop automatically stops when the channel is closed.
What Actually Happens Internally
When Julia executes:
for val in ch
it translates roughly into:
state = iterate(ch)
while state !== nothing
(val, next_state) = state
println(...)
state = iterate(ch, next_state)
end
For Channel, iterate(ch) is defined such that:
It internally calls
take!(ch)If data is available → returns
(value, state)If the channel is:
Why This is Perfect for Concurrency
Let’s break down the behavior in your example:
Producer Thread
put!(ch, i)
Pushes data into the channel
If the channel buffer is full → producer blocks
Consumer Thread
for val in ch
If data is available → consumes immediately
If empty → consumer blocks (non-busy wait!)
If channel is closed → loop exits cleanly
Important: This is NOT Busy Waiting
A common mistake in other languages:
while(queue.empty()) { /* spin */ }
But Julia does this instead:
The consumer yields control
The scheduler runs another task
When
put!happens → consumer is resumed
This is cooperative scheduling , not CPU spinning.
Why for val in ch is Better Than take! Loop
We could write:
while true
val = take!(ch)
println(val)
end
But then we must manually handle termination:
How do we know when to stop?
We'd need a sentinel value or extra signaling
With:
for val in ch
we get:
Automatic blocking
Automatic wake-up
Automatic termination on
close(ch)Cleaner, declarative code
The Role of close(ch)
This line in our producer is critical:
close(ch)
Without it:
The consumer will wait forever
Because it assumes more data might come
With it:
The iteration ends naturally
forloop exits → task completes
Subtle but Important Detail
Even though we used:
Threads.@spawn
The channel itself is thread-safe , meaning:
Multiple producers/consumers can safely operate
Synchronization is handled internally
Final Insight
Consumer code:
for val in ch
is not just syntactic sugar—it encodes three things at once:
Blocking synchronization (wait for data)
Data flow semantics (consume stream)
Termination protocol (stop on close)
That’s why it’s considered idiomatic Julia concurrency.
Via Event/Condition
This is pretty good for implementing signalling between threads.
Here's the code for such a system.
using Base.Threads
mtx = ReentrantLock()
cond = Base.GenericCondition(mtx) #bind lock + condition
@sync begin
Threads.@spawn begin
for i in 1:5
println(i)
sleep(1)
end
println("Now Waiting on a condition and will wake up only when that condition will be signalled...")
lock(mtx) do
wait(cond) #correct lock
end
println("Now again Resuming!")
for j in 6:10
println(j)
sleep(1)
end
end
sleep(10)
lock(mtx) do
notify(cond) #SAME lock
end
end
Core Idea
A Condition in Julia is a wait queue tied to a lock.
Threads can:
- wait(cond) → sleep until signaled
- notify(cond) → wake waiting thread(s)
What happens in the code?
- Prints numbers
1 → 5 Acquires lock and calls:
-
Internally:
- Releases
mtx - Goes to sleep
- Gets queued on
cond
- Releases
The thread is now blocked without consuming CPU
Main Thread (Producer / Notifier)
sleep(10)
lock(mtx) do
notify(cond)
end
What happens:
- After 10 seconds, main thread:
- Acquires the same lock (
mtx) - Calls
notify(cond)
- Acquires the same lock (
- This:
- Wakes the waiting thread
- That thread re-acquires the lock
- Continues execution
Execution Timeline
Worker Thread Main Thread
-------------- -------------
1 → 5 printed
Now Waiting...
(wait → sleep)
sleep(10)
notify(cond)
Resuming!
6 → 10 printed
Critical Rules
1. Lock must be held
Both must be inside:
lock(mtx) do ... end
Otherwise:
ConcurrencyViolationError("lock must be held")
Same lock everywhere
cond = GenericCondition(mtx)
👉 You must use this exact mtx for:
waitnotify
wait is atomic
When calling:
wait(cond)
Julia:
- Releases lock
- Sleeps
- On notify → wakes up
- Re-acquires lock
This avoids race conditions
Conceptual Model
Think of it like:
Condition = Lock + Queue of waiting threads
-
wait→ join queue -
notify→ wake one (or all)
When to use this?
Use Condition variables when:
- You need pure signaling
- No data needs to be transferred
Use Channel when:
- You need data + synchronization
Top comments (0)