In Xcode 14.1 there is a new warning for using NSLock.lock() inside an async function, that says
Instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in Swift 6
What does "async-safe scoped locking" even mean, since there are no locks in swift concurrency?
What I actually need is a method that guarantees that a certain function call can not run in parallel (@synchronized). The closest I can think of in Swift is the following, but I am not certain that this is not racy:
It means to use Lock.withLock. This establishes a "scope" in which you are holding the lock (hence "scoped locking"), and it forbids await calls within that block, so it's guaranteed to be safe.
Ok, so that seems new in macOS 13, which I can't use yet. My deployment target is still 12. Also it seems Lock.withLock won't work, since I have to do a network request (refresh an authentication token) which is async while its locked.
Correct: you'll need a different object. You're conceptually looking for something like a token bucket or a queue. The downside of a token bucket is that it's inherently unowned, so you don't get sensible priority donation by waiters, but a better primitive isn't something you can build yourself so for now it's probably your best option.
A simple token bucket for the degenerate case of having only one token looks like this:
actor TokenBucket {
enum State {
case full
case empty(waiters: [CheckedContinuation<Void, Never>])
}
private var state: State
init() {
self.state = .full
}
private func enter() async {
switch self.state {
case .full:
self.state = .empty(waiters: [])
return
case .empty(waiters: var waiters):
await withCheckedContinuation {
waiters.append($0)
self.state = .empty(waiters: waiters)
}
}
}
private func exit() {
guard case .empty(waiters: var waiters) = self.state else {
fatalError("Exiting in invalid state")
}
if waiters.isEmpty {
self.state = .full
return
}
let nextWaiter = waiters.removeFirst()
self.state = .empty(waiters: waiters)
nextWaiter.resume()
}
func withToken<ReturnValue>(_ body: () async throws -> ReturnValue) async rethrows -> ReturnValue {
await self.enter()
defer {
self.exit()
}
return try await body()
}
}
This is not really an ideal primitive for the reasons noted above, but it will work for the basic use-case.
If I understood your problem correctly and you only want to do an async request once we can simplify this. We can just use a lazily initialised Task and cache it:
actor Cache {
lazy var token: Task<String, Error> = Task {
// do request
return "my token"
}
func getToken() async throws -> String {
return try await token.value
}
}
The WWDC talk about actors uses a similar approach but a bit more complicated as it caches request for a given URL.
The complete solution is not shown in the video but attached to the session. Here it is:
actor ImageDownloader {
private enum CacheEntry {
case inProgress(Task<Image, Error>)
case ready(Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value
}
}
let task = Task {
try await downloadImage(from: url)
}
cache[url] = .inProgress(task)
do {
let image = try await task.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
I'm not quite sure why we need the CacheEntry enum though. My best guess would be to release the Task to save some memory but @drexin should know.
I still don't know if this is due to the bad interaction between Xcode 14 and macOS before 13, or if this is strictly necessary. The people responsible for Swift concurrency won't discuss the proper way to implement a counting semaphore in a way that 1. fits their view of Swift concurrency, 2. back-deploys, 3. deals with Xcode 14 and macOS in the less painful way possible. ¯\_(ツ)_/¯
Did I mention that testing such code is hum hum tricky, since iOS simulators have a limited thread pool, and macOS faces the difficulties linked above?
When locks can be removed, AsyncSemaphore will stop using them. So far, this code is reliable to my knowledge. It answers the OP's question, and beyond (cancellation).
That's exactly how the warning is avoided, as designed by SE-0340:
The noasync availability attribute only prevents API usage in the immediate asynchronous context; wrapping a call to an unavailable API in a synchronous context and calling the wrapper will not emit an error. This allows for cases where it is possible to use the API safely within an asynchronous context, but in specific ways.
I think what's important here is that @gwendal.roue is not locking across an await, therefore he's doing a perfectly fine use of a lock (and honestly I don't even think it needs to be removed at some point) and solves the problem in a way your code doesn't.
It's too early, Swift concurrency is too young, and basic building blocks for everyday coding aren't settled yet.That's why you see sample code pasted in forum posts, and a few third party libraries.