What thread does a custom Global Actor use?

I have mutable state in a class so I can share it using Observable. I would like to achieve two things:

  • Prevent race conditions when multiple sources from multiple threads update and access my state
  • Ensure this state does not run on the main thread until it propagates

I wrote a custom global actor expecting it to run everything on the same non-main thread, but when I check by setting a breakpoint in a called function it's also executed on the main thread, unless I put it on a DispatchQueue.global() thread first. Then, it will stay on that global thread.

When I apply the MainThread it will force the rest of the code to go on the main thread.

Function called:

    @StateActor
    @discardableResult
    public func add(
        amount: Int,
        of product: Product
    ) -> Bool {

Test code

    @StateActor
    func testAddingProductToOrder() {
        let exp = expectation(description: "test")
        let expectedAmount = 2
        let expectedProduct = Mocks.hamSandwich
        DispatchQueue.global().async {
            print("async") // async thread
            Task { @MainActor in
                // back in thread 1, need to add await to call the @StateActor function add
                print(await self.order.add(amount: expectedAmount, of: expectedProduct))
                exp.fulfill()
            }
        }

Versus

    @StateActor
    func testAddingProductToOrder() {
        let exp = expectation(description: "test")
        let expectedAmount = 2
        let expectedProduct = Mocks.hamSandwich
        // I'm on thread 1 now
        DispatchQueue.global().async {
            print("async")
            Task { @StateActor in
                // I'm off thread 1 now, also inside of this function
                print(self.order.add(amount: expectedAmount, of: expectedProduct))
                exp.fulfill()
            }
        }

How does my custom global actor maintain concurrency when it's not accessing it on the same thread? Is there any way to lock a certain actor to a certain thread just like MainActor does?

Edit: I just read this answer: ios - Understanding GlobalActor, is it guaranteed it won't execute on Main Thread - Stack Overflow

So I understand it's not about threads at all. Still, I don't understand how it achieves robust concurrency when it's not isolating the access to one thread?

1 Like

You shouldn't need to lock it to a particular thread, it will maintain mutual exclusion internally and make sure that even if it switches threads it never has two threads simultaneously. This is similar to how a serial dispatch queue is allowed to switch threads between each block, but can still safely be used for synchronization.

It should also be avoiding the main thread without you having to do anything, I wonder if the debugger is reporting misleading results, or if there's something we haven't noticed about your code that would explain this (or, less likely but still possible, if you've found a compiler bug).

4 Likes

OK let me share the minimum code to reproduce. First, the globalActor:

@globalActor
public struct StateActor {
    public actor ActorType { }

    public static let shared: ActorType = ActorType()
}

Next, the minimum from the Order class:

@Observable
final public class Order {
    public private(set) var lines = [OrderLine]()

    @StateActor
    @discardableResult
    public func add(
        amount: Int
    ) -> Bool {
        return true
    }
}

Dummy OrderLine:

@Observable
@StateActor
final public class OrderLine: Equatable {
}

Then the test I'm using:

final class OrderTests: XCTestCase {
    private var order: Order!

    override func setUp() {
        super.setUp()

        order = Order()
    }

    @StateActor
    func testAddingProductToOrder() {
        let exp = expectation(description: "test")
        let expectedAmount = 2
        DispatchQueue.global().async {
            print("async")
            Task { @StateActor in
                print(self.order.add(amount: expectedAmount))
                exp.fulfill()
            }
        }
        XCTAssertTrue(order.add(amount: expectedAmount))

        wait(for: [exp], timeout: 1)
    }
}

To reproduce what? You need to outline what your expectations are and how your example differs.

Hi @Jon_Shier , let me sum it up again as it might got lost in between code comments:

  • I expect everything executed on @MainActor to be on the main thread. This is true.
  • I expected everything on my @StateActor to happen on the same, non-main thread. This is not true.

I observe that when I go async MainThread will put it back on the main thread (Task { @MainActor in but when I run code in @StateActor it's on the main thread and when I create an async thread and execute code within Task { @StateActor in it's still in that particular async thread rather than either the main thread or the non-main thread I expected to start out with.

1 Like

That is a mistaken expectation. Swift Concurrency uses a thread pool, with the only shipping exception being MainActor which uses a custom executor to run tasks on the main thread. All other actors, including global actors, use the thread pool.

2 Likes

Is there anything I can expect from that thread pool?

My first concern is that I don't want hundreds of events triggering hundreds of updates per second on my state and everything happening on the Main thread, possibly messing with my UI performance. I'm not sure if the thread pool is really that smart.

My second concern is that state can be mutated by many different sources like async network results, messages coming from a web socket or longer running tasks completing all from different threads. I don't want race conditions / data races to start happening in my state when it's taxed. It seems that this is guaranteed by using a separate actor for everything related to state.

My apologies for asking such basic questions about actors and Tasks, but the magicness of @MainActor might led me to believe there was more to get from a custom global actor.

1 Like

You can expect that it will not be the main thread, and it will only be one thread at a time.

1 Like

I am not sure how to force work off the main thread. I know that the runtime will avoid thread hops if it can, in a manner similar to GCD.

In terms of protecting state, that's exactly what an actor is for. While async methods of an actor are re-entrant, the runtime ensures that only one task is executing within the actor's domain at any time. You have to be careful about checking for mutations after suspension points, but in between those points you can be sure no other code can affect the state.

1 Like

https://github.com/apple/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md explains how to get work off the main thread. It's actually very simple: don't put it on the main thread (actor). If you have an async function that isn't @MainActor-bound, it won't run on the main thread, ever.

3 Likes

Just to confirm, does that mean if you only run work on your own actors you will never use all cores; one will always be left otherwise idle?

No, that's not what it means. The main thread does not get a dedicated core (it does get higher priority scheduling to a core, but if it's idle another thread will just go instead).

The mapping of threads to cores is an implementation detail. My understanding is that the current implementation has a rather conservative pool, but there's nothing that prevents that from changing.

Thank you so far for all of the insightful answers. I think what threw me off the right trail is the fact that while I marked my tests @StateActor, they were still executed on the main thread. I'm not sure why this is but I'll avoid doing so in my further investigations to prevent uncertainty, at the cost of slightly more complex (async) tests.

To get more insight in what we've just discussed I tried to recreate a race condition within a unit test and then solved it by using my @StateActor:

import XCTest

// Without the actor
class Actorless {
    private(set) var sum: Int!

    func add() -> Int {
        print(Thread.current)
        sum = nil
        sum += 1
        return sum
    }
}

// Exactly the same, but with the actor
@StateActor
class Actored {
    private(set) var sum: Int!

    func add() -> Int {
        print(Thread.current) // Remove this to make the non-isolated example crash
        sum = nil
        sum = 1
        return sum
    }
}

@globalActor
struct StateActor {
    actor ActorType { }

    static let shared: ActorType = ActorType()
}

final class NavigationTestTests: XCTestCase {
    // This test crashes fairly quickly on nil-unwrapping
    func testActorless() throws {
        let group = DispatchGroup()
        let obj = Actorless()

        for _ in 1...1000 {
            DispatchQueue.global().async {
                _ = obj.add()
            }
        }

        let result = group.wait(timeout: DispatchTime.now() + 5)

        XCTAssert(result == .success)
    }

    // This test passes, but the printed thread numbers are all over the place
    func testActoredNonisolated() throws {
        let group = DispatchGroup()

        Task { @StateActor in
            let obj = Actored()

            for _ in 1...1000 {
                DispatchQueue.global().async {
                    _ = obj.add() // ⚠️ Call to global actor 'StateActor'-isolated instance method 'add()' in a synchronous nonisolated context; this is an error in Swift 6
                }
            }
        }

        let result = group.wait(timeout: DispatchTime.now() + 5)

        XCTAssert(result == .success)
    }

    // This test also passes and also prints the same thread
    func testActored() throws {
        let group = DispatchGroup()

        Task { @StateActor in
            let obj = Actored()

            for _ in 1...1000 {

                DispatchQueue.global().async {
                    Task { @StateActor in
                        _ = obj.add()
                    }
                }
            }
        }

        let result = group.wait(timeout: DispatchTime.now() + 5)

        XCTAssert(result == .success)
    }
}
  1. The first test crashes, which is great because then we know we have an issue with concurrency.
  2. The second test passes but all of the thread numbers are all over the place. Yet, it seems to be accessing the function in a somewhat isolated manner as it doesn't crash anymore. This is probably not optimal in terms of performance and I guess that's why we're getting that warning.
  3. The third example behaves exactly as expected, doing all of its work on one thread.

Edit: I found out that if I remove the print statement for the current thread it also starts crashing on the non-isolated example. So don't ignore that warning, unless you're looking for trouble. Funny thing is that it's about 100x faster to do this in a non-isolated way when it doesn't crash with the print statement.

1 Like

Hey @LucasVanDongen :wave:
Try looking at the SE-0392 proposal

1 Like