Swift Concurrency : Why Task is not executed on other background thread
Asked Answered
G

1

6

I am trying to learn the swift concurrency but it brings in a lot of confusion. I understood that a Task {} is an asynchronous unit and will allow us to bridge the async function call from a synchronous context. And it is similar to DispatchQueue.Global() which in turn will execute the block on some arbitrary thread.

override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            do {
                    
                 let data = try await asychronousApiCall()
                 print(data)
                    
                } catch {
                    print("Request failed with error: \(error)")
                }
        }
        
        for i in 1...30000 {
            print("Thread \(Thread.current)")
        }
    }

my asychronousApiCall function is below

func asychronousApiCall() async throws -> Data {
        print("starting with asychronousApiCall")
        print("Thread \(Thread.current)")
        let url = URL(string: "https://www.stackoverflow.com")!
        // Use the async variant of URLSession to fetch data
        // Code might suspend here
        let (data, _) = try await URLSession.shared.data(from: url)
        
        return data
    }

When I try this implementation. I always see that "starting with asychronousApiCall" is printed after the for loop is done and the thread is MainThread.

like this

Thread <_NSMainThread: 0x600000f10500>{number = 1, name = main}
Geum answered 29/9, 2022 at 13:38 Comment(5)
Isn't this because viewDidLoad is within a @MainActor context, and the task, unless .detached() inherits the context it's dispatched in?Ridiculous
Yes @Ridiculous I think that should be the cause. But this link medium.com/@ali-akhtar/…. Please scroll to figure 8 in last of the article. It has a normal task and Thread is print is some other thread.Geum
For that: see raywenderlich.com/books/modern-concurrency-in-swift/v1.0/… Because each await suspend point breaks the task up into "task partials" and at the systems discretion you can be resumed on a different threadRidiculous
If I run the same code from Xcode 13 it prints some other background thread. This is interesting.Geum
@themonk The reason Figure 8 is showing non-bg thread is prob because the ViewController wasn't on MainActor for some reason in author's xcode. You won't be able to get the same behavior today. Maybe Apple updated ViewController to be MainActor, who knowsAbsentee
R
9

tl;dr

Coming from GCD and Thread programming, we used to regularly concern ourselves with details regarding on which thread code runs. In Swift concurrency, we focus on actors rather than threads, and let the compiler and runtime take care of the rest. So, while it is interesting to see how the Swift concurrency threading model works, in practice, we focus on actors, instead.


You said:

I understood that a Task {} is an asynchronous unit and will allow us to bridge the async function call from a synchronous context.

Yes.

You continue:

And it is similar to DispatchQueue.global() which in turn will execute the block on some arbitrary thread.

No, if you call it from the main actor, it is more akin to DispatchQueue.main.async {…}. As the documentation says, it “[r]uns the given nonthrowing operation asynchronously as part of a new top-level task on behalf of the current actor” [emphasis added]. I.e., if you are currently on the main actor, the task will be run on behalf of the main actor, too.

While it is a probably mistake to dwell on direct GCD-to-concurrency mappings, Task.detached {…} is more comparable to DispatchQueue.global().async {…}.

You commented:

Please scroll to figure 8 in last of the article. It has a normal task and Thread is print is some other thread.

figure 8 figure 8

In that screen snapshot, they are showing that prior to the suspension point (i.e., before the await) it was on the main thread (which makes sense, because it is running it on behalf of the same actor). But they are also highlighting that after the suspension point, it was on another thread (which might seem counterintuitive, but it is what can happen after a suspension point).

The precise behavior exhibited by the author (where code isolated to the main actor after the suspension point could run on a different thread) only happened when the continuation did not perform anything requiring the main actor and is not reproducible on contemporary versions of the compiler.

That having been said, the author’s broader point still holds, that, in general, code in a continuation may run on a different thread than before the suspension point. But, the particular example in that article, where he exhibits that behavior for the main actor, was probably an idiosyncratic behavior/optimization of older versions of the compiler. I experienced the same behavior that the author identified in older compilers, but no longer.

The following might be a better illustration of the fact that continuations may run on different threads than you might have otherwise expected:

nonisolated func threadInfo(context: String, message: String? = nil) {
    print(context, Thread.current, message ?? "")
}

nonisolated func spin(for duration: Duration) throws {
    let start = ContinuousClock.now
    while start.duration(to: .now) < duration { 
        try Task.checkCancellation()
    }
}

