What does "Use async-safe scoped locking instead" even mean?

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:

actor Foo {

    var isCalling = false

    func call(_ i: Int) async {
        while isCalling {
            await Task.yield()
        }
        isCalling = true
        defer {
            isCalling = false
        }
        print("---")
        print(i)
        await sleepRandom()
        print(i)
    }

    private func sleepRandom() async {
        try! await Task.sleep(nanoseconds: UInt64.random(in: 100_000_000...500_000_000))
    }
}

The problem with actor is, that call() could still be invoked while await sleepRandom() has suspended the previous task invoking call().

How am I supposed to replace a lock() call in Swift concurrency?

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.

3 Likes

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.

You can straightforwardly reinvent it:

extension NSLock {
    func withLock<ReturnValue>(_ body: () throws -> ReturnValue) rethrows -> ReturnValue {
        self.lock()
        defer {
            self.unlock()
        }
        return try body()
    } 
}
3 Likes

This is a problem, I need to ensure that a network request is only done once (refreshing an auth token).

So withLock is not an option as it seems, even if I use a shim :)

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.

1 Like

Thanks. This seems really excessive for basic functionality.

This is a worrysome trend (the last 10 years) that Apple always introduces new things that do not have at least the feature set of the old thing.

Just as an example, I have tried to use Actors in a meaningful context for a year now, but there are none (except for @MainActor).

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.

You can use an AsyncSemaphore:

actor MyActor {
  private let semaphore = AsyncSemaphore(value: 1)
  
  func call(_ i: Int) async {
    // Makes sure no two tasks can execute
    // call(_:) concurrently. 
    await semaphore.wait()
    defer { semaphore.signal() }
    
    print("---")
    print(i)
    await sleepRandom()
    print(i)
  }
}

And if you need to support cancellation:

func call(_ i: Int) async throws {
  // throws CancellationError if the current task is cancelled 
  try await semaphore.waitUnlessCancelled()
  defer { semaphore.signal() }
  ...
}
2 Likes

AsyncSemaphore still uses a regular lock inside, so I would just move the problem from my code into the package.

It seems the sensible approach is to go back to DispatchQueues and locking.

AsyncSemaphore still uses a regular lock inside

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).

1 Like

Yes, thank you - I was not criticising your answer - I was just saying that compiling AsyncSemaphore would give you the same warnigns as I encounter.

So the ball is in Apples court to provide a suitable primitive as an alternative.

I did not emit any warning last time I compiled it.

Probably because your lock() call is hidden behind an internal function

func lock() { _lock.lock() }

Also keep in mind this is an Xcode 14.1 RC1 warning (so maybe you still have 14.01)

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.

The lock is not "hidden", it is "wrapped" :wink:

1 Like

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.

2 Likes

I know his use is valid, that is also not what this thread is about.

This thread is about "how do I solve this with a standard Swift Concurrency primitive without including a several 100LOC 3rd party package".

If Apple introduces a warning, it should be a given there is an alternative solution in place that is 2 lines max.

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.

3 Likes

In case anyone is interested, here is the minimal AsyncLock I came up with, that is not an actor, and thus can be used inside a defer statement.

final class AsyncLock: @unchecked Sendable {

    private enum Locker {
        case holder
        case waiter(CheckedContinuation<Bool, Never>)
    }

    private var _lock = NSLock()
    private var lockers: [Locker] = []

    func lock() async {
        while true {
            let acquiredLock = await withCheckedContinuation { cont in
                _lock.lock()
                if lockers.isEmpty {
                    lockers.append(.holder)
                    cont.resume(returning: true)
                } else {
                    lockers.append(.waiter(cont))
                }
                _lock.unlock()
            }
            if acquiredLock {
                break
            }
        }
    }

    func unlock() {
        _lock.lock()
        assert(!lockers.isEmpty)
        for locker in lockers {
            if case let .waiter(cont) = locker {
                cont.resume(returning: false)
            }
        }
        lockers = []
        _lock.unlock()
    }
}

Are you sure it’s safe to resume the continuation while holding the lock? I think you want to unlock first.

1 Like