diff --git a/README.md b/README.md index e56f60b..433091a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdodobrands%2FDBThreadSafe-ios%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/dodobrands/DBThreadSafe-ios) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdodobrands%2FDBThreadSafe-ios%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/dodobrands/DBThreadSafe-ios) -DBThreadSafeContainer is a generic class that provides thread-safe read and write access to a stored value. It uses a `pthread_rwlock_t` lock to ensure that multiple threads can safely access the value concurrently. +DBThreadSafeContainer is a generic class that provides thread-safe read and write access to a stored value. By default it prefers Apple's `Synchronization.Mutex` on supported OS versions and falls back to `pthread_rwlock_t` elsewhere. ## Usage @@ -17,6 +17,59 @@ To create a new instance of DBThreadSafeContainer, simply initialize it with an let container = DBThreadSafeContainer("Hello, World!") ``` +The default initializer prefers `Synchronization.Mutex` when the current OS supports it: + +You can inspect the chosen backend through `lockType`: + +```swift +let container = DBThreadSafeContainer("Hello, World!") +let lockType = container.lockType // .mutex on supported OS versions, otherwise .pthreadRWLock +``` + +### Selecting a lock backend explicitly + +Use `DBThreadSafeLock` to force a specific backend: + +```swift +let pthreadContainer = DBThreadSafeContainer("Hello, World!", lock: .pthreadRWLock) +``` + +Use this explicit opt-out when you need the old `pthread_rwlock_t` semantics with concurrent readers or same-thread nested reads. + +`Synchronization.Mutex` can only be selected on supported platforms: + +```swift +if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + let mutexContainer = DBThreadSafeContainer("Hello, World!", lock: .mutex) +} +``` + +The same selection API is available on the property wrapper: + +```swift +@ThreadSafe(lock: .pthreadRWLock) var counter = 0 +``` + +### Mutex-compatible access + +If you plan to migrate from `DBThreadSafeContainer` to Apple's `Mutex`, prefer `withLock`: + +```swift +let length = container.withLock { value in + value.count +} +``` + +`withLock` also supports mutation through `inout`, matching the call-site shape of `Mutex`: + +```swift +container.withLock { value in + value += "!" +} +``` + +The existing `read` and `write` APIs remain available. When the mutex backend is active, both of them route through the same exclusive critical section as `withLock`. + ### Reading the value To read the value stored in the container, use the `read()` method: @@ -81,11 +134,16 @@ try container.write { value in ## Thread Safety -DBThreadSafeContainer ensures that read and write operations are thread-safe by using a `pthread_rwlock_t` lock. This allows multiple threads to read the value concurrently, while ensuring that only one thread can write to the value at a time. +DBThreadSafeContainer ensures that read and write operations are thread-safe, but the exact semantics depend on the selected backend: + +- `pthread_rwlock_t`: multiple readers can proceed concurrently, while writes remain exclusive +- `Synchronization.Mutex`: `read`, `write`, and `withLock` all use the same exclusive critical section + +The default initializer now prefers `Synchronization.Mutex` when available. If you need concurrent-reader behavior or same-thread nested `read` calls, select `.pthreadRWLock` explicitly. The mutex backend is not re-entrant: calling `read` again from inside `read`/`withLock` on the same container can deadlock. ## Cleanup -DBThreadSafeContainer automatically destroys the `pthread_rwlock_t` lock when it is deallocated to prevent any resource leaks. +DBThreadSafeContainer automatically cleans up the selected lock backend when it is deallocated. ## License diff --git a/Sources/DBThreadSafe/DBThreadSafeContainer.swift b/Sources/DBThreadSafe/DBThreadSafeContainer.swift index 2fee5d1..2c22d03 100644 --- a/Sources/DBThreadSafe/DBThreadSafeContainer.swift +++ b/Sources/DBThreadSafe/DBThreadSafeContainer.swift @@ -1,45 +1,73 @@ import Foundation public final class DBThreadSafeContainer: Sendable { - nonisolated(unsafe) private var value: T - private let lock = Lock() - + private let storage: any LockStorage + + /// The concrete lock backend currently used by the container. + public var lockType: DBThreadSafeLock { + storage.lockType + } + public init(_ value: T) { - self.value = value + self.storage = Self.makeDefaultStorage(value) + } + + public init(_ value: T, lock: DBThreadSafeLock) { + self.storage = Self.makeStorage(value, lock: lock) } - + + private static func makeDefaultStorage(_ value: T) -> any LockStorage { + #if canImport(Synchronization) + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + return MutexStorage(value) + } + #endif + + return PThreadRWLockStorage(value) + } + + private static func makeStorage(_ value: T, lock: DBThreadSafeLock) -> any LockStorage { + switch lock { + case .pthreadRWLock: + return PThreadRWLockStorage(value) +#if canImport(Synchronization) + case .mutex: + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + return MutexStorage(value) + } else { + preconditionFailure("DBThreadSafeLock.mutex requires a supported OS version") + } +#endif + } + } + /// Reads the value stored /// - Returns: The value stored in the container. public func read() -> T { - lock.readLock() - defer { lock.unlock() } - return value + storage.read() } - + public func read(_ closure: (_ value: T) throws -> Void) rethrows { - lock.readLock() - defer { lock.unlock() } - try closure(value) + try storage.read(closure) } - + public func read(_ closure: (_ value: T) throws -> U) rethrows -> U { - lock.readLock() - defer { lock.unlock() } - return try closure(value) + try storage.read(closure) } - + + /// Executes a closure while holding an exclusive lock on the stored value. + public func withLock(_ closure: (_ value: inout T) throws -> U) rethrows -> U { + try storage.withLock(closure) + } + /// Replaces current value with a new one /// - Parameter newValue: The new value to be stored in the container. public func write(_ newValue: T) { - lock.writeLock() - defer { lock.unlock() } - value = newValue + storage.write(newValue) } - + /// Returns current value in a closure with possibility to make multiple modifications of any kind inside a single lock. public func write(_ closure: (_ value: inout T) throws -> Void) rethrows { - lock.writeLock() - defer { lock.unlock() } - try closure(&value) + try storage.write(closure) } } diff --git a/Sources/DBThreadSafe/DBThreadSafeLock.swift b/Sources/DBThreadSafe/DBThreadSafeLock.swift new file mode 100644 index 0000000..7b86f6a --- /dev/null +++ b/Sources/DBThreadSafe/DBThreadSafeLock.swift @@ -0,0 +1,11 @@ +/// Selects the synchronization backend used by `DBThreadSafeContainer`. +public enum DBThreadSafeLock: Sendable { + /// Uses the `pthread_rwlock_t` backend. Multiple readers may proceed concurrently. + case pthreadRWLock + +#if canImport(Synchronization) + /// Uses the `Synchronization.Mutex` backend. + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + case mutex +#endif +} diff --git a/Sources/DBThreadSafe/Lock.swift b/Sources/DBThreadSafe/Lock.swift index ec0076f..5dd98ad 100644 --- a/Sources/DBThreadSafe/Lock.swift +++ b/Sources/DBThreadSafe/Lock.swift @@ -1,27 +1,132 @@ import Foundation +#if canImport(Synchronization) +import Synchronization +#endif + +protocol LockStorage: AnyObject, Sendable { + associatedtype Value + + var lockType: DBThreadSafeLock { + get + } + + func read() -> Value + func read(_ closure: (_ value: Value) throws -> U) rethrows -> U + func write(_ newValue: Value) + func write(_ closure: (_ value: inout Value) throws -> Void) rethrows + func withLock(_ closure: (_ value: inout Value) throws -> U) rethrows -> U +} + +final class PThreadRWLockStorage: LockStorage, @unchecked Sendable { + nonisolated(unsafe) private var value: T + private let lock = Lock() + + var lockType: DBThreadSafeLock { + .pthreadRWLock + } + + init(_ value: T) { + self.value = value + } + + func read() -> T { + lock.readLock() + defer { lock.unlock() } + return value + } + + func read(_ closure: (_ value: T) throws -> U) rethrows -> U { + lock.readLock() + defer { lock.unlock() } + return try closure(value) + } + + func write(_ newValue: T) { + lock.writeLock() + defer { lock.unlock() } + value = newValue + } + + func write(_ closure: (_ value: inout T) throws -> Void) rethrows { + lock.writeLock() + defer { lock.unlock() } + try closure(&value) + } + + func withLock(_ closure: (_ value: inout T) throws -> U) rethrows -> U { + lock.writeLock() + defer { lock.unlock() } + return try closure(&value) + } +} + +#if canImport(Synchronization) +@available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) +final class MutexStorage: LockStorage, @unchecked Sendable { + nonisolated(unsafe) private var value: T + private let mutex: Mutex + + var lockType: DBThreadSafeLock { + .mutex + } + + init(_ value: T) { + self.value = value + self.mutex = Mutex(()) + } + + func read() -> T { + withLock { value in + value + } + } + + func read(_ closure: (_ value: T) throws -> U) rethrows -> U { + try withLock { value in + try closure(value) + } + } + + func write(_ newValue: T) { + withLock { value in + value = newValue + } + } + + func write(_ closure: (_ value: inout T) throws -> Void) rethrows { + try withLock(closure) + } + + func withLock(_ closure: (_ value: inout T) throws -> U) rethrows -> U { + try mutex.withLock { _ in + try closure(&value) + } + } +} +#endif final class Lock: Sendable { nonisolated(unsafe) private let lock = UnsafeMutablePointer.allocate(capacity: 1) - + init() { precondition(pthread_rwlock_init(lock, nil) == 0, "Failed to initialize the lock") } - + func readLock() { pthread_rwlock_rdlock(lock) } - + func writeLock() { pthread_rwlock_wrlock(lock) } - + func unlock() { pthread_rwlock_unlock(lock) } - + deinit { precondition(pthread_rwlock_destroy(lock) == 0, "Failed to destroy the lock") - + lock.deallocate() } } diff --git a/Sources/DBThreadSafe/ThreadSafe.swift b/Sources/DBThreadSafe/ThreadSafe.swift index dad0543..2497bb1 100644 --- a/Sources/DBThreadSafe/ThreadSafe.swift +++ b/Sources/DBThreadSafe/ThreadSafe.swift @@ -8,6 +8,10 @@ public final class ThreadSafe { self.container = DBThreadSafeContainer(wrappedValue) } + public init(wrappedValue: T, lock: DBThreadSafeLock) { + self.container = DBThreadSafeContainer(wrappedValue, lock: lock) + } + public var wrappedValue: T { get { container.read() @@ -22,6 +26,7 @@ public final class ThreadSafe { } } + /// Exposes the underlying container, including `read`, `write`, `withLock`, and `lockType`. public var projectedValue: DBThreadSafeContainer { container } diff --git a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift index 9e5c1e1..9a28541 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift @@ -6,6 +6,50 @@ import Testing struct DBThreadSafeContainerTests { let iterations = 100000 + @Test("Explicit pthread rwlock selection reports pthread backend") + func explicitPThreadRWLockSelection() { + let container = DBThreadSafeContainer(0, lock: .pthreadRWLock) + + #expect(container.lockType == .pthreadRWLock) + #expect(container.read() == 0) + } + +#if canImport(Synchronization) + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("Explicit mutex selection reports mutex backend") + func explicitMutexSelection() { + let container = DBThreadSafeContainer(0, lock: .mutex) + + #expect(container.lockType == .mutex) + #expect(container.read() == 0) + } +#endif + + @Test("Default initializer prefers mutex backend when available") + func defaultInitializerPrefersMutexBackendWhenAvailable() { + let container = DBThreadSafeContainer(0) + + #if canImport(Synchronization) + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + #expect(container.lockType == .mutex) + } else { + #expect(container.lockType == .pthreadRWLock) + } + #else + #expect(container.lockType == .pthreadRWLock) + #endif + #expect(container.read() == 0) + } + + @Test("Explicit pthread backend preserves nested read access") + func explicitPThreadBackendPreservesNestedReadAccess() { + let container = DBThreadSafeContainer(0, lock: .pthreadRWLock) + + container.read { _ in + #expect(container.read() == 0) + } + } + @Test("Concurrent reads return correct value") func concurrentGet() { let container = DBThreadSafeContainer(0) @@ -126,4 +170,53 @@ struct DBThreadSafeContainerTests { #expect(container.read() == ["key": iterations]) } + // MARK: - Mutex-compatible API + + @Test("withLock returns transformed value") + func withLockReturnValue() { + let container = DBThreadSafeContainer("Hello, World!") + + let length = container.withLock { value in + value.count + } + + #expect(length == 13) + } + + @Test("withLock provides inout access for mutation") + func withLockMutation() { + let container = DBThreadSafeContainer(0) + + container.withLock { value in + value = 42 + } + + #expect(container.read() == 42) + } + + @Test("Concurrent increments via withLock") + func concurrentWithLock() { + let container = DBThreadSafeContainer(0) + + DispatchQueue.concurrentPerform(iterations: iterations) { _ in + container.withLock { value in + value += 1 + } + } + + #expect(container.read() == iterations) + } + + @Test("withLock rethrows from throwing closure") + func withLockThrowing() { + let container = DBThreadSafeContainer(0) + + enum TestError: Error { case someError } + + #expect(throws: TestError.self) { + try container.withLock { _ in + throw TestError.someError + } + } + } } diff --git a/Tests/DBThreadSafeTests/LockStorageTests.swift b/Tests/DBThreadSafeTests/LockStorageTests.swift new file mode 100644 index 0000000..4f0591b --- /dev/null +++ b/Tests/DBThreadSafeTests/LockStorageTests.swift @@ -0,0 +1,24 @@ +@testable import DBThreadSafe +import Testing + +@Suite("LockStorage Protocol Tests") +struct LockStorageTests { + @Test("LockStorage supports existential storage for pthread backend") + func existentialPThreadStorage() { + let storage: any LockStorage = PThreadRWLockStorage(42) + + #expect(storage.lockType == .pthreadRWLock) + #expect(storage.read() == 42) + } + +#if canImport(Synchronization) + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("LockStorage supports existential storage for mutex backend") + func existentialMutexStorage() { + let storage: any LockStorage = MutexStorage(42) + + #expect(storage.lockType == .mutex) + #expect(storage.read() == 42) + } +#endif +} diff --git a/Tests/DBThreadSafeTests/ThreadSafeTests.swift b/Tests/DBThreadSafeTests/ThreadSafeTests.swift index c327643..61fa5c4 100644 --- a/Tests/DBThreadSafeTests/ThreadSafeTests.swift +++ b/Tests/DBThreadSafeTests/ThreadSafeTests.swift @@ -6,6 +6,41 @@ import Testing struct ThreadSafeTests { let iterations = 100000 + @Test("Property wrapper supports explicit pthread rwlock selection") + func explicitPThreadRWLockSelection() { + @ThreadSafe(lock: .pthreadRWLock) var counter = 42 + + #expect(counter == 42) + #expect($counter.lockType == .pthreadRWLock) + } + +#if canImport(Synchronization) + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("Property wrapper supports explicit mutex selection") + func explicitMutexSelection() { + @ThreadSafe(lock: .mutex) var counter = 42 + + #expect(counter == 42) + #expect($counter.lockType == .mutex) + } +#endif + + @Test("Property wrapper default prefers mutex backend when available") + func defaultSelectionPrefersMutexBackendWhenAvailable() { + @ThreadSafe var counter = 42 + + #expect(counter == 42) + #if canImport(Synchronization) + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + #expect($counter.lockType == .mutex) + } else { + #expect($counter.lockType == .pthreadRWLock) + } + #else + #expect($counter.lockType == .pthreadRWLock) + #endif + } + @Test("Reading wrappedValue returns correct value") func readWrappedValue() { @ThreadSafe var counter = 42