actor Foo {
    func foo() async throws {
        threadInfo(context: #function, message: "foo’s starting thread")
        async let value = Bar().bar()
        try spin(for: .seconds(0.5))
        threadInfo(context: #function, message: "foo still on starting thread")
        let result = try await value
        threadInfo(context: #function, message: "foo’s continuation often runs on the thread previously used by bar! result=\(result)")
    }
}

actor Bar {
    func bar() throws -> Int {
        threadInfo(context: #function, message: "bar is running on some other thread")
        try spin(for: .seconds(1))
        return 42
    }
}

Producing:

foo() <NSThread: 0x6000017612c0>{number = 7, name = (null)} foo’s starting thread
bar() <NSThread: 0x600001760540>{number = 6, name = (null)} bar is running on some other thread
foo() <NSThread: 0x6000017612c0>{number = 7, name = (null)} foo still on starting thread
foo() <NSThread: 0x600001760540>{number = 6, name = (null)} foo’s continuation often runs on the thread previously used by bar! result=42

Note, this precise behavior might change depending what else the runtime is doing and/or whatever optimizations future compilers might introduce. The “take home” message is simply one should be aware that continuations may run on different threads than the code before the suspension point (in those extremely rare cases you might be calling some API that has some thread-specific assumptions … in general, we simply do not concern ourselves with which thread the continuation runs).

FWIW, in your example above, you only examine the thread before the suspension point and not after. The point of figure 8 is to illustrate that the thread used after the suspension point may not be the same one used before the suspension point (though their illustration of this behavior on the main actor is not a great example).

If you are interested in learning more about some of these implementation details, I might suggest watching WWDC 2021 video Swift concurrency: Behind the scenes. That video discusses this feature of Swift concurrency.


While it is interesting to look at Thread.current, it should be noted that Apple is trying to wean us off of this practice. E.g., in Swift 5.7, if we look at Thread.current from an asynchronous context, we get a warning:

Class property 'current' is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts.; this is an error in Swift 6

The whole idea of Swift concurrency is that we stop thinking in terms of threads and we instead let Swift concurrency choose the appropriate thread on our behalf (which cleverly avoids costly context switches where it can; sometimes resulting code that runs on threads other than what we might otherwise expect).

Ruche answered 29/9, 2022 at 18:49 Comment(7)
Thank you for such an explanation. It helps to clear a lot about Concurrency. I checked with detached task and made the function nonisolated it appeared to run on another thread. Yes, I am following WWDC Videos.Geum
There is one more confusion with Tasks as I understood that tasks allows structured concurrency and if I Cancel a Task a subtask will also get canceled but in an example, I tried to cancel the let task1 = Task { await someHeavyTaskFunction }, the task inside someHeavyTaskFunction doesn't get canceled. I checked it with Task.checkCancellation. Please help to make understand this. If it is required to put it in another question, I will create a new question.Geum
It's hard to say without a specific example, but when a Task or async function launches another Task, that is unstructured concurrency. I.e. this isn't a ”sub-task“, but rather another top-level task on that actor, and you have to manage all the details, such as cancelation, manually. See the discussion about tasks and unstructured concurrency in The Swift Programming Language: Concurrency. I'd advise only using Task and Task.detached where you must, and otherwise stay within structured concurrency.Ruche
@Ruche In figure 8, why did line 191 and SynchronousFunctionOne run on non-MainThread? Since line 176's Task inherited the MainActor context, I imagine it will make sure it will continue on the Main Actor after any suspension points. Am I wrong?Absentee
@Ruche Sorry I am still confused. So I think we are aligned that "using our own actor will make sure even after the suspension point, we will run on that actor". But why this doesn't apply to "MainActor"? MainActor is just an actor right?Absentee
@ShuaiqingLuo – I strongly encourage you let go of this weird edge case where the code running on the main actor after the suspension point wasn’t on the main thread. First, this exact behavior can no longer be reproduced in contemporary Swift compilers. Second, even when we would manifest this behavior, the thread used by the continuation only manifested this behavior when the continuation did something that required the main actor. Personally, I think it was an esoteric optimization subsequently removed from the compiler.Ruche
Thanks @Rob, this part prob answered my confusion: First, this exact behavior can no longer be reproduced in contemporary Swift compilers. I am already thinking about it in terms of actors. For example, Main Actors should make sure all the isolated functions run on Main Actor, while ur example code's output didn't manifest this. I think it is prob a bug now fixed by AppleAbsentee

© 2022 - 2024 — McMap. All rights reserved.