Does Tasks inside `actor` init implicitly inherit some super low prio?

This simple unit test suprisingly fails:


final class MyActorTest: XCTestCase {
    
    // Takes ~4 secondsd on a M1.
    func test_actor() async {
        enum Outcome: Sendable, Equatable {
            case initTaskRanFirst
            case methodTaskRanFirst
        }
        func do_test() async -> Outcome {
            let myActor = MyActor()
            await myActor.appendElement(element: MyActor.initTaskElement + 1)
            let elements = await myActor.elements
            return elements[0] == MyActor.initTaskElement ? .initTaskRanFirst : .methodTaskRanFirst
        }
        var results: [Outcome] = []
        let runs = 10000
        for _ in 0..<runs {
            let outcome = await do_test()
            results.append(outcome)
        }
        let timesInitTaskRanFirst = results.filter{ $0 == .initTaskRanFirst }.count
        let percentagInitTaskRanFirst = Float(timesInitTaskRanFirst) / Float(runs)
        XCTAssertEqual(percentagInitTaskRanFirst, 0.5, accuracy: 0.1) // always around 14%, not near 50%
    }
}

Where MyActor is this simple actor:

final actor MyActor {
    typealias Element = Int

    public private(set) var elements: [Element] = []
    public static let initTaskElement: Element = 3
    
    // CANNOT be marked `async` since `MyActor` in my case REALLY is a `GlobalActor`
    // but cannot be marked as such for the test.
    init() {
        Task(priority: .high) { // <-- let's call this `initTask`
            await appendElement(element: Self.initTaskElement)
        }
    }
    
    func appendElement(element: Int) async {
        await Task(priority: .low) { // <-- let's call this `methodTask`
            self.elements.append(element)
        }.value
    }
}

I understand that the two tasks initTask and methodTask are just... two tasks, and Structured Concurrency does not guarantee any ordering. Why I even try giving initTask a prio of high and methodTask a prio of low.

However, I run this test many times (10000) and initTask only runs first in about ~14% of the time.

My question: Does initTask inherit some implicit superLowPrio which is the reason why methodTask runs before initTask in over 80% of the runs?

First, a terminology thing: you are using unstructured concurrency, not structured concurrency. Only task groups and async let create child tasks with structured concurrency (where cancellation is propagated). Task.init creates an unstructured task (no cancellation propagation, but inherits priority and task-local values, in contrast to a detached task, which inherits nothing).

Even though you created a low-priority task here, the system will escalate its priority if your appendElement method is running in a higher-priority task. This is described in SE-304:

Task priorities are set on task creation (e.g., Task.detached or TaskGroup.addTask) and can be escalated later, e.g., if a higher-priority task waits on the task handle of a lower-priority task.

Since appendElement is awaiting the low-priority task, the runtime will increase the awaited task's priority if the waiting task has a higher priority.

You may be able to verify if this is the case by checking Task.currentPriority inside your Task closure.

1 Like

Yes initTask finishes first in 95% of the cases when the actor is declared like this:


final actor MyActor {
    typealias Element = Int

    public private(set) var elements: [Element] = []
    public static let initTaskElement: Element = 3
    
    // CANNOT be marked `async` since `MyActor` in my case REALLY is a `GlobalActor`
    // but cannot be marked as such for the test.
    init() {
        Task {
            await _appendElement(element: Self.initTaskElement)
        }
    }
    
    private func _appendElement(element: Int) {
        self.elements.append(element)
    }
    
    public func appendElement(element: Int) async {
        await Task {
            _appendElement(element: element)
        }.value
    }
}

Notice the difference, where I'm introducing a new private func _appendElement call by both init and public func appendElement.

Please note that if I only declare the public func as:

 public func appendElement(element: Int) {
        _appendElement(element: element)
    }

Suddenly the unit test fails, initTask only completed first 4% of the cases instead. I find this behaviour very... unintuitive, no? I must creat one extra function explicitly marked as async, and do await Task{}.value, then suddently rate with which initTask runs first goes from 4% to 95%...

Can someone explain this?

What you are seeing is basically a good old race condition.

When you create a new task in MyActor.init(), the task is first enqueued to be executed by the Concurrency runtime and that task will start executing sometime later when the runtime (i.e. libdispatch) decides to do so. If you are "unlucky" (seems like you are highly "unlucky" with the 4%), it will start executing after your call to appendElement(...) in your test code.

Now if you also create a task in the appendElement(...) method, i.e. mark it async and do await Task{}.value, then that task is enqueued after the one that was enqueued in MyActor.init() so there is a much higher chance for the task created in init() to start executing first and you will see the 95%.

So yes, concurrent code is often quite unintuitive if all factors haven't been taken into account.

1 Like