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, walls, and green threads stop being rules to memorize and become the only way it could work. and because the two axes are separate, the one about order has a surprising default: 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.
import "std/list"
fn cheapest prices
(list/sort prices)[1]
pub play =
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"
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. this is the whole trick—the absence of a dependency is the license to overlap.
order without data moves through the wall
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 pure sequence, and it gets its own symbol, >>, the wall. above it, bare lines with no order between them; the wall runs only once everything above has settled.
print "steeping the sencha" # two bare lines: no order between them,
print "warming the cups" # each runs as if on its own thread
>> print "serving" # the wall: runs after both settle
steeping the sencha
warming the cups
serving
steeping the sencha
warming the cups
serving
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—past the wall. --plan isn't guessing; the group and the wall are structure in the value, and the flag just renders that structure instead of performing it. this is the chapter 05 idea seen sideways: a program is a description, and its concurrency is part of the description.
bare lines are green threads
the bare lines above aren't a notational convenience for "do these in some order i don't care about." they 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.
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
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. sequence within a thread, overlap between threads—each chosen with one symbol. the --plan makes the structure explicit:
brew: steeping
roll 1: a 3
roll 2: a 1
roll 3: a 4
roll 4: a 1
roll 5: a 1
brew: poured
both threads live inside one join: the brew chain, and the five throws chained by >> into a single sequence. nothing hangs below the block, because this program has no wall—order exists only inside each thread. walled.kso is the same program with the wall put back:
import "std/math"
import "std/time"
brew = print "brew: steeping" >> time/sleep 60 >> print "brew: poured"
pub play =
brew
>> roll 1 # the wall is now above roll 1 too --
>> roll 2 # brew alone is the group, so every roll
>> 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}")
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.
when an interleaving surprises you, run it with --plan and look for the join braces. what's inside runs together; what's below a wall waits. most "why did that print first?" questions are answered by where the brace closes, not by timing.
a scheduler you can predict
overlap would be a menace if it were nondeterministic. it isn't. 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:
one refinement rides under that guarantee. 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.
import "std/time"
gyoza =
print "gyoza: in the pan"
>> time/sleep 20 >> print "gyoza: flip"
>> time/sleep 20 >> print "gyoza: plated"
pub play =
gyoza
ramen
ramen =
print "ramen: boiling"
>> time/sleep 30 >> print "ramen: noodles in"
>> time/sleep 30 >> print "ramen: bowled"
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.
randomness rides the same rails. 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 walled.kso both show 3, 1, 4, 1, 1 here: same program, same pinned seed, same dice. replay on demand is a debugging superpower—a bug seen once under seed 41 reproduces forever under seed 41, so there is no "heisenbug that only happens under load."
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. three names for three jobs, and the actual work hides behind them. in kanso those three jobs collapse into two ideas you already have: the bare lines are the goroutines, and the wall is the WaitGroup. there is no channel, because the data flow is the channel—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 likewise just kanso's dispatch from chapter 03: one function arm per message, no separate statement for it. the concurrency falls out of keeping data and order apart.
what actually crosses a wall
the question that keeps coming back, answered once: nothing crosses a wall except permission. 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 a bug, not a statement. when every member settles, the group carries no value at all. the wall asks exactly one thing: did anything above me fail?
| what | crosses the wall? |
|---|---|
| a member's value | no—discarded at the member's own line, never collected |
| timing | yes—everything above has fully settled before anything below starts |
| failure | yes—and it is the only survivor: every member still runs to completion, then all errs merge into one, and that one err gates the wall |
that last row is worth seeing run. 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.
err "the steep went cold"
err "the cups cracked"
>> print "serving"
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 wall could gate. serving never ran; the merged err short-circuits from the wall onward. this is the one place an err pauses on its way out: at the join, to wait for its siblings. the . bind stays short-circuiting 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:
1 + 1
print "the sum above went nowhere"
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
one snag catches everyone once. 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 dot, and the value arrives once it exists:
import "std/math"
pub play =
roll 1
roll 2
>> print "both dice have landed"
fn roll i
math/random 6 . (n -> print "roll {i}: a {n + 1}")
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; the dot is the join that turns each effect into a value the print can read. want a member's value on the far side of the wall? then it was never a group member—it is a binding, and naming it replaces the wall with the dependency.
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 wall above you never opens on a failure.
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 above me failed. it walls off the whole group above it, not just the line before 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 wall 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 independence is unrepresentable to violate, and the runtime never has to prove it.
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 walls 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 exactly where it pauses and what it carries. concurrency stopped being a subsystem and became a consequence of keeping two ideas apart.
exercises
- write a program with three bare-line effects and one
>>wall after them. run it with--planand confirm all three land inside onejoinblock with the wall's step below the closing brace. - take
race.ksoand 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. - bind
a = random 6and print"rolled {a}". observe what prints, then fix it so a real number appears—without adding a wall. name the operator that does the work. - write a group of two members where both raise an
err, walled before aprint. 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 the wall.