Rolf: User Input, Threads, and Channels
Assume that you’re writing a TUI application (like
rolf
) which does at least two
things:
-
Respond to user input, and
-
Load in incredibly large[1] images.
If you’re working on a program like this, you might run into an annoying problem: users have to wait for images to load before they can do anything.
The solution to this problem is to asynchronously obtain user input.
Depending on your runtime (e.g. nodejs), it’s possible to be both asynchronous and single-threaded.
However, for the purposes of rolf
, we used multiple threads to handle
asynchronous input.
Architecture
For the purposes of this post, let’s say that there are 3 threads involved.[2]
As seen in the diagram above, we have a thread for:
-
Receiving user input,
-
Decoding images,
-
And everything else (the Main thread)
You might also notice that the Input and Image Decoding threads only interact with the Main thread, sending data back and forth to each other.
The Input and Main threads will both run on "infinite" loops. The Input thread will wait for user input, sending it to the Main thread as necessary, and the Main thread will wait for either input data from the Input thread or decoded image data from the Image Decoding thread, processing this data as necessary.
Notably, we achieve all of this without polling, instead using channels to send data between threads.
Our Main thread will simply be the initial thread of the program, and our two other threads will be started by the Main thread.
In this post, we’ll be looking at the relationship between the Input thread and Main thread. The Image Decoding thread is sophisticated enough to warrant a post of its own, which I may write in the future.
Channels
Before starting the loops for either of the threads (in fact, before starting any additional threads beyond the Main one), we’re going to create a few channels.
1
2
3
let (tx, rx) = channel();
let (to_input_tx, from_main_rx) = sync_channel(0);
Here, we’ve created two channels:
-
One which we’ll use for sending data to the Main thread,
-
And another which we’ll use for sending data to the Input thread.
Since channels only send information in one direction, we need two channels if we want bidirectional communication between the Input thread and the Main thread. Why do we want bidirectional communication in the first place? We’ll discuss that more later.
From our point of view, each channel consists of a sender and a receiver.
-
For the first channel,
tx
will be our sender andrx
will be our receiver. -
For the second channel,
to_input_tx
is our sender andfrom_main_rx
is our receiver.
Notably, we use a different function to create each of our channels. I’m not sure if this is actually necessary, but this allows us to create asynchronous/infinitely buffered vs. synchronous/bounded channels. The distinction won’t matter massively for the purposes of this blog post, but you can read more about it here.
Data Between Channels
Before we actually use the channels to send data between threads, let’s first establish the types of data that will be sent.
1
2
3
4
5
6
7
enum InputEvent {
CrosstermEvent {
event: crossterm::event::Event,
input_request_count: usize,
},
// Other events...
}
1
2
3
4
enum InputRequest {
RequestNumber(usize),
Quit,
}
The first enum we defined will be sent from the Input thread to the Main thread.
The second enum is sent from the Main thread to the Input thread. This implies that the Main thread must request input from the Input thread before the Input thread actually sends any input. This is the primary reason we have bidirectional communication between the Main and Input threads, and it allows us to avoid a potentially annoying bug.
Bug: Too Many Inputs
In rolf
, there’s a feature which allows the user to open up their current file
in an external text editor of their choice (using the EDITOR
and VISUAL
environmental variables). Typical values of EDITOR
might be nano
, vim
, or
emacsclient
. For this feature, the Main thread will block until the external
text editor is closed.[3]
Now imagine if we didn’t have the Main thread request input from the Input thread, instead making the Input thread send input through its infinitely-buffered channel as soon as it receive any from the user.
If we combine this channel setup with the external editor feature described above, we’ll run into a curious problem: the Input thread may "steal" keypresses from the external editor.
Since the Input thread will just receive input and send it to the Main thread
whenever it receives a keypress, it can occasionally continue receiving these
inputs even if, say, vim
is being used to edit the current file. As a result,
two annoying things will happen:
-
These occasional inputs won’t make it to
vim
, resulting in the user being unsettled by the inconsistent (in)ability to provide input. -
These occasional inputs will be sent to the main thread, which will then process those inputs after the external editor is closed. This would also be disjarring for the user, as they would see apparent "phantom inputs" be rapidly processed after they finish editing.
By making the Main Thread send requests for input to the Input thread, we can ensure that the Input thread only sends input to the Main thread when necessary. This prevents the above bug from happening entirely.
You might’ve also noticed that both InputRequest
and CrosstermEvent
contain
request numbers. These request numbers help the Main thread determine whether or
not it should actually send a request for input, which is crucial for properly
fixing this bug. We’ll dive more into that later.
Starting the Input Thread
Now that we have the channels and their data set up, it’s time to start the input loop.
Specifically, we’ll start the input loop in its own thread, the Input thread.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let crossterm_input_tx = tx.clone();
std::thread::spawn(move || {
loop {
let input_request_count = match from_main_rx.recv() {
Ok(InputRequest::RequestNumber(input_request_count)) => input_request_count,
Ok(InputRequest::Quit) => break,
Err(err) => panic!("Input thread: Lost connnection to main thread: {:?}", err),
};
let crossterm_event = event::read().expect("Unable to read crossterm event");
crossterm_input_tx
.send(InputEvent::CrosstermEvent {
event: crossterm_event,
input_request_count,
})
.expect("Unable to send on channel");
}
});
Here, to satisfy the Rust borrow checker, we need to clone our sender before we use it in the actual thread. Since these channels are multi-producer, single-consumer, this is how we handle having more than one possible sender (producer) to the Main thread.
The rest of the thread is mostly what you’d expect, with a bit of bookkeeping for request numbers and requests to quit the thread.
Notably, the Input thread keeps track of the request number of the most recent input request sent to it by the Main thread, and then it sends this number back to the Main thread alongside the actual input data.
This implies the Main thread will be the only thread to modify request numbers at all. We’ll see more about this in a bit.
Loop in the Main Thread
As the name might indicate, the Main thread is where the real meat and bones of
rolf
takes place. Since they can get fairly complex, we’ll omit most of those
details, only showing the parts that are relevant to managing concurrency.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let mut input_request_count = 0;
let mut last_recv_req_count = 0;
// Other initialization code...
loop {
// Main drawing code...
// Other stuff...
let event = match rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => {
if input_request_count == last_recv_req_count {
input_request_count += 1;
to_input_tx
.send(InputRequest::RequestNumber(input_request_count))
.expect("Unable to send to input thread");
}
rx.recv().unwrap()
}
Err(err) => panic!("Unable to obtain input event: {}", err),
};
match event {
InputEvent::CrosstermEvent {
event,
input_request_count,
} => {
last_recv_req_count = input_request_count;
// Process input from user...
}
// Handle other events...
}
}
As mentioned earlier, the Main thread handles updates request numbers and determines if it should send an input request.
When determining if we should send an input request, there are two key things to keep in mind:
-
We should only ask for a keypress if there’s no other input or data to process.
-
We should only ask for a keypress if the Input thread has already responded to our last input request.
In practice, the first item means only asking for a keypress if there are no
existing InputEvent
values in the channel to the main thread. This is
precisely what lines 11
-24
of the above code block are achieving.
To handle the second item, we need to keep track of two key things:
-
The request number of the most recently sent input request (
input_request_count
). -
And the request number associated with the last
CrosstermEvent
received by the Main thread from the Input thread (last_recv_req_count
).
By comparing request numbers for equality, we can see which CrosstermEvent
values were prompted by which input requests. Thus, to see if the Input thread
has already responded to our last input request, we can check if the
input_request_count
number is equal to the last_recv_req_count
number.
Possible Bug: Integer Overflow
You might’ve noticed that we’re just incrementing input_request_count
every
time we make a new input request.
If we continue doing that for long enough, the value of input_request_count
will surpass the maximum value of a 64-bit unsigned integer, resulting in
integer overflow.
There’s a simple fix to this problem: wrapping the integer.
-
Before incrementing, check if
input_request_count
is at the maximum value for its type. -
If it is, then set
input_request_count
back to 0. -
Otherwise, increment it as normal.
In practice, I haven’t run into this bug yet (due to the incredibly high number
of CrosstermEvent
values which would have to be sent to trigger it).
In release builds with Rust, integers will wrap around using two’s complement, so this bug won’t really be visible. If I understand this correctly, two’s complement wraparound will in effect achieve the wrapping algorithm I described above.
In debug builds, overflow will be checked at runtime, with the program panicking
if overflow does occur. I probably won’t ever use rolf
enough to trigger this
bug in a debug build (or in any build, really).
Fortunately, if this does turn out to be an issue, the fix should be fairly simple.
Parting Thoughts
It took me a crazy long time to figure out how to address the input "stealing"
bug. Arguably, finding that bug (in addition to the asynchronous image loading
problem) was one of the leading factors which led me to reshape the architecture
of rolf
into what it is now.
If you ever run into a similar problem, I hope this is useful to you!
Note: Have I made any typos? Am I blatantly wrong about something? Do
you just have general feedback? I’m still a Rust novice (arguably a
programming novice, in general), so feel free to email me at
chiggiechang@gmail.com
. But be polite,
please!
rolf
actually uses more threads than the ones shown here, but they’re not relevant to this post.