Group
← Back
Part 5 of 6 · Rust for Swift devs

Rust for Swift devs - Concurrency

Updated September 15, 2025 · originally November 12, 2021RustSwiftSystems

Once two threads run against the same memory, the question is no longer "is this reference valid?". It's "can these threads touch this value at the same time without tearing it?". Data races, the canonical bug class, have no runtime fix; by the time you notice, the read or the write has already happened. Both Rust and Swift answer this at compile time, but they got there on different paths.

Swift's came through the concurrency model that landed in 5.5 (September 2021): async/await in the language itself, Task as the unit of work, actor as a first-class primitive for shared mutable state, and Sendable as the marker that gates what may cross a concurrency boundary. Swift 6 (2024) made Sendable checking strict by default and turned a lot of previously-warning conditions into errors. Rust's answer falls out of the ownership rules from the previous posts. Two auto traits, Send and Sync, label the types that can move between threads and the ones that can be shared between threads. Arc<T>, Mutex<T>, RwLock<T>, the atomics, and channels compose on top, and the single-thread borrow rules carry across thread boundaries unchanged. async/await stabilised in Rust 1.39 (November 2019), Tokio reached 1.0 a year later, scoped threads followed in 2022, and async fn in traits in late 2023, which closed the last big ergonomic gap.

A Swift developer already knows what Sendable, Task, and actor do. The work of this post is mapping each onto its Rust counterpart, calling out where the mapping is exact, where it breaks down, and what the breakages say about the underlying type-system choices.

Send, Sync, and Sendable

The Rust side opens with two marker traits, Send and Sync. They are auto traits: traits the compiler implements automatically for any type whose fields all implement them. Neither has a method body. Both are pure compile-time labels the borrow checker reads off a type to decide what it is allowed to do.

The two label different operations:

  • T: Send means it is safe to move a T from one thread to another.
  • T: Sync means it is safe for two threads to share a T through shared references at the same time. Equivalently, T: Sync if and only if &T: Send.

The Swift counterpart is a single protocol, Sendable. A type conforms to Sendable when its values may safely cross concurrency boundaries (Tasks, actor methods, isolation domains). Conformance is mostly inferred, just as it is for Send and Sync in Rust. A struct of Sendable fields is automatically Sendable; an enum whose associated values are Sendable is automatically Sendable; a final class with immutable stored properties of Sendable type can be Sendable.

