From 35260ba3a9dc6a4f9a32869734c7b46f8a2451df Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Thu, 30 Apr 2026 16:55:25 +0500 Subject: [PATCH 1/4] feat: add selectable Mutex backend Add DBThreadSafeLock for explicit backend selection and keep the public API source-compatible while defaulting to Synchronization.Mutex on supported OS versions with fallback to pthread_rwlock_t elsewhere. Also preserve nested read compatibility for the default Mutex-backed path and document the backend semantics and availability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 45 ++++- .../DBThreadSafe/DBThreadSafeContainer.swift | 68 +++++--- Sources/DBThreadSafe/DBThreadSafeLock.swift | 11 ++ Sources/DBThreadSafe/Lock.swift | 163 +++++++++++++++++- Sources/DBThreadSafe/ThreadSafe.swift | 8 + .../DBThreadSafeContainerTests.swift | 45 +++++ Tests/DBThreadSafeTests/ThreadSafeTests.swift | 28 +++ 7 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 Sources/DBThreadSafe/DBThreadSafeLock.swift diff --git a/README.md b/README.md index e56f60b..c0656d4 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,40 @@ To create a new instance of DBThreadSafeContainer, simply initialize it with an let container = DBThreadSafeContainer("Hello, World!") ``` +The default initializer automatically selects the concrete lock backend: + +- `Synchronization.Mutex` on iOS 18+, macOS 15+, macCatalyst 18+, tvOS 18+, watchOS 11+, visionOS 2+ +- `pthread_rwlock_t` everywhere else + +You can inspect the chosen backend through `lockType`: + +```swift +let container = DBThreadSafeContainer("Hello, World!") +let lockType = container.lockType +``` + +### Selecting a lock backend explicitly + +Use `DBThreadSafeLock` to force a specific backend: + +```swift +let pthreadContainer = DBThreadSafeContainer("Hello, World!", lock: .pthreadRWLock) +``` + +`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 +``` + ### Reading the value To read the value stored in the container, use the `read()` method: @@ -81,11 +115,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`: both reads and writes are exclusive critical sections + +Because the default initializer now prefers `Mutex` on supported OS versions, the same source code may use concurrent reads on older systems and exclusive reads on newer ones. ## 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..2f99a45 100644 --- a/Sources/DBThreadSafe/DBThreadSafeContainer.swift +++ b/Sources/DBThreadSafe/DBThreadSafeContainer.swift @@ -1,45 +1,65 @@ import Foundation public final class DBThreadSafeContainer: Sendable { - nonisolated(unsafe) private var value: T - private let lock = Lock() - + private let storage: LockStorage + + /// The concrete lock backend currently used by the container. + public var lockType: DBThreadSafeLock { + storage.lockType + } + + /// Creates a container that prefers `Synchronization.Mutex` when the current OS supports it + /// and otherwise falls back to the `pthread_rwlock_t` backend. public init(_ value: T) { - self.value = value +#if canImport(Synchronization) + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + self.storage = MutexStorage(value) + } else { + self.storage = PThreadRWLockStorage(value) + } +#else + self.storage = PThreadRWLockStorage(value) +#endif + } + + /// Creates a container using the explicitly requested lock backend. + public init(_ value: T, lock: DBThreadSafeLock) { + switch lock { + case .pthreadRWLock: + self.storage = PThreadRWLockStorage(value) +#if canImport(Synchronization) + case .mutex: + if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + self.storage = 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.readValue() } - + public func read(_ closure: (_ value: T) throws -> Void) rethrows { - lock.readLock() - defer { lock.unlock() } - try closure(value) + try storage.withReadValue(closure) } - + public func read(_ closure: (_ value: T) throws -> U) rethrows -> U { - lock.readLock() - defer { lock.unlock() } - return try closure(value) + try storage.withReadValue(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.overwrite(with: 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.withWriteValue(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..209ee84 100644 --- a/Sources/DBThreadSafe/Lock.swift +++ b/Sources/DBThreadSafe/Lock.swift @@ -1,27 +1,178 @@ import Foundation +#if canImport(Synchronization) +import Synchronization +#endif + +class LockStorage: @unchecked Sendable { + var lockType: DBThreadSafeLock { + fatalError("Subclasses must override lockType") + } + + func readValue() -> T { + fatalError("Subclasses must override readValue()") + } + + func withReadValue(_ closure: (_ value: T) throws -> Void) rethrows { + fatalError("Subclasses must override withReadValue(_:)") + } + + func withReadValue(_ closure: (_ value: T) throws -> U) rethrows -> U { + fatalError("Subclasses must override withReadValue(_:)") + } + + func overwrite(with newValue: T) { + fatalError("Subclasses must override overwrite(with:)") + } + + func withWriteValue(_ closure: (_ value: inout T) throws -> Void) rethrows { + fatalError("Subclasses must override withWriteValue(_:)") + } +} + +final class PThreadRWLockStorage: LockStorage, @unchecked Sendable { + nonisolated(unsafe) private var value: T + private let lock = Lock() + + override var lockType: DBThreadSafeLock { + .pthreadRWLock + } + + init(_ value: T) { + self.value = value + } + + override func readValue() -> T { + lock.readLock() + defer { lock.unlock() } + return value + } + + override func withReadValue(_ closure: (_ value: T) throws -> Void) rethrows { + lock.readLock() + defer { lock.unlock() } + try closure(value) + } + + override func withReadValue(_ closure: (_ value: T) throws -> U) rethrows -> U { + lock.readLock() + defer { lock.unlock() } + return try closure(value) + } + + override func overwrite(with newValue: T) { + lock.writeLock() + defer { lock.unlock() } + value = newValue + } + + override func withWriteValue(_ closure: (_ value: inout T) throws -> Void) rethrows { + lock.writeLock() + defer { lock.unlock() } + 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 + private let readDepthToken = NSObject() + + override var lockType: DBThreadSafeLock { + .mutex + } + + init(_ value: T) { + self.value = value + self.mutex = Mutex(()) + } + + override func readValue() -> T { + withNestedReadSupport { value in + value + } + } + + override func withReadValue(_ closure: (_ value: T) throws -> Void) rethrows { + try withNestedReadSupport(closure) + } + + override func withReadValue(_ closure: (_ value: T) throws -> U) rethrows -> U { + try withNestedReadSupport(closure) + } + + override func overwrite(with newValue: T) { + mutex.withLock { _ in + value = newValue + } + } + + override func withWriteValue(_ closure: (_ value: inout T) throws -> Void) rethrows { + try mutex.withLock { _ in + try closure(&value) + } + } + + private var readDepthKey: String { + "dbthreadsafe.mutex.readDepth.\(UInt(bitPattern: Unmanaged.passUnretained(readDepthToken).toOpaque()))" + } + + private var currentReadDepth: Int { + Thread.current.threadDictionary[readDepthKey] as? Int ?? 0 + } + + private func incrementReadDepth() { + Thread.current.threadDictionary[readDepthKey] = currentReadDepth + 1 + } + + private func decrementReadDepth() { + let newValue = currentReadDepth - 1 + + if newValue > 0 { + Thread.current.threadDictionary[readDepthKey] = newValue + } else { + Thread.current.threadDictionary.removeObject(forKey: readDepthKey) + } + } + + private func withNestedReadSupport(_ closure: (_ value: T) throws -> U) rethrows -> U { + if currentReadDepth > 0 { + return try closure(value) + } + + return try mutex.withLock { _ in + incrementReadDepth() + defer { decrementReadDepth() } + + return 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..ade4b42 100644 --- a/Sources/DBThreadSafe/ThreadSafe.swift +++ b/Sources/DBThreadSafe/ThreadSafe.swift @@ -4,10 +4,17 @@ import Foundation public final class ThreadSafe { private let container: DBThreadSafeContainer + /// Creates a thread-safe wrapper that prefers `Synchronization.Mutex` when available and + /// otherwise falls back to the `pthread_rwlock_t` backend. public init(wrappedValue: T) { self.container = DBThreadSafeContainer(wrappedValue) } + /// Creates a thread-safe wrapper using the explicitly requested lock backend. + public init(wrappedValue: T, lock: DBThreadSafeLock) { + self.container = DBThreadSafeContainer(wrappedValue, lock: lock) + } + public var wrappedValue: T { get { container.read() @@ -22,6 +29,7 @@ public final class ThreadSafe { } } + /// Exposes the underlying container, including `read`, `write`, and `lockType`. public var projectedValue: DBThreadSafeContainer { container } diff --git a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift index 9e5c1e1..475b95f 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift @@ -6,6 +6,51 @@ 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) + } + + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("Default initializer prefers mutex when available") + func defaultInitializerPrefersMutexWhenAvailable() { + let container = DBThreadSafeContainer(0) + + #expect(container.lockType == .mutex) + #expect(container.read() == 0) + } + + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("Default backend preserves nested read access") + func defaultBackendPreservesNestedReadAccess() { + let container = DBThreadSafeContainer(0) + let finished = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + container.read { _ in + #expect(container.read() == 0) + } + + finished.signal() + } + + #expect(finished.wait(timeout: .now() + 1) == .success) + } +#endif + @Test("Concurrent reads return correct value") func concurrentGet() { let container = DBThreadSafeContainer(0) diff --git a/Tests/DBThreadSafeTests/ThreadSafeTests.swift b/Tests/DBThreadSafeTests/ThreadSafeTests.swift index c327643..190a9b2 100644 --- a/Tests/DBThreadSafeTests/ThreadSafeTests.swift +++ b/Tests/DBThreadSafeTests/ThreadSafeTests.swift @@ -6,6 +6,34 @@ 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) + } + + @available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + @Test("Property wrapper default prefers mutex when available") + func defaultSelectionPrefersMutexWhenAvailable() { + @ThreadSafe var counter = 42 + + #expect(counter == 42) + #expect($counter.lockType == .mutex) + } +#endif + @Test("Reading wrappedValue returns correct value") func readWrappedValue() { @ThreadSafe var counter = 42 From 324f5416a51e66575050500b05bcd0a3961aaae4 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Thu, 30 Apr 2026 17:43:20 +0500 Subject: [PATCH 2/4] test: increase nested read timeout Increase the nested read regression test timeout from 1 to 3 seconds to reduce watchOS simulator flakiness in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift index 475b95f..e0ba36c 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift @@ -47,7 +47,7 @@ struct DBThreadSafeContainerTests { finished.signal() } - #expect(finished.wait(timeout: .now() + 1) == .success) + #expect(finished.wait(timeout: .now() + 3) == .success) } #endif From cae59bb2822c67d6be2979c69dbffa01f38fb953 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Thu, 30 Apr 2026 17:49:45 +0500 Subject: [PATCH 3/4] test: remove async wait from nested read regression Rewrite the nested read regression test to assert the same-thread nested read behavior directly instead of relying on DispatchQueue scheduling and semaphore timeouts, which proved flaky on simulator CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DBThreadSafeContainerTests.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift index e0ba36c..cb8cd9a 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeContainerTests.swift @@ -37,17 +37,10 @@ struct DBThreadSafeContainerTests { @Test("Default backend preserves nested read access") func defaultBackendPreservesNestedReadAccess() { let container = DBThreadSafeContainer(0) - let finished = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - container.read { _ in - #expect(container.read() == 0) - } - - finished.signal() + container.read { _ in + #expect(container.read() == 0) } - - #expect(finished.wait(timeout: .now() + 3) == .success) } #endif From 46b368d8cf1454db44ddad735feee244c09dc4a5 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Thu, 30 Apr 2026 17:59:14 +0500 Subject: [PATCH 4/4] ci: use Xcode 26 in tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .xcode-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.xcode-version b/.xcode-version index 19c7bdb..6f4247a 100644 --- a/.xcode-version +++ b/.xcode-version @@ -1 +1 @@ -16 \ No newline at end of file +26