GlobalActor and DispatchQueue pairing like @MainActor and DispatchQueue.main

Right now I can use @MainActor and DispatchQueue.main to effectively step into the @MainActor context, for example:

@MainActor
final class Example {
  var test = "hello"
}

var myExample = Example()

now if I want to change the value of myExample.test from some other thread context, I have to use await to jump into the @MainActor context. But if I use DispatchQueue.main like this:

DispatchQueue.main.async { 
  myExample.test = "world"
}

it'll work with no need for await and no compiler complaints. Running my code inside the DispatchQueue.main instance is recognized as being in the @MainActor thread context.

My question is: how can I reproduce this pairing with my own @globalActor and DispatchQueue? Try as I might, I cannot make this happen.

thanks for any help!

1 Like

You should be able to reproduce this relationship if your actor uses a custom serial executor that is wraps a serial dispatch queue. You might start here since there are some code fragments that show how it might be done.

Kind of, it'll boil down to using assumeIsolated(_:file:line:) | Apple Developer Documentation on your actor where you've set some specific serial queue as executor, so give this API a look, and the linked thread.

thanks so much for your help! here's what I had worked out that I thought should work, but doesn't seem to...

final class DispatchQueueExecutor: SerialExecutor {
  let queue = DispatchQueue(label: "com.example.myCustomQueue")

  func enqueue(_ job: UnownedJob) {
    self.queue.async {
      job.runSynchronously(on: self.asUnownedSerialExecutor())
    }
  }

  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }

  func checkIsolated() {
    dispatchPrecondition(condition: .onQueue(self.queue))
  }
}

@globalActor
actor MyCustomActor: GlobalActor {
  static let shared = MyCustomActor()

  internal static let dispatchQueueExecutor: DispatchQueueExecutor =
    DispatchQueueExecutor()
  static var queue: DispatchQueue { dispatchQueueExecutor.queue }

  nonisolated public var unownedExecutor: UnownedSerialExecutor {
    Self.dispatchQueueExecutor.asUnownedSerialExecutor()
  }
}

@MyCustomActor
final class MyCustomClass {

}

Alas, with this setup, dispatching with MyCustomActor.queue still doesn't seem to get me into the same context (as least, not insofar as the compiler is pleased...) as my MyCustomClass that has been annotated with the @MyCustomActor context.

Something to do with using assumeIsolated instead of checkIsolated, I'm gathering...

It would help if you could share exact snippets and errors you encounter. I'm somewhat guessing what you're after here since it's a bit vague.

If you want the MainActor.assumeIsolated equivalent for your MyCustomActor then you can write it like this:

// The Swift Programming Language
// https://docs.swift.org/swift-book
// 
// Swift Argument Parser
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation

import ArgumentParser
import Dispatch

@available(macOS 15, *)
final class DispatchQueueExecutor: SerialExecutor {
  let queue = DispatchSerialQueue(label: "Mine")

  func enqueue(_ job: UnownedJob) {
    self.queue.async {
      job.runSynchronously(on: self.asUnownedSerialExecutor())
    }
  }

  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }

  func checkIsolated() {
    dispatchPrecondition(condition: .onQueue(self.queue))
  }
}

@globalActor
@available(macOS 15, *)
actor MyCustomActor: GlobalActor {
  static let shared = MyCustomActor()

  internal static let dispatchQueueExecutor: DispatchQueueExecutor =
    DispatchQueueExecutor()
  static var queue: DispatchSerialQueue { dispatchQueueExecutor.queue }

  nonisolated public var unownedExecutor: UnownedSerialExecutor {
    Self.dispatchQueueExecutor.asUnownedSerialExecutor()
  }
  
  
  @_unavailableFromAsync(message: "await the call to the @MainActor closure directly")
  public static func assumeIsolated<T : Sendable>(
      _ operation: @MyCustomActor () throws -> T,
      file: StaticString = #fileID, line: UInt = #line
  ) rethrows -> T {
    typealias YesActor = @MyCustomActor () throws -> T
    typealias NoActor = () throws -> T

    /// This is guaranteed to be fatal if the check fails,
    /// as this is our "safe" version of this API.
    dispatchPrecondition(condition: .onQueue(queue))

    // To do the unsafe cast, we have to pretend it's @escaping.
    return try withoutActuallyEscaping(operation) {
      (_ fn: @escaping YesActor) throws -> T in
      let rawFn = unsafeBitCast(fn, to: NoActor.self)
      return try rawFn()
    }
  }
}

@MyCustomActor
@available(macOS 15, *)
final class MyCustomClass {
  nonisolated func test() {
    MyCustomActor.assumeIsolated {
      print("OK!")
    }
  }
  
  func check() {
    self.test()
  }
}

@main
struct MyTool  {
    public static func main() async throws {
      if #available(macOS 15, *) {
        let actor = MyCustomClass()
        
        MyCustomActor.queue.async {
          print("inside queue")
          actor.test()
        }
        await Task {
          print("in some task... hop to the actor...")
          await actor.check()
        }.value
        
      } else {
        fatalError()
      }
    }
}

Output:

inside queue
OK!
in some task... hop to the actor...
OK!
Program ended with exit code: 0

We can't have this function declared on GlobalActor because we can't be generic over @TheActor annotations, but if you write the func youtself and properly write the dispatchPrecondition that'd work.

1 Like

sorry, yes, here's a full example of what I'd like to see work:

import SwiftUI

final class DispatchQueueExecutor: SerialExecutor {
  private let actor: MyCustomActor
  init(_ shared: MyCustomActor) {
    actor = shared
  }

