Struct property wrapper with NSLock crashed while concurrently access

The following property wrapper will crash:

@propertyWrapper
struct Atomic<T> {
    private var value: T
    private let locker = NSLock()
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    var wrappedValue: T {
        _read {
            locker.lock()
            defer { locker.unlock() }
            yield value
        }
        _modify {
            locker.lock()
            defer { locker.unlock() }
            yield &value
        }
    }
}


final class Main: @unchecked Sendable {

    @Atomic
    var arr = [Int]()

    func run() {
        DispatchQueue.global().async {
            DispatchQueue.concurrentPerform(iterations: 100_000_000) { _ in
                self.arr.append(0)
            }
            print(self.arr.count)
        }
    }
}

let m = Main()
m.run()

But if i use class instead, it won't crash, why?

@propertyWrapper
final class Atomic<T> {
    private var value: T
    private let locker = NSLock()
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    var wrappedValue: T {
        _read {
            locker.lock()
            defer { locker.unlock() }
            yield value
        }
        _modify {
            locker.lock()
            defer { locker.unlock() }
            yield &value
        }
    }
}
1 Like

Just as a couple quick notes that shouldn't be related to the crash:

  • This doesn't compile for me unless I add import Foundation
  • That kind of property wrapper is not generally sufficient to ensure thread-safety

Anyway, when I run this it doesn't appear to crash... but also doesn't print anything, which is equally odd.

can you try again?
Add the code below to the last line.

while true {}

Have you considered using Mutex or OSAllocatedUnfairLock, which jump through the necessary hoops to do this safely for you, and are also faster and use less memory?

i intentionally use NSLock and not use Mutex. as i just want to know why using struct will crash but class won't

Accessing wrappedValue on the struct via the _modify accessor requires exclusive access to the entire struct. Since that exclusivity is asserted over the whole struct, it occurs before you have a chance to grab the lock,so you have racing exclusive accesses to the arr variable and the behavior is undefined. In the class case, since accessing wrappedValue doesn't need to modify the object reference itself, exclusive access isn't needed until you access the value property of the object, which happens inside of the lock.

16 Likes

Hey Carl, the issue comes from struct value semantics. When Atomic is a struct, each time wrappedValue is accessed, a copy is made, and NSLock doesn’t protect these copies. That’s why concurrent writes lead to crashes. When using a class, the reference remains the same, and NSLock properly synchronizes access to value. Also, when debugging tricky concurrency issues, I take breaks and read Surah Yaseen (https://suraheyaseen.com/)—it helps me reset and focus. Hope this helps!

@carlhung

I have been unable to reproduce your crash with your example using the struct rendition of your @Atomic property wrapper on macOS with Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1; arm64-apple-macosx15.0).

Admittedly, since you are dispatching asynchronously to a global queue, it begs the question of how you are preventing the app from terminating before the asynchronous work is done. It must not be a command line app as suggested by code in the question. Or perhaps you have something else (like your own run loop or doing this within a UIKit/AppKit/SwiftUI app) letting this run to completion before the app terminates.

Can you provide more details about your project and/or environment for this example? Can you help us reproduce the crash?


As an aside, it does seem antithetical to use a value type (a struct), with inherent copy semantics, for a property wrapper designed to manage concurrent access. If you were to do this, I might declare struct rendition of Atomic as ~Copyable (to bring to your attention any unintentional copying). But a reference type (a class) seems less constraining and more idiomatic for concurrent access. But it is hard to comment on your crash without being able to reproduce it.

The particular example you provide (where you mutate from within a closure) would seem to avoid initiating copies, but we can easily construct examples where copies would be made with unintended behaviors.

I was able to reproduce the crash with the struct.

Object 0x600000d60120 of class _ContiguousArrayStorage deallocated with non-zero retain count 2. This object's deinit, or something called from it, may have created a strong reference to self which outlived deinit, resulting in a dangling reference.

However, this confuses me—I would have expected an exclusive access to memory violation since we are accessing the locker property inside _modify.

Edit: After thinking it through, I realize that nothing happening inside _modify itself is inherently incorrect. The issue is that the lock isn’t preventing overlapping _modify calls, which is not allowed for structs. I suspect the crash above is just a side effect of the racing access.

here's a variant that crashes consistently for me (same environment you posted, which i assume is Xcode 16.2 on macos 15.x):

import Foundation

@propertyWrapper
struct Atomic<T> {
    private var value: T
    private let locker = NSLock()
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    var wrappedValue: T {
        _read {
            locker.lock()
            defer { locker.unlock() }
            yield value
        }
        _modify {
            locker.lock()
            defer { locker.unlock() }
            yield &value
        }
    }
}

final class Main: @unchecked Sendable {
    @Atomic
    var arr = [Int]()

    func run() async {
        await withTaskGroup(of: Void.self) { grp in
            for _ in 1...10 { // increase this if needed. eventually seems to crash
                grp.addTask {
                    self.arr.append(0)
                }
            }
        }
    }
}

@main
struct App {
    @Atomic
    var arr = [Int]()

    static func main() async {
        let m = Main()
        await m.run()
    }
}

build and run via:

swiftc -swift-version 6 -parse-as-library <file>.swift && ./<file>

the exact failure mode appears (unsurprisingly) nondeterministic – sometimes it crashes with SIGABRT, sometimes it aborts on retain count invariants being broken, etc. building with TSAN on will point to the racing accesses:

swiftc -swift-version 6 -parse-as-library -sanitize=thread <file>.swift && ./<file>
1 Like