Skip to content

Commit a66aa5b

Browse files
authored
async: add bounded pool, periodic, and group helpers (#27524)
1 parent 4e521db commit a66aa5b

13 files changed

Lines changed: 1354 additions & 48 deletions

File tree

vlib/x/async/README.md

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ context and function-type helpers:
2121
sibling jobs cooperatively.
2222
- `Task[T]`: run one value-returning job and wait for its result once.
2323
- `Pool`: run accepted jobs with a fixed concurrency limit and bounded backlog.
24-
- `every`: run a periodic job without overlapping iterations.
24+
- periodic jobs: run a blocking `every()` loop or a detached
25+
`PeriodicHandle` without overlapping iterations.
2526
- `with_timeout` / `with_timeout_context`: run one job with a bounded deadline.
2627

2728
Ticker objects and server-specific helpers are not part of this API.
29+
`PeriodicHandle` is only an explicit `stop()` / `wait()` lifecycle handle for
30+
one detached periodic loop.
2831

2932
## Why
3033

@@ -137,9 +140,9 @@ fn worker(mut ctx context.Context) ! {
137140
```
138141

139142
If a job ignores `ctx.done()`, `Group.wait()` will still wait for it to return.
140-
`Pool.close()` and `every()` also wait for running non-cooperative jobs to return.
141-
`with_timeout()` returns when the timeout expires, but the ignored job may keep
142-
running until it finishes naturally.
143+
`Pool.close()`, `every()`, and `PeriodicHandle.wait()` also wait for running
144+
non-cooperative jobs to return. `with_timeout()` returns when the timeout
145+
expires, but the ignored job may keep running until it finishes naturally.
143146

144147
## Group
145148

@@ -175,6 +178,20 @@ Guarantees:
175178
keeps the lifecycle simple: create a group, submit jobs, wait once, then discard
176179
the group.
177180

181+
Use `new_group_with_config()` only when callers need a bounded snapshot of job
182+
errors in addition to the first error returned by `wait()`:
183+
184+
```v ignore
185+
mut group := async.new_group_with_config(parent, collect_errors: true, max_errors: 8)!
186+
```
187+
188+
Error collection is opt-in. `new_group()` does not collect errors, and
189+
`errors()` returns an empty array for that default mode. When collection is
190+
enabled, `max_errors` must be positive; collection stores at most `max_errors`
191+
observed job errors and `errors()` returns a copy of that bounded snapshot.
192+
`wait()` still returns only the first observed error and never returns an
193+
aggregate. Concurrent error order is not guaranteed.
194+
178195
## Task
179196

180197
`Task[T]` represents one concurrent computation that returns either a value or
@@ -252,6 +269,35 @@ Backpressure is explicit. `try_submit()` never waits for backlog space:
252269
- if the pool is closed or already waiting, it returns `async: pool is closed`;
253270
- if the job function is nil, it returns `async: job function is nil`.
254271

272+
Use `submit_with_context()` when the caller should wait for backlog space, but
273+
only while a parent context remains active:
274+
275+
```v ignore
276+
parent_ctx, cancel := async.with_cancel()
277+
defer {
278+
cancel()
279+
}
280+
pool.submit_with_context(parent_ctx, fn (mut ctx context.Context) ! {
281+
process_message(mut ctx)!
282+
})!
283+
```
284+
285+
Use `submit_with_timeout()` when admission should wait only up to a fixed
286+
duration:
287+
288+
```v ignore
289+
pool.submit_with_timeout(250 * time.millisecond, fn (mut ctx context.Context) ! {
290+
process_message(mut ctx)!
291+
})!
292+
```
293+
294+
The context or timeout bounds admission only. Once the job is accepted, it
295+
receives the pool's context, the same as `try_submit()`. If the parent context is
296+
canceled before acceptance, `submit_with_context()` returns that parent context
297+
error. If the timeout expires before acceptance, `submit_with_timeout()` returns
298+
`async: timeout`. If `wait()` or `close()` starts while a caller is waiting for
299+
admission, the submit call returns `async: pool is closed`.
300+
255301
Lifecycle:
256302

257303
- `workers` and `queue_size` must be positive and are fixed at creation.
@@ -273,7 +319,7 @@ held.
273319
## Periodic Jobs
274320

275321
`every()` runs a job repeatedly until its context is canceled or the job returns
276-
an error.
322+
an error. It is blocking and does not start background work:
277323

278324
```v ignore
279325
ctx, cancel := async.with_cancel()
@@ -296,10 +342,57 @@ Guarantees:
296342
- a job error stops the loop and is returned unchanged.
297343
- context cancellation stops the loop and returns the context error.
298344

299-
`every()` is not a scheduler and does not expose a ticker handle. If a running
300-
job ignores cancellation, `every()` cannot return until that job returns
345+
Use `start_every()` when the periodic loop should run in the background with an
346+
explicit lifecycle handle:
347+
348+
```v ignore
349+
ctx, cancel := async.with_cancel()
350+
defer {
351+
cancel()
352+
}
353+
354+
mut handle := async.start_every(ctx, 5 * time.second, fn (mut ctx context.Context) ! {
355+
cleanup_stale_clients(mut ctx)!
356+
})!
357+
defer {
358+
handle.stop()
359+
handle.wait() or {}
360+
}
361+
```
362+
363+
`start_every()` has the same interval, nil-job, first-tick, no-overlap, and job
364+
error rules as `every()`, but returns immediately after starting one detached
365+
loop. If the parent context is already canceled, it returns the parent context
366+
error and does not start a worker.
367+
368+
`PeriodicHandle` lifecycle:
369+
370+
- `stop()` is idempotent and requests cooperative shutdown of the detached loop;
371+
- `stop()` is non-blocking and does not kill a running job;
372+
- `wait()` blocks until the detached loop exits;
373+
- `wait()` is one-shot; a second call returns
374+
`async: periodic wait was already called`;
375+
- a normal `stop()` makes `wait()` return successfully;
376+
- a job error is returned unchanged by `wait()`, even if its message matches
377+
`context canceled`;
378+
- parent cancellation is returned by `wait()` as the parent context error.
379+
380+
The detached loop publishes its final result through a bounded channel with
381+
capacity 1, so it can exit even if the owner has not called `wait()` yet.
382+
383+
A normal stop is recognized only when the loop itself observes the handle-owned
384+
context cancellation outside a user job error. If parent cancellation is already
385+
observable when `stop()` is called, parent cancellation wins and `wait()` returns
386+
the parent context error.
387+
388+
If the owner never calls `stop()` or the parent context is never canceled, the
389+
detached loop can keep running. If the owner calls `stop()` but never calls
390+
`wait()`, a running non-cooperative job may still continue until it returns
301391
naturally.
302392

393+
Periodic helpers are not schedulers and do not expose ticker objects. They run
394+
one serial loop; a slow iteration delays the next one.
395+
303396
## Timeout
304397

305398
`with_timeout()` runs one job with a background context and a timeout:
@@ -329,13 +422,20 @@ async.with_timeout_context(parent, 250 * time.millisecond, fn (mut ctx context.C
329422

330423
Error behavior:
331424

332-
- if the job returns first, the job error is returned unchanged;
425+
- if the job finishes before the effective timeout deadline, the job error is
426+
returned unchanged;
333427
- if the timeout expires first, the public error is `async: timeout`;
334428
- if the parent context is canceled first, including when the parent's own
335429
deadline expires first, the parent context error is returned;
336430
- a job that observes the local `x.async` timeout by returning
337431
`context deadline exceeded` is normalized to `async: timeout`.
338432

433+
Timeout ownership matters at the deadline boundary. If the job result is received
434+
first but the job finished at or after an `x.async`-owned timeout deadline,
435+
`with_timeout_context()` still returns `async: timeout`. That normalization is
436+
not applied to a shorter parent-owned deadline; parent cancellation keeps the
437+
parent context error path.
438+
339439
The result channel used internally is buffered so the spawned job can finish and
340440
publish its result even if the caller has already returned on timeout.
341441

@@ -352,8 +452,10 @@ publish its result even if the caller has already returned on timeout.
352452
- `Pool.close()` drains accepted jobs before returning and reports the first
353453
job error.
354454
- `every()` is blocking and serial, so periodic iterations cannot overlap.
455+
- `PeriodicHandle.stop()` is cooperative, and `PeriodicHandle.wait()` is
456+
one-shot so detached periodic lifecycle ownership is explicit.
355457
- It uses `sync.Mutex` to protect mutable lifecycle/result state in `Group`,
356-
`Task[T]`, and `Pool`.
458+
`Task[T]`, `Pool`, and `PeriodicHandle`.
357459
- It keeps `sync.WaitGroup.add()` and `sync.WaitGroup.wait()` separated by a
358460
lifecycle mutex for `Group` and `Pool` where accepted work can race with
359461
shutdown.
@@ -365,8 +467,8 @@ validation and resource limits for that domain.
365467

366468
This milestone does not include:
367469

368-
- blocking pool submission;
369-
- ticker objects or detached periodic handles;
470+
- unbounded blocking pool submission without a context or timeout;
471+
- ticker objects;
370472
- multi-consumer futures or promise chaining;
371473
- panic recovery;
372474
- scheduler changes;
@@ -379,17 +481,11 @@ replace them with a new runtime.
379481
Possible future additions, if accepted by the project maintainers and backed by
380482
tests, could still fit the current philosophy:
381483

382-
- blocking or timeout-based pool submission, built on existing channels;
383-
- detached periodic handles with explicit `stop()` / `wait()` lifecycle;
384-
- helpers that combine `Group`, `Pool`, `Task[T]`, and timeout for server-style
385-
code;
386484
- more examples and integration tests for other V modules that already use
387485
concurrency;
388486
- careful rewrites of existing V modules that need concurrency, if maintainers
389487
decide `x.async` makes their lifecycle, cancellation, or backpressure simpler
390488
and safer without breaking compatibility;
391-
- optional error collection helpers, as long as first-error behavior stays
392-
simple and documented;
393489
- more benchmarks and stress tests that remain bounded and reproducible.
394490

395491
Other ideas would require a separate design because they move beyond this
@@ -413,6 +509,7 @@ Small runnable examples live in `vlib/x/async/examples/`:
413509
- `basic_group.v`: first error propagation and cooperative sibling cancellation.
414510
- `basic_task.v`: run one value-returning task and consume it with `wait()`.
415511
- `worker_pool.v`: fixed concurrency with explicit `try_submit()` backpressure.
512+
- `bounded_pool_submit.v`: context/timeout-bounded `Pool` admission.
416513
- `periodic.v`: a blocking `every()` loop stopped by context cancellation.
417514
- `timeout.v`: run one cooperative job with a bounded timeout.
418515
- `net_http/`: synthetic `net.http` request/response work through `Pool`.
@@ -446,13 +543,15 @@ V build artefact collisions; it is separate from the runtime guarantees of
446543
`x.async`.
447544

448545
The tests cover successful groups, first-error propagation, empty waits,
449-
rejected submissions after `wait()`, one-shot task waits, task values and
450-
errors, pool worker limits, pool queue backpressure, pool close/wait behavior,
451-
periodic execution, periodic cancellation, non-overlapping periodic iterations,
546+
rejected submissions after `wait()`, optional bounded group error collection,
547+
one-shot task waits, task values and errors, pool worker limits, pool queue
548+
backpressure, pool close/wait behavior, bounded pool submission with context
549+
cancellation and timeout, periodic execution, detached periodic handle stop/wait
550+
lifecycle, periodic cancellation, non-overlapping periodic iterations,
452551
cooperative cancellation, timeout errors, parent cancellation, nil job rejection,
453552
parent deadline preservation, invalid intervals, zero/already-expired timeouts,
454-
stress cases, and jobs that ignore cancellation. Internal tests also verify
455-
that the derived `AsyncContext` closes `done()` and propagates parent
553+
stress cases, and jobs that ignore cancellation. Internal tests also verify that
554+
the derived `AsyncContext` closes `done()` and propagates parent
456555
cancellation.
457556

458557
Module-oriented integration tests live in `vlib/x/async/tests/`. They are
@@ -470,11 +569,13 @@ sh vlib/x/async/benchmarks/run_async_benchmark.sh
470569

471570
The script uses the local `./v`, runs serially, and isolates `VTMP`, `VCACHE`,
472571
and the benchmark executable output. It measures short default runs for
473-
`Group`, `Task[T]`, `Pool`, `with_timeout()`, and `every()`.
572+
`Group`, `Task[T]`, `Pool`, bounded pool admission, `with_timeout()`, and
573+
`every()`.
474574

475575
The default sizes are intentionally modest. They can be changed with
476576
`XASYNC_BENCH_GROUP_ROUNDS`, `XASYNC_BENCH_GROUP_JOBS`,
477577
`XASYNC_BENCH_TASK_ROUNDS`, `XASYNC_BENCH_POOL_JOBS`,
478-
`XASYNC_BENCH_POOL_WORKERS`, `XASYNC_BENCH_TIMEOUT_ROUNDS`,
578+
`XASYNC_BENCH_POOL_WORKERS`, `XASYNC_BENCH_POOL_BOUNDED_ROUNDS`,
579+
`XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS`, `XASYNC_BENCH_TIMEOUT_ROUNDS`,
479580
`XASYNC_BENCH_EVERY_ITERATIONS`, and `XASYNC_BENCH_EVERY_INTERVAL_MS`.
480581
Benchmark output is local diagnostic data, not a portable performance claim.

vlib/x/async/benchmarks/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ claims.
77
## Available benchmark
88

99
- `async_benchmark.v`: measures short default runs for `Group`, `Task[T]`,
10-
`Pool`, `with_timeout()`, and `every()`.
10+
`Pool`, bounded pool admission, `with_timeout()`, and `every()`.
1111
- `run_async_benchmark.sh`: builds and runs the benchmark with the local `./v`,
1212
isolated `VTMP`/`VCACHE`, and a temporary output binary.
1313

@@ -32,9 +32,15 @@ The defaults are intentionally small. Tune them locally with:
3232
- `XASYNC_BENCH_TASK_ROUNDS`
3333
- `XASYNC_BENCH_POOL_JOBS`
3434
- `XASYNC_BENCH_POOL_WORKERS`
35+
- `XASYNC_BENCH_POOL_BOUNDED_ROUNDS`
36+
- `XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS`
3537
- `XASYNC_BENCH_TIMEOUT_ROUNDS`
3638
- `XASYNC_BENCH_EVERY_ITERATIONS`
3739
- `XASYNC_BENCH_EVERY_INTERVAL_MS`
3840

41+
The bounded pool admission case exercises `submit_with_timeout()` while the
42+
pool is full and `submit_with_context()` after capacity opens. The timeout only
43+
bounds admission; the benchmark does not make performance assertions.
44+
3945
Do not commit machine-specific benchmark results as permanent truth. Tests
4046
remain the authority for functional and concurrency-safety validation.

0 commit comments

Comments
 (0)