iOS Concurrency: GCD - 3. 동시성 문제

J.Noma·2021년 12월 23일
1

iOS : 동시성

목록 보기
3/3

내용전반: iOS개발자 앨런
내용전반: 동시성 프로그래밍
내용전반: naljin님블로그

lazy var: The thread safety of lazy variables in Swift
Lock vs GCD: Concurrency in Swift: Reader Writer Lock
Thread-safe: Concurrency & Thread Safety in Swift
Priority Inversion: [iOS] 차근차근 시작하는 GCD — 15
QoS: Building Responsive and Efficient Apps with GCD
Thread Explosion: How to avoid Thread Explosion and Deadlock with GCD in Swift?


학습목표

  • 동시성과 관련한 문제에는 어떤 것들이 있는지 공부합니다
  • Thread-safe의 개념과 Thread-safe한 코드를 구현하는 방법을 공부합니다
  • 타입 설계 시 동시성을 고려해야 함을 이해합니다

Remind List

  • global 큐는 Barrier를 설정할 수 없다 (이 외에도 global에는 설정할 수 없는 기능들이 몇 있다고 한다)
  • 타입을 thread-safe하게 설계하였더라도 사용부에서도 조심해야 한다 (ex. 프로퍼티의 get/set을 thread-safe하게 만들더라도 사용부에서 += 이런걸 쓰면 결국 어긋난다)

🐶 동시성과 관련한 문제

여러 개의 스레드를 동시에 사용하면서 발생할 수 있는 문제에 대해 다룹니다.

✔️ Race Condition

▪️ 정의

Race Condition이란, 하나의 값에 여러 스레드가 '동시에' 접근하는 경우를 말합니다. 아래 예제를 보면서 동시에 접근하는 것이 왜 문제가 되는지 알아봅시다

▪️ 예제

두 개의 스레드를 하나의 값(a)에 대해 경쟁시켜 결과를 확인합니다. print되길 기대하는 값은 2000이지만 Race Condition이 발생하여 1986같은 2000미만의 값이 출력됩니다

var a = 0

DispatchQueue.global().async {
    for _ in (1...1000) {
        a += 1
    }
}
DispatchQueue.global().async {
    for _ in (1...1000) {
        a += 1
    }
}

sleep(1)
print(a) //매번 값이 다름

▪️ 예제 원인분석

a += 1이라는 Swift 코드는 기계어 레벨로 내려가면 대략 3개의 스텝으로 이루어집니다

  1. a의 현재값을 Load
  2. a에 1을 add
  3. 더한 값을 store

문제는 여러 스레드가 동시에 동작하고 있는 상황이라는 점입니다. 어떤 한 스레드가 2번 스텝을 진행 중이라 아직 store를 하지 않은 상황을 가정해봅시다. 이 상황에서 만약 다른 스레드가 a += 1을 수행하기 위해 1번 스텝으로 진입하면 아직 store 되지 않은 예전의 a 값을 읽어버리게 됩니다

결국 operation은 2회 수행되었으나 값의 증가는 1회만 이루어져 기댓값보다 낮은 값이 출력되게 됩니다

▪️ 결론

두 개 이상의 스레드가 한 곳의 메모리(저장공간)에 동시에 접근하여 값을 사용하려고 하면 문제가 발생합니다


✔️ Deadlock

▪️ 정의

여러 프로세스/스레드가 공유 자원을 사용하는 상황에서, 어떤 한 스레드가 자원을 점유하고 있다면 해당 자원에 Lock을 걸어 다른 스레드가 접근하지 못하고 기다리도록 하는 방법을 사용하게 됩니다.

Deadlock은 이런 기다림 상태가 해소되지 못하고 다음 처리를 할 수 없는 상황을 말합니다. 어떤 경우에 Deadlock이 유발되는지 아래 예시들로 알아봅시다

▪️ 예시1. Serial인 현재 큐가 자신에게 sync로 작업을 맡길 때

이전 포스팅에서 언급했듯 아래 코드처럼 main 큐에서 main 큐로 sync로 작업을 맡긴다던지 하는 상황을 말합니다

DispatchQueue.main.sync {
    ...
}

▪️ 예시2. lock을 구현한 경우에서 서로의 자원을 원할 때

