Not sure if that makes it easier or harder to actually implement, but I am dreaming about this type of thing as the "ephemeral" version of @GlobalActor
annotations.
I'm trying to sketch the evolution proposal, and here are some ideas so far:
Entities that store isolation inside them are very similar to actors, and I think they should be used similar to actors:
actor ActorCounter {
var data: Int = 0
func inc() { data += 1 }
}
func useNonIsolated(_ c: ActorCounter) async {
await c.inc()
await useIsolated(c)
}
func useIsolated(_ c: isolated ActorCounter) {
c.inc()
}
class IsolatedCounter {
let actor: isolated any Actor
var data: Int = 0
init(actor: isolated any Actor) { self.actor = actor }
func inc() { data += 1 }
}
func useNonIsolated(_ c: IsolatedCounter) async {
await c.inc()
await useIsolated(c)
}
func useIsolated(_ c: isolated IsolatedCounter) {
c.inc()
}
To be able to use them like that, we need a way to ask them for the executor. This can be done using a protocol. Something like this:
public protocol ActorLike {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}
class IsolatedCounter: ActorLike {
let actor: isolated any Actor
var data: Int = 0
init(actor: isolated any Actor) { self.actor = actor }
func inc() { data += 1 }
// Synthesises by the compiler
nonisolated var unownedExecutor: UnownedSerialExecutor {
return actor.unownedExecutor
}
}
It is tempting to reuse Actor
protocol for that, but Actor
implies AnyObject
, so it cannot be used for structs and tuples. So, I think a new protocol would be needed. Name is bike-shreddable. It would make sense to make Actor
refine this protocol, but I guess it would be an ABI breaking change, wouldn't it? But I guess we can make actor declarations conform to that protocol too, so that any ActorLike
can be used as a supertype for actors and self-isolating types.
The isolation
keyword can be applied to any subtypes of any ActorLike
- both concrete types and existentials. It means that isolation of that value is statically known to be equal to the isolation of the current context. Is there are multiple values of isolated
type in the same context, they all share the same isolation.
This allows to have multiple isolated
stored properties. Their values all share the same isolation region between themselves and with the instance of the parent type. To be able to initialise such data structures, we need to lift restriction for single isolated
argument in functions. If there are multiple, caller must prove in compile time that they all share the same isolation region.
If there are other properties of non-sendable types, then they are isolated to the same region. This effectively makes a tuple of (NonSendable, isolated any Actor)
an isolation-erasing existential.
// f and x.0 are isolated to x.1
func f(_ x: isolated (NonSendable, isolated any Actor)) {}
// g is nonisolated, x.0 is isolated to x.1
func g(_ x: (NonSendable, isolated any Actor)) async {
await f(x)
}
// h and x.0 are isolated to current task
func h(_ x: (NonSendable, any Actor)) async {
await f(x) // error cannot convert from (NonSendable, any Actor) to (NonSendable, isolated any Actor)
}
// error: (NonSendable, any Actor) does not conform to ActorLike, because it does not contain any isolated properties.
func i(_ x: isolated (NonSendable, any Actor)) async {}
TODO:
- Can it help with knowing isolation of
DispatchQueue.main
in compile-time? - How would it behave with
weak
andowned
actor references? - Can we put
isolated T
insideArray<>
andOptional<>
?
I might be missing something here, but isn'n this just making this class an actor with custom executor? It has same calling semantic, requires executor in the same way (you just don't need to write it by yourself), as if we just made it to be an actor. I think it is already possible:
actor A {
}
actor B {
nonisolated var unownedExecutor: UnownedSerialExecutor {
actor.unownedExecutor
}
private let actor: any Actor
init(actor: any Actor) {
self.actor = actor
}
var i = 0
func inc() {
i += 1
}
}
let a = A()
let b = B(actor: a)
await b.inc()
Primary difference is that a
and b
are known to be in the same isolation region, but to construct b
code needs to be executing isolated to a
.
EDIT: Also inside B
, self
and self.actor
are known to be in the same isolation region, so functions of B
can access isolated members of self.actor
without awaiting. Not so useful when type of self.actor
is any Actor
, but useful when it is a more specific type.
And also, I think actor-structs and actor-tuples can be supported.
I feel sceptical on options that require that much work on the non-Sendable type side; it is clearly better compared to passing isolation with each method, and I suppose simplifies implementation side. But so far I am leaning towards defining isolation on the caller side, instead of modifying a type.
I have been looking trough concurrency roadmap, and there were notion of actor local values, that have to enforce classes isolation on actors to prevent data races as part of the full actor isolation, and the local
below was inspired by that. Maybe actorlocal
as it was in original roadmap could be used in all these cases as well, but I thought that maybe expressing "locality" as part of a type might an option, allowing to pass current actor notion to the type?
final class NonSendableType {
private(set) var i = 0
func inc() async {
i += 1
}
}
actor SomeActor {
// isolated to an actor
// isolatedInstance is allowed to be used in this actor only
let isolatedInstance: local NonSendableType
init() {
isolatedInstance = NonSendableType()
}
// not isolated, so can be used from anywhere, yet it is not safe
let nonIsolatedInstance = NonSendable()
func incSafe() async {
// ok, we have same isolation, so no data races
await isolatedInstance.inc()
// and we still can interact with it in a synchronous way
print(isolatedInstance.i)
}
func incUnsafe() async {
// this will produce data race warning as it is now
await nonIsolatedInstance.inc()
}
}
@MainActor
struct Command {
// isolated as type, so main actor in that case
// `instance` is allowed to be used in this struct only
let instance: local NonSendableType
func execute() {
print(instance.i)
Task {
await instance.inc()
print(instance.i)
}
}
}
let nonIsolatedInstance = NonSendableType()
let command = Command(instance: nonIsolatedInstance)
// nonIsolatedInstance is transferred to Command's isolation domain,
// so it's not allowed to be used outside of Command
// await b.inc()
@MainActor
func run() async {
// isolated as function, so main actor in that case
// `instance` is allowed to be used in this function only
let instance: local NonSendableType = NonSendableType()
await instance.inc()
print(instance.i)
// the following is not allowed
// return instance
}
// finally, this one will be executed on generic executor
let anotherNonIsolatedInstance = NonSendableType()
await anotherNonIsolatedInstance.inc()
print(anotherNonIsolatedInstance.i)
I was thinking about this more, and I think we don't need any language changes at all. I think this isolation-erasing existential can be implemented in a library using existing escape hatches.
struct ActorBox<T, A: Actor>: @unchecked Sendable {
public let actor: A
private var _value: T
init(_ value: T, actor: isolated A) {
self.actor = actor
self._value = value
}
mutating func open<R>(_ block: @Sendable (inout T, isolated A) -> R) async -> ActorBox<R, A> {
await ActorBox<R, A>(block(&_value, actor), actor: actor)
}
func open<R>(_ block: @Sendable (T, isolated A) -> R) async -> ActorBox<R, A> {
await ActorBox<R, A>(block(_value, actor), actor: actor)
}
mutating func open<R: Sendable>(_ block: @Sendable (inout T, isolated A) -> R) async -> R {
await block(&_value, actor)
}
func open<R: Sendable>(_ block: @Sendable (T, isolated A) -> R) async -> R {
await block(_value, actor)
}
func tryMerge<U, R>(with other: ActorBox<U, A>, _ block: @Sendable (T, U, isolated A) -> R) async -> ActorBox<R, A>? {
if actor !== other.actor { return nil }
return await ActorBox<R, A>(block(_value, other._value, actor), actor: actor)
}
...
}
extension ActorBox where T: Sendable {
var value: T { _value }
}