Apple Systems & Concurrency Primer

Memory, ownership, value/reference semantics, threads, async, and data-race prevention, side by side. For the mobile→systems jump: the ObjC/Swift you know, anchored against the C++ that's new.
Color key: C++ Objective-C Swift (incl. Combine, async/await, actors)
0 · Overview 1 · Memory management 2 · Ownership & smart pointers / references 3 · Value vs reference semantics 4 · Retain cycles & how each breaks them 5 · Threads & the primitives 6 · Async: blocks / GCD / async-await / actors 6.5 · Apple evolution: pthread → GCD → Combine → actors ↳ Structured concurrency & Tasks 7 · Data races & how each prevents them 8 · Atomics & the memory model 9 · Gotchas worth naming 10 · Summary

0 · Overview

C++: you own memory; RAII ties lifetime to scope; deterministic destruction; concurrency is manual and the memory model is exposed (you can get it wrong, fast).
Objective-C: ARC = compiler-inserted retain/release on reference-counted objects; concurrency via GCD + blocks; almost no compile-time race safety.
Swift: ARC too, but value types (structs) by default + async/await + actors + Sendable push race-safety into the type system — the compiler rejects many data races.

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.

1 · Memory management — who frees, and when

AxisC++Objective-CSwift
ModelManual + RAII (scope-bound)ARC (ref counting)ARC (ref counting, classes only)
Frees whenscope exit / deletedeterministicrefcount → 0 — deterministicrefcount → 0 — deterministic
GC?nono (ARC, not tracing GC)no (ARC, not tracing GC)
Who inserts releaseyou / destructorcompiler (ARC)compiler (ARC)
Value typesstack/inline by defaultC primitives only; objects are heapstructs/enums = value, heap-free
Main leak riskforgetting delete / raw newretain cyclesretain cycles (fewer — value types)
The key contrast
All three are deterministic (no GC pauses) — that's why they're all viable for latency-sensitive systems. The difference is who does the bookkeeping: C++ you (via RAII so it's automatic-at-scope), ObjC/Swift the ARC compiler. None scan the heap; all free the instant the last reference dies.

2 · Ownership & smart pointers / references — the direct mapping

IntentC++Objective-CSwift
Sole ownerstd::unique_ptr<T> (C++11+); boost::scoped_ptr / pre-11 ancestor(n/a — refcount)(n/a — refcount)
Shared ownerstd::shared_ptr<T> (was boost::shared_ptr)strong (default)strong (default)
Non-owning, nilablestd::weak_ptr<T>weak (auto-nils)weak (auto-nils, optional)
Non-owning, won't outliveraw T* / ref T&unsafe_unretainedunowned (traps if dead)
Refcount costshared_ptr = atomic inc/decatomicatomic
C++
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
Objective-C
@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; ... }];
Swift
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 }; ... }
The mapping to keep in your head
shared_ptrstrongstrong · weak_ptrweakweak · raw pointer ≈ unsafe_unretainedunowned. 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).
scoped_ptr vs unique_ptr — the sole-owner lineage (C++)
All are sole owners that free on scope exit; the difference is transferability, and there are three distinct pre/post-C++11 stories: Today: always reach for 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.

3 · Value vs reference semantics — the biggest Swift divergence

