What is the point of having actor?

What I have seen about the actor is that it guarantees to isolate the storage/memory is not accessed by the other threads to cause data race.

So, why can't swift just make the type Sendable?
anything isn't Sendable. can be protected by Mutex or NSLock, etc.

Everything that you can do with actors, you can do with other mechanisms, but actors have the benefit of having the compiler support you in implementing concurrency correctly.

Actors give you:

  • Guaranteed serial accesses on the actor’s private queue.
  • Protection of shared mutable state from data races.
  • The compiler forces all external callers to use asynchronous calls to call the actor’s public APIs.

You could implement the underlying functionality yourself by using a selection of DispatchQueues or Locks, but the compiler won’t help you out — you have to remember to only call the class’s public methods on the right queue, or you have to add boilerplate public wrapper methods that do nothing but jump to a queue and then call a private method. If you use locks, you have to take care not to cause deadlocks.

Because the compiler forces all external callers to make async calls, and automatically serializes everything onto the queue, you are at less risk of causing data races or deadlocks by forgetting to do any of the above. This is especially useful in large projects with many concurrent parts and multiple programmers, where the risk of a concurrency mistake in one small part causing a regression is much greater.

Overall, the mechanisms you mention above have been around for a long time, but concurrent programming with those mechanisms has long been understood as difficult to reason about and maintain.

Because the compiler helps you out by requiring you to access actors in using a safe pattern, this dramatically decreases the mental workload required to implement concurrent systems.

9 Likes

Protection of shared mutable state from data races.
it can always use lock.
As long as trying to make a type Sendable, at some point down to the root, I believe it still requires lock. Can't swift just make the compiler detect the types Sendable or nonSendable without Actor?
I know it can point out the race issue at compile time. But I think Sendable and sending are enough. Actor just makes the language so hard to use. now, it keeps adding new keywords like isolated (any Actor)? = #isolated, nonisolated, nonisolated(unsafe), isolated conformance, region base concept, @isolated(any), @concurrent, { @MainActer in }, etc.
it is just getting worse.

Sendable between what exactly?

2 Likes

AFAIK actors in Swift implemented in non-blocking manner internally. So it’s a bit different than using mutex.

1 Like

Nothing is stopping you from using a lock.

In the case of an Actor, data race safety is achieved by using a serial queue, so the lock is implemented in the DispatchQueue.

If you mark a class as Sendable, it will only be allowed if it has immutable members (let properties). If it has var bindings, then you are required to manually implement locks and mark the class as @unchecked Sendable.

One way to implement Sendable for a class without using @unchecked Sendable, is to use the Mutex<T> type from the Synchronization module, which is always declared with a let binding. This is a convenient way to not have to use actors if you don't want to, but it does open you up to the possibility of deadlocks if you do anything complicated inside the withLock { } block.

That is a fair criticism, but it's important to keep the Law of Conservation of Complexity in mind. Getting concurrent programming right is extremely difficult and complex. That complexity will leak out either in the language (as you point out) or it is complexity that you will have to keep in your mind as you are programming.

4 Likes

This is not actually true, fwiw. Actors on Darwin do use a queue under the hood (via the default executor), but it's a special concurrent queue and is just there to provide threads to run things on when needed.

But your overall point is correct: actors are state-carrying asynchronous locks

4 Likes

This is my point. Using mutex/NSLock, etc to make the types Sendable. I just don’t understand why making Actor. Eventually, using Actor to isolate to stop accessing the properties in actor at the same time. Why don’t just use lock or mutex?
It is causing the complexity. It isn’t worthy

At some point, I believe it is still blocked to access the storage. Otherwise it will cause data race. I consider queuing is some sorts of blocking. Mutex and NSLock to block until it finishes using it for the next. I know it is still different. But I don’t see it can justify to make it so complex.

That’s probably a good high level abstraction, but there is a difference: if thread performs some work or just waiting. Queuing allows to avoid just waiting.

@jaleel asked a good question I think: without actors in Swift what sendability would mean? Sending objects between what?

1 Like

Between different thread.

That’s we who know about threads, how you’d explained this to language and compiler in a formal way so that it can reason about it?

1 Like

