Skip to content

Instantly share code, notes, and snippets.

@christianselig
Created February 28, 2022 20:46
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save christianselig/3596716c876830b2f4683461be15d38a to your computer and use it in GitHub Desktop.
Save christianselig/3596716c876830b2f4683461be15d38a to your computer and use it in GitHub Desktop.
Some questions about async await threading in Swift's new concurrency model.
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Task {
// 1️⃣❓ UIViewController is in a MainActor context, so this Task
// will inherit that, so the following pretend expensive call will
// be on the main thread and likely block?
ExpensiveOperationPerformer.doExpensiveLoopAndPrint()
}
Task.detached {
// 2️⃣❓ Is this guaranteed to be off the main thread, so perhaps a
// better way to do a one-off, expensive operation? If it's not
// guaranteed, how would I ensure that? Wrap it in an actor
// instead of a class? What if it's not my class/I can't
// change the code?
ExpensiveOperationPerformer.doExpensiveLoopAndPrint()
}
Task {
ExpensiveOperationPerformer.printSomeNetworkData()
}
}
}
class ExpensiveOperationPerformer {
@MainActor
static func printSomeNetworkData() async throws {
let url = URL(string: "https://example.com/example.json")!
// 3️⃣❓ Will this time consuming network call be guaranteed to NOT
// execute on main, despite the MainActor context? Or will it block?
let (data, response) = try await URLSession.data(for: url)
print(data)
}
static func doExpensiveLoopAndPrint() async {
let upperEnd = 9_999_999_999
var sum = 0
for i in 0 ..< upperEnd {
sum += 1
}
print(sum)
}
}
@divadretlaw
Copy link

divadretlaw commented Feb 28, 2022

1️⃣ The way I understand this is that Task will not really block, but prevent other Tasks from being run immediately, as other Tasks will only be added to the queue. So if you have a random action like this:

@IBAction func someAction() {
      print("Hello World")
      
      Task {
          print("Hello Task")
      }
  }

Then Hello World will get printed on each call. But only after the Task with doExpensiveLoopAndPrint is done will the other Tasks start to execute sequentially and then print Hello Task as often as the action was called.

2️⃣ Task.detached will create a new top-level Task. So it will not block anything but you have to keep in mind that it will not be automatically cancelled so you need to keep a reference to it and manually cancel if needed. Like when dismissing the view.

@ole
Copy link

ole commented Feb 28, 2022

Task {
    // 1️⃣❓ UIViewController is in a MainActor context, so this Task
    // will inherit that, so the following pretend expensive call will
    // be on the main thread and likely block?
    ExpensiveOperationPerformer.doExpensiveLoopAndPrint()
}

(Note: this is missing an await.)

It won't block immediately, i.e. viewDidLoad will continue running. But yes, Task { … } creates a new task that will run on the main actor and when that task runs, it will block the main thread because doExpensiveLoopAndPrint isn't a good concurrency citizen (see below).

Note: once SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions ships (Swift 5.7 or possibly Swift 5.6?), this will not block the main actor because doExpensiveLoopAndPrint isn't @MainActor-isolated. If I understand SE-0338 correctly, only the Task closure will run on the main actor, but doExpensiveLoopAndPrint will then run on the default executor (= off the main thread).


Task.detached {
    // 2️⃣❓ Is this guaranteed to be off the main thread, so perhaps a
    // better way to do a one-off, expensive operation? If it's not
    // guaranteed, how would I ensure that? Wrap it in an actor 
    // instead of a class? What if it's not my class/I can't 
    // change the code?
    ExpensiveOperationPerformer.doExpensiveLoopAndPrint()
}

(Note: this is missing an await.)

99% sure it's guaranteed to be off the main thread. This creates a new task that runs concurrently with the current thread (viewDidLoad continues running). So there would be no reason for the Swift runtime to schedule the new task on the main thread.

