This article, the second in a series on Swift’s New Concurrency Framework, focuses on the actors introduced to Swift 5.5.

This series of articles takes a step-by-step look at what is involved in Swift’s new concurrency framework, as follows:

  • Swift new concurrency framework async/await

  • Actor for Swift’s new concurrency framework

  • Sendable for Swift’s new concurrency framework

  • Task for Swift’s new concurrency framework

This post is also published on my personal blog

Overview


Swift’s new concurrency model not only solves the asynchronous programming problem we mentioned in “Swift’s new concurrency framework async/await”, it also aims to solve the Data Races problem, which is the most annoying problem in concurrent programming.

To this end, Swift introduced the Actor model:

  • Actors represent a set of (mutable) states that can be safely accessed in a concurrent environment;

  • Actors ensure data security by means of the so-called Actor isolation, which is realized by maintaining a serial queue inside actors, and all external calls related to data security need to join the queue, that is, they are executed in serial.

To do this, Swift introduces the actor keyword, which is used to declare actor types, such as:

actor BankAccount {
  let accountNumber: Int
  var balance: Double

  enum BankAccountError: Error {
    case insufficientBalance(Double)
    case authorizeFailed
  }

  init(accountNumber: Int.initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }

  func deposit(amount: Double) {
    assert(amount > = 0)
    balance = balance + amount
  }
}
Copy the code

Actors are very similar to classes, except that they do not support inheritance:

  • Reference type;

  • Can abide by the specified agreement;

  • Support extension, etc.

The biggest difference, of course, is that actors implement synchronization of data access internally, as shown in the figure above.

Actor isolation


The so-called Actor isolation refers to the isolation of the inside and outside of an Actor instance by taking it as a unit (boundary).

Strictly restrict cross-border access.

Access across Actor Isolation is calledcross-actor reference, as shown in the figure below:Cross-actor reference has two cases:

  • Reference the “immutable state” in actor, such as the accountNumber in the above example. Since it will not be modified after initialization, there will be no Data Races, so there will be no problem even for cross-border access.

  • References to “mutable state” in actors, calling their methods, accessing computed properties, and so on are considered to have potential Data races, and therefore cannot be accessed as ordinary.

    As mentioned earlier, actors have a mailbox inside that is designed to receive such access and execute them sequentially to ensure data security in a concurrent manner.

    We can also see from this that this type of access is “asynchronous” in that it does not return results immediately and needs to be queued.

    Therefore, such access needs to be performed with await, such as:

    class AccountManager {
      let bankAccount = BankAccount.init(accountNumber: 123456789, initialDeposit: 1 _000)
    
      func depoist(a) async {
        / / the following now. AccountNumber, now. Deposit amount: 1) belong to a cross - actor reference
    
        // The let accountNumber can be accessed as a normal attribute
        //
        print(bankAccount.accountNumber)
    
        // Methods, whether asynchronous or not, need to be called with await
        //
        await bankAccount.deposit(amount: 1)}}Copy the code

    Of course, it is even more unlikely that cross-actor will modify the actor state directly:

      func depoist(a) async {    
        // ❌ Error: Actor-isolated property 'balance' can not be mutated from a non-isolated context
        bankAccount.balance + = 1
      }
    Copy the code

nonisolated


Synchronous access in Actor through the Mailbox mechanism is bound to have certain performance loss.

However, methods and computed properties inside actors do not always give rise to Data Races.

To solve this contradiction, Swift introduces the keyword nonisolated to modify methods and properties that will not cause Data races, such as:

extension BankAccount {
  // Only let accountNumber is referenced inside this method, so there is no Data Races
  // We can also use nonisolated modifier
  nonisolated func safeAccountNumberDisplayString(a) -> String {
    let digits = String(accountNumber)
    return String(repeating: "X", count: digits.count - 4) + String(digits.suffix(4))}}// can be called like a normal method without await
bankAccount.safeAccountNumberDisplayString()
Copy the code

Of course, it is not possible to access Isolated State in nonIsolated methods, as in:

extension BankAccount {
  nonisolated func deposit(amount: Double) {
    assert(amount > = 0)
    // Error: Actor-isolated property 'balance' can not be mutated from a non-isolated context
    balance = balance + amount
  }
}
Copy the code

Inside actors, methods and properties can be accessed directly, whether or not they are nonisolated. For example:

extension BankAccount {
  // Balance can be accessed and modified directly in the deposit method
  func deposit(amount: Double) {
    assert(amount > = 0)
    balance = balance + amount
  }
}
Copy the code