So why one protocol on the Swift side and two traits on the Rust side? Because T: Sync is defined as &T: Send, and that definition only matters in a language where &T is a first-class type values can have. Rust separates the two so a type can be "movable across threads but not shareable" (every Cell<T> and RefCell<T>) or "shareable but not movable" (a MutexGuard<'_, T> from later in this chapter, which is pinned to its acquiring thread). Swift has no value-level shared reference. For value types you always send a copy, and the question never comes up. For class types you always send a reference, and Sendable covers both situations at once. Swift folds Send and Sync together because there is no syntax that would force them apart.

The instructive cases are the types that don't qualify, because each one names the bug class the marker prevents.

Rc<T>, the non-atomic reference counter from the ownership post, is neither Send nor Sync. Its reference count is a plain integer increment. If two threads cloned the same Rc<T> at the same instant, both writes could land on top of each other, one increment would vanish, and the count would drop one short of where it should be, freeing the value early. Arc<T> swaps the non-atomic counter for an atomic one and recovers both markers. The Swift parallel is exact: an ordinary class with var properties does not conform to Sendable, and Swift 6 will refuse to capture it across a concurrency boundary unless you mark it @unchecked Sendable (and take responsibility for its thread-safety) or wrap its state in an actor.

Cell<T> and RefCell<T>, the interior-mutability types from the ownership post, are Send but not Sync. You can move them between threads, because moving transfers exclusive access. You cannot share them, because their whole purpose is to mutate through a shared reference, and two threads holding &Cell<T> could race on the cell's contents. Swift has no exact analog, since it doesn't separate "interior mutability through a shared reference" from "shared mutable state on a class". A Swift class with a var property is the same shape as Rc<RefCell<T>> from a thread-safety perspective: usable freely on one isolation domain, refused at the boundary unless you wrap or annotate it.

MutexGuard<'_, T>, the handle that locks return and that releases the lock when dropped, is Sync but not Send. Some operating-system lock primitives require that the same thread which acquired the lock also releases it, so the guard is pinned to its thread. There is no Swift analog, because Swift's lock APIs don't return guard handles in this form; the closest equivalent is NSLock.withLock { ... }, which keeps the guard implicit and tied to the closure body.

Raw pointers, *const T and *mut T, are neither Send nor Sync. The compiler knows nothing about what they point at, so it refuses to make a thread-safety claim it can't substantiate. Library types built on raw pointers (Arc<T>, Box<T>, the collections) supply Send and Sync impls by hand, in unsafe blocks where the author takes responsibility for the property. Swift's analog is @unchecked Sendable on a class: an explicit "I have synchronised this myself, trust me" override that the compiler can't derive.

The check happens at the API boundary. std::thread::spawn requires its closure to be Send + 'static, and the closure inherits the Send-ness of every value it captures. Try to capture an Rc<T> and the compiler refuses to compile the call:

use std::rc::Rc;
use std::thread;

fn main() {
    let counter = Rc::new(0u32);
    thread::spawn(move || {
        println!("{counter}");
    });
}
error[E0277]: `Rc<u32>` cannot be sent between threads safely
  --> src/main.rs:6:5
   |
6  |     thread::spawn(move || {
   |     ^^^^^^^^^^^^^ `Rc<u32>` cannot be sent between threads safely
   |
   = help: within `[closure@...]`, the trait `Send` is not implemented for `Rc<u32>`
note: required because it's used within this closure

Swap the Rc for an Arc and the same code compiles, because Arc<T> is Send + Sync whenever T is. The Swift counterpart is the same shape with a non-Sendable class:

final class Counter {
    var value: Int = 0
}

let counter = Counter()
Task {
    print(counter.value)
}
error: capture of 'counter' with non-sendable type 'Counter' in a `@Sendable` closure

Mark the class @unchecked Sendable and add the synchronisation it implies, refactor it into an actor, or change var to let, and the program compiles. Either compiler rejects the racy program before it runs. The historical asymmetry is in defaults and scope, not correctness: Rust shipped Send and Sync from the start; Swift retrofitted Sendable onto an existing class system in 2021 and made the check strict by default in 2024. The bug class and the compile-time refusal look the same on both sides.

thread::spawn, scoped threads, and the 'static bound

The standard primitive for getting work onto a fresh OS thread is std::thread::spawn. Its signature reads:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

Two things stand out. The closure must be Send + 'static, and the return type must be Send + 'static. The Send requirement is the marker-trait check from the previous section: nothing the closure captures may be !Send. The 'static requirement is new: nothing the closure captures may be a reference that outlives the spawned thread.

'static shows up because spawn returns immediately, the thread runs in parallel, and the spawning function can return before the thread finishes. If the closure captured a reference into the caller's stack frame, that reference might outlive its referent. The compiler has no way to track when the spawned thread will finish, so the only conservative answer is that every captured reference must live for the entire program. That's what 'static means here.

The practical effect is that closures handed to spawn almost always need to be move closures, transferring ownership of captured values into the closure body rather than borrowing them:

use std::thread;

fn main() {
    let buffer = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("{:?}", buffer);
    });

    handle.join().unwrap();
}

Without move, the closure borrows buffer from main's stack frame, and the compiler refuses:

error[E0373]: closure may outlive the current function, but it borrows `buffer`,
              which is owned by the current function

The fix is one of two patterns: use move to transfer ownership, or wrap the data in Arc<T> and clone the Arc into the closure when several threads need shared read access.

std::thread::scope (stable in Rust 1.63, August 2022) is the structured-concurrency alternative. It introduces a scope that guarantees every thread spawned inside it has finished before the scope returns. Because the scope tracks the children's lifetime, the spawned closures can borrow from the surrounding stack frame:

use std::thread;

fn main() {
    let buffer = vec![1, 2, 3];

    thread::scope(|s| {
        s.spawn(|| println!("first: {:?}", buffer));
        s.spawn(|| println!("second: {:?}", buffer));
    });
    // both threads have joined here; buffer is still valid
}

The closures aren't move, they capture buffer by shared reference, and there is no 'static bound on them. The scope's drop implementation joins every spawned thread before the scope exits, which gives the compiler a static lifetime story it can check. The 'static requirement is replaced by a scope-bound lifetime.

Swift has the same two shapes at the language level. Task { ... } is the unstructured primitive: a child task that runs independently and may outlive the surrounding scope. withTaskGroup(of:body:) is the structured one: every child task added to the group finishes before the group's body returns.

let buffer = SomeBuffer()

Task {
    buffer.process()
}
// the task may still be running here; buffer kept alive by ARC
await withTaskGroup(of: Void.self) { group in
    let buffer = SomeBuffer()
    group.addTask { buffer.process() }
    group.addTask { buffer.process() }
}
// both child tasks finished; buffer dropped at scope exit

Neither Swift form has a 'static requirement, and the reason is that Swift doesn't need one. ARC keeps any captured class instance alive as long as the task holds a reference, so there's no use-after-free risk. Value types are copied into the task, so there's no aliasing risk either. The same problem (closures that may outlive their captures) is solved at runtime rather than in the type system. The price is per-capture refcount work and object lifetimes that extend further than the source might suggest; the benefit is that no 'static annotation has to appear in the API.

ConcernRustSwift
Spawn an independent unit of workthread::spawnTask { ... }
Spawn a scoped, joining unit of workthread::scope + s.spawnwithTaskGroup
Capture rulesSend + 'static (or scope-bounded)Sendable (via ARC)

The structured form is the better default in either language: tying the children's lifetime to the parent makes resource management predictable and rules out the "task still running after the function returned" class of bug. The unstructured form is the right choice when the spawned work genuinely needs to outlive the caller, which is rarer than it looks at first.

Locks, atomics, and the absence of actors

By 2025, Rust and Swift ship the same low-level synchronisation toolkit. Rust has Mutex<T>, RwLock<T>, the Atomic* types, and Arc<T> for shared ownership. Swift has Mutex<Value> and Atomic<T> in the Synchronization module (Swift 6.0, 2024), OSAllocatedUnfairLock, and class instances for shared ownership. The primitives map almost one-to-one. What differs is what sits on top.

Rust's API is built so the borrow checker does the policing. Mutex<T> owns the inner T directly. lock() returns a MutexGuard<'_, T> that derefs to &mut T and releases the lock on drop. The inner T is unreachable without going through the guard, the guard cannot escape its scope, and MutexGuard is !Send so it cannot leak across threads. Compose with Arc for shared ownership (Arc<Mutex<T>>), use RwLock<T> if reads dominate, or skip the lock entirely with an atomic for a primitive field. The one ergonomic wart is poisoning: a panic while holding the lock marks the mutex poisoned, future lock() returns Err, and the recovery decision is yours.

Swift's primitives have the same shape. Mutex<Value>.withLock { ... } runs a closure while holding the lock and releases on return; Atomic<T> exposes load, store, compareExchange, the usual set. The bugs a careful programmer can avoid are roughly the same on both sides.

The interesting question is why Swift added actor and Rust didn't. Swift's pre-2021 concurrency story (classes plus GCD plus manual locking) was easy to misuse: a class with var properties was shareable across threads by default, with no compile-time reminder that access needed synchronising, and the lock-and-data pair had to be wired up by hand at every call site. The actor abstraction bakes the contract into the type. An actor is a class whose stored properties are reachable only through awaited methods that serialise access, and the compiler refuses any other path. Actors exist to make a previously error-prone pattern correct by construction.

Rust never had the underlying problem. Mutex<T> wraps the data it protects, so the lock-and-data pair is one type and there's nowhere for them to drift apart. The only access path is through the guard, and the guard's lifetime and Send-ness are checked at compile time. Send and Sync already gate cross-thread sharing, and the borrow checker already prevents holding the guard incorrectly. The class of bugs an actor abstraction would prevent (forgetting to lock, racing on a class field, leaking a lock holder) is gone before any higher-level abstraction enters the picture, eliminated by the same ownership rules that handle single-thread memory. Rust does not have actors because it does not need them. The same memory model that made lifetimes work makes locks and lockless primitives safe and ergonomic on their own.

Channels and async streams

The library answer to "how do two threads coordinate without sharing memory" is the channel. The standard library ships std::sync::mpsc::channel, which gives a Sender<T> and a Receiver<T> joined by an unbounded queue:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel::<String>();

    thread::spawn(move || {
        tx.send(String::from("hello")).unwrap();
    });

    let msg = rx.recv().unwrap();
    println!("{msg}");
}

