Thursday, April 16, 2026

Inter thread communication in Julia - Channel and Wait-Notify...

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:

    • empty but open → it blocks (yields the task)

    • closed and empty → returns nothing → loop ends

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

  • for loop 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:

  1. Blocking synchronization (wait for data)

  2. Data flow semantics (consume stream)

  3. 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 Waitin...")

lock(mtx) do
wait(cond) #correct lock
end

println("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?

  1. Prints numbers 1 → 5
  2. Acquires lock and calls:

    wait(cond)
  3. Internally:
    • Releases mtx
    • Goes to sleep
    • Gets queued on cond

The thread is now blocked without consuming CPU

Main Thread (Producer / Notifier)

sleep(10)

lock(mtx) do
notify(cond)
end

What happens:

  1. After 10 seconds, main thread:
    • Acquires the same lock (mtx)
    • Calls notify(cond)
  2. 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:

  • wait
  • notify

wait is atomic

When calling:

wait(cond)

Julia:

  1. Releases lock
  2. Sleeps
  3. On notify → wakes up
  4. 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

No comments: