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

(again, remember that we are talking about language mode, and that the compiler version is a function of the version of Xcode you are using)

The language mode is controlled by Xcode build settings. So, it is 100% defined by the project itself. There is no Xcode-app-level setting that can influence this.

Swift's TaskPriority is modeled after Dispatch's QoS. Specifying a TaskPriority of .background generally carries the same meaning, with respect to priority, as enqueuing a block of work on a DispatchQueue with a QoS of .background. However, it is ultimately up to each executor to determine how priority affects job[1] scheduling. For example, an executor could ignore priority altogether and schedule jobs in strict FIFO order (as is the case with @MainActor), or it could treat all priorities below, e.g., .high as equivalent.

async let is a concurrency primitive for executing multiple tasks simultaneously (and, on multicore processors, in parallel). An async let is similar to a TaskGroup with a single child task:

await withTaskGroup { group in 
  group.addTask {
    session.startRunning()
  }
}

For a TaskGroup or async let to provide task concurrency, the child task must run off a SerialExecutor, since such an executor can only execute a single job at a time and cannot preempt the job it is currently executing. By default, child tasks created through these primitives run on the global concurrent executor.

If the task in Mr. Shier's example is @MainActor isolated (and startRunning() is non-isolated), then the async let syntax causes startRunning() to execute on the global concurrent executor, i.e., not on the main thread.


  1. A task is composed of one or more jobs. ↩︎

Yes. I'm still very curious why the compiler? Runtime? complains that you're still on the main thread.

The previous non-beta version of Xcode is 26, not 16.

Note that as of April 28th of this year, you must use Xcode 26 when submitting apps to Apple's stores (App Store, Mac App Store).

Thus, if you currently have software in the stores, you really should have the minimum tool versions to allow you make updates at any time.

If all the software you're building is just for personal use, then it should be even easier to migrate to the new tools as they come out.

2 Likes

I'd just like to stress that while using Swift 6.1 is not ideal (Xcode 26 also includes a few concurrency-specific improvements to AVFoundation as well), I do not think this is a major impediment to making progress here.

The language behavior of 6.1 is absolutely still supported in later versions.

