Cancelling and re-creating a task using Task.detached

Hi!

I'm just getting started with async/await and I'm trying to implement a sort of a polling mechanism with it.

I have a Store annotated with @MainActor with a long running nonisolated func work() async function inside of it. The store runs this function every 2 seconds using recursion and Task.sleep

The store runs the work function using Task.detached so as to not block the main thread.
You can start the polling mechanism from outside. In this case my ViewController is doing it. Every time you call doWork() the store cancels the current task and starts a new one. This allows me to pass some new context to the store in my actual production code.

I was expecting the doWork() function to (almost) immediately call work() since its doing it in a detached context. In reality, it always waits till the current execution is finished before starting a new one (except for the first time). Is this expected behaviour?

Also, on a side note, am I doing something fundamentally wrong here? :)

Here's the code:

import Foundation
import UIKit

@MainActor
final class Store {
    private var task: Task<(), Never>?

    func doWork() {
        debugPrint("Cancelling previous task and restarting ", Date())
        task?.cancel()
        doContinuousWork()
    }

    private func doContinuousWork() {
        task = Task.detached { [weak self] in
            await self?.work()
            debugPrint("Finished doing work ", Date())
            try? await Task.sleep(nanoseconds: 2_000_000_000)
            guard !Task.isCancelled else { return }
            await self?.doContinuousWork()
        }
    }

    nonisolated
    private func work() async {
        debugPrint("Doing work ", Date())
        await withUnsafeContinuation { continuation in
            (0 ... 10_000_000).forEach { _ in
                _ = DateFormatter()
            }
            continuation.resume(with: .success(()))
        }
    }
}

final class ViewController: UIViewController {

    let store = Store()

    override func viewDidLoad() {
        super.viewDidLoad()
        store.doWork()

        DispatchQueue.main.asyncAfter(deadline: .now() + 32) {
            self.store.doWork()
        }
    }
}

And the output it prints:

Cancelling previous task and restarting  2022-09-18 13:32:05 +0000
"Doing work " 2022-09-18 13:32:05 +0000
"Finished doing work " 2022-09-18 13:32:13 +0000
"Doing work " 2022-09-18 13:32:15 +0000
"Finished doing work " 2022-09-18 13:32:23 +0000
"Doing work " 2022-09-18 13:32:25 +0000
"Finished doing work " 2022-09-18 13:32:33 +0000
"Doing work " 2022-09-18 13:32:35 +0000
Cancelling previous task and restarting  2022-09-18 13:32:39 +0000
"Finished doing work " 2022-09-18 13:32:43 +0000
"Doing work " 2022-09-18 13:32:43 +0000.   // Should start at 13:32:39?
"Finished doing work " 2022-09-18 13:32:51 +0000
"Doing work " 2022-09-18 13:32:53 +0000
"Finished doing work " 2022-09-18 13:33:01 +0000
"Doing work " 2022-09-18 13:33:03 +0000

What's your testing environment? The iOS simulator? If so, please test on a device and report back.

1 Like

Is that intentional, calling store.doWork() twice?

There is no guarantee what thread/queue the task you started with Task.detached runs on. Your func work() might run on the main thread, blocking UI updates. (nonisolated is not @BackgroundActor)

That’s what you are seeing here:

  • old task is running in work, blocking main thread for ~8s.
  • old task is calling continuation.resume, it yields the main thread, allowing DispatchQueue.main.asyncAfter to run.
  • This marks your running task as canceled and starts a new one (which itself blocks main).
  • Let’s say the new task blocks main until await self?.work(). That await yields main.
  • This allows your old task to continue with what the resume call wanted to do: It prints “Finished doing work”, schedules a call to sleep (which throws because it is on a canceled task), and finally bails out.

(Or something similar)

TL;DR: Assume everything you do using Swift concurrency blocks main. There is (currently) no way to avoid that. There is no @BackgroundActor, especially Task.detached and nonisolated do not work like there was one.

It seems to work as expected on a real device (iPhone 12 Pro, iOS 16.0)!

