_posts/2020-07-19-popol.md 9.1 KiB raw
1
---
2
title: "Popol: Minimal non-blocking I/O with Rust"
3
headline: "Popol: Minimal non-blocking I/O with Rust"
4
date: "19.07.2020"
5
teaser: "async i/o"
6
type: note
7
slug: popol
8
crate: popol
9
layout: default
10
---
11
12
Asynchronous I/O in Rust is still very much in its infancy. *Async/await*,
13
Rust's solution to the problem, was recently stabilized and so when came
14
the time to implement some peer-to-peer networking code, I reached for the shiny new
15
feature. To my dismay, it created more problems than it solved. Indeed, I quickly
16
regretted going down that path and searched for alternatives. All I was
17
looking for was an easy way to handle between fifty and a hundred TCP connections
18
(`net::TcpStream`) efficiently to implement the reactor for [nakamoto][], a
19
Bitcoin client I've been working on.
20
21
The crux of the problem with *async/await* is that it is incompatible with the
22
standard library traits, such as `Read` and `Write`. Partly, this is due to the
23
design decisions behind Rust's async implementation: the choice of cooperative
24
multitasking instead of preemptive multitasking, for instance.
25
Languages built on the latter don't suffer from this scission. Good examples
26
include Haskell and Erlang, which don't have a language-level concept of "async"
27
-- the implementation is transparent to the user.
28
29
As it stands, the *async/await* system in Rust comes with an incompatible set of
30
traits: `AsyncRead` and `AsyncWrite`, which don't play nicely with the standard
31
library. Channels, for example the amazing *crossbeam-channel* don't work in
32
asynchronous code -- you need an *async* variant. Alternatively, most of the
33
runtimes that effectively provide replacement types for channels, files and
34
sockets, such as *async-std* and *tokio*, have a large dependency footprint,
35
inherent in the complexity of the problem. For example, [smol][], one of the
36
"small" runtimes still consists of about 24 crates, including its transitive
37
dependencies, as of today.
38
39
    $ cargo tree -p smol -e no-dev --prefix none --no-dedupe | sort | uniq | wc -l
40
    24
41
42
Async/await may be a "zero-cost abstraction", but it certainly has a cost in
43
usability, maintenance and complexity. I think it still has a long way to go
44
before the benefits outweigh the costs.
45
46
So what are the alternatives? Well, for parallelism, we have threads. Threads
47
are *great* in Rust.  However, for peer-to-peer networking, threads can get a little
48
unwieldy. For my use case, the ability to handle between a dozen and a few
49
hundred open connections is all I need.  And I believe this is true for *most*
50
people, especially in the peer-to-peer space. This requirement  places us squarely
51
within the territory of the venerable `poll(2)` system call, which is available on
52
almost all platforms nowadays.[^1]
53
54
[*Popol*][code] is designed as a minimal ergonomic wrapper around `poll`,
55
built for use cases such as peer-to-peer networking, where you typically have
56
no more than a few hundred concurrent connections. It's meant to be familiar
57
enough for those with experience using [mio][], but a little easier to use, and
58
a lot smaller.[^2]
59
60
```rust
61
// Create a registry to hold I/O sources.
62
let mut sources = popol::Sources::with_capacity(1);
63
// Create an events buffer to hold readiness events.
64
let mut events = popol::Events::with_capacity(1);
65
66
// Register the program's standard input as a source of "read" readiness events.
67
// The first parameter is the key we want to associate with the source. Since
68
// we only have one source in this example, we just pass in the unit type.
69
sources.register((), &io::stdin(), popol::interest::READ);
70
71
// Wait on our event sources for at most 6 seconds. If an event source is
72
// ready before then, process its events. Otherwise, timeout.
73
match sources.wait_timeout(&mut events, Duration::from_secs(6)) {
74
    Ok(()) => {}
75
    Err(err) if err.kind() == io::ErrorKind::TimedOut => process::exit(1),
76
    Err(err) => return Err(err),
77
}
78
79
// Iterate over source events. Since we only have one source
80
// registered, this will only iterate once.
81
for ((), event) in events.iter() {
82
    // The standard input has data ready to be read.
83
    if event.readable || event.hangup {
84
        let mut buf = [0; 1024];
85
86
        // Read what we can from standard input and echo it.
87
        match io::stdin().read(&mut buf[..]) {
88
            Ok(n) => io::stdout().write_all(&buf[..n])?,
89
            Err(err) => panic!(err),
90
        }
91
    }
92
}
93
```
94
95
By building on `poll`, we have the following benefits:
96
97
* Much smaller implementation than even the smallest *async/await* runtimes.[^3]
98
* All the standard library traits just work (`io::Read`, `io::Write`, etc.)
99
* All the standard library sockets work, (`net:::TcpStream`, `net::UdpStream`, etc.)
100
* No "runtime" keeps the code much easier to reason about.
101
102
Some people ask why I didn't build on `epoll`[^4]. There's a couple of reasons:
103
104
1. `epoll` is more complex than `poll` and thus requires us to write more code.
105
2. `epoll` isn't portable; it only works on Linux.
106
3. `epoll` requires a system call to add, remove or modify the list of sources
107
   to poll. This introduces new failure modes.