(세마포어라던지) 자원에 lock을 구현한 경우 현재 작업이 끝날 때까지 자신이 점유한 자원을 놓아주지 않습니다. 아래 그림처럼 여러 스레드가 자원을 하나씩 점유한 상태에서 서로의 자원을 원하는 경우 Deadlock이 유발됩니다

▪️ 참고 (문제 해결 관련)

  • (단순한 방법으로는) Serial 큐를 사용함으로써 일부 해결할 순 있습니다
  • 세마포어 등 lock을 이 구현된 자원에 접근하는 작업은 순서를 조심히 사용/설계해야 합니다

✔️ 우선 순위 뒤바뀜

▪️ 정의

서로 다른 우선순위를 가진 작업들이 들어왔을 때 낮은 우선순위의 작업이 먼저 완료되는 경우를 말합니다

▪️ 예시

서로 다른 우선순위를 가진 작업들이 시간 간격을 두고 들어오는 경우를 살펴봅시다. 이 때 낮은 우선순위의 작업이 먼저 들어와 자원에 lock을 걸어 사용하고 있을 때 우선 순위 뒤바뀜 현상이 발생할 수 있습니다 (이 외에도 발생 가능한 케이스가 많습니다)

▪️ 참고 (문제 해결 관련)

  • 이런 문제에 대해 1차적으로는 GCD가 직접적으로 개입합니다. qos가 낮은 작업의 qos를 올려서 처리합니다
  • (lock이 필요한) 공유 자원에 접근하는 작업 간에는 동일한 QoS를 사용하는게 우선순위 문제를 줄일 수 있습니다

✔️ Thread Explosion

Thread Explosion: How to avoid Thread Explosion and Deadlock with GCD in Swift?

▪️ 정의

Thread Explosion는 OS가 함께 관리해야 하는 running 스레드 개수가 너무 많아지는 상황을 말합니다.이는 성능저하를 유발하며 경우에 따라 Deadlock이 발생할 수도 있습니다.

조금 더 자세하게 설명해보면, 먼저 우리는 스레드가 값싼 시스템 자원이 아니라는 것을 이해해야 합니다. 스레드는 OS에 의해 관리되며 만약 함께 관리해야 하는 running 스레드 개수가 너무 많아져 임계점을 넘으면 메모리 오버헤드와 지나친 컨텍스트 스위칭로 인한 스케줄링 오버헤드 등이 발생할 수 있습니다

▪️ 예시

GCD에서 Thread Explosion를 유발하는 익숙한 예시가 있습니다.

  1. Concurrent 큐에 매우 많은 async 작업을 요청 (그만큼 스레드가 할당됩니다)
  2. 이 스레드들이 block되거나 코어가 이미 열일 중이라 처리되지 못하고 기다림
    (ex. 다른 큐로 sync 작업을 요청한다던지, system call을 기다리는 등)
  3. 많은 요청들이 처리되지 못해 running 스레드 개수가 매우 많아짐
let group = DispatchGroup()

for iterator in 1...5000 {
    DispatchQueue.global().async(group: group) {
        sleep(1)
        print("\(iterator) - Oh, No")
    }
}

group.wait()

▪️ 해결방법

  • 많은 스레드들이 block 되지 않도록 주의합니다
  • 세마포어 등으로 스레드 할당 개수를 제한합니다

✔️ Lazy var의 초기화

Medium: The thread safety of lazy variables in Swift

▪️ 문제정의

lazy var로 선언된 프로퍼티는 타입 인스턴스 초기화 시점이 아닌 '해당 프로퍼티가 처음으로 사용되는 시점'에 초기화가 이루어집니다. 만약 이 첫 사용 시점을 여러 스레드가 동시에 수행한다면 어떻게 될까요?

결국 lazy var 프로퍼티에 대한 초기화가 여러 스레드에서 동시다발적으로 발생하게 되어 초기화 자체가 여러 번 이루어지게 됩니다. 하지만 우리는 lazy var의 원래 의도와 동일하게 처음 한 번 초기화된 이후부터는 새로 초기화하지 않고 그 값을 계속 사용하기를 원합니다

▪️ 예시

아래 코드를 실행하면 lazy var가 여러 번 초기화되면서 서로 다른 값들이 출력됩니다

class ABC {
    lazy var testVar = Int.random(in: 0...99)
}

let group = DispatchGroup()
let concurrentQueue = DispatchQueue(
                    	label: "com.raywenderlich.number.isolation",
                    	attributes: [.initiallyInactive, .concurrent]
                      )
