Lowent Manual←↑→

26 Tasks and channels — exchange between bound flows

What to know first

chapter 15, Effects · concurrent brings wait with it
chapter 25, Actors · state locked inside, messages handled one at a time
chapter 2, A first program · test blocks run under --test

Looking back

How were concurrent and wait told apart in chapter 15?

A. wait is waiting that ends eventually without anyone’s help (the kernel or a device wakes it), and concurrent is waiting that ends only when another flow of the same program makes progress. That is why writing concurrent counts as writing wait too. This chapter covers making those “other flows” and exchanging values between them.

The need for this chapter, and its context

Actors (chapter 25) were the shape that locks state inside. But splitting work over several flows and gathering results, or producers and consumers handing values over, are awkward to write with actors alone. Flow defects come in three common kinds: flows made and forgotten, flows waiting forever for a partner that does not exist, and flows waiting on each other in deadlock. Lowent ties flow lifetimes to blocks to remove the first by grammar, and rejects at translation the second and third when they can be seen from what is written. It also provides a tool that tests every ordering of several flows.

By the end of this chapter

You will learn to make flows with spawn <op> inside a task_group and receive results with await. You will see the rule that a flow cannot outlive the block that made it. You will exchange values with channel, chsend and chrecv and understand why channel operations are the concurrent effect. You will also see waiting without a partner and deadlocks with no sender rejected at translation, and schedule explore_interleavings tests that run every possible ordering.

The questions this chapter answers

  1. What does task_group cancel_on_error do?

26.1 Flows are bound to blocks#

examples/ch26/await.low

module awaiting .
rem run: drive
rem test

fn square input n u64 . output u64 .
  requires le n 1000 .
do
  return mul n n .
end

proc drive output u64 . effects state .
do
  var r u64 be 0 .
  task_group do
    var h1 u64 be spawn square 5 .
    var h2 u64 be spawn square 6 .
    let a u64 be await h1 .
    let b u64 be await h2 .
    set r (add a b) .
  end
  return r .
end

test join_any_order schedule explore_interleavings .
do
  var r u64 be 0 .
  task_group do
    var h1 u64 be spawn square 5 .
    var h2 u64 be spawn square 6 .
    let a u64 be await h1 .
    let b u64 be await h2 .
    set r (add a b) .
  end
  expect eq r 61 .
end

Output

$ lowentc --run drive await.low
drive() = 61
$ lowentc --test await.low
  [PASS] join_any_order  (schedule: 2 interleavings agree, exhaustive)
== tests: 1 run, 1 passed, 0 FAILED ==

Drawn on a time axis, the lifetimes look like this.

drive       ──┬── task_group do                                   end ──┬──▶ continues
              │                                                         │
flow h1       │   spawn ├─── square 5 runs ─┤ done                      │
flow h2       │       spawn ├─── square 6 runs ───────┤ done            │
              │                     await h1 ▲    await h2 ▲            │
              └─ where the block opened                where it closed ─┘
                  flows live only between these two lines --- none is left when end is passed

A flow cannot outlive the block that made it. So making a flow outside a binding place is rejected.

examples/ch26/scope.low

module scope .
rem expect: E-SPAWN-SCOPE

fn square input n u64 . output u64 .
  requires le n 1000 .
do
  return mul n n .
end

proc loose output u64 . effects state .
do
  var h u64 be spawn square 5 .
  return await h .
end

Output

$ lowentc --check scope.low
scope.low:12:0 E-SPAWN-SCOPE: spawning a task (`spawn <op>`) is only allowed inside a `task_group` (RFC-0009 D3-b SC1: no task may outlive its group). Wrap it in `task_group do … end`, or if you meant an actor instance write `spawn actor <T>`

A flow made and forgotten survives after the program ends, touches things already gone, or produces failures nobody reads. When a block holds the lifetime, those three vanish by grammar — there is no place to forget.

test join_any_order schedule explore_interleavings in the same file runs every ordering of the two flows and checks that the answers agree. “2 interleavings agree, exhaustive” in the result line says so.

Q. What does task_group cancel_on_error do?

A. When one bound flow ends with an error, siblings that have not started or are waiting are treated as ended with that error. But cancellation does not unwind. The effects of flows that already did work are not rolled back; it only stops future work. Which siblings were cancelled depends on the ordering, which the specification does not fix. The error itself shows up through await.

26.2 Channels — containers with an order#

A channel is a container for putting and taking values between flows. Values come out in the order put in, and the number it can hold is fixed.

examples/ch26/chan.low

module chan .
rem run: drive
rem test

