3

In the Apple Docs, it says

Tasks can start running immediately after creation; you don’t explicitly start or schedule them.

However, in my code, the Task only starts running when the function that calls it goes out of scope.

func test() {
    Task {
        print("in task")
    }
    for _ in 0 ..< 10_000_000 { }
    print("done counting")
}

This code when executed hangs / waits for a certain amount of time and then always prints:
done counting
in task

I expected it to print
in task
done counting

Can someone explain what I'm missing from what's written in the Apple Docs?

rayaantaneja
  • 1,182
  • 7
  • 18

3 Answers3

4

You say elsewhere that Task {…} “starts running on its own thread asynchronously”. It doesn’t. It creates a new task for the current actor.

As the docs for Task {…} says, it:

Runs the given nonthrowing operation asynchronously as part of a new top-level task on behalf of the current actor.

But an actor can only be running one thing at a time. So, that means that until the current actor finishes execution (or hits an await “suspension point”), this Task will simply not have an opportunity to run.

If you want it to start something on a separate thread, consider Task.detached {…}. The docs for detached tell us that it:

Runs the given throwing operation asynchronously as part of a new top-level task.

So, a detached task may avail itself of a thread pulled from the cooperative thread pool, running in parallel with the current code. (Obviously, if the cooperative thread pool is busy running other stuff, even Task.detached might not start immediately: It depends upon your hardware and how busy the the cooperative thread pool might be with other tasks.)

But the following will likely show “in task” before “done counting”. (Technically, there is a “race” and either message could appear first, but presumably your for loop is slow enough that you will consistently see “in task” message first.)

func test() {
    Task.detached {
        print("in task")
    }
    for _ in 0 ..< 10_000_000 { }
    print("done counting")
}

Needless to say, you never would spin like this unnecessarily. I presume that the spinning for loop is here merely to manifest the problem at hand. But in more practical examples, where you have an await suspension point, the problem simply disappears. Consider the following:

func downloads(_ urls: [URL]) async {
    Task {
        print("in task")
    }

    for url in urls {
        await download(url)
    }

    print("done counting")
}

Here, the detached task is not needed because we have an await in this routine, so the is ample opportunity for the Task to be run. This obviously is a contrived example (i.e., I generally would avoid unnecessary unstructured concurrency), but hopefully it illustrates that if your loop doesn’t actually block, but rather has await suspension points, using the current actor is not a problem.


Note, the detached task example earlier is a bit backwards from the common pattern. (I did that to preserve your code order.) Usually, it is the blocking code that would go inside the detached task. E.g., consider an example where we want to call some image processing service that is synchronous, computationally intensive, and blocks the caller; this is a more practical example of a detached task, explicitly avoiding the blocking of the current actor:

func process(_ image: UIImage) async throws {
    print("starting")

    processedImage = try await Task.detached {
        ImageService.shared.convertToBlackAndWhite(image)
    }.value

    print("done")
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
1

You're probably testing in the simulator, which serializes Swift Concurrency to a single thread. Note the phrase "can start running." It does not promise that it will.

You also are blocking the whole thread with your loop. You may want to add a Task.yield() in the block to allow other things to run. Structured concurrency is all about cooperating tasks that await when they would otherwise block. It's not specifically about parallelism of CPU-intensive tasks. Tasks are not intended to block their thread (doing so violates their contract to "make progress").

Tasks also default to the same context that they are started in. If you want a new independent task, you want to use Task.detached {}.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • I'm running on a device. Also yes I'm blocking the thread because I wanted to see if the Task starts running on it's own thread asynchronously. It's just a testing project so I was blocking it for testing purposes – rayaantaneja Mar 20 '23 at 14:29
  • While I don’t think it is the operative issue here, your point is well taken, Rob. But a minor detail: In my experience, the cooperative thread pool on the simulator is not limited to a single thread, but rather [two](https://stackoverflow.com/q/67978028/1271826). (Why in heaven’s name couldn’t they just give the various simulators a cooperative thread pool size that mirrors the capabilities of the respective simulated device?! They really made life far more confusing for developers with this crippled simulator thread pool.) – Rob Mar 20 '23 at 17:13
  • 1
    I could have sworn it was currently one... but I'll bow to your testing. (I actually think 2 is probably fine for sussing out problems, without creating extra complexity. My assumption has been that the headaches were due to it being one.) – Rob Napier Mar 20 '23 at 17:27
  • 1
    I just retested it in Xcode 14.2 on a 2018 MacBook Pro and a 2022 Mac Studio, and the older hardware had a simulator cooperative thread pool of two threads, whereas the newer, more capable, hardware had [three](https://stackoverflow.com/a/75793720/1271826). Maybe if you were using older hardware, you actually might end up with a single thread in the cooperative thread pool. I can’t say. But your point stands, regardless, that the cooperative thread pool is constrained on the simulator. – Rob Mar 20 '23 at 18:26
0

In Swift's concurrency model, tasks created using the Task API are not executed immediately upon creation. Instead, they are added to the system's task scheduler and executed asynchronously at some point in the future.

In the code snippet you provided, the Task block is created but not executed immediately. The for loop following the Task block is executed before the Task block is scheduled for execution.

If you want to wait for the task to complete before executing the rest of the code, you can use the await keyword to pause the current task and wait for the child task to complete. For example:

   func test() async {
    await Task {
        print("in task")
    }
    for _ in 0..<10_000_000 { }
    print("done counting")
}

In this version, the await keyword is used to pause the current task and wait for the child task to complete before the print("done counting") statement is executed. This ensures that the message from the Task is printed before the "done counting" message.

Aaron A
  • 225
  • 2
  • 7
  • In the code you provided it is still printing "done counting" first – rayaantaneja Mar 20 '23 at 16:23
  • You are correct. In the original code snippet, the for loop following the Task block is executed before the Task is scheduled for execution, so "done counting" is printed before the Task's message. To fix this, the code should be updated to use an asynchronous function and the await keyword to pause the current task and wait for the child task to complete before continuing. I'll update the code. – Aaron A Mar 20 '23 at 16:28
  • great answer! just out of curiosity, did you get this from chatgpt? – starball Jun 21 '23 at 05:33