👌

Swift Actorsで並行処理における可変状態の保護

2021/10/01に公開1

並行処理における命題

最近はCPUにマルチコアプロセッサを採用していることが多いので、マルチコアプロセッサにおいて同時にタスクを実行できる機能を並行処理とします。
シングルプロセッサの逐次処理とは違い、マルチコアプロセッサによる並行処理は便利な反面、以下の命題が出てきます。

データ競合の回避

データ競合は、2つのスレッドからアクセス(1つ以上の書き込みを含む)することで起きます。これをいかに防ぐかが並行処理プログラミングにおいて重要になってきます。

データ競合についてはとてもデバッグが難しいですが、swift 5.5から導入されたactorsによって、言語レベルでデータ競合を防ぐための仕組みが備わりました。

https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID645
https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md

この記事は、以下のWWDCセッションについてまとめたものになります。
https://developer.apple.com/videos/play/wwdc2021/10133/

今までの問題

今までのswiftでは、以下のような問題がありました。

classの場合

classは参照型です。
以下のようなコードを考えてみます。

class Counter {
  var value = 0
  func increment() -> Int {
    value = value + 1
    return value
  }
}

let counter = Counter()
Task.detached {
  print(counter.increment())
}

Task.detached {
  print(counter.increment())
}

Task.detachedはタスクを生成・実行する関数です。

この場合、タスクが順番に呼ばれることは非確定なので、出力結果として、[1,1], [1,2], [2,1], [2,2]が表示される可能性があり、データ競合が起きていると判断されます。

structの場合

structは値型です。swiftでは以下のようにして、classの場合と同じように書いてみます。

struct Counter {
  var value = 0
  mutating func increment() -> Int {
    value = value + 1
    return value
  }
}

let counter = Counter()
Task.detached {
  print(counter.increment())
}

Task.detached {
  print(counter.increment())
}

このコードではコンパイラがエラーを出します。letvarに変える必要があります。

var counter = Counter()

Task.detached {            
  print(counter.increment())
}
          
Task.detached {            
  print(counter.increment())
}

これもコンパイラがエラーを出します。これはTask.detachedでの判定によるものです。これについては後述します。

Mutation of captured var 'counter' in concurrently-executing code
var counter = Counter()

Task.detached {            
  let counter = counter
  print(counter.increment())
}
          
Task.detached {        
  let counter = counter    
  print(counter.increment())
}

これもclassの場合のようにデータ競合が起きます。

この場合、タスクが順番に呼ばれることは非確定なので、出力結果として、[1,1], [1,2], [2,1], [2,2]が表示される可能性があり、データ競合が起きていると判断されます。

actorsの導入

swift 5.5では、concurrency機能の一部としてactorsがあります。並列アクセスを起こさないためのswift言語の仕組みです。

actorsに関連する用語として

  • actor
  • actor reentrancy
  • Sendable
  • nonisolated
  • MainActor
    があります。

actor

actorclassと同じ参照型です。
actorで宣言された定義、その関数については必ずawaitを記述し、処理を待ちます。つまり他の処理から隔離されます。

actor Counter {
  var value = 0
  func increment() -> Int {
    value = value + 1
    return value
  }
}

let counter = Counter()
Task.detached {
  print(await counter.increment())
}

Task.detached {
  print(await counter.increment())
}

結果は[1,2], [2,1]のどちらかになり、データ競合を排除できました。

actor reentrancy

直訳すると、再入可能性です。
以下のような場合を考えます。

actor ImageDownloader {
  private var cache: [URL: Image] = [:]
  
  func image(from url: URL) async throws -> Image? {
    if let cached = cache[url] {
      return cached
    }
    
    let image = try await downloadImage(from: url)
    
    cache[url] = image
    return image
    
  }
}

処理を順に追ってみます。

  • ①ダウンロードをリクエスト(サーバの画像は1)
  • ②ダウンロードをリクエスト(サーバの画像は2)
  • ①ダウンロードが完了(画像は1)
  • ②ダウンロードが完了(画像は2)

データの競合はおきませんが、cache[url]の上書きが発生してバグとなり得ます。await後に処理を再開する際に、プログラムの状態が変わったためです。
そこで、以下のようにします。