actor sink do
  state do
    v u64 .
  end
  proc put input n u64 . output u64 . effects state .
    requires le n 1000 .
  do
    set v (wrap_add v n) .
    return v .
  end
  fn get output u64 .
  do
    return v .
  end
end

proc producer input ch u64 . input n u64 . output u64 . effects concurrent .
do
  return chsend ch n .
end

proc consumer input ch u64 . input s sink . output u64 . effects state concurrent .
do
  let x u64 be chrecv ch .
  return send s put (min x 1000) .
end

proc drive output u64 . effects state .
do
  var ch u64 be channel u64 .
  var s sink be spawn actor sink .
  task_group do
    spawn consumer ch s .
    spawn consumer ch s .
    spawn producer ch 10 .
    spawn producer ch 32 .
  end
  return send s get .
end

test every_interleaving schedule explore_interleavings .
do
  var ch u64 be channel u64 .
  var s sink be spawn actor sink .
  task_group do
    spawn consumer ch s .
    spawn consumer ch s .
    spawn producer ch 10 .
    spawn producer ch 32 .
  end
  expect eq (send s get) 42 .
end

Output

$ lowentc --run drive chan.low
drive() = 42
$ lowentc --test chan.low
  [PASS] every_interleaving  (schedule: 58 interleavings agree, exhaustive)
== tests: 1 run, 1 passed, 0 FAILED ==

“58 interleavings agree, exhaustive” in the test line means all 58 orderings in which the four flows stop and continue were run, and every one answered 42. Concurrency defects usually show only in rare orderings, so tests that run a few times do not catch them. For a small group, running every ordering is the sure way.

Channel operations are the concurrent effect, because their completion depends on another flow’s progress.

examples/ch26/pure_chan.low

module pure_chan .
rem expect: E-EFFECT

proc producer input ch u64 . output u64 . effects none .
do
  return chsend ch 1 .
end

Output

$ lowentc --check pure_chan.low
pure_chan.low:5:1 E-EFFECT: this op performs `concurrent`, `wait`, which its `effects` clause does not declare — add it to `effects …`, or stop calling what needs it

A producer written as effects none calls chsend and is rejected for not declaring concurrent and wait. With this effect in the head, the fact that the op cannot be used on a machine without an operating system — which has no executor to run partner flows — shows at translation time.

26.3 Deadlocks you can see from what is written#

A binding place that makes only one flow, where that flow waits for a partner, is rejected.

examples/ch26/alone.low

module alone .
rem expect: E-CONC-ALONE

proc consumer input ch u64 . output u64 . effects concurrent .
do
  return chrecv ch .
end

proc lonely output u64 . effects state concurrent .
do
  var ch u64 be channel u64 .
  task_group do
    spawn consumer ch .
  end
  return 0 .
end

Output

$ lowentc --check alone.low
alone.low:12:0 E-CONC-ALONE: this `task_group` spawns exactly ONE task, and that task needs a PEER to finish (`concurrent` — a `chrecv` on an empty channel, or a `chsend` on a full one). There is no peer: the parent is waiting on the group. This is not "might be slow" — NO interleaving completes, so it is a provable deadlock, and the compiler can see it here instead of letting it die at runtime. Spawn the other side in the same group, or use `wait` I/O, which the KERNEL wakes and which needs no peer (RFC-0022 D-A)

Waiting without a partner cannot end in any ordering. It is reported at translation rather than waiting for a hang.

If every bound flow only receives and none sends, it is rejected too.

examples/ch26/deadlock.low

module deadlock .
rem expect: E-CONC-DEADLOCK

proc consumer input ch u64 . output u64 . effects concurrent .
do
  return chrecv ch .
end

proc stuck output u64 . effects state .
do
  var ch u64 be channel u64 .
  task_group do
    spawn consumer ch .
    spawn consumer ch .
  end
  return 0 .
end

Output

$ lowentc --check deadlock.low
deadlock.low:12:0 E-CONC-DEADLOCK: this `task_group`'s tasks wait to RECEIVE (`chrecv`) but NOTHING sends. There is no `chsend` anywhere in this self-contained module (no `use` imports), and the channels are created locally — the enclosing op takes no parameters, so no caller can feed them either. Every task blocks on an empty channel while the parent waits on the group: NO interleaving completes, so it is a PROVABLE deadlock the compiler names here instead of letting it die at runtime (E-VM-DEADLOCK). Add a producer (`chsend`), or a task that sends, in the same group (RFC-0022 D-A — the multi-member sibling of E-CONC-ALONE)

A common misconception. With the deadlock check, deadlocks cannot happen in this language