More importantly (and this applies to both (1) and (2)), you should not run long-running expensive, blocking operations in a concurrency context, regardless of if it's the main actor or not. Swift concurrency is a cooperative model, i.e. the functions that run in a concurrency context* are expected to regularly suspend to give up control of their thread and give the runtime a chance to schedule other tasks on that thread.

* A concurrency context is any async function (including async closures likes the ones passed to Task { … } and Task.detached { … }) or the (sync or async) functions called (directly or indirectly) from an async function.

You can do this by calling await Task.yield() periodically inside doExpensiveLoopAndPrint.


Task {
    ExpensiveOperationPerformer.printSomeNetworkData()
}

This is also missing an await.

// 3️⃣❓ Will this time consuming network call be guaranteed to NOT
// execute on main, despite the MainActor context? Or will it block?
let (data, response) = try await URLSession.data(for: url)

It will not block. It also won't run on the main thread (because URLSession uses a background thread or queue), but that isn't really significant. Even if the URLSession operation ran asynchronously on the main thread (e.g. by using the run loop to manage the network operation), it wouldn't block.

Very important: await suspends the execution of the current function*, it does not block! This has nothing to do with MainActor. So when you call try await URLSession.data(for: url), the calling function will suspend and give up control of the main thread. The main thread can do other stuff now, so the app remains responsive. When the network request completes, the runtime will resume the function (on the main thread).

* More precisely: await marks a potential suspension point, so the function may suspend here or not.

@christianselig
Copy link
Author

Ah ha, thanks Ole! That all actually makes a lot of sense! So in a way, this "cooperation" model is a pretty decent departure from GCD where you could dispatch a heavy task to a background queue and essentially not worry about having to yield here and there?

(Though as I type that it occurred to me that that is also problematic as it's basically eating up that entire thread by itself, so it's not being a great concurrency citizen even if it's technically permitted)

@ole
Copy link

ole commented Mar 1, 2022

So in a way, this "cooperation" model is a pretty decent departure from GCD where you could dispatch a heavy task to a background queue and essentially not worry about having to yield here and there?

Yes. And if you have long-running, non-cooperative pieces of code, it may still be a good idea to execute them outside of the cooperative thread pool (you can still use DispatchQueue.async or even bring up a separate Thread). You can use withCheckedContinuation to bridge between the two worlds.

(Though as I type that it occurred to me that that is also problematic as it's basically eating up that entire thread by itself, so it's not being a great concurrency citizen even if it's technically permitted)

Yes. GCD will bring up more and more threads to keep the thread pool from starving, but this is generally less efficient because threads and thread switching are expensive. The new cooperative thread pool in which the tasks run doesn't do this; it has roughly one thread per core (or possibly less, this isn't a guarantee). If all those threads are busy with blocking work without suspending, the system would become unresponsive.

@christianselig
Copy link
Author

Thank you!

@christianselig
Copy link
Author

@ole, one last question if it's okay. You said:

Even if the URLSession operation ran asynchronously on the main thread (e.g. by using the run loop to manage the network operation), it wouldn't block.

This part confuses me a bit and perhaps it's just terminology. But if instead of a URLSession operation firing, say, I just ran an expensive math operation 1 million times in a for loop, that grinds the UI to a halt (I can't scroll anymore while the operation proceeds) when inside a MainActor context. Isn't that "blocking"? I guess that's what I meant, but maybe I'm mixing up terms here? At some point the operation has to run, right, and if it executes on the main thread it's going to block, no?

@ole
Copy link

ole commented Mar 4, 2022

Isn't that "blocking"?

I'm not sure if there is a widely accepted definition for "blocking".

Some people use "blocking" only for functions that block a thread while waiting for something else (usually an I/O operation to complete). E.g. Data(contentsOf: URL) will wait synchronously for the data to be loaded from the disk or network. The thread isn't doing anything useful in the meantime and doesn't use CPU time, but still takes up resources (memory and the cost of bringing up a new thread to keep the CPU core busy).