I'm confused, isn't the language mode (can't find the comment that mentions the build mode anymore) the language version you pick for this specific project, so 5 for my current one and also for the sample project, even though this specific Xcode version also supports 6?

So basically: Whoever at Apple created this sample project, picked the wrong Swift version and now it's all wrong but buggy and still working because of that?

Swift's TaskPriority is modeled after Dispatch's QoS. Specifying a TaskPriority of .background generally carries the same meaning, with respect to priority, as enqueuing a block of work on a DispatchQueue with a QoS of .background.

This doesn't make sense now. @mattie said that ".background" is a bad description because it's actually running on low priority, which could be the main thread or a background thread. But, DispatchQueue with .background is correctly running it in the background. I remember using it a bunch of times when I was last working with Swift a couple of years ago and never had any problems with it picking the wrong (=main) thread.

Xcode 27 is still in beta, so the current non-beta version is 26, which makes 16 the previous non-beta version.

Thanks for the warning but I'm not going to submit it to the store.

My first priority is getting all of this to run properly (including going back and forth - opened another thread about that), if I've still got time afterwards, then I'll upgrade the whole thing to Xcode 26/Swift 6, then do the same thing for my older, larger app before I integrate this one into it.

1 Like

Essentially, yes. However, I would word this differently.

Swift supports multiple "language modes" for the purposes of backwards compatibility. The person that created this project used a mode that didn't provide adequate compiler feedback about the language features they were making use of. This permitted invalid language use, but does not guarantee incorrect runtime behavior.

1 Like

I'm purely talking about TaskPriority and QoSClass. .background in both cases, as Mattie mentioned previously, means a very low scheduling priority.

2 Likes

This is very important, because it is causing confusion. The .background specifier you are seeing in that dispatch code is also not controlling execution context. This is not how you get a "background thread".

The QoSClass documentation explains it pretty well:

Use quality-of-service classes to communicate the intent behind the work that your app performs. The system uses those intentions to determine the best way to execute your tasks given the available resources. For example, the system gives higher priority to threads that contain user-interactive tasks to ensure that those tasks are executed quickly. Conversely, it gives lower priority to background tasks, and may attempt to save power by executing them on more power-efficient CPU cores. The system determines how to execute your tasks dynamically based on system conditions and the tasks you schedule.

DispatchQueue.global, and DispatchQueue.init with a target that isn't the main queue, – regardless of QoS! – never runs blocks submitted to it on the main thread. This is why your GCD code works. The QoS does not determine whether your submitted block runs on the main thread, it's mainly a scheduling hint for priority, and your DispatchQueue code will also work as-is with the default QoS (try it!).

In fact as far as I can tell .background is the wrong QoS to use here and if you want to specify one, I would go with .userInitiated (though the default is probably fine).

(There is a special behavior for the .background QoS mentioned in dispatch_queue_create(3), but as far as I can tell it's not relevant here.)

There's a subtle conflict in terminology here that I think is the root of the misunderstanding.

It's common in UI programming to talk about "background threads" to mean, essentially, any thread that's not the UI-processing thread. Using that definition, every DispatchQueue is "background" (unless it either is or targets DispatchQueue.main): if you enqueue something to run asynchronously on the queue, it will be processed on a non-UI thread.

It is also common in threading libraries (and similar abstractions like DispatchQueues and Swift tasks) to allow threads/queues/tasks to have an execution priority. The idea is that operations that need to execute more urgently are given higher execution priority, allowing them to not be interrupted by less urgent operations. The UI thread in UI programming is generally given a very high priority so that other work doesn't prevent the UI from promptly redrawing itself and responding to user interactions.[1] In POSIX, threads have a numeric execution priority, the exact interpretation of which is pretty system-specific; here's some docs about Linux. In Swift, we follow the lead of DispatchQueue QoS by intentionally narrowing that down to a small number of named priorities.

Now, one of those named priorities happens to be called background; it's meant for low-priority work like periodically cleaning up various data structures. But this "background priority" is not the same thing as being a "background thread", and even in Dispatch, you should generally not be using a background queue just because you want work to not be done on the UI thread. Most work that's initiated by a UI event should be userInitiated so that it doesn't get excessively delayed.[2]

So I think you're thinking about passing priority: .background to Task.init as if it were an instruction to run the task on a background thread. Hopefully you understand now that it isn't. Of course, that just raises the question of how you're supposed to tell Task.init where to run. This is a really important difference between Swift concurrency and the traditional way you do programming around DispatchQueues.

In typical queue-oriented concurrency, a lot of data is protected from data races by only accessing it on a specific serial queue. In a world like that, in order for Swift to have any hope of proving that a function doesn't create data races, the compiler needs to understand something about where that function is running. That's not really compatible with just running functions on whatever queue you happen to pass at the same time to dispatch_async. So in Swift concurrency, the fact that a function is supposed to run on a specific queue (we say "actor", but an actor is basically just a serial queue + some associated data that protected by that queue) is basically part of the function's static type, and a whole lot of the concurrency language mechanics are designed around preserving that and making sure that functions run in the right place.

That's why, if you want a task to start running on a background thread, you don't just pass something special to Task.init. You have to make sure the function itself "knows" that it's not supposed to run on the main actor. That's why Jon Shier and I were talking above about how to declare that a closure is non-isolated: if the current function is @MainActor, then a closure passed to Task.init will normally also be inferred to be @MainActor. You can override that by writing Task { @concurrent in ... }: the @concurrent attribute on the closure changes its isolation to say that it no longer needs to be run on the main actor, which ultimately means it'll run on a "background thread" in the sense you originally meant it.


  1. The UI thread will often be the absolute highest-priority thread in a process, but there are exceptions. For example, if you are currently playing audio in the process, it is common to have a dedicated audio thread that is higher priority than the UI thread. This is because, as bad as briefly UI hangs are, skips in the audio are actually much worse. Audio threads actually need more attention than just being high priority — you really need real-time guarantees — but priority is certainly part of the picture. ↩︎

  2. In fact, that's the default policy for tasks created by Swift's Task.init. ↩︎

14 Likes

I really don't want to take away from this description, because it is great. But I do feel compelled to point out that the @concurrent attribute was introduced in Swift 6.2, and because of this, will not be a tool you can make use of with an Xcode version before 26.

3 Likes

Just to add to this, since the OP is on an older toolchain version, you can also use Task.detached to remove the inferred @MainActor isolation.

Edit: I forgot that Task { } is annotated with @_inheritActorContext; @Sendable doesn't work in this specific case.

1 Like

Task.detached was misused on a wide scale to mean "run on not-main thread", but I think it doesn't actually guarantee that the work stays off the main actor?

I 100% agree that Task.detached has been used inappropriately. There are a number of other solutions that have many advantages and none of the disadvantages. However, it is appealing because it shares a very similar (but ultimately superficial) shape to queue.async {}.

The body of a detached task will never inherit existing actor context. But that is not the only way to end up on the main actor. And this is actually sensible if (and this is a huge if) you are using detached for its intended purpose, which is to strip away existing context when creating a new unstructured task.

Task.detached { @MainActor in
  print("no inherited task locals here, but also on the main actor")
}
1 Like

Here's the code, reduced to the basics (no handling of the detections,...):

import UIKit
import AVFoundation
import Vision

class ViewController: UIViewController, AVCapturePhotoCaptureDelegate {
    @IBOutlet weak var previewView: PreviewView!
    
    private let session = AVCaptureSession()
    private let output = AVCapturePhotoOutput()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func onClickOpenCamera(_ sender: UIButton) {
        configCamera()
    }
    
    private func configCamera() {
        Task {
            if await checkCameraAuthorization() {
                session.beginConfiguration()
                
                if let device = AVCaptureDevice.default(for: .video) {
                    do {
                        let input = try AVCaptureDeviceInput(device: device)
                        
                        if session.canAddInput(input) {
                            session.addInput(input)
                            
                            if session.canAddOutput(output) {
                                session.sessionPreset = .photo
                                session.addOutput(output)
                                previewView.videoPreviewLayer.session = session
                                session.commitConfiguration()
                                
                                //Task(priority: .background) {
                                //Task.detached(priority: .background) {
                                DispatchQueue.global(qos: .background).async {
                                    self.session.startRunning()
                                }
                                //Jon's suggestion that works:
                                /*Task {
                                    async let didStart: Void = session.startRunning()
                                    await didStart
                                    //UI can be updated here
                                }*/
                            }
                        }
                    } catch {
                        print("Error: \(error)")
                    }
                } else {
                    print("Didn't find a capture device!")
                }
            } else {
                print("No camera access!")
            }
        }
    }
    
    private func checkCameraAuthorization() async -> Bool {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
            case .authorized:
                return true
            case .notDetermined:
                let status = await AVCaptureDevice.requestAccess(for: .video)
                return status
            case .denied:
                return false
            case .restricted:
                return false
            @unknown default:
                return false
        }
    }
}

Btw, session.stopRunning() does not complain when I call it from the main thread. Is that correct or also not working correctly?

"target" as in .global or async?

To summarize this:
DispatchQueue.global(qos: anything).async - never runs on the main thread
Task(anything) - might run on the main thread but might not (if the function called inside is marked as non-insolated)
?

The docs for userInitiated say:

A flag to indicate the app is performing a user-requested action.

Not directly (the user doesn't care about sessions) but close enough, I guess?
Apple really needs to find a better name for .background!

1 Like