chapter 06

flow and concurrency

every program answers two questions that most languages braid into one: where does data go, and what order do things happen in? kanso keeps them apart, one construct each. once you see the split, groups, binds and green threads follow from it. and because the two axes are separate, the one about order has a default that surprises people: things run at the same time unless you say otherwise.

data moves through names

a binding hands a value forward; the dot hands a value into the next call. if step two needs step one's result, that need is the ordering—no operator required, and the compiler is free to run everything else alongside it. here two bindings share nothing, so nothing sequences them; the function that reads both is the barrier.

dataflow.kso
import "std/list"

fn cheapest prices
  (list/sort prices)[1]

prices = [520 380 450 610 290]
low = cheapest prices   # these two share nothing:
total = list/sum prices  # the compiler may run them together
report low total         # reads both, so it waits for both

fn report low total
  print "cheapest: {low} yen / total: {total} yen"
kanso play dataflow.kso
cheapest: 290 yen / total: 2250 yen

you never wrote an ordering between low and total, because there isn't one to write. the dependency graph is the schedule: report depends on both, so both finish before it starts, and nothing forces the two of them into a line. two lines with no dependency between them are free to overlap.

order without data

sometimes two effects share nothing, but you still care when they happen: pour the tea only after it steeped and the cups warmed. that ordering carries no value. it is written as a bind whose callback ignores what it is handed, .> (_ -> next). the two bare lines are a group with no order between them, so the group gets a name, and the bind after that name runs its step only once everything in the group has settled.

join.kso
pub play = prepare .> (_ -> print "serving")  # runs after both settle

prepare =
  print "steeping the sencha"   # two bare lines: no order between them,
  print "warming the cups"      # each runs as if on its own thread
kanso run join.kso
steeping the sencha
warming the cups
serving
kanso run join.kso --plan
plan:
  join {
  print "steeping the sencha"    # from line 4
  print "warming the cups"    # from line 5
  } # unordered; both run
  print "serving"    # from line 1

the plan says it plainly. the two bare lines land inside a join block the runtime marks unordered; both run, and serving sits after the closing brace. --plan can say this without running anything because the group and the bind are structure in the value; the flag renders that structure instead of performing it. it is the idea from chapter 05 again: a program is a description, and its concurrency is part of the description.

bare lines are green threads

the bare lines above are independent green threads, and the runtime overlaps them. you feel the difference the moment one of them blocks. sleep pauses a thread; while it waits, the scheduler runs whatever else is ready. here is the repository's set-piece: a slow steep runs beside a handful of dice.

concurrency.kso
import "std/math"
import "std/time"

brew = print "brew: steeping"
  .> (_ -> time/sleep 60)
  .> (_ -> print "brew: poured")

pub play =
  brew
  rolls

fn roll i
  math/random 6 .> (n -> print "roll {i}: a {n + 1}")

rolls = roll 1
  .> (_ -> roll 2)
  .> (_ -> roll 3)
  .> (_ -> roll 4)
  .> (_ -> roll 5)
kanso run concurrency.kso
brew: steeping
roll 1: a 3
roll 2: a 1
roll 3: a 4
roll 4: a 1
roll 5: a 1
brew: poured

read the transcript against the structure. brew and rolls are the two bare lines—the green threads. brew prints steeping and hits its sleep, so it parks. the scheduler turns to rolls, whose five throws chain with .> (_ -> ...) inside one thread: they run in order, and every one of them lands during the steep, because the steep belongs to the other thread. only when the sleep ends does brew: poured print. lines in one thread run in order, and threads overlap each other. the --plan shows the structure:

kanso run concurrency.kso --plan
plan:
  join {
  print "brew: steeping"    # from line 4
  sleep 60
  print "brew: poured"    # from line 6
  random 6
  . <continuation>
  random 6
  . <continuation>
  random 6
  . <continuation>
  random 6
  . <continuation>
  random 6
  . <continuation>
  } # unordered; both run

both threads live inside one join: the brew chain, and the five throws chained into a single sequence. nothing hangs below the block, because nothing in this program waits for the group—order exists only inside each thread. ordered.kso is the same program with the rolls bound after the brew:

ordered.kso — every roll after the brew
import "std/math"
import "std/time"

brew = print "brew: steeping"
  .> (_ -> time/sleep 60)
  .> (_ -> print "brew: poured")

brew
  .> (_ -> roll 1)  # brew runs first now, and every roll
  .> (_ -> roll 2)  # is bound after it, so each one
  .> (_ -> roll 3)  # waits for the steep to finish
  .> (_ -> roll 4)
  .> (_ -> roll 5)