This is where Swift breaks from both. Swift structs/enums are value types — copied on assignment (copy-on-write under the hood, so it's cheap). A copied value can't be mutated by another thread → value semantics is itself a concurrency-safety tool. ObjC has no struct objects (only C structs); C++ has value semantics but you opt into reference via pointers.

AxisC++Objective-CSwift
Default for user typesvalue (copy); ref via */&objects = reference; only C structs are valuestruct=value, class=reference
Copy costdeep copy unless you write moven/a (pointer copy)copy-on-write — cheap until mutated
Move semanticsstd::move, rvalue refsnocompiler COW; consuming/borrowing (newer)
Concurrency anglecopies are independent (good); aliasing via ptr (danger)shared mutable objects everywherevalue copy = no shared mutable state
Why it matters for systems
"Prefer value types" in Swift is the same instinct as "pass by value / make copies independent" in C++ — no aliasing means no data race. The mobile habit of throwing reference objects around (ObjC) is exactly what creates shared mutable state; Swift's value-default is a deliberate correction.

4 · Retain cycles & how each breaks them — the #1 refcount bug

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.

C++ — weak_ptr
struct Node {
  std::shared_ptr<Node> next;
  std::weak_ptr<Node>   prev; // break cycle
};
ObjC — weak / [weak self]
@property (weak) id delegate; // classic
__weak typeof(self) ws = self;
self.block = ^{ [ws doThing]; };
Swift — weak/unowned capture
weak var delegate: Delegate?
task = Task { [weak self] in
  guard let self else { return }
}
Team rule
Breaking a retain cycle is one rule across all three languages: make the back-reference weak (or unowned/weak_ptr). Audit these sites in review: parent↔child, delegate properties, and closures/blocks capturing self (the most common offender in app code). Default to [weak self] in any closure that outlives the current scope and guard let self at the top.

5 · Threads & the primitives — the building blocks

PrimitiveC++ (std)Objective-CSwift
Raw threadstd::thread, jthreadNSThread (rare)Thread (rare)
Mutexstd::mutex + lock_guardNSLock, @synchronized, os_unfair_lockNSLock, OSAllocatedUnfairLock
Preferred modelthreads + locks, or a poolGCD (dispatch queues)structured concurrency (async/await)
Serial executionsingle-thread + queueserial dispatch_queueactor
Condition / signalcondition_variabledispatch_semaphore, NSConditioncontinuation, AsyncStream
Thread poolroll your own / libGCD global queuescooperative pool (runtime-managed)
C++ — mutex + lock_guard (RAII!)
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);
ObjC — GCD
dispatch_queue_t q =
  dispatch_queue_create("work", DISPATCH_QUEUE_SERIAL);
dispatch_async(q, ^{ [self doWork]; });
// serial queue = mutual exclusion w/o a lock
Swift — async/await + actor
actor Counter {
  private var n = 0
  func inc() { n += 1 }  // isolated, race-free
}
let c = Counter()
await c.inc()             // suspension point
Locks → queues → actors
C++: locks are explicit; 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.

6 · Async — callbacks → structured concurrency

AxisC++Objective-CSwift
Closureslambdas [capture](){}blocks ^{}closures { }
Futurestd::future/promise, std::asynccallback blocks; no native futureasync funcs, Task
awaitcoroutines (C++20 co_await) — low-levelnone — nested callbacksasync/await first-class
Cancellationmanual (stop_token on jthread)manual flagscooperative (Task.cancel, checked)
Structuredno (manual join)noyesasync let, task groups, auto-await children
The big jump — and why it took until 2021
Callback hell wasn't an ObjC-only thing — Swift had it worst, for seven years. Swift shipped in 2014 with no language-level async at all; 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.

Structured concurrency & Tasks — the model that makes the lifetime guarantee

The core promise: a child task cannot outlive the scope that spawned it. The parent cannot return until every child has finished or been cancelled — the compiler enforces it. That single rule is what kills GCD's orphaned-work problem: lifetime becomes a tree, not a free-for-all.
ConstructKindUse it for
async letstructureda fixed, known set of concurrent children (2–3 parallel fetches). Auto-awaited at scope end; auto-cancelled on throw.
withTaskGroupstructureda dynamic number of children (one per item in a list). Group won't return until all finish; cancel the group → cancels all.
Task { }unstructuredbridging sync→async (e.g. a button handler kicks off async work). Escapes the scope — you own its lifetime + cancellation.
Task.detachedunstructuredrare — fully independent work with no inherited context/priority/actor. Usually a smell.
Swift — async let (fixed fan-out)
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
Swift — task group (dynamic fan-out)
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
Swift — unstructured Task (you own it)
@IBAction func tap() {
  task = Task { [weak self] in      // escapes scope
    guard let self else { return }
    await self.refresh()
  }                                   // must cancel in deinit:
}                                     // task?.cancel()
What structure buys you (and the rules)
vs C++ / ObjC
Neither has this. C++: 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.

