😺

【翻訳】How does Swift Actor prevent data race?

2023/07/19に公開

データ競合とは?

データ競合は、1つのプロセス内の2つ以上のスレッドが同じメモリ位置に同時にアクセスし、そのうちの少なくとも1つのアクセスが書き込みであり、スレッドがそのメモリへのアクセスを制御するために排他的ロックを使用していない場合に発生します。

競合状態とは?

競合状態は、2つのスレッドが同時に共有変数にアクセスしたときに発生します。最初のスレッドが変数を読み、2番目のスレッドがその変数から同じ値を読みます。つまり、事象のタイミングや順序がコードの正しさに影響を与える場合に競合状態が発生します。

class Account {
    var balance: Double
    var transactions: [String] = []
    
    init(balance: Double) {
        self.balance = balance
    }
    
    func withdraw(amount: Double) {
        
        if balance >= amount {
            let processingTime = UInt32.random(in: 0...3)
            transactions.append("[Withdraw] Processing for \(amount) \(processingTime) seconds")
            sleep(processingTime)
            transactions.append("Withdrawing \(amount) from account")
            
            self.balance -= amount
            transactions.append("Balance is \(balance)")
        }
    }
}
var account= Account(balance: 500)
DispatchQueue.concurrentPerform(iterations: 2) { _ in 
    self.bankAccountVM.withdraw(500)
}
// Balance is 0.0
// Balance is -500.0

マルチスレッド・アプリケーションでは、複数のスレッドが同じ口座の異なる出金をトリガーします。同じ2つの引き出しの順番は予測不可能であり、実行の順番によって2つの異なる結果になる可能性があります。同期も行われていません。

Swift 以前の actor は、DispatchQueue、DispatchSemaphore、Lockなどの 同期メカニズムを使ってこれを解決していました。

// using DispatchQueue
let lockQueue = DispatchQueue(label: "my.serial.lock.queue")
DispatchQueue.concurrentPerform(iterations: 2) { _ in 
   lockQueue.sync {
     self.bankAccountVM.withdraw(500)
    }
}
// using DispatchSemaphore
let semaphore = DispatchSemaphore(value: 1)
DispatchQueue.concurrentPerform(iterations: 2) { _ in 
   semaphore.wait()
   self.bankAccountVM.withdraw(500)
   semaphore.signal()
}
// using lock
var lock = os_unfair_lock_s()
DispatchQueue.concurrentPerform(iterations: 2) { _ in 
   os_unfair_lock_lock(&lock)
   self.bankAccountVM.withdraw(500)
   os_unfair_lock_unlock(&lock)
}

Actor

Actor は、データの同期に取り組むためのSwiftの具体的な名目型です。それらは参照型であり、メソッドとプロパティを持つことができ、プロトコルを実装することもできますが、それらのイニシャライザをよりシンプルにする継承をサポートしていません - convenience initializers、override、finalキーワードは必要ありません。

Actor は、新しい actor キーワードを使用して作成します。Actor はデータ分離によって内部状態を保護することができ、ある時点で単一のスレッドだけが基礎となるデータ構造にアクセスできるようにします。すべての Actor は暗黙のうちに新しい Actor ・プロトコルに準拠します。Actor は actor 分離を導入することでデータ競合問題を解決します。保存されたプロパティは actor・オブジェクトの外からは一切書き込めません。

enum ARBankError: Error {
    case insufficientFunds(Double)
}
actor ARBankAccount {
    let accountNumber: Int
    var balance: Double

    var details: String {
        "Account holder Number: \(accountNumber)"
    }

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

    func deposit(_ amount: Double) async {
        balance += amount
    }

    func transfer(amount: Double, to other: ARBankAccount) async throws  {
        if amount > balance {
            throw ARBankError.insufficientFunds(amount)
        }
        balance -= amount
        await other.deposit(amount)

        print("Account: \(balance), Other Account: \(await other.balance)")
    }
}

Actor のメソッド内部でコードを書いている場合は、 await を使わなくてもその actor の他のプロパティを読み込んだり、その同期メソッドを呼び出したりすることができますが、 actor の外部からそのデータを使おうとする場合は、同期プロパティやメソッドであっても await が必要になります。

