-
동시성 프로그래밍(6) Race Condition(경쟁 상태) 해결 방법iOS 2022. 3. 9. 20:14
안녕하세요.
저번 시간에는 동시성 프로그래밍을 하면서 발생할 수 있는 문제들에 대해서 알아보았는데요.
이번 시간에는 그러한 문제들을 해결하여 Thread-Safe한 코드를 작성하는 방법을 알아보겠습니다.
TSan(Thread Sanitizer)
Xcode는 Thread Sanitizer라는 기능을 제공합니다.
Sanitizer라는 단어가 낯설어서 찾아보니 살균제, 불쾌한 부분을 제거하다라는 뜻을 가지고 있더군요.
이름처럼 Thread Sanitizer는 Thread를 사용함에 있어서 불쾌한 부분, 즉 경쟁 상태가 발생하는 부분을 찾아줍니다.
사용 방법은 매우 간단합니다.
Product -> Scheme -> Edit Scheme -> Run -> Diagnostics에서 Thread Sanitizer를 체크하면 됩니다.
이 기능을 사용하면 빌드 시간이 길어질 수 있으니 사용 후에는 다시 꺼주어야 합니다.
직접 경쟁 상태가 발생하는 코드를 통해 확인해보겠습니다.
import UIKit final class ViewController: UIViewController { private var sharedArray = [1, 2, 3, 4, 5] // 공유 자원 override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.removeLast() } concurrentQueue.async { self.last() } } func last() { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } func removeLast() { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } }
공유 자원인 sharedArray의 마지막 원소를 출력하는 last() 메서드와 마지막 원소를 제거하는 removeLast() 메서드가 Concurrent한 Queue에서 비동기적으로 실행되고 있습니다.
따라서 서로 다른 두 개의 스레드에서 동시에 공유자원에 접근할 수 있기 때문에 경쟁 상태가 발생할 수 있겠죠?위의 코드를 Thread Sanitizer를 켜고 실행시키면
경쟁 상태가 나타나는 곳을 찾아 알려줍니다.
thread9와 thread3에서 동시에 접근하고 있다고 알려줍니다.
Thread Sanitizer를 사용하면 이러한 부분을 발견하고 해결할 수 있겠죠?
Thread Sanitizer를 사용한다고 항상 경쟁 상태를 발견하지는 못하는 것을 확인했습니다.
Thread Sanitizer를 맹신하기보다는 경쟁 상태를 이해하고 방지하는 것이 중요하다 생각합니다.
경쟁 상태가 발생하는 부분, 즉 Thread-Safe하지 않은 곳을 찾아내는 방법을 알아봤으니
이제 Thread-Safe한 코드를 작성하는 방법을 알아봅시다.
1. NSLock
https://developer.apple.com/documentation/foundation/nslock
NSLock은 Foundation이 제공하는 멀티 스레드의 작업을 조정하기 위한 객체입니다.
lock() 메서드를 통해 Thread가 lock을 획득할 때까지 스레드를 Block 할 수 있습니다.
unlock() 메서드를 통해 lock을 해제합니다.
공유 자원에 접근할 때 lock(), 완료되면 unlock()을 호출하면
한 번에 하나의 스레드만 자원에 접근할 수 있으므로 Thread-Safe한 코드를 작성할 수 있습니다.
코드를 수정해보겠습니다.
import UIKit final class ViewController: UIViewController { private let lock: NSLock = NSLock() private var _sharedArray = [1, 2, 3, 4, 5] // 공유 자원 private(set) var sharedArray: [Int] { get { self.lock.lock() defer { self.lock.unlock() } return _sharedArray } set { self.lock.lock() self._sharedArray = newValue self.lock.unlock() } } override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.removeLast() } concurrentQueue.async { self.last() } } func last() { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } func removeLast() { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } }
NSLock 객체를 생성하고
getter와 setter에서 공유자원에 접근할 때 lock() 메서드를 호출하고 작업을 완료하면 unlock() 메서드를 호출합니다.
하나의 스레드가 공유자원에 접근하고 있을 때 다른 스레드가 lock을 획득하지 못하기 때문에 한 번에 하나의 스레드만이 접근할 수 있습니다.
Thread-Safe하다고 볼 수 있습니다.
2. DispatchSemaphore
DispatchSemaphore 또한 NSLock과 용도로 사용할 수 있습니다.
접근 가능한 Thread의 수를 1개로 제한하는 것이죠.
import UIKit final class ViewController: UIViewController { private let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) private var _sharedArray = [1, 2, 3, 4, 5] // 공유 자원 private(set) var sharedArray: [Int] { get { self.semaphore.wait() defer { self.semaphore.signal() } return _sharedArray } set { self.semaphore.wait() self._sharedArray = newValue self.semaphore.signal() } } override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.removeLast() } concurrentQueue.async { self.last() } } func last() { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } func removeLast() { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } }
NSLock과 DispatchSemaphore와 같이 Lock을 사용하여 공유자원에 접근을 제한하는 방법을 알아보았습니다.
그러나 이러한 방법은 교착 상태를 유발할 가능성이 있습니다.
두 개 이상의 Lock을 사용하는 경우를 생각해봅시다.
import UIKit final class ViewController: UIViewController { private let lockA: NSLock = NSLock() private let lockB: NSLock = NSLock() override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.lockA.lock() self.funcA() self.lockB.lock() self.funcB() self.lockA.unlock() self.lockB.unlock() } concurrentQueue.async { self.lockB.lock() self.funcB() self.lockA.lock() self.funcA() self.lockB.unlock() self.lockA.unlock() } } func funcA() { print(#function) } func funcB() { print(#function) } }
funcA()와 funcB()가 한 번에 하나의 스레드만이 접근해야 하는 임계 구역(Critical Section)이라고 할 때,
각각의 메서드를 위해 lockA, lockB가 필요합니다.
두 개의 함수를 모두 필요로 하는 작업 두 개가 존재할 때, 두 개의 작업이 서로가 서로의 lock이 해제되기를 무한정 기다리는 상황이 발생하는 것이죠.
이전 포스트에서 교착 상태를 설명하면서 봤던 상황 말이죠.
따라서 Thread-Safe 한 코드를 작성하는 또 다른 방법을 알아보겠습니다.
3. SerialQueue + Sync
SerialQueue는 한 번에 하나의 작업만을 순차적으로 실행시키는 직렬 큐입니다.
공유자원에 접근하는 작업을 동일한 SerialQueue에서만 실행시킨다면??
한 번에 한 개의 스레드만이 공유자원에 접근하겠죠?
또한 Async가 아닌 Sync로 작업을 실행하게 되면 모든 스레드에서 일관된 값을 얻을 수 있습니다.
import UIKit final class ViewController: UIViewController { private let serialQueue: DispatchQueue = DispatchQueue.init(label: "com.hanjun.serial") private var sharedArray = [1, 2, 3, 4, 5] // 공유 자원 override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.serialQueue.sync { self.removeLast() } } concurrentQueue.async { self.serialQueue.sync { self.last() } } // Here concurrentQueue.async { self.serialQueue.async { self.removeLast() } print(self.sharedArray) //[1,2,3,4] } } func last() { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } func removeLast() { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } }
만약 작업을 sync 하게 실행시키지 않는다면
concurrentQueue에서 실행시킨 sharedArray를 출력하는 작업이 즉시 반환되어
removeLast()가 실행되기 이전 결과인 [1,2,3,4]가 출력됩니다.
import UIKit final class ViewController: UIViewController { private let serialQueue: DispatchQueue = DispatchQueue.init(label: "com.hanjun.serial") private var sharedArray = [1, 2, 3, 4, 5] // 공유 자원 override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.serialQueue.sync { self.removeLast() } } concurrentQueue.async { self.serialQueue.sync { self.last() } } // Here concurrentQueue.async { self.serialQueue.sync { self.removeLast() } print(self.sharedArray) //[1,2,3] } } func last() { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } func removeLast() { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } }
하지만 sync로 작업을 진행하게 되면 작업을 보내는 스레드가 보낸 작업이 완료될 때까지 기다리기 때문에
removeLast()가 실행된 후의 결과인 [1,2,3]을 얻을 수 있습니다.
물론 현재 스레드가 메인 스레드일 경우에는 sync로 작업을 보내면 안 됩니다.
이유는 이곳에서 알아보았습니다.
4. DispatchBarrier
SerialQueue + Sync를 사용하게 되면 매우 엄격한 Thread-Safe 코드를 작성하는 방법을 알아봤습니다.
읽기 작업과 쓰기 작업 모두 한 번에 하나씩만 실행하기에 매우 안전합니다.
하지만 조금 비효율적이라는 생각이 들기도 합니다.
읽기 작업의 경우에는 동시에 실행이 되어도 공유 자원의 영향을 끼치지 않기 때문에 Thread-Safe 하기 때문입니다.
즉 Concurrent Queue를 사용하면서 특정 작업에 경우에만 Serial하게 동작시키고 싶은 거죠.
이럴 경우 사용할 수 있는 것이 Dispatch Barrier입니다.
https://developer.apple.com/documentation/dispatch/dispatch_barrier/
사용 방법은 간단합니다.
DispatchQueue의 async 메서드 중 위와 같이 DispatchWorkItemFlag를 요구하는 메서드가 존재합니다.
그중 barrier 변수를 넣어주면 Dispatch Barrier를 사용할 수 있습니다.
이렇게 작업을 실행하게 되면
큐에 이미 들어있던 작업들이 모두 완료되고 나서 한 개의 스레드에서 해당 작업이 실행됩니다.
다른 스레드는 해당 작업이 완료될 때까지 Block 됩니다
코드를 통해 살펴보겠습니다.
import UIKit final class ViewController: UIViewController { private var sharedArray = [1, 2, 3, 4, 5] // 공유 자원 private let secondConcurrentQueue = DispatchQueue.init(label: "com.hanjun.secondConcurrent", attributes: .concurrent) override func viewDidLoad() { super.viewDidLoad() let concurrentQueue = DispatchQueue.init(label: "com.hanjun.concurrent", attributes: .concurrent) concurrentQueue.async { self.removeLast() } concurrentQueue.async { self.last() } } func last() { self.secondConcurrentQueue.sync { print("Start last()") print(self.sharedArray.last ?? 0) print("End last()") } } func removeLast() { self.secondConcurrentQueue.async(flags: .barrier) { print("Start removeLast()") self.sharedArray.removeLast() print("End removeLast()") } } }
last() 메서드와 removeLast() 메서드를 확인하면 됩니다.
last() 메서드의 경우 읽기 작업이기 때문에 sync로 작업을 실행합니다.(async로 작업을 실행하지 않는 이유는 값을 즉시 반환하면 안 되고 정확한 값을 읽어야 하기 때문입니다.)
removeLast() 메서드의 경우는 쓰기 작업이기 때문에 경쟁 상황이 발생해서는 안됩니다.
따라서 barrier를 사용해 removeLast() 메서드만이 실행되도록 합니다.
위와 같이 Dispatch Barrier를 사용하면 Concurrent Queue를 사용해도 Thread-Safe 한 코드를 작성할 수 있습니다.
오늘은 Thread-Safe한 코드를 작성하는 방법을 알아보았습니다.
이제 객체를 설계할 때 항상 해당 객체가 메인 스레드가 아닌 다른 스레드에서 접근하는 경우가 있는지 고려해야 한다는 것을 알게 되었습니다.
만약 다른 스레드에서 접근해야 한다면 위의 방법들을 이용하여 Thread-Safe 한 코드를 작성할 수 있도록 해야 합니다.
Ref.
https://www.inflearn.com/course/iOS-Concurrency-GCD-Operation/dashboard
'iOS' 카테고리의 다른 글
UIResonder & Responder Chain (0) 2022.03.17 동시성 프로그래밍(7) Operation (0) 2022.03.15 동시성 프로그래밍(5) Concurrency Problems(동시성과 관련된 문제들) (0) 2022.02.17 동시성 프로그래밍(4) DispatchGroup, DispatchWorkItem, DispatchSemaphore (0) 2022.02.14 동시성 프로그래밍(3) DispatchQueue (0) 2022.02.10