async/await is a really good solution for this use case because it allows the function suspend while it's waiting. So the same thread can do other useful work while the suspended function waits for the I/O operation to complete.

An expensive math operation also keeps its thread occupied, so in that sense the thread is "blocked". But at least it's doing something useful with the CPU.

At some point the operation has to run, right, and if it executes on the main thread it's going to block, no?

Yes. In the context of async/await, you can call await Task.yield() periodically to play nicely with the cooperative scheduling (give waiting tasks a chance to run for a while), but yes, eventually the operation has to do its work. So this kind of CPU-intensive work is not such a good fit for async/await. If you really have a long-running CPU-bound operation and you don't want to put it on the cooperative thread pool, running it on a separate thread or dispatch queue is still a good idea, I think.

Even if the URLSession operation ran asynchronously on the main thread (e.g. by using the run loop to manage the network operation), it wouldn't block.

What I meant by this: The old NSURLConnection(request:delegate:) can perform non-blocking I/O on the main thread. It's perhaps not a perfect analogy because it uses a delegate, but NSURLConnection.init creates the network connection, registers itself with the run loop, and then returns immediately (you could say it "suspends"). The main thread can now do other work and when network packets come in, the run loop notifies the object so it can process the data and call the delegate. No background thread is involved.

@christianselig
Copy link
Author

In that case the downloading (expensive) is done on a background thread though, right, and the "updating the delegate" (cheap) is handled on the main thread? Or is everything truly done on the main thread and it basically does something akin to a Task.yield() while it waits for the next bytes to arrive? Or perhaps I don't understand how modern networking is done on iOS and it's a separate system entirely?

@ole
Copy link

ole commented Mar 7, 2022

In that case the downloading (expensive) is done on a background thread though, right, and the "updating the delegate" (cheap) is handled on the main thread?

I'm quite sure no other thread in your app is involved. The actual download is being performed by the networking subsystem of the OS (however that works) and is being managed by the kernel (I think). When new data becomes available, the kernel informs your app that new data on the socket is available. No multithreading required.

Or perhaps I don't understand how modern networking is done on iOS and it's a separate system entirely?

NSURLConnection(request:delegate:) isn't modern networking, but I'd imagine the general principle would be the same for URLSession. The difference might be (I don't know) that URLSession could be using GCD dispatch sources or similar (instead of the run loop) to handle notifications from the socket and then calls your callbacks on a background queue (instead of the thread that started the connection).

@christianselig
Copy link
Author

Thank you so much for taking the time to explain all this! I've learned a lot! ❤️

@ole
Copy link

ole commented Mar 8, 2022

❤️

Correction: I tried to prove my claims with some sample code and it turns out NSURLConnection does use a separate thread for something. See the screenshot: when I set a breakpoint in one of the delegate calls, there is a com.apple.NSURLConnectionLoader thread:

Screen Shot 2022-03-08 at 16 43 27

It's possible this thread does things like setting up the connection, HTTP parsing, etc. This doesn't invalidate everything I said above because it's definitely possible to write asynchronous networking code without any sort of multithreading (e.g. by using the socket APIs directly), but apparently that's not what NSURLConnection does. Sorry for leading you on the wrong track.

You can also see in the screenshot in the main thread's stack trace that it's using the run loop to invoke the delegate. __CFRunLoopDoSource sounds like it's invoking a callback for a run loop source that has new events (the socket).

Here's my code (macOS Command Line Project in Xcode):

import Foundation

final class Loader: NSObject, NSURLConnectionDataDelegate {
  var connection: NSURLConnection? = nil

  func start() {
    let url = URL(string: "https://google.com")!
    connection = NSURLConnection(request: URLRequest(url: url), delegate: self)
  }

  func connection(_ connection: NSURLConnection, didReceive data: Data) {
    print(#function, data)
  }

  func connection(_ connection: NSURLConnection, didFailWithError error: Error) {
    print(#function, error)
  }
}

let loader = Loader()
loader.start()

RunLoop.current.run()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment