본문 바로가기

Swift/GCD

Multithreading with GCD 3 - Concurrency Problems

 

Dispatch queue에는 앱에서 동시성을 구현할 때 발생할 수 있는 세 가지 잘 알려진 문제가 있습니다.

  • Race conditions (경쟁상태)
  • Deadlock (데드락)
  • Priority inversion (우선순위 역전)

Race conditions (경쟁상태)

앱 자체에서 동일한 프로세서를 공유하는 스레드는 같은 주소공간을 공유합니다. 이 의미는 각 스레드들이 같은 공유 리소스를 읽고 쓰려고 한다는 것입니다. 만약 주의를 하지 않으면 같은 타이밍에 같은 변수를 여러 스레드들이 수정하려고 하면서 race condition(경쟁상태)에 빠질 수 있습니다.

두 스레드가 실행중이라고 가정하고 두 스레드 모두 객체의 count 변수를 수정하려고 한다고 가정해 보겠습니다. 읽기와 쓰기는 컴퓨터가 단일 작업으로 실행할 수 없는 별도의 작업입니다. 컴퓨터는 클럭 사이클에서 작업을 실행할 수 있는 단위로 작동합니다.

 

💡 클럭 사이클(Clock cycle)은 컴퓨터의 기본적인 작업 단위 중 하나로, 컴퓨터 시스템에서 모든 처리 과정은 클럭 사이클에 따라 이루어집니다. iPhone XS는 2.49 GHz 프로세서가 탑재되어, 초당 2,490,000,000 클럭 사이클을 수행할 수 있습니다.

 

스레드 1과 스레드 2 가 모두 count를 업데이트하기를 원합니다. count를 업데이트하는 플로우는 다음과 같습니다.

  1. count 변수를 메모리에 로드
  2. 메모리에서 count 변수에 값을 하나 추가
  3. 업데이트된 count 를 디스크에 다시 쓰기

  스레드1 스레드2
1 count값 로드(value: 1) 아직 실행 X
2 count 업데이트 (value 1 → 2) count의 값을 로드 (value: 1)
3 value: 2를 다시 count 변수에 write count 업데이트 (value: 1 → 2)
4   value: 2를 다시 count 변수에 write

원래 스레드 1,2 모두 값을 업데이트하기 때문에 3이 되어야 하는데 2가 된 경우입니다.

이러한 경쟁상태의 경우, 비결정적인 성격 때문에 매우 복잡한 디버깅을 유발합니다. 만약 스레드 1이 두 클럭사이클 먼저, 혹은 스레드 2가 두 클럭사이클 나중에 실행됐다면, 예상대로 값이 3이 나왔겠지만, 1초에 어마어마한 클럭사이클을 수행하기 때문에 이는 불안정합니다.

이러한 경우, 일반적으로 직렬 큐를 통해 race condition을 해결할 수 있습니다. 만약 프로그램에서 병렬적으로 접근하는 변수가 있다면, private queue로 래핑을 하여 읽기와 쓰기를 실행합니다.

private let threadSafeCountQueue = DispatchQueue(label: "...")
private var _count = 0
public var count: Int {
  get {
    return threadSafeCountQueue.sync { 
      _count
    }
  }
  set {
    threadSafeCountQueue.sync { 
      _count = newValue
    }
  }
}

threadSafeCountQueue는 attributes를 명시하지 않았기 때문에 serial queue입니다.

이 의미는 한 번에 하나의 작업만 시작할 수 있음을 의미합니다. 따라서 변수에 대한 액세스를 제어하고 한 번에 하나의 스레드만 변수에 액세스 할 수 있도록 합니다.

 

💡 여러 스레드에서 실행될 수 있는 private queue를 lazy variable로 생성한 경우에도 동기화를 구현할 수 있습니다. 그렇지 않으면, 두 개의 lazy variable이 생성될 수 있습니다. 이 또한 위 예시와 비슷하게 두 번째 스레드에서 lazy variable에 액세스할 때, 첫번째 스레드에서 lazy variable을 초기화하지 않았다면 두번째 스레드 또한 lazy variable을 초기화하기 때문입니다. 이는 전형적인 경쟁 상태입니다.

 

Thread barrier

경우에 따라 공유 리소스에는 단순한 변수 수정 보다 getter, setter에 더 복잡한 로직을 요구합니다.

이것은 lock과 semaphore와 관련된 솔루션이 있습니다. Locking은 제대로 구현하기가 매우 어렵습니다. 대신 GCD에서 Apple의 dispatch barrier을 사용할 수 있습니다.

만약 concurrent queue를 생성하면, 동시에 실행할 수 있는 만큼 읽기 작업을 원하는 만큼 처리할 수 있습니다.

변수를 write 할 때, queue를 잠그고 제출된 모든 항목이 완료될 때까지 기다려야 합니다. 이 작업이 완료될 때까지 새로운 작업을 실행하지 않습니다.

private let threadSafeCountQueue = DispatchQueue(label: "...",
                                                 attributes: .concurrent)
private var _count = 0
public var count: Int {
  get {
    return threadSafeCountQueue.sync {
      return _count
    }
  }
  set {
    threadSafeCountQueue.async(flags: .barrier) { [unowned self] in
      self._count = newValue
    }
  }
}

Deadlock (교착상태)

데드락은 스위프트 프로그래밍에서 semaphores 나 다른 명시적 lock 메커니즘을 사용하지 않는 한 드물게 발생합니다. Dispatch queue에서 명시적으로 sync를 호출하는 것은 가장 흔히 데드락을 발생하는 일입니다.

  1. Thread1 은 Object1을 소유하고 있고, Thread2는 Object2를 소유하고 있습니다. → 각 Thread는 각 Object를 lock 한 상태입니다.
  2. Thread1은 Object2가 필요하고 Thread2는 Object1이 필요한 상황입니다.
  3. 하지만 각 Thread에서 lock 한 Object를 해제하지 않으면 Thread1은 Object2에, Thread2는 Object1에 접근하지 못하게 됩니다.
  4. 각 Thread에 필요한 Object의 lock 이 해제될 때까지 두 Thread모두 진행될 수 없기 때문에 교착상태가 발생합니다.

Priority inversion (우선순위 역전)

기술적으로, 우선순위 역전은 낮은 qos의 queue가 높은 qos의 queue보다 더 높은 system priority를 부여받을 때 발생합니다.

앞서 “Queues & Threads”에서 queue의 QoS는 바뀔 수 있다고 언급되어 있습니다. 일반적으로 queue에 작업을 제출하면, 그 작업은 queue의 priority를 부여받는데, 필요한 경우, 특정 작업의 우선순위가 일반적인 priority보다 높거나 낮게 지정될 수 있습니다.

만약 userInitiated queue와 utility queue를 사용하고 있는데 utility queue에 userInteractive 작업을 제출한다면, OS에 의해 utility queue가 더 높은 priority를 부여받을 것입니다. 따라서 userInitiated queue보다 utility queue가 더 일찍 마치게 됩니다.

이러한 우선순위가 역전되는 상황이 많이 발생하는 상황은 높은 QoS의 queue와 낮은 QoS의 queue가 자원을 공유할 경우 발생합니다. 낮은 QoS의 queue가 객체를 lock 하면, 더 높은 queue는 해당 객체의 lock이 해제될 때까지 기다려야 합니다. 따라서 우선순위의 역전이 발생합니다.

 

출처

https://www.kodeco.com/books/concurrency-by-tutorials/v2.0/chapters/5-concurrency-problems