Ch 4 — Create Your Own Event Queue

Asynchronous Programming in Rust — Carl Fredrik Samson · pages 86–115 · 183 text blocks · 2 figures

Create Your Own Event Queue In this chapter, we’ll create a simple version of an event queue using epoll. We’ll take inspiration from mio (https://github.com/tokio-rs/mio), a low-level I/O library written in Rust that underpins much of the Rust async ecosystem. Taking inspiration from mio has the added benefit of making it easier to dive into their code base if you wish to explore how a real production-ready library works. By the end of this chapter, you should be able to understand the following:

• The difference between blocking and non-blocking I/O • How to use epoll to make your own event queue • The source code of cross-platform event queue libraries such as mio • Why we need an abstraction layer on top of epoll, kqueue, and IOCP if we want a program or library to work across different platforms

We’ve divided the chapter into the following sections:

• Design and introduction to epoll • The ffi module • The Poll module • The main program

Technical requirements This chapter focuses on epoll, which is specific to Linux. Unfortunately, epoll is not part of the Portable Operating System Interface (POSIX) standard, so this example will require you to run Linux and won’t work with macOS, BSD, or Windows operating systems. If you’re on a machine running Linux, you’re already set and can run the examples without any further steps.

66 Create Your Own Event Queue

     If you’re on Windows, my recommendation is to set up WSL (https://learn.microsoft.
     com/en-us/windows/wsl/install), if you haven’t already, and install Rust on the Linux
     operating system running on WSL.
     If you’re using Mac, you can create a virtual machine (VM) running Linux, for example, by using
     the QEMU-based UTM application (https://mac.getutm.app/) or any other solution for
     managing VMs on a Mac.
     A last option is to rent a Linux server (there are even some providers with a free layer), install Rust,
     and either use an editor such as Vim or Emacs in the console or develop on the remote machine
     using VS Code through SSH (https://code.visualstudio.com/docs/remote/ssh).
     I personally have good experience with Linode’s offering (https://www.linode.com/), but
     there are many, many other options out there.
     It’s theoretically possible to run the examples on the Rust playground, but since we need a delay server,
     we would have to use a remote delay server service that accepts plain HTTP requests (not HTTPS)
     and modify the code so that the modules are all in one file instead. It’s possible in a clinch but not
     really recommended.
        The delay server
        This example relies on calls made to a server that delays the response for a configurable duration.
        In the repository, there is a project named delayserver in the root folder.
        You can set up the server by simply entering the folder in a separate console window and
        writing cargo run. Just leave the server running in a separate, open terminal window as
        we’ll use it in our example.
        The delayserver program is cross-platform, so it works without any modification on all
        platforms that Rust supports. If you’re running WSL on Windows, I recommend running the
        delayserver program in WSL as well. Depending on your setup, you might get away with
        running the server in a Windows console and still be able to reach it when running the example
        in WSL. Just be aware that it might not work out of the box.
        The server will listen to port 8080 by default and the examples there assume this is the port
        used. You can change the listening port in the delayserver code before you start the server,
        but just remember to make the same corrections in the example code.
        The actual code for delayserver is less than 30 lines, so going through the code should
        only take a few minutes if you want to see what the server does.
     Design and introduction to epoll
     Okay, so this chapter will be centered around one main example you can find in the repository under
     ch04/a-epoll. We’ll start by taking a look at how we design our example.
                                                                        Design and introduction to epoll    67

As I mentioned at the start of this chapter, we’ll take our inspiration from mio. This has one big upside and one downside. The upside is that we get a gentle introduction to how mio is designed, making it much easier to dive into that code base if you want to learn more than what we cover in this example. The downside is that we introduce an overly thick abstraction layer over epoll, including some design decisions that are very specific to mio. I think the upsides outweigh the downsides for the simple reason that if you ever want to implement a production-quality event loop, you’ll probably want to look into the implementations that are already out there, and the same goes for if you want to dig deeper into the building blocks of asynchronous programming in Rust. In Rust, mio is one of the important libraries underpinning much of the async ecosystem, so gaining a little familiarity with it is an added bonus. It’s important to note that mio is a cross-platform library that creates an abstraction over epoll, kqueue, and IOCP (through Wepoll, as we described in Chapter 3). Not only that, mio supports iOS and Android, and in the future, it will likely support other platforms as well. So, leaving the door open to unify an API over so many different systems is bound to also come with some compromises if you compare it to what you can achieve if you only plan to support one platform.

mio mio describes itself as a “fast, low-level I/O library for Rust focusing on non-blocking APIs and event notification for building performance I/O apps with as little overhead as possible over the OS abstractions.” mio drives the event queue in Tokio, which is one of the most popular and widely used asynchronous runtimes in Rust. This means that mio is driving I/O for popular frameworks such as Actix Web (https://actix.rs/), Warp (https://github.com/seanmonstar/ warp), and Rocket (https://rocket.rs/). The version of mio we’ll use as design inspiration in this example is version 0.8.8. The API has changed in the past and may change in the future, but the parts of the API we cover here have been stable since 2019, so it’s a good bet that there will not be significant changes to it in the near future.

As is the case with all cross-platform abstractions, it’s often necessary to go the route of choosing the least common denominator. Some choices will limit flexibility and efficiency on one or more platforms in the pursuit of having a unified API that works with all of them. We’ll discuss some of those choices in this chapter. Before we go further, let’s create a blank project and give it a name. We’ll refer to it as a-epoll going forward, but you will of course need to replace that with the name you choose. Enter the folder and type the cargo init command.

68 Create Your Own Event Queue

     In this example, we’ll divide the project into a few modules, and we’ll split the code up into the
     following files:
       src
        |-- ffi.rs
        |-- main.rs
        |-- poll.rs
     Their descriptions are as follows:
        • ffi.rs: This module will contain the code related to the syscalls we need to communicate
          with the host operating system
        • main.rs: This is the example program itself
        • poll.rs: This module contains the main abstraction, which is a thin layer over epoll
     Next, create the four files, mentioned in the preceding list, in the src folder.
     In main.rs, we need to declare the modules as well:
     a-epoll/src/main.rs
       mod ffi;
       mod poll;
     Now that we have our project set up, we can start by going through how we’ll design the API we’ll
     use. The main abstraction is in poll.rs, so go ahead and open that file.
     Let’s start by stubbing out the structures and functions we need. It’s easier to discuss them when we
     have them in front of us:
     a-epoll/src/poll.rs
       use std::{io::{self, Result}, net::TcpStream, os::fd::AsRawFd};
       use crate::ffi;
       type Events = Vec<ffi::Event>;
       pub struct Poll {
         registry: Registry,
       }
       impl Poll {
         pub fn new() -> Result<Self> {
           todo!()
                                                                        Design and introduction to epoll    69
    }
    pub fn registry(&self) -> &Registry {
      &self.registry
    }
    pub fn poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()> {
      todo!()
    }
  }

pub struct Registry { raw_fd: i32, }

  impl Registry {
    pub fn register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()>
  {
      todo!()
    }
  }
  impl Drop for Registry {
    fn drop(&mut self) {
      todo!()
    }
  }

We’ve replaced all the implementations with todo!() for now. This macro will let us compile the program even though we’ve yet to implement the function body. If our execution ever reaches todo!(), it will panic. The first thing you’ll notice is that we’ll pull the ffi module in scope in addition to some types from the standard library. We’ll also use the std::io::Result type as our own Result type. It’s convenient since most errors will stem from one of our calls into the operating system, and an operating system error can be mapped to an io::Error type. There are two main abstractions over epoll. One is a structure called Poll and the other is called Registry. The name and functionality of these functions are the same as they are in mio. Naming abstractions such as these is surprisingly difficult, and both constructs could very well have had a different name, but let’s lean on the fact that someone else has spent time on this before us and decided to go with these in our example.

70 Create Your Own Event Queue

     Poll is a struct that represents the event queue itself. It has a few methods:
        • new: Creates a new event queue
        • registry: Returns a reference to the registry that we can use to register interest to be notified
          about new events
        • poll: Blocks the thread it’s called on until an event is ready or it times out, whichever occurs first
     Registry is the other half of the equation. While Poll represents the event queue, Registry is
     a handle that allows us to register interest in new events.
     Registry will only have one method: register. Again, we mimic the API mio uses (https://
     docs.rs/mio/0.8.8/mio/struct.Registry.html), and instead of accepting a predefined
     list of methods for registering different interests, we accept an interests argument, which will
     indicate what kind of events we want our event queue to keep track of.
     One more thing to note is that we won’t use a generic type for all sources. We’ll only implement this
     for TcpStream, even though there are many things we could potentially track with an event queue.
     This is especially true when we want to make this cross-platform since, depending on the platforms
     you want to support, there are many types of event sources we might want to track.
     mio solves this by having Registry::register accept an object implementing the Source
     trait that mio defines. As long as you implement this trait for the source, you can use the event queue
     to track events on it.
     In the following pseudo-code, you’ll get an idea of how we plan to use this API:
       let queue = Poll::new().unwrap();
       let id = 1;
       // register interest in events on a TcpStream
       queue.registry().register(&stream, id, ...).unwrap();
       let mut events = Vec::with_capacity(1);
       // This will block the curren thread
       queue.poll(&mut events, None).unwrap();
       //...data is ready on one of the tracked streams
     You might wonder why we need the Registry struct at all.
     To answer that question, we need to remember that mio abstracts over epoll, kqueue, and IOCP.
     It does this by making Registry wrap around a Selector object. The Selector object is
     conditionally compiled so that every platform has its own Selector implementation corresponding
     to the relevant syscalls to make IOCP, kqueue, and epoll do the same thing.
                                                                         Design and introduction to epoll   71

Registry implements one important method we won’t implement in our example, called try_clone. The reason we won’t implement this is that we don’t need it to understand how an event loop like this works and we want to keep the example simple and easy to understand. However, this method is important for understanding why the responsibility of registering events and the queue itself is divided.

Important note By moving the concern of registering interests to a separate struct like this, users can call Registry::try_clone to get an owned Registry instance. This instance can be passed to, or shared through Arc<Registry> with, other threads, allowing multiple threads to register interest to the same Poll instance even when Poll is blocking another thread while waiting for new events to happen in Poll::poll.

Poll::poll requires exclusive access since it takes a &mut self, so when we’re waiting for events in Poll::poll, there is no way to register interest from a different thread at the same time if we rely on using Poll to register interest, since that will be prevented by Rust’s type system. It also makes it effectively impossible to have multiple threads waiting for events by calling Poll::poll on the same instance in any meaningful way since it would require synchronization that essentially would make each call sequential anyway. The design lets users interact with the queue from potentially many threads by registering interest, while one thread makes the blocking call and handles the notifications from the operating system.

Note The fact that mio doesn’t enable you to have multiple threads that are blocked on the same call to Poll::poll isn’t a limitation due to epoll, kqueue, or IOCP. They all allow for the scenario that many threads will call Poll::poll on the same instance and get notifications on events in the queue. epoll even allows specific flags to dictate whether the operating system should wake up only one or all threads that wait for notification (specifically the EPOLLEXCLUSIVE flag).

The problem is partly about how the different platforms decide which threads to wake when there are many of them waiting for events on the same queue, and partly about the fact that there doesn’t seem to be a huge interest in that functionality. For example, epoll will, by default, wake all threads that block on Poll, while Windows, by default, will only wake up one thread. You can modify this behavior to some extent, and there have been ideas on implementing a try_clone method on Poll as well in the future. For now, the design is like we outlined, and we will stick to that in our example as well.

This brings us to another topic we should cover before we start implementing our example.

72 Create Your Own Event Queue

     Is all I/O blocking?
     Finally, a question that’s easy to answer. The answer is a big, resounding… maybe. The thing is that
     not all I/O operations will block in the sense that the operating system will park the calling thread and
     it will be more efficient to switch to another task. The reason for this is that the operating system is
     smart and will cache a lot of information in memory. If information is in the cache, a syscall requesting
     that information would simply return immediately with the data, so forcing a context switch or any
     rescheduling of the current task might be less efficient than just handling the data synchronously.
     The problem is that there is no way to know for sure whether I/O is blocking and it depends on what
     you’re doing.
     Let me give you two examples.
     DNS lookup
     When creating a TCP connection, one of the first things that happens is that you need to convert
     a typical address such as www.google.com to an IP address such as 216.58.207.228. The
     operating system maintains a mapping of local addresses and addresses it’s previously looked up in
     a cache and will be able to resolve them almost immediately. However, the first time you look up an
     unknown address, it might have to make a call to a DNS server, which takes a lot of time, and the OS
     will park the calling thread while waiting for the response if it’s not handled in a non-blocking manner.
     File I/O
     Files on the local filesystem are another area where the operating system performs quite a bit of
     caching. Smaller files that are frequently read are often cached in memory, so requesting that file
     might not block at all. If you have a web server that serves static files, there is most likely a rather
     limited set of small files you’ll be serving. The chances are that these are cached in memory. However,
     there is no way to know for sure – if an operating system is running low on memory, it might have
     to map memory pages to the hard drive, which makes what would normally be a very fast memory
     lookup excruciatingly slow. The same is true if there is a huge number of small files that are accessed
     randomly, or if you serve very large files since the operating system will only cache a limited amount of
     information. You’ll also encounter this kind of unpredictability if you have many unrelated processes
     running on the same operating system as it might not cache the information that’s important to you.
     A popular way of handling these cases is to forget about non-blocking I/O, and actually make a
     blocking call instead. You don’t want to do these calls in the same thread that runs a Poll instance
     (since every small delay will block all tasks), but you would probably relegate that task to a thread
     pool. In the thread pool, you have a limited number of threads that are tasked with making regular
     blocking calls for things such as DNS lookups or file I/O.
     An example of a runtime that does exactly this is libuv (http://docs.libuv.org/en/
     v1.x/threadpool.html#threadpool). libuv is the asynchronous I/O library that Node.
     js is built upon.
                                                                                                The ffi module     73

While its scope is larger than mio (which only cares about non-blocking I/O), libuv is to Node in JavaScript what mio is to Tokio in Rust.

Note The reason for doing file I/O in a thread pool is that there have historically been poor crossplatform APIs for non-blocking file I/O. While it’s true that many runtimes choose to relegate this task to a thread pool making blocking calls to the OS, it might not be true in the future as the OS APIs evolve over time.

Creating a thread pool to handle these cases is outside the scope of this example (even mio considers this outside its scope, just to be clear). We’ll focus on showing how epoll works and mention these topics in the text, even though we won’t actually implement a solution for them in this example. Now that we’ve covered a lot of basic information about epoll, mio, and the design of our example, it’s time to write some code and see for ourselves how this all works in practice.

The ffi module Let’s start with the modules that don’t depend on any others and work our way from there. The ffi module contains mappings to the syscalls and data structures we need to communicate with the operating system. We’ll also explain how epoll works in detail once we have presented the syscalls. It’s only a few lines of code, so I’ll place the first part here so it’s easier to keep track of where we are in the file since there’s quite a bit to explain. Open the ffi.rs file and write the following lines of code:

ch04/a-epoll/src/ffi.rs pub const EPOLL_CTL_ADD: i32 = 1; pub const EPOLLIN: i32 = 0x1; pub const EPOLLET: i32 = 1 << 31;

  #[link(name = "c")]
  extern "C" {
    pub fn epoll_create(size: i32) -> i32;
    pub fn close(fd: i32) -> i32;
    pub fn epoll_ctl(epfd: i32, op: i32, fd: i32, event: *mut Event) -> i32;
    pub fn epoll_wait(epfd: i32, events: *mut Event, maxevents: i32, timeout: i32) -> i32;
  }

The first thing you’ll notice is that we declare a few constants called EPOLL_CTL_ADD, EPOLLIN, and EPOLLET.

74 Create Your Own Event Queue

     I’ll get back to explaining what these constants are in a moment. Let’s first take a look at the syscalls
     we need to make. Fortunately, we’ve already covered syscalls in detail, so you already know the basics
     of ffi and why we link to C in the preceding code:
        • epoll_create is the syscall we make to create an epoll queue. You can find the documentation
          for it at https://man7.org/linux/man-pages/man2/epoll_create.2.html.
          This method accepts one argument called size, but size is there only for historical reasons.
          The argument will be ignored but must have a value larger than 0.
        • close is the syscall we need to close the file descriptor we get when we create our epoll
          instance, so we release our resources properly. You can read the documentation for the syscall
          at https://man7.org/linux/man-pages/man2/close.2.html.
        • epoll_ctl is the control interface we use to perform operations on our epoll instance. This
          is the call we use to register interest in events on a source. It supports three main operations:
          add, modify, or delete. The first argument, epfd, is the epoll file descriptor we want to perform
          operations on. The second argument, op, is the argument where we specify whether we want
          to perform an add, modify, or delete operation
        • In our case, we’re only interested in adding interest for events, so we’ll only pass in EPOLL_
          CTL_ADD, which is the value to indicate that we want to perform an add operation. epoll_
          event is a little more complicated, so we’ll discuss it in more detail. It does two important
          things for us: first, the events field indicates what kind of events we want to be notified of
          and it can also modify the behavior of how and when we get notified. Second, the data field
          passes on a piece of data to the kernel that it will return to us when an event occurs. The latter
          is important since we need this data to identify exactly what event occurred since that’s the
          only information we’ll receive in return that can identify what source we got the notification
          for. You can find the documentation for this syscall here: https://man7.org/linux/
          man-pages/man2/epoll_ctl.2.html.
        • epoll_wait is the call that will block the current thread and wait until one of two things
          happens: we receive a notification that an event has occurred or it times out. epfd is the epoll
          file descriptor identifying the queue we made with epoll_create. events is an array of
          the same Event structure we used in epoll_ctl. The difference is that the events field
          now gives us information about what event did occur, and importantly the data field contains
          the same data that we passed in when we registered interest
        • For example, the data field lets us identify which file descriptor has data that’s ready to be
          read. The maxevents arguments tell the kernel how many events we have reserved space
          for in our array. Lastly, the timeout argument tells the kernel how long we will wait for
          events before it will wake us up again so we don’t potentially block forever. You can read the
          documentation for epoll_wait at https://man7.org/linux/man-pages/man2/
          epoll_wait.2.html.
                                                                                         The ffi module    75

The last part of the code in this file is the Event struct:

ch04/a-epoll/src/ffi.rs
  #[derive(Debug)]
  #[repr(C, packed)]
  pub struct Event {
      pub(crate) events: u32,
      // Token to identify event
      pub(crate) epoll_data: usize,
  }
  impl Event {
      pub fn token(&self) -> usize {
          self.epoll_data
      }
  }

This structure is used to communicate to the operating system in epoll_ctl, and the operating system uses the same structure to communicate with us in epoll_wait. Events are defined as a u32, but it’s more than just a number. This field is what we call a bitmask. I’ll take the time to explain bitmasks in a later section since it’s common in most syscalls and not something everyone has encountered before. In simple terms, it’s a way to use the bit representation as a set of yes/no flags to indicate whether an option has been chosen or not. The different options are described in the link I provided for the epoll_ctl syscall. I won’t explain all of them in detail here, but just cover the ones we’ll use:

• EPOLLIN represents a bitflag indicating we’re interested in read operations on the file handle • EPOLLET represents a bitflag indicating that we’re interested in getting events notified with epoll set to an edge-triggered mode

We’ll get back to explaining bitflags, bitmasks, and what edge-triggered mode really means in a moment, but let’s just finish with the code first. The last field on the Event struct is epoll_data. This field is defined as a union in the documentation. A union is much like an enum, but in contrast to Rust’s enums, it doesn’t carry any information on what type it is, so it’s up to us to make sure we know what type of data it holds. We use this field to simply hold a usize so we can pass in an integer identifying each event when we register interest using epoll_ctl. It would be perfectly fine to pass in a pointer instead – just as long as we make sure that the pointer is still valid when it’s returned to us in epoll_wait. We can think of this field as a token, which is exactly what mio does, and to keep the API as similar as possible, we copy mio and provide a token method on the struct to get this value.

76 Create Your Own Event Queue

        What does #[repr(packed)] do?
        The #[repr(packed)] annotation is new to us. Usually, a struct will have padding either
        between fields or at the end of the struct. This happens even when we’ve specified #[repr(C)].
        The reason has to do with efficient access to the data stored in the struct by not having to make
        multiple fetches to get the data stored in a struct field. In the case of the Event struct, the
        usual padding would be adding 4 bytes of padding at the end of the events field. When the
        operating system expects a packed struct for Event, and we give it a padded one, it will write
        parts of event_data to the padding between the fields. When you try to read event_data
        later on, you’ll end up only reading the last part of event_data, which happened to overlap
        and get the wrong data
        The fact that the operating systemexpects a packed Event struct isn’t obvious by reading the
        manpages for Linux, so you have to read the appropriate C header files to know for sure. You
        could of course simply rely on the libc crate (https://github.com/rust-lang/
        libc), which we would do too if we weren’t here to learn things like this for ourselves.
     So, now that we’ve finished walking through the code, there are a few topics that we promised to get
     back to.
     Bitflags and bitmasks
     You’ll encounter this all the time when making syscalls (in fact, the concept of bitmasks is pretty
     common in low-level programming). A bitmask is a way to treat each bit as a switch, or a flag, to
     indicate that an option is either enabled or disabled.
Figure from page 97
figure · book page 97
                                                                                       The ffi module   77

An integer, such as i32, can be expressed as 32 bits. EPOLLIN has the hex value of 0x1 (which is simply 1 in decimal). Represented in bits, this would look like 000000000000000000000000 00000001. EPOLLET, on the other hand, has a value of 1 << 31. This simply means the bit representation of the decimal number 1, shifted 31 bits to the left. The decimal number 1 is incidentally the same as EPOLLIN, so by looking at that representation and shifting the bits 31 times to the left, we get a number with the bit representation of 10000000000000000000000000000000. The way we use bitflags is that we use the OR operator, |, and by OR’ing the values together, we get a bitmask with each flag we OR’ed set to 1. In our example, the bitmask would look like 10000000 000000000000000000000001. The receiver of the bitmask (in this case, the operating system) can then do an opposite operation, check which flags are set, and act accordingly. We can create a very simple example in code to show how this works in practice (you can simply run this in the Rust playground or create a new empty project for throwaway experiments such as this): fn main() { let bitflag_a: i32 = 1 << 31; let bitflag_b: i32 = 0x1; let bitmask: i32 = bitflag_a | bitflag_b; println!("{bitflag_a:032b}"); println!("{bitflag_b:032b}"); println!("{bitmask:032b}"); check(bitmask); }

  fn check(bitmask: i32) {
    const EPOLLIN: i32 = 0x1;
    const EPOLLET: i32 = 1 << 31;
    const EPOLLONESHOT: i32 = 0x40000000;
    let read = bitmask & EPOLLIN != 0;
    let et = bitmask & EPOLLET != 0;
    let oneshot = bitmask & EPOLLONESHOT != 0;
    println!("read_event? {read}, edge_triggered: {et}, oneshot?: {oneshot}")
  }

This code will output the following: 10000000000000000000000000000000 00000000000000000000000000000001

78 Create Your Own Event Queue

       10000000000000000000000000000001
       read_event? true, edge_triggered: true, oneshot?: false
     The next topic we will introduce in this chapter is the concept of edge-triggered events, which probably
     need some explanation.
     Level-triggered versus edge-triggered events
     In a perfect world, we wouldn’t need to discuss this, but when working with epoll, it’s almost impossible
     to avoid having to know about the difference. It’s not obvious by reading the documentation, especially
     not if you haven’t had previous experience with these terms before. The interesting part of this is that
     it allows us to create a parallel between how events are handled in epoll and how events are handled
     at the hardware level.
     epoll can notify events in a level-triggered or edge-triggered mode. If your main experience is
     programming in high-level languages, this must sound very obscure (it did to me when I first learned
     about it), but bear with me. In the events bitmask on the Event struct, we set the EPOLLET flag
     to get notified in edge-triggered mode (the default if you specify nothing is level-triggered).
     This way of modeling event notification and event handling has a lot of similarities to how computers
     handle interrupts.
     Level-triggered means that the answer to the question “Has an event happened” is true as long as the
     electrical signal on an interrupt line is reported as high. If we translate this to our example, a read
     event has occurred as long as there is data in the buffer associated with the file handle.
     When handling interrupts, you would clear the interrupt by servicing whatever hardware caused
     it, or you could mask the interrupt, which simply disables interrupts on that line until it’s explicitly
     unmasked later on.
     In our example, we clear the interrupt by draining all the data in the buffer by reading it. When the
     buffer is drained, the answer to our question changes to false.
     When using epoll in its default mode, which is level-triggered, we can encounter a case where we get
     multiple notifications on the same event since we haven’t had time to drain the buffer yet (remember,
     as long as there is data in the buffer, epoll will notify you over and over again). This is especially
     apparent when we have one thread that reports events and then delegates the task of handling the
     event (reading from the stream) to other worker threads since epoll will happily report that an event
     is ready even though we’re in the process of handling it.
     To remedy this, epoll has a flag named EPOLLONESHOT.
                                                                                              The ffi module    79

EPOLLONESHOT tells epoll that once we receive an event on this file descriptor, it should disable the file descriptor in the interest list. It won’t remove it, but we won’t get any more notifications on that file descriptor unless we explicitly reactivate it by calling epoll_ctl with the EPOLL_CTL_MOD argument and a new bitmask. If we didn’t add this flag, the following could happen: if thread 1 is the thread where we call epoll_ wait, then once it receives a notification about a read event, it starts a task in thread 2 to read from that file descriptor, and then calls epoll_wait again to get notifications on new events. In this case, the call to epoll_wait would return again and tell us that data is ready on the same file descriptor since we haven’t had the time to drain the buffer on that file descriptor yet. We know that the task is taken care of by thread 2, but we still get a notification. Without additional synchronization and logic, we could end up giving the task of reading from the same file descriptor to thread 3, which could cause problems that are quite hard to debug. Using EPOLLONESHOT solves this problem since thread 2 will have to reactivate the file descriptor in the event queue once it’s done handling its task, thereby telling our epoll queue that it’s finished with it and that we are interested in getting notifications on that file descriptor again. To go back to our original analogy of hardware interrupts, EPOLLONESHOT could be thought of as masking an interrupt. You haven’t actually cleared the source of the event notification yet, but you don’t want further notifications until you’ve done that and explicitly unmask it. In epoll, the EPOLLONESHOT flag will disable notifications on the file descriptor until you explicitly enable it by calling epoll_ctl with the op argument set to EPOLL_CTL_MOD. Edge-triggered means that the answer to the question “Has an event happened” is true only if the electrical signal has changed from low to high. If we translate this to our example: a read event has occurred when the buffer has changed from having no data to having data. As long as there is data in the buffer, no new events will be reported. You still handle the event by draining all the data from the socket, but you won’t get a new notification until the buffer is fully drained and then filled with new data. Edge-triggered mode also comes with some pitfalls. The biggest one is that if you don’t drain the buffer properly, you will never receive a notification on that file handle again.

80 Create Your Own Event Queue

                            Figure 4.1 – Edge-triggered versus level-triggered events
     mio doesn’t, at the time of writing, support EPOLLONESHOT and uses epoll in an edge-triggered
     mode, which we will do as well in our example.
        What about waiting on epoll_wait in multiple threads?
        As long as we only have one Poll instance, we avoid the problems and subtleties of having
        multiple threads calling epoll_wait on the same epoll instance. Using level-triggered events
        will wake up all threads that are waiting in the epoll_wait call, causing all of them to try
        to handle the event (this is often referred to as the problem of the thundering heard). epoll
        has another flag you can set, called EPOLLEXCLUSIVE, that solves this issue. Events that are
        set to be edge-triggered will only wake up one of the threads blocking in epoll_wait by
        default and avoid this issue.
        Since we only use one Poll instance from a single thread, this will not be an issue for us.
     I know and understand that this sounds very complex. The general concept of event queues is rather
     simple, but the details can get a bit complex. That said, epoll is one of the most complex APIs in my
     experience since the API has clearly been evolving over time to adapt the original design to suit
     modern requirements, and there is really no easy way to actually use and understand it correctly
     without covering at least the topics we covered here.
     One word of comfort here is that both kqueue and IOCP have APIs that are easier to understand.
     There is also the fact that Unix has a new asynchronous I/O interface called io_uring that will be
     more and more and more common in the future.
Figure from page 101
figure · book page 101
                                                                                             The Poll module     81

Now that we’ve covered the hard part of this chapter and gotten a high-level overview of how epoll works, it’s time to implement our mio-inspired API in poll.rs.

The Poll module If you haven’t written or copied the code we presented in the Design and introduction to epoll section, it’s time to do it now. We’ll implement all the functions where we just had todo!() earlier. We start by implementing the methods on our Poll struct. First up is opening the impl Poll block and implementing the new function:

ch04/a-epoll/src/poll.rs
  impl Poll {
      pub fn new() -> Result<Self> {
          let res = unsafe { ffi::epoll_create(1) };
          if res < 0 {
              return Err(io::Error::last_os_error());
          }
          Ok(Self {
              registry: Registry { raw_fd: res },
          })
      }

Given the thorough introduction to epoll in the The ffi module section, this should be pretty straightforward. We call ffi::epoll_create with an argument of 1 (remember, the argument is ignored but must have a non-zero value). If we get any errors, we ask the operating system to report the last error for our process and return that. If the call succeeds, we return a new Poll instance that simply wraps around our registry that holds the epoll file descriptor. Next up is our registry method, which simply hands out a reference to the inner Registry struct:

ch04/a-epoll/src/poll.rs
      pub fn registry(&self) -> &Registry {
          &self.registry
      }

82 Create Your Own Event Queue

     The last method on Poll is the most interesting one. It’s the poll function, which will park the
     current thread and tell the operating system to wake it up when an event has happened on a source we’re
     tracking, or the timeout has elapsed, whichever comes first. We also close the impl Poll block here:
     ch04/a-epoll/src/poll.rs
         pub fn poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()> {
           let fd = self.registry.raw_fd;
           let timeout = timeout.unwrap_or(-1);
           let max_events = events.capacity() as i32;
           let res = unsafe { ffi::epoll_wait(fd, events.as_mut_ptr(), max_events, timeout)
       };
           if res < 0 {
             return Err(io::Error::last_os_error());
           };
           unsafe { events.set_len(res as usize) };
           Ok(())
         }
       }
     The first thing we do is to get the raw file descriptor for the event queue and store it in the fd variable.
     Next is our timeout. If it’s Some, we unwrap that value, and if it’s None, we set it to –1, which is
     the value that tells the operating system that we want to block until an event occurs even though that
     might never happen.
     At the top of the file, we defined Events as a type alias for Vec<ffi::Event>, so the next thing
     we do is to get the capacity of that Vec. It’s important that we don’t rely on Vec::len since that
     reports how many items we have in the Vec. Vec::capacity reports the space we’ve allocated
     and that’s what we’re after.
     Next up is the call to ffi::epoll_wait. This call will return successfully if it has a value of 0 or
     larger, telling us how many events have occurred.
        Note
        We would get a value of 0 if a timeout elapses before an event has happened.
     The last thing we do is to make an unsafe call to events.set_len(res as usize). This
     function is unsafe since we could potentially set the length so that we would access memory that’s not
     been initialized yet in safe Rust. We know from the guarantee the operating system gives us that the
     number of events it returns is pointing to valid data in our Vec, so this is safe in our case.
                                                                                   The Poll module   83

Next up is our Registry struct. We will only implement one method, called register, and lastly, we’ll implement the Drop trait for it, closing the epoll instance:

ch04/a-epoll/src/poll.rs
  impl Registry {
      pub fn register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()>
  {
          let mut event = ffi::Event {
              events: interests as u32,
              epoll_data: token,
          };
          let op = ffi::EPOLL_CTL_ADD;
          let res = unsafe {
              ffi::epoll_ctl(self.raw_fd, op, source.as_raw_fd(), &mut event)
          };
          if res < 0 {
              return Err(io::Error::last_os_error());
          }
          Ok(())
      }
  }

The register function takes a &TcpStream as a source, a token of type usize, and a bitmask named interests, which is of type i32.

Note This is where mio does things differently. The source argument is specific to each platform. Instead of having the implementation of register on Registry, it’s handled in a platformspecific way in the source argument it receives.

The first thing we do is to create an ffi::Event object. The events field is simply set to the bitmask we received and named interests, and epoll_data is set to the value we passed in the token argument.

84 Create Your Own Event Queue

     The operation we want to perform on the epoll queue is adding interest in events on a new file descriptor.
     Therefore, we set the op argument to the ffi::EPOLL_CTL_ADD constant value.
     Next up is the call to ffi::epoll_ctl. We pass in the file descriptor to the epoll instance first,
     then we pass in the op argument to indicate what kind of operation we want to perform. The last
     two arguments are the file descriptor we want the queue to track and the Event object we created
     to indicate what kind of events we’re interested in getting notifications for.
     The last part of the function body is simply the error handling, which should be familiar by now.
     The last part of poll.rs is the Drop implementation for Registry:
     ch04/a-epoll/src/poll.rs
       impl Drop for Registry {
           fn drop(&mut self) {
               let res = unsafe { ffi::close(self.raw_fd) };
               if res < 0 {
                   let err = io::Error::last_os_error();
                   eprintln!("ERROR: {err:?}");
               }
           }
       }
     The Drop implementation simply calls ffi::close on the epoll file descriptor. Adding a panic
     to drop is rarely a good idea since drop can be called within a panic already, which will cause the
     process to simply abort. mio logs errors if they occur in its Drop implementation but doesn’t handle
     them in any other way. For our simple example, we’ll just print the error so we can see if anything
     goes wrong since we don’t implement any kind of logging here.
     The last part is the code for running our example, and that leads us to main.rs.
     The main program
     Let’s see how it all works in practice. Make sure that delayserver is up and running, because we’ll
     need it for these examples to work.
     The goal is to send a set of requests to delayserver with varying delays and then use epoll to wait
     for the responses. Therefore, we’ll only use epoll to track read events in this example. The program
     doesn’t do much more than that for now.
                                                                                  The main program     85

The first thing we do is to make sure our main.rs file is set up correctly:

ch04/a-epoll/src/main.rs use std::{io::{self, Read, Result, Write}, net::TcpStream};

use ffi::Event; use poll::Poll;

mod ffi; mod poll;

We import a few types from our own crate and from the standard library, which we’ll need going forward, as well as declaring our two modules. We’ll be working directly with TcpStreams in this example, and that means that we’ll have to format the HTTP requests we make to our delayserver ourselves. The server will accept GET requests, so we create a small helper function to format a valid HTTP GET request for us:

ch04/a-epoll/src/main.rs
  fn get_req(path &str) -> Vec<u8> {
      format!(
          "GET {path} HTTP/1.1\r\n\
               Host: localhost\r\n\
               Connection: close\r\n\
               \r\n"
      )
  }

The preceding code simply takes a path as an input argument and formats a valid GET request with it. The path is the part of the URL after the scheme and host. In our case, the path would be everything in bold in the following URL: http://localhost:8080/2000/hello-world. Next up is our main function. It’s divided into two parts:

• Setup and sending requests • Wait and handle incoming events

86 Create Your Own Event Queue

     The first part of the main function looks like this:
       fn main() -> Result<()> {
           let mut poll = Poll::new()?;
           let n_events = 5;
           let mut streams = vec![];
           let addr = "localhost:8080";
           for i in 0..n_events {
               let delay = (n_events - i) * 1000;
               let url_path = format!("/{delay}/request-{i}");
               let request = get_req(&url_path);
               let mut stream = std::net::TcpStream::connect(addr)?;
               stream.set_nonblocking(true)?;
               stream.write_all(request.as_bytes())?;
               poll.registry()
                   .register(&stream, i, ffi::EPOLLIN | ffi::EPOLLET)?;
               streams.push(stream);
           }
     The first thing we do is to create a new Poll instance. We also specify what number of events we
     want to create and handle in our example.
     The next step is creating a variable to hold a collection of Vec<TcpStream> objects.
     We also store the address to our local delayserver in a variable called addr.
     The next part is where we create a set of requests that we issue to our delayserver, which will
     eventually respond to us. For each request, we expect a read event to happen sometime later on in
     the TcpStream we sent the request on.
     The first thing we do in the loop is set the delay time in milliseconds. Setting the delay to (n_events
     - i) * 1000 simply sets the first request we make to have the longest timeout, so we should expect
     the responses to arrive in the reverse order from which they were sent.
                                                                                              The main program    87

Note For simplicity, we use the index the event will have in the streams collection as its ID. This ID will be the same as the i variable in our loop. For example, in the first loop, i will be 0; it will also be the first stream to be pushed to our streams collection, so the index will be 0 as well. We therefore use 0 as the identification for this stream/event throughout since retrieving the TcpStream associated with this event will be as simple as indexing to that location in the streams collection.

The next line, format!("/{delay}/request-{i}"), formats the path for our GET request. We set the timeout as described previously, and we also set a message where we store the identifier for this event, i, so we can track this event on the server side as well. Next up is creating a TcpStream. You’ve probably noticed that the TcpStream in Rust doesn’t accept &str but an argument that implements the ToSocketAddrs trait. This trait is implemented for &str already, so that’s why we can simply write it like we do in this example. Before Tcpstream::connect actually opens a socket, it will try to parse the address we pass in as an IP address. If it fails, it will parse it as a domain address and a port number, and then ask the operating system to do a DNS lookup for that address, which it then can use to actually connect to our server. So, you see, there is potentially quite a bit going on when we do a simple connection. You probably remember that we discussed some of the nuances of the DNS lookup earlier and the fact that such a call could either be very fast since the operating system already has the information stored in memory or block while waiting for a response from the DNS server. This is a potential downside if you use TcpStream from the standard library if you want full control over the entire process.

TcpStream in Rust and Nagle’s algorithm Here is a little fact for you (I originally intended to call it a “fun fact,” but realized that’s stretching the concept of “fun” just a little too far!). In Rust’s TcpStream, and, more importantly, most APIs that aim to mimic the standard library’s TcpStream such as mio or Tokio, the stream is created with the TCP_NODELAY flag set to false. In practice, this means that Nagle’s algorithm is used, which can cause some issues with latency outliers and possibly reduced throughput on some workloads.

Nagle’s algorithm is an algorithm that aims to reduce network congestion by pooling small network packages together. If you look at non-blocking I/O implementations in other languages, many, if not most, disable this algorithm by default. This is not the case in most Rust implementations and is worth being aware of. You can disable it by simply calling TcpStream::set_ nodelay(true). If you try to create your own async library or rely on Tokio/mio, and observe lower throughput than expected or latency problems, it’s worth checking whether this flag is set to true or not.

88 Create Your Own Event Queue

     To continue with the code, the next step is setting TcpStream to non-blocking by calling Tcp
     Stream::set_nonblocking(true).
     After that, we write our request to the server before we register interest in read events by setting the
     EPOLLIN flag bit in the interests bitmask.
     For each iteration, we push the stream to the end of our streams collection.
     The next part of the main function is handling incoming events.
     Let’s take a look at the last part of our main function:
       let mut handled_events = 0;
           while handled_events < n_events {
               let mut events = Vec::with_capacity(10);
               poll.poll(&mut events, None)?;
               if events.is_empty() {
                   println!("TIMEOUT (OR SPURIOUS EVENT NOTIFICATION)");
                   continue;
               }
               handled_events += handle_events(&events, &mut streams)?;
           }
           println!("FINISHED");
           Ok(())
       }
     The first thing we do is create a variable called handled_events to track how many events we
     have handled.
     Next is our event loop. We loop as long as the handled events are less than the number of events we
     expect. Once all events are handled, we exit the loop.
     Inside the loop, we create a Vec<Event> with the capacity to store 10 events. It’s important that
     we create this using Vec::with_capacity since the operating system will assume that we pass
     it memory that we’ve allocated. We could choose any number of events here and it would work just
     fine, but setting too low a number would limit how many events the operating system could notify
     us about on each wakeup.
     Next is our blocking call to Poll::poll. As you know, this will actually tell the operating system
     to park our thread and wake us up when an event has occurred.
     If we’re woken up, but there are no events in the list, it’s either a timeout or a spurious event (which
     could happen, so we need a way to check whether a timeout has actually elapsed if that’s important
     to us). If that’s the case, we simply call Poll::poll once more.
                                                                                 The main program     89

If there are events to be handled, we pass these on to the handle_events function together with a mutable reference to our streams collection. The last part of main is simply to write FINISHED to the console to let us know we exited main at that point. The last bit of code in this chapter is the handle_events function. This function takes two arguments, a slice of Event structs and a mutable slice of TcpStream objects. Let’s take a look at the code before we explain it: fn handle_events(events: &[Event], streams: &mut [TcpStream]) -> Result<usize> { let mut handled_events = 0; for event in events { let index = event.token(); let mut data = vec![0u8; 4096];

          loop {
              match streams[index].read(&mut data) {
                  Ok(n) if n == 0 => {
                      handled_events += 1;
                      break;
                  }
                  Ok(n) => {
                      let txt = String::from_utf8_lossy(&data[..n]);
                      println!("RECEIVED: {:?}", event);
                      println!("{txt}\n------\n");
                  }
                  // Not ready to read in a non-blocking manner. This could
                  // happen even if the event was reported as ready
                  Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
                  Err(e) => return Err(e),
              }
          }
      }
      Ok(handled_events)
  }

The first thing we do is to create a variable, handled_events, to track how many events we consider handled on each wakeup. The next step is looping through the events we received.

90 Create Your Own Event Queue

     In the loop, we retrieve the token that identifies which TcpStream we received an event for. As we
     explained earlier in this example, this token is the same as the index for that particular stream in the
     streams collection, so we can simply use it to index into our streams collection and retrieve the
     right TcpStream.
     Before we start reading data, we create a buffer with a size of 4,096 bytes (you can, of course, allocate
     a larger or smaller buffer for this if you want to).
     We create a loop since we might need to call read multiple times to be sure that we’ve actually drained
     the buffer. Remember how important it is to fully drain the buffer when using epoll in edge-triggered mode.
     We match on the result of calling TcpStream::read since we want to take different actions based
     on the result:
        • If we get Ok(n) and the value is 0, we’ve drained the buffer; we consider the event as handled
          and break out of the loop.
        • If we get Ok(n) with a value larger than 0, we read the data to a String and print it out
          with some formatting. We do not break out of the loop yet since we have to call read until 0
          is returned (or an error) to be sure that we’ve drained the buffers fully.
        • If we get Err and the error is of the io::ErrorKind::WouldBlock type, we simply
          break out of the loop. We don’t consider the event handled yet since WouldBlock indicates
          that the data transfer is not complete, but there is no data ready right now.
        • If we get any other error, we simply return that error and consider it a failure.
        Note
        There is one more error condition you’d normally want to cover, and that is
        io::ErrorKind::Interrupted. Reading from a stream could be interrupted by
        a signal from the operating system. This should be expected and probably not considered
        a failure. The way to handle this is the same as what we do when we get an error of the
        WouldBlock type.
     If the read operation is successful, we return the number of events handled.
        Be careful with using TcpStream::read_to_end
        You should be careful with using TcpStream::read_to_end or any other function that
        fully drains the buffer for you when using non-blocking buffers. If you get an error of the
        io::WouldBlock type, it will report that as an error even though you had several successful
        reads before you got that error. You have no way of knowing how much data you read successfully
        other than observing any changes to the &mut Vec you passed in.
                                                                  The main program   91

Now, if we run our program, we should get the following output: RECEIVED: Event { events: 1, epoll_data: 4 } HTTP/1.1 200 OK content-length: 9 connection: close content-type: text/plain; charset=utf-8 date: Wed, 04 Oct 2023 15:29:09 GMT

request-4

RECEIVED: Event { events: 1, epoll_data: 3 } HTTP/1.1 200 OK content-length: 9 connection: close content-type: text/plain; charset=utf-8 date: Wed, 04 Oct 2023 15:29:10 GMT

request-3

RECEIVED: Event { events: 1, epoll_data: 2 } HTTP/1.1 200 OK content-length: 9 connection: close content-type: text/plain; charset=utf-8 date: Wed, 04 Oct 2023 15:29:11 GMT

request-2

RECEIVED: Event { events: 1, epoll_data: 1 } HTTP/1.1 200 OK content-length: 9 connection: close content-type: text/plain; charset=utf-8 date: Wed, 04 Oct 2023 15:29:12 GMT

request-1

RECEIVED: Event { events: 1, epoll_data: 0 } HTTP/1.1 200 OK

92 Create Your Own Event Queue

       content-length: 9
       connection: close
       content-type: text/plain; charset=utf-8
       date: Wed, 04 Oct 2023 15:29:13 GMT
       request-0
       FINISHED
     As you see, the responses are sent in reverse order. You can easily confirm this by looking at the output
     on the terminal on running the delayserver instance. The output should look like this:
       #1 - 5000ms: request-0
       #2 - 4000ms: request-1
       #3 - 3000ms: request-2
       #4 - 2000ms: request-3
       #5 - 1000ms: request-4
     The ordering might be different sometimes as the server receives them almost simultaneously, and
     can choose to handle them in a slightly different order.
     Say we track events on the stream with ID 4:
       1.   In send_requests, we assigned the ID 4 to the last stream we created.
       2.   Socket 4 sends a request to delayserver, setting a delay of 1,000 ms and a message of
            request-4 so we can identify it on the server side.
       3.   We register socket 4 with the event queue, making sure to set the epoll_data field to 4 so
            we can identify on what stream the event occurred.
       4.   delayserver receives that request and delays the response for 1,000 ms before it sends an
            HTTP/1.1 200 OK response back, together with the message we originally sent.
       5.   epoll_wait wakes up, notifying us that an event is ready. In the epoll_data field of the
            Event struct, we get back the same data that we passed in when registering the event. This
            tells us that it was an event on stream 4 that occurred.
       6.   We then read data from stream 4 and print it out.
     In this example, we’ve kept things at a very low level even though we used the standard library to
     handle the intricacies of establishing a connection. Even though you’ve actually made a raw HTTP
     request to your own local server, you’ve set up an epoll instance to track events on a TcpStream
     and you’ve used epoll and syscalls to handle incoming events.
     That’s no small feat – congratulations!
                                                                                               Summary      93

Before we leave this example, I wanted to point out how few changes we need to make to have our example use mio as the event loop instead of the one we created. In the repository under ch04/b-epoll-mio, you’ll see an example where we do the exact same thing using mio instead. It only requires importing a few types from mio instead of our own modules and making only five minor changes to our code! Not only have you replicated what mio does, but you pretty much know how to use mio to create an event loop as well!

Summary The concept of epoll, kqueue, and IOCP is pretty simple at a high level, but the devil is in the details. It’s just not that easy to understand and get it working correctly. Even programmers who work on these things will often specialize in one platform (epoll/kqueue or Windows). It’s rare that one person will know all the intricacies of all platforms, and you could probably write a whole book about this subject alone. If we summarize what you’ve learned and got firsthand experience with in this chapter, the list is quite impressive:

• You learned a lot about how mio is designed, enabling you to go to that repository and know what to look for and how to get started on that code base much easier than before reading this chapter • You learned a lot about making syscalls on Linux • You created an epoll instance, registered events with it, and handled those events • You learned quite a bit about how epoll is designed and its API • You learned about edge-triggering and level-triggering, which are extremely low-level, but useful, concepts to have an understanding of outside the context of epoll as well • You made a raw HTTP request • You saw how non-blocking sockets behave and how error codes reported by the operating system can be a way of communicating certain conditions that you’re expected to handle • You learned that not all I/O is equally “blocking” by looking at DNS resolution and file I/O

That’s pretty good for a single chapter, I think! If you dive deeper into the topics we covered here, you’ll soon realize that there are gotchas and rabbit holes everywhere – especially if you expand this example to abstract over epoll, kqueue, and IOCP. You’ll probably end up reading Linus Torvald’s emails on how edge-triggered mode was supposed to work on pipes before you know it.

94 Create Your Own Event Queue

     At least you now have a good foundation for further exploration. You can expand on our simple
     example and create a proper event loop that handles connecting, writing, timeouts, and scheduling;
     you can dive deeper into kqueue and IOCP by looking at how mio solves that problem; or you can
     be happy that you don’t have to directly deal with it again and appreciate the effort that went into
     libraries such as mio, polling, and libuv.
     By this point, we’ve gained a lot of knowledge about the basic building blocks of asynchronous
     programming, so it’s time to start exploring how different programming languages create abstractions
     over asynchronous operations and use these building blocks to give us as programmers efficient,
     expressive, and productive ways to write our asynchronous programs.
     First off is one of my favorite examples, where we’ll look into how fibers (or green threads) work by
     implementing them ourselves.
     You’ve earned a break now. Yeah, go on, the next chapter can wait. Get a cup of tea or coffee and reset
     so you can start the next chapter with a fresh mind. I promise it will be both fun and interesting.
← / → change chapter. Esc returns to the main menu. Click a figure to zoom.