无状态执行者
Stateless Actors

原始链接: https://www.massicotte.org/stateless-actors/

虽然 Actor 的主要设计目的是隔离可变状态,但“无状态”Actor 也可以用于特定目的。开发者通常使用它们来确保符合 `Sendable` 协议、强制在后台线程执行,或为未来的状态预留位置。 然而,以这种方式使用 Actor 会带来一些权衡: * **串行瓶颈:** 与无状态的 `struct` 不同,Actor 会串行执行同步代码,这可能会强制任务排队,从而人为地限制性能。 * **复杂性:** Actor 可能会使协议采纳变得复杂,并引入难以撤销的“传染性”隔离需求。 * **资源消耗:** 全局 Actor 或处理阻塞 I/O 的 Actor 可能会占用有限的并发线程,因此需要谨慎管理阻塞操作(有时甚至需要回到 GCD)。 总之,虽然无状态 Actor 有一些利基应用场景——例如作为文件系统访问的并发网关,或通过自定义执行器与旧有的调度队列集成——但它们往往被过度使用。“Actor 的第一法则”是明确阐述其必要性。在选择 Actor 之前,请先考虑无状态的 `struct` 或其他 Swift 并发原语是否能提供更简单、性能更高的解决方案。

```Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 无状态执行者 (massicotte.org) 7 点,由 frizlab 发布于 1 小时前 | 隐藏 | 往期 | 收藏 | 1 条评论 帮助 mrkeen 19 分钟前 [–] 一种竞态条件:两个进程都打算给一个数字加二。 它们各自读取当前数值。 然后它们各自将比原值大二的结果写回。 如果你转而使用私有字段和公有 getter/setter,或者使用执行者在可变状态周围形成一个保护圈,你得到的……依然是完全相同的结果,但伴随着更多的模板代码。 回复 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索: ```
相关文章

原文

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 {
	func loadCart() async throws -> [Product] {
		let (data, _) = try await URLSession.shared.data(for: cartRequest)

		return try JSONDecoder().decode([Product].self, from: data)
	}
}

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: "something")


	nonisolated var unownedExecutor: UnownedSerialExecutor {
			queue.asUnownedSerialExecutor()
	}


	func acceptTransport(_ transport: PersonalTransportation) {
		
	}
}

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.

联系我们 contact @ memedata.com