swiftasync-awaitthread-safetysingletonactor

thread safe swift singleton with async/ await and async init


Suppose I have a swift class written like this, how can I ensure the access to the shared() function is thread-safe?

class Singleton {
    static private var _shared: Singleton?
    static func shared() async -> Singleton {
        if let shared = _shared {
            return shared
        }
        let shared = await Singleton()
        _shared = shared
        return shared
    }
    
    private init() async {
        // Some mandatory async work
    }
}

I know that we can use actors for instances but how do I do this for static members?

I tried to do something like this

@globalActor
struct SingletonActor {
    public actor SingletonActor {}
    public static let shared = SingletonActor()
}

and then marked the Singleton class as @SingletonActor like

@SingletonActor
class Singleton { ... }

but that doesn't seem to be working either, what am I doing wrong?

EDIT: Practical use case example per comments

Essentially I want to ensure that all access to firestore first has valid authentication. So I have an AuthRules actor that validates the current user. This is called in the init of the FirestoreServer actor so all functions are ensured to have awaited for valid auth. Here is the practical code, using my unsafe static function access approach.

actor AuthRules {
    
    static func shared() async -> AuthRules {
        if let shared = _shared {
            return shared
        }
        let shared = await AuthRules()
        return shared
    }
    static private var _shared: AuthRules?
    
    private init() async {
        await validateAnonymous()
    }
    
    private func signInAnonymously() async {
        do {
            print("AuthBackend.signInAnonymously: Attempting to sign in user anonymously")
            let result = try await Auth.auth().signInAnonymously()
            print("AuthBackend.signInAnonymously: Auth signed in as \(result.user.uid)")
        } catch(let error) {
            print("AuthBackend.signInAnonymously: Unable to sign in user anonymously \(error.localizedDescription)")
        }
    }
    
    private func validateAnonymous() async {
        if let currentUser = Auth.auth().currentUser, currentUser.isAnonymous {
            print("AuthBackend.validateAnonymous: Auth signed in as \(currentUser.uid)")
        } else {
            await signInAnonymously()
        }
    }
}

actor FirestoreServer {
    
    static func server() async -> FirestoreServer {
        if let server = _server {
            return server
        }
        let server = await FirestoreServer()
        return server
    }
    static private var _server: FirestoreServer?
    
    private let db = Firestore.firestore()
    private let authRules: AuthRules
    
    private init() async {
        authRules = await AuthRules.shared()
    }
    
    /* several async functions that communicate with firestore through db */
}

Solution

  • The first example (a class with async method initializer and async rendition of shared()) is definitely not thread safe. And the property wrapper approach won’t work, either.

    If you really needed to have a singleton with an async initializer, I guess you could do something like:

    actor Foo {
        static private var task: Task<Foo, Never>?
        
        static func shared() async -> Foo {
            if let task {
                return await task.value
            }
            let task = Task { await Foo() }
            self.task = task
            return await task.value
        }
        
        private init() async {
            // something asynchronous, with an `await`
        }
    }
    

    Or, better, using an async property:

    actor Foo {
        static private var task: Task<Foo, Never>?
    
        static var shared: Foo {
            get async {
                if let task {
                    return await task.value
                }
                let task = Task { await Foo() }
                self.task = task
                return await task.value
            }
        }
    
        private init() async {
            // something asynchronous, with an `await`
        }
    }
    

    FWIW, Swift 5.10 will generate warnings on those static properties, but Swift 6 (in Xcode 16) does not.

    Needless to say, this obviously lacks the elegance and simplicity of the standard singleton pattern:

    final class Foo {
        static let shared = Foo()
        
        private init() {
            // standard synchronous initializer
        }
    }
    

    The idea behind the first snippet is to use a actor to eliminate data races, but because actors are reentrant, we await a Task for the original instantiation.

    It works, but I am hard pressed to think of a practical example where I would ever want to use something as cumbersome as this.