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

Yeah, I am pretty sure it is not a problem. You merely release the awaiting continuation, there is no possibility of something calling lock recursively.

Also I tested it with a TaskGroup and 1000 child tasks.

Is it really impossible for the continuation to synchronously call unlock? Note that unlock is not async.

How would the continuation ever call unlock()? The continuation only briefly aquires the internal _lock, it never calls the AsyncLock.unlock().

Resuming a continuation doesn't call anything, it simply schedules it to resume execution in the context it was called in.

This is correct: continuation.resume cannot take over the current thread, which will continue in its execution order. There's no problem here.

1 Like

In WWDC 2021’s Swift concurrency: Behind the scenes they say:

Primitives like os_unfair_locks and NSLocks are also safe but caution is required when using them. Using a lock in synchronous code is safe when used for data synchronization around a tight, well-known critical section. This is because the thread holding the lock is always able to make forward progress towards releasing the lock. As such, while the primitive may block a thread for a short period of time under contention, it does not violate the runtime contract of forward progress.

The idea is that you can use a lock for a very “tight data synchronization” (e.g., a quick “lock, get counter value, increment it, update counter, and unlock”). But it is not intended for anything prolonged that will block the thread from the cooperative thread pool. I.e., it is not intended to be used in scenarios like the one proposed here, where a task might be blocked for a prolonged period of time, while other, previous task(s) might be finishing.

I would suggest that you do not use locks to manage dependencies between tasks at all. I would suggest that you simply await the prior task. E.g.,

import os.log

let poi = OSLog(subsystem: "Test", category: .pointsOfInterest)

actor Foo {
    private var task: Task<Void, Error>?

    func call(_ i: Int) async {
        task = Task { [previousTask = task] in
            // in the following Instruments’ screen snapshot below, I created Ⓢ signposts with the following
            //
            // os_signpost(.event, log: poi, name: #function, "%d", i)

            _ = try await previousTask?.value

            // in the following Instruments’ screen snapshot below, I created intervals with the following
            //
            // let id = OSSignpostID(log: poi)
            // os_signpost(.begin, log: poi, name: #function, signpostID: id, "%d", i)
            // defer { os_signpost(.end, log: poi, name: #function, signpostID: id, "%d", i) }

            print("---")

            print(i)
            try await sleepRandom()
            print(i)
        }
    }

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

Two subtle observations here:

  1. Note the capture list to get existing task reference; and
  2. Note that the await of the prior task is inside the Task, not before it.

Anyway, profiling that in Instruments with the “Points of Interest” and “Swift Tasks” tools, that yields:

Note the Ⓢ signposts where I invoked call and the intervals are obviously where the method is running.

7 Likes

By coincidence, I've been playing around with your approach (via Stack Overflow) this weekend. I've run into two moderate concerns about it.

First, I can't figure out how to integrate it with cancellation. Second, despite this living inside an actor, these tasks are not actor isolated and I can't find a way to make them be. Both of these may not matter that much in practice (particularly the latter), but they were surprising.

I did some work to make this support returning arbitrary values rather than just running void-returning closures.

actor SerialTaskQueue {
    private var tail: Task<(), Error>?

    func run<Value>(block: @Sendable @escaping () async throws -> Value) async throws -> Value {
        return try await withCheckedThrowingContinuation { continuation in
            tail = Task { [tail] in
                let _ = await tail?.result  // wait for previous tasks to complete, ignoring their failures
                do {
                    try Task.checkCancellation()
                    continuation.resume(returning: try await block())
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

Here's an example of a non-trivial usage. Note that despite all the cancellation logic, I can't get cancellations to really happen properly. And when I try to extend this to work with actors, things get a little more complicated because of lacking actor isolation (though there are obviously ways to work around that with await and adding mutating methods).

let queue = SerialTaskQueue()
struct SomeError: Error {}

await withTaskGroup(of: Void.self) { group in
    for i in 0..<3 {
        group.addTask {
            do {
                let result = try await queue.run {
                    print("\(i) Started")
                    defer { print("\(i) Finished")}

                    if i == 1 { throw SomeError() } // Fail one of the tasks
                    // Do some stuff
                    try await Task.sleep(nanoseconds: NSEC_PER_SEC)

                    return i
                }
                print("\(i) -> \(result) (not on queue)")
            } catch {
                print("\(i) ***failed*** \(error) (not on queue)")
            }
        }
    }
}

For many of my problems, I've been moving towards using AsyncChannels instead. I create a task that loops over requests on the channel and returns responses on a one-shot AsyncChannel that's included in the request. That works well, though it has some sharp edges, too. It somewhat reinvents what I really think of as "Actors" (i.e. Akka) in that it is a thing that accepts messages and responds with messages. But this seems completely parallel to what Swift thinks of as Actors.

1 Like