GuideThe Silo HandbookConcurrency

Chapter 17 Concurrency

Source

Concurrency 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+Executor specialised for OS threads. Its associated id type is ThreadId.
  • +Concurrent+Executor specialised for async tasks. Its associated id type is TaskId. 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 local value cannot cross a .spawn boundary. The compiler rejects the capture. This covers the "this handle shouldn't leave my region" case.
  • A unique value 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 linear value must be consumed; if it crosses into a spawned task, the caller has given it up entirely.
  • A global value 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; .lock returns a guard that disposes to unlock.
  • RwLock — multi-reader / single-writer; .read-lock and .write-lock return differently-typed guards.
  • Atomic — atomic integer / pointer cells for lock-free patterns.
  • Sender / Receiverbounded MPSC channels. channel takes a capacity and returns a (Sender t) + (Receiver t) pair. Sender is cloneable (multiple producers); Receiver is 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 .recv returns None once the buffer drains. .select takes 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. Returns Ok(val) if the quotation finishes in time or Err(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

  • +Executor is the shared surface for concurrency; +Thread and +Concurrent are 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.
  • .spawn returns a (Handle val) with local mode. .await consumes it; dropping it without .await cancels 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/ Sync traits needed.
  • Standard primitives: Mutex, RwLock, Atomic, channels via Sender/Receiver with .select.
  • Async-only: .cancel, .timeout on +Concurrent.

Next: variadic functions and reflection — the /N arity suffix, output variadics, and :type-info.