fn roll i
  math/random 6 .> (n -> print "roll {i}: a {n + 1}")
kanso play ordered.kso
brew: steeping
brew: poured
roll 1: a 3
roll 2: a 1
roll 3: a 4
roll 4: a 1
roll 5: a 1

now every roll waits for poured. same five rolls, same five values—only the barrier moved, and with it the overlap. the panels on this page still show 3, 1, 4, 1, 1 because they run with a pinned seed, which is the next thing worth pinning down.

mugi the tanuki - runs the interpreter; pays for everything in boba pearls
mugi

when an interleaving surprises you, run it with --plan and look for the join braces. what's inside runs together; what follows a bind waits for it. most "why did that print first?" questions are answered by where the brace closes, not by timing.

a scheduler you can predict

overlapping threads would be hard to reason about if their order changed from run to run, and in kanso it does not. the scheduler decides who runs next by the numbers you wrote—sleep durations and spawn order—never by the machine's clock. so the transcript is a function of the source, and every run is byte-for-byte identical. watch two dishes cook at once:

race.kso
import "std/time"

gyoza = print "gyoza: in the pan"
  .> (_ -> time/sleep 20)
  .> (_ -> print "gyoza: flip")
  .> (_ -> time/sleep 20)
  .> (_ -> print "gyoza: plated")

ramen = print "ramen: boiling"
  .> (_ -> time/sleep 30)
  .> (_ -> print "ramen: noodles in")
  .> (_ -> time/sleep 30)
  .> (_ -> print "ramen: bowled")

gyoza
ramen
kanso run race.kso
gyoza: in the pan
ramen: boiling
gyoza: flip
ramen: noodles in
gyoza: plated
ramen: bowled

both threads start, print their opening line, and park on a sleep. the gyoza wakes at 20, the ramen at 30, the gyoza again at 40, the ramen again at 60—so the lines interleave in exactly that order. run it a thousand times and it never wavers, because "who is due next" is settled by 20 < 30 < 40 < 60, not by which thread the operating system happened to favor this millisecond. the delays are real time—sleep 30 genuinely pauses that thread—but the order they impose is fixed by the numbers, so the output is reproducible to the byte.

the clock does matter in one place. while a fiber sleeps, real time another fiber spends computing counts against the pending wait—so a thread that grinds for 900 ms beside a 2000 ms steep finishes with the steep, and the whole group's wall-clock is the longest branch. the machine's clock shortens the physical wait, and only that: who runs next, what prints when, and every counter still come from the numbers you wrote.

randomness works the same way. math/random n is an effect drawn from a seeded generator. a bare run seeds from the clock, so your dice differ from these panels. set KANSO_SEED and the whole run replays byte-identically—scheduling, interleaving, and rolls. that is why concurrency.kso and ordered.kso both show 3, 1, 4, 1, 1 here: same program, same pinned seed, same dice. a bug seen once under seed 41 reproduces every time under seed 41, which makes a failure that only shows up under load something you can rerun and study.

the scheduler orders threads by the sleep durations and spawn order in the source, so a concurrent program can be printed, replayed and pinned in a test.

the same program in go

go solves the same problem with three separate pieces of machinery: a goroutine to run each task, a WaitGroup to know when they've all finished, and a channel to carry results back out. in kanso the same three jobs are covered by two ideas you already have: the bare lines are the goroutines, and a bind after the named group is the WaitGroup. there is no channel, because data already moves between threads the ordinary way: a value crosses at a binding or a dot, and the function that reads it is the join. go's select, which picks a branch by which message type showed up, is kanso's dispatch from chapter 03: one function arm per message, with no separate statement for it.

what crosses from a group to the next step

what passes from a group to the step after it? only permission to continue. the members of a group are bare lines. nobody binds their results, so their results are discarded where they stand—the compiler even refuses a bare line whose value could never be an effect, because a value nobody can read is almost certainly a mistake. when every member settles, the group carries no value worth reading, and the _ in .> (_ -> next) discards it. the bind asks one thing: did anything in the group fail?

whatcrosses to the next step?
a member's valueno—discarded at the member's own line, never collected
timingyes—the whole group has settled before the next step starts
failureyes—and it is the only survivor: every member still runs to completion, then all errs merge into one, and that one err stops the bind
print "steeping the sencha" print "warming the cups" value discarded here value discarded here .> crosses: settled or failed. nothing else. print "serving" if steeping errs AND warming errs: both still run, the two reasons merge into one err, serving never runs, and the merged err short-circuits to the top.

