Jack Morris
Shipping Values Between Actors
29 Nov 2023

I was recently designing an API with the following constraints:

Picture this:

@MainActor
func handle<V>(_ value: V) {
  Task.detached {
    // Not on the main actor.
    //
    // Do some stuff...
    Task { @MainActor in
      // Back on the main actor.
      value.something()
    }
  }
}

Going down this path, V has to be constrained as Sendable (to avoid warnings when strict concurrency checking is enabled), which is an unfortunate over-requirement. Whilst the reference is being passed between concurrency domains (i.e. "sent"), it's only ever interacted with on the main actor. Therefore I tried using a lightweight box type to "ship" a value from an @MainActor context to another.

struct MainActorBox<V>: @unchecked Sendable {
  @MainActor
  init(value: V) {
    self.value = value
  }

  @MainActor
  func get() -> V {
    value
  }

  private let value: V
}

The box itself is Sendable, however the value is effectively hidden away when not on the main actor. This allows the box to be safely transferred between concurrency domains without warnings.

@MainActor
func handle<V>(_ value: V) {
  let box = MainActorBox(value: value)
  Task.detached {
    // Not on the main actor.
    //
    // Do some stuff...
    Task { @MainActor in
      // Back on the main actor.
      box.get().something()
    }
  }
}

It would be awesome if the compiler could figure this out without having to wrap the type, but I acknowledge that this could be quite tricky to do statically (particularly for a non-trivial example).

Another improvement would be able to make this generic to any concurrency domain (rather than just the main actor), however I don't believe that's possible right now (even for custom global actors).