The check catches only deadlocks visible from what is written. Two flows waiting crosswise on different channels, or deadlocks that happen only under some condition, are not caught by translation. For those, the processor reports E-VM-DEADLOCK at run time when every flow stops, and small cases are found by explore_interleavings tests running every ordering. That some deadlocks are not caught makes clear what this rule promises.

26.4 Flows and memory#

Tasks run side by side. So two rules apply when a task uses a root from chapter 18 or an allocator from chapter 20.

First, an op that carves from a root cannot be spawned as a task.

examples/ch26/mistake_taskregion.low

module mistake_taskregion .
rem expect: E-ALLOC-TASK

proc worker input k u64 . output u64 . effects alloc .
do
  var n u64 be 0 .
  region r arena do
    let g option mut slice u8 . . be alloc_bytes r capacity 1000 .
    if is_some g . do
      set n k .
    end
  end
  return n .
end

proc two_workers input al cap allocator . output u64 . effects alloc state .
do
  task_group do
    rem ✘ spawning, as a task, an op that carves from a root
    spawn worker 1 .
    spawn worker 2 .
  end
  return 0 .
end

Output

$ lowentc --check mistake_taskregion.low
20:0 E-ALLOC-TASK: this spawns a TASK whose op takes memory from a ROOT (its effects include `alloc` or `heap`). A root has one cursor, and regions rewind it in order — two tasks interleaving would rewind each other's bytes (measured on the VM's green threads: `region reset needs its own mark`; native tasks are real threads). Give the task an allocator over borrowed bytes instead (RFC-0112 D11)
21:0 E-ALLOC-TASK: this spawns a TASK whose op takes memory from a ROOT (its effects include `alloc` or `heap`). A root has one cursor, and regions rewind it in order — two tasks interleaving would rewind each other's bytes (measured on the VM's green threads: `region reset needs its own mark`; native tasks are real threads). Give the task an allocator over borrowed bytes instead (RFC-0112 D11)

Each root has one cursor, and regions are rewound in order. If two tasks open regions in turn, one side’s end rewinds bytes the other side still uses. A lock cannot fix it, because rewinding relies on order, not on locking. So spawning as a task an op whose effects include alloc or heap is refused with E-ALLOC-TASK.

Second, an allocator whose cursor does not move atomically cannot be handed to a task.

examples/ch26/mistake_taskshared.low

module mistake_taskshared .
rem expect: E-ALLOC-SHARED

use allocs .

proc user input a allocs.bump_bytes . output u64 . effects state .
do
  let g option mut slice u8 . . be send a reserve 4 .
  if is_some g . do
    return 1 .
  end
  return 0 .
end

proc share input buf mut slice u8 . output u64 . effects state .
do
  var b allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let z u64 be send b init buf .
  task_group do
    rem ✘ handing an allocator whose cursor is not moved atomically to two tasks
    spawn user b .
    spawn user b .
  end
  return 0 .
end

Output

$ lowentc --check mistake_taskshared.low
21:0 E-ALLOC-SHARED: the same ALLOCATOR is in play in two places at once here, and its `reserve` does not move its cursor atomically (no `atomic` effect): it is handed to more than one task, or handed to a task and still used beside it. Two of them reserving race on one cursor. Give each task its OWN allocator over its own bytes — two `spawn actor` instances, each `init`ed on a disjoint slice — or use one whose `reserve` is atomic (RFC-0112 D11)

Two tasks reserving from the same b race over one cursor. If the allocator’s reserve does not declare atomic, it is E-ALLOC-SHARED. What is refused is the sharing — handing one allocator to two tasks, or handing it to a task and still using it beside them. Spawning a separate allocator for each task works. Another way is to hand over bytes and create the allocator inside the task, which is what follows.

examples/ch26/taskshared_fixed.low

