swiftstaticthread-safetyswift-concurrencyswift6

How to access a static property Swift 6 concurrency-safe?


My app uses a class A and its subclasses SubA_x. A has a static property serialNumber that is only modified by class A when a subclass is initialized. It sets a property let name so that every subclass has a unique name. Here is the code:

class A {
    static var serialNumber = 0
    let name: String

    init( /* some parameters */ ) {
        A.serialNumber += 1
        self.name = "\(A.serialNumber)"
    }
}

final class SubA_1: A {
    init( /* some parameters */ ) {
        super.init( /* some parameters */ )
    }
}

With Swift 6 strict concurrency checking, the line where serialNumber is initialized gives the error

Static property 'serialNumber' is not concurrency-safe because it is non-isolated global shared mutable state

I understand that every subclass of A could modify var serialNumber from any thread, so data races are possible.

But how do I achieve this functionality in a concurrency-safe way?

I tried to provide serialNumber by an actor:

actor SerialNumberManager {
    private var serialNumber = 0
    
    func getNextSerialNumber() -> Int {
        serialNumber += 1
        return serialNumber
    }
}

but then I cannot call getNextSerialNumber() in init, except when init is async. But then I can initialize a subclass only in an async context, etc.

I could probably provide my own synchronization based on GCD, but there should be a way to do it within Swift concurrency.


Solution

  • If you want a thread-safe, shared state, an actor is one logical approach. If you want one that you can invoke from synchronous contexts, though, you can just write your own manager, implementing your own manual synchronization (either with GCD serial queue or, as shown below, with a lock):

    class SerialNumberManager: @unchecked Sendable {
        static let shared = SerialNumberManager()
    
        private let lock = NSLock()
        private var serialNumber = 0
    
        private init() { }
    
        func nextSerialNumber() -> Int {
            lock.withLock {
                serialNumber += 1
                return serialNumber
            }
        }
    }
    

    Note, we would only use the @unchecked Sendable when we have implemented the manual synchronization, like above.

    And, you could use it like so:

    class A {
        let serialNumber = SerialNumberManager.shared.nextSerialNumber()
        let name: String
    
        init( /* some parameters */ ) {
            self.name = "\(serialNumber)"
        }
    }
    

    Alternatively, you could use an OSAllocatedUnfairLock, which already is Sendable:

    import os.lock
    
    class A {
        private static let serialNumber = OSAllocatedUnfairLock(initialState: 0)
        let name: String
    
        init( /* some parameters */ ) {
            let value = Self.serialNumber.withLock { value in
                value += 1
                return value
            }
            self.name = "\(value)"
        }
    }
    

    For the sake of completeness, other alternatives include atomics or UUIDs.