2

I have a singleton class where I make a network call to initialise it. I want to make sure that load() is not called concurrently - I can cache the results and avoid unnecessary network round-trips. In the old world of GCD, I could use a DispatchQueue, DispatchGroup or a DispatchSemaphore to achieve this. In the new world of async/await, Actors seem to be the way to go. But I can't get it to work.

Example

With actors, only one function can run at a time - unless an async call is part of the function.

Let's define a log function for convenience:

func log(_ string: String) {
    print("\(Date.now) - \(string)")
}

And then compare the following two actors:

actor AsyncActor {
    static var shared = AsyncActor()
    
    func load() async throws {
        log("AsyncActor load() start")
        try await Task.sleep(nanoseconds: 1_000_000_000)
        log("AsyncActor load() end")
    }
}

Task { try? await AsyncActor.shared.load() }
Task { try? await AsyncActor.shared.load() }


actor SyncActor {
    static var shared = SyncActor()
    
    func load() async throws {
        log("SyncActor load() start")
        sleep(1)
        log("SyncActor load() end")
    }
}

Task { try? await SyncActor.shared.load() }
Task { try? await SyncActor.shared.load() }

Output:

2022-10-09 13:45:55 +0000 - AsyncActor load() start
2022-10-09 13:45:55 +0000 - SyncActor load() start
2022-10-09 13:45:55 +0000 - AsyncActor load() start
2022-10-09 13:45:56 +0000 - SyncActor load() end
2022-10-09 13:45:56 +0000 - SyncActor load() start
2022-10-09 13:45:56 +0000 - AsyncActor load() end
2022-10-09 13:45:56 +0000 - AsyncActor load() end
2022-10-09 13:45:57 +0000 - SyncActor load() end

(You can try this in a playground)

The same async code with semaphores works:

struct AsyncSemaphore {
    let semaphore = DispatchSemaphore(value: 1)
    
    static var shared = Self()
    
    func load() async throws {
        log("AsyncSemaphore load() barriers")
        semaphore.wait()
        defer { semaphore.signal() }
        log("AsyncSemaphore load() start")
        try await Task.sleep(nanoseconds: 1_000_000_000)
        log("AsyncSemaphore load() end")
    }
}

Task { try await AsyncSemaphore.shared.load() }
Task { try await AsyncSemaphore.shared.load() }

Output:

2022-10-09 13:49:24 +0000 - AsyncSemaphore load() barriers
2022-10-09 13:49:24 +0000 - AsyncSemaphore load() barriers
2022-10-09 13:49:24 +0000 - AsyncSemaphore load() start
2022-10-09 13:49:25 +0000 - AsyncSemaphore load() end
2022-10-09 13:49:25 +0000 - AsyncSemaphore load() start
2022-10-09 13:49:26 +0000 - AsyncSemaphore load() end

I'd normally be happy with this, but semaphore.wait() triggers the following warning in Xcode:

Instance method 'wait' is unavailable from asynchronous contexts; Await a Task handle instead; this is an error in Swift 6

Question

It seems that Actors do not provide the form of protection from concurrent execution that I want. How can I achieve with Async/Await the same behaviour I achieved with DispatchSemaphore?

Thomas Walther
  • 526
  • 1
  • 6
  • 15
  • 1
    I believe what you mean to do is put both calls to the actors in the same `Task`, one after the other. Like: `Task { try? await AsyncActor.shared.load() try? await AsyncActor.shared.load() }`. You will see the output "start-end-start-end". – HunterLion Oct 09 '22 at 14:16
  • No, that would be trivially serial. I’m trying to avoid that this function can be called in parallel globally. – Thomas Walther Oct 09 '22 at 14:50
  • I believe this is a duplicate of the linked answer. Let me know if you believe there is more to this question. (There is no actor equivalent to DispatchSemaphore, but you can serialize operations by chaining them.) See also https://stackoverflow.com/questions/70540541/swift-5-5-concurrency-how-to-serialize-async-tasks-to-replace-an-operationqueue/70586879#70586879 Also note that Actors are not a replacement for serial queues. They solve different problems (though there is some overlap). There is no concurrency replacement for serial queues currently. – Rob Napier Oct 09 '22 at 15:08
  • I agree with everything you said here, Rob. I might add, though, that serial dispatch queues are even worse than async-await at handling sequential series of tasks that are, themselves, asynchronous (which is why we often fell back to operation queues, combine, etc.). That having been said, Swift actors desperately need non-reentrant option (or, even better IMHO, constrained concurrency, a kin to OperationQueue’s `maxConcurrentOperationCount` or Combine’s `flatMap(maxPublishers:)`). – Rob Oct 09 '22 at 21:43
  • Thomas, see [Swift concurrency: Behind the scenes](https://developer.apple.com/videos/play/wwdc2021/10254/?time=1558) for a discussion about why unsafe primitives, like semaphores, are not permitted. – Rob Oct 10 '22 at 14:49
  • Thanks so much for all your comments, and sorry for only replying now. It turns out the magnificent Groue has solved this problem with a library: https://github.com/groue/Semaphore – Thomas Walther Nov 10 '22 at 14:58

0 Answers0