Why do Task executions appear ordered inside an actor even when priorities differ?

I've been experimenting with Swift Concurrency and noticed an interesting behavior.
If I write the following code inside an actor:

actor MyActor {
    func runTasks() {
        for i in 0..<100 {
            let priority: TaskPriority = (i % 2 == 0) ? .high : .low
            Task(priority: priority) {
                print(i)
            }
        }
    }
}

I expected the output to be out of order due to the use of different task priorities.
However, the numbers are printed in order (0 to 99), consistently.

I was under the impression that:

  • Task {} creates a new concurrent task, and
  • Task(priority:) could affect the scheduling order.

So why does the output still follow a strict order?

Does creating Task objects from within an actor method cause them to execute serially, even if those tasks are not explicitly bound to the actor?

Is this behavior reliable, or just an artifact of how the concurrency system behaves in this simple example?

Any clarification or pointers to official documentation would be appreciated to undersant thank a lot in advance

2 Likes

hi @NicolasL, and welcome to the Swift forums – i tried out your code in a Swift playground (Xcode 16.3) and it appeared to generally execute the high priority Tasks before the low priority ones. the output sort of looked like this (running only 20 iterations):

0 (high)
2 (high)
4 (high)
6 (high)
8 (high)
10 (high)
12 (high)
14 (high)
16 (high)
20 (high)
3 (low)
18 (high)
7 (low)
9 (low)
11 (low)
19 (low)
1 (low)
5 (low)
15 (low)
17 (low)
13 (low)

perhaps there's something about your build or execution environment that might help explain the behavioral difference?

1 Like

I think that's because the tasks don't have much to do, apart from printing an integer.

I hope @robert.ryan sees your post, but the following code (adapted from yours) produces output which shows that tasks do run in random order (on a 2018 Mac mini.)

@main
enum Driver {
    static func main () async throws {
        for i in 1...5 {
            print (i, "...")
            let u = MyActor ()
            await u.runTasks (N: 10)
        }
    }
}

func hibernate (seconds: Double) async {
    try! await Task.sleep (until: .now + .seconds(seconds))
}

func work () async {
    await hibernate (seconds: Double.random (in: 0.1...0.5))
}

actor MyActor {
    var counter: Int = 0

    func taskFinished (_ i: Int) {
        print ("\t", i)
        counter += 1
    }
    
    func runTasks(N: Int) async {
        counter = 0
        for i in 0..<N {
            let priority: TaskPriority = (i % 2 == 0) ? .high : .low
        #if true
            Task (priority: priority) {
                await work()
                self.taskFinished (i)
            }
        #else
            Task (priority: priority) {@Foo in
                await work ()
                await self.taskFinished (i)
            }
        #endif
        }
        
        while counter < N {
            await hibernate (seconds:0.1)
        }
    }
}

@globalActor
actor Foo {
    static let shared = Foo ()
}

There is a thread that got necroed about a day ago: Task: Is order of task execution deterministic?

Hello :sun:

Thank you for your response! After reading your message, I tested my code again and realized I had made a mistake. :pensive_face:

So when I have this:

actor MyActor {
  var value = 0
    func runTasks() {
        for i in 0..<100 {
            let priority: TaskPriority = (i % 2 == 0) ? .high : .low
            Task(priority: priority) {
                print(i)
            }
        }
    }
}

With this code, I observed the same behavior as you described and for me it's logically and was I expected as we use Task.

However, when I used @MainActor, like this:

@MainActor
class MyVM {
  func runTasks() {
    for i in 0 ..< 100 {
      let priority = Int.random(in: 0...1)
      Task(priority: priority == 0 ? .high : .low) {
        print(i)
      }
    }
  }
}

All the numbers printed in order: 1, 2, 3, 4... up to 100. ( I have tested 10 time to not say error again) on iphone environement

To summarize where my observation comes from: at first, I wanted to do this:

actor MyActor {
  @MainActor var publisher = PassthroughSubject<(Int), Never>()
  var value: Int = 1 {
    didSet {
      let value = value
      Task {
        await didUpdate(value: value)
      }
    }
  }
  
  func tryToDoMultipleUpdate()  {
    for _ in 0 ... 1000 {
      value =  value + 1
    }
  }
  
  @MainActor
  func didUpdate(value: Int) {
    publisher.send((value))
  }
}

And I started to doubt whether it was a good idea, because for me with a Task, the publisher’s sink might not receive values in the right order.
And I was actually surprised by testing, to see that the values came in the same order.
However, when I change the priorities, they’re no longer in order, as expected this one.

I am really sorry to be confused because I did a lot of test to try to understant

