swiftmultithreadingasync-awaitconcurrencyswift5

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


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}

Solution

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