Chapter 10 · translating real .NET code

Your concurrency patterns, line for line.

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.

10.1The lay of the land

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 counterpartstd or crate?
Interlocked.Increment / Exchange / CompareExchangeAtomicU64 etc. (fetch_add / swap / compare_exchange)std
lock (obj) { }Mutex<T>std (or parking_lot)
ReaderWriterLockSlimRwLock<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)
SemaphoreSlimSemaphoretokio
volatile / Volatile.Read/Writeatomic load/store with an Orderingstd
CancellationTokenCancellationToken + select!tokio_util / tokio
First, a reframe

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.

10.2Interlocked → atomic types

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."

C#

private long _requests;

// hot path, many threads
Interlocked.Increment(ref _requests);
long n = Interlocked.Read(ref _requests);

Rust

use std::sync::atomic::{AtomicU64, Ordering};

static REQUESTS: AtomicU64 = AtomicU64::new(0);

REQUESTS.fetch_add(1, Ordering::Relaxed);
let n = REQUESTS.load(Ordering::Relaxed);
Method map

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.

10.3lock (obj) {} → Mutex<T>

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.

C#

private readonly object _lock = new();
private int _ticks;

void Bump() {
    lock (_lock) { _ticks++; }
}

Rust

use std::sync::Mutex;

let ticks = Mutex::new(0);

// the i32 lives INSIDE the mutex
*ticks.lock().unwrap() += 1;
Why this is safer

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.

10.4ConcurrentDictionary → DashMap (or RwLock<HashMap>)

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.

C#

static readonly ConcurrentDictionary<string, Session>
    Cache = new();

var s = Cache.GetOrAdd(id, _ => new Session());
Cache.TryRemove(id, out _);

Rust

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.

The "swap the whole map" drain

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.

C#

ConcurrentDictionary<long, byte> _pending = new();

var drained =
    Interlocked.Exchange(ref _pending, new());

Rust

let pending = Mutex::new(HashMap::new());

let drained =
    std::mem::take(&mut *pending.lock().unwrap());

10.5Lazy<T> → LazyLock / OnceLock

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.

C#

static readonly Lazy<Manager> Instance =
    new(() => new Manager());

// first .Value runs the factory, once
Manager m = Instance.Value;

Rust

use std::sync::LazyLock;

static INSTANCE: LazyLock<Manager> =
    LazyLock::new(|| Manager::new());

// first deref runs the closure, once
let m: &Manager = &INSTANCE;
Versions & cousins

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).

10.6SemaphoreSlim → tokio::sync::Semaphore

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.

C#

private readonly SemaphoreSlim _gate = new(1, 1);

await _gate.WaitAsync(ct);
try {
    // critical async section
} finally {
    _gate.Release();
}

Rust

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
Equivalence

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.

10.7CancellationToken → tokio cancellation

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.

C#

protected override async Task ExecuteAsync(
    CancellationToken stop) {
  while (!stop.IsCancellationRequested) {
    await DoWorkAsync(stop);
    await Task.Delay(1000, stop);
  }
}

Rust

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;
  }
}
The mapping

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.

10.8What's std vs. a crate

So you know what needs a cargo add:

In the standard libraryExternal crate
atomics, Mutex, RwLock, OnceLock, LazyLockdashmap — concurrent map (ConcurrentDictionary)
mpsc channel (basic)parking_lot — faster, non-poisoning locks
thread, Arctokio — async runtime, Semaphore, async Mutex, channels
crossbeam — lock-free queues & richer channels
once_cellLazy/OnceCell for older toolchains
Bottom line

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.