Docs/Building applications

Concurrency

Overlap waiting work with coroutines and coordinate shared state.

On this page

Coroutines let functions make progress while other functions wait. Threads let work run on separate CPU threads. Choose between them based on whether the work mainly waits for I/O or needs CPU time.

Start work and await its result#

Valk
use valk.time

fn delayed_value(value: int) int {
    time.sleep_ms(10)
    return value
}

fn main() {
    let first = co delayed_value(10)
    let second = co delayed_value(20)
    let a = await first
    let b = await second
    println(a + b) // 30
}

co creates a coroutine call. await waits for completion and retrieves the result. Both calls are started before either result is awaited, so one can make progress while the other is suspended.

A coroutine does not automatically put a CPU-heavy calculation on another thread. Use the thread API for CPU parallelism.

Handle failure at await#

When a coroutine returns an error, handle it on the await:

Valk
fn parse_count(text: String) int !SyntaxError {
    return text.to(int) !>
}

fn main() {
    let pending = co parse_count("42")
    let count = await pending !? 0
    println(count)
}

Await work whose result or failure matters. A coroutine that is never awaited does not report its error to a caller. Coroutine error types cannot contain payload fields; handle those errors inside the coroutine or represent the outcome in a return value.

Run work on a thread#

Valk
use valk.thread

fn main() {
    let worker = thread.Thread[int].start(fn() int {
        return 6 * 7
    }) ! panic("Could not start a worker")
    println(worker.await()) // 42
}

Thread[int] declares the worker's result type. Starting a thread can fail, and worker.await() joins it and returns its result. The example uses no shared mutable input; when workers need common state, make that sharing explicit.

Protect shared state#

Use Lock[T] for an object that multiple threads need to modify. Access its value only inside a lock block:

Valk
class Progress {
    completed: int (0)
}

fn main() {
    let progress: shared Lock[Progress] = .new(Progress{}) !!
    lock progress as state {
        state.completed++
        println(state.completed)
    }
}

The block holds the lock until it exits. state is a temporary, mutable locked Progress view; it cannot be saved or returned for use after the block. Values reached through it carry the same restriction. Make an independent copy inside the block if data must leave it.

Creating a shared view publishes the object graph for cross-thread use. The compiler requires that graph to be unique and prevents ordinary aliases from continuing to use the published data. Prefer constructing the object directly where you publish it, as above.

Keep lock blocks short. A lock is not reentrant: trying to acquire the same lock again while holding it can deadlock. For compound updates, keep the related reads and writes in the same block.

Use an atomic operation for a number#

atomic(counter + 1) updates a numeric location atomically and returns its previous value. This can suit a counter, but does not make a larger sequence of reads and writes atomic. Use a lock when several fields must stay consistent together.