Skip to content

SergeyPetrachkov/ReaderWriterProblemPlayground

Repository files navigation

Reader–Writer Problem Playground

This repo explores different approaches to protecting shared mutable state in Swift, and how they behave under concurrency. It intentionally includes both correct and incorrect implementations to show real-world failure modes.

Implementations Overview

1. UnprotectedReaderWriter<Value>

public final class UnprotectedReaderWriter<Value>: ReaderWriter {
    private nonisolated(unsafe) var mutableState: Value

    public init(value: Value) {
        self.mutableState = value
    }

    public func write<R>(_ body: (inout Value) -> R) -> R {
        body(&mutableState)
    }

    @discardableResult
    public func read() -> Value {
        mutableState
    }
}
  • No synchronization at all: every concurrent use is a data race.
  • nonisolated(unsafe) opts out of the compiler's checking.
  • Behavior in tests:
    • Int:
      • Concurrent increments are racy but usually don’t crash.
      • On current CPUs, aligned Int loads/stores tend to be atomic at the word level.
      • Result: lost updates and wrong final count, but still valid integer values → “seems fine” but logically broken.
    • String and [Int]:
      • Mutate complex, reference-counted, copy-on-write storage.
      • Concurrent mutations corrupt internal invariants (refcounts, buffers, lengths, capacities).
      • Result: allocator/runtime crashes or traps → crash in production :)
  • Key lesson: data races are always undefined behavior, but:
    • Simple value types often “only” give you wrong answers.
    • Rich CoW/reference-counted types often crash quickly when raced.

There are also specialized @unchecked Sendable variants:

public final class UnprotectedIntReaderWriter: @unchecked Sendable { ... }
public final class UnprotectedArrayReaderWriter: @unchecked Sendable { ... }
public final class UnprotectedStringReaderWriter: @unchecked Sendable { ... }
  • All three are unsafe by design.
  • @unchecked Sendable just silences the compiler; it does not add synchronization.

2. SyncQueueReaderWriter<Value>

public final class SyncQueueReaderWriter<Value: Sendable>: ReaderWriter {
    private let queue = DispatchQueue(
        label: "SyncQueueReaderWriter"
    )

    private nonisolated(unsafe) var value: Value

    public init(value: Value) {
        self.value = value
    }

    public func write<R>(_ body: (inout Value) -> R) -> R {
        queue.sync { body(&value) }
    }

    @discardableResult
    public func read() -> Value {
        queue.sync { value }
    }
}
  • Uses a private serial DispatchQueue and queue.sync for both reads and writes.
  • All access to value is serialized → no data race on value when using this API correctly.
  • Value: Sendable is required, because otherwise we can't guarantee that the value that we read won't participate in a data race on its own.
  • Deadlock risk (classic GCD pattern):
    • Calling queue.sync from a block already running on the same serial queue deadlocks.

    • In this API, that means consumer bugs like:

      let box = SyncQueueReaderWriter<Int>(value: 0)
      
      box.write { value in
          value = box.read() + 1    // deadlocks: nested sync on same serial queue
      }
    • The outer write holds the queue; the inner read tries to sync on that queue again.

  • Insight:
    • This class is safe for non-reentrant usage.
    • It’s vulnerable if clients call read/write from inside write closures (re-entrancy on the same queue).

3. ConcurrentQueueReaderWriter<Value>

public final class ConcurrentQueueReaderWriter<Value: Sendable>: Sendable {
    private let queue = DispatchQueue(
        label: "ConcurrentQueueReaderWriter",
        attributes: .concurrent,
        target: DispatchQueue.global(qos: .userInitiated)
    )

    private nonisolated(unsafe) var value: Value

    public init(value: Value) {
        self.value = value
    }

    public func write<R>(_ body: (inout Value) -> R) -> R {
        queue.sync(flags: .barrier) {
            body(&value)
        }
    }

