Do.

Race Condition / Thread Safe 본문

General Dev

Race Condition / Thread Safe

Hey_Hen 2023. 6. 26. 23:31

Race Condition

레이스 컨디션이란 동시에 여러 스레드 또는 프로세스에서 공유 자원에 접근하고 수정하는 상황에서 발생하는 문제를 말합니다. 특별히 신경쓰지 않으면 멀티스레드 환경에서 반드시 발생할 수 있는 것이죠.
흔히 발생하는 현상으로는 결과값이 예상과는 다르게 나오거나 잘못된 순서로 데이터가 적재됩니다. 메모리를 다루는 경우에는 BAC_ACCESS와 함께 앱 크래시가 발생할 수 있는 위험한 경우입니다!
어떤 상황에서 레이스 컨디션이 발생하는지 알아보겠습니다.

Example

let concurrentQueue = DispatchQueue(label: "com.queue", attributes: .concurrent)
var sharedResource = 0
  func test_task() throws {
    for i in 1...100 {
      concurrentQueue.async {
        self.append(pass: i)
      }
    }
  }

private func append(pass: Int) {
  sharedResource += pass
  if pass == 100 {
    print(sharedResource)
  }
}

앞에서 설명한 것 처럼 레이스 컨디션은 멀티스레드 환경에서 발생합니다.
sharedResource가 있고 이를 append라는 메서드에서 접근하고 있죠, 전달받은 pass값을 sharedResource에 더하는 로직입니다.

이미 아시겠지만 만약 1 부터 100까지 모두 더한다면 100번째에는 5050이라는 값이 출력될것입니다! 하지만 test_task 에서는 append 메서드를 비동기로 호출하고 있습니다. 심지어 concurrentQueue이기 때문에 append 메서드는 수많은 스레드에서 실행될 예정입니다.
즉 위와같은 코드는 순차적으로 발생하지 않기 때문에 우리가 원하는 결과를 얻을 수 없습니다. 동시다발적으로 스케줄링이 이루어졌고 가장 먼저 작업이 수행되는게 pass 100부터 라서 1만 출력되고 아무것도 안나올 수도 있죠.
작업이 동시적으로 발생하기 때문에 이러한 경우는 레이스 컨디션 보다는 동시적 작업의 결과입니다.
예제를 조금 바꿔 보겠습니다.

let concurrentQueue = DispatchQueue(label: "com.queue", attributes: .concurrent)
var sharedResource: [Int] = []
  func test_task() throws {
    for i in 1...100 {
      concurrentQueue.async {
        self.append(pass: i)
      }
    }
  }

private func append(pass: Int) {
  sharedResource.append(pass)
}

전체를 다 보실 필요는 없습니다. sharedResource가 Int 에서 Array로 바뀐 것만 주목하시면 됩니다.
이제는 단순히 값을 더하는 것이 아니라 배열에 값을 추가하는 형태가 되었네요! 이 경우 가장 치명적인 케이스가 발생할 수 있습니다.

BAD_ACCESS가 발생했는데 이외에도 똑같은 코드에서 Out_Of_Range나 SIGABRT가 발생할 수도 있습니다.
이러한 문제가 왜 발생할까요? 이는 Swift가 Array의 용량을 확장하는 방법과 연관이 있습니다. 하지만 오늘 주제에서 다룰 내용은 아니죠!
중요한 점은 레이스 컨디션으로 인한 문제가 앱 강제종료 라는 크리티컬한 상황으로 이어질 수 있다는 것입니다.

예방하기

레이스 컨디션을 예방하는 방법에는 무엇이 있을까요?

Singleton Class 자제하기

