Howdy, and Welcome!
Introduction
I have been writing Rust exclusively for over a year now and it has been a fantastic year of ideas and projects. Up to this point in my career, I have been using .Net with C#, JavaScript, and SQL. Rust and .Net are fun, but they certainly have some differences. These differences and my experience looking for the next chapter of my professional life lead me to these posts!
An Interview Derailed
I was sitting in a conference room under a barrage of interview questions from three senior engineers. I got stumped. No real surprise it is an interview and part of it is always seeing your potential new dev buddy squirm. However, this question was something I should have been able to answer. The subject was about sharing data between threads in .Net. If I had have taken a little time before this interview to review concurrent programming in C# I would have been fine. I have done enough of it. The job post had specified it as a requirement after all, but I had been busy with a fun programming project. The issue at hand was about what is locked in .Net. It threw me for a loop because locking in Rust and .Net are different. I couldn't remember the answer for .Net it was a major brain fart. Oh well it was still a good interview and that happens.
Bettering Myself
My interview blunder lead me to the creation of this series of posts about synchronization primitives in C# and Rust. This is not a comparison. There won't be any benchmarks or proclamations superiority. I love both and will continue to use both in the future. We are simply looking at them together and working to cement my understanding of both. Our first primitive will be the Mutex!
The Mutex
A mutex in both Rust and .Net is a mechanism for synchronizing threads. In .Net you can use a mutex to define a critical portion of code allowing only 1 thread at a time to execute it. In Rust, a mutex represents data and prevents it's modification unless the executing thread has acquired a lock. In Rust when using a mutex to lock shared data you also provide an atomic smart pointer to that memory. Otherwise, that data would go out of scope after the context of the new thread completes. .Net does this as a bit of magic and we will look at that in the samples later. Our goal is to create a counting app in both C# and Rust to illustrate data shared between threads in both languages.
C# Mutex
var MyMutex = new Mutex();
Rust Mutex
let counter: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
We can start to see differences off the bat. In C# we are declaring a Mutex exists, but what it guards will not be apparent until we look at the code definition. In Rust, we are declaring a smart pointer to a mutex that guards a signed 32 bit integer.
.Net Example
... int count = 0; List<Thread> handles = new List<Thread>(); for (var i = 0; i < 4; i++) { var thread = new Thread(new ThreadStart(() => { while (true) { MyMutex.WaitOne(); if (count > 99) { MyMutex.ReleaseMutex(); break; } else { Console.WriteLine($"{Thread.CurrentThread.Name}! - {count}"); count += 1; MyMutex.ReleaseMutex(); } Thread.Sleep(TimeSpan.FromSeconds(0.01)); } })) {Name = $"thread:{i}"}; threads.Add(thread); } ...
Here we are creating a list of thread handles. There is magic in this code. Normally in C#, you would expect an integer to be passed by value with the result of an infinite loop because count declared in Main would never change. However, .Net creates a class for us based on the anonymous functions closure. All of the variables declared in the enclosing function will be available to the anonymous function as properties on that class. As a result, we have a scenario similar to what we created in Rust where we wrapped our count variable in a smart pointer and all of our threads use that reference.
The next thing to take note of is the MyMutex.WaitOne() and MyMutex.ReleaseMutex() methods. These calls define a critical section of code that you only want to allow one thread at a given time access to. Here we call it before our check of count > 99 since count is a reference to a property on a class you could have threads modify this as you read it resulting in undefined behavior. We then release the mutex. The sleep in C# is unnecessary, but there for symmetry with the Rust code and I will explain why with that code. One of the differences between Rust and C# at this point to note is that C# won't complain if you were to read count without first acquiring a lock on it. An example of getting concurrency a bit wrong could be something like:
.Net Bad Read Example
// MyMutex.WaitOne(); if (count > 99) { // MyMutex.ReleaseMutex(); break; } else { MyMutex.WaitOne(); Console.WriteLine($"{Thread.CurrentThread.Name}! - {count}"); count += 1; MyMutex.ReleaseMutex(); } //Thread.Sleep(TimeSpan.FromSeconds(0.01));
Result:
thread:3! - 99
thread:1! - 100
thread:2! - 101
thread:0! - 102
Done
This is an unexpected result! It is also easy to do by accident especially if your reference exists in other areas of your code base.
Rust Example
... let count: Arc<Mutex<i32>> = Arc::new(Mutex::new(0)); let mut handles = vec![]; for i in 0..4 { let counter = Arc::clone(&counter); let handle = thread::Builder::new() .name(format!("thread:{}", i)) .spawn(move || { loop { { let mut count = counter.lock().unwrap(); if *count > 99 { break; } let name = thread::current(); println!("{}! - {}", name.name().unwrap(), *count); *count += 1; } thread::sleep(Duration::from_secs_f32(0.01)); } }).expect("Failed to create thread"); handles.push(handle); } ...
Something to note is that we keep a lock on the count variable as long as it is in scope. In this case, to release our lock before the thread sleeps we add scope with curly brackets. A contrast between these languages is how C# creates references for us leveraging .Net's garbage collection while Rust releases references for us with lifetimes. I do believe Rust's release of the reference is easier to reason about than how a value type became a shared reference.
Now finally we sleep for a bit. This was done so that the output between C# and Rust would behave identically. Without the sleep Rusts threads execute completely randomly. C# on the other hand I believe because .Net schedules the threads will execute our 4 threads in a random order that repeats. Rust on the other hand creates OS threads and leaves the scheduling up to the OS. Now let's do something bad in Rust!
Rust Bad Read Example
... let count: Arc<Mutex<i32>> = Arc::new(Mutex::new(0)); let mut handles = vec![]; for i in 0..4 { let counter = Arc::clone(&counter); let handle = thread::Builder::new() .name(format!("thread:{}", i)) .spawn(move || { loop { let mut sub_cnt = 0; { sub_cnt = *counter.lock().unwrap(); } if sub_cnt > 99 { break; } let mut count = counter.lock().unwrap(); let name = thread::current(); println!("{}! - {}", name.name().unwrap(), *count); *count += 1; } }).expect("Failed to create thread"); handles.push(handle); } ...
I had to put more effort into this, but happily the result:
thread:1! - 98
thread:3! - 99
thread:0! - 100
thread:2! - 101
Done
I like that I had to think about how to do this wrong in Rust and I also like the way Rust declares a smart pointer over it's mutex. This makes me aware that no matter where in the code base I see the reference I need to think about concurrency.
Summary
As a method of thread synchronization on the .Net platform, you can use a mutex to control access to a critical portion of code. Rusty code achieves synchronization by locking references to memory. As always there are a lot of ways to have fun (Dwarf Fortress "Fun") with concurrency in any language.
Also somewhere in this post I went off on a tangent writing code to do something wrong! Hopefully it is fun for someone to read as I enjoyed writing it.
Final C# Counter
C# Output
thread:3! - 0
thread:2! - 1
thread:0! - 2
...
thread:0! - 97
thread:3! - 98
thread:1! - 99
Done
Final Rust Counter
Rust Output
thread:0! - 0
thread:3! - 1
thread:2! - 2
...
thread:1! - 97
thread:0! - 98
thread:2! - 99
Done