DEV Community

Somenath Mukhopadhyay
Somenath Mukhopadhyay

Posted on • Originally published at som-itsolutions.blogspot.com on

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

this is conceptually equivalent to:

while true
    val = take!(ch) # blocks if empty
    println("Consuming $val on thread $(threadid())")
end

Enter fullscreen mode Exit fullscreen mode

…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

Enter fullscreen mode Exit fullscreen mode

it translates roughly into:

state = iterate(ch)

while state !== nothing
    (val, next_state) = state
    println(...)
    state = iterate(ch, next_state)
end

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode
  • Pushes data into the channel

  • If the channel buffer is full → producer blocks

Consumer Thread

for val in ch

Enter fullscreen mode Exit fullscreen mode
  • 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 */ }

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 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

Enter fullscreen mode Exit fullscreen mode

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:

  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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Critical Rules

1. Lock must be held

Both must be inside:


lock(mtx) do ... end

Enter fullscreen mode Exit fullscreen mode

Otherwise:


ConcurrencyViolationError("lock must be held")

Enter fullscreen mode Exit fullscreen mode

Same lock everywhere


cond = GenericCondition(mtx)

Enter fullscreen mode Exit fullscreen mode

👉 You must use this exact mtx for:

  • wait
  • notify

wait is atomic

When calling:


wait(cond)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
  • 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)