From 08c65b688e84e4d80c008b2c4a1eb242052a314d Mon Sep 17 00:00:00 2001 From: Kaho Yeung Date: Sun, 14 Sep 2025 01:20:52 +0100 Subject: [PATCH] basic implementation and unit tests --- Package.swift | 26 +++++ Sources/ThreadSafe/ThreadSafe.swift | 114 ++++++++++++++++++++ Tests/ThreadSafeTests/ThreadSafeTests.swift | 68 ++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 Package.swift create mode 100644 Sources/ThreadSafe/ThreadSafe.swift create mode 100644 Tests/ThreadSafeTests/ThreadSafeTests.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..70086e1 --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ThreadSafe", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "ThreadSafe", + targets: ["ThreadSafe"] + ), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "ThreadSafe" + ), + .testTarget( + name: "ThreadSafeTests", + dependencies: ["ThreadSafe"] + ), + ] +) diff --git a/Sources/ThreadSafe/ThreadSafe.swift b/Sources/ThreadSafe/ThreadSafe.swift new file mode 100644 index 0000000..78b40d0 --- /dev/null +++ b/Sources/ThreadSafe/ThreadSafe.swift @@ -0,0 +1,114 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book +import Foundation + +@propertyWrapper +public struct ThreadSafe { + + public var wrappedValue: T { + get { + lock.withReadLock { box.value } + } + set { + lock.withWriteLock { + if valueTypeCopyOnWrite { + let addrHash = withUnsafePointer(to: &self) { $0.hashValue } + if addrHash != lastAddressHash { + // addrHash changed - this is a copy + // make new box with new value + box = Box(newValue) + lastAddressHash = addrHash + // new lock for the copied property wrapper + lock = RWLock() + } else { + box.value = newValue + } + } else { + box.value = newValue + } + } + } + } + + /*** + Regarding `valueTypeCopyOnWrite`: + When `valueTypeCopyOnWrite` is `true` and the wrapped value type is a value type,\ + the property wrapper will do extra memory check on writes to determine if the current instance is under a copied struct,\ + and if so it will create a new box to ensure the original instance is not affected.\ + For reference types `valueTypeCopyOnWrite` does nothing.\ + You can disable it if you can be sure that maintaining value type copy isn't necessary, this will give you a back a tiny bit of performance as it won't need to check its memory address on writes anymore + ``` + struct Example { + // valueTypeCopyOnWrite enabled by default + @ThreadSafe var string: String = "Hello" + // valueTypeCopyOnWrite disabled + @ThreadSafe(valueTypeCopyOnWrite: false) var anotherString: String = "Hello" + // won't do anything as NSMutableString is a class + @ThreadSafe(valueTypeCopyOnWrite: true) var nsString: NSMutableString = "Hello" + } + + let example = Example() + var copy = example + copy.string.append(", World!") + print(example.string) // Hello + print(copy.string) // Hello, World! + copy.anotherString.append(", World!") + print(example.anotherString) // Hello, World! + print(copy.anotherString) // Hello, World! + copy.nsString.append(", World!") + print(example.nsString) // Hello, World! + print(copy.nsString) // Hello, World! + ``` + */ + public init(wrappedValue: T, valueTypeCopyOnWrite: Bool = true) { + self.box = Box(wrappedValue) + self.valueTypeCopyOnWrite = valueTypeCopyOnWrite && !(T.self is AnyObject.Type) + if self.valueTypeCopyOnWrite { + lastAddressHash = withUnsafePointer(to: &self) { $0.hashValue } + } + } + + private final class Box { + var value: T + init(_ value: T) { self.value = value } + } + + private var box: Box + + private var lock = RWLock() + + private let valueTypeCopyOnWrite: Bool + private var lastAddressHash: Int! + +} + +final class RWLock { + + private var rwlock = pthread_rwlock_t() + + init() { + // Initialize the read-write lock. + // The second parameter is for attributes, which can be nil for default attributes. + let status = pthread_rwlock_init(&rwlock, nil) + assert(status == 0, "Failed to initialize read-write lock: \(status)") + } + + deinit { + let status = pthread_rwlock_destroy(&rwlock) + assert(status == 0, "Failed to destroy read-write lock: \(status)") + } + + @discardableResult @inline(__always) + func withReadLock(_ body: () -> Result) -> Result { + pthread_rwlock_rdlock(&rwlock) + defer { pthread_rwlock_unlock(&rwlock) } + return body() + } + + @discardableResult @inline(__always) + func withWriteLock(_ body: () -> Result) -> Result { + pthread_rwlock_wrlock(&rwlock) + defer { pthread_rwlock_unlock(&rwlock) } + return body() + } +} diff --git a/Tests/ThreadSafeTests/ThreadSafeTests.swift b/Tests/ThreadSafeTests/ThreadSafeTests.swift new file mode 100644 index 0000000..7caee83 --- /dev/null +++ b/Tests/ThreadSafeTests/ThreadSafeTests.swift @@ -0,0 +1,68 @@ +import Testing +import Foundation +@testable import ThreadSafe + +class ThreadSafetyTest: @unchecked Sendable { + + class Obj { + let id = UUID().uuidString + } + + // If @ThreadSafe is removed, the test would crash instantly + @ThreadSafe var obj = Obj() + + @Test func testThreadSafe() async throws { + let coreCount = ProcessInfo.processInfo.activeProcessorCount + + for _ in 0 ..< coreCount / 2 { + Thread { // write thread + while true { + self.obj = .init() + } + }.start() + } + + for _ in 0 ..< coreCount / 2{ + Thread { // read thread + while self.obj.id.count != 0 {} + }.start() + } + + // If it doesn't crash after 10s, consider it passed. + // I think it's good enough as the possibility for a false positive is microscopic. + // Can't think of a way to definitively prove it's thread safe + try? await Task.sleep(for: .seconds(10)) + } +} + +@Test func TestCopyBehaviour() async throws { + struct Example { + // valueTypeCopyOnWrite enabled by default + @ThreadSafe var string: String = "Hello" + // valueTypeCopyOnWrite disabled + @ThreadSafe(valueTypeCopyOnWrite: false) var anotherString: String = "Hello" + // won't do anything as NSMutableString is a class + @ThreadSafe(valueTypeCopyOnWrite: true) var nsString: NSMutableString = "Hello" + // same as above + @ThreadSafe(valueTypeCopyOnWrite: false) var anotherNSString: NSMutableString = "Hello" + } + + let original = Example() + var copy = original + + copy.string.append(", World!") + #expect(original.string == "Hello") // copy on write triggered, original not affected + #expect(copy.string == "Hello, World!") + + copy.anotherString.append(", World!") + #expect(original.anotherString == "Hello, World!") // valueTypeCopyOnWrite == false, underlying box not copied + #expect(copy.anotherString == "Hello, World!") + + copy.nsString.append(", World!") + #expect(original.nsString == "Hello, World!") + #expect(copy.nsString == "Hello, World!") + + copy.anotherNSString.append(", World!") + #expect(original.anotherNSString == "Hello, World!") + #expect(copy.anotherNSString == "Hello, World!") +}