The I/O Object Model¶
Every asynchronous I/O facility in coio — sockets, files, pipes — follows a single model: an I/O execution context (epoll_context, uring_context, or iocp_context) exposes a scheduler that can create backend-specific io objects, and user-facing wrapper classes own those io objects and forward operations to them. This page describes that model and the contracts that apply to all I/O wrappers: synchronous versus asynchronous members, cancellation, close() semantics, lifetime rules, outstanding-operation limits, and EOF conventions.
Headers: #include <coio/asyncio/epoll_context.h>, #include <coio/asyncio/uring_context.h>, #include <coio/asyncio/iocp_context.h> (backends); wrappers in <coio/asyncio/file.h>, <coio/asyncio/pipe.h>, <coio/net/socket.h>
Overview¶
The pieces fit together like this:
- An I/O execution context (e.g.
epoll_context) owns the OS demultiplexer and the completion queue. Itsget_scheduler()returns a lightweight, copyable scheduler that models thecoio::io_schedulerconcept. scheduler.make_io_object(native_handle)wraps a native handle (POSIX fd, WindowsHANDLE/SOCKET) in a backend-specific, move-only io object. The io object registers the handle with the backend as needed and takes ownership of the handle.- Wrapper class templates —
basic_socket<Protocol, IoScheduler>and friends,stream_file<IoScheduler>,random_access_file<IoScheduler>,pipe_reader<IoScheduler>/pipe_writer<IoScheduler>— are parameterized on the scheduler type and hold the io object as their single data member. All their data-path members forward to it.
epoll_context / uring_context / iocp_context
│ get_scheduler()
▼
scheduler ──── make_io_object(handle) ───► io object (owns the native handle)
▲
│ owned by
stream_file / basic_stream_socket / pipe_reader / ...
The io_scheduler concept (from <coio/core.h>) is what the wrapper templates require:
template<typename Scheduler>
concept io_scheduler =
execution::scheduler<Scheduler> and
std::derived_from<typename std::remove_cvref_t<Scheduler>::scheduler_concept,
detail::io_scheduler_tag>;
It is satisfied by epoll_context::scheduler, uring_context::scheduler and iocp_context::scheduler, but not by plain time_loop schedulers.
Native handle types¶
| Platform | Files / pipes (detail::file_native_handle_type) |
Sockets (detail::socket_native_handle_type) |
|---|---|---|
| Linux | int (invalid: -1) |
int (invalid: -1) |
| Windows | void* (i.e. HANDLE; invalid: INVALID_HANDLE_VALUE) |
UINT_PTR (i.e. SOCKET; invalid: SOCKET(-1)) |
On Linux, make_io_object(int fd) produces one io object type used for both files and sockets. On Windows, iocp_context::scheduler has two overloads: make_io_object(HANDLE) returns a file object and make_io_object(SOCKET) returns a socket object; both associate the handle with the completion port at construction (the handle must have been opened for overlapped I/O).
Synopsis¶
Every io object provides (modulo backend-specific subsets) the following surface; wrappers re-export the parts that make sense for their category:
// common to all backends
auto get_io_scheduler() const noexcept -> scheduler;
auto native_handle() const noexcept -> /* platform handle type */;
auto close() -> void; // release the handle (precondition: no outstanding operations)
auto cancel() -> void; // cancel outstanding async operations
// synchronous data path (blocking; throw std::system_error on failure)
auto read_some(std::span<std::byte>) -> std::size_t;
auto write_some(std::span<const std::byte>) -> std::size_t;
auto read_some_at(std::size_t offset, std::span<std::byte>) -> std::size_t;
auto write_some_at(std::size_t offset, std::span<const std::byte>) -> std::size_t;
auto receive(std::span<std::byte>) -> std::size_t;
auto send(std::span<const std::byte>) -> std::size_t;
auto receive_from(std::span<std::byte>) -> std::pair<endpoint, std::size_t>;
auto send_to(std::span<const std::byte>, const endpoint&) -> std::size_t;
auto seek(std::size_t offset, seek_whence) -> std::size_t;
auto resize(std::size_t new_size) -> void;
// asynchronous data path (each returns a sender)
auto async_read_some(std::span<std::byte>) noexcept;
auto async_write_some(std::span<const std::byte>) noexcept;
auto async_read_some_at(std::size_t, std::span<std::byte>) noexcept;
auto async_write_some_at(std::size_t, std::span<const std::byte>) noexcept;
auto async_receive(std::span<std::byte>) noexcept;
auto async_send(std::span<const std::byte>) noexcept;
auto async_receive_from(std::span<std::byte>) noexcept;
auto async_send_to(std::span<const std::byte>, const endpoint&) noexcept;
auto async_accept() noexcept;
auto async_connect(const endpoint&) noexcept;
All buffers are single contiguous std::spans. There is no scatter-gather buffer sequence type in coio.
API Reference¶
Synchronous members¶
Synchronous data-path members (read_some, receive, send_to, ...) block the calling thread until the operation completes, return their result directly, and throw std::system_error on failure. They never touch the context's completion queue, so they may be called without a running consumer thread.
Linux: sync ops on nonblocking descriptors
epoll_context forces O_NONBLOCK on every descriptor it adopts (see platform notes below). Synchronous operations still behave as blocking calls: on EAGAIN/EWOULDBLOCK they internally poll(2) the descriptor for readiness and retry, so the calling thread blocks until the operation can proceed.
Asynchronous members¶
Members named async_* are lazy sender factories: calling one performs no I/O; the returned sender initiates the operation when its operation state is start()ed (e.g. when co_awaited inside a task). Base completion signatures are:
set_value(payload)—std::size_tbytes transferred for reads/writes,(endpoint, std::size_t)forasync_receive_from, a native handle (wrapped into a socket bybasic_socket_acceptor) forasync_accept, nothing forasync_connect;set_error(std::error_code)— OS failure;set_stopped()— the operation was cancelled.
Completions are always delivered on the context's consumer thread (the thread inside run()/poll()), never inline on the initiating thread.
Cancellation¶
Two mechanisms cancel outstanding asynchronous operations; both complete the affected operations with set_stopped():
- Context stop token. Every
async_*sender an io object returns is wrapped instop_when(op, context_stop_token)at construction.context.request_stop()therefore cancels all in-flight I/O on that context. A stop request also propagates from the receiver's environment as usual (stop_whencomposes). cancel()on the io object / wrapper: cancels the operations currently outstanding on that one object (CancelIoEx(handle, nullptr)on IOCP,IORING_ASYNC_CANCEL_ALLfor the fd on io_uring, unhooking the registered in/out waiters on epoll).
close() does not cancel: it requires that no operation is outstanding (see below).
A stop request never overwrites a real result: if the target operation completes successfully (or with an ordinary error) before the cancellation wins, the receiver still gets set_value/set_error. Cancellation is a request, not a guarantee of set_stopped.
close()¶
close() releases the native handle back to the OS and resets the object to the not-open state. It throws std::system_error if the OS close fails. Precondition: no outstanding operations. Every operation started on the object must have completed (delivered set_value/set_error/set_stopped) before close() is called — if operations may still be in flight, cancel() first and wait for their completions. Closing with operations outstanding is undefined behavior. Per backend:
| Backend | What close() does |
|---|---|
epoll_context |
Removes the fd from the epoll set (EPOLL_CTL_DEL), returns the per-descriptor bookkeeping entry to an internal pool, then close(fd). |
uring_context |
close(fd). |
iocp_context |
CloseHandle (files/pipes) or closesocket (sockets). |
Destroying a wrapper object implicitly closes it, under the same precondition.
Descriptor ownership — dup is out of scope
coio assumes each io object is the sole owner of its native handle; descriptors duplicated with dup/dup2/fcntl(F_DUPFD)/DuplicateHandle are not accounted for. In particular, cancel() on uring_context is matched by fd number (IORING_ASYNC_CANCEL_ALL), so operations initiated through a duplicate of the descriptor, or a descriptor that later reuses the number, are outside the model. Do not share an io object's descriptor via duplication.
Lifetime¶
An I/O object must outlive all of its operations, and every operation must complete before the object is closed or destroyed. A sender obtained from an I/O object (async_read_some, async_receive, ...) must be connected and started before the object is closed or destroyed, and its completion (set_value/set_error/set_stopped) must happen-before the close()/destruction; violating either is undefined behavior. On epoll_context in particular, close() returns the object's per-descriptor bookkeeping entry to an internal pool, so a stale start may silently corrupt the state of an unrelated I/O object that has since reused the entry, rather than failing cleanly with EBADF.
Warning
"Completed before close" is a hard precondition, not a checked error. Keep the wrapper alive — and un-close()d — until every sender obtained from it has completed (structured concurrency — co_await, when_all, async_scope::join() — makes this automatic). To tear down early, cancel() (or context.request_stop()), then await the completions, then close.
Outstanding-operation limits¶
Per I/O object (Asio-style): at most one outstanding read-direction operation and one outstanding write-direction operation at a time; one of each may overlap. Acceptors allow at most one outstanding accept. Initiating a second operation in the same direction before the first completes is undefined behavior (epoll_context asserts on it in debug builds). See Sockets — concurrency rules for the full rules and examples; they apply equally to files and pipes.
EOF conventions¶
- A backend read that transfers 0 bytes into a non-empty buffer means end-of-stream. The stream wrappers (
basic_stream_socket,stream_file,random_access_file,pipe_reader) map this tocoio::error::eof: synchronous members throwstd::system_error{coio::error::eof};async_read_some/async_read_some_atcomplete withset_error(coio::error::eof). - On Windows,
ERROR_HANDLE_EOFandERROR_BROKEN_PIPEare mapped to the same EOF path (ERROR_BROKEN_PIPEis how a pipe reports its writer closing, matching POSIXread() == 0). - Datagram sockets have no EOF concept; a 0-byte receive is a valid empty datagram.
- Stream-oriented I/O (files, pipes, stream sockets): a read or write with an empty buffer is a no-op that completes immediately with 0, without touching the OS, and is never treated as EOF (asio parity).
- Datagram sockets: zero-length operations are real. An empty
send/send_totransmits an empty datagram; a zero-length receive waits for and consumes a datagram.
Platform notes¶
epoll rejects regular files
epoll_context::scheduler::make_io_object calls fstat on the descriptor and throws std::system_error (operation_not_permitted) for regular files and directories, closing the descriptor — epoll(7) cannot wait on them. Use uring_context for file I/O on Linux. Pipes, FIFOs, character devices and sockets are fine. The constructor also sets O_NONBLOCK on the adopted descriptor if not already set.
IOCP stream-file offset is reserved at initiation (Asio-style)
Windows overlapped files have no kernel file position, so the IOCP file object keeps its own offset for stream_file. async_read_some/async_write_some capture and advance that offset when the sender is created, not when it completes. Consequently a sender that is built but never started still consumes its offset range, and the sequential-stream illusion only holds if you respect the one-outstanding-op-per-direction limit and start senders in the order you create them.
Linux synchronous socket ops poll internally
Because descriptors adopted by epoll_context are switched to O_NONBLOCK, the synchronous socket members (receive, send, receive_from, send_to, accept, connect) emulate blocking behavior by retrying after an internal poll(2) when the call would block.
Example¶
#include <coio/core.h>
#include <coio/asyncio/io.h>
#include <coio/net/socket.h>
#include <coio/net/tcp.h>
#if COIO_OS_LINUX
#include <coio/asyncio/epoll_context.h>
using io_context = coio::epoll_context;
#elif COIO_OS_WINDOWS
#include <coio/asyncio/iocp_context.h>
using io_context = coio::iocp_context;
#endif
using tcp_socket = coio::tcp::socket<io_context::scheduler>;
auto client() -> io_context::task<> {
// the scheduler creates the io object; the wrapper owns it
io_context::scheduler sched = co_await coio::read_scheduler();
tcp_socket socket{sched};
co_await socket.async_connect({coio::ipv4_address::loopback(), 8086});
char buffer[1024];
const std::size_t n = co_await socket.async_read_some(coio::as_writable_bytes(buffer));
// `socket` stays alive until the operation completes: lifetime rule satisfied
co_await socket.async_write_some(coio::as_bytes(buffer, n));
} // ~tcp_socket closes the handle (all operations completed above — precondition satisfied)
auto main() -> int {
io_context context;
coio::async_scope scope;
scope.spawn_on(context.get_scheduler(), client());
context.run(); // consumer thread: completions run here
coio::this_thread::sync_wait(scope.join());
}
See also¶
- Files —
stream_file,random_access_file - Pipes —
pipe_reader,pipe_writer,make_pipe - I/O algorithms —
async_read,async_write,async_read_until, device concepts - Sockets — socket wrappers and the full concurrency rules
- Execution contexts, epoll, io_uring, IOCP
- Error handling, Thread safety