I am really sorry I have not seen it :( I am new here, I have to delete my post and write my comment there?

hello :D
I was thinking like you at firs
I just wanted to be sure this isn’t the default behavior, and not rely only on what I saw. :slightly_smiling_face:
Thank you for your answer :D

Instead of using Combine here (which has poor compatibility with new concurrency model), consider AsyncStream. There you would be able to control order of sent events.

3 Likes

Yes, Task {…} will create a new top-level task on behalf of the current actor (if any). Since your tasks are synchronous, they won’t run concurrently with respect to each other, but they are separate top-level tasks on behalf of MyActor.

It can. It does affect the scheduling order. As SE-0304 says :

Now, I draw your attention to key phrases, namely “may inform”, “may utilize”, and “attempt to” (emphasis added above). In my experience it tends to run them (a) in order of priority and then (b) the order that they were submitted, but neither of those are technically guaranteed. [Edit: As noted in this discussion, at least in Swift 6, a key differentiator is whether the Task closure references self or not; if you don’t do something that requires isolation, the task may not be isolated, the task itself might not be isolated and will result in the jumbled order. But once you do reference self, you will get the order of execution you expected.]

A few observations:

  1. Playgrounds are a poor platform to test performance. Build an app. Also, I might advise using an optimized “release” executable, not an unoptimized “debug” build.

  2. I might suggest doing something more than print, which runs exceedingly quickly; perhaps too quickly for the scheduler to have a chance to catch up. With your code, I saw a general preference for the high priority tasks, but not strictly so. As soon as I added something that simulated a little work (e.g., spinning in a while loop for some short period of time, say a few msec) added something that referenced self, then they ran in exactly the order one would expect.

When I did that in Xcode 16.4, I experienced the behavior one would expect: It ran the high priority tasks first, largely, if not entirely, in order, and then the low priority tasks, again in their respective order. I seem to have experienced a little more variability in this regard in the past, but that was the result of my test this evening with Swift 6.1.2.


By the way, I should make the obligatory observation that this sort of use of unstructured concurrency (neither awaiting nor handling cancellation) is obviously an anti-pattern. But as a purely educational exercise, we’re free to do what we want. Lol. Just a caveat for future readers.

3 Likes

I believe this is expected and guaranteed to always be executed in this fashion. This is because of the @isolated(any) and @_inheritActorContext annotations in the Task.init - the tasks you create inherit the actor context of the surrounding scope, which in this case is MainActor and synchronously schedule the work on the main actor's serial executor (this does not mean they execute synchronously - they just reserve the place in the queue synchronously). You can read more about it here: SE-0431: @isolated(any) Function Types | massicotte.org

Since the work you're scheduling here is synchronous and there are no suspension points expressed by await expression, I believe the priority just does not come to play any role here. If you were scheduling child tasks from those parent tasks you create in the loop, or you would be awaiting some other calls, then the work won't be synchronous anymore and priority could be taken into account.

If you want tasks to avoid inheriting the actor context and always take priority into account, even for synchronous work, you could look into Task.detached or Task.init(executorPreference:) where you can pass globalConcurrentExecutor.

1 Like

hello thank for your answer
do you means like this:

actor MyActor {
  private var continuation: AsyncStream<Int>.Continuation?
  var stream: AsyncStream<Int> {
    AsyncStream { continuation in
      self.continuation = continuation
    }
  }
  
  var value: Int = 1 {
    didSet {
      continuation?.yield(value)
    }
  }
  
  func tryToDoMultipleUpdate()  {
    for _ in 0 ... 1000 {
      value = value + 1
    }
  }
}
@MainActor
class ViewModel: ObservableObject {
  
  let myActor = MyActor()
  
  init() {
    
    Task {
      for await value in await myActor.stream {
        print("Received: \(value)")
      }
    }
    
    Task {
      await myActor.tryToDoMultipleUpdate()
    }
  }
}

Effectively it's much better thank you, but we can have only one to one with publisher we can one to n

1 Like

Thank you very much for your time and your explanations — it’s all much clearer now :D

thank you for you help :) I'm starting to better understand how it works.

Yes, that exactly what I meant. The one-to-many is a standing issue, I recall several topics on that matter on the forum, you can try to look via search them, there were solutions.

Still if you are going to use new concurrency, I suggest to look into using its capabilities instead of mixing with Combine, to my experience and quite a few posts on this forum this is much more painful, as Combine wasn’t designed for this nor planned by Apple to accommodate this to my knowledge

1 Like

i was going to write something along these lines, but another thread on this same topic surfaced before i finished and @ktoso's reply there regarding how actor isolation inference works explains the difference in your examples here[1].

a small nit: as pointed out in the thread referenced above, in the original example the Tasks aren't isolated to MyActor since they do not capture the isolated self parameter, so they will in that particular case run concurrently with respect to each other. it's not that they are synchronous, but that the closure is inferred to be nonisolated.

apologies for the pedantry, but i think it is perhaps worth reiterating @robert.ryan's citation of SE-0304 from upthread that the ultimate scheduling and execution semantics for code like this is up to decisions made by an executor. for @MainActor code specifically, it explicitly specifies some details about the executor in the docs:

[The MainActor is a] singleton actor whose executor is equivalent to the main dispatch queue.

but the precise behavior is still dependent on however the 'main dispatch queue' is implemented. it happens to be the case that the main dispatch queue is a certain serial dispatch queue, and so enqueuing and priority escalation work in a particular way in that context[2] (and this seems unlikely to change). but in general an actor could have a custom serial executor implementation, or end up using one from an inherited executor preference that had different semantics.

i guess i think the point to highlight here is that if you have specific execution ordering requirements, it's probably best to explicitly manage that rather than relying on behaviors that may not always work the way you want.


  1. global actor isolation applies somewhat differently to Task.init closures than instance-based actor isolation ↩︎

  2. work items are processed FIFO and priority escalation boosts the priority of earlier-enqueued items but doesn't alter ultimate execution order ↩︎

1 Like

in the original example the Task s aren't isolated to MyActor since they do not capture the isolated self parameter, so they will in that particular case run concurrently with respect to each other.

I completely forgot that detail. :see_no_evil_monkey:

Thank you for all your precision, now It's much clearer to me now.

1 Like