The Pragmatic Ball boy

iOSを中心にやってる万年球拾いの老害エンジニアメモ

Swift ConcurrencyのActor reentrancy

SwiftでActorsが導入され、actorを使えば簡単にスレッドセーフにすることができるようになりました。

ただこれでrace conditionやdead lockといった並行プログラミングにおける問題が解決されるわけではありません。

Actor reentrancy

SwiftのActors(正確にはActor-isolated function)はreentrant(再入可能)となっています。そのため、あるActorのActor-isolated functionの中断中にそのActor-isolated functionは排他されるわけではなく実行可能となります。

Actorsのproposalから例を持ってくると

actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                       // <1>
    await friend.tell(opinion, heldBy: self)  // <2>
    return opinion // 🤨                      // <3>
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea                       // <4>
    await friend.tell(opinion, heldBy: self) // <5>
    return opinion // 🤨                     // <6>
  }
}

このように実行すると、thinkOfGoodIdea()を実行中断している間にthinkOfBadIdea()が実行されるとopinionが中断中に変わってしまい、.goodIdeaを返すはずが.badIdeaが返ってくるといったことが起こる可能性があります。つまりrace conditionが発生してしまうのです。

let goodThink = detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = detach { await person.thinkOfBadIdea() } // runs async

let shouldBeGood = await goodThink.get()
let shouldBeBad = await badThink.get()

await shouldBeGood // could be .goodIdea or .badIdea ☠️
await shouldBeBad

こういった問題を起こさないためには、Actor-isolated functionは同時に呼ばれても問題ないように実装する必要があります。

じゃnon-reentrant(再入可能にしない)のほうがよいのでは?と思うかもしれません。non-reentrantにした場合を見ていきます。

non-reentrant actors

actorをnon-reentrantにした場合は、Actor-isolated functionの呼び出し中はactorがロックされるので呼び出しが終わるまでは他のActor-isolated functionは実行されません。そのためreentrantであげた例と同様のことを行うとnon-reentrantの場合は意図した通りの動きとなります。

non-reentrantの場合はなにが問題かというと、大きく2つあります。

1つ目は、dead lockを引き起こすことです。

このようにDecisionMakerのActor-isolated functionから更に別のDecisionMakerのActor-isolated functionを呼び出しを行うと、お互いに実行終了待ちとなりdead lockが発生します。

extension DecisionMaker {
  func tell(_ opinion: Judgment, heldBy friend: DecisionMaker) async {
    if opinion == .badIdea {
      await friend.convinceOtherwise(opinion)
    }
  }
}

2つ目はパフォーマンスです。

このImageDownloaderの例だと、non-reentrantだとgetImage中は排他されるので、キャッシュにヒットするURLの場合も、異なるURLの場合でも前の画像のダウンロードが終わるまで実行できないので、並行処理を活かせていません。

actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

まとめ

reentrant, non-reentrantにはどちらもメリット・デメリットがあります。

  • reentrantは正しく実装しないとrace conditionを起こしますが、dead lockは起こしません。中断中に割り込みできるのでパフォーマンスがよいです。
  • non-reentrantはrace conditionは起こしませんが、dead lockを起こす可能性があります。中断中でも割り込みできないのでパフォーマンスがよくないです。

SwiftのActorsではdead lockを起こさないこととパフォーマンスが優先されreentrantがデフォルトとして採用されましたが、dead lockを起こしてくれたほうがバグとしては気づきやすかったりもするので、どちらがよいとは一概には言えないと思います。

将来的にはnon-reentrantにすることもできるようになるので状況に応じて使い分ける必要が出てくると思いますので、これらの違いについては理解しておいたほうがよさそうです。