🪢

[Swift] actor, Mutexを使ったデータ競合の排除

2025/03/02に公開

データ競合

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

@Test
func classTest() {
  let counter = Counter()
  Task {
    print(counter.increment())
  }
  Task {
    print(counter.increment())
  }
}

上記のCounterはマルチスレッドからcounter.increment()にアクセスされるとデータ競合が起きてしまい、プリントされるパターンは保証されません。
プリントされるパターンは1, 2, 2, 1, 1, 1, 2, 2の4パターンでプリントされる可能性があります。
このデータ競合をactor, Mutex, OSAllocatedUnfairLock, DispatchQueueで排除することができ、1, 2, 2, 1のパターンのみにさせることができます。

名前 同期 Platform
actor 非同期処理 iOS 15.0+, macOS 12.0+, Linux
Mutex 同期処理 iOS 18.0+, macOS 15.0+, Linux
OSAllocatedUnfairLock 同期処理 iOS 16.0+, macOS 13.0+
DispatchQueue.asyncAndWait 同期処理 iOS 12.0+, macOS 10.14+
DispatchQueue.sync 同期処理 iOS, macOS

Counterを用いた各テストコード

1,000,000回程度で実行させるとデータ競合が確認できました。

◇ Test run started.
↳ Testing Library Version: 120
↳ Target Platform: arm64e-apple-macos14.0
◇ Test classTest() started.
DATA RACE DETECTED [2, 2]
DATA RACE DETECTED [2, 2]
DATA RACE DETECTED [1, 1]
DATA RACE DETECTED [2, 2]
  • macOS 14.0
  • Swift 6.1(Xcode 16.3 Beta)
  • n = 1,000,000
テストケース 平均実行時間 平均データ競合数
class 3.7s 10
actor 4.0s 0
Mutex 3.7s 0
OSAllocatedUnfairLock 3.85s 0
DispatchQueue.asyncAndWait 5.75s 0
DispatchQueue.sync 6000s 0
final class ClassCounter: Counter, @unchecked Sendable {
  var count: Int = 0
  
  func increment() -> Int {
    count += 1
    return count
  }
}

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

import Synchronization

@available(macOS 15.0, *)
final class MutexCounter: Counter,Sendable {
  let count: Mutex<Int> = .init(0)
  
  func increment() -> Int {
    count.withLock {
      $0 += 1
      return $0
    }
  }
}

import os.lock

final class OSLockCounter: Counter, Sendable {
  let count = OSAllocatedUnfairLock(initialState: 0)
  
  func increment() -> Int {
    count.withLock {
      $0 += 1
      return $0
    }
  }
}

final class DispatchQueueCounter: Counter, @unchecked Sendable {
  let queue = DispatchQueue(label: "Counter")
  var value: Int = 0
  func increment() -> Int {
    return queue.asyncAndWait {
      value += 1
      return value
    }
  }
}

@Test
func classTest() async {
  for _ in 0..<1_000_000 {
    let counter = ClassCounter()

    let values: [Int] = await countInMultiThreads(counter: counter)
    
    if Set(values) != Set([1, 2]) {
      print("DATA RACE DETECTED", values)
    }
  }
}

@Test
func actorTest() async {
  for _ in 0..<1_000_000 {
    let counter = ActorCounter()
    let values: [Int] = await countInMultiThreads(counter: counter)
    
    if Set(values) != Set([1, 2]) {
      print("DATA RACE DETECTED", values)
    }
  }
}

@available(macOS 15.0, *)
@Test
func mutexTest() async {
  for _ in 0..<1_000_000 {
    let counter = MutexCounter()
    let values: [Int] = await countInMultiThreads(counter: counter)
    
    if Set(values) != Set([1, 2]) {
      print("DATA RACE DETECTED", values)
    }
  }
}

@Test
func osLockTest() async {
  for _ in 0..<1_000_000 {
    let counter = OSLockCounter()
    let values: [Int] = await countInMultiThreads(counter: counter)
    
    if Set(values) != Set([1, 2]) {
      print("DATA RACE DETECTED", values)
    }
  }
}

@Test
func dispatchQueueTest() async {
  for _ in 0..<1_000_000_000 {
    let counter = DispatchQueueCounter()

    let values: [Int] = await countInMultiThreads(counter: counter)
    
    if Set(values) != Set([1, 2]) {
      print("DATA RACE DETECTED", values)
    }
  }
}

protocol Counter: Sendable {
  func increment() async -> Int
}

func countInMultiThreads(counter: some Counter) async -> [Int] {
  let values: [Int] = await withTaskGroup { group in
    for _ in 0..<2 {
      group.addTask {
        await counter.increment()
      }
    }
    
    var values: [Int] = []
    for await value in group {
      values.append(value)
    }
    
    return values
  }
  
  return values
}

参考資料

Discussion