However, it should be noted that, as mentioned above, Actor isolation is bounded by Actor instances, which is problematic as follows:

extension BankAccount {
  func transfer(amount: Double.to other: BankAccount) throws {
    if amount > balance {
      throw BankAccountError.insufficientBalance(balance)
    }

    print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")

    balance = balance - amount
    // Actor-isolated property 'balance' can not be mutated on a non-isolated actor instance
    // Actor-isolated property 'balance' can not be referenced on a non-isolated actor instance
    other.balance = other.balance + amount  // error: actor-isolated property 'balance' can only be referenced on 'self'}}Copy the code

Other belongs to another actor instance relative to self and cannot be accessed directly across boundaries.

Actor reentrancy


To avoid deadlocks and improve performance, the actor-Isolated method is reentrant:

  • Actor-isolated methods that are explicitly declared as asynchronous may have internal pause points;

  • When an actor-Isolated method is suspended due to a pause point, the method is reentrant, that is, it can be re-entered before the previous suspension is resumed.

extension BankAccount {
  private func authorize(a) async -> Bool {
    // Simulate the authentication process
    //
    try? await Task.sleep(nanoseconds: 1 _000_000_000)
    return true
  }

  func withdraw(amount: Double) async throws -> Double {
    guard balance > = amount else {
      throw BankAccountError.insufficientBalance(balance)
    }

    // suspension point
    //
    guard await authorize() else {
      throw BankAccountError.authorizeFailed
    }

    balance - = amount
    return balance
  }
}
Copy the code
class AccountManager {
  let bankAccount = BankAccount.init(
    accountNumber: 123456789, 
    initialDeposit: 1000
  )

  func withdraw(a) async {
    for _ in 0..<2 {
      Task {
        let amount = 600.0
        do {
          let balance = try await bankAccount.withdraw(amount: amount)
          print("Withdrawal succeeded, balance = \(balance)")}catch let error as BankAccount.BankAccountError {
          switch error {
          case .insufficientBalance(let balance):
            print("Insufficient balance, balance = \(balance), withdrawal amount = \(amount)!")
          case .authorizeFailed:
            print("Authorize failed!")}}}}}}Copy the code
Withdrawal succeeded, balance = 400.0
Withdrawal succeeded, balance = -200.0
Copy the code

These results are clearly not true.

In general, the check reference/change two-step operation should not cross await suspension point.

Therefore, fix is also simple to check again before the actual reference/change:

  func withdraw(amount: Double) async throws -> Double {
    guard balance > = amount else {
      throw BankAccountError.insufficientBalance(balance)
    }

    // suspension point
    //
    guard await authorize() else {
      throw BankAccountError.authorizeFailed
    }

    // re-check
    guard balance > = amount else {
      throw BankAccountError.insufficientBalance(balance)
    }

    balance - = amount
    return balance
  }
Copy the code
Withdrawal succeeded, balance = 400.0
Insufficient balance, balance = 400.0, withdrawal amount = 600.0!
Copy the code

In short, in the development process to pay attention to Actor reentrancy problems.

globalActor/MainActor


As mentioned above, actors are bound by actual cases for data protection.

But what if you need to protect global variables globalVar, static properties currentTimeStampe, and data across types (ClassA1, ClassA2)/ instances?

var globalVar: Int = 1
actor BankAccount {
  static var currentTimeStampe: Int64 = 0
}

class ClassA1 {
  var a1 = 0;
  func testA1(a){}}class ClassA2 {
  var a2 = 1
  var a1: ClassA1

  init(a) {
    a1 = ClassA1.init()}func testA2(a){}}Copy the code

This is exactly the problem globalActor is trying to solve.

CurrentTimeStampe is defined in actor BankAccount, but it is static and is not protected by Actors. That is, the actor-Isolated scope outside the BankAccount.

Therefore, can be anywhere by now. CurrentTimeStampe access and modify its values.

@globalActor
public struct MyGlobalActor {
  public actor MyActor{}public static let shared = MyActor()}Copy the code

As above, we define a global actor: MyGlobalActor, with several key points:

  • The global actor definition needs to be decorated with @globalActor;

  • @globalActor Implements the globalActor protocol:

    @available(macOS 10.15.iOS 13.0.watchOS 6.0.tvOS 13.0.*)
    public protocol GlobalActor {
    
        /// The type of the shared actor instance that will be used to provide
        /// mutually-exclusive access to declarations annotated with the given global
        /// actor type.
        associatedtype ActorType : Actor
    
        /// The shared actor instance that will be used to provide mutually-exclusive
        /// access to declarations annotated with the given global actor type.
        ///
        /// The value of this property must always evaluate to the same actor
        /// instance.
        static var shared: Self.ActorType { get }
    
        /// The shared executor instance that will be used to provide
        /// mutually-exclusive access for the global actor.
        ///
        /// The value of this property must be equivalent to `shared.unownedExecutor`.
        static var sharedUnownedExecutor: UnownedSerialExecutor { get}}Copy the code
  • In The GlobalActor protocol, we generally implement the shared property only (sharedUnownedExecutor is implemented by default in the GlobalActor Extension).

  • GlobalActor (MyGlobalActor in this case) is essentially a marker type whose synchronization function is accomplished by the actor instance provided by the shared property.

  • Global Actor can be used to modify type definitions (e.g., class, struct, enum, but not actor), methods, properties, closures, etc.

    // The use of closures is as follows:
    Task { @MyGlobalActor in
      print("")}Copy the code
@MyGlobalActor var globalVar: Int = 1

actor BankAccount {
  @MyGlobalActor static var currentTimeStampe: Int64 = 0
}

@MyGlobalActor class ClassA1 {
  var a1 = 0;
  func testA1(a){}}@MyGlobalActor class ClassA2 {
  var a2 = 1
  var a1: ClassA1

  init(a) {
    a1 = ClassA1.init()}func testA2(a) {
    / / globalVar, ClassA1 / ClassA2 instance, now. CurrentTimeStampe
    // They are both protected by MyGlobalActor
    // The relationship between them belongs to the inner actor relationship, they can be accessed normally
    //
    globalVar + = 1
    a1.testA1()
    BankAccount.currentTimeStampe + = 1}}await globalVar
await BankAccount.currentTimeStampe
Copy the code

As shown above, they can be protected by @myGlobalActor and form an isolated actor bounded by MyGlobalActor between them:

  • They can be accessed normally from within MyGlobalActor, as the classa2.testa2 method does;

  • Outside of MyGlobalActor, access is synchronous, e.g. Await globalVar.

All UI operations need to be performed on the main thread, hence MainAcotr, a few key points:

  • MainActor is a special case of globalAcotr;

    @globalActor final public actor MainActor : GlobalActor
    Copy the code
  • Methods, properties, and so on decorated by MainActor are executed on the main thread.

Remember in the article “Async /await for Swift’s new Concurrency framework” that asynchronous methods might switch to running on different threads before and after the pause point?

The exception is methods decorated with MainActor, which must be executed on the main thread.

In addition to using the @mainActor property, we can also execute a piece of code on the main thread via mainactor.run:

extension MainActor {
  /// Execute the given body closure on the main actor.
  public static func run<T> (resultType: T.Type = T.self.body: @MainActor @Sendable(a)throws -> T) async rethrows -> T where T : Sendable
}
Copy the code

Such as:

await MainActor.run {
  print("")}Copy the code

Beware of internal unpredictability


So far, we know that actor performs external calls serially through the Mailbox mechanism to secure data.

If there are Data races inside the actor method, it will be powerless. For example:

1  actor BankAccount {
2    var balances: [Int: Double] = [1: 0.0]
3
4    func deposit(amount: Double) {
5      assert(amount > = 0)
6      for i in 0..<1000 {
7        // Manually start child threads inside the actor method
8        //
9        Thread.detachNewThread {
10         let b = self.balances[1] ?? 0.0
11         self.balances[1] = b + 1
12         print("i = \(i), balance = \ [self.balances[1])")
13       }
14     }
15   }
16 }
17
18 class AccountManager {
19   let bankAccount = BankAccount.init(accountNumber: 123, initialDeposit: 1000, name: "Jike", age: 18)
20   func depoist(a) async {
21     await bankAccount.deposit(amount: 1)
22   }
23 }
Copy the code

For example, in the code above (deliberately fabricated), the child thread (line 9 ~ 13) is manually opened inside bankAccount. deposit, so there is a Data Races problem and it will crash.

In general, actors are primarily used as Data models and should not process a lot of business logic in them.

Try to avoid manually opening child threads and using GCD, otherwise you need to use traditional methods (such as lock) to solve the multithreading problems caused by this.

Avoid external traps


Say that the internal troubles, then look at the external invasion!

As mentioned earlier, Actor solves the multithreading problem caused by external calls through the Mailbox mechanism.

But… Can you rest easy for external calls?

class User {
  var name: String
  var age: Int

  init(name: String.age: Int) {
    self.name = name
    self.age = age
  }
}

actor BankAccount {
  let accountNumber: Int
  var balance: Double
  var name: String
  var age: Int

  func user(a) -> User {
    return User.init(name: name, age: age)
  }
}
Copy the code
class AccountManager {
  let bankAccount = BankAccount.init(accountNumber: 123, initialDeposit: 1000, name: "Jike", age: 18)

  func user(a) async -> User {
    // Wraning: Non-sendable type 'User' returned by implicitly asynchronous call to actor-isolated instance method 'user()' cannot cross actor boundary
    return await bankAccount.user()
  }
}
Copy the code

Note that the compiler gave this code a Warning at compile time:

Non-sendable type ‘User’ returned by implicitly asynchronous call to actor-isolated instance method ‘user()’ cannot cross actor boundary.

All Sendable related warnings need Xcode 13.3 to be reported.

What is Sendable

This warning is understandable:

  • User is a reference type (class);

  • Pass the User instance outside the actor through the actor-Isolated method;

  • After that, the passed user instance is naturally not protected by the actor, and is obviously not secure in a concurrent environment.

Passing class instances across actor boundaries with parameters is a similar problem:

extension actor BankAccount {
  func updateUser(_ user: User) {
    name = user.name
    age = user.age
  }
}

extension AccountManager {
  func updateUser(a) async {
    // Wraning: Non-sendable type 'User' passed in implicitly asynchronous call to actor-isolated instance method 'updateUser' cannot cross actor boundary
    await bankAccount.updateUser(User.init(name: "Bob", age: 18))}}Copy the code

Of course, passing functions and closures across actors does not work either:

extension BankAccount {
  func addAge(amount: Int.completion: (Int) - >Void) {
    age + = amount
    completion(age)
  }
}

extension AccountManager {
  func addAge(a) async {
    // Wraning: Non-sendable type '(Int) -> Void' passed in implicitly asynchronous call to actor-isolated instance method 'addAge(amount:completion:)' cannot cross actor boundary
    await bankAccount.addAge(amount: 1, completion: { age in
      print(age)
    })
  }
}
Copy the code

Warning: Crash:

extension User {
  func testUser(callback: @escaping() - >Void) {
    for _ in 0..<1000 {
      DispatchQueue.global().async {
        callback()
      }
    }
  }
}

extension BankAccount {
  func test(a) {
    let user = User.init(name: "Tom", age: 18)
    user.testUser {
      let b = self.balances[1] ?? 0.0
      self.balances[1] = b + 1
      print("i = \ [0).\(Thread.current), balance = \(String(describing: self.balances[1]))")}}}Copy the code

As shown above, although BankAccount is an actor and there are no “illegal operations” inside it such as opening child threads,

However, crash occurs after a call to user.testuser (callback: @escaping () -> Void).

How to do?

This is where Sendable comes in: “Sendable for Swift’s new Concurrency framework”

summary

  • Actor is a new type of reference designed to address Data Races;

  • Inside actor, serial execution of all external calls is realized through the Mailbox mechanism.

  • For methods and properties that explicitly do not exist Data Races, nonisolated modifications can be used to make them “normal” methods to improve performance.

  • @GlobalActor allows you to define global actors to protect global variables, static variables, multiple instances, and so on.

  • Actor internal as far as possible to avoid opening child threads so as not to cause multithreading problems;

  • Actor should be used as Data Model, not in which to process too much business logic.

The resources

Swift evolution/0296-async-await. Md at main · Apple /swift evolution · GitHub

Swift evolution/0302-concurrent-value-and-concurrent-closures. Md at Main · Apple/Swift Evolution · GitHub

Swift evolution/0337-support-incremental-migration-to-concurrency-checking. Md at main · Apple/Swift Evolution · GitHub

Swift-evolution /0304- Structured – Concurrency. Md at Main · Apple/Swift-Evolution · GitHub

Swift evolution/0306- Actors. Md at main · Apple/Swift Evolution · GitHub

Swift evolution/0337-support-incremental-migration-to-concurrency-checking. Md at main · Apple/Swift Evolution · GitHub

Understanding async/await in Swift • Andy Ibanez

Concurrency — The Swift Programming Language (Swift 5.6)

Connecting async/await to other Swift code | Swift by Sundell