I am reading chapter 29 of OS: the three easy pieces, which is about concurrent data structures. The first example these is an approximate counter. This data structure increments numbers by using a global Mutex and several local Mutexes with local counters. When a local counter hits a threshold, it grabs the global mutex and flushes its local counter number to the global counter.
This chapter shows code in C language. Since I'm practising Rust language, I implemented the concurrent data structure in Rust. When I see my code, I feel that there must be room to improve my code quality and performance.
Notes:
The book shows a graph that approximate counter scales very well, but in my test code, performance of my approximate counter was not good.
Because mutex is used in the original C code, I used mutex in the Rust code. but the rust programming language book in the official site, shows an example of using
std::sync::mpsc
module. Would it be better to use message rather than mutex to avoid mistakes made by humans?
Original code in OS book:
typedef struct __counter_t { int global; // global count pthread_mutex_t glock; // global lock int local[NUMCPUS]; // per-CPU count pthread_mutex_t llock[NUMCPUS]; // ... and locks int threshold; // update frequency } counter_t; // init: record threshold, init locks, init values // of all local counts and global count void init(counter_t *c, int threshold) { c->threshold = threshold; c->global = 0; pthread_mutex_init(&c->glock, NULL); int i; for (i = 0; i < NUMCPUS; i++) { c->local[i] = 0; pthread_mutex_init(&c->llock[i], NULL); } } // update: usually, just grab local lock and update // local amount; once local count has risen ’threshold’, // grab global lock and transfer local values to it void update(counter_t *c, int threadID, int amt) { int cpu = threadID % NUMCPUS; pthread_mutex_lock(&c->llock[cpu]); c->local[cpu] += amt; if (c->local[cpu] >= c->threshold) { // transfer to global (assumes amt>0) pthread_mutex_lock(&c->glock); c->global += c->local[cpu]; pthread_mutex_unlock(&c->glock); c->local[cpu] = 0; } pthread_mutex_unlock(&c->llock[cpu]); } // get: just return global amount (approximate) int get(counter_t *c) { pthread_mutex_lock(&c->glock); int val = c->global; pthread_mutex_unlock(&c->glock); return val; // only approximate! }
My Counter traits in counter.rs module.
use std::fmt;
use std::sync::Mutex;
pub struct Counter {
value: Mutex<i32>
}
impl Counter {
pub fn new() -> Self {
Counter { value: Mutex::new(0)}
}
pub fn test_and_increment(&self) -> i32 {
let mut value = self.value.lock().unwrap();
*value += 1;
if *value >= 250 {
let old = *value;
*value = 0;
return old;
}
else {
return 0;
}
}
pub fn get(&self) -> i32 {
*(self.value.lock().unwrap())
}
pub fn add(&self, value: i32) {
*(self.value.lock().unwrap()) += value;
}
}
impl fmt::Display for Counter {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", *self.value.lock().unwrap())
}
}
pub struct ApproximateCounter {
value: Counter,
local_counters: [Counter; 4]
}
impl ApproximateCounter {
pub fn new() -> Self {
ApproximateCounter {
value: Counter::new(),
local_counters: [Counter::new(), Counter::new(), Counter::new(), Counter::new()]
}
}
pub fn increment(&self, i: usize) {
let local_value = self.local_counters[i].test_and_increment();
if local_value > 0 {
self.value.add(local_value);
}
}
pub fn get(&self) -> i32 {
self.value.get()
}
}
main.rs
use std::time::Instant;
use std::sync::Arc;
use std::thread;
mod counter;
const NUM_TO_LOOP: i32 = 1000000000;
fn main() {
{
let now = Instant::now();
let counter = counter::Counter::new();
let mut result = 0;
for _ in 0..(NUM_TO_LOOP) {
result += counter.test_and_increment();
}
let elapsed = now.elapsed();
println!("{}", result);
println!("[one thread + thread-safe counter] Elapsed: {:.2?}", elapsed);
}
{
let now = Instant::now();
let counter = Arc::new(counter::ApproximateCounter::new());
let mut threads = Vec::new();
for i in 0..4 {
let c_counter = counter.clone();
threads.push(thread::spawn(move || {
for _ in 0..(NUM_TO_LOOP / 4) {
c_counter.increment(i);
}
}));
}
for thread in threads {
thread.join().unwrap();
}
println!("{}", counter.get());
let elapsed = now.elapsed();
println!("[four threads + ApproximateCounter]: Elapsed: {:.2?}", elapsed)
}
}
Result of running main.rs:
1000000000
[one thread + thread-safe counter] Elapsed: 146.02s
1000000000
[four threads + ApproximateCounter]: Elapsed: 104.38s
After following the answer by @Matthieu M. I got following result.
Using Mutex without false sharing
1000000000
[one thread + thread-safe counter] Elapsed: 144.44s
1000000000
[four threads + ApproximateCounter]: Elapsed: 77.16s
Using Atomic with false sharing
999999813
[one thread + thread-safe counter] Elapsed: 35.47s
999999060
[four threads + ApproximateCounter]: Elapsed: 36.40s
Using atomic without false sharing
999999813
[one thread + thread-safe counter] Elapsed: 46.65s
999999060
[four threads + ApproximateCounter]: Elapsed: 28.29s
Still need to figure out why Atomic do not give 1000000000 in one thread test.
counter.test_and_increment()
only returns non-zero when the count is above a threshold. So unless you massively fluke and hit the threshold on exactly iteration number 1,000,000,000, it won’t return the final count. Or, put another way: right after the loop, tryresult += counter.get();
. \$\endgroup\$