Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Concurrency

There are two ways to start a function in a new goroutine. Goblin(function, args...) starts it immediately and returns a handle; wait() on the handle joins the function and delivers its result. spawn(function, args...) is fire-and-forget: it returns nil immediately, and the function's return value and unhandled error are discarded. Use a Goblin handle when the caller cares about the outcome, and spawn when it does not.

func square(value) {
    return value * value
}

var worker = Goblin(square, 6)
print(worker.wait()) # 36

Goblin handles

Construction starts the function right away; there is no separate start step. Arguments after the function are forwarded to it.

OperationBehavior
Goblin(function, args...)Start the function in a new goroutine and return a handle
handle.wait()Block until the function finishes, then return its result
handle.wait(timeout = seconds)Same, but raise TimeoutError if the function is still running when the timeout expires
handle.done()Report whether the function has finished, without blocking

The outcome is computed once and cached, so wait() can be called any number of times — and from several places at once — with the same answer. An error raised inside the function is not printed anywhere; it is stored and re-raised by every wait() call, where the normal try/catch machinery applies:

func fail() {
    raise ValueError.wrap("bad input")
}

var failing = Goblin(fail)
try {
    failing.wait()
} catch err {
    print(err.is(ValueError)) # true
}

A handle that is never waited on is abandoned: when the program ends, running goblins are stopped mid-flight, and a stored error nobody asked for is discarded silently. If a function's failure matters, wait() for it; if the result never matters, spawn is the honest way to say so (an uncaught error in a spawned function is at least reported on stderr).

Sharing data with a goblin

Pass data into a goblin through its arguments, and out through its return value or a channel. Two goblins — or a goblin and the top-level program — reading and writing the same variable concurrently is a data race: no ordering is defined, and the compiled backend inherits Go's undefined behavior for races. Reading surrounding names that nothing writes concurrently (module imports, top-level functions, builtins) is safe.

The same rule covers the inside of a shared mutable value: lists and dictionaries are not synchronized, and Goblin never locks them for you. The consequence of a race is not limited to stale or torn data — two goblins writing to the same dictionary can terminate the whole process at once, and try/catch cannot intercept that crash. When several goblins must work on the same data, keep one owner that mutates it and let the others send requests or results over a channel, as in the examples below.

goblin build-exe --race compiles the executable with Go's race detector: when a race actually occurs at run time, the program reports both racing operations with stack traces and exits with a failure status. The interpreter has no equivalent switch — race instrumentation happens when Go code is compiled — so a program that only ever ran under goblin run may hide a race that the compiled backend hits. Build the racy suspect with --race and exercise it when in doubt.

Channels

Chan() and Chan(0) create an unbuffered channel. A send waits until some goroutine receives it, and a receive waits until a value is sent. Chan(size) creates a buffer that can hold up to size values before sends block.

OperationBehavior
channel.send(value)Blocks until a receiver is ready or buffer space exists
channel.recv()Blocks until a value is available
channel.close()Prevents future sends; buffered values can still be received
Chan(size)Requires a non-negative integer; omitting size means zero

Sending on a closed channel, closing a channel twice, or receiving after a channel is closed and drained raises ValueError. Channels are not iterable and there is no special end-of-stream value, so a receiver must know how many values to expect or use a separate completion signal.

Buffering a known number of results

Use a buffered channel when several workers can finish before the caller starts receiving. The buffer capacity here matches the number of results.

func square(value, result) {
    result.send(value * value)
}

var results = Chan(3)
for value in [2, 3, 4] {
    spawn(square, value, results)
}

var total = 0
for ignored in range(0, 3) {
    total = total + results.recv()
}
results.close()
print(total) # 29

An unbuffered results channel also works in this example because the caller begins receiving after it starts the workers. Buffering changes when sends block; it does not guarantee a result order. Do not rely on spawned work finishing in the order it was started.

Returning errors explicitly

wait() re-raises a goblin's error at the call site, which covers the common case of one job whose failure the caller handles. When workers stream results through a channel instead, an error raised inside a spawned function is not delivered to the caller. Catch it in the worker and send a result record when the caller needs to handle failures.

func load_number(text, result) {
    try {
        result.send({"value": Int(text), "error": nil})
    } catch err {
        result.send({"value": nil, "error": err})
    }
}

var result = Chan()
spawn(load_number, "not-a-number", result)
var outcome = result.recv()
if outcome["error"] {
    print(outcome["error"].message)
}
result.close()

Use a dictionary only as a small result record like this. For a repeated or larger protocol, define a custom type so the fields and methods are explicit.

Ownership and deadlocks

The code that knows no more values will be sent should close the channel. Do not close a channel while a spawned sender may still use it. A common deadlock is sending to an unbuffered channel in the same goroutine before starting a receiver:

var messages = Chan()
# messages.send("hello") would block here: no receiver can run yet.
spawn(func() { messages.send("hello") })
print(messages.recv())
messages.close()

Goblin has no select operation, no cancellation primitive, and no timeout on channel operations; wait(timeout = seconds) on a Goblin handle is the only time-bounded wait, and functions started with spawn cannot be joined at all. Design each concurrent operation so every blocking send has a receiver, every expected result is received, and the owner can decide when it is safe to close its channel.