Recently, I was asked an interesting question. If the purpose of an actor is to protect mutable state, is a stateless actor pointless?
At first, I thought it was an easy answer. Actors exist to define a little, protective bubble around state. They "isolate" data away from any unsafe accesses. An actor that has nothing to isolate seems like a strange thing.
Can such an arrangement serve a purpose?
Note
I wrote another thing on actors that might be interesting.
Easy non-MainActor types
A thing I run into from time to time is a "NetworkClient"-style type. It contains methods that deal with some network API. It isn't uncommon for these kinds of types to be actors.
actor NetworkClient {
}
This particular NetworkClient is an actor that has no state. But it being an actor gives it two advantages. First, actor types are Sendable. That means we can pass this type around easily without having to think very much.
And second, this loadCart method will never run on the main thread. Default actor types execute their synchronous work on a shared thread pool. The JSON decoding here could be expensive. Ensuring that work does not ever happen on the main thread is nice.
I'm reaching a little, but a believable third reason is this type might just not have state yet. We might end up with caches or authentication, and all that will need a place to live.
(It has been pointed out that predicting ahead of time where state could live can itself be problematic. I tend to agree so be extra careful here.)
I think this is fine provided you are doing all this intentionally. As long as you understand the trade-offs, go for it. But there really are trade-offs here. Most notably, actor types can be difficult/impossible to use with protocols. And, they also require both their method inputs and outputs to be safe to transfer into/out of the actor. They push you towards needing more Sendable types.
Now, contrast with this:
struct NetworkClient: Sendable {
@concurrent
func loadCart() async throws -> [Product] {
let (data, _) = try await URLSession.shared.data(for: cartRequest)
return try JSONDecoder().decode([Product].self, from: data)
}
}
This type has some advantages. The first is it can be easier to use with protocols because you won't have to wrestle with isolation mismatches.
The second problem is an artificial limitation. Actors run synchronous blocks of code serially. This means no matter how many tasks you throw at this NetworkClient actor, it will only be able to decode JSON responses one at a time. With a @concurrent function, we no longer have that limitation.
I'm definitely not saying that a struct is better here. But there are serious trade-offs and they are worth thinking about.
Note
Check out this post for more information on how to manage expensive work.
The background actor
Here's an interesting type!
@globalActor
actor BackgroundActor {
static let shared = BackgroundActor()
}
And then, you can use it in all places where a global actor annotation works.
Task { @BackgroundActor in
}
I get it. We're used to seeing the one global @MainActor. This gives us a familiar way to define non-main work. But such a type has two serious drawbacks.
Just like with our NetworkClient actor above, this BackgroundActor executes synchronous work serially. It cannot run more than one background task at time. That's not an ideal quality for a tool like this.
Another problem is that global actors integrate very tightly with the type system. When you add one, you are forcing the compiler to guarantee the work is executed on that actor. This can have a viral effect, something many people notice with MainActor. And the reverse can also be true. If you later change your mind, removing a global actor can also be painful.
I'm sympathetic to the motivations here. But I really think you'll be best served by taking some time to learn about the language's existing constructs for controlling isolation. I'm not sure that's really optional anyways, so it feels like a good investment.
Custom executor actors
I somehow forgot about this one, but thankfully Gwendal did not let me get away with it!
It's not something you'll need everyday, but actors that exist purely to adapt Swift's concurrency system with another, preexisting system are very important. This is one of the primary use cases for custom executors.
This is a powerful tool and is surprisingly easy to use with a dispatch queue. I've seen this used to better integrate with AVFoundation. But the approach can potentially work with any other queue-based system.
If you'll allow me to lift an example from the migration guide:
actor LandingSite {
private let queue = DispatchSerialQueue(label: )
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
}
Another example of this, and one that I'm pretty embarrassed I didn't think of, is the MainActor! This actor doesn't have any direct properties, but it most certainly does manage state - the entirely of the UI. So it kind of straddles the line here, but it's still very interesting to consider.
The file system
Ok, this is an interesting one. The file system is absolutely a form of state. But it is state that is outside of our program and completely invisible to the compiler. This is a case where I think a "stateless" actor can make sense. The state does not have to literally mean instance properties.
Say you have some kind of on-disk cache, used by lots of different parts of your system. Concurrent accesses could corrupt the files/directories involved. The serialization an actor provides gives you a way to prevent that. It isn't ideal, because the compiler cannot check your work. And getting it right does require manually encapsulating everything, but you probably want to do that anyways.
One concern that can come up here is blocking operations. When you read/write to the disk, you're doing it synchronously. That means this actor is tying up one of the concurrency runtime's threads. Those are a finite resource, and on the order of the number of CPU cores per priority level. This is quite different from GCD, which will happily create a large (but still ultimately finite) number of threads if none are available.
My opinion is that you usually do not need to concern yourself with this. Sure, a thread is occupied. But what specifically it is occupied doing is typically not an important detail. As long as the work satisfies the runtime's requirement of forward progress, you should be fine.
However if you are worried (or know for a fact) you will not be fine, shifting your blocking work off the concurrency pool's threads makes sense and is usually quite easy to do. GCD is still here and you should not be afraid to use it.
(I forgot that Jaim also wrote about this, and went into quite a bit more detail. Worth checking out.)
The first rule of actors
Actors have a tendency to be over-used. They are a very useful tool, and I prefer them to locks or queues. But like any synchronization primitive, you should be able to clearly articulate why it is necessary. This is the first rule of actors.
I think that in general, yes. An actor with no state is a strange thing. It could represent a misunderstanding. It might be making a design more complex. But I think they definitely can also make sense.
Did you know that I do consulting for concurrency and Swift 6 migrations? If you think I could help, get in touch.