6.5 · The Apple concurrency evolution — pthread → GCD → Combine → async/await + actors

Nothing here was "replaced" — each layer was buried under the next and still ships. async/await runs on top of GCD; @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.
EraLayer (still ships)What it gaveIts OWN distinct problem
~2000spthreads / NSThread + locksraw concurrency at all (POSIX threads; NSThread, NSLock)manage everything by hand — thread lifetime, pools, locks; every app re-implements a thread pool
2009→GCDdispatch_asyncOS-managed pool; hand work to queues not threads; serial queue = lock-free mutual exclusionfire-and-forget → callback pyramids, no return value, no cancellation, no structure (orphaned work)
2009→GCDdispatch_syncrun-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/awaitsteep 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 yearsactor reentrancy; Sendable friction (Swift 6); migration cost (but this is finally what let teams leave callbacks)
The real root cause: two axes most developers never modeled correctly

GCD forced a decision on two orthogonal axes for every call, with zero guardrails — and most people never held both straight:

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.

GCD's other standing problems (carried until actors/async-await buried them)

Swift/ObjC — GCD: the pyramid + the deadlock
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-deadlock
Swift — Combine: streams, operator zoo
cancellable = 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-shot
Swift — async/await: flat, cancellable
func 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
When to reach for each (don't default to "always async/await")
Pick the layer by the shape of the work, not by recency: Heuristic: streams → Combine/AsyncSequence (Swift) or producer-consumer queue (C++); one-shot + state → async/await (Swift) or thread pool + mutex (C++); raw dispatch → GCD / std::thread. The decision is stream-vs-request, in any language.
How this compares to C++
Apple's arc is callbacks → streams → structured concurrency, and the endpoint (structured concurrency + compiler-enforced isolation) is something C++ doesn't have — C++ stopped at the "primitives" stage (threads, 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.

7 · Data races & how each prevents them — the safety gradient

The single most important difference. Race-prevention moves from runtime discipline (C++/ObjC: you must not mess up) to compile-time enforcement (Swift: the compiler rejects many races). This is the headline when comparing them.
MechanismC++Objective-CSwift
Default safetynone — UB on a racenone — you discipline itcompiler-checked isolation
Toolmutex / atomic, by handserial queues, by handactor + Sendable
Cross-thread sharinganything, anytime (danger)anything, anytime (danger)only Sendable types may cross
DetectionThreadSanitizer (runtime)TSan (runtime)compile error (statically-detectable; unsafe opt-outs → TSan)
Sendable — the Swift idea with no C++/ObjC analog
A type is 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).

8 · Atomics & the memory model — the low level (mostly C++)

C++ exposes the hardware memory model directly; ObjC/Swift mostly hide it (you use atomics/locks/actors and don't pick orderings).

C++ — explicit ordering
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
ObjC — atomic property
@property (atomic) int count; // seq-cst-ish
// OSAtomic* (deprecated) / stdatomic.h
// you rarely pick an ordering
Swift — high level
// Atomics package (swift-atomics)
let a = ManagedAtomic<Int>(0)
a.wrappingIncrement(ordering: .relaxed)
// but usually: use an actor instead
The one pairing to internalize
acquire/release is the pair that matters: a 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.

9 · Gotchas worth naming

GotchaWhereWhat / fix
Retain cycle in closureall 3closure captures self strongly → [weak self] / __weak / weak_ptr
Dangling pointerC++ / ObjCraw ptr / unsafe_unretained outlives object → UB. Swift unowned traps instead.
Iterator invalidationC++mutating a container while iterating → UB. (Swift value-copy sidesteps it.)
False sharingC++two atomics on one cache line → cores fight. Pad to 64B (alignas).
Deadlock by lock orderall 3A locks X then Y, B locks Y then X → always acquire in a global order
GCD deadlockObjC/Swiftdispatch_sync onto the current serial queue → self-deadlock
Out-of-order completionObjC/Swiftconcurrent 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 UIObjC/SwiftUIKit not thread-safe → hop to main (@MainActor in Swift checks it)
Actor reentrancySwiftstate can change across an await inside an actor — re-check invariants after suspension

10 · Summary

What to hold in your head

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