내용전반: 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이란, 하나의 값에 여러 스레드가 '동시에' 접근하는 경우를 말합니다. 아래 예제를 보면서 동시에 접근하는 것이 왜 문제가 되는지 알아봅시다
두 개의 스레드를 하나의 값(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개의 스텝으로 이루어집니다
문제는 여러 스레드가 동시에 동작하고 있는 상황이라는 점입니다. 어떤 한 스레드가 2번 스텝을 진행 중이라 아직 store를 하지 않은 상황을 가정해봅시다. 이 상황에서 만약 다른 스레드가 a += 1
을 수행하기 위해 1번 스텝으로 진입하면 아직 store 되지 않은 예전의 a 값을 읽어버리게 됩니다
결국 operation은 2회 수행되었으나 값의 증가는 1회만 이루어져 기댓값보다 낮은 값이 출력되게 됩니다
두 개 이상의 스레드가 한 곳의 메모리(저장공간)에 동시에 접근하여 값을 사용하려고 하면 문제가 발생합니다
여러 프로세스/스레드가 공유 자원을 사용하는 상황에서, 어떤 한 스레드가 자원을 점유하고 있다면 해당 자원에 Lock을 걸어 다른 스레드가 접근하지 못하고 기다리도록 하는 방법을 사용하게 됩니다.
Deadlock은 이런 기다림 상태가 해소되지 못하고 다음 처리를 할 수 없는 상황을 말합니다. 어떤 경우에 Deadlock이 유발되는지 아래 예시들로 알아봅시다
이전 포스팅에서 언급했듯 아래 코드처럼 main 큐에서 main 큐로 sync로 작업을 맡긴다던지 하는 상황을 말합니다
DispatchQueue.main.sync {
...
}
(세마포어라던지) 자원에 lock을 구현한 경우 현재 작업이 끝날 때까지 자신이 점유한 자원을 놓아주지 않습니다. 아래 그림처럼 여러 스레드가 자원을 하나씩 점유한 상태에서 서로의 자원을 원하는 경우 Deadlock이 유발됩니다
서로 다른 우선순위를 가진 작업들이 들어왔을 때 낮은 우선순위의 작업이 먼저 완료되는 경우를 말합니다
서로 다른 우선순위를 가진 작업들이 시간 간격을 두고 들어오는 경우를 살펴봅시다. 이 때 낮은 우선순위의 작업이 먼저 들어와 자원에 lock을 걸어 사용하고 있을 때 우선 순위 뒤바뀜 현상이 발생할 수 있습니다 (이 외에도 발생 가능한 케이스가 많습니다)
Thread Explosion: How to avoid Thread Explosion and Deadlock with GCD in Swift?
Thread Explosion
는 OS가 함께 관리해야 하는 running 스레드 개수가 너무 많아지는 상황을 말합니다.이는 성능저하를 유발하며 경우에 따라 Deadlock이 발생할 수도 있습니다.
조금 더 자세하게 설명해보면, 먼저 우리는 스레드가 값싼 시스템 자원이 아니라는 것을 이해해야 합니다. 스레드는 OS에 의해 관리되며 만약 함께 관리해야 하는 running 스레드 개수가 너무 많아져 임계점을 넘으면 메모리 오버헤드와 지나친 컨텍스트 스위칭로 인한 스케줄링 오버헤드 등이 발생할 수 있습니다
GCD에서 Thread Explosion를 유발하는 익숙한 예시가 있습니다.
let group = DispatchGroup()
for iterator in 1...5000 {
DispatchQueue.global().async(group: group) {
sleep(1)
print("\(iterator) - Oh, No")
}
}
group.wait()
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()
Medium: Concurrency in Swift: Reader Writer Lock
Medium: Concurrency & Thread Safety in Swift
어떤 데이터에 여러 스레드가 동시에 접근을 시도하더라도 한 번에 하나의 스레드만 접근가능하도록 처리하여 Race condition 문제없이 사용할 수 있는 상태를 말합니다
사실 Lock 코드를 직접 구현하는 것은 올바르게 구현하기도 어렵고 Deadlock 가능성을 내포하므로 낮은 수준의 해결책이라고 합니다.
참고 : https://dmytro-anokhin.medium.com/concurrency-in-swift-reader-writer-lock-4f255ae73422
이번 포스팅에서는 GCD를 통해 데이터에 Lock을 직접적으로 걸지 않으면서 Thread-safe하게 만드는 3가지 방법을 다룹니다
Xcode에서 잠재적 Race Condition을 찾아주는 TSan(Thread Sanitizer tool)이라는 툴을 제공합니다. (앱스토어 출시 전 이를 확인하는 것이 반드시 필요합니다)
TSan 사용법
Edit Scheme에서 아래를 체크해주면 Xcode에서 Race condition에 대한 문구들을 띄워줍니다
WWDC(11분20초): Concurrent Programming With GCD in Swift 3
하나의 대상에 대해 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)
이전 섹션에서 다룬 '엄격한 Thread-safe 처리방식'은 Serial 큐를 사용함으로써 모든 작업의 동시성을 포기하게 됩니다. 하지만 엄밀히 따지면 '읽기' 작업은 thread-safe 처리하지 않아도 문제가 없으므로 성능을 해치지 않도록 동시 작업이 가능한 것이 좋습니다.
Dispatch Barrier를 활용하면 쓰기 작업은 thread-safe 처리하고, 읽기 작업은 동시 작업이 가능하도록 만들 수 있습니다
Dispatch Barrier는
Concurrent 큐
에서 특정 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()
(개념적인 부분은 이전 포스팅에서 이미 다루었으므로 생략합니다)
Semaphore의 value를 1로 설정하면 Barrier처럼 사용할 수 있습니다
지금까지 동시성에 의해 발생하는 문제들과 해결방향에 대해 알아보았습니다. 이쯤되면 우리가 어떤 타입을 설계할 때 여러 스레드에서 동시에 다뤄질 가능성이 있는지를 고려해야 하며, 필요하다면 Thread-safe하게 설계해야 함을 알 수 있습니다
프로퍼티들에 우리가 배운 Thread-safe 설계 방법들을 적용해 봅시다
❗️주의❗️
아래 예제들은 공부한 내용을 바탕으로 연습삼아 만들어본 코드이며, 특정 상황에서만 Thread-safe하므로 그대로 사용하기엔 무리가 있습니다
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
}
}
}
}
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
}
}
}
}
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"...?