Swift 5.5 Concurrency: How to Serialize Async Tasks to Replace an Operationqueue with Maxconcurrentoperationcount = 1

How to make an async Swift function @synchronized?

You can have every Task await the prior one. And you can use actor make sure that you are only running one at a time. The trick is, because of actor reentrancy, you have to put that "await prior Task" logic in a synchronous method.

E.g., you can do:

actor Experiment {
private var previousTask: Task<Void, Error>?

func startSomethingAsynchronous() {
previousTask = Task { [previousTask] in
let _ = await previousTask?.result
try await self.doSomethingAsynchronous()
}
}

private func doSomethingAsynchronous() async throws {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "Task", signpostID: id, "Start")
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
os_signpost(.end, log: log, name: "Task", signpostID: id, "End")
}
}

Now I am using os_signpost so I can watch this serial behavior from Xcode Instruments. Anyway, you could start three tasks like so:

import os.log

private let log = OSLog(subsystem: "Experiment", category: .pointsOfInterest)

class ViewController: NSViewController {

let experiment = Experiment()

func startExperiment() {
for _ in 0 ..< 3 {
Task { await experiment.startSomethingAsynchronous() }
}
os_signpost(.event, log: log, name: "Done starting tasks")
}

...
}

And Instruments can visually demonstrate the sequential behavior (where the shows us where the submitting of all the tasks finished), but you can see the sequential execution of the tasks on the timeline:

Sample Image


I actually like to abstract this serial behavior into its own type:

actor SerialTasks<Success> {
private var previousTask: Task<Success, Error>?

func add(block: @Sendable @escaping () async throws -> Success) {
previousTask = Task { [previousTask] in
let _ = await previousTask?.result
return try await block()
}
}
}

And then the asynchronous function for which you need this serial behavior would use the above, e.g.:

class Experiment {
let serialTasks = SerialTasks<Void>()

func startSomethingAsynchronous() async {
await serialTasks.add {
try await self.doSomethingAsynchronous()
}
}

private func doSomethingAsynchronous() async throws {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "Task", signpostID: id, "Start")
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
os_signpost(.end, log: log, name: "Task", signpostID: id, "End")
}
}

Concurrently run async tasks with unnamed async let

This can be achieved with a task group using withTaskGroup(of:returning:body:). The method calls are individual tasks, and then we await waitForAll() which continues when all tasks have completed.

Code:

await withTaskGroup(of: Void.self) { group in
let myActor = MyActor()

group.addTask {
await myActor.setFoo(to: 5)
}
group.addTask {
await myActor.setBar(to: 10)
}

await group.waitForAll()
await myActor.printResult()
}

Why does my BlockOperation continue after I add it to an OperationQueue?

You said:

Apple documentation says that Operations run synchronously. Why then does the code continue to run after an operation is added to a queue?

Your debug output is correct. The BlockOperation will run asynchronously with respect to the thread which added it to the operation queue. The confusing and inconsistent terminology employed by the Operation and OperationQueue documentation does not help the matter (see below).

But, in short, operations added to an operation queue will run asynchronously with respect to the thread which added them to the queue (unless you explicitly wanted to “wait until finished”, i.e., you supply true for the second parameter of addOperations(_:waitUntilFinished:); but that is an anti-pattern which we simply do not use very often).


I do not know if this is the source of the confusion or not, but there is also a question as to whether the task wrapped by the operation is, itself, synchronous or asynchronous. The Apple documentation refers to these as “non-concurrent” or “concurrent” operations, respectively.

BlockOperation is for “non-concurrent” tasks only (i.e., when the block finishes, the BlockOperation is finished). So, if you stumble across any documentation that refers to the synchronous nature of BlockOperation, that is in reference to the synchronous, i.e., non-concurrent, nature of the block supplied to the operation, not the broader relationship between the BlockOperation and the thread that added it to the queue. Operations run asynchronously with respect to the thread that added them to the queue (whether the operation, itself, is concurrent or not).

FWIW, if you really were wrapping an inherently asynchronous task (such as a network request) within an operation, that called a “concurrent” operation in Apple’s terminology. You would not use BlockOperation for a “concurrent” operation. You would subclass Operation, perform the necessary KVO for “concurrent” operations. (If you really want to go tumbling down that rabbit hole, see https://stackoverflow.com/a/40560463/1271826 or https://stackoverflow.com/a/48104095/1271826, but this is largely unrelated to the question at hand.)


There is a final notion of “synchronous” vs “asynchronous” within the Operation and OperationQueue documentation. Specifically, the documentation dwells on the terms “asynchronous” and “synchronous” operations in the context of the start method, i.e., operations that are not added to a queue, but are just started immediately. If you call start on a “synchronous operation” (rather than adding it to a queue), the calling thread will wait. If you call start an “asynchronous operation”, the calling thread will not wait.

I hate to even bring up this idiosyncratic terminology, but only mention for those stumbling through Apple’s documentation. Apple’s use of “synchronous” vs “asynchronous” in this section only applies within the context of the start method. Obviously, we (and Apple, elsewhere in their own documentation) often use these terms more generally.


You raised another question:

The synchronous code runs immediately and the thread that calls it waits until the synchronous code finishes. Or is that only if the synchronous code runs on the same thread?

“Synchronous” simply means that the caller will not proceed until the synchronous code finishes.

Not to split hairs, but it makes no assurances that the synchronous code will execute immediately (though it generally will). It just means that the caller will not proceed until the synchronous call finishes. But if, for example, the synchronously dispatched code is added to a queue that is backlogged, it might not start immediately.

The term “synchronous” also makes no assurances that it will run on the same thread or a different one. Sometimes is will run it on the current thread. Sometimes it will simply wait on the current thread while the synchronous task finishes on some other thread. It depends upon the particular situation.

“Synchronous” only means that the caller will wait, with no assurances about when or where the synchronous code will execute. That’s it.

What is the correct way to await the completion of two Tasks in Swift 5.5 in a function that does not support concurrency?

I would make the tasks discardable with an extension. Perhaps something like this:

extension Task {
@discardableResult
func finish() async -> Result<Success, Failure> {
await self.result
}
}

Then you could change your loading task to:

Task {
defer { self.isVerifyingRoute = false }
await firTask.finish()
await airportTask.finish()
}

Cancelling all operations on OperationQueue

Well even setting isExecuting = false and isFinished = true the operations were still in the queue. What I did was re-initializing the queue again after cancelling the requests. In that way the operations are "deleted" and the queue is empty.



Related Topics



Leave a reply



Submit