Skip to content

Commit e53dc73

Browse files
lukewagnerlann
andauthored
Add section to Async.md discussing readiness/non-blocking I/O (#565)
* Add 'Readiness' section to Async.md Resolves #561 * Fix typo Co-authored-by: Lann <[email protected]> --------- Co-authored-by: Lann <[email protected]>
1 parent 3870cbe commit e53dc73

File tree

2 files changed

+74
-11
lines changed

2 files changed

+74
-11
lines changed

design/mvp/Async.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ summary of the motivation and animated sketch of the design in action.
1818
* [Context-Local Storage](#context-local-storage)
1919
* [Structured concurrency](#structured-concurrency)
2020
* [Streams and Futures](#streams-and-futures)
21+
* [Stream Readiness](#stream-readiness)
2122
* [Waiting](#waiting)
2223
* [Backpressure](#backpressure)
2324
* [Returning](#returning)
@@ -408,6 +409,68 @@ successfully read, conveys the completion of a second event.
408409
The [Stream State] and [Future State] sections describe the runtime state
409410
maintained for streams and futures by the Canonical ABI.
410411

412+
### Stream Readiness
413+
414+
When passed a non-zero-length buffer, the `stream.read` and `stream.write`
415+
built-ins are "completion-based" (in the style of, e.g., [Overlapped I/O] or
416+
[`io_uring`]) in that they complete only once one or more values have been
417+
copied to or from the memory buffer passed in at the start of the operation.
418+
In a Component Model context, completion-based I/O avoids intermediate copies
419+
and enables a greater degree of concurrency in a number of cases and thus
420+
language producer toolchains should attempt to pass non-zero-length buffers
421+
whenever possible.
422+
423+
Given completion-based `stream.{read,write}` built-ins, "readiness-based" APIs
424+
(in the style of, e.g., [`select`] or [`epoll`] used in combination with
425+
[`O_NONBLOCK`]) can be implemented by passing an intermediate non-zero-length
426+
memory buffer to `stream.{read,write}` and signalling "readiness" once the
427+
operation completes. However, this approach incurs extra copying overhead. To
428+
avoid this overhead in a best-effort manner, `stream.{read,write}` allow the
429+
buffer length to be zero in which case "completion" of the operation is allowed
430+
(but not required) to wait to complete until the other end is "ready". As the
431+
"but not required" caveat suggests, after a zero-length `stream.{read,write}`
432+
completes, there is *no* guarantee that a subsequent non-zero-length
433+
`stream.{read,write}` call will succeed without blocking. This lack of
434+
guarantee is due to practical externalities and because readiness may simply
435+
not be possible to implement given certain underlying host APIs.
436+
437+
As an example, to implement `select()` and non-blocking `write()` in
438+
[wasi-libc], the following implementation strategy could be used (a symmetric
439+
scheme is also possible for `read()`):
440+
* The libc-internal file descriptor table tracks whether there is currently a
441+
pending write and whether `select()` has indicated that this file descriptor
442+
is ready to write.
443+
* When `select()` is called to wait for a stream-backed file descriptor to be
444+
writable:
445+
* `select()` starts a zero-length write if there is not already a pending
446+
write in progress and then [waits](#waiting) on the stream (along with the
447+
other `select()` arguments).
448+
* If the pending write completes, `select()` updates the file descriptor and
449+
returns that the file descriptor is ready.
450+
* When `write()` is called for an `O_NONBLOCKING` file descriptor:
451+
* If there is already a pending `stream.write` for this file descriptor,
452+
`write()` immediately returns `EWOULDBLOCK`.
453+
* Otherwise:
454+
* `write()` calls `stream.write`, forwarding the caller's buffer.
455+
* If `stream.write` returns that it successfully copied some bytes without
456+
blocking, `write()` returns success.
457+
* Otherwise, to avoid blocking:
458+
* `write()` calls [`stream.cancel-write`] to regain ownership of the
459+
caller's buffer.
460+
* If `select()` has *not* indicated that this file descriptor is ready,
461+
`write()` starts a zero-length write and returns `EWOULDBLOCK`.
462+
* Otherwise, to avoid the potential infinite loop:
463+
* `write()` copies the contents of the caller's buffer into an
464+
internal buffer, starts a new `stream.write` to complete in the
465+
background using the internal buffer, and then returns success.
466+
* The above logic implicitly waits for this background `stream.write`
467+
to complete before the file descriptor is considered ready again.
468+
469+
The fallback path for when the zero-length write does not accurately signal
470+
readiness resembles the buffering normally performed by the kernel for a
471+
`write` syscall and reflects the fact that streams do not perform internal
472+
buffering between the readable and writable ends.
473+
411474
### Waiting
412475

413476
When a component asynchronously lowers an import, it is explicitly requesting
@@ -1134,6 +1197,12 @@ comes after:
11341197
[FS or GS Segment Base Address]: https://docs.kernel.org/arch/x86/x86_64/fsgs.html
11351198
[Cooperative]: https://en.wikipedia.org/wiki/Cooperative_multitasking
11361199
[Multithreading]: https://en.wikipedia.org/wiki/Multithreading_(computer_architecture)
1200+
[Overlapped I/O]: https://en.wikipedia.org/wiki/Overlapped_I/O
1201+
[`io_uring`]: https://en.wikipedia.org/wiki/Io_uring
1202+
[`epoll`]: https://en.wikipedia.org/wiki/Epoll
1203+
1204+
[`select`]: https://pubs.opengroup.org/onlinepubs/007908799/xsh/select.html
1205+
[`O_NONBLOCK`]: https://pubs.opengroup.org/onlinepubs/7908799/xsh/open.html
11371206

11381207
[AST Explainer]: Explainer.md
11391208
[Lift and Lower Definitions]: Explainer.md#canonical-definitions
@@ -1152,6 +1221,7 @@ comes after:
11521221
[`thread.spawn*`]: Explainer.md#-threadspawn_ref
11531222
[`{stream,future}.new`]: Explainer.md#-streamnew-and-futurenew
11541223
[`{stream,future}.{read,write}`]: Explainer.md#-streamread-and-streamwrite
1224+
[`stream.cancel-write`]: Explainer.md#-streamcancel-read-streamcancel-write-futurecancel-read-and-futurecancel-write
11551225
[ESM-integration]: Explainer.md#ESM-integration
11561226

11571227
[Canonical ABI Explainer]: CanonicalABI.md
@@ -1190,6 +1260,7 @@ comes after:
11901260
[shared-everything-threads]: https://github.com/webAssembly/shared-everything-threads
11911261
[memory64]: https://github.com/webAssembly/memory64
11921262
[wasm-gc]: https://github.com/WebAssembly/gc/blob/main/proposals/gc/MVP.md
1263+
[wasi-libc]: https://github.com/WebAssembly/wasi-libc
11931264

11941265
[WASI Preview 3]: https://github.com/WebAssembly/WASI/tree/main/wasip2#looking-forward-to-preview-3
11951266
[`wasi:http/handler.handle`]: https://github.com/WebAssembly/wasi-http/blob/main/wit-0.3.0-draft/handler.wit

design/mvp/CanonicalABI.md

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,17 +1411,8 @@ was first, the zero-length `write` always completes, leaving the zero-length
14111411
*must* (eventually) follow a completed zero-length `write` with a
14121412
non-zero-length `write` that is allowed to block. This will break the loop,
14131413
notifying the reader end and allowing it to rendezvous with a non-zero-length
1414-
`read` and make progress. Based on this rule, to implement a traditional
1415-
`O_NONBLOCK` `write()` or `sendmsg()` API, a writer can use a buffering scheme
1416-
in which, after `select()` (or a similar API) signals a file descriptor is
1417-
ready to write, the next `O_NONBLOCK` `write()`/`sendmsg()` on that file
1418-
descriptor copies to an internal buffer and suceeds, issuing an `async`
1419-
`stream.write` in the background and waiting for completion before signalling
1420-
readiness again. Note that buffering only occurs when streaming between two
1421-
components using non-blocking I/O; if either side is the host or a component
1422-
using blocking or completion-based I/O, no buffering is necessary. This
1423-
buffering is analogous to the buffering performed in kernel memory by a
1424-
`pipe()`.
1414+
`read` and make progress. See the [stream readiness] section in the async
1415+
explainer for more background on purpose of zero-length reads and writes.
14251416

14261417
The two ends of a stream are stored as separate elements in the component
14271418
instance's table and each end has a separate `CopyState` that reflects what
@@ -4486,6 +4477,7 @@ def canon_thread_available_parallelism():
44864477
[Readable or Writable End]: Async.md#streams-and-futures
44874478
[Context-Local Storage]: Async.md#context-local-storage
44884479
[Subtask State Machine]: Async.md#cancellation
4480+
[Stream Readiness]: Async.md#stream-readiness
44894481
[Lazy Lowering]: https://github.com/WebAssembly/component-model/issues/383
44904482

44914483
[Core WebAssembly Embedding]: https://webassembly.github.io/spec/core/appendix/embedding.html

0 commit comments

Comments
 (0)