here is the last row running. a group joins failures the way a validation form joins them: it does not stop at the first bad field. both members run to completion, and if both fail, you get both reasons—merged into one err that carries the pair.

accumulate.kso
pub play = prepare .> (_ -> print "serving")

prepare =
  err "the steep went cold"
  err "the cups cracked"
kanso run accumulate.kso
error[endpoint]: unhandled err reached the entry: ["the steep went cold" "the cups cracked"]

neither err was rescued, so both rode up to the endpoint exactly as chapter 04 described—but they arrived together, as a list, because the join collected them before the bind could call its callback. serving never ran; the merged err short-circuits from the bind onward. this is the one place an err pauses on its way out: at the join, to wait for its siblings. the bind itself short-circuits as always—it is only the parallel group that accumulates.

and the bare-line rule the table mentioned is real. a group member exists for its effect; a line that computes a plain value and binds nothing has thrown that value away, and the compiler says so rather than letting it rot:

bare_check.kso
1 + 1
print "the sum above went nowhere"
kanso check bare_check.kso
error[unused]: this value is never used: a non-final line binds a name, or is an effect joining the group
  --> bare_check.kso:1:3
   1 | 1 + 1
         ^

if you meant to keep that value, name it (sum = 1 + 1) and the data dependency carries it forward. if you meant an effect, it already would have been one. there is no third thing a bare line could be, so there is no silently-discarded value.

values, not descriptions

a common first mistake: random n is a description—an effect—just like read_file in chapter 05. bind it with = and you have named the description, not a number; interpolate that name and you get <io>, not a roll. to get the value, pipe the effect into what needs it with .>, the bind dot, and the value arrives once it exists:

dice.kso
import "std/math"

pub play = rolls .> (_ -> print "both dice have landed")

rolls =
  roll 1
  roll 2

fn roll i
  math/random 6 .> (n -> print "roll {i}: a {n + 1}")
kanso run dice.kso
roll 1: a 3
roll 2: a 1
both dice have landed

inside roll, random 6 .> (n -> ...) pipes the effect into a lambda whose parameter n is the resolved value—a number in the range 0 to 5, which is why the die adds one. the two rolls are a group; .> is the join that turns each effect into a value the print can read. want a member's value after the group? then it was never a group member—it is a binding, and the dependency on its name does the ordering.

err - always arrives, never uninvited
err

a group is the one place i wait. everywhere else i leave the instant i'm raised. but siblings running beside me might fail too, so at the join i pause to gather them, and we ride up together as one reason with many parts. the step after the group never runs while we are on our way up.

if you know the vocabulary from haskell: .> is monadic bind, where what comes next can look at the value. .> (_ -> ...) is the same bind with the value discarded. a group is applicative: its shape is fixed before anything runs, and no member's value can steer another member, which is why the runtime may run the members at the same time without checking anything.

the whole model in five lines

  • data crosses only at bindings and .>—if downstream needs a value, name it or pipe it; the dependency is the order.
  • .> (_ -> ...) carries no data—it carries one bit: nothing before me failed. after a named group it waits for the whole group, not for one line of it.
  • a group discards every member's value—bare lines exist for their effects; all of them always run, as independent green threads.
  • only failure survives a group—errs merge into one and short-circuit from the next bind onward; the join is the one place an err pauses to collect its siblings.
  • parallel is the default because nothing can depend on anything—the group's syntax has no way to express a dependency, so a member cannot depend on another, and the runtime never has to check that it doesn't.

you can now read any kanso program as two questions at once and answer each on sight: follow the names and dots to see where data goes, follow the binds to see what waits. you can put two slow effects side by side and let them overlap without a single primitive named "thread." you can predict an interleaving from the source, replay it byte-for-byte, and pin it in a test. and when something fails mid-flight, you know where it pauses and what it carries.

exercises

  1. write a program with three bare-line effects in a named group and one .> (_ -> ...) after it. run it with --plan and confirm all three land inside one join block with the bound step below the closing brace.
  2. take race.kso and change the ramen's two sleeps from 30 to 15. predict the new interleaving from the numbers before you run it, then run it and check. explain why the order changed.
  3. bind a = random 6 and print "rolled {a}". observe what prints, then fix it so a real number appears. name the operator that does the work.
  4. write a group of two members where both raise an err, name it, and bind a print after it. run it and confirm the print never happens and both reasons reach the endpoint together. then change one member to succeed and describe what now crosses to the print.