Wednesday, April 8, 2026

Half Sync - Half Async design pattern implemented using Julia...

My deep study of the Android AsyncTask framework many years ago - a perfect example of this design pattern by bright Google engineers...





The Julia Code


struct Task
id::Int
payload::Float64
end

function worker(id, ch::Channel)
for task in ch
println("Worker $id processing Task $(task.id)")
result = sum(sin.(1:10^6 .* task.payload))
println("Worker $id finished Task $(task.id)")
end
println("Worker $id shutting down")
end

function async_producer(ch::Channel, n::Int)
for i in 1:n
sleep(rand())
println("Producing Task $i")
put!(ch, Task(i, rand()))
end
close(ch)
end

function run_system(num_tasks=10, num_workers=4)
ch = Channel{Task}(32)

@sync begin
# Producer runs as async task tracked by @sync
@async async_producer(ch, num_tasks)

# Workers
for i in 1:num_workers
Threads.@spawn worker(i, ch)
end
end
end

run_system(20, Threads.nthreads())


1. The Pattern Refresher

Half-Sync/Half-Async splits a system into:

🔹 Async Layer 

  • Non-blocking

  • Event-driven

  • Produces work

🔹 Sync Layer 

  • Blocking / CPU-bound

  • Deterministic execution

  • Processes work

🔹 Boundary 

  • A queue (here: Channel)

  • Decouples the two layers

2. Mapping The Code to the Pattern

🔸 (A) Boundary → Channel

ch = Channel{Task}(32)

This is the core of the pattern.

👉 It acts as:

  • A thread-safe queue

  • A decoupling buffer

  • A synchronization boundary

Interpretation:

“Async world hands off work to Sync world through a controlled interface.”

(B) Async Layer → async_producer

@async async_producer(ch, num_tasks)

Inside:

for i in 1:n
    sleep(rand())
    put!(ch, Task(i, rand()))
end
close(ch)

Why this is “Async”:

  • @async → cooperative scheduling (non-blocking)

  • sleep(rand()) → simulates unpredictable external events

  • put! → hands off work without doing computation

Conceptual role:

“I don’t process. I just observe and emit events.”

(C) Sync Layer → worker

Threads.@spawn worker(i, ch)

Inside:

for task in ch
    result = sum(sin.(1:10^6 .* task.payload))
end

Why this is “Sync”:

  • take! (via for task in ch) → blocking

  • CPU-heavy computation

  • Runs on real OS threads

Conceptual role:

“Give me work. I will process it fully and deterministically.”

(D) Coordination → @sync

@sync begin
    @async async_producer(...)
    Threads.@spawn worker(...)
end

This is not part of the original pattern per se, but in Julia it ensures:

  • The system behaves like a long-running service

  • Main thread waits for both layers

3. End-to-End Flow (Pattern in Action)

Step-by-step:

  1. Async Layer wakes up

    • Generates a task (like a sensor or network event)

  2. Task is enqueued

    put!(ch, Task(...))
    
  3. Sync Layer pulls work

    task = take!(ch)
    
  4. Processing happens

    • Heavy computation (sin, sum, etc.)

  5. Repeat until channel closes

4. Why This is Half-Sync/Half-Async (Not Just Threads)

Because of strict separation of concerns:

ConcernWhere handled
Event timingAsync layer
Work queueingChannel
ExecutionSync layer

👉 The producer never processes
👉 The worker never generates events

That separation is the essence of the pattern

5. Key Properties The Code Achieves

Decoupling

  • Producer speed ≠ Worker speed

  • Buffered via Channel(32)

Backpressure

  • If workers are slow → channel fills → put! blocks

  • Natural flow control

Scalability

Threads.@spawn worker(i, ch)
  • Increase workers → parallelism increases

Clean Shutdown

close(ch)
  • Workers exit automatically via:

for task in ch

6. Subtle but Deep Insight

The system is not just parallel — it is:

A streaming system with a controlled execution boundary

This is exactly how:

  • High-performance servers

  • Simulation engines

  • Data pipelines

are designed internally.

No comments: