-
Chapter 5: Concurrency ProblemsRaywenderlich/Concurrency by Tutorials 2020. 7. 9. 01:15
dispatch queues는 많은 이점(benefits)이 있지만, 불행하게도 모든 성능(performance) 문제에 대한 만병 통치약(panacea)은 아니다. 앱에서 동시성(concurrency)을 구현할 때, 주의하지 않으면 발생할 수 있는 잘 알려진 세 가지 문제가 있다:
- 경쟁 상태(Race conditions)
- 교착 상태(Deadlock)
- 우선 순위 역전(Priority inversion)
Race conditions
앱 자체를 포함하여 동일한 프로세스(process)를 공유하는 스레드(threads)는 동일한 주소(address) 공간(space)을 공유(share)한다. 이것은 각 스레드(thread)가 동일한 공유 리소스를 읽고(read) 쓰려(write)고 한다는 것을 의미한다. 조심하지 않으면 여러 스레드(threads)가 동시에 동일한 변수(variable)에 쓰려고(write) 하는 경쟁 상태(race conditions)에 처할 수 있다.
두 개의 스레드(threads)가 실행 중이고, 둘 다 객체(object)의 개수(count) 변수(variable)를 업데이트 하려는 예를 생각해본다. 읽기(reads)와 쓰기(writes)는 컴퓨터가 단일(single) 작업(operation)으로 실행할 수 없는 별도의 작업이다. 컴퓨터는 클럭(clock)의 각 틱(tick)이 단일 작업을 실행할 수 있는 클럭 사이클(clock cycles)에서 작동한다.
컴퓨터의 클럭 사이클(clock cycle)을 시계와 혼동해선 안 된다. 아이폰 XS는 2.49GHz 프로세서를 가지고 있다. 이는 초당 2,490,000,000회의 클럭 사이클(clock cycle)을 수행할 수 있다는 것을 의미한다.
스레드(thread) 1과 스레드(thread) 2는 모두 개수(count)를 업데이트하려 하므로, 다음과 같이 코드를 작성할 수 있다:
count += 1
이 구문을 구성 요소(component)로 세분화하고, 약간의 설명(hand-waving)을 추가하면 다음과 같다:
- count 변수의 값을 메모리에 로드(load)한다.
- 메모리에서 count 값을 1 씩 증가(increment)시킨다.
- 새로 업데이트 된 count를 디스크에 다시 쓴다(write).
위의 그림(graphic)은 다음을 보여준다:
- 스레드(thread) 1은 스레드(thread) 2 이전에 클럭 사이클(clock cycle)을 시작(kicked off)하고, count 변수에서 값 1을 읽는다(read).
- 두 번째 클럭 사이클(clock cycle)에서 스레드(thread) 1은 메모리 내 값을 2로 업데이트하고, 스레드(thread) 2는 count에서 값 1을 읽는다(read).
- 세 번째 클록 사이클(clock cycle)에서 스레드(thread) 1은 이제 count 변수에 값 2를 다시 쓴다(writes). 그러나 스레드(thread) 2는 이제 메모리 내 값을 1에서 2로 업데이트하고 있다.
- 네 번째 클록 사이클(clock cycle)에서 스레드(thread) 2도 이제 count에 값 2를 쓴다(writes). 두 개의 개별 스레드(thread)가 모두 값을 업데이트했기 때문에, 값 3을 예상했지만, 결과는 이와 달라졌다.
이러한 유형의 경쟁 상태(race condition)는 비 결정적(non-deterministic ) 특성(nature)으로 인해, 매우 복잡한 디버깅(debugging)으로 이어진다.
스레드(thread) 1이 두 클럭 사이클(clock cycles) 전에 시작됐다면 예상대로 3의 값을 가질 수 있지만, 초당 클럭 사이클(clock cycles) 수를 잊지 말아야 한다.
프로그램을 20번 실행(run)하여 올바른 결과를 얻은 다음, 배포(deploy)하고 버그 보고서(bug reports)를 받을 수도 있다.
일반적으로 경쟁 상태(race conditions)가 발생한다는 것을 알 수 있다면, 연속 대기열(serial queue)로 해결할 수 있다. 프로그램에서 동시(concurrently)에 접근(accessed)해야 하는 변수(variable)가 있는 경우, 다음과 같이 private 대기열(queue)로 읽기(reads) 및 쓰기(writes)를 랩핑(wrap)할 수 있다:
private let threadSafeCountQueue = DispatchQueue(label: "...") //attribute에서 따로 concurrent를 지정하지 않으면 동기 큐가 된다. private var _count = 0 public var count: Int { get { return threadSafeCountQueue.sync { _count } } set { threadSafeCountQueue.sync { //동기 _count = newValue } } }
threadSafeCountQueue는 직렬 대기열(serial queue)이다.
즉, 한 번에 하나의 작업만 시작할 수 있다. 따라서 변수(variable)에 대한 접근(access)을 제어(controlling)하고, 한 번에 하나의 스레드(thread)만 변수에 접근(access)할 수 있다. 위와 같이 간단한 읽기(read) / 쓰기(write)를 수행하고 있다면 이것이 최선의 해결책이다.
여러 스레드(threads)에 대해 실행될 수 있는 지연(lazy) 변수(variables)에 대해 동일한 private 대기열(queue) 동기화(sync)를 구현할 수 있다. 그렇지 않으면 두 개의 지연(lazy) 변수(variable) 초기화(initializer)가 실행될 수 있다. 이전의 변수 할당과 마찬가지로 두 스레드(threads)는 거의 동일한 시간에 동일한 지연(lazy) 변수(variable)에 접근(access)하려고 시도할 수 있다. 두 번째 스레드(thread)가 지연(lazy) 변수(variable)에 접근(access)하려고 시도하면, 아직 초기화(initialized)되지 않았지만 첫 번째 스레드(thread)의 접근(access)으로 생성된다. 고전적인 경쟁 상태(race conditions)이다.
Thread barrier
때때로 공유(shared) 리소스(resource)는 단순한 변수(variable) 수정(modification)보다 더 복잡한 논리를 getters와 setters에 필요로 한다. 온라인 커뮤니티에서 이와 관련된 질문을 자주 볼 수 있으며, 잠금(locks) 및 세마포어(semaphores)와 관련된 해결책(solution)이 제시되는 경우가 많다. 잠금(locking)은 제대로 구현하기 매우 어렵다. 대신 GCD의 dispatch barrier를 해결책(solution)으로 사용할 수 있다.
동시(concurrent) 대기열(queue)를 작성하면, 동시에 실행할 수 있는 만큼의 읽기(read) 유형 작업을 처리(process)할 수 있다.
변수(variable)를 기록(written)해야 하는 경우에는 이미 제출(submitted)된 모든 것이 완료되도록 대기열을 대기열을 잠궈(lock)야 한다. 그래야 업데이트를 완료(completes)할 때까지 새로운 제출(submissions)이 실행(run)되지 않는다.
다음과 같이 dispatch barrier를 구현한다:
private let threadSafeCountQueue = DispatchQueue(label: "...", attributes: .concurrent) //비동기 private var _count = 0 public var count: Int { get { return threadSafeCountQueue.sync { _count } } set { threadSafeCountQueue.async(flags: .barrier) { [unowned self] in //비동기 //완료될 때까지 barrier의 작업이 직렬처럼 실행된다. self._count = newValue } } }
동시(concurrent) 대기열(queue)을 사용하고, 쓰기(writes)를 barrier로 구현한다. 이전의 모든 읽기(reads)가 완료(completed)될 때까지 barrier 작업은 수행되지 않는다.
barrier에 도달하면 대기열(queue)은 직렬(serial)인 것처럼 가정하고, 완료(completion)될 때까지 barrier의 작업만 실행할 수 있다. 그것이 완료되면, 완료되면 barrier 작업 이후의 제출(submitted)된 모든 작업을 다시 동시(concurrently)로 실행할 수 있다.
Deadlock
맑고 화창한 날에 2차선 도로를 주행하여 목적지에 도착한다고 가정해 본다. 목적지가 도로 반대편에 있으면 자동차의 방향 지시등(turn signal)을 켠다. 이때, 반대 방향의 많은 교통량(traffic)이 해소될 때까지 대기해야 한다.
기다리는 동안 5대가 자신의 차 뒤에 줄지어(line up) 있다. 방향 지시등(turn signal)을 켠 채 반대쪽에서 오는 차를 살펴보게 된다. 몇 대의 차가 지나가면 교통량이 해소되겠지만, 지금은 지나갈 수 없는 상태이다.
불행히도 반대편의 차선에서 방향을 바꾸려는 자동차가 있다면, 반대편 차선도 정체되어 대기하는 차가 늘어난다. 목적지(destination)로 방향을 바꾸고 차선을 비우는 능력이 차단된다.
둘 다 결코 완료할 수 없는 다른 작업을 기다리는(waiting) 중이므로 교착 상태(deadlock)에 빠진다. 자동차가 목적지 입구를 막고(blocking) 있어 어느 쪽도 방향을 바꿀 수 없다.
교착 상태(deadlock)는 세마포어(semaphores) 또는 기타 명시적인 잠금(locking) 메커니즘(mechanisms) 등을 사용하지 않는 한, Swift 프로그래밍에서 거의 발생하지 않는다. 실수로 현재(current) dispatch queue에 대해 동기화(sync)를 호출하는 것이 가장 일반적인 경우이다.
세마포어(semaphores)를 사용하여 여러 리소스(resources)에 대한 접근(access)을 제어하는 경우, 동일한 순서(order)로 리소스(resources)를 요청(ask)해야 한다. 스레드(thread) 1이 망치(hammer)와 톱(saw)의 순서로 요청(requests)하고, 스레드(thread) 2가 톱(saw)과 망치(hammer)순으로 요청(requests)하면, 교착 상태(deadlock)가 발생할 수 있다. 스레드(thread) 1은 망치(hammer)를 요청(requests)하고 동시에 망치를 수신(receives)하며, 스레드(thread) 2는 톱(saw)을 요청(requests)하고 받는다(receives). 그런 다음 스레드(thread) 1은 망치(hammer)를 해제하지(releasing)않고, 톱(saw)을 요청(requests)하지만, 스레드(thread) 2가 리소스(resource)를 소유(owns)하므로 스레드(thread) 1은 기다려야(wait) 한다. 스레드 2는 톱(saw)을 요청(asks)하지만 스레드(thread) 1은 여전히 리소스(resource)를 소유(owns)하므로, 스레드(thread) 2는 톱(saw)이 사용 가능할 때까지 기다려야(wait) 한다. 두 스레드(thread)는 교착 상태(deadlock)에 빠지지지만, 요청(requested)된 리소스(resources)가 해제(freed)되지 않으므로 더 진행되지 않는다.
Priority inversion
기술적으로 말하자면, 우선 순위 역전(priority inversion)은 서비스 품질(quality of service)이 낮은 대기열(queue)에 높은 시스템 우선순위(system priority) 또는 QoS(quality of service)의 대기열(queue)이 제공될 때 발생한다. 대기열(queue)에 작업을 제출하(submitting)할 때, qos 매개 변수(parameter)를 사용하는 비동기(async) 생성자(constructor)를 사용할 수 있다.
3장(chapter) "Queues & Threads"에서 대기열(queue)의 QoS는 제출(submitted)된 작업에 따라 변경될 수 있다고 언급했다. 일반적으로 작업을 대기열(queue)에 제출(submit)할 때는, 대기열(queue) 자체의 우선 순위(priority)를 가진다. 그러나 필요한 경우 특정 작업의 우선 순위가 보통(normal)보다 높거나(higher) 낮도록(lower) 지정할 수 있다.
.userInitiated 대기열(queue)과 .utility 대기열(queue)를 사용하는 중에 여러 작업을 .userInteractive 서비스 품질(quality of service, .userInitiated보다 우선 순위가 높은)로 이후의 대기열(queue)에 제출해야 하는 경우, 운영체제에서 후자 대기열(queue)에 더 높은 우선 순위를 할당(assigned)하는 상황이 발생할 수 있다. .utility 서비스 품질(quality of service)이 대부분인 대기열(queue)의 모든 작업이 갑자기 .userInitiated 대기열(queue)의 작업보다 먼저 실행된다. 이 문제는 간단하게 방지할 수 있다. 보다 높은 서비스 품질(quality of service)이 필요한 경우, 다른 대기열(queue)을 사용하면 된다.
우선 순위 역전(priority inversion)이 발생하는 보다 일반적인 상황은 서비스 품질(quality of service)이 높은 대기열(queue)이 서비스 품질(quality of service)이 낮은 대기열(queue)의 리소스(resource)를 공유(shares)하는 경우이다. 낮은 우선 순위의 대기열(queue)이 객체(object)를 잠그면(lock), 높은 우선 순위의 대기열(queue)일지라도 기다려야(wait) 한다. 잠금(lock)이 해제(released)될 때까지 우선 순위가 낮은(low-priority) 작업이 실행되고, 그 동안 우선 순위가 높은(high-priority) 대기열(queue)은 사실상 아무 것도 수행하지 않는다.
우선 순위 역전(priority inversion)의 실제 사례를 살펴 보려면 이 장(chapter)의 시작(starter) 폴더(folder)에서 PriorityInversion.playground를 연다.
코드에는 세마포어(semaphore)뿐만 아니라, QoS 값이 다른 세 개의 스레드(threads)가 있다:
let high = DispatchQueue.global(qos: .userInteractive) let medium = DispatchQueue.global(qos: .userInitiated) let low = DispatchQueue.global(qos: .background) let semaphore = DispatchSemaphore(value: 1)
모든 대기열(queues)에서 다양한 작업이 시작된다:
high.async { // Wait 2 seconds just to be sure all the other tasks have enqueued Thread.sleep(forTimeInterval: 2) semaphore.wait() defer { semaphore.signal() } print("High priority task is now running") } for i in 1 ... 10 { medium.async { let waitTime = Double(exactly: arc4random_uniform(7))! print("Running medium task \(i)") Thread.sleep(forTimeInterval: waitTime) } } low.async { semaphore.wait() defer { semaphore.signal() } print("Running long, lowest priority task") Thread.sleep(forTimeInterval: 5) }
콘솔(console, ⇧ + ⌘ + Y)을 표시한 다음, playground를 실행하면, 실행할 때마다 다른 순서로 작업이 출력된다:
Running medium task 7
Running medium task 6
Running medium task 1
Running medium task 4
Running medium task 2
Running medium task 8
Running medium task 5
Running medium task 3
Running medium task 9
Running medium task 10
Running long, lowest priority task
High priority task is now running최종 결과는 항상 같다. 우선 순위가 높은 작업은 우선 순위 역전(priority inversion)으로 인해, 항상 중간(medium) 우선 순위 및 낮은 우선 순위(low-priority) 작업 이후에 실행된다.
'Raywenderlich > Concurrency by Tutorials' 카테고리의 다른 글
Chapter 7: Operation Queues (0) 2020.07.10 Chapter 6: Operations (0) 2020.07.09 Chapter 4: Groups & Semaphores (0) 2020.07.08 Chapter 3: Queues & Threads (0) 2020.07.06 Chapter 2: GCD & Operations (0) 2020.07.06