It literally is an asynchronous mutex. If you want a synchronous mutex you can just use one, but asynchrony is sometimes helpful.

3 Likes

Using system threads comes with a bunch of problems, but besides that—what does sending data to thread mean? You need some abstraction to implement sending something between threads, like channels in Rust.

Questions can continue, concurrency is hard and often you land with the same approach as message passing + state isolation as actors in Swift to work with data.

Is it really that hard, though? There is an actor, it gives you isolation, and to send messages between actors, you need to mark the data as Sendable. Isolation is not only about state, but also about execution and all these keywords are just ways to control where the function will be exactly executed. I’m also not sure people should use them that often. Recently, I’ve seen a bunch of discussions where, instead of using some of this stuff, the problem could have easily been solved by just wrapping the logic into a new actor.

3 Likes

What abstraction?
In swift, you can always use Mutex to make the storage safe and the type to become sendable. Eventually, it is still safe

I think it is just not worthy. As I can spell out the keywords. I believe I have a certain understanding.
The problem is the complexity was added to the language. I believe it will have some new keywords to fix the issue. As someone mentioned above, it will still leak somewhere.

You also mentioned channels. I don’t know much about rust. But I guess channels is a library. Not the language level. But Swift’s actor is language level, compiler-time. Because it is language level, therefore it has so many keywords for that. I think you are right that, at some points, it needs something like channels. But I don’t think it is worth to have actor at language level.

So why can’t Swift use rust’s approach?

I think you meant Go :wink: At least they are an abstraction over threads while Rust simply utilized its memory safety to implement convenient types.

Rust’s approach relies on different complexity of the language — ownership. It also much limited in reasoning about concurrency safety. I know Rust advertises that they have "fearless concurrency", but I think it’s just the same way complicated. Take that for example: Pin and suffering

Abstraction of threads, putting simple. Take plain non-sendable type. How Swift could reason that it’s unsafe to pass it somewhere, like in a function, without any model of those threads? Without knowing that each function has or hasn’t an isolation? If not actors, some other abstraction is required here — just to explain to language these concepts.

Complexity of concurrency is being underestimated in my opinion. Swift helps to formalise many rules that we have had to keep in our minds before, and these rules are complex.

3 Likes

Let me just put on my "never finished my C.S. degree" hat here, and…

For trivial value types like Int, the standard library explicitly marks them Sendable. From there, the compiler can infer sendability for more complex value types if they are composed solely of other sendable types. If a reference type is composed of immutable sendable values, the compiler can infer sendability.

But real-world programs have mutable state, and if that state can be accessed from multiple threads, the compiler generally can't reason about whether or not that state is thread-safe (and thus sendable.) This constraint is a manifestation of Rice's theorem which is a generalization of the halting problem[1].

A lock/mutex allows you, the programmer, to guard mutable state and make your code thread-safe, but the compiler cannot reason about all uses of a lock/mutex the same way it can about an actor, because a lock/mutex guards state by convention only. There's not really anything preventing you from writing:

let lock: Lock = ...
let state: StateObject = ...
lock.lock()
state.mutate()
callOnAnotherThread {
  state.mutate()
}
lock.unlock()

The Mutex type in Swift's Synchronization module is @unchecked Sendable because the authors of the type took care to make sure it was thread-safe, but the compiler has to trust that those authors got it right, and can't actually do the verification itself.

Actors are reference-counted objects whose mutable state is guaranteed to be accessible by only one thread (or task or isolation domain or what-have-you) at a time. Thus, they are inherently sendable and the compiler can reason about them: the set of possible programs is constrained to those that are thread-safe and so Rice's theorem is no longer in play.


  1. Corollary: this implies that AI running on a classical computer cannot solve this problem either. It also implies that either: humans can't reason about such programs; or humans can and are able to apply reasoning above and beyond that which a classical computer (including an AI) can apply. :troll: ↩︎

18 Likes

You don't "have" to use actors/sendable/mutexes, etc.. Many (most?) apps could be single threaded. You start the request (could do this on the main thread), instruct the request to complete on the main thread, parse the response (could be done on the main thread), store the result on the file system and show the result in the UI (on the main thread).