actor ImageDownloader {
  private var cache: [URL: Image] = [:]
  
  func image(from url: URL) async throws -> Image? {
    if let cached = cache[url] {
      return cached
    }
    
    let image = try await downloadImage(from: url)
    
    cache[url] = cache[url, default: image]
    return image
    
  }
}

ここでDictionaryのデフォルト用サブスクリプトを使っています。
https://developer.apple.com/documentation/swift/dictionary/2894528-subscript

重要な点としては、

awaitの後に確認する必要がある

ということです。

actor isolation

直訳すると、隔離性です。プロトコルなどとどう相互作用するかを見ていきます。
例として、Hashableに適合してみます。

actor LibraryAccount {
  let idNumber: Int
  let booksOnLoan: [Book]
}

extension LibraryAccount: Hashable {
  func hash(into hasher: inout Hasher) {
    hasher.combine(idNumber)
  }
}

hashが隔離されたactorの外からアクセスできてしまうため、エラーとなります。

Actor-isolated instance method 'hash(into:)' cannot be used to satisfy a protocol requirement

nonisolatedをつけると、非隔離となりアクセスできるようになります。

actor LibraryAccount {
  let idNumber: Int
  let booksOnLoan: [Book]
}

extension LibraryAccount: Hashable {
  nonisolated func hash(into hasher: inout Hasher) {
    hasher.combine(idNumber)
  }
}

nonisolatedをつけると、actor外として扱われるので、letのみアクセスできます。
varはデータ競合を引き起こすため、アクセスできません。

Sendable

Sendable type are safe to share concurrently
Sendableとは、同期的に共有できるタイプのことです。

  • 値をある場所から別の場所にコピーし、両方の場所で変更しても互いに干渉しないこと
  • 値型、actor、一部のclass(注意深く実装された場合)

Sendable protocol

https://developer.apple.com/documentation/swift/sendable/
Sendableであることを保証します。
例えば、

  • Bool
  • Int
  • Float
  • Double
    などがあります。

カスタムのstructにも明示的につけることができる。Sendableではない場合はエラーが出る

struct Book: Sendable {
  var title: String
  var authors: [Author]
}

class Author {
    var name: String = ""
}

上記の場合、以下のエラーが出ます。

Stored property 'authors' of 'Sendable'-conforming struct 'Book' has non-sendable type '[Author]'

Authorをstructに変えるとエラーは出なくなります。つまりSendableになります。

struct Author {
  var name: String
}

@Sendable

@Sendableは、関数をSendableに適合しているかどうかチェックします。

  • クロージャでキャプチャした変数がSendableであるかどうかチェックする
  • actorで定義した関数にawaitがついているかチェックする

例えばTask.detachedの定義を見てみます。

@discardableResult static func detached(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success) -> Task<Success, Failure>

前述のstruct Counterの場合でエラーが起きていたのは、この@Sendableチェックによることが原因でした。

MainActor

@MainActorで定義すると、以下のような特徴があります。

  • UIスレッドで実行できる(DispatchQueue.mainのような感じ)
  • var, func, classにつけることができる
  • UIのアップデートを行う処理にはつけるべき

例えば、以下のように定義します。

@MainActor
class ViewModel: ObservableObject {
  ...

classにつけることで、全てのvarの変更(ObservableObjectの場合は@Publishedなど), funcの処理をUIスレッドで実行するようになります。

サンプルプロジェクト

https://github.com/usk-sample/GithubConcurrencySample

Discussion

zundazunda

自分も勘違いしていたのですが、おそらくactorのコードはactorの利点の説明になっていないと思います。
actorは構造化されたタスクのみ値の変更の保証がされているので、記事上にある構造化されていないタスクはactorでは保証されていないはずです。(実行速度が速すぎて、毎回同じ結果になると思いますが、Task.sleep()などをしてあげるとわかると思います。)

actor Counter {
  var value = 0

  func increment() async -> Int {
    value = value + 1

    if (value == 1) {
      try! await Task.sleep(nanoseconds: 1)
      print("sleeping")
    }

    return value
  }
}

let counter = Counter()

// Taskが2つに分かれているので構造化されていないタスク
Task.detached {
  print(await counter.increment())
}

Task.detached {
  print(await counter.increment())
}
// 実行結果(例1)
2
sleeping
2
// 実行結果(例2)
sleeping
1
2

構造化

1つのタスクにまとめることで値の保証がされるはずです。

Task.detached {
  print(await counter.increment())
  print(await counter.increment())
}