Skip to content

std/sync

Goroutine-style concurrency primitives: channels for passing values between green threads, plus a mutex, a wait group, timed sleeping, and a multi-channel receive. std/sync pairs with the spawn keyword, which starts a goroutine running a fun() -> Unit closure. Goroutines run on an M:N scheduler that spreads them across a pool of worker threads (one per available core), so they run in parallel. Within a worker, scheduling is cooperative: a goroutine keeps running until it hits a yield point (a blocking channel operation or an explicit yield_now()), at which the worker resumes another ready goroutine.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    let ch = channel()
    spawn(fun() -> Unit {
        ch.send(42)
    })
    print(ch.recv())        // 42
}

Channels in this slice carry Int values.

Importing

import std/sync { channel, channel_buffered, yield_now }

channel, channel_buffered, and yield_now are free functions, so import the ones you use by name. send and recv are methods on Channel and need no separate import once you hold a channel value.

The spawn keyword is part of the language itself and needs no import. It takes a closure of type fun() -> Unit and starts it as a goroutine.

Creating channels

channel() -> Channel

Create an unbuffered (rendezvous) channel. A send blocks until a receiver is ready to take the value, and a recv blocks until a sender hands one over. The two sides meet: nothing is stored in between.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    let ch = channel()
    spawn(fun() -> Unit {
        ch.send(7)          // blocks until main receives
    })
    print(ch.recv())        // 7
}

channel_buffered(cap: Int) -> Channel

Create a buffered channel of capacity cap. A send returns immediately while there is room in the buffer, and only blocks once the buffer is full. A recv takes the oldest buffered value, and blocks only when the buffer is empty.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    let ch = channel_buffered(2)
    ch.send(1)              // room: returns at once
    ch.send(2)              // room: returns at once
    print(ch.recv())        // 1
    print(ch.recv())        // 2
}

Channel methods

send(self, value: Int)

Send value, blocking until the channel can accept it. On an unbuffered channel it blocks until a receiver is ready; on a buffered channel it blocks only when the buffer is full. When a send blocks, the goroutine yields to the scheduler so other goroutines can run.

recv(self) -> Int

Receive a value, blocking until one is available. On an empty channel the goroutine yields to the scheduler and resumes when a sender delivers a value.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    let ch = channel()
    spawn(fun() -> Unit {
        ch.send(10)
        ch.send(20)
    })
    print(ch.recv())        // 10
    print(ch.recv())        // 20
}

Yielding

yield_now()

Yield control to the scheduler so other ready goroutines can run, then resume later. This is the explicit cooperative yield point. You rarely need it when your goroutines communicate over channels, since send and recv already yield when they block, but it is useful for handing off in a tight loop that otherwise never reaches a blocking operation.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    spawn(fun() -> Unit {
        print(1)
        yield_now()
        print(3)
    })
    print(2)
}

Pairing channels with spawn

spawn starts a goroutine from a fun() -> Unit closure. The closure can capture channels from the surrounding scope and use them to communicate with main (which is itself goroutine 0) or with other goroutines.

import std/sync { channel, channel_buffered, yield_now }

fun main() {
    let ch = channel()

    // Producer goroutine: send three values, then a sentinel.
    spawn(fun() -> Unit {
        ch.send(100)
        ch.send(200)
        ch.send(300)
        ch.send(-1)
    })

    // Main consumes until the sentinel.
    loop {
        let v = ch.recv()
        if v == -1 {
            break
        }
        print(v)            // 100, 200, 300
    }
}

When main returns the program exits, and any goroutines still alive (ready or blocked) are abandoned without finishing. If every goroutine ends up blocked with none ready, the scheduler reports a deadlock and exits.

Worked example: a worker over a buffered channel

A buffered channel decouples the producer from the consumer so the producer can get ahead while the consumer catches up.

import std/sync { channel_buffered, yield_now }

fun main() {
    let jobs = channel_buffered(4)

    // Worker: read each job and print its square.
    spawn(fun() -> Unit {
        loop {
            let n = jobs.recv()
            if n == 0 {
                break
            }
            print(n * n)        // 1, 4, 9, 16
        }
    })

    // Feed work, then a 0 to signal done.
    let k = 1
    while k <= 4 {
        jobs.send(k)
        k = k + 1
    }
    jobs.send(0)

    // Give the worker a turn to drain the channel before main exits.
    yield_now()
}

Releasing a channel

free(self)

Release a channel's runtime resources when you are done with it. A channel is otherwise reclaimed when the program exits; free is for a long-running program that creates many channels over time.

Sleeping

sleep_millis(ms: Int)

Block the current goroutine for ms milliseconds, yielding so other goroutines run in the meantime.

import std/sync { sleep_millis }

fun main() {
    spawn(fun() -> Unit {
        sleep_millis(50)
        print(2)
    })
    print(1)
    sleep_millis(100)           // give the goroutine time to finish
}

Mutex

A mutex guards a critical section so only one goroutine holds it at a time, which matters now that goroutines run in parallel across worker threads.

mutex() -> Mutex, lock(self), unlock(self)

import std/sync { mutex }

fun main() {
    let m = mutex()
    m.lock()
    // ... exclusive section ...
    m.unlock()
}

Wait group

A wait group counts outstanding work and lets one goroutine block until it reaches zero, the usual way to wait for spawned goroutines to finish.

wait_group() -> WaitGroup

Create a wait group with a zero counter.

add(self, n: Int), done(self), wait(self), free(self)

add(n) raises the counter by n (call it before spawning the work), done() lowers it by one (call it as each unit finishes), wait() blocks until the counter reaches zero, and free() releases the wait group.

import std/sync { wait_group, sleep_millis }

fun main() {
    let wg = wait_group()
    wg.add(2)
    spawn(fun() -> Unit {
        sleep_millis(20)
        wg.done()
    })
    spawn(fun() -> Unit {
        sleep_millis(20)
        wg.done()
    })
    wg.wait()                   // both goroutines have finished
    wg.free()
}

Selecting over several channels

select_recv(channels: List<Channel>) -> SelectResult

Block until one of channels has a value to receive, then return a SelectResult with index (the position in the list of the channel that produced the value, or -1 when the list is empty) and value (that value). When several channels are ready, the lowest index wins.

import std/sync { channel_buffered, select_recv }

fun main() {
    let a = channel_buffered(1)
    let b = channel_buffered(1)
    b.send(22)
    let chosen = select_recv([a, b])
    print(chosen.index)         // 1
    print(chosen.value)         // 22
}

See also

  • The language reference for spawn, goroutines, the parallel scheduler, and deadlock behavior.