The send is a move. tx.send(msg) transfers ownership of msg from the sending thread to the queue, and rx.recv() transfers it out the other side. The move closure captures tx for the same reason thread::spawn did in the previous section: the channel handle has to outlive the spawning function. Because the message moves, T must be Send for the channel to work across threads, and that's exactly what the channel constructor requires.

Sender<T> is Clone, so multiple producers can share the sending end (the "mp" in "mpsc" is for multi-producer). The receiver is single (the "sc" is single-consumer). For bounded queues with back-pressure, mpsc::sync_channel(capacity) makes sends block when the queue is full. crossbeam::channel provides multi-producer multi-consumer variants and a select!-style API for the cases the standard library doesn't cover.

For async code, tokio::sync::mpsc gives the async-aware version: send().await yields to the executor when the channel is full instead of blocking the OS thread, and recv().await yields when empty. async_channel is the runtime-agnostic alternative for libraries that should not pin themselves to Tokio.

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(8);

    tokio::spawn(async move {
        tx.send(String::from("hello")).await.unwrap();
    });

    while let Some(msg) = rx.recv().await {
        println!("{msg}");
    }
}

Channels are the alternative to Arc<Mutex<T>>-style shared state. Rather than have two threads racing to mutate the same value, each thread owns its own state and exchanges values through the channel. Either pattern is correct in Rust; channels are the more readable choice when the threads have a clear producer-consumer or pipeline shape, and shared state is the more direct choice when the data is genuinely shared rather than handed off.

