cell - shared state

A thread-safe mutable box. The one place in Ecko where sharing mutable state is intentional.

hits = cell(0)

http.serve(8080, fn(req) {
    n = cell_update(hits, fn(v) v + 1)     # atomic read-modify-write
    http.text("request #{n}")
})

Why it exists

Parallel workers - pmap, http.serve handlers, async tasks - each see a snapshot of captured variables. Mutating an ordinary mut inside one changes that call's copy and nothing else. That makes races impossible by default, and it makes a shared counter impossible too.

A cell is the escape hatch, and it is deliberately a different thing with a different name, so that shared mutable state is visible in the source rather than implied by where a variable was declared.

The operations

calleffect
cell(initial)Create a cell
cell_get(c)Read the current value (a copy)
cell_set(c, v)Replace the value
cell_update(c, fn)Run fn(current), store the result if the cell has not changed meanwhile (else run again), return it

Use cell_update for anything read-modify-write

cell_update(hits, fn(v) v + 1)          # atomic
cell_set(hits, cell_get(hits) + 1)      # races - two workers can read the same v

The second form is two operations with a gap between them. cell_update commits its write only if the cell still holds the value it read, and otherwise runs the function again on the new value (compare-and-swap), which is the only way a counter is correct under concurrency.

Cells share on assignment

a = cell(0)
b = a  # b and a are the same cell
cell_set(b, 5)
cell_get(a)  # 5

Everything else in Ecko has value semantics - binding a list to a new name gives you an independent list. A cell shares, and that is exactly why it survives being captured into a spawned task.

The one trap

Keep the update function pure. Because a conflicting write makes cell_update run it again, under contention the function can run more than once for one update. A side effect inside it - a print, an HTTP request, an ai call - can happen twice:

cell_update(c, fn(v) {
    log_hit(v)                          # may run more than once
    v + 1
})
cell_update(c, fn(v) v + 1)             # pure: safe to re-run

The function runs without the lock held, so reading the same cell inside it does not deadlock - but the value you need is the argument already.

Cells and structure

A cell can hold any value, including a map or a list, so a small amount of shared application state fits in one:

state = cell({ users: [], connections: 0 })
cell_update(state, fn(s) insert(s, "connections", s.connections + 1))

For anything that must outlive the process, use std.sql or the filesystem. A cell is process memory.