Waiting is overlapped; computing must be split
@async/@sync.@spawn.▶ Script · Notebook · 🐍 Python · ✏️ Activity · ✏️ Activity (Colab)
⬅ Previous: 2 — Multiple dispatch vs OOP · Next: 4 — Memory & batching ➡
| problem | fix | cores | |
|---|---|---|---|
| Concurrency | waiting (network, disk) | overlap the waits | 1 |
| Parallelism | computing | split the work | many |
Waiting is overlapped; computing must be split.
One core is enough: only waiting to overlap.
Bet first: how long does each one take?
▶ Script: cell “Three ways to fetch”
| time | what happened | |
|---|---|---|
| 1) sequential | ~4 s | sum of 15 waits |
2) @async without @sync |
~10 µs | ⚠️ timed task creation only |
3) @sync + @async |
~0.3 s | ≈ one round-trip |
@sync: honest timing and correct result.~4 s → ~0.3 s on one core.
Julia @async |
Python async def |
|
|---|---|---|
| the task is… | eager: running | lazy: a coroutine object |
| ready at once | 0 / 15 | 0 / 15 |
| 2 s later | 15 / 15 | 0 / 15: never started |
@sync.await / asyncio.gather.
asyncio.gather=@sync+@async.
@spawn: real parallelismjulia --threads=auto (else nthreads() = 1).Threads.nthreads() = 22.
@asyncand@spawnlook alike. Only one uses more cores.
markov_seq(K, L) = [simulate_markov(L) for _ in 1:K]
markov_spawn(K, L) =
fetch.([Threads.@spawn simulate_markov(L) for _ in 1:K])K = 8 chains, L = 2×10⁷ steps, no shared state.▶ Script: cell “Predict the speedup on 8 chains, then run”
Independent tasks: each returns,
fetchcollects.
@asyncBet first: π (
n = 10⁸),@async× 22: speedup? ▶ Script: cell “Same CPU work with @async”
| time | speedup | |
|---|---|---|
pi_seq (one core) |
0.357 s | — |
@async × 22 |
0.332 s | 1.1× |
@spawn × 22 |
0.057 s | 6.3× |
Concurrency ≠ parallelism, as a number.
π on all 22 threads, 20 million points, one task per thread.
What does it print?
▶ Script: cell “π on all threads, one task per thread: what does it print?”
| run 1 | run 2 | run 3 | run 4 | |
|---|---|---|---|---|
pi_race |
0.219 | 0.361 | 0.354 | 0.393 |
pi_spawn |
3.14126 | 3.14174 | 3.14172 | 3.14146 |
hits += 1 = read, add, write: increments get lost.Warning
No error. No warning. Just a wrong number.
No shared counter in the hot loop: correct and fast.
▶ Script: cell “The cure”
Threads.Atomic makes hits += 1 safe. Correct? Fast?
▶ Script: cell “Why not an atomic counter?”
22 threads, vs pi_seq (1 core) |
|
|---|---|
pi_spawn (return + reduce) |
6.3× faster |
pi_atomic |
~7× slower |
Correct, but catastrophic. The cure is less sharing, not a bigger lock.
Bet first: speedup on 2, 4, 22 threads? ▶ Script: cell “Speedup vs nthreads” (
--threads=1, 2, 4)
| threads | 1 | 2 | 4 | 22 |
|---|---|---|---|---|
| speedup | 1× | 1.8× | 3.1× | 6.3× |
6 fast (5.0 GHz), 8 medium (3.8 GHz), 2 slow (2.5 GHz) cores.
22 equal π tasks, each timed (demo, not in the script):
| fastest task | slowest task |
|---|---|
| 0.022 s | 0.051 s (2.3×) |
The slowest task sets the wall clock. Fix: more, smaller tasks.
ThreadPoolExecutor on… |
speedup |
|---|---|
| I/O (15 URLs) | ~10× |
| CPU (pure-Python loop) | ~1× |
“Threads are pointless in Python”: false, only for CPU. Julia has no GIL.
| I/O-bound (waiting) | CPU-bound (computing) | |
|---|---|---|
| Python | threads (GIL released), asyncio |
multiprocessing |
| Julia | @async / @sync (one core) |
@spawn / fetch (many cores) |
Waiting is overlapped; computing must be split.
@spawn vs @async: experiences/threads_scaling/⬅ Previous: 2 — Multiple dispatch vs OOP · Next: 4 — Memory & batching ➡