Chapter 17 Concurrency
SourceConcurrency in Silo is built out of two pieces: an effect hierarchy that describes where code can run, and the locality modes from chapter 17 that decide which values can cross a spawn boundary. Everything else — tasks, threads, locks, channels — is ordinary library code.
Crucially, there are no function colors. Pure words can be called from concurrent code; +Concurrent code can call into synchronous code. The split between async and threads is a choice the host makes, not a pervasive property of your code.
The executor hierarchy
At the top of the concurrency stack sits +Executor. Every execution unit — an OS thread or an async task — runs under some executor. Below that are two children:
+Thread—+Executorspecialised for OS threads. Its associatedidtype isThreadId.+Concurrent—+Executorspecialised for async tasks. Its associatedidtype isTaskId. Adds async-only primitives like cancellation and timeout.
Both children inherit the shared surface — .spawn, .await, .join-all, .yield, .sleep, .suspend/.resume, .current-id — so code that constrains only +Executor works under either. Use the child when you need something specific (cancellation only makes sense for async tasks, stack sizes only make sense for threads).
The host decides which executor is available. A CLI program might install a threadpool that exposes +Thread; a server might install an async runtime that exposes +Concurrent. Your code picks the effect it constrains against.
Spawning
.spawn takes a quotation and returns a (Handle val):
:fn parallel-work ( -> ) + Concurrent
[ heavy-computation-1 ] .spawn pop-> h1
[ heavy-computation-2 ] .spawn pop-> h2
h1 .await
h2 .await
:end
.spawn starts the quotation running on the executor. The returned Handle is a value you hold until you want the result — .await consumes the handle and blocks the current task until the spawned one finishes.
For batches, .join-all takes a Vec of handles and waits on all of them:
urls [ [ pop-> u u http-get ] .spawn ] .map .join-all
Handles carry the local mode (from chapter 17) so they can't be stored in longer-lived containers without care. If a handle's RC drops to zero without an .await or .cancel, the task is cancelled automatically — structured concurrency falls out of locality and :dispose.
Thread-safety via locality
Here's the important bit. Silo has no Send/Sync traits and no function-coloring. Thread safety is enforced by the locality modes from the lifecycle chapter:
- A
localvalue cannot cross a.spawnboundary. The compiler rejects the capture. This covers the "this handle shouldn't leave my region" case. - A
uniquevalue cannot be aliased, so the question of "is it safe to share across threads?" doesn't come up — you can pass it, but you can't keep a reference on the caller's side. - A
linearvalue must be consumed; if it crosses into a spawned task, the caller has given it up entirely. - A
globalvalue can be captured freely. The compiler handles the reference counting so both sides see a consistent view.
No data races because the compiler rejects any capture that would create one.
:fn use-guard ( ( RwLock Int ) -> Int ) + Concurrent
.read-lock pop-> guard # guard is local
[ guard .get ] .spawn .await # compile error — local can't cross spawn
:end
Synchronisation primitives
The standard library ships the usual suspects:
Mutex— mutual exclusion;.lockreturns a guard that disposes to unlock.RwLock— multi-reader / single-writer;.read-lockand.write-lockreturn differently-typed guards.Atomic— atomic integer / pointer cells for lock-free patterns.Sender/Receiver— bounded MPSC channels.channeltakes a capacity and returns a(Sender t)+(Receiver t)pair.Senderis cloneable (multiple producers);Receiveris affine (single consumer — moving one would require giving up your own, no aliases possible). A channel closes when every sender has been dropped or.closed, at which point.recvreturnsNoneonce the buffer drains..selecttakes a(Seq (Receiver t))and waits on whichever receiver is ready first.
Each uses the same locality-mode story to stay sound. A RwLock's read guard is local so it can't escape the acquired scope; a Mutex's write guard is unique so two aliased references to it can't exist.
Async-only primitives
On top of the shared +Executor surface, +Concurrent adds two async-only operations:
.cancel— request cancellation of a handle. The task may not stop immediately; the host makes a best effort..timeout— run a quotation with a duration limit. ReturnsOk(val)if the quotation finishes in time orErr(TimedOut)if the limit elapses first.
:fn fetch-with-timeout ( Url UDuration -> ( Result HttpResp Str ) ) + Concurrent + Http
pop-> limit pop-> url
limit [ url http-get .unwrap ] .timeout
:match
| Ok resp => resp Ok
| Err TimedOut => "request timed out" Err
:end
:end
Key points
+Executoris the shared surface for concurrency;+Threadand+Concurrentare the two specialised child effects for OS threads and async tasks respectively.- No function colors. Pure code and concurrent code call each other freely; the effect row tells you which is which.
.spawnreturns a(Handle val)withlocalmode..awaitconsumes it; dropping it without.awaitcancels the task (structured concurrency via dispose).- Thread safety is enforced by locality modes from chapter 17. The compiler rejects spawn-captures that would create data races — no
Send/Synctraits needed. - Standard primitives:
Mutex,RwLock,Atomic, channels viaSender/Receiverwith.select. - Async-only:
.cancel,.timeouton+Concurrent.
Next: variadic functions and reflection — the /N arity suffix, output variadics, and :type-info.