pmap
Map over a list in parallel, on a bounded worker pool.
summaries = pmap(docs, fn(d) ai "Summarize in one line: {d}")
pages = pmap(urls, http.get, limit: 16) # a set number of workers
Same shape as map, same result order. The difference is that the calls overlap, which for I/O-bound work - model calls, HTTP, SQL - is the whole cost of the program.
Share-nothing by construction
Each call works on a snapshot of the argument and the variables it captures, taken from the state before the map started. Mutating an ordinary mut from inside a parallel closure changes only that call's copy - the caller never sees it, and no call sees another's:
mut count = 0
seen = pmap(items, fn(i) {
count = count + 1
count
})
print(seen) # [1, 1, 1, ...] - every call started from 0
print(count) # 0
What a call sees never depends on which calls ran before it on the same worker, so the result is the same however the work was scheduled.
This is not a bug to work around, it is the design. Parallel work cannot corrupt shared state because there is no shared state unless you ask for it. When workers genuinely need to share, use a cell - explicit, atomic, and the one place locking is intentional.
The practical effect is that data races are not a category of bug you debug in Ecko. The default is isolation, and sharing is a visible construct in the source.
The pool
Bounded by ECKO_LIMIT_PARALLEL, defaulting to the number of cores. A list of 10,000 items does not create 10,000 threads; it queues onto the pool.
The core count suits CPU-bound work. For work that waits - uploads, HTTP, model calls - say how many you want with limit:, or the same program runs at a different concurrency on every machine: four uploads at once on a laptop can be two on a 2-core CI runner.
pmap(parts, upload_part, limit: 4) # 4 at a time, on any machine
limit: is a whole number from 1 to 1024 and overrides ECKO_LIMIT_PARALLEL for that call. A list shorter than the limit runs one worker per item.
Each call carries a small fixed cost on top of the work itself, for the snapshot described above. When the callback does almost nothing (fn(x) x * 2 over a million numbers), plain map finishes as fast; pmap pays off as soon as each call does real work.
The same pool backs parallel ai calls and tool rounds, so one setting bounds the machine's total concurrency for CPU-adjacent work.
Errors
An error in one worker surfaces from the pmap call. Wrap the body when you want partial results instead of an abort:
results = pmap(urls, fn(u) try { fetch(u) } catch (e) { null })
ok = filter(results, fn(r) r != null)
pmap or async?
| use | when |
|---|---|
pmap | A list, one operation per item, all results needed. Fork-join. |
async / await | Independent operations of different kinds, or overlapping work with other work. |
| Channels | Producers and consumers, backpressure, streaming between stages. |
std.bg | Fire-and-forget, timers, intervals. |
pmap is the right default for the common case: you have a list and each item needs the same expensive thing done to it.