Implementation of triple buffering in Rust
This is an implementation of triple buffering written in Rust. You may find it
useful for the following class of thread synchronization problems:
For many use cases, you can use the ergonomic write/read interface, where
the producer moves values into the buffer and the consumer accesses the
latest buffer by shared reference:
// Create a triple buffer
use triple_buffer::triple_buffer;
let (mut buf_input, mut buf_output) = triple_buffer(&0);
// The producer thread can move a value into the buffer at any time
let producer = std::thread::spawn(move || buf_input.write(42));
// The consumer thread can read the latest value at any time
let consumer = std::thread::spawn(move || {
let latest = buf_output.read();
assert!(*latest == 42 || *latest == 0);
});
// Wait for both threads to be done
producer.join().unwrap();
consumer.join().unwrap();
In situations where moving the original value away and being unable to
modify it on the consumer’s side is too costly, such as if creating a new
value involves dynamic memory allocation, you can use a lower-level API
which allows you to access the producer and consumer’s buffers in place
and to precisely control when updates are propagated:
// Create and split a triple buffer
use triple_buffer::triple_buffer;
let (mut buf_input, mut buf_output) = triple_buffer(&String::with_capacity(42));
// --- PRODUCER SIDE ---
// Mutate the input buffer in place
{
// Acquire a reference to the input buffer
let input = buf_input.input_buffer_mut();
// In general, you don't know what's inside of the buffer, so you should
// always reset the value before use (this is a type-specific process).
input.clear();
// Perform an in-place update
input.push_str("Hello, ");
}
// Publish the above input buffer update
buf_input.publish();
// --- CONSUMER SIDE ---
// Manually fetch the buffer update from the consumer interface
buf_output.update();
// Acquire a read-only reference to the output buffer
let output = buf_output.output_buffer();
assert_eq!(*output, "Hello, ");
// Or acquire a mutable reference if necessary
let output_mut = buf_output.output_buffer_mut();
// Post-process the output value before use
output_mut.push_str("world!");
Finally, as a middle ground before the maximal ergonomics of thewrite()
API and the maximal control of theinput_buffer_mut()
/publish()
API, you can also use theinput_buffer_publisher()
RAII API on the
producer side, which ensures that publish()
is automatically called when
the resulting input buffer handle goes out of scope:
// Create and split a triple buffer
use triple_buffer::triple_buffer;
let (mut buf_input, _) = triple_buffer(&String::with_capacity(42));
// Mutate the input buffer in place and publish it
{
// Acquire a reference to the input buffer
let mut input = buf_input.input_buffer_publisher();
// In general, you don't know what's inside of the buffer, so you should
// always reset the value before use (this is a type-specific process).
input.clear();
// Perform an in-place update
input.push_str("Hello world!");
// Input buffer is automatically published at the end of the scope of
// the "input" RAII guard
}
// From this point on, the consumer can see the updated version
Compared to a mutex:
Compared to the read-copy-update (RCU) primitive from the Linux kernel:
Compared to sending the updates on a message queue:
In short, triple buffering is what you’re after in scenarios where a shared
memory location is updated frequently by a single writer, read by a single
reader who only wants the latest version, and you can spare some RAM.
By running the tests, of course! Which is unfortunately currently harder than
I’d like it to be.
First of all, we have sequential tests, which are very thorough but obviously
do not check the lock-free/synchronization part. You run them as follows:
$ cargo test
Then we have concurrent tests where, for example, a reader thread continuously
observes the values from a rate-limited writer thread, and makes sure that he
can see every single update without any incorrect value slipping in the middle.
These tests are more important, but also harder to run because one must first
check some assumptions:
Taking this and the relatively long run time (~10-20 s) into account, the
concurrent tests are ignored by default. To run them, make sure nothing is
eating CPU in the background and do:
$ cargo test --release -- --ignored --nocapture --test-threads=1
Finally, we have benchmarks, which allow you to test how well the code is
performing on your machine. We are now using criterion
for said benchmarks,
which seems that to run them, you can simply do:
$ cargo install cargo-criterion
$ cargo criterion
These benchmarks exercise the worst-case scenario of u8
payloads, where
synchronization overhead dominates as the cost of reading and writing the
actual data is only 1 cycle. In real-world use cases, you will spend more time
updating buffers and less time synchronizing them.
However, due to the artificial nature of microbenchmarking, the benchmarks must
exercise two scenarios which are respectively overly optimistic and overly
pessimistic:
triple-buffer
specific overhead here. All you need to do is totriple-buffer
.Therefore, consider these benchmarks’ timings as orders of magnitude of the best
and the worst that you can expect from triple-buffer
, where actual performance
will be somewhere inbetween these two numbers depending on your workload.
On an Intel Core i3-3220 CPU @ 3.30GHz, typical results are as follows:
This crate is distributed under the terms of the MPLv2 license. See the LICENSE
file for details.
More relaxed licensing (Apache, MIT, BSD…) may also be negociated, in
exchange of a financial contribution. Contact me for details at
knights_of_ni AT gmx DOTCOM.