|
Condy v1.1.0
C++ Asynchronous System Call Layer for Linux
|
Step-by-step introduction to Condy’s concepts and usage.
This section introduces how to define coroutine functions, as well as how to run and manage coroutines in Condy.
To define a coroutine, declare a function that returns condy::Coro<T>, where T is the return type (default is void). Coroutines can use co_await to await asynchronous operations or other coroutines.
Use condy::sync_wait() to run a coroutine and block until all spawned tasks are finished.
In the following example, only a single coroutine task is created:
You can explicitly create a condy::Runtime object to manage the event loop and run coroutines on it. Pass the runtime to condy::sync_wait(Runtime&, Coro) to run the coroutine on the specified runtime.
The following code is equivalent to condy::sync_wait(caller()).
condy::default_runtime_options() returns a global condy::RuntimeOptions object. You can also create your own condy::RuntimeOptions for custom configuration.
Besides condy::sync_wait(), you can also run the runtime directly. The difference is that running the runtime directly will not exit even if there are no tasks.
Use the condy::Runtime::allow_exit() function to allow the runtime to exit when there are no tasks.
You can use condy::co_spawn() to start a coroutine as a task. Different tasks within the same runtime will execute concurrently. The condy::co_spawn() function returns a condy::Task<T> object. The task object can be awaited inside a coroutine using co_await, or synchronously waited outside a coroutine using condy::Task<T>::wait(). You can also detach a task to let it run independently.
You can spawn tasks on different runtimes. Waiting for or detaching a task is thread-safe.
Inside a coroutine, you can call condy::co_spawn(func()) to run another task on the current runtime, without passing the runtime parameter.
io_uring provides a rich set of asynchronous operations, covering not only I/O but also various system calls. Condy builds on top of these interfaces, offering well-designed abstractions and wrappers, making Condy a true asynchronous system call layer.
In addition to these asynchronous operations, Condy also provides a condy::Channel type, similar to the channel in Go. As a fundamental component of Condy, Channel can be combined with other mechanisms to implement more complex asynchronous control logic.
Condy offers lightweight wrappers for most io_uring operations. Essentially, each condy::async_*() function corresponds to an io_uring_prep_*() function. In liburing, io_uring_prep_*() is used to prepare an asynchronous operation.
The condy::async_* functions return awaitable objects. You need to use co_await to submit the operation to the io_uring backend and asynchronously wait for its completion.
The following example creates 5 coroutine tasks, each calling condy::async_timeout() to wait for 2 seconds in a non-blocking way. condy::async_timeout() corresponds to io_uring_prep_timeout() in liburing and uses the same parameters.
Since condy::async_timeout() is an asynchronous operation, it can be executed concurrently in each task. As a result, the total time for all tasks to complete is still 2 seconds.
Condy is not just a simple wrapper around liburing functions. Through carefully designed mechanisms, it provides intuitive and expressive interfaces for many io_uring-specific features. These designs will be explained in detail in later sections.
Condy introduces the condy::Channel type, which is a thread-safe, bounded, buffered or unbuffered queue. condy::Channel is a building block for many advanced features in Condy.
condy::Channel supports both synchronous (condy::Channel::try_push()/condy::Channel::try_pop()) and asynchronous (condy::Channel::push()/condy::Channel::pop()) operations. For asynchronous operations, condy::Channel::push() and condy::Channel::pop() return awaitable objects, which you can submit and wait for using co_await. This is similar to the condy::async_*() functions.
You can also close a channel using the condy::Channel::push_close() function. After closing, any subsequent condy::Channel::try_push() or condy::Channel::push() operations are invalid.
The following example creates a producer task and a consumer task.
Output:
How to combine condy::Channel with other Condy features will be introduced in later sections.
This section introduces methods for composing and controlling asynchronous operations in Condy. These methods provide support for certain io_uring features, enabling richer semantics and finer-grained control over program flow.
Condy provides a set of combinator functions to compose multiple asynchronous operations, allowing you to express complex async logic in an intuitive way.
You can use condy::when_all() or condy::operators::operator&& to wait for a group of operations to all complete. All operations will start concurrently, and the coroutine resumes when all have finished.
condy::when_all() can accept multiple different Awaiters as input, or a container of Awaiters of the same type.
The following example reads user input and writes the data to both a file and standard output concurrently.
You can use condy::when_any() or condy::operators::operator|| to wait for any one of a group of operations to complete. All operations start concurrently, and once any operation completes, the others are cancelled.
The parameter types accepted by condy::when_any() are the same as those for condy::when_all().
The following example waits for user input, and exits if there is no input within 5 seconds.
The push() and pop() methods of condy::Channel are also asynchronous operations, so they can be passed to condy::when_any(). This allows condy::Channel to be used as a signal slot for cancellation.
This is an io_uring feature. io_uring supports linking a group of asynchronous operations so that they are executed sequentially in the backend until all operations are complete. This reduces the number of syscalls and improves performance.
You can use condy::link() or condy::operators::operator>> to compose a group of asynchronous operations. All operations will be executed in the linked order, returning when all are complete or an error occurs.
The parameter types accepted by condy::link() are the same as those for condy::when_all().
condy::hard_link() is a variant of condy::link(). Even if an intermediate operation fails, condy::hard_link() will continue to execute subsequent operations.
The following example copies data from a file input.txt to another file output.txt, with the read and write operations linked together.
In addition to condy::when_all() and condy::when_any(), users may sometimes need more information, such as the results of all completed operations in condy::when_any().
Condy provides the condy::parallel() function, which is a lower-level interface beneath condy::when_all() and condy::when_any(). Users can specify the Awaiter type to control the return result of the composed operation.
For example, you can set the Awaiter type to condy::ParallelAnyAwaiter. The return type of this Awaiter is std::pair<std::array<size_t, N>, std::tuple<...>>, where the first element is the completion order of all asynchronous operations, and the second is the results of all operations. This allows you to implement more complex control logic.
This is an io_uring feature. io_uring provides a series of flags to control the behavior of individual asynchronous operations, such as IOSQE_IO_DRAIN and IOSQE_ASYNC. The former delays the execution of the operation until all previously submitted operations have completed; the latter forces the operation to always execute asynchronously.
Condy wraps these configurations as condy::drain() and condy::always_async() functions.
The following example uses condy::drain() to decorate a condy::async_fsync() operation, ensuring it is executed only after all write operations have completed.
In addition to the io_uring features related to asynchronous operations mentioned above, Condy also supports many other io_uring-specific features. In most asynchronous frameworks, these features are difficult to fully utilize due to cross-platform requirements. By building directly on io_uring, Condy provides a wealth of easy-to-use interfaces, allowing users to leverage kernel features and fully exploit hardware performance.
Multishot operations are a special type of io_uring operation. These operations only need to be submitted once but can produce multiple results. In liburing, multishot functions include io_uring_prep_multishot_accept(), io_uring_prep_read_multishot(), and others.
Condy supports multishot operations. Unlike regular operations, you need to pass an additional callback function to the multishot operation. For every result except the last, the callback is invoked for processing; only the last result resumes the coroutine.
Condy provides several helper functions to simplify writing callbacks, including:
The following example shows how to use condy::async_multishot_accept() to create a simple TCP server.
For asynchronous operations like condy::async_read_multishot(), spawning a coroutine for each result may not be ideal. Instead, you can use condy::will_push() to push results into a condy::Channel, and have another coroutine process them sequentially.
Zero Copy Tx is another special type of io_uring operation. When successful, such operations return twice: the first time indicates the operation is complete, and the second time indicates the corresponding buffer is no longer needed.
When supported by hardware, using such operations allows the NIC to send data directly from user memory, avoiding user-to-kernel data copies. In liburing, zero copy Tx include functions like io_uring_prep_send_zc().
Similar to multishot operations, these asynchronous operations in Condy also require a callback function. Condy manages the callback's lifetime and invokes it when the buffer is no longer needed.
The following example shows how to use condy::async_send_zc() and a condy::Channel to ensure the buffer is not released before the callback. You can also provide a custom callback, such as using delete or free() to release memory.
io_uring allows you to register files with the kernel. Normally, each asynchronous operation increments/decrements the file's reference count, but registering files with the kernel can skip this process and improve performance.
Condy abstracts file registration as operations on the condy::FdTable type. Each condy::Runtime has an condy::FdTable object, accessible via condy::Runtime::fd_table().
For files registered with the kernel, you can use their index in the condy::FdTable instead of the file descriptor for asynchronous operations. Use condy::fixed(int) to convert an int to a FixedFd type, then pass it to async operation functions. io_uring will treat the argument as a registered file index.
Some async operations have Direct variants. These operations, which would normally return a file descriptor, instead register the file with the kernel and return its index. You need to specify the index or use CONDY_FILE_INDEX_ALLOC to let the operation choose a free slot.
The following example uses a fixed fd instead of a regular fd to open and write to a file.
Similar to files, each io_uring async operation needs to acquire a reference to memory pages. Pre-registering memory regions with the kernel can avoid this overhead and improve performance.
Condy abstracts buffer registration as operations on the condy::BufferTable type. Each condy::Runtime has a condy::BufferTable object, accessible via condy::Runtime::buffer_table().
For async operations that require a buffer, you can optionally pass the index of a registered region to optimize the operation. Note that the buffer used in the async operation does not need to be the entire registered region, only within it. Use condy::fixed(index, buf) to attach registration info to the buffer.
The following example demonstrates how to use buffer registration.
io_uring supports pre-providing a set of buffers for I/O operations. Condy provides the condy::ProvidedBufferQueue and condy::ProvidedBufferPool types to support this feature.
condy::ProvidedBufferQueue wraps the liburing interface. It is a bounded queue; you can add a buffer to the queue using condy::ProvidedBufferQueue::push(), which returns an incrementing id to identify the buffer.
You can pass a condy::ProvidedBufferQueue object as a substitute for a normal buffer in async operations. After the operation completes, it returns a condy::BufferInfo object indicating which buffers in the queue were consumed.
condy::ProvidedBufferPool provides more advanced functionality. It manages a set of buffers internally. After an async operation using this type completes, it returns a condy::ProvidedBuffer object. This is an RAII type; when the object is destroyed, its buffer is returned to the pool.
You can use condy::bundled() to decorate condy::ProvidedBufferQueue and condy::ProvidedBufferPool objects. In this case, the async operation may consume multiple buffers at once, and the return type will change accordingly.
See the API documentation for details on how to use condy::ProvidedBufferQueue and condy::ProvidedBufferPool.
The following example demonstrates using a condy::ProvidedBufferPool as a buffer pool for async operations to implement an echo server.
As mentioned earlier, the condy::Runtime type can accept a condy::RuntimeOptions object, which contains a series of configurable initialization parameters for condy::Runtime. These parameters can be set using chained calls as shown below:
condy::RuntimeOptions provides wrappers for io_uring setup options. For details, see the API documentation and liburing documentation.
After creating a condy::Runtime object, you may need to adjust some settings dynamically. Condy associates each condy::Runtime with a condy::RingSettings object, accessible via condy::Runtime::settings().
The condy::RingSettings object wraps various io_uring configuration options, providing features such as Personality, Restrictions, NAPI, Clock, and more. For details, see the API documentation and liburing documentation.
This section describes features that are not directly related to io_uring.
In previous examples, you may have noticed the use of condy::buffer() when passing buffer arguments to asynchronous operations. Since io_uring supports multiple advanced buffer-related features (such as buffer registration and provided buffers), Condy overloads the async_* functions so they can accept ordinary buffers, fixed buffers, or provided buffers directly. This approach keeps the API clean and consistent.
All asynchronous operations in Condy that require a buffer accept a single buffer parameter, regardless of the underlying buffer type or io_uring feature being used. For ordinary buffers (such as a pointer and size, or a std::string/std::vector), you can use condy::buffer() to convert them into a single basic buffer object (condy::MutableBuffer or condy::ConstBuffer) suitable for async operations.
This design allows you to write concise and flexible code, and makes it easy to switch between different buffer management strategies as needed.
Example:
For advanced scenarios (such as using fixed buffers or provided buffers), you can pass the corresponding buffer object directly to the same async function, thanks to function overloading.
The second template parameter of condy::Coro<T, Allocator> can be used to specify a custom allocator for the coroutine frame. The default is void, which uses the system default allocator.
Example:
Condy provides condy::co_switch to move a coroutine to a different runtime. After co_await condy::co_switch(other_runtime), the current coroutine will continue execution in the specified runtime. This is useful for load balancing across multiple runtimes: