Switch to background thread, then back to main once it's done

I'm currently working on a small Swift 5/iOS 18/Xcode 16 app that starts an AVCaptureSession and displays the camera's image:

//Regular class linked to my VC
class ViewController: UIViewController, AVCapturePhotoCaptureDelegate {
    ...

    private func configCamera() {
        ...
        Task(priority: .background) {
            session.startRunning()
            //Update UI here
        }
    }
}

I know that session.startRunning() has to run on a background thread but when I run this code, it complains that it's running on the main thread and the UI updates as expected. I already tried to use Task.detached(priority: .background) instead but it's still running on the main thread.

If I use DispatchQueue.global(qos: .background).async (only tested without UI changes), then it doesn't complain but afaik you should use Tasks for new projects now.

The weird thing is: Apple's example project (Xcode 15 project, iOS 18) also uses Task(priority: .background) but there's no warning.

How do I properly switch to the background thread to start the session and then, once it's done, switch back to the main thread to update the UI? If switching to the background thread already isn't working, then I probably can't rely on await MainActor.run to correctly do its thing either, right?

Unfortunately AVFoundation still hasn't embraced Swift concurrency, so integrating the two is extremely awkward. There's not really a good, fully safe way to connect to two, given you need to interact with multiple isolations simultaneously. I suggest you watch the Build a responsive camera app that launches quickly session for a bit of guidance here.

Your fundamental issue is that Task inherits its isolation, so calling it from something on @MainActor will stay there. If nothing else you can do something like

Task {
  async let didStart: Void = session.startRunning()
  await didStart
}

(Also, no need to use priority at all.)

This should throw the startRunning into the background.

Personally, I've been experimenting with a custom global AVActor that shares its executor with the AVSession delegate, so you can apply it to a class that is the delegate and then call back to the main queue for any UI updates you need, as well as sharing the AVCaptureVideoPreviewLayer. This also allows any processing to occur on the custom executor rather than the Swift concurrency pool, keeping things a bit more open for other work.

Edit: You'll also want to check your project settings for what Swift mode you're running in and whether you have default actor isolation or approachable concurrency enabled, so you can better predict where stuff will run.

2 Likes

Is there a way to inspect the isolation of a Task?

The #isolation macro works with both static and dynamic isolation. Besides that, inspecting SIL works great too.

1 Like

Are you asking if you can inspect the isolation of a Task instance at runtime? There's no way to do this, and I'm having a difficult time imagining a scenario where it would be useful. Did you have a use in mind?

1 Like

I don't think there's a good way to get this at compile time, but here's a thread about adding such a feature to sourcekit.

1 Like

Thank you, @NotTheNHK

I am using the following function, but I am not certain that it is semantically correct.

@inline(always)
func isolation (at f: String = #function, _ line: Int = #line, _ u: (any Actor)? = #isolation) {
    if let u {
        print ("-->", "isolation at \(f) \(line):", u)
    }
    else {
        print ("-->", "isolation at \(f) \(line):", "undefined")
    }
}

Thank you, @mattie

Yes. I just need a widget to verify my understanding.

I can't explain why I get undefined in the output:

--> isolation at main() 6: Swift.MainActor
test1()
--> isolation at test1() 26: undefined
--> isolation at f(session:) 42: undefined
test2()
--> isolation at test2() 35: undefined
--> isolation at g(session:) 52: undefined
--> isolation at f(session:) 45: Swift.MainActor
--> isolation at startRunning() 72: undefined
--> isolation at started() 76: undefined
--> isolation at g(session:) 55: A.BGWActor
--> isolation at startRunning() 72: undefined
--> isolation at g(session:) 58: Swift.MainActor
--> isolation at started() 76: undefined
Program ended with exit code: 0

From this code:

@main
enum Driver {
    @MainActor
    static func main() async throws {
        MainActor.assertIsolated()
        isolation()
        await test1 ()
        await test2 ()
        try await Task.sleep (until: .now + .seconds (3))
    }
}

@inline(always)
func isolation (at f: String = #function, _ line: Int = #line, _ u: (any Actor)? = #isolation) {
    if let u {
        print ("-->", "isolation at \(f) \(line):", u)
    }
    else {
        print ("-->", "isolation at \(f) \(line):", "undefined")
    }
}

extension Driver {
    static func test1() async {
        print (#function)
        isolation()
        let session = Session ()
        await f (session: session)
    }
}

extension Driver {
    static func test2() async {
        print (#function)
        isolation()
        let session = Session ()
        await g (session: session)
    }
}

func f (session: Session) async {
    isolation()

    Task {@MainActor in
        isolation()
        session.startRunning ()
        session.started ()
    }
}

func g (session: Session) async {
    isolation()

    Task {@BGWActor  in
        isolation()
        session.startRunning ()
        await MainActor.run {
            isolation()
            session.started ()
        }
    }
}

@globalActor
actor BGWActor {
   static let shared = BGWActor ()
   private init () {}
}

final class Session: Sendable {
    func startRunning () {
        isolation()
    }
    
    func started () {
        isolation()
    }
}

1 Like

You can just write Task { nonisolated in session.startRunning() } to get this effect without the async let.

Hadn't realized you could use nonisolated like that. When was it added? From what I can tell it was part of the closure isolation control pitch, but it was part of Swift 6.1?

Actually, this doesn't seem to work, even in Swift 6.4:

Task { nonisolated in
    print(#isolation)
}

This fails with

Contextual closure type '@isolated(any) () async -> ()' expects 0 arguments, but 1 was used in closure body

Even in Swift 6.4. Is it even newer?

Sorry, I was away from a compiler. I guess nonisolated is being interpreted as a parameter; you have to write @concurrent.

Ah, thanks.

#isolation resolves to the current static isolation, which could be nil (representing nonisolated). It's a very useful tool for inspecting the isolation a compile-time.

Your code prints "undefined" at all the points in this program that are executing from a nonisolated function. Two settings that will have a major impact on this behavior are default isolation and NonisolatedNonsendingByDefault. Perhaps that explains why you aren't getting what you expect?

1 Like

That will work.

As Mattie already explained, if you have neither the NonisolatedNonsendingByDefault flag enabled nor @MainActor default isolation, a non-isolated asynchronous function will not run on the calling context's actor. You can, however, manually opt such functions into dynamic isolation with the nonisolated(nonsending) keyword.

Dynamic isolation also works with the #isolation macro, although it's somewhat awkward to use.

SE-0461: swift-evolution/proposals/0461-async-function-isolation.md at main · swiftlang/swift-evolution · GitHub

Godbolt example: Compiler Explorer

1 Like

Thank you, @mattie

I was literally following the documentation for this:

Swift Standard Library>Concurrency>isolation ()

Macro

isolation()

Produce a reference to the actor to which the enclosing code is isolated, or nil if the code is nonisolated.

iOS 13.0+iPadOS 13.0+Mac Catalyst 13.0+macOS 10.15+tvOS 13.0+visionOS 1.0+watchOS 6.0+

@freestanding(expression)
macro isolation<T>() -> T

Overview

If the type annotation provided for #isolation is not (any Actor)?, the type must match the enclosing actor type. If no type annotation is provided, the type defaults to (any Actor)?.

Thank you, @NotTheNHK

I'm new to the whole actor thing and even though I think I kind of understand the main bits, it's still really confusing to me, like that Task(priority: .background) isn't guaranteed to always run on a background thread (what's the point then?). I didn't specifically make the VC a @MainActor, does Xcode set it automatically?

Going to try your suggestion, thanks! But why does it run it on a background thread? I thought that async isn't guaranteed to do that.

Sorry, there's a lot of new information in your "personally" paragraph. I've only got a single class, the VC, and it handles the camera, its UI and also calls the next VC to display the result.

Where can I find the build mode? In "Targets > Build Settings > Swift Compiler - Language" there's only the option "Swift Language Version" and it's set to "Swift 5". In "Projects > Build Settings" the same setting says "Unspecified" (???). I didn't find a "Swift Mode".

Please don't hijack my question for your own unrelated questions, there are a lot of new comments now and none of them help with my problem.

I'm not 100% sure (someone please correct me if I'm wrong!) but my understanding is that if you set the priority of a task, that just controls its priority relative to other tasks on the same executor, not which thread it actually runs on. (With the caveat, of course, that someone could write a custom executor that uses different threads for different-priority jobs -- but @MainActor definitely doesn't do that.)

UIViewController is @MainActor, and actor isolation is inherited by subclasses by default. I think in modern Swift you can suppress this with nonisolated class, but you probably don't want this on a UI class where most things it does have to be on the main thread anyways.

That is relevant information. "Swift language mode" refers to the language version your code is being built with, as opposed to the version of the compiler you have installed; a Swift 6 compiler can still build in a Swift 5 language mode. The settings under "Swift Compiler - Concurrency", and some of the settings under "Swift Compiler - Upcoming Features" can also be relevant for concurrency-related problems, but I don't think any of them would change the fact that that task is inferred as @MainActor.