"Cancelling previous task and restarting " 2022-09-19 09:02:59 +0000
"Doing work " 2022-09-19 09:02:59 +0000
"Finished doing work " 2022-09-19 09:03:12 +0000
"Doing work " 2022-09-19 09:03:14 +0000
"Finished doing work " 2022-09-19 09:03:24 +0000
"Doing work " 2022-09-19 09:03:26 +0000
"Cancelling previous task and restarting " 2022-09-19 09:03:33 +0000
"Doing work " 2022-09-19 09:03:33 +0000  // Starts immediately after cancelling
"Finished doing work " 2022-09-19 09:03:39 +0000  // The iteration that was interrupted
"Finished doing work " 2022-09-19 09:03:46 +0000  // The new iteration
"Doing work " 2022-09-19 09:03:48 +0000
"Finished doing work " 2022-09-19 09:03:59 +0000
"Doing work " 2022-09-19 09:04:01 +0000
"Finished doing work " 2022-09-19 09:04:13 +0000
"Doing work " 2022-09-19 09:04:15 +0000
"Finished doing work " 2022-09-19 09:04:26 +0000
"Doing work " 2022-09-19 09:04:28 +0000

Yeah, the second doWork() after the delay should interrupt/cancel the currently executing one, and restart the polling mechanism. My problem is (on the simulator at least) that it doesn't immediately do it and waits till the first one is finished.

This is because the iOS simulator only uses a single thread for its cooperative thread pool (at least it used to do that, I haven't checked recently) and your work function blocks its thread for a significant amount of time.

Your work function should check periodically for cancellation inside the (0 ... 10_000_000).forEach loop and cancel its work early if its task got canceled.

1 Like

This is true in a very strict sense — it has to be, because Swift concurrency can work in single-threaded environments. But I don't think it's true in practice, at least not on mainstream platforms (Apple platforms and Linux).

As of Swift 5.7, SE-0338 guarantees that non-isolated async functions run on the generic executor (aka the cooperative thread pool). Strictly speaking, this generic executor could use the main thread for running its jobs, but on Apple platforms it doesn't, and I think the same is true for Linux.

So practically speaking, Task.detached { … } will always run its operation off the main thread unless you're running on a Swift runtime that's designed for single-threaded environments, like embedded devices (or maybe WebAssembly? I'm not sure).

2 Likes

Thanks for the detailed response! :slight_smile:
The code behaves as I expected on a real device but your TL;DR definitely worries me :grimacing:

old task is running in work , blocking main thread for ~8s

Although this is completely anecdotal, from my testing, both on the simulator and the device, work() always runs in a background thread. The only case where it runs on the main thread is when I use Task.init instead of Task.detached. I've checked this by attaching a break point on every step in the work() function. I've also confirmed this in Instruments (main thread is not busy when work() is running).

Also, calling doWork() again does print out the cancellation message. If the main thread was indeed blocked during this time, it would not print it till the work function returns/continues. In my production code with a similar setup I can access all UI elements and interact with them normally.

But then again, why doesn't it start a new task is a mystery to me. Perhaps the scheduler is not comfortable with creating a new thread in my simulator environment?

There is (currently) no way to avoid that. There is no @BackgroundActor , especially Task.detached and nonisolated do not work like there was one.

Would you rather recommend to continue using libdispatch/NSOperations to do async tasks?

This is because the iOS simulator only uses a single thread for its cooperative thread pool

Aha! That makes a lot of sense :slight_smile:
Thanks a lot!

I really hope that is true. (And, if you are right that iOS simulator is a single threaded environment in this sense, this really should be fixed.)

An I think this should be a guarantee not an implementation detail.

Just recently there was a thread about unexpected switches to main. If @ole is right, this might only be a problem in the iOS simulator.

On the other hand, if there is no guarantee that Task.detached does not use the main thread, I consider it a no-go for anything that might block the thread for more than 1/120s. In that case I’ll continue to use dispatch queues.

1 Like

To be clear, the simulator is not a single-threaded environment. It only uses a single thread for the cooperative thread pool, but that thread is separate from the main thread.

If I remember correctly, the rationale given last year for the simulator behavior was that the simulator needed to run on older macOS versions that don't have the concurrency runtime, so there were constraints on what the implementation could do. I don't know if these constraints still exist.

In some sense, you might even argue that the simulator behavior is a feature because it can help uncover problems in async code, as it did in @lol's code in this example (violating the rule that async functions should not block their thread).

I don't know if this is written down in formal documentation, but I'm almost 100% sure it is in fact guaranteed on Apple platforms, Linux, and similar "mainstream" platforms.

If anything, it always has to be a per-platform guarantee because the behavior is defined by the implementation of the Swift runtime on each platform. Some platforms may only have a single thread (e.g. some versions of WebAssembly, though I think this is changing/has changed) or may not even have the concept of threads (e.g. embedded devices).

1 Like