108
4. Even though `epoll` should be able to handle more connections, `poll` is
109
   *plenty enough* for all the use-cases I'm interested in.
110
111
How about *mio*? It solves the first two problems, one might say. But
112
there are a couple of things that irked me about *mio*. In particular:
113
114
* *mio* comes bundled with its own socket types, which you are supposed to use
115
  instead of the ones provided by the standard library.
116
* *mio* identifies event sources by a `Token` type, which is basically a wrapper
117
  around a `u64`. This is leaked from the underlying `epoll` implementation,
118
  and is less than ideal.
119
120
Compared to *mio*, *popol*:
121
122
* Is a lot smaller (about 10% of the size).
123
* Allows you to use any type implementing `Eq` and `Clone` to identify event
124
  sources. It's not limited to `u64`.
125
* Supports standard library sockets (and other file types) transparently.
126
* Supports multiple *wakers* per wait call.
127
* Has only a single API call that can fail, since it's built on `poll`.
128
129
Some of the above is exemplified by popol's `Sources::register` function below:
130
131
```rust
132
impl<K: Eq + Clone> Sources<K> {
133
    /// Register a new source with the given key, and wait for the specified
134
    /// events.
135
    pub fn register(&mut self, key: K, source: &impl AsRawFd, events: Interest);
136
}
137
```
138
139
It goes without saying that *popol* is also a lot less mature than *mio*,
140
so use it at your own risk! *Popol* also doesn't currently support Windows,
141
though this is planned.
142
143
---
144
145
Finally, we can look at an example of a TCP server which accepts incoming
146
connections and registers them with *popol*. This example showcases the
147
use of more complex types for `K`:
148
149
```rust
150
use std::{io, net};
151
use popol;
152
153
/// The identifier we'll use with `popol` to figure out the source of an event.
154
/// The `K` in `Sources<K>`.
155
#[derive(Eq, PartialEq, Clone)]
156
enum Source {
157
    /// An event from a connected peer.
158
    Peer(net::SocketAddr),
159
    /// An event on the listening socket. Most probably a new peer connection.
160
    Listener,
161
}
162
163
let listener = net::TcpListener::bind("0.0.0.0:8888")?;
164
let mut sources = popol::Sources::new();
165
let mut events = popol::Events::new();
166
167
// Register the listener socket, using the corresponding identifier.
168
sources.register(Source::Listener, &listener, popol::interest::READ);
169
170
// It's important to set the socket in non-blocking mode. This allows
171
// us to know when to stop accepting connections.
172
listener.set_nonblocking(true)?;
173
174
loop {
175
    // Wait for something to happen on our sources.
176
    sources.wait(&mut events)?;
177
178
    for (key, event) in events.iter() {
179
        match key {
180
            Source::Listener => loop {
181
                // Accept as many connections as we can.
182
                let (conn, addr) = match listener.accept() {
183
                    Ok((conn, addr)) => (conn, addr),
184
185
                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
186
                    Err(e) => return Err(e),
187
                };
188
                // Register the new peer using the `Peer` variant of `Source`.
189
                sources.register(
190
                    Source::Peer(addr),
191
                    &conn,
192
                    popol::interest::ALL
193
                );
194
            }
195
            Source::Peer(addr) if event.readable => {
196
                println!("{} has data to be read", addr);
197
            }
198
            Source::Peer(addr) if event.writable => {
199
                println!("{} is ready to be written", addr);
200
            }
201
        }
202
    }
203
}
204
```
205
206
---
207
208
*Popol* is available on [crates.io][crate]. The source code is hosted on
209
[GitHub][code]. Discuss on [reddit][].
210
211
[^1]: Even Windows has an implementation of poll, called `WSAPoll`.
212
[^2]: The name "popol" comes from [Popol Vuh](https://en.wikipedia.org/wiki/Popol_Vuh).
213
[^3]: *Popol* is currently around 400 lines of code, excluding tests. The only dependency is the *libc* crate.
214
[^4]: <https://en.wikipedia.org/wiki/Epoll>
215
216
[nakamoto]: https://github.com/cloudhead/nakamoto
217
[smol]: https://crates.io/crates/smol
218
[mio]: https://crates.io/crates/mio
219
[code]: https://github.com/cloudhead/popol
220
[crate]: https://crates.io/crates/popol
221
[docs]: https://docs.rs/popol
222
[reddit]: https://www.reddit.com/r/rust/comments/htyyf3/popol_minimal_nonblocking_io_with_rust/