Why I struggle to use actors
Following up on some discussion about why I keep finding myself using Mutex (née OSAllocatedUnfairLock) rather than actors. This is kind of slapped together; eventually I may try to write up a proper blog post, but I wanted to capture it.
Each of these is a runnable file using swift ...
, with embedded commentary.
// Actor to manage deferring execution on a bunch of things until requested
// Seems a very nice use for an actor.
public actor Defer {
public nonisolated static let shared = Defer()
private var defers: [@Sendable () -> Void] = []
public func addDefer(_ f: @escaping @Sendable () -> Void) { defers.append(f) }
public func execute() {
for f in defers { f() }
defers.removeAll()
}
}
// First attempt to use it is not surprisingly wrong. The call to `addDefer` is async because actor.
public final class DeferUser: Sendable {
init() {
// Call to actor-isolated instance method 'addDefer' in a synchronous nonisolated context
Defer.shared.addDefer { print("Cleanup") }
}
}
OK, this makes sense. But making DeferUser.init
async puts a lot of restrictions on callers of it. It means there can’t be a shared
instance. It can’t be deterministically constructed in AppDelegate or SwiftUI if anything relies on it. And there’s nothing inherently async
about constructing it. It’s leaking an implementation detail. “Just make everything async” is not where we should be going in Swift (and is not IMO where we intend to go).
Try 2. Maybe addDefer
could be nonisolated
:
public actor Defer {
public nonisolated static let shared = Defer()
public private(set) var defers: [@Sendable () -> Void] = []
private func append(_ f: @escaping @Sendable () -> Void) { defers.append(f) }
public nonisolated func addDefer(_ f: @escaping @Sendable () -> Void) {
Task { await append(f) }
}
public func execute() {
for f in defers { f() }
defers.removeAll()
}
}
// Sure. But now there's a surprising race condition:
func run() async {
Defer.shared.addDefer { print("Cleanup") }
// print(await Defer.shared.defers.count) // Uncomment the next line to make this "work"
await Defer.shared.execute()
}
await run()
There is no promise that “Cleanup” will be printed until at least ClosureIsolation
is available. Task
would also need to be updated I believe. This is a serious footgun IMO, and unlikely to be fixed for some time. (For more, see previous Mastodon discussion.)
In my experiments, this will never print “Cleanup” unless you “pump the runloop” (to mix an old-school metaphor) by tickling some other async property.
The problem is worse if you move the Task into DeferUser.init
public actor Defer {
public nonisolated static let shared = Defer()
public private(set) var defers: [@Sendable () -> Void] = []
public func addDefer(_ f: @escaping @Sendable () -> Void) { defers.append(f) }
public func execute() {
for f in defers { f() }
defers.removeAll()
}
}
public final class DeferUser: Sendable {
init() {
// Even with future-Swift, I doubt folks will realize this needs to be put on the Defer context.
Task { await Defer.shared.addDefer { print("Cleanup") } }
}
}
// I expect many people will reach for this version, and it's even more unsafe IMO than the last one.
func run() async {
_ = DeferUser()
// print(await Defer.shared.defers.count) // Uncomment the next line to make this "work"
await Defer.shared.execute()
}
await run()
This one IMO is worse than the last one because even with “future Swift,” I doubt folks will figure out how to get this Task onto the right context to remove the race condition.
So even with future Swift, I have to think really hard about this.
OR….
I can use a mutex and it all works in a way that I can reason about.
import os
public final class Defer: Sendable {
public static let shared = Defer()
private let defers = OSAllocatedUnfairLock<[@Sendable () -> Void]>(initialState: [])
public func addDefer(_ f: @escaping @Sendable () -> Void) { defers.withLock { $0.append(f) } }
// If I want this to be async, it can be by putting the `for` loop in a Task.
// Or it can be synchronous. The choice is up to me rather than being forced by the actor.
public func execute() {
// Yes, this requires some special care to do correctly. But `withLock` points a big arrow to
// the piece of code I need to think hard about.
let defers = defers.withLock { defers in
defer { defers.removeAll() }
return defers
}
for f in defers { f() }
}
}
public final class DeferUser: Sendable {
init() {
Defer.shared.addDefer { print("Cleanup") }
}
}
// And now it should "just work."
func run() async {
_ = DeferUser()
Defer.shared.execute()
}
await run()