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; compiler-checked races. The first real language-level async — Swift had none for its first 7 years | actor reentrancy; Sendable friction (Swift 6); migration cost (but this is finally what let teams leave callbacks) |
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.| 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 |
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.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++.