module taskshared_fixed .
rem run: share [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

use allocs .

rem the task receives bytes, not an allocator; the allocator is created inside the task
proc user input piece mut slice u8 . . output u64 . effects state .
do
  var a allocs.bump_bytes be spawn actor allocs.bump_bytes .
  let room u64 be send a init piece .
  let g option mut slice u8 . . be send a reserve 4 .
  if is_some g . do
    return send a used .
  end
  return 0 .
end

proc share input buf mut slice u8 . output u64 . effects state .
do
  rem split the buffer into two pieces that do not overlap and give one to each task
  var r u64 be 0 .
  task_group do
    var h1 u64 be spawn user (subslice buf 0 8) .
    var h2 u64 be spawn user (subslice buf 8 16) .
    let a u64 be await h1 .
    let b u64 be await h2 .
    set r (add a b) .
  end
  return r .
end

Output

$ lowentc --run share taskshared_fixed.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
share([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 8
  arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

share splits the buffer into two pieces that do not overlap and gives one to each task. Each task spawns an allocator on its own piece and uses 4 bytes; adding the two answers gives 8. Because the pieces do not overlap, there is no cursor to race over and no bytes to be rewound.

26.5 What does not exist yet#

Channels without an end (channel … unbounded) and lock state shared by several flows (lock, rwlock) are not accepted yet (E-CHAN-UNBOUNDED, E-LOCK-NOTYET). One could accept what does not exist and build it later, but then programs written meanwhile would find that what seemed to work does not. It is more honest to say it does not exist. Handling the same memory atomically from several flows is covered in chapter 27.

examples/ch26/lock.low

module lock .
rem expect: E-LOCK-NOTYET

rem ✘ lock state shared between flows; the name is in the canon but it is not accepted yet
fn read_count input c lock u64 . output u64 . do
  return 0 .
end

Output

$ lowentc --check lock.low
lock.low:5:0 E-LOCK-NOTYET: `lock t` / `rwlock t` / `shared_read t` are level-3 shared-state types that SPEC-008 §74 names but this compiler DOES NOT IMPLEMENT: no lock is taken, no release happens. Writing one tells every reader the state is lock-protected while NOTHING checks that — an unchecked safety claim is the lie PRINCIPLES.md §0 is about. ★ And today there is nothing for a lock to protect against: execution is cooperative on ONE OS thread and actor delivery is SEQUENTIAL, so such a lock could never even be exercised. This is refused for the same reason as `mailbox unbounded` and `bounded 0`. Use an `actor` (sequential delivery IS the mutual exclusion) or the `atomic_*` ops, whose memory ordering IS real (RFC-0018 §6.1). These types come back when real OS threads do
lock.low:5:0 W-NOT-YET: this type NAME is accepted but carries NO MEANING yet: lowering cannot read it, so every op in its signature falls to the interpreter (~80x). The answer stays right, so no oracle will ever see this. Declare it (`type str slice u8 .`) or write the underlying type

lock u64 is a type named in the canon, so the tool does not call it “a type that does not exist”. Instead E-LOCK-NOTYET says “not built yet”. Until then, when a count must be shared, use atomic operations (chapter 27) or an actor (chapter 25).

26.6 Common mistakes#

Counter-example. Two flows that both start by receiving from each other

examples/ch26/mistake_crossed.low

module mistake_crossed .
rem trap: crossed

proc relay input inbox u64 . input outbox u64 . output u64 . effects concurrent .
do
  let x u64 be chrecv inbox .
  return chsend outbox x .
end
proc crossed output u64 . effects state .
do
  var a u64 be channel u64 .
  var b u64 be channel u64 .
  task_group do
    rem ✘ both start by receiving --- no flow puts in the first value
    spawn relay a b .
    spawn relay b a .
  end
  return 0 .
end

Output

$ lowentc --run crossed mistake_crossed.low
== ir diagnostics (1) ==
0:0 E-VM-DEADLOCK: the scheduler is stuck — every remaining task is blocked on a channel (a `chrecv` on an empty channel or `chsend` on a full one) and none can wake another. This is a real deadlock, not a tool limit: no interleaving completes

relay a b receives from a and passes to b; relay b a does the opposite. Both start by receiving, so neither can put in the first value. With two flows and crossed channels, the source alone does not show a deadlock, so translation accepts it. At run time the processor sees every flow blocked and reports E-VM-DEADLOCK. To avoid crossed waits, decide for each flow “who sends first”, and where possible let values flow in one direction only.

Counter-example. Lending the same variable for writing to two flows

examples/ch26/mistake_sharedvar.low

module mistake_sharedvar .
rem expect: E-EXCL

proc bump input p mut_ref u64 . output void . effects state .
do
  set p (add (deref p) 1) .
end
proc race output u64 . effects state .
do
  var n u64 be 0 .
  task_group do
    rem ✘ the same variable is lent for writing to two flows
    spawn bump (mut_ref n) .
    spawn bump (mut_ref n) .
  end
  return n .
end

Output

$ lowentc --check mistake_sharedvar.low
14:0 E-EXCL: exclusivity violation: overlapping borrow/owner access (readers-XOR-writer)

Two flows incrementing the same n at once is a data race. The rule that there is only one write borrow (chapter 12) holds between flows too, so it is rejected with E-EXCL. The W-EFFECT-OVER on the first line is an unrelated defect of this edition (a write through mut_ref is not counted as state). Let each flow return its share, and have the grouping side combine them after await.

examples/ch26/sharedvar_fixed.low

module sharedvar_fixed .
rem run: count2

rem each flow returns its share, and the grouping side combines them after `await`
fn one output u64 .
do
  return 1 .
end

proc count2 output u64 . effects state .
do
  var n u64 be 0 .
  task_group do
    var h1 u64 be spawn one .
    var h2 u64 be spawn one .
    let a u64 be await h1 .
    let b u64 be await h2 .
    set n (add a b) .
  end
  return n .
end

Output

$ lowentc --run count2 sharedvar_fixed.low
count2() = 2

A common misconception. Concurrent code that gave the right answer once is correct

examples/ch26/order_dependent.low

module order_dependent .
rem run: drive
rem test-fail

actor first_seen do
  state do
    v u64 .
  end
  proc put input n u64 . output u64 . effects state .
    requires le n 1000 .
  do
    rem keeps only the first value received --- the answer depends on arrival order
    if eq v 0 . do set v n . end
    return v .
  end
  fn get output u64 .
  do
    return v .
  end
end
proc producer input ch u64 . input n u64 . output u64 . effects concurrent .
do
  return chsend ch n .
end
proc consumer input ch u64 . input s first_seen . output u64 . effects state concurrent .
do
  let x u64 be chrecv ch .
  let y u64 be chrecv ch .
  let a u64 be send s put (min x 1000) .
  return send s put (min y 1000) .
end
proc drive output u64 . effects state .
do
  var ch u64 be channel u64 .
  var s first_seen be spawn actor first_seen .
  task_group do
    spawn consumer ch s .
    spawn producer ch 10 .
    spawn producer ch 32 .
  end
  return send s get .
end
test first_is_ten schedule explore_interleavings .
do
  var ch u64 be channel u64 .
  var s first_seen be spawn actor first_seen .
  task_group do
    spawn consumer ch s .
    spawn producer ch 10 .
    spawn producer ch 32 .
  end
  expect eq (send s get) 10 .
end

Output

$ lowentc --run drive order_dependent.low
drive() = 32
$ lowentc --test order_dependent.low
  [FAIL] first_is_ten
         E-SCHED-NONDET: this test PASSES under some message-delivery orders and FAILS under others (diverged at interleaving 1 of 8 explored) — its result DEPENDS on the order the scheduler delivers messages. A deterministic actor program must give the SAME result under EVERY interleaving (RFC-0009 D6 — the actor analogue of a data race). Fix the handler so order does not matter, or serialize with `drain`
== tests: 1 run, 0 passed, 1 FAILED ==

first_seen keeps only the first value it receives. Running drive gives 32; another order would give 10. One run gives the answer for one order only. The schedule explore_interleavings test runs all eight orders and reports with E-SCHED-NONDET that the answer depends on the order. A deterministic program must give the same answer in every order. Write handlers whose result does not depend on order, like the sink of chan.low, which adds up every value it receives.

26.7 This chapter’s syntax at a glance#

ShapeMeaningWhy
task_group do … endbind flows to a block — all finish at its endforgotten flows vanish by grammar
var h1 u64 be spawn square 5 .start a flow and receive its handleoutside a group: E-SPAWN-SCOPE
await h1wait for that flow’s resultother flows progress meanwhile
task_group cancel_on_error do … endcancel siblings on the first errorcancellation does not roll back
var ch u64 be channel u64 .a bounded, ordered containerno unbounded channels yet — E-CHAN-UNBOUNDED
chsend ch n · chrecv chput · take — blocks when full or emptyconcurrent effect — completion depends on others
a lone wait · receives with no senderrejected at translation (E-CONC-ALONE · E-CONC-DEADLOCK)deadlocks visible from the source
test … schedule explore_interleavings . do … endrun every possible order and compare answerstests find bugs of rare orders
alloc or heap in a task op’s effectsE-ALLOC-TASKa root’s rewind relies on order — a lock cannot protect it
a non-atomic allocator as a spawn argumentE-ALLOC-SHAREDhand over byte pieces and create the allocator inside the task
lock t · rwlock t · shared_read tE-LOCK-NOTYETthe name is in the canon — what is not built is not accepted

Table 26.1 — Task and channel syntax — shape · meaning · why it looks this way

Recap

Inside a task_group, spawn <op> makes a flow and gives a handle, and await receives the result. A flow cannot outlive the block that made it, so spawn outside a binding place is rejected. channel, chsend and chrecv form an ordered container of fixed size, and channel operations are the concurrent effect. Waiting without a partner and deadlocks with no sender are rejected at translation, and schedule explore_interleavings tests run every ordering and compare answers.