레이스 컨디션이 발생하는 데에는 두가지 필수 조건이 있습니다. 첫번째는 멀티스레드 환경이어야 하고 두번째는 공유된 자원에 여러 곳에서 접근하는 환경 이어야 한다는 것입니다. 엄밀히 말하면 여러 스레드에서 하나의 공유된 자원에 접근하는 것이지만 iOS 환경은 기본적으로 멀티스레드 환경이기 여러곳 이라는 것은 여러 스레드와 동일할 수 있죠.
이러한 공유된 자원에 접근하는 것중 가장 흔한 케이스가 바로 싱글톤 클래스입니다. 싱글톤 클래스는 App State나 User State등 앱 전반에 걸쳐 상태를 관리하는 데에 아주 유용한 디자인 패턴이지만 레이스 컨디션이 발생하기 쉬운 위치에 노출되어 있습니다. 만약 싱글톤을 써야 한다는 각별히 주의해야 합니다.

Concurreny 자제하기

레이스 컨디션이 발생하는 대부분의 경우는 Concurrent한 작업 때문입니다. 특별히 작업이 아니라면 오히려 대부분의 작업은 작업 순서가 중요한 경우가 많은데요. 일반적인 경우는 Serial 작업으로 진행하는 것이 좋습니다.

Thread safe

각별한 주의에도 어쟀든 레이스 컨디션이 발생하는 상황을 어찌할 수는 없습니다. Singleton Class을 꼭 써야 할 수도 있고, Concurreny 작업 스케줄링이 필요할 수도 있죠, 이런 경우 레이스 컨디션이 발생하지 않도록 Thread safe 환경을 만들어야 합니다. 애플에서는 Thread safe 환경을 위한 다수의 편리한 API를 제공합니다.

Grand Central Dispatch

이하 GCD는 queue 기반의 멀티스레드 / 병럴처리 작업 API입니다. 우리가 흔히 사용하는 DispatchQueue가 바로 그것이죠.
DispatchQueue를 이용해서 레이스 컨디션을 해결하는 법은 매우 쉽습니다.

let concurrentQueue = DispatchQueue(label: "com.queue", attributes: .concurrent)
let serialQueue = DispatchQueue(label: "com.queue.serial")
var sharedResource: [Int] = []
  func test_task() throws {
    for i in 1...100 {
      concurrentQueue.async {
        self.append(pass: i)
      }
    }
  }

private func append(pass: Int) {
  serialQueue.async { [unowned self] in
    sharedResource.append(pass)
  }
}

주목할 부분은 새롭게 추가된 serialQueue입니다. DispatchQueue는 별도의 attributes를 지정하지 않으면 serial이 기본 설정입니다.
이 경우 append(pass:) 메서드는 동시적으로 발생하지만 이후 작업은 serialQueue에서 순차적으로 진행되기 때문에 sharedResource에 접근에서 발생하는 레이스 컨디션은 사라집니다. 쉽죠?

DispatchSemaphore

세마포어도 GCD의 기능 중 일부입니다. DispatchQueue는 동시적으로 공유 자원에 접근하려는 것을 직렬로 순서대로 접근하도록 만들었습니다. 반면 세마포어는 스레드간의 상호작용을 제어하는데요,

let semaphore = DispatchSemaphore(value: 1)

세마포어를 생성할때 value를 할당하는데 이 값은 semaphore가 통제할 스레드의 수 입니다.
세마포어는 wait()와 signal() 한쌍의 메서드로 이루어지는데, wait는 값을 감소시키고 signal은 값을 증가시킵니다.
wait로 값이 감소해서 0보다 작아지면 해당 작업 블럭은 대기 상태에 들어갑니다. signal이 나올때 까지요,
좀더 자세히 알아보기 위해 출력문을 좀 활용해봤습니다.

private func append(pass: Int) {
  print("start! \(#function) \(pass)")
  semaphore.wait()
  print("stop! \(#function) \(pass)")
  sharedResource.append(pass)
  print("task done! \(#function) \(pass)")
  semaphore.signal()
  print("let! \(#function) \(pass)")
}

위와 같이 작성했을 때 콘솔 로그를 보면 아래와 같습니다.