  let queue = DispatchQueue(label: "com.example.myCustomQueue")

  func enqueue(_ job: UnownedJob) {
    self.queue.async {
      job.runSynchronously(on: self.asUnownedSerialExecutor())
    }
  }

  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }

  func checkIsolated() {
    dispatchPrecondition(condition: .onQueue(self.queue))
  }
}

@globalActor
actor MyCustomActor: GlobalActor {
  static let shared = MyCustomActor()

  internal static let dispatchQueueExecutor: DispatchQueueExecutor =
    DispatchQueueExecutor(shared)
  static var queue: DispatchQueue { dispatchQueueExecutor.queue }

  nonisolated public var unownedExecutor: UnownedSerialExecutor {
    Self.dispatchQueueExecutor.asUnownedSerialExecutor()
  }
}

@MyCustomActor
final class MyCustomClass {
  var text = "hello"
}

struct ContentView: View {
  var body: some View {
    Button("mutate custom class class") {
      Task.detached {
        let mine = MyCustomClass()
        MyCustomActor.queue.async {
          mine.text = "world" // Compiler complains here
        }
      }
    }
  }
}

If I were to swap out @MainActor for @MyCustomActor, and DispatchQueue.main for MyCustomActor.queue it'd work.

In other words, this snippet in the bottom instead has no compiler complaints:

@MainActor
final class MyCustomClass {
  var text = "hello"
}

struct ContentView: View {
  var body: some View {
    Button("mutate custom class class") {
      Task.detached {
        let mine = MyCustomClass()
        DispatchQueue.main.async {
          mine.text = "world"
        }
      }
    }
  }
}

Please check my snippet, you can write an assumeIsolated like that that'd do that.

The main queue is getting special magic treatment here that you can't simulate (arguably this causes some trouble for other things, but that's how it works).

You can write methods which check and give you the isolation like in my snippet above though: see the assumeIsolated impl how to checks and casts if (and only if) it is safe to assume the isolation.

1 Like

ok, thanks. I was hoping whatever @MainActor and DispatchQueue.main were doing was generically reproducible and not special-cased. But I'm very glad to have it resolved that there's no way to make that work and I won't bang my head against the syntax anymore. :)

Yeah, I can confirm that there's special knowledge in the language that "knows" DispatchQueue.main APIs "are" @MainActor.

This'll somewhat backfire on us when we allow customizing main actor executors... but it's a common case in current world so we did bless those two specific APIs (sync/async on that queue).

We'll have to think what to do about this assumption in the ver near future I think.

1 Like

it's interesting to see how you can make this work so that's safe and isolated while being nonisolated at the same time :). thanks for the education!

1 Like

So the nonisolated there is so we don't hop "anywhere", and the caller happens to be on the "right queue" so in a way this method could have been just a global or on some other class/type. But yeah, as you can see that'll work as well.

1 Like

interesting, ok, I'm picking up what you're laying down. thanks again for everything. I don't really need this functionality, was just enjoying trying to understand how it works. :)

1 Like

@jubishop In case you're interested, I blogged about how this "specal knowledge in the language" works: How the Swift compiler knows that DispatchQueue.main implies @MainActor. This was written pre-Swift 6, but I think the inner workings haven't changed.

4 Likes

You said:

You do not “have to use await to jump into the @MainActor context”. You can remain within the realm of Swift concurrency with:

Task { @MainActor in
    myExample.test = "world"
}

That achieves the same thing as DispatchQueue.main.async {…} (and includes all of the limitations that entails), but avoids introducing GCD in a Swift concurrency codebase. In short, you do not need to await.

Anyway, the global actor pattern would be identical:

@MyGlobalActor
final class Example {
    var string = "hello"
}

And:

Task { @MyGlobalActor in
    myExample.test = "world"
}
2 Likes

yeah. as I alluded to in a previous reply, I wasn't aiming to do it as a sort of modern best practice, I just wanted to understand how it was working.

follow up question. as far as I know the approach you laid out is the only way to modify a public var on an actor directly instead of calling a public function, is that correct?

Yes, that was one simple way to do it with a class isolated to a global actor.

As you suggest, the more generalized approach with an actor is to expose an function for mutating its state:

actor Example {
    var string = "hello"

    func setString(to value: String) {
        string = value
    }
}

And, if updating this from a synchronous context (where you cannot await directly), you could again use a Task:

Task {
    await myExample.setString(to: "world")
}

With actor types, you cannot directly update the individual properties directly.

This is an important language feature. The elimination of the data race on a given property is not our only concern: There might be interdependencies with other properties within the actor, too. Callers really should not be reaching and mutating individual properties, and the language enforces this principle for us. The actor bears the responsible for coordinating the mutation of its internal state.

This example is so trivial that having to go through the function seem like an unnecessary step. But it is, more generally, a very important design principle, namely that the actor bears responsible for maintaining its own internal integrity and for providing external interfaces.


On a separate topic, elsewhere you have contemplated the use of custom executors. I know this was just a thought-exercise, but I might advise some care: One of the principles of Swift concurrency’s cooperative thread pool is to ensure that the system is never over-committed (by limiting this pool to the number of the CPUs on the device). If you start employing custom executors all over the place, you lose this aspect of Swift concurrency.

Don’t get me wrong: Custom executors are a very welcome addition to the language. But, I might suggest limiting their use to their intended purpose (e.g., where some external system necessitates it due to some unusual threading limitations, etc.). Or, at the very least, if you adopt custom executors, understand the tradeoffs they entail. But, this does not strike me as a prudent use-case.

1 Like