Model of actors in swift

Timur Cheberda
5 min readMay 5, 2022

--

Communication with actors

The actor model was invented in 1973 as a theoretical model for describing various parallel systems, where it gained its popularity thanks to the Erlang programming language.

One of the sources of problems, in multithreaded programming, is the presence of a shared state and multithreading, that is, when we have a shared, changeable state + parallelism = problems, as an example: race conditions, data races, the need for locks and other difficulties.

One of the ways to solve such problems can be considered the rejection of the changeable state, that is instead of having separable, changeable objects, we work with invariable data and build chains for processing data. Actors offer a completely different approach, instead of abandoning the changeable state, actors propose abandoning the shared state, that is, instead of threads working on common data, we say that our data is private data and strictly one stream can work with it, that is, we prohibit any parallelism over this data.

Actors in swift

A reference data type that protects instance data from concurrent access. This is achieved by “isolating” the actor, during compilation, which ensures that all calls to the data of this instance pass through the synchronization mechanism, which is performed sequentially. The main idea of ​ ​ actors is to provide “islands of single multithreading” that do not need internal synchronization, because the general changeable state is avoided in favor of transmitting semantic types of values through asynchronous messages.

Actors are similar to other types in swift: structures, enums, and classes.

  • Actors may contain: methods, including static methods, properties, indexes, initialization.
  • Do not support: inheritance, convenience initializer, overriding, scope open, and final modifier.

As an exception, the actor can inherit from the root class NSObject.

Example of not supported in actors

Actors combine the best of both tools, taking advantage of a collaborative pool of threads for efficient planning. If you call a method on an actor that is not yet running, the calling thread can be reused to call the method. If the called actor is already running, the calling thread can suspend the function it is performing and intercept other work.

  • If the queue is not already running, we say that there is no contention.
  • If the serial queue is already running, we will assume that the queue is in an under contention.

@GlobalActor exist due to the fact that state synchronization is not limited to local variables, meaning that global access to the actor may be required. Instead of forcing everyone to write singletons everywhere, global actors make it easy to specify that a specific piece of code must be executed within a specific global actor.

  • You can create your own global actors by adding the @globalActorattribute to the actor

@MainActor — is a global actor that executes all code in the main thread.

  • Adding the @MainActorattribute to the class, all properties and methods as @MainActor, if — suddenly you need to avoid this, for a specific function or property, then just mark this entity as nonisolated.
@MainActor
class ViewController: UIViewController {
//...
nonisolated var fetchBooks(with page: Int) -> [Books] { ... }
//...
}

What problems do actors solve?

Data race — occurs when one thread accesses (reads) the object being modified, and the other writes to it.

If two streams operating in parallel call worker(number: Int) method, then at best, it will be an inconsistency object that we change, otherwise, if at the same moment in time both threads turn to the function worker(number: Int)then there will be a crash.

public final class Counter {    
private var myVariable = 0

func worker(number: Int) {
myVariable += number
print(myVariable)
}
}
Example of data race problem

Before Actors, we can create a thread safe like this:

Without actor

As you can see, there is enough code here to provide synchronization. For writing, the barrier flag is necessary to stop reading for a moment and allow writing, and we have to worry about this.

Actors, on the other hand, allow Swift to optimize synchronized access as much as possible. The underlying lock that’s used is just an implementation detail. As a result, the Swift compiler can enforce synchronized access, preventing us from introducing data races most of the time.

The same example, but using an actor:

actor Counter {
public var myVariable = 0

/// We no longer need a barrier
func addNumber(with number: Int) {
myVariable += number
}

/// We no longer need a barrier
func removeNumber(with number: Int) {
myVariable -= number
}
}

Now the code has become much smaller, plus all the logic with access synchronization is hidden, as is the implementation detail.

How do actors work?

  • Sendable — protocol whose purpose is to “mark” that the type is safe to operate in a parallel environment.
  • nonisolated — disables security checking and allows you to “override” actor checks during Swift compilation.
  • UnownedSerialExecutor — weak reference to theSerialExecutorprotocol.

SerialExecutor: Executor from Executor has a func enqueue (_ job: StrandedJob) method that performs tasks. First we write this:

let imageDownloader = ImageDownloader()
Task {
await imageDownloader.setImage(for: "image", image: UIImage())
}

Semantically, the following occurs:

let imageDownloader = ImageDownloader()
Task {
imageDownloader.unownedExecutor.enqueue {
setImage(for: "image", image: UIImage())
}
}

By default, Swift generates a standard SerialExecutor for custom actors, where custom SerialExecutor implementations switch threads. This is how MainActor works — an actor whose Executor translates into the main thread, where it cannot be created, but you can access its MainActor.shared instance.

--

--

Responses (1)