One day, while messing with global actors, I found out that they can have generic parameters.
@globalActor
struct GenericGlobalActor<T> {
static var shared: MainActor { .shared }
}
@GenericGlobalActor<Int>
func foo() { print(#isolation) }
await foo()
When I discovered this, an idea came to me. Can I use this to make generic isolation sinde since there is no direct support for it?
After a bit of work and dancing with EXC_BAD_ACCESS crashes, I finally found a stable solution. This approach uses a helper in-between layer to achieve generic isolation.
Long Implementation
// You could simplify this a lot if you only need generic global actor isolation.
// MARK: Instance ID
protocol ActorInstanceProtocol<ActorType>: SendableMetatype {
associatedtype ActorType: InstancedActor
associatedtype ID: GNumber = IN0
static var id: UInt { get }
static var shared: ActorType { get }
}
extension ActorInstanceProtocol {
static var id: UInt { ID.value }
}
enum ActorInstance<
ActorType: InstancedActor,
ID: GNumber
>: ActorInstanceProtocol {
static var shared: ActorType {
ActorType.instances.withLock { $0.get(with: id) }
}
}
// MARK: - GNumber
// Integer generic parameters has a high version requirement and are a bit limiting.
protocol GNumber: SendableMetatype {
static var value: UInt { get }
}
enum IN0: GNumber { static let value: UInt = 0 }
enum IN1: GNumber { static let value: UInt = 1 }
enum IN2: GNumber { static let value: UInt = 2 }
enum IN3: GNumber { static let value: UInt = 3 }
enum IN4: GNumber { static let value: UInt = 4 }
enum IN5: GNumber { static let value: UInt = 5 }
enum IN6: GNumber { static let value: UInt = 6 }
enum IN7: GNumber { static let value: UInt = 7 }
enum IN8: GNumber { static let value: UInt = 8 }
enum IN9: GNumber { static let value: UInt = 9 }
struct DynamicInstanceNumber<
N9: GNumber,
N8: GNumber,
N7: GNumber,
N6: GNumber,
N5: GNumber,
N4: GNumber,
N3: GNumber,
N2: GNumber,
N1: GNumber,
N0: GNumber
>: GNumber {
static var value: UInt {
let digitTypes: [any GNumber.Type] = [
N0.self, N1.self, N2.self, N3.self, N4.self,
N5.self, N6.self, N7.self, N8.self, N9.self
]
var iterator = digitTypes.makeIterator()
var multiplier: UInt = 1
var result: UInt = iterator.next()!.value
while let digitType = iterator.next() {
multiplier *= 10
result += multiplier * (digitType.value)
}
return result
}
}
func instanceNumber(from int: UInt) -> any GNumber.Type {
func constructor<
N9: GNumber,
N8: GNumber,
N7: GNumber,
N6: GNumber,
N5: GNumber,
N4: GNumber,
N3: GNumber,
N2: GNumber,
N1: GNumber,
N0: GNumber
>(
type9: N9.Type,
type8: N8.Type,
type7: N7.Type,
type6: N6.Type,
type5: N5.Type,
type4: N4.Type,
type3: N3.Type,
type2: N2.Type,
type1: N1.Type,
type0: N0.Type,
) -> any GNumber.Type {
DynamicInstanceNumber<N9, N8, N7, N6, N5, N4, N3, N2, N1, N0>.self
}
func typeFromDigit(_ digit: UInt) -> any GNumber.Type {
switch digit {
case 0: IN0.self
case 1: IN1.self
case 2: IN2.self
case 3: IN3.self
case 4: IN4.self
case 5: IN5.self
case 6: IN6.self
case 7: IN7.self
case 8: IN8.self
case 9: IN9.self
default:
fatalError()
}
}
var int = int
var digitTypes = Array<any GNumber.Type>(repeating: IN0.self, count: 10)
var digit = 0
while int > 10 {
digitTypes[digit] = typeFromDigit(int % 10)
int = int/10
digit += 1
precondition(digit < 10, "Digit count overflow")
}
digitTypes[digit] = typeFromDigit(int % 10)
return constructor(
type9: digitTypes[9],
type8: digitTypes[8],
type7: digitTypes[7],
type6: digitTypes[6],
type5: digitTypes[5],
type4: digitTypes[4],
type3: digitTypes[3],
type2: digitTypes[2],
type1: digitTypes[1],
type0: digitTypes[0]
)
}
// MARK: - Instance Find-ability
// You may be asking why these conform to AnyObject rather than Actor.
// Well if you try to, MainActor will start giving you EXC_BAD_ACCESS crashes
// in the most random places.
typealias InstancedActor = Instanced & Actor
protocol Instanced: AnyObject {
static var instances: Mutex<Instances<Self>> { get }
}
struct Instances<T: AnyObject> {
var references: Set<Weak<T>> = []
var nextID: UInt = 0
init() {
references = []
nextID = 0
}
init(_ actor: T) {
references = [Weak(actor, with: 0)]
nextID = 1
}
init(_ actors: [T]) {
references = []
nextID = 0
actors.forEach { put($0) }
}
mutating func put(_ actor: T) {
references.insert(Weak(actor, with: nextID))
nextID += 1
}
func get(with id: UInt) -> T {
let i = references
let weakInstance = i.first { $0.id == id }
guard let instance = weakInstance?.value else {
preconditionFailure("Attempted access on deallocated actor")
}
return instance
}
mutating func remove(with id: Int) {
let weakInstance = references.first { $0.id == id }
guard let weakInstance else {
preconditionFailure("Attempted access on deallocated actor")
}
references.remove(weakInstance)
}
mutating func cleanup() {
let deallocated = references.filter { $0.value == nil }
references.subtract(deallocated)
}
}
extension Instanced where Self: Actor {
// You can drop down the required version lower by wrapping this type.
nonisolated var instanceID: any ActorInstanceProtocol<Self>.Type {
func construct<ID: GNumber>(
id: ID.Type
) -> any ActorInstanceProtocol<Self>.Type {
ActorInstance<Self, ID>.self
}
return Self.instances.withLock { instance in
let instance = instance.references.first { $0.value === self }
guard let instance else {
fatalError("Actor has not been registered")
}
return construct(id: instanceNumber(from: instance.id))
}
}
nonisolated func addInstance() {
Self.instances.withLock { $0.put(self) }
}
nonisolated func removeInstance() {
Self.instances.withLock { $0.cleanup() }
}
}
private let _mainActorSharedMutex = Mutex(Instances<MainActor>(.shared))
extension MainActor: ActorInstanceProtocol, Instanced {
static var instances: Mutex<Instances<MainActor>> { _mainActorSharedMutex }
}
// MARK: - Generic Isolation Global Actor
@globalActor
struct GenericIsolation<I: ActorInstanceProtocol> {
static var shared: I.ActorType {
return I.shared
}
}
extension Instanced where Self: ActorInstanceProtocol {
static func withGenericOverlay<T: Sendable>(
_ operation: @GenericIsolation<Self> () throws -> T,
file: StaticString = #fileID,
line: UInt = #line
) rethrows -> T {
typealias RawClosure = () throws -> T
GenericIsolation<Self>.preconditionIsolated()
return try withoutActuallyEscaping(operation) { closure in
let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
return try rawClosure()
}
}
}
// This crashes the compiler.
//
//extension Instanced where Self: Actor {
// func withGenericOverlay<I: ActorInstanceProtocol<Self>, T: Sendable>(
// _ operation: @GenericIsolation<I> () throws -> T,
// isolation: I.Type,
// file: StaticString = #fileID,
// line: UInt = #line,
// ) rethrows -> T {
// typealias RawClosure = () throws -> T
//
// return try withoutActuallyEscaping(operation) { closure in
// let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
// return try rawClosure()
// }
// }
//}
// As a workaround, erasing the return value to Any works:
extension Instanced where Self: Actor {
@discardableResult
func withGenericOverlay<I: ActorInstanceProtocol<Self>>(
_ operation: @GenericIsolation<I> () throws -> Any,
// Sadly you need to provide this manually.
// Swift doesn't allow `instanceID` to be the default argument.
isolation: I.Type,
file: StaticString = #fileID,
line: UInt = #line,
) rethrows -> Any {
typealias RawClosure = () throws -> Any
preconditionIsolated("\(#function) misuse, isolation does not match outer isolation", file: file, line: line)
return try withoutActuallyEscaping(operation) { closure in
let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
return try rawClosure()
}
}
}
extension GenericIsolation {
static func _withCoreIsolation_core<T: Sendable>(
_ operation: @isolated(any) () throws -> T,
file: StaticString = #fileID,
line: UInt = #line
) rethrows -> T {
preconditionIsolated("withCoreIsolation misuse, isolation does not match outer isolation", file: file, line: line)
func withoutIsolatedAny(_ rawClosure: () throws -> T) rethrows -> T {
return try rawClosure()
}
return try withoutIsolatedAny(operation)
}
}
extension GenericIsolation where I == MainActor {
static func withCoreIsolation<T: Sendable>(
_ operation: @MainActor () throws -> T,
file: StaticString = #fileID,
line: UInt = #line
) rethrows -> T {
try _withCoreIsolation_core(operation, file: file, line: line)
}
}
// MARK: - Miscellaneous
struct Weak<T: AnyObject>: Hashable {
weak let value: T?
var id: UInt = 0
init(_ value: T, with id: UInt) {
self.value = value
self.id = id
}
static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
final class Mutex<Value>: @unchecked Sendable {
private var lock = NSLock()
private var value: Value
init(_ value: Value) {
self.value = value
}
func withLock<R: ~Copyable, E>(_ body: (inout Value) throws(E) -> R) throws(E) -> R {
lock.lock()
defer { lock.unlock() }
return try body(&value)
}
}
Here are some examples & explanations:
// This is how you support a GlobalActor.
@globalActor
actor DatabaseActor: Instanced, ActorInstanceProtocol {
static let instances: Mutex<Instances<DatabaseActor>> = Mutex(.init(shared))
static let shared = DatabaseActor()
}
extension GenericIsolation where I == DatabaseActor {
static func withCoreIsolation<T: Sendable>(
_ operation: @DatabaseActor () throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T {
try _withCoreIsolation_core(operation, file: file, line: line)
}
}
// An actor that can have its instances be isolated to.
actor Test: Instanced {
init() { addInstance() }
deinit { removeInstance() }
static let instances = Mutex(Instances<Test>())
}
// Since singletons *kind of* look like global actors.
actor A123: ActorInstanceProtocol, Instanced {
static let shared: A123 = .init()
private init() {}
static let instances: Mutex<Instances<A123>> = Mutex(.init(shared))
}
// Any isolation check still passes since it's just a "wrapper" for the actor.
@GenericIsolation<MainActor>
func preconditionIn() {
MainActor.preconditionIsolated() // This check passes.
}
// The other way around is also true.
@MainActor
func preconditionOut() {
GenericIsolation<MainActor>.preconditionIsolated() // This check passes.
}
Task { @GenericIsolation<MainActor> in
preconditionIn()
}
await Task.yield()
preconditionOut()
// Works, but needs external support with the parameter.
// It doesn't resolve automatically from the context.
@GenericIsolation<I>
func genericIsolatedFunction<I: ActorInstanceProtocol>(isolation: I.Type) {}
// Works. The same applies to this one, too.
func closuredFunction<I: ActorInstanceProtocol>(
isolation: I.Type = I.self,
closure: @GenericIsolation<I> () -> Void
) {}
typealias IsolatedClosure = @GenericIsolation<DatabaseActor> () -> Void
// But, the function infers it if you do it like this.
closuredFunction(closure: {} as IsolatedClosure)
// Surprisingly works.
// You can access generic parameters from the global actor specifier.
// Everything below is valid.
@GenericIsolation<I>
struct Structure<I: ActorInstanceProtocol> {
nonisolated init(_ foo1: @escaping @GenericIsolation<I> () -> Void) {
self.foo1 = foo1
}
var foo1: @GenericIsolation<I> () -> Void
@GenericIsolation<I>
var foo2: () -> Void = {}
@GenericIsolation<I>
func foo3() { print("Structure \(#function):", #isolation) }
// This is also isolated.
func foo4() { print("Structure \(#function):", #isolation) }
}
// Function for testing purposes.
@GenericIsolation<I>
func foo<I: ActorInstanceProtocol>(isolation: I.Type) {
print("\(#function):", #isolation)
}
let test: Test = Test()
// You can isolate it to individual actor instances.
await foo(isolation: test.instanceID)
// Works.
let structure = Structure<MainActor> {
foo(isolation: MainActor.self)
}
// You normally can't call this.
// GenericIsolation<X> and X are not callable within each other.
// But you can with the help of the `withGenericOverlay` and
// `withCoreIsolation` functions.
MainActor.withGenericOverlay {
structure.foo3()
structure.foo4()
}
// An erased isolator.
let anyIsolator: any ActorInstanceProtocol.Type = MainActor.self
// This fails to compile as expected.
//
// MainActor.withGenericOverlay {
// foo(isolation: anyIsolator)
// }
// Also this.
//
// Task { @DatabaseActor in
// DatabaseActor.withGenericOverlay {
// foo(isolation: anyIsolator)
// }
// }
// But you can call it in an async context without a problem.
Task {
await foo(isolation: anyIsolator)
}
await Task.yield()
// Works.
// You have to use the Self prefix for protocols in the header for some reason.
@GenericIsolation<Self.I1>
protocol `Protocol`<I1> {
associatedtype I1: ActorInstanceProtocol
associatedtype I2: ActorInstanceProtocol
associatedtype T
func foo() -> T
@GenericIsolation<I2>
func bar()
}
// The isolation does get resolved from the protocol.
struct ProtocolImplementer: `Protocol` {
typealias I1 = MainActor
typealias I2 = DatabaseActor
func foo() -> Int {
print("ProtocolImplementer", #function, #isolation)
return 0
}
func bar() { print("ProtocolImplementer", #function, #isolation) }
}
let implementer = ProtocolImplementer()
// Works.
_ = MainActor.withGenericOverlay {
implementer.foo()
}
// Works.
Task { @DatabaseActor in
DatabaseActor.withGenericOverlay {
implementer.bar()
}
}
await Task.yield()
// Existential types also work. Constrained existentials work with a workaround.
let existential: any `Protocol`<MainActor> = ProtocolImplementer()
await existential.bar()
@GenericIsolation<MainActor>
func open<T: `Protocol`<MainActor>>(_ value: T) -> T.T {
value.foo()
}
MainActor.withGenericOverlay { [existential] in
let value = open(existential)
print("foo value", value)
}
// I tried to write an eraser but I couldn't find a valid implementation for it.
// I couldn't make isolated conformances work for some reason?
//
// protocol ProtocolFoo {
// func foo()
// }
//
// @GenericIsolation<I>
// class Foo<I: ActorInstanceProtocol>: @GenericIsolation<I> ProtocolFoo {
// var test: String = "hi"
//
// @GenericIsolation<I> func foo() {
// print(test)
// }
// }
//
// func fooTest<T: ProtocolFoo>(_ input: T) { input.foo() }
//
// let foo2 = Foo<DatabaseActor>()
// fooTest(foo2)
var deallocatingActor: Test? = Test()
Task { [instanceID = deallocatingActor!.instanceID] in
await foo(isolation: instanceID)
_ = ()
}
// This is a danger that I found. Although, I provided some safety checks that
// crash the program to not cause undefined behaviour. You can hold a reference
// to the actor in the closure or by wrapping the instanceID.
//
// deallocatingActor = nil
// deallocatingActor = Test()
await Task.yield()
// I haven't tested with distributed actors because I don't have the best
// knowledge on them.
//
// You could probably a very simple macro that gets the current isolation and
// converts it to the required format for generic isolation.
It's not the best. Some things don't work or require a workaround, it could probably be improved in some angles and there may be some issues that I missed. But still, I partially managed to make generic isolation work, yay.