let instanceABC = ABC()

for _ in (1...5) {
  concurrentQueue.async(group: group) {
      print(instanceABC.testVar)
  }
}
concurrentQueue.activate()
group.wait()

▪️ 해결방법

  • lazy var를 Thread-safe하게 설계합니다 (Serial 큐, Barrier, Semaphore 등)
  • 미리 초기화되도록 dummy 코드를 넣는 방법도 있습니다


🐔 Race Condition을 피하는 방법 (= Thread-Safe)

Medium: Concurrency in Swift: Reader Writer Lock
Medium: Concurrency & Thread Safety in Swift

✔️ 배경지식

▪️ Thread-safe란?

어떤 데이터에 여러 스레드가 동시에 접근을 시도하더라도 한 번에 하나의 스레드만 접근가능하도록 처리하여 Race condition 문제없이 사용할 수 있는 상태를 말합니다

▪️ Lock 코드를 구현하면 되는건가?

사실 Lock 코드를 직접 구현하는 것은 올바르게 구현하기도 어렵고 Deadlock 가능성을 내포하므로 낮은 수준의 해결책이라고 합니다.
참고 : https://dmytro-anokhin.medium.com/concurrency-in-swift-reader-writer-lock-4f255ae73422

이번 포스팅에서는 GCD를 통해 데이터에 Lock을 직접적으로 걸지 않으면서 Thread-safe하게 만드는 3가지 방법을 다룹니다

▪️ Xcode에서 Thread Sanitizer tool를 제공한다

Xcode에서 잠재적 Race Condition을 찾아주는 TSan(Thread Sanitizer tool)이라는 툴을 제공합니다. (앱스토어 출시 전 이를 확인하는 것이 반드시 필요합니다)

TSan 사용법
Edit Scheme에서 아래를 체크해주면 Xcode에서 Race condition에 대한 문구들을 띄워줍니다


✔️ 방법1. 단일 Serial 큐 사용 (엄격한 방법)

WWDC(11분20초): Concurrent Programming With GCD in Swift 3

▪️ 대상에 대해 하나의 Serial 큐가 관할하도록

하나의 대상에 대해 Concurrent 큐를 사용하거나 여러 개의 Serial 큐를 사용하면 여러 스레드가 동시에 접근하는 경우가 발생합니다. 이를 근본적으로 막기 위해 '하나의 Serial 큐'가 관할하도록 구현합니다

▪️ 예제

실제 용도는 이런 for문 형태가 아니겠지만 코드가 길어지므로 단순 예제로..
여러 스레드에서 shared에 접근하고 있지만 Thread-safe가 실현되고 있습니다

var shared: Int = 0
let queue = DispatchQueue(label: "serialQueue")
let group = DispatchGroup()

DispatchQueue.global().async(group: group) {
  for _ in (1...500) {
      queue.async(group: group) {
          shared += 1
      }
  }
}
DispatchQueue.global().async(group: group) {
  for _ in (1...500) {
      queue.async(group: group) {
          shared += 1
      }
  }
}
group.wait()

print(shared)

✔️ 방법2. Concurrent 큐 + Barrier 사용 (보다 효율적인 방법)

▪️ 필요한 작업만 선택적으로 thread-safe 처리합니다

이전 섹션에서 다룬 '엄격한 Thread-safe 처리방식'은 Serial 큐를 사용함으로써 모든 작업의 동시성을 포기하게 됩니다. 하지만 엄밀히 따지면 '읽기' 작업은 thread-safe 처리하지 않아도 문제가 없으므로 성능을 해치지 않도록 동시 작업이 가능한 것이 좋습니다.

Dispatch Barrier를 활용하면 쓰기 작업은 thread-safe 처리하고, 읽기 작업은 동시 작업이 가능하도록 만들 수 있습니다

▪️ Dispatch Barrier

Dispatch BarrierConcurrent 큐에서 특정 Task를 수행할 때는 다른 스레드들이 일을 하지 못하도록 막는 메커니즘을 말합니다

  • Concurrent 큐에서만 사용할 수 있습니다
  • global 큐에는 설정할 수 없습니다 (custom concurrent 큐에서만 가능)
  • 막는 스레드 대상은 'Concurrent 큐 자신이 관할하는 스레드'들입니다.

