Chapter 10 · translating real .NET code
A big C#/.NET codebase leans on a handful of shared-state primitives over and over: ConcurrentDictionary, Interlocked, lock, Lazy<T>, SemaphoreSlim, CancellationToken. Each has a direct Rust counterpart. This chapter is the cookbook for porting them.
Here's the whole map up front; the rest of the chapter works through the common ones with side-by-side code.
| .NET (what you reach for) | Rust counterpart | std or crate? |
|---|---|---|
Interlocked.Increment / Exchange / CompareExchange | AtomicU64 etc. (fetch_add / swap / compare_exchange) | std |
lock (obj) { } | Mutex<T> | std (or parking_lot) |
ReaderWriterLockSlim | RwLock<T> | std (or parking_lot) |
ConcurrentDictionary<K,V> | DashMap<K,V>, or Arc<RwLock<HashMap>> | dashmap / std |
Lazy<T> | LazyLock<T> / OnceLock<T> | std (or once_cell) |
SemaphoreSlim | Semaphore | tokio |
volatile / Volatile.Read/Write | atomic load/store with an Ordering | std |
CancellationToken | CancellationToken + select! | tokio_util / tokio |
Many of these primitives exist in C# because any reference can be mutated from any thread, so you bolt on a lock to make that safe. Rust's borrow checker (ch. 2) already forbids shared mutation at compile time, so a lot of code that needs a lock in C# needs none in Rust. You reach for the tools below only at genuine cross-thread sharing points — and when you do, the type system makes you do it correctly.
Atomic counters and flags map straight onto std::sync::atomic. The one new thing is that Rust asks you to name the memory ordering; C#'s Interlocked is always fully sequenced, whereas Rust lets you say "this counter doesn't coordinate with other data, so the cheapest ordering is fine."
private long _requests;
// hot path, many threads
Interlocked.Increment(ref _requests);
long n = Interlocked.Read(ref _requests);
use std::sync::atomic::{AtomicU64, Ordering};
static REQUESTS: AtomicU64 = AtomicU64::new(0);
REQUESTS.fetch_add(1, Ordering::Relaxed);
let n = REQUESTS.load(Ordering::Relaxed);
Interlocked.Exchange → .swap(v, ordering); Interlocked.CompareExchange → .compare_exchange(current, new, ok, fail); Interlocked.Add → .fetch_add. Ordering::Relaxed is correct for standalone counters and metrics. Use a stronger ordering (Acquire/Release, or SeqCst) only when the atomic is gating access to other data — that's also exactly where C# would have needed volatile, which here is just an atomic load/store with the right Ordering rather than a separate keyword.
The everyday lock statement becomes Mutex<T>, with one structural upgrade: in Rust the mutex owns the data it protects, so there's no way to touch the data without locking.
private readonly object _lock = new();
private int _ticks;
void Bump() {
lock (_lock) { _ticks++; }
}
use std::sync::Mutex;
let ticks = Mutex::new(0);
// the i32 lives INSIDE the mutex
*ticks.lock().unwrap() += 1;
In C#, the lock object and the field it guards are linked only by convention — nothing stops a stray read of _ticks outside the lock, and that's a classic source of heisenbugs. Rust's Mutex<T> hands you the data only through .lock(), so "forgot to take the lock" is a compile error. The .unwrap() deals with poisoning (a thread panicked mid-lock); many codebases use parking_lot::Mutex, which is faster and non-poisoning, so .lock() just returns the guard. To share the mutex across threads, wrap it in Arc (ch. 2): Arc<Mutex<T>> — the workhorse you saw there.
There's no concurrent hash map in std, but the dashmap crate is a close drop-in: internally sharded, no single global lock, with an entry(...).or_insert_with(...) that mirrors GetOrAdd.
static readonly ConcurrentDictionary<string, Session>
Cache = new();
var s = Cache.GetOrAdd(id, _ => new Session());
Cache.TryRemove(id, out _);
use dashmap::DashMap;
use std::sync::LazyLock;
static CACHE: LazyLock<DashMap<String, Session>>
= LazyLock::new(DashMap::new);
let s = CACHE.entry(id).or_insert_with(Session::new);
CACHE.remove(&id);
If you'd rather stay in the standard library, Arc<RwLock<HashMap<K,V>>> works: .read() for lookups, .write() for inserts. DashMap tends to win when many threads read and write across many keys — the cache/registry shape you see most.
A pattern that shows up for batching: atomically replace the entire dictionary with a fresh one and process the old contents (Interlocked.Exchange(ref _pending, new())). With a std map behind a lock, that's std::mem::take — it moves the contents out and leaves an empty map in place, no clone.
ConcurrentDictionary<long, byte> _pending = new();
var drained =
Interlocked.Exchange(ref _pending, new());
let pending = Mutex::new(HashMap::new());
let drained =
std::mem::take(&mut *pending.lock().unwrap());
The thread-safe lazy singleton — extremely common for managers, caches, and compiled regexes — is LazyLock<T> in std (Rust 1.80+). Like Lazy<T>, the initializer runs exactly once on first access.
static readonly Lazy<Manager> Instance =
new(() => new Manager());
// first .Value runs the factory, once
Manager m = Instance.Value;
use std::sync::LazyLock;
static INSTANCE: LazyLock<Manager> =
LazyLock::new(|| Manager::new());
// first deref runs the closure, once
let m: &Manager = &INSTANCE;
On Rust before 1.80, the same thing lives in the once_cell crate (once_cell::sync::Lazy) or the older lazy_static! macro — you'll see all three in the wild. When you want "set exactly once, sometime later" rather than "compute on first read," use OnceLock<T> (its .get_or_init(...) is also a fine GetOrAdd for a single value).
For throttling async work — limit N concurrent operations, or a count-1 gate around a lazy load — tokio::sync::Semaphore is the match. The ergonomic difference: there's no try/finally to Release(), because the permit releases itself when dropped.
private readonly SemaphoreSlim _gate = new(1, 1);
await _gate.WaitAsync(ct);
try {
// critical async section
} finally {
_gate.Release();
}
use tokio::sync::Semaphore;
let gate = Semaphore::new(1);
let permit = gate.acquire().await.unwrap();
// critical async section
// permit auto-released when it drops — no finally
The permit is an RAII guard — chapter 2's "drop = Dispose," applied to a lock release. It frees on the way out of scope, including on early return or panic, so the finally is structural rather than something you remember to write. A count-1 semaphore is also how you'd express an async-aware mutex; if that's all you need, tokio::sync::Mutex says it more directly.
The most common pattern in both codebases — a background loop that runs until shutdown — ports cleanly. tokio_util even keeps the name CancellationToken, and select! (ch. 6's Task.WhenAny) is how you race real work against the shutdown signal.
protected override async Task ExecuteAsync(
CancellationToken stop) {
while (!stop.IsCancellationRequested) {
await DoWorkAsync(stop);
await Task.Delay(1000, stop);
}
}
use tokio_util::sync::CancellationToken;
async fn run(stop: CancellationToken) {
loop {
tokio::select! {
_ = stop.cancelled() => break,
_ = do_work() => {}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
token.cancel() ≈ cts.Cancel(), and token.cancelled().await ≈ observing the token. The real shift: because futures are lazy and droppable (ch. 6), dropping a future cancels the work it represented — so select! racing do_work() against cancelled() abandons the work cleanly the moment shutdown wins. C# tasks, once started, can't be torn down like that; you have to thread the token everywhere and cooperate.
So you know what needs a cargo add:
| In the standard library | External crate |
|---|---|
atomics, Mutex, RwLock, OnceLock, LazyLock | dashmap — concurrent map (ConcurrentDictionary) |
mpsc channel (basic) | parking_lot — faster, non-poisoning locks |
thread, Arc | tokio — async runtime, Semaphore, async Mutex, channels |
crossbeam — lock-free queues & richer channels | |
once_cell — Lazy/OnceCell for older toolchains |
Every .NET concurrency primitive you depend on has a Rust home, and the translation is usually one-to-one. The recurring upgrade is that Rust ties the lock to the data, releases via drop instead of finally, and — thanks to ownership — lets you delete a lot of synchronization that only existed to make shared mutation safe in the first place.