Swift's AsyncStream<T> is the closest standard-library counterpart. It produces values asynchronously through a continuation and is consumed via for await:

let stream = AsyncStream<String> { continuation in
    Task {
        continuation.yield("hello")
        continuation.yield("world")
        continuation.finish()
    }
}

for await msg in stream {
    print(msg)
}

AsyncStream is single-producer, single-consumer, which makes it closer to a Tokio mpsc with a single sender than to a multi-producer channel. The AsyncSequence protocol generalises this to "anything that yields values asynchronously" and underlies the for await syntax. For the multi-producer case, AsyncChannel from the swift-async-algorithms package fills the same niche tokio::sync::mpsc does on the Rust side.

Back-pressure is explicit on both sides, just at different ends. On the Swift side it sits at the consumer: for await drives the producer, so a slow consumer backs the producer's continuation up. On the Rust side it sits at the producer: bounded channels make send block (sync) or await (async) when the buffer is full. Both let you choose the policy at the channel constructor.

The shared property worth pulling out: in either language, the message moves into the channel and out the other side, never aliased. Send and Sendable guarantee this at the type level. Channels are where the "share by moving ownership" idiom is most visible, and they tend to make concurrent code substantially easier to reason about than the equivalent shared-state version.

async/await today

Both languages compile async functions to state machines. An async fn in Rust returns an opaque impl Future<Output = T>; an async method in Swift returns a value driven by the runtime. The await keyword on either side marks the suspension points, and the compiler generates the state machine that holds the locals across them.