아래 그림과 같이 Dispatch Barrier가 설정된 Task를 수행할 때는
(1) 다른 스레드들이 수행중인 기존 작업이 있다면 완료할 때까지 기다립니다
(2) Barrier Task를 수행합니다
(3) 완료되면, 다시 다른 스레드들이 작업할 수 있도록 허용합니다

▪️ 예제

var shared: Int = 0
let group = DispatchGroup()
let concurrentQueue = DispatchQueue(label: "barrierTestQueue", attributes: .concurrent)

for _ in (1...30) {
  concurrentQueue.async(group: group) {
      print(shared, terminator: "")
  }
}
concurrentQueue.async(group: group, flags: .barrier) {
    print("")
    print("Barrier In")
    shared += 1
    sleep(3)
    print("Barrier Out")
}
for _ in (1...30) {
    concurrentQueue.async(group: group) {
        print(shared, terminator: "")
    }
}

group.wait()

✔️ 방법3. Dispatch Semaphore 사용 (Barrier와 유사)

(개념적인 부분은 이전 포스팅에서 이미 다루었으므로 생략합니다)

Semaphore의 value를 1로 설정하면 Barrier처럼 사용할 수 있습니다



🦊 실습: Thread-safe 타입 설계하기

지금까지 동시성에 의해 발생하는 문제들과 해결방향에 대해 알아보았습니다. 이쯤되면 우리가 어떤 타입을 설계할 때 여러 스레드에서 동시에 다뤄질 가능성이 있는지를 고려해야 하며, 필요하다면 Thread-safe하게 설계해야 함을 알 수 있습니다

✔️ 예제

프로퍼티들에 우리가 배운 Thread-safe 설계 방법들을 적용해 봅시다

❗️주의❗️
아래 예제들은 공부한 내용을 바탕으로 연습삼아 만들어본 코드이며, 특정 상황에서만 Thread-safe하므로 그대로 사용하기엔 무리가 있습니다

▪️ 방법1. 단일 Serial 큐 사용

class ThreadSafeType {
    private var _myProperty: Int

    private let queue = DispatchQueue(label: "serialIsDelicious")
    
    var myProperty: Int {
        get {
            return queue.sync {
                _myProperty
            }
        }
        set {
            queue.sync {
                _myProperty = newValue
            }
        }
    }
}

▪️ 방법2. Dispatch Barrier 사용

class ThreadSafeType {
    private var _myProperty: Int

    private let queue = DispatchQueue(
                        label: "concurrentQueue",
                        attributes: .concurrent
                      )
    
    var myProperty: Int {
        get {
            return queue.sync {
                _myProperty
            }
        }
        set {
            queue.async(flags: .barrier) {
                self._myProperty = newValue
            }
        }
    }
}

▪️ 방법3. Dispatch Semaphore 사용

class ThreadSafeType3 {
    private var _myProperty: Int

    private let queue = DispatchQueue(
                        label: "concurrentQueue",
                        attributes: .concurrent
                      )
    private let semaphore = DispatchSemaphore(value: 1)
    
    var myProperty: Int {
        get {
            return queue.sync {
                _myProperty
            }
        }
        set {
            queue.async {
                self.semaphore.wait() 
                self._myProperty = newValue
                self.semaphore.signal()
            }
        }
    }
}

✔️ 타입을 이렇게 설계하더라도 사용에는 주의가 필요합니다

위 예제에서처럼 myProperty에 대해 get/set을 thread-safe하게 설계하더라도, 아래 코드처럼 사용시점에 get의 결과를 set한다던지 하는 경우엔 결국 싱크가 맞지 않게 됩니다

꼭 아래와 같이 사용해야 하는 경우라면, increment 작업(읽고 쓰기)을 통째로 atomic하게 수행할 수 있는 별도의 메서드를 만든다던지 등의 방법으로 해결해야 합니다.

let queue = DispatchQueue(label: "queueForSemaTest", attributes: .concurrent)
let sema = DispatchSemaphore(value: 1)
var sum = ThreadSafeType()

for _ in (1...100) {
    queue.async {
        sum.myProperty += 1
    }
}

sleep(1)

print(sum.myProperty)
// print "12"...?


GCD 정리

profile
노션으로 이사갑니다 https://tungsten-run-778.notion.site/Study-Archive-98e51c3793684d428070695d5722d1fe

0개의 댓글