mitchellh / libxev

libxev is a cross-platform, high-performance event loop that provides abstractions for non-blocking IO, timers, events, and more and works on Linux (io_uring or epoll), macOS (kqueue), and Wasm + WASI. Available as both a Zig and C API.
MIT License
1.97k stars 65 forks source link

More advanced examples of using the API? #61

Closed penberg closed 1 year ago

penberg commented 1 year ago

I am using libxev to write a toy database library that's looks something like RocksDB or SQLite where you embed the thing into your application. However, the examples in the tree only show stuff like timers that arm or rearm them. I would love to know how you @mitchellh use libxev in a more complex application.

For example, when my toy library reads the database file, it first needs to parse the header before anything else can happen. What would be the recommended way to do this? Submit the read and return a completion to it? How do you structure the application to wait for the completion?

I tried to make an API like read_header(callback) that takes a callback that libxev invokes on I/O completion, but that's proving to be bit awkward with the file watcher API. (It's also possible that I just have no idea what I am doing.) Another option I explored was to return the completion from read_header(callback), but now it's unclear to me how I would even wait for that without busy-polling.

mitchellh commented 1 year ago

I will describe my use case. In describing my use case please note I'm not dismissing your issue and that there might be genuine usability problems to address.

My primary (and only) use case for libxev personally is my terminal project. libxev was extracted from that. In that project, I do not use any networking primitives, only file IO, timers, and async wakeups. My terminal is cross-platform Mac and Linux so on Mac I use a threadpool and on Linux I use io_uring typically without a threadpool (for file IO). From the perspective of my terminal, there is no difference -- that all happens in libxev under the covers.

For timers and async wakeups, there is a predictable amount ever active at any given time so I just use completions that are members of a struct and therefore have stable pointers.

For file IO, I maintain a pool of completions and buffers using a small layer built on top of Zig's std.SegmentedList. This allows me to statically preallocate N values, but heap-allocate and grow under high pressure, all while keeping my values in the list pointer-stable.

For dependent operations, I do maintain access to various completions from the callback. Remember that you always have access to the current completion, but using the userdata you can then access structs or other completions depending how you lay it out. For example, if I get a "quit" message via an async handle, the userdata is the thread state structure, and I can issue cancellation completions for the other active completions. This is not hypothetical -- this is how I actually handle early termination in the terminal (i.e. someone clicks "close window").

For writes, chaining was so common I extracted that into the queueWrite function in libxev. That started originally in Ghostty and is a good example of how I was maintaining state across multiple completions in an order-dependent manner. You could generalize this to not just order-dependence but simply... dependence.

For example, when my toy library reads the database file, it first needs to parse the header before anything else can happen. What would be the recommended way to do this? Submit the read and return a completion to it? How do you structure the application to wait for the completion?

Not knowing the details, this sounds like you could reuse one completion:

  1. Submit the completion for reading the header.
  2. Parse the header in the callback.
  3. Modify the completion argument in the callback (all callbacks get the triggering completion) and resubmit to the loop (all callbacks also get the loop). It is safe to modify the completion in a callback so long as you .disarm.
  4. Repeat.

If you find you need more completions, then you need to consider whether its a predictable amount and can therefore be statically allocated somewhere (i.e. on the struct as a member) or whether you want to heap allocate it. That's a domain-specific decision, functionally libxev of course doesn't care.

As a final note, note that libxev's API is more or less a very thin layer on top of io_uring (and non-Linux platforms are basically a compatibility layer to act like io_uring on top of other backends such as kqueue or epoll). Therefore, it is reasonable to ask: how would I do this with _raw io_uring_? That's likely the answer with libxev.

penberg commented 1 year ago

Thanks a lot, @mitchellh! I will try out the completion approach you suggested (I don't do any writes now) to see how it works out. And great point about thinking this from the perspective of io_uring!

mitchellh commented 1 year ago

Going to close this but feel free to ask any further questions!