The split is in what the language ships. Rust stabilised async/await in 1.39 (November 2019) but didn't ship an executor with the standard library. The Future trait and .await syntax are in std; the thing that polls futures and schedules them onto threads is a separate library. Tokio reached 1.0 in December 2020 and has been the de-facto runtime for server and async-application Rust ever since. async-std offered an alternative until it was archived in 2024, smol fills a small-footprint niche, and embassy is the embedded answer, a no-std, single-core scheduler suited to microcontrollers.

Swift took the opposite design choice. Swift Concurrency (5.5, September 2021) shipped a cooperative thread pool with the runtime. Writing Task { ... } works out of the box with no executor selection and no dependency to add. The pool is global, sized to the core count, and runs every task that doesn't have explicit isolation.

The visible cost on the Rust side is a #[tokio::main] attribute on main and a Cargo dependency on the runtime. Library code that calls .await is in principle generic over the runtime, but in practice it often reaches for Tokio-specific types, which propagates the runtime choice across the dependency graph. The benefit is that Rust async runs in environments where Swift's cannot (kernel modules, embedded, no-std), and that the runtime is a normal library you can swap, profile, and replace.

async fn in traits stabilised in Rust 1.75 (December 2023), closing the most painful ergonomic gap that had stood since the original async/await stabilisation. Before 1.75, a trait with an async method had to either return Pin<Box<dyn Future<Output = T> + Send>> by hand or rely on the async-trait macro to do the same thing through procedural macros. After 1.75, the trait method is just async fn, and the compiler handles the state-machine return type. The change brought async-friendly trait design closer to the Swift counterpart, where async methods on protocols are just async methods with no manual return-type wrapping.

A std::sync::Mutex lock held across a .await is a footgun in practice. The OS thread that holds the lock is suspended along with the future, and the lock stays held until the future is polled to completion. In a multi-threaded executor, that can block other tasks for arbitrarily long; in a single-threaded executor, it can deadlock outright. The async-aware alternative is tokio::sync::Mutex, whose lock().await yields the executor while waiting and whose guard is Send, so the lock can be released on a different thread than the one that took it. The cost is that tokio::sync::Mutex is slower than std::sync::Mutex for the contention-free case, so the right rule of thumb is: prefer the std mutex when you can guarantee the critical section never crosses an .await.

Swift's actor finesses this by making cooperative scheduling implicit. An await inside an actor method releases isolation while the awaited future pends, so other tasks waiting on the actor can run during that window. The actor abstraction is the language-level expression of "do not block while holding the resource"; the Rust idiom is the same in shape, with the rule enforced by the programmer choosing tokio::sync::Mutex over std::sync::Mutex when the lock is held across .await.

The shared shape: futures are types in both languages, schedulers run them, and .await is a suspension point. The visible difference is whether the scheduler is part of the language. In Swift it is. In Rust it's a library you choose, and the language itself takes no position on which one.

Pin and !Send futures

The state machine an async fn compiles to has one property that distinguishes it from an ordinary struct: the locals it holds may include references to other locals it also holds. A function body like:

async fn fetch(url: &str) -> Vec<u8> {
    let buffer = vec![];
    process(&buffer).await;  // borrow of `buffer` held across .await
    buffer
}

compiles to a state machine whose paused state holds both buffer and &buffer. The reference points into the same struct that owns the buffer. If that struct moves in memory after polling has begun, the reference is left dangling.

Rust solves this with Pin<&mut T>, a wrapper that asserts the pointed-to T will not move from this point on. Once a future has been polled, the executor wraps the future in a Pin<&mut Future> for every subsequent poll, and the future's implementation is allowed to assume its own fields will not be relocated. Pin is a compile-time discipline rather than a runtime cost: the wrapper type prevents the unsafe operations (like mem::swap) that would move the value.

For most code, this is invisible. tokio::spawn takes care of pinning, Box::pin wraps a future into a heap-allocated and pinned form, and the pin! macro pins on the stack. Pin shows up in source mostly at the boundaries of executor implementation and in a handful of generic combinators.

The user-facing question is !Send futures. The state machine inherits the Send-ness of every value it holds, and the values it holds include anything alive across an .await. A future that holds an Rc<T> clone or a MutexGuard across a suspension point is !Send:

use std::rc::Rc;

