🍏

Swift データ競合と競合状態

2024/09/09に公開

非同期処理、並列処理を扱う際は、データ競合(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