Hello,
Im trying to create a Queue, which allows me to synchronise execution despite having a reentry point.
Doing the above I ran into few problems. Basically I have a simple actor
public typealias RoutingQueueOperation<T> = () async -> T
protocol Waitable {
func waitForCompletion() async
}
extension Task: Waitable {
func waitForCompletion() async {
_ = try? await value
}
}
public actor RoutingQueue {
private var operations: [Waitable] = []
func execute<T: Sendable>(operation: @escaping RoutingQueueOperation<T>) async -> T {
let last = operations.last
let task = Task {
let _ = self
await last?.waitForCompletion()
return await operation()
}
operations.append(task)
let value = await task.value
operations.removeFirst()
return value
}
}
Usage:
@MainActor
class myCLass {
var temp: Int = 0
func myCallingFuncion() async {
let queue = RoutingQueue()
await queue.execute { //Sending main actor-isolated value of type '() async -> ()' with later accesses to actor-isolated context risks causing data races
self.temp = 10
}
}
}
Few questions here,
1 - when I work with non mutable or non sendable, how can sending a closure which is isolated inherently to my main actor, cause an issue if it was called from another isolated actor? I mean In general why there is a problem sending an isolated value to another actor, as long as the non sendable value/closure is isolated.
2- Doing more research I found 2 ways to make the above work, First use @_inheritActorContext. This seems to tell the compiler explicitly that the closure is isolated or at least allow the compiler to know that this closure isolation if exists, unless I miss understand. Btw I am not sure I can use this since not documented.
func execute<T: Sendable>(@_inheritActorContext operation: @escaping RoutingQueueOperation<T>) async -> T {
The second way that I was able to make it that work, is to create a Task that run queue.execute
and create the closure outside of The Task. For example
func myCallingFuncion() async {
let queue = RoutingQueue()
let closure = {
self.temp = 10
}
Task {
await queue.execute(operation: closure)
}
}
Can someone explain to me why this work? the closure and the task here inherits the main actor isolation, so im still kind of sending a closure from different actor isolation to another one, but now the compiler does not complain.
Or so I dont have to do this at each time. I have added the below to my RoutingQueue.
Actor RoutingQueue {
func execute<T: Sendable>(isolation: isolated (any Actor)? = #isolation,_ operation: @escaping RoutingQueueOperation<T>) async -> T {
return await Task {
let _ = isolation
return await self._execute(operation: operation)
}.value
}
}
Now this is same concept as above, but this one does allow data race. Imagine my RoutingQueue is not actually queuing and instead just executing the closure, so the below final RoutingQueue
Actor RoutingQueue {
func execute<T: Sendable>(isolation: isolated (any Actor)? = #isolation,_ operation: @escaping RoutingQueueOperation<T>) async -> T {
return await Task {
let _ = isolation
return await self._execute(operation: operation)
}.value
}
private func _execute<T: Sendable>(operation: @escaping RoutingQueueOperation<T>) async {
let _ = await operation()
self.operations = []
}
}
And then I do this
class myCLass {
var temp: Int = 0
func myCallingFuncion() async {
let queue = RoutingQueue()
for _ in 0..<1000000 {
await queue.execute {
self.temp += 1
}
}
}
}
Does not that risk, calling the closure with no isolation and therefore causing data race on the variable temp. Or is it because now im using dynamic isolation, the compiler cannot verify?
Like if I do this, it clearly complains.
Task {
self.temp += 1 //Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
}
Basically what Im finding hard to understand is closure isolation inheritance. If someone can help shed some light on the issue I faced, and why it work in certain a way and not other. Thank you In advance.