デフォルトでは、 actor の各メソッドは分離されます。つまり、すでに actor のコンテキストにいるか、 await を使用して actor に含まれるデータへの承認されたアクセスを待つ必要があります。 actor のメソッドはデフォルトで分離されていますが、明示的に分離されているわけではありません。

isolated としての actor ・パラメーター

パラメータに isolated キーワードを使うと、特定の問題を解決するためのコードを少なくすることができます。上記のコード例では、別の銀行口座の残高を変更するための入金メソッドを紹介しました。

パラメータを isolated にすることで、この余分なメソッドを取り除き、別の銀行口座の残高を更新することができます。

func transfer(amount: Double, to other: isolated ARBankAccount) async throws {
  if amount > balance {
    throw ARBankError.insufficientFunds(amount)
  }
 balance -= amount
 other.balance += amount
 print(Account: \(balance), Other Account: \(other.balance))
}

Actor の nonisolated キーワード

メソッドやプロパティを nonisolated としてマークすることで、 actor のデフォルトの分離を許可しないことができます(オプトアウトする)。オプトアウトは、不変の値にアクセスする場合やプロトコルの要件に準拠する場合に役立ちます。

1.計算プロパティからの不変値へのアクセス

口座番号は不変なので、非分離環境からアクセスしても安全です。コンパイラはこの状態を十分に認識しているので、このパラメータを明示的に nonisolated としてマークする必要はありません。

しかし、計算プロパティがイミュータブル・プロパティにアクセスすると、以下のようにコンパイル・エラーが表示されます。

ここでは、エラーを取り除くために、明示的に nonisolated とマークする必要があります。

actor ARBankAccount {
   let accountNumber: Int
   var balance: Double
nonisolated var details: String {
        "Account holder Number: \(accountNumber)"
    }
init(accountNumber: Int, balance: Double) {
         self.accountNumber = accountNumber
         self.balance = balance
    }
}

2.nonisolated によるプロトコル適合性

プロトコルの適合性を追加することで、不変の状態のみにアクセスできるようにします。以下は、details プロパティを CustomStringConvertible プロトコルに置き換える例です。

ここでも、 nonisolated キーワードを明示的に使用することで解決できます。

extension ARBankAccount: CustomStringConvertible {     
    nonisolated var description: String {         
          "Account holder number: \(accountNumber)"     
     }
 }

MainActor

MainActor は、メイン・キューでコードを実行するためのグローバル・アクタです。@MainActor というキーワードのグローバル・アクタを使用すると、メイン・スレッドでのみアクセスするプロパティやメソッドをマークできます。MainActorは、基本となるMainActor構造体のグローバル・アクタ・ラッパーで、静的なrun()メソッドがあるため、実行する作業をスケジューリングできて便利です。

import Foundation

class ARController {
    func save() -> Bool {
        guard Thread.isMainThread else {
            print("Func Save is not in main thread...")
            return false
        }
        print("Saving old data...")
        return true
    }
}

class AROtherController {
    @MainActor func save() {
        print("Saving new data...")
    }
}

let obj = ARController()
DispatchQueue.concurrentPerform(iterations: 2) { _ in
    obj.save()
}
// Saving old data...
// Func Save is not in main thread...

let newObj = AROtherController()
await newObj.save()
// Saving new data...

await の有無にかかわらず、 actor の外部からプロパティを書き込むことはできません。

Swift ActorのデモプロジェクトはGitHubで確認できます。
https://github.com/ashokrwt31/Actor-POC

Swiftの Actor は、データ競合を防ぐ,共有された変更可能な状態へのアクセスを同期するための素晴らしい方法です。Swift の Async-await は、構造化された同時実行を可能にし、複雑な非同期コードの可読性を向上させます。自己キャプチャーによるクロージャを簡単に回避し、エラー処理を改善することができます。Swiftの async-await ブログで async-await の詳細をチェックしましょう。

【翻訳元の記事】

How does Swift Actor prevent data race?
https://medium.com/@ashokrawat086/how-does-swift-actor-prevent-data-race-b6f484e7eb8c

Discussion