async fn bad() {
    let data = Rc::new(42);
    some_async_op().await;  // Rc held across .await
    println!("{data}");
}

The body compiles, but spawning the resulting future on a multi-threaded executor doesn't:

error: future cannot be sent between threads safely
   = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `Rc<i32>`
note: future is not `Send` as this value is used across an await
  --> src/main.rs:3:21
   |
2  |     let data = Rc::new(42);
   |         ---- has type `Rc<i32>` which is not `Send`
3  |     some_async_op().await;
   |                     ^^^^^ await occurs here, with `data` maybe used later

Tokio's default executor migrates tasks between worker threads between polls, so anything the state machine carries has to be safe to send. The fix is one of two things: drop the non-Send value before the .await so it doesn't survive into the suspended state, or use a single-threaded executor (tokio::task::LocalSet, or a single-thread runtime) where futures stay on one thread and Send isn't required.

The Swift parallel is actor isolation. A Task running on an actor is pinned to that actor's executor for its lifetime. Inside the actor, the task can hold non-Sendable values across await points because every resumption re-enters the same isolation domain; the Sendable check applies at the actor's boundary, not within it. In Rust terms, an actor-bound task is structurally a !Send future running on a single-threaded executor; the rules line up exactly even though the surface syntax is different.

The cost on the Swift side is that actor-bound tasks can't be load-balanced across cores. The cost on the Rust side is the same: a LocalSet runs every task it owns on one thread. Both languages let you opt in to single-thread execution when you need to hold non-thread-safe state across suspension points; both pay the same scheduling cost for it.

Composition versus bundling

Both languages reject racy programs before they run. The difference is where the gate sits and what it's built on top of.

Rust's gate is derived from ownership. The borrow rules that prevent aliasing in single-threaded code extend across thread boundaries through Send and Sync: a value may move to another thread iff it is Send, and a reference may be shared across threads iff the pointee is Sync. Library primitives compose on top: Arc<T> for shared ownership, Mutex<T> and RwLock<T> for synchronisation, the Atomic* types for lock-free synchronisation on primitives, channels for ownership-transferring messaging. Each piece does one thing, and combinations cover every shape from "shared immutable read" to "multi-producer messaging" to "read-mostly cache" without the language having to ship a primitive for each case.

Swift's gate is bolted onto an existing class system. Sendable labels the types that can cross concurrency boundaries; actor provides a class-shaped primitive whose stored properties are reachable only through awaited methods. Both were added in Swift 5.5 (2021) on top of language semantics that predated them, and the strict-by-default checking in Swift 6 (2024) made the model load-bearing. Below the actor abstraction, Swift ships the same low-level toolkit Rust does: Mutex<Value>, Atomic<T>, and RwLock<T> in the Synchronization module, plus the older OSAllocatedUnfairLock and NSLock.

The architectural difference is composition versus bundling. Rust separates ownership, mutation, and synchronisation into independent types you mix and match. Swift bundles common combinations into language primitives. A class already bundles refcounted sharing with mutation; an actor already bundles refcounted sharing with synchronisation. Bundling is friendlier at the call site for the bundled cases. Composition expresses every case, including the ones Swift has no language-level name for. The same pattern shows up at every layer of the chapter: Cell/RefCell versus a Swift class with var, Arc<Mutex<T>> versus actor, Send/Sync versus Sendable. In each pairing, Rust offers the pieces and Swift offers the bundle.

The same difference shows up at run time. Swift ships a cooperative thread pool with the runtime, so Task { ... } and actor work without configuration. Rust ships no executor, so async work means picking a runtime and adding it as a dependency. The Rust answer runs in environments where the Swift answer cannot (kernel, embedded, no-std); the Swift answer runs out of the box where Rust requires setup. Both gates eliminate the same bug class, through different combinations of compile-time labels and structural primitives, and the design choices reflect what each type system already had to work with. Rust's ownership rules made library-level composition safe, so the language never needed an actor concept. Swift's class-and-GCD model needed actor and Sendable to retrofit the same guarantees onto a system that already existed.