I encountered an issue where the compiler signals an error "Mutation of captured var 'a' in concurrently-executing code" which should be fine in my opinion:
actor TestActor {
private let _send: (isolated TestActor) -> Void
init() {
var a: [Int] = []
_send = { actor in
actor.assertIsolated()
a.append(0)
Task {
actor.assertIsolated()
a.append(1) // Error: Mutation of captured var 'a' in concurrently-executing code
}
}
}
func send() {
_send(self)
}
}
The var a is defined in local scope in the init function and will be captured in the closure _send and then by the Task's closure. The non-detached task will be created by the closure member _send of the actor. The closure _send has an isolated parameter (which will be set to self) in order to bind the closure to the provided actor's context.
The idea behind this is, that in the actual implementation, a's type will be defined in the initialiser via additional generic parameters and the type of the actor should not depend on this. Thus a cannot be a member var in the actor.
My understanding is, that the task's closure gets a mutable reference of the var a, and that the non-detached task inherits the actor context of the the provided isolated parameter actor from the _send closure.
Now, function send() could potentially be called multiple times and there could be multiple tasks created at the same time. But these tasks all inherit its context from the same actor and there is only one and there is also only one instance of a.
So, my understanding is, one should be able to safely mutate a.
This gist provides a little more context what I am trying to do: FSM Actor · GitHub
If you give the task a capture list, the error message becomes Cannot use mutating member on immutable value: 'a' is an immutable capture
actor TestActor {
private let _send: (isolated TestActor) -> Void
init() {
var a: [Int] = []
_send = { actor in
actor.assertIsolated()
a.append(0)
Task {[a] in
actor.assertIsolated()
a.append(1) // Error: Cannot use mutating member on immutable value: 'a' is an immutable capture
}
}
}
func send() {
_send(self)
}
}
I would just use a wrapper, and let it be captured instead.
actor TestActor {
private let _send: (isolated TestActor) -> Void
init() {
class Wrapper {
var a: [Int] = []
}
let r = Wrapper ()
_send = { actor in
actor.assertIsolated()
r.a.append(0)
Task {
actor.assertIsolated()
r.a.append(1)
}
}
}
func send() {
_send(self)
}
}
When compiling, the compiler emits a warning: Capture of 'r' with non-sendable type 'Wrapper' in a @Sendable closure
That whole point of having the _send closure providing an isolated parameter, is to make accesses to a (actually the captured a) synchronised. But here, the compiler "thinks" that it is not. Maybe it's right ;) but my understanding is, it's different here.
So what you are trying to do here is to mutate a value type (represented by a local variable) in a concurrent code. It does not isolated on an actor, it just a local value. I would rather avoid such complex constructs after all.
Another question is why do you need a Task in the first place here? It reduces your options on using structured concurrency.
I agree, that it appears to be wacky. The current solution that compiles fine, even with "-strict-concurrency=complete), is when using a as a member of the actor.
In the actual implementation, the actor models a finite state machine which also manages operations returned by the transition function which then get wrapped into a task, so that this task can be cancelled. Thus, the task.
The last one I am currently using in an implementation of a type that has to handle operations cancellation on its own. In the essence it is similar to gist example you’ve provided and it works well. Initially it has used internal tasks to do the same thing, but I changed to provide more options on controlling execution from the outside.
The problem I think which could arise when using structured concurrency here in my gist example, is that, the actor's send(_:) function will not return until after the executed operations have been finished.
Of course, I would need to take a look at a possible implementation, first.
But, the operations returned by the transition function should execute asynchronously with respect to send(_:). Once send(_:) returns, the actor's state should have been changed, though.
Also, each operation should be cancellable within the transition function. That requires to have some Task value.
The Proxy should ensure that there is no strong reference hold until after the operations complete. So there is ideally only one "external" strong reference to the actor. When this goes puhf, the deinitialiser can cancel all running tasks (not shown in the gist).