start! append(pass:) 1
start! append(pass:) 2
stop! append(pass:) 1
start! append(pass:) 4
task done! append(pass:) 1
let! append(pass:) 1
stop! append(pass:) 2
task done! append(pass:) 2
start! append(pass:) 6
let! append(pass:) 2
start! append(pass:) 8
start! append(pass:) 5
stop! append(pass:) 4
task done! append(pass:) 4
start! append(pass:) 7

pass가 1번이 들어왔을 때 작업이 끝나기도 전에 pass 2가 들어왔군요!
하지만 이때 세마포어 먼저 들어온 pass 1번에서 세마포어 wait이 먼저 발동됩니다.
그 다음 pass 4도 들어왔네요 하지만 그 각 pass에서 작업 진행사항은 pass 1번 밖에 없네요!
pass 1번만 유일하게 작업을 끝낸다음 세마포어 signal을 보냈더니 let 이 출력 됐습니다.
그 이후엔 pass 1 다음으로 start한 pass 2에서 wait이 걸렸네요.
메서드는 동시에 호출 됐지만 세마포어 signal이 나올 때 까지 다른 작업 블록을 정지 시켜 버립니다. 세마포어는 이러한 방식으로 Thread-safe 하도록 만듭니다!

NSRecursiveLock

NSRecursiveLock은 Objective-C 에서 지원하던 레거시 방법입니다. 사용법은 세마포어와 다르지 않지만 결과 양상이 조금! 다릅니다.

let lock = NSRecursiveLock()

과 같이 선언 한 뒤

private func append(pass: Int) {
  print("start! \(#function) \(pass)")
  lock.lock()
  print("stop! \(#function) \(pass)")
  sharedResource.append(pass)
  print("task done! \(#function) \(pass)")
  lock.unlock()
  print("let! \(#function) \(pass)")
}

세마포어와 똑같죠? 그럼 출력 결과는 어떨까요?

start! append(pass:) 1
stop! append(pass:) 1
task done! append(pass:) 1
let! append(pass:) 1
start! append(pass:) 4
stop! append(pass:) 4
task done! append(pass:) 4
let! append(pass:) 4
start! append(pass:) 5
stop! append(pass:) 5
task done! append(pass:) 5
let! append(pass:) 5
start! append(pass:) 6
stop! append(pass:) 6
task done! append(pass:) 6
let! append(pass:) 6
start! append(pass:) 7
stop! append(pass:) 7
task done! append(pass:) 7
let! append(pass:) 7
start! append(pass:) 10
stop! append(pass:) 10
task done! append(pass:) 10
let! append(pass:) 10

unlock을 할때 까지 작업이 뭔가 되게 순차적이지만, 사실 이건 출력문의 일부분일 뿐입니다.
전체를 놓고 보면 세마포어와 다를 바 없지만 내부적으로 조금 다른 로직으로 굴러간다는 것 정도는 알 수 있겠네요

Advanced

사실 일일이 스레드 세이프한 코드를 작성해주기는 너무나 귀찮고 까먹기 쉬운 일입니다. 좀더 편한 방법을 찾아보자면 간단한 방법이 존재하는데요!
바로 값 자체를 스레드 세이프 하게 만들어 주는 것, Swift의 위대한 property wrapper가 있습니다.

@propertyWrapper
final class Atomic<T>: NSLock {
  var wrappedValue: T {
    get {
      lock()
      defer { unlock() }
      return value
    }

    _modify {
        lock()
        var tmp: T = value

        defer {
            value = tmp
            unlock()
        }

        yield &tmp
    }
  }
  private var value: T

  init(wrappedValue: T) {
    self.value = wrappedValue
  }
}

쫘란~

Conclusion

사실 우리에겐 이런것보다 더 강력하고 모던한 swift concurrency와 함께 도입된 actor가 있습니다.
swift concurrency 공부하세요!

Comments