The organizing question: where does lifetime/safety get decided? C++ at scope exit (RAII, deterministic). ObjC at runtime (refcount, you avoid cycles by hand). Swift increasingly at compile time (ownership + actor isolation checked by the compiler). Moving C++→Swift you trade manual control for compiler-enforced guarantees; moving Swift→C++ you regain control and lose the guardrails.
| Axis | C++ | Objective-C | Swift |
|---|---|---|---|
| Model | Manual + RAII (scope-bound) | ARC (ref counting) | ARC (ref counting, classes only) |
| Frees when | scope exit / delete — deterministic | refcount → 0 — deterministic | refcount → 0 — deterministic |
| GC? | no | no (ARC, not tracing GC) | no (ARC, not tracing GC) |
| Who inserts release | you / destructor | compiler (ARC) | compiler (ARC) |
| Value types | stack/inline by default | C primitives only; objects are heap | structs/enums = value, heap-free |
| Main leak risk | forgetting delete / raw new | retain cycles | retain cycles (fewer — value types) |
| Intent | C++ | Objective-C | Swift |
|---|---|---|---|
| Sole owner | std::unique_ptr<T> (C++11+); boost::scoped_ptr / pre-11 ancestor | (n/a — refcount) | (n/a — refcount) |
| Shared owner | std::shared_ptr<T> (was boost::shared_ptr) | strong (default) | strong (default) |
| Non-owning, nilable | std::weak_ptr<T> | weak (auto-nils) | weak (auto-nils, optional) |
| Non-owning, won't outlive | raw T* / ref T& | unsafe_unretained | unowned (traps if dead) |
| Refcount cost | shared_ptr = atomic inc/dec | atomic | atomic |
auto p = std::make_unique<Node>(); // sole owner auto s = std::make_shared<Node>(); // shared, atomic rc std::weak_ptr<Node> w = s; // no rc bump if (auto sp = w.lock()) { ... } // promote to use
@property (strong) Node *next; // owns @property (weak) Node *prev; // auto-nils // __weak / __strong in blocks: __weak typeof(self) ws = self; [obj run:^{ __strong typeof(ws) ss = ws; ... }];
var next: Node? // strong (default) weak var prev: Node? // auto-nils, optional unowned let parent: Node // non-nil, traps if dead obj.run { [weak self] in // capture list guard let self else { return }; ... }
shared_ptr ≈ strong ≈ strong ·
weak_ptr ≈ weak ≈ weak ·
raw pointer ≈ unsafe_unretained ≈ unowned.
The one with no ObjC/Swift analog is sole ownership — modern C++'s default,
which refcounted languages don't have because everything's refcounted.
unowned vs unsafe_unretained:
both non-nilling, but unowned traps on access-after-free (safe-ish);
unsafe_unretained is a dangling read (UB, like a raw C++ pointer).boost::scoped_ptr — the genuinely non-transferable one:
noncopyable, non-movable (but swappable). Ownership is pinned to one scope,
full stop. Pre-C++11, this is how you said "owns, and ownership cannot
travel."std::auto_ptr (pre-C++11, deprecated C++11, removed C++17) — the opposite mistake: it was copyable, but with destructive transfer-on-copy — copying it null'd the source. A broken pre-move that silently stole ownership, which is why it couldn't live in STL containers.std::unique_ptr (C++11+) — noncopyable but movable via
std::move: ownership is handed off explicitly (returned from a
factory, moved into a container). It replaced auto_ptr's implicit
destructive copy with an explicit move — same intent, no footgun.unique_ptr (default sole-owner) and
make_unique; you'll only see scoped_ptr/auto_ptr
in legacy or Boost-era code. If you want to express "must not move,"
that's a code-review convention now, not a type.| Axis | C++ | Objective-C | Swift |
|---|---|---|---|
| Default for user types | value (copy); ref via */& | objects = reference; only C structs are value | struct=value, class=reference |
| Copy cost | deep copy unless you write move | n/a (pointer copy) | copy-on-write — cheap until mutated |
| Move semantics | std::move, rvalue refs | no | compiler COW; consuming/borrowing (newer) |
| Concurrency angle | copies are independent (good); aliasing via ptr (danger) | shared mutable objects everywhere | value copy = no shared mutable state |
Refcounting (ARC, shared_ptr) can't free a cycle: A→B→A keeps both
counts ≥1 forever. The fix is the same in all three: make one edge non-owning.
struct Node { std::shared_ptr<Node> next; std::weak_ptr<Node> prev; // break cycle };
@property (weak) id delegate; // classic __weak typeof(self) ws = self; self.block = ^{ [ws doThing]; };
weak var delegate: Delegate? task = Task { [weak self] in guard let self else { return } }
[weak self]
in any closure that outlives the current scope and guard let self at
the top.| Primitive | C++ (std) | Objective-C | Swift |
|---|---|---|---|
| Raw thread | std::thread, jthread | NSThread (rare) | Thread (rare) |
| Mutex | std::mutex + lock_guard | NSLock, @synchronized, os_unfair_lock | NSLock, OSAllocatedUnfairLock |
| Preferred model | threads + locks, or a pool | GCD (dispatch queues) | structured concurrency (async/await) |
| Serial execution | single-thread + queue | serial dispatch_queue | actor |
| Condition / signal | condition_variable | dispatch_semaphore, NSCondition | continuation, AsyncStream |
| Thread pool | roll your own / lib | GCD global queues | cooperative pool (runtime-managed) |
std::mutex m; void inc() { std::lock_guard lk(m); // RAII: unlocks on scope exit ++count; // even on exception } std::atomic<int> n{0}; n.fetch_add(1);
dispatch_queue_t q = dispatch_queue_create("work", DISPATCH_QUEUE_SERIAL); dispatch_async(q, ^{ [self doWork]; }); // serial queue = mutual exclusion w/o a lock
actor Counter { private var n = 0 func inc() { n += 1 } // isolated, race-free } let c = Counter() await c.inc() // suspension point
lock_guard is RAII applied to locking
(unlock on scope exit, even on throw) — the same RAII idea as memory.
ObjC/GCD: a serial queue gives you mutual exclusion without a lock —
hand work to a queue, it runs one-at-a-time.
Swift actors: the queue idea promoted into the type system — an
actor serializes access to its own state, and the compiler
forces you to await across the boundary. Each step hides the one
below.| Axis | C++ | Objective-C | Swift |
|---|---|---|---|
| Closures | lambdas [capture](){} | blocks ^{} | closures { } |
| Future | std::future/promise, std::async | callback blocks; no native future | async funcs, Task |
| await | coroutines (C++20 co_await) — low-level | none — nested callbacks | async/await first-class |
| Cancellation | manual (stop_token on jthread) | manual flags | cooperative (Task.cancel, checked) |
| Structured | no (manual join) | no | yes — async let, task groups, auto-await children |
async/await didn't land until Swift 5.5 (2021). So from 1.0
through 5.4 the only option was ObjC's completion-handler model, and Swift
amplified it (nested closures + Result/error params + trailing-closure
pyramids). That gap is a big reason GCD-with-closures stayed entrenched so long —
not preference, just the absence of an alternative. Adoption tracked the
language feature: teams moved off callbacks when 5.5 finally let them,
not before. 5.5 flattened async into straight-line code, and structured
concurrency guarantees child tasks finish (or cancel) before the parent
returns — no orphaned work. C++20 coroutines exist but are a low-level toolkit you
build futures/generators on top of, not an app-level model. This is the
same "barrier / don't-finalize-in-flight" discipline from the distributed
problems, but built into the language.| Construct | Kind | Use it for |
|---|---|---|
async let | structured | a fixed, known set of concurrent children (2–3 parallel fetches). Auto-awaited at scope end; auto-cancelled on throw. |
withTaskGroup | structured | a dynamic number of children (one per item in a list). Group won't return until all finish; cancel the group → cancels all. |
Task { } | unstructured | bridging sync→async (e.g. a button handler kicks off async work). Escapes the scope — you own its lifetime + cancellation. |
Task.detached | unstructured | rare — fully independent work with no inherited context/priority/actor. Usually a smell. |
func load() async throws -> Page { async let user = fetchUser() // both run async let feed = fetchFeed() // concurrently return Page(try await user, // joined here try await feed) } // if this throws, the other is auto-cancelled
try await withThrowingTaskGroup(of: Img.self) { g in for url in urls { g.addTask { try await fetch(url) } } var out: [Img] = [] for try await img in g { out.append(img) } return out // won't return till all done } // throw/cancel cancels every child
@IBAction func tap() { task = Task { [weak self] in // escapes scope guard let self else { return } await self.refresh() } // must cancel in deinit: } // task?.cancel()
Task.isCancelled / hit a suspension point; cancellation is a request, not a kill.)Task.detached breaks that (why it's a smell).async let / task groups. Reach for unstructured Task { } only to bridge from sync code, and when you do, store it and cancel it (in deinit / onDisappear) — an un-cancelled Task is the async-era's retain-cycle-equivalent leak.std::async/jthread give you
futures + a stop_token, but you join and you propagate
cancellation — no scope enforces it. (std::jthread auto-joins on
destruction, the closest C++ gets to "scoped," but it's one thread, not a task
tree.) std::execution (senders/receivers, P2300 — now in the C++26
working draft) heads toward structured concurrency but isn't shipping in stdlibs
yet. ObjC: no
concept — every dispatch_async is unstructured by definition.@MainActor hops to
the main dispatch queue. The arc is Apple repeatedly chasing a better
abstraction over the same dispatch machinery — because each abstraction, once
it hid the queues, got misused in a new way. Understand the lineage so
your team reaches for the right layer and knows what the layers beneath are doing.
| Era | Layer (still ships) | What it gave | Its OWN distinct problem |
|---|---|---|---|
| ~2000s | pthreads / NSThread + locks | raw concurrency at all (POSIX threads; NSThread, NSLock) | manage everything by hand — thread lifetime, pools, locks; every app re-implements a thread pool |
| 2009→ | GCD — dispatch_async | OS-managed pool; hand work to queues not threads; serial queue = lock-free mutual exclusion | fire-and-forget → callback pyramids, no return value, no cancellation, no structure (orphaned work) |
| 2009→ | GCD — dispatch_sync | run-and-wait; simple "do this now, block until done" | self-deadlock (sync onto your own queue) + it blocks a whole thread while waiting → feeds thread explosion |
| 2019→ | Combine (reactive) | composable async streams; declarative pipelines; backpressure — and the closest thing to "structured async" while the language still had no async/await | steep operator zoo; hard to debug; verbose for one-shot; Apple-only; partly a stopgap, overtaken two years later |
| 2021→ | async/await + actors (Swift 5.5) | straight-line async; structured cancellation; cooperative pool (~1 thread/core, tasks suspend not block → no thread exhaustion); compiler-checked races. The first real language-level async — Swift had none for its first 7 years | actor reentrancy; Sendable friction (Swift 6); the forward-progress contract — block a pool thread (mutex/semaphore/GCD-sync) and you can deadlock all cores; migration cost |
GCD forced a decision on two orthogonal axes for every call, with zero guardrails — and most people never held both straight:
dispatch_sync to main from main (instant deadlock), or async when they meant sync (ordering bugs).Because the queue was hidden behind a tidy API, people wrote async calling
async calling async without realizing each blocking link (a dispatch_sync,
semaphore wait, blocking I/O) tied up a real pool thread. GCD adds threads to keep
a blocked pool alive, up to a global ceiling (historically ~512 worker
threads) — so blocking-heavy work saturates the pool and stalls (or, on a
deadlocked dependency, wedges permanently). Not "thousands of threads," but a
thrashing, stuck pool — and either way the app hangs. (A separate "64" figure that
floats around is the number of in-flight blocked work items a queue
tolerates, not a thread cap.)
The deeper pain was debuggability: with work hopping across dozens of anonymous queues, a crash or hang gave you a stack that started mid-block with no caller — the chain that led there lived on some other thread that had already moved on. "Hundreds of threads with untraceable stacks" is the exact thing that made early heavy-GCD apps miserable to debug.
async/await is the answer to precisely this: a cooperative thread
pool sized to ~core count (a soft target — it can overcommit under priority
inversion, not a hard cap) — tasks suspend (free the thread) instead
of block (hold the thread), so nesting async no longer creates threads;
and the await chain preserves a logical stack you can actually follow.
It also collapses both axes out of your head: await doesn't
block, so the sync/async footgun is gone; and @MainActor makes "runs
on main" a compiler-checked annotation instead of a DispatchQueue.main.async
you have to remember.
The pattern: each generation moves the queue/sync decision out of the developer's head and into the compiler — because most developers never had a correct mental model of main-vs-background or sync-vs-async. That's why it's buried, not replaced: same machinery, each layer asks you to understand less.
dispatch_sync, semaphore/condition waits, blocking I/O) on the global concurrent queues tie up pool threads; GCD keeps spawning worker threads to replace blocked ones, up to a global ceiling (historically ~512 threads). You don't get "thousands of threads" — long before the ceiling you get a saturated, thrashing pool where new work stalls (and on a deadlocked dependency, never runs). The fix: never block a pool thread — await suspends instead. Rule: don't dispatch_sync / wait on semaphores from a concurrent queue. (The oft-cited "64" is the in-flight-blocked-work-item count, not a thread cap.)DispatchWorkItem.cancel() only skips work that hasn't started, it can't interrupt a running block. You end up threading manual isCancelled flags. (Tasks are cooperatively cancellable end-to-end.)sync from a high-QoS context raises the target), but cross-QoS queue hops still cause inversions you have to reason about by hand. (async/await escalates priority automatically.)Sendable enforce isolation at compile time.)fetchUser(id) { user in
loadProfile(user) { profile in
loadAvatar(profile) { img in // 3 deep…
DispatchQueue.main.async { update(img) }
}
}
}
// and the classic foot-gun:
queue.sync { queue.sync { } } // ⛔ self-deadlockcancellable = fetchUser(id)
.flatMap { loadProfile($0) }
.flatMap { loadAvatar($0) }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { ... }, // error here
receiveValue: { update($0) })
// powerful for streams, heavy for one-shotfunc load() async throws { let user = try await fetchUser(id) let profile = try await loadProfile(user) let img = try await loadAvatar(profile) await MainActor.run { update(img) } } // errors via throws; cancellation cooperative
AsyncSequence — genuine streams of events over time with operators (debounce search input, combine UI state). SwiftUI's @Published is Combine, so you'll keep it on the UI layer regardless.DispatchQueue.main.async hop in code not yet on the concurrency model.std::async / a library executor) for request/response; producer-consumer queues for streams; raw std::thread/jthread only for long-lived dedicated threads. State that crosses threads → a mutex-guarded type or atomic. There's no actor/Sendable safety net — you supply the discipline.std::thread. The decision is stream-vs-request, in any language.future, C++20 coroutines as a toolkit). So Swift went further
than C++ on safety, while C++ retains more control. The practical takeaway:
Swift moved race-safety into the compiler; C++ leaves it to you — so on a
C++ codebase, invest in the discipline (RAII locks, ThreadSanitizer in CI) that
Swift gets for free. Where you can, borrow Swift Concurrency's concepts into
C++ by convention: structured task scope (don't fire-and-forget — own and join
children), value/immutable types across boundaries, and a "never block the worker
pool" rule if you build one. And when mixing Swift and C++, put an explicit
jumping-off point at the boundary — a single, well-marked place where you hand
off — so you don't accidentally mix threads that follow different rules (a
cooperative-pool thread must not wander into blocking C++; blocking C++ work must be
dispatched to an expendable thread before results bridge back).| GCD | Swift Concurrency | |
|---|---|---|
| Pool | threads per queue; can spawn many (up to a global ceiling) | ONE cooperative pool, ~1 thread per core (8-core device → ~8 threads) |
| On block | the thread is blocked and held; GCD spawns another to keep going → thread exhaustion (scheduling + memory overhead that tanks throughput) | on await the task suspends and gives up the thread for other tasks → no exhaustion |
| The contract | blocking is tolerated (at a cost) | forward progress required — never block a pool thread |
syncs (locks) from inside
a Swift Concurrency task, you block that pool thread instead of suspending
it. Do that on enough tasks and you block all 8 threads on an 8-core device →
hard deadlock, with no thread left to make progress. GCD would have spawned more
threads (degraded but alive); the cooperative pool by design will not.
The practical rule (and the pattern to use):
when you must call into blocking GCD/legacy code from Swift Concurrency, get it
off the cooperative pool — dispatch the work item to a GCD
async queue so it blocks one of GCD's expendable threads, not a cooperative
one, and bridge the result back with a withCheckedContinuation. Never
let a blocking call run directly on a cooperative-pool thread.
nonisolated@MainActor isolation instead of nonisolated — "single-threaded by default." It's opt-in at the language level (-default-isolation MainActor / .defaultIsolation(MainActor.self)), but Xcode 26 turns it ON for new projects via the "Default Actor Isolation" build setting. The intent: most app code is main-thread code, so make that the safe default.@concurrent (SE-0461): you now move work OFF the actor explicitly — mark an async function @concurrent to run it on the global concurrent executor. The old model (background by default, hop to main for UI) inverts to main by default, opt out for parallelism.nonisolated changed (gated by the NonisolatedNonsendingByDefault upcoming-feature flag, part of the Approachable Concurrency bundle): a nonisolated async func now runs on the caller's actor rather than hopping off it — so nonisolated no longer means "runs off main," and you reach for @concurrent to actually get parallelism.@concurrent.SWIFT_APPROACHABLE_CONCURRENCY) is a bundle of upcoming-feature flags and is a distinct setting from "Default Actor Isolation = MainActor" (SE-0466). Refs: SE-0466, SE-0461, Swift 6.2 release notes.| Mechanism | C++ | Objective-C | Swift |
|---|---|---|---|
| Default safety | none — UB on a race | none — you discipline it | compiler-checked isolation |
| Tool | mutex / atomic, by hand | serial queues, by hand | actor + Sendable |
| Cross-thread sharing | anything, anytime (danger) | anything, anytime (danger) | only Sendable types may cross |
| Detection | ThreadSanitizer (runtime) | TSan (runtime) | compile error (statically-detectable; unsafe opt-outs → TSan) |
Sendable if it's safe to pass across concurrency domains
(value types, immutables, or internally-synchronized classes). The compiler
refuses to let a non-Sendable value cross an actor/Task
boundary. So "can this be shared between threads?" becomes a type-checked
question, not a code-review hope. In C++/ObjC that guarantee lives only in
your head and your tests (and TSan, which only catches races that actually
execute). Swift 6 complete checking turns statically-detectable data races into
compile errors — for code that doesn't reach for the unsafe opt-outs
(@unchecked Sendable, nonisolated(unsafe), raw pointers,
C/ObjC interop), which stay runtime-checked (TSan).C++ exposes the hardware memory model directly; ObjC/Swift mostly hide it (you use atomics/locks/actors and don't pick orderings).
std::atomic<int> flag{0}; flag.store(1, std::memory_order_release); int v = flag.load(std::memory_order_acquire); // relaxed | acquire | release | seq_cst
@property (atomic) int count; // seq-cst-ish // OSAtomic* (deprecated) / stdatomic.h // you rarely pick an ordering
// Atomics package (swift-atomics) let a = ManagedAtomic<Int>(0) a.wrappingIncrement(ordering: .relaxed) // but usually: use an actor instead
release store
"publishes" everything written before it; an acquire load that sees
that store "sees" all those writes. It's how you hand data between threads without
a full lock. seq_cst (default) is simplest + slowest;
relaxed is just-an-atomic-counter (no ordering guarantees — fine for
stats, wrong for handoff). Guidance: default to seq_cst until a
profile says the atomic is hot, then drop to acquire/release deliberately and
document why. This is C++ territory — in Swift, reach for an actor and
don't hand-pick orderings at all.| Gotcha | Where | What / fix |
|---|---|---|
| Retain cycle in closure | all 3 | closure captures self strongly → [weak self] / __weak / weak_ptr |
| Dangling pointer | C++ / ObjC | raw ptr / unsafe_unretained outlives object → UB. Swift unowned traps instead. |
| Iterator invalidation | C++ | mutating a container while iterating → UB. (Swift value-copy sidesteps it.) |
| False sharing | C++ | two atomics on one cache line → cores fight. Pad to 64B (alignas). |
| Deadlock by lock order | all 3 | A locks X then Y, B locks Y then X → always acquire in a global order |
| GCD deadlock | ObjC/Swift | dispatch_sync onto the current serial queue → self-deadlock |
| Out-of-order completion | ObjC/Swift | concurrent queues (and parallel async tasks) finish in any order, not submission order — two dispatch_asyncs to a concurrent queue can complete B-before-A. Don't assume FIFO results. Fix: use a serial queue (or actor) if order matters, or tag + reorder results. A serial queue preserves order; a concurrent one only preserves start order, not finish. |
| Main-thread UI | ObjC/Swift | UIKit not thread-safe → hop to main (@MainActor in Swift checks it) |
| Actor reentrancy | Swift | state can change across an await inside an actor — re-check invariants after suspension |
| Blocking the cooperative pool | Swift | mutex / semaphore / GCD-sync inside a Task blocks a pool thread (only ~1/core); block all cores → deadlock. Dispatch blocking work to a GCD async queue + withCheckedContinuation. |
| MainActor-by-default opt-out | Swift 6.2 | with SE-0466 on (new Xcode 26 projects), forgetting @concurrent (SE-0461) serializes the hot path on main; nonisolated async now runs on the caller's actor, not off main |
shared_ptr≈strong, weak_ptr≈weak, raw-ptr≈unowned. The C++-only one is unique_ptr — sole ownership, which refcounted languages don't need.async let (fixed fan-out) / task groups (dynamic); use unstructured Task { } only to bridge sync→async, and always store + cancel it.sync inside a task blocks a pool thread; block all N cores → deadlock (GCD would just spawn more threads). Dispatch blocking/legacy work to a GCD async queue and bridge back with withCheckedContinuation.@MainActor; you opt out with @concurrent (SE-0461) to run off main, and nonisolated async now runs on the caller's actor. Watch for accidentally serializing the hot path on main.seq_cst until a profile demands otherwise. In Swift, use an actor and never hand-pick orderings.For anyone moving mobile→systems: the hard
concepts (refcounting, retain cycles, queues) you already own from ObjC/Swift.
C++ adds sole ownership (unique_ptr), deterministic
scope-bound cleanup (RAII), and the exposed memory model (orderings) —
more control, fewer guardrails. Swift adds compile-time race safety
(actors/Sendable) that C++ leaves to your discipline. Choose the language's grain:
lean on the compiler in Swift, lean on RAII + sanitizers in C++.