    @discardableResult
    public func read() -> Value {
        queue.sync {
            value
        }
    }
}
  • Uses a concurrent GCD queue:
    • read uses queue.sync ⇒ multiple readers can run concurrently.
    • write uses queue.sync(flags: .barrier) ⇒ writers are exclusive and wait for prior operations to finish.
  • Semantics:
    • Read–write pattern with synchronous exclusive writers:
      • Many concurrent readers.
      • Writes block the caller until they’re fully applied.
      • When write returns, the mutation is visible to subsequent reads/writes.
  • Safety:
    • Internally race-free with respect to value as long as all access goes through read/write.
    • Value: Sendable is enforced; wrapper itself relies on manual synchronization.
  • Deadlock risk:
    • External callers doing read/write from other queues/threads are fine.
    • As with the serial version, re-entrant usage (e.g. calling write from a closure already running on this same queue) can create self-deadlock scenarios; the abstraction assumes callers don’t do this.
  • Caveat for both queue-based variants:
    • Returning Value directly means reference-like payloads can be mutated off-queue, which can reintroduce races the wrapper can’t prevent.

4. LockedReaderWriter<Value>

import os

public struct LockedReaderWriter<Value>: Sendable {
    // Uses os_unfair_lock under the hood. It's not a recursive lock.
    // Attempting to lock it again from the same thread while the lock is already locked will crash.
    let value: OSAllocatedUnfairLock<Value>

    public init(value: Value) {
        self.value = .init(uncheckedState: value)
    }

    public func write<R>(_ body: (inout Value) throws -> R) rethrows -> R {
        try value.withLockUnchecked(body)
    }

    public func read<R>(_ body: (Value) throws -> R) rethrows -> R {
        try value.withLockUnchecked { try body($0) }
    }
}
  • Wraps OSAllocatedUnfairLock<Value> (which uses os_unfair_lock).
  • Mutual exclusion:
    • write and read both run under the same unfair lock.
    • No concurrent access to value through these APIs ⇒ no data race on value.
  • Non-recursive:
    • os_unfair_lock is not recursive; re-locking it on the same thread while already held is undefined / crash.

    • That means nested write/read on the same instance (on the same thread) are invalid:

      rw.write { value in
          rw.read { v in ... }     // UB: same thread, same lock, re-entrant
      }

5. ActorReaderWriter<Value>

public actor ActorReaderWriter<Value> {
    private var value: Value

    public init(value: Value) {
        self.value = value
    }

    public func write<R>(_ body: (inout Value) -> R) -> R {
        body(&value)
    }

    public func read() -> Value {
        value
    }
}
  • Uses a Swift actor instead of explicit locks or queues.
  • Actor guarantees:
    • Only one task accesses value at a time.
    • No explicit synchronization required; the runtime enforces isolation.
  • write and read are synchronous (non-async) methods:
    • They execute atomically inside the actor, no suspension inside them.
    • No lock-style deadlocks or GCD self-sync issues.
  • Re-entrancy:
    • You can’t just await the same actor from inside its isolated context, the compiler catches many problematic patterns that would be easy to write with locks/queues.
  • Same external caveat: if you return a reference-like Value and mutate it off-actor, you can race outside of the actor’s control.

Key Lessons from the Tests

  • Data races on simple values (Int) often “just” produce wrong results:
    • Still UB in Swift’s memory model.
    • But operations usually stay within valid value ranges, so crashes are rare.
  • Data races on complex CoW / reference-counted types (String, [Int]) tend to crash:
    • They manipulate shared heap buffers, refcounts, and metadata.
    • Races corrupt those invariants, triggering use-after-free or allocator/runtime failures.
  • Lock and queue abstractions are easy to get mostly right but still fragile:
    • Safe only if users:
      • Don’t re-enter read/write from inside their closures on the same queue/lock.
      • Don’t leak mutable references and then mutate them unsafely elsewhere.
    • Useful when users:
      • Don't want to or can't introduce async-await in their piece of codebase
  • Actors are the safest high-level abstraction:
    • Isolation and Sendable checking are enforced by the compiler/runtime.
    • Many patterns that deadlock or crash with manual locking become compile-time errors.
    • But require async context which introduce another level of complexity

This project serves as a catalog of how different synchronization strategies behave in Swift. From completely unsafe to fully actor‑safe. It illustrates how subtle the distinction is between “works most of the time” and “actually correct.”

About

A project that demonstrates different strategies and caveats of Reader-Writer problem in Swift

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages