Swift データ競合と競合状態
非同期処理、並列処理を扱う際は、データ競合(Data Race)と競合状態(Race Condition)に注意する必要がある。
データ競合(Data Race)
概要
データ競合は、複数のスレッドが同時に同じメモリにアクセスし、少なくとも1つのスレッドが書き込みを行う場合に発生する。これは予測不可能な結果やクラッシュを招く可能性がある。
例
以下のコードを実行すると、countが10000にならないことがあり、想定していない結果となる場合がある。
class UnsafeCounter {
var count = 0
func increment() {
count += 1
}
func getCount() -> Int {
return count
}
}
func unsafeParallelIncrement(times: Int) async {
let counter = UnsafeCounter()
await withThrowingTaskGroup(of: Void.self) { group in
for _ in 0..<times {
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000 * 1)
counter.increment()
}
}
}
// countが10000未満になる場合がある
print("count: \(counter.getCount())")
}
Task {
await unsafeParallelIncrement(times: 10000)
}
対策
1. アクセス順序の制限:
ConcurrencyやDispatchQueueを利用し、メモリへのアクセス順序を制限する。(同時にメモリにアクセスすることを防ぎ、処理の途中で割り込みさせないようにする)
Concurrencyを利用した例
func unsafeParallelIncrement(times: Int) async {
let counter = UnsafeCounter()
await withThrowingTaskGroup(of: Void.self) { group in
for _ in 0..<times {
group.addTask {
try await Task.sleep(nanoseconds: 1_000_000_000 * 1)
// メインスレッドで実行
await MainActor.run {
counter.increment()
}
}
}
}
}
DispatchQueueを利用することでもデータ競合を防ぐことが可能。
DispatchQueue.main.sync {
counter.increment()
}
2. NSLockやSemaphoreによるロック機構の活用:
NSLockインスタンスを作成し、プロパティへのアクセスを保護。
class UnsafeCounter {
var count = 0
private let lock = NSLock()
func increment() {
lock.lock()
defer { lock.unlock() }
count += 1
}
func getCount() -> Int {
lock.lock()
defer { lock.unlock() }
return count
}
}
Semaphoreも同じように書くことができる。
class UnsafeCounter {
var count = 0
private let semaphore = DispatchSemaphore(value: 1)
func increment() {
semaphore.wait()
defer { semaphore.signal() }
count += 1
}
func getCount() -> Int {
semaphore.wait()
defer { semaphore.signal() }
return count
}
}
3. Actorの活用:
Actorを使用することで、同時に処理が走らないことが保証される。
actor SafeCounterActor {
private var count = 0
func increment() {
count += 1
}
func getCount() -> Int {
return count
}
}
競合状態(Race Condition)
概要
競合状態は、複数の操作の実行順序が予測不可能で、その順序によってプログラムの動作が変わる状況を指す。データ競合とは異なり、必ずしもバグやクラッシュを引き起こすわけではないが、予期せぬ動作の原因となることがある。
例
この例では、一度だけdownloadImageが実行され、それ以降キャッシュの値が返されるべきであるが、複数回downloadImageが実行され、異なる画像が返されたり、キャッシュされる可能性がある。
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` が変化している可能性がある
cache[url] = image
return image
}
}
let downloader = ImageDownloader()
Task {
await withThrowingTaskGroup(of: Void.self) { group in
for _ in 0..<5 {
group.addTask {
try await downloader.image(from: URL(string: "url")!)
}
}
}
}
Actorを利用しているため、データ競合は起きないが、awaitしている箇所で割り込みが入る可能性があるため、競合状態が起こってしまう。
When an actor-isolated function suspends, reentrancy allows other work to execute on the actor before the original actor-isolated function resumes, which we refer to as interleaving.
(翻訳)
アクターから分離された関数が一時停止した場合、リエントランシーにより、元のアクターから分離された関数が再開する前に、アクター上で他の作業が実行されます(これをインターリーブと呼びます)。
引用: swift-evolution/0306-actors.md at main · apple/swift-evolution
対策
この例では、downloadImageを実行した後に、キャッシュの確認を行う方法がある。
let image = try await downloadImage(from: url)
if let cached = cache[url] {
return cached
}
要件に応じて適切に設計を行うことで競合状態を防ぐことが可能。
Discussion