본문 바로가기

Swift/GCD

GCD PlayGround

애플은 WWDC 2015부터 2017까지 꾸준히 GCD에 대한 주제를 발표하였다.

 

WWDC 2015 - Building Responsive and Efficient Apps with GCD - https://kswift.tistory.com/21

 

WWDC 2015 - Building Responsive and Efficient Apps with GCD

시작에 앞서 WWDC 2015에서 소개된 GCD관련 API는 대부분 Swift3.0 이전 버전이기 때문에 현재는 사용되지 않는 것들이 대부분이라 전체적인 흐름과 각 내부 메서드의 역할을 파악하는데 집중했습니다

kswift.tistory.com

WWDC 2016 - Concurrent Programming With GCD in Swift 3 - https://kswift.tistory.com/22

 

WWDC 2016 - Concurrent Programming With GCD in Swift 3

Concurrency allows multiple parts of your application to run at the same time. Concurrency(동시성)는 우리의 앱을 동시적으로 여러 부분을 같은 시간에 실행되도록 돕는다. 시스템단에서는, 동시성을 구현하기 위해 t

kswift.tistory.com

WWDC 2017 - Modernizing Grand Central Dispatch Usage - https://kswift.tistory.com/23

 

WWDC 2017 - Modernizing Grand Central Dispatch Usage

Parallelism and concurrency Parallelism - 밀접히 관련된 계산들의 동시 실행 Concurrency - 앱에서 동시에 실행할 독립적 요소의 구성 두 개념을 쉽게 설명하면 병렬처리(Parallelism)는 우리가 일반적으로 여러

kswift.tistory.com

 

이를 모두 시청하면서 각 GCD항목들의 생성 배경(이유)과 특징들에 대해서 알 수 있었다. 그리고 추가적으로 흥미롭거나 더 알고 싶은 부분을 파보았고 이것을 정리를 해보려고 한다. (위 세션들을 먼저 읽고 오시는 것을 권장드립니다)

 

·  QoS 전파 또는 상속: 특별한 규칙?

기본적으로 Dispatch Queue의 QoS는 자식 Dispatch Queue에게 상속된다.

다만 예외상황(특별한 규칙)이 있다.

func performQOSPropagation() {
        print("performQOSPropagation() start, QoS: \(DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified)
		\n and thread is \(Thread.current)")
        DispatchQueue.global().async {
            print("\nExecuting on global(), QoS: \(DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified)
		\n and thread is \(Thread.current)")
            DispatchQueue.main.async {
                print("\=\nExecuting on main, QoS: \(DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified)
		\n and thread is \(Thread.current)")
            }
        }
    }

 

결과

메인 스레드에서 위 메서드를 실행하여 QoS가 자동으로 상속되는 상황을 확인

 

특이점

메인 스레드에서 실행되는 DispatchQueue.global().async 메서드 내의 블럭의 qos는 부모 큐의 qos를 상속받은 default여야 하는 상황. 하지만 global() 블럭의 qos는 userInitiated. 그리고 global() 블럭 내부에서 실행된 DispatchQueue.main.async 블럭 또한 부모 큐의 qos의 상속을 무시한 userInteractive이다. 이것은 과도한 전파를 막아주기 위한 특별한 규칙(예외상황) 이다.

 

 

·  DispatchWorkItem

queue.async(qos: .utility, flags: .inheritQoS) {
	// async block
}

// equal
        
let workItem = DispatchWorkItem(qos: .utility, flags: .inheritQoS) {
	// async block
}
        
queue.async(execute: workItem)
// or
queue.async {
	workItem.perform()
}

workItem.wait()
print("workItem finished")

WWDC2016에서 처음 소개됐다. Dispatch Block을 캡슐화하며, 캡슐화 한 블럭에 qos, flags 파라미터를 통해 특정 옵션과 qos를 지정할 수 있다. 또한 cancel() 메서드를 통해 취소할 수 있고, wait() 메서드를 통해 workItem이 실행된 고난 다음 실행할 부분을 설정할 수도 있다.

flags

work item을 생성할 때 사용되는 옵션, 동작을 제어한다

  1. .assignCurrentContext: work item이 현재 실행되고 있는 컨텍스트를 사용하도록 지정한다. 이 옵션을 사용하면, 워크 아이템이 실행될 때, 해당 컨텍스트에서 실행된다.
  2. .inheritQoS: 이 플래그는 QoS를 낮추지 않는 한 workItem의 QoS보다 현재 실행 컨텍스트(제출된 큐)의 QoS에 우선순위를 둔다. → 즉 제출된 Queue의 QoS를 상속받는다.
  3. .enforceQoS: work item의 QoS가 현재 실행 컨텍스트의 QoS보다 낮지 않은 경우 work item의 QoS를 강제로 적용한다. 이 옵션을 사용하면, 생성된 큐의 QoS와 관계없이 work item의 QoS가 적용된다.
  4. .noQoS: work item에 QoS를 적용하지 않는다. 이 옵션을 사용하면, 생성된 큐의 QoS와 관계없이 워크 아이템은 기본적으로 적용되는 QoS를 사용한다.
  5. .barrier: 워크 아이템이 큐에 추가될 때, 해당 작업이 마무리될 때까지 다른 작업이 실행되지 않도록 한다. 이 옵션을 사용하면, 큐에서 실행 중인 다른 작업이 모두 완료된 후에 해당 워크 아이템이 실행된다.
  6. .detached : 이 플래그는 work item이 현재 context와 분리된다. 이것은 work item이 생성된 context와 무관하게 실행됨을 의미한다.

.assignCurrentContext, .inheritQoS 모두 해당 workItem의 QoS를 Queue의 QoS를 상속받아 실행한다는 공통점이 있다.

차이점은 .inheritQoS는 큐의 QoS만 상속받는 반면에, .assignCurrentContext는 작업이 실행되는 스레드가 속한 Queue, Queue의 QoS를 모두 상속받는다.

 

 

· DispatchGroup

DispatchGroup 또한 WWDC2016에서 소개됐고, 생성 배경(이유)을 알 수 있다.

 

WWDC 2016 - Concurrent Programming With GCD in Swift 3

예를 들어, 사용자가 특정 기능을 요청했을 때, 시스템에서는 백그라운드에서 데이터를 다운로드하고, 이미지를 로딩하고, 다른 연산들을 수행한다고 가정해 보자.

과거에는 이러한 작업들을 단계별로 데이터의 flow에 맞게 진행하고, 각 작업은 해당 queue를 통해 실행하는 방식이었다. 이 방식은 일부 작업이 오랜 시간이 걸리는 경우, 전체 작업이 지연되고 응답 시간이 길어지는 등 성능에 문제가 있었다. 또한, 복잡한 프로그램에서는 이러한 작업을 관리하기 어려웠다.

이러한 문제점을 해결하기 위해 등장한 것이 dispatch group이다. dispatch group은 작업들을 묶어서 처리할 수 있기 때문에, 복잡한 프로그램에서 작업의 관리가 용이하다. 작업이 완료될 때마다 그룹에 등록된 handler를 호출하여 다음 작업을 수행할 수 있도록 할 수 있다. 이렇게 하면 작업들을 관리하는 데 있어서도 편리하다.

DispatchGroup에 대한 자세한 설명은 https://kswift.tistory.com/18 에 있습니다.

 

· Concurrency Problems:  Thread safety 코드를 작성하기

WWDC 세션들에서 모두 GCD를 통한 Concurrency를 구성하면서 발생할 수 있는 문제들에 대해서 소개를 하고 있는데

WWDC 2015 - Building Responsive and Efficient Apps with GCD 에선 Thread Explosion과 Deadlock,

WWDC 2016 - Concurrent Programming With GCD in Swift 3 에선 race condition,

WWDC 2017 - Modernizing Grand Central Dispatch Usage 에선 과도한 Context Switching 등이 있다.

 

멀티스레딩 환경에서 위 문제들은 앱을 멈추게 하거나(Thread Explosion과 Deadlock) 성능을 저하(과도한 Context Switching) 하기도 하며, 예상치 못한 결과를 발생(race condition)시킬 수도 있기 때문에 결국 우린 동시에 여러 스레드에서 접근하더라도 안전하게 동작하는 (Thread safety) 코드를 작성할 수 있어야 한다.

 

시작하기 전, 데이터의 무결성 · 원자성 · 동기화 · 스레드 세이프티 이 단어들을 정확히 짚고 시작하고 싶다. 유사하면서 조금씩 다른 의미가 있기 때문에 공부하며 많이 애를 먹었다.

  • 쓰레드 세이프티: 여러 스레드가 동시에 실행되는 환경에서도 안전하게 동작하는 것을 말한다. 즉, 여러 스레드가 공유하는 자원에 대해 동기화를 통해 접근하는 등의 방법을 사용하여, 스레드 간의 경쟁 상황을 제어하고 안전하게 동작하는 것.
  • 데이터의 무결성: 데이터가 원하는 방식으로 정확하게 유지되는 것을 보장하는 것을 의미한다. 즉, 데이터에 대한 일관성과 정확성을 유지하며, 데이터가 무결한 상태로 유지되도록 보장하는 것.
  • 원자성: 어떤 연산이 원자적이라는 것은, 그 연산이 중간에 끼어들거나 중단될 가능성이 없으며, 전체가 실행되거나 실행되지 않는 것을 보장하는 것을 의미. 즉, 한 번에 전체 연산이 실행되거나 실행되지 않도록 보장한다.
  • 동기화: 여러 개의 스레드가 공유하는 자원에 대해, 하나의 스레드가 해당 자원을 사용 중일 때 다른 스레드가 이를 사용하지 못하도록 제어하는 것을 말한다. 이를 통해 스레드 간의 경쟁 상황을 제어하고, 자원에 대한 일관성을 유지하는 것.

위 개념을 그림으로 쉽게 표현하면 다음과 같다.

스레드 세이프티 코드를 구현하기 위해선 데이터의 무결성을 보장하는 것 이외에도 더 많은 조건들이 있다.

그러나 이번 글은 데이터 무결성을 보장하기 위한 방법에 대해 중점적으로 다룰 것이다. 

데이터의 무결성을 보장하기 위해선, 동기화를 구현하는 것뿐만 아니라, 변경 작업을 원자적으로 처리하는 과정이 추가로 필요하다.

그렇다면 "동기화와 원자성 보장이 뭐가 다른데?" 란 생각이 들것이다. 

 

멀티스레드 환경에서 동기화는 다른 스레드가 공유하는 자원에 대한 접근을 제어하여 데이터의 일관성과 무결성을 보장한다. 즉, 동기화를 통해 다른 스레드가 변경 중인 자원에 대해 접근하지 않도록 막아 데이터의 일관성을 보장하는 것이다.

하지만, 동기화만으로는 여러 스레드에서 동시에 실행되는 연산에 대한 원자성을 보장할 수 없다. 예를 들어, 다수의 스레드가 자원에 접근하여 해당 자원의 값을 1 증가시키는 연산을 수행하는 경우, 동기화는 여러 스레드가 자원에 동시에 접근하지 못하도록 제어하지만, 각 스레드가 연산을 수행하는 과정에서 값이 변경될 수 있기 때문이다.

그렇다면 상호배제(동기화)와 원자성을 구현하는 법을 간단한 예시와 함께 설명해 보겠다.

· 상호배제(동기화) 구현하기

동기화를 구현하기 위한 기술들

- Serial queue

Serial queue에 동기적으로 작업을 제출하면 해당 작업을 순차적으로 처리하는데 이를 통해 동기화를 구현한다.

장점

  • 여러 큐에서 실행되는 작업들 간의 우선순위를 지정할 수 있다.
  • 다른 큐에서 실행되는 작업 간의 순서도 보장할 수 있다.

단점

  • 많은 스레드를 사용하여 직렬 큐를 처리하면 성능 저하가 발생할 수 있다.

 

- DispatchBarrier

큐 내에서 특정 작업이 끝나고 다음 작업이 실행되기 전에 일시적으로 큐의 동작을 중지시키는 방식

func performBarrier() {
        var array = [Int]()
        
        let queue = DispatchQueue(label: "com.example.arrayQueue", qos: .background, attributes: .concurrent)
        
        for i in 0..<10 {
            let addWorkItem = DispatchWorkItem(flags: [.barrier]) {
                array.append(i)
                print("Adding \(i) to array executed on thread: \(Thread.current)")
            }
            queue.async(execute: addWorkItem)
        }
        
        let removeWorkItem = DispatchWorkItem(flags: [.barrier]) {
            array.forEach { element in
                if let index = array.firstIndex(of: element) {
                    array.remove(at: index)
                    print("Removing \(element) from array executed on thread: \(Thread.current)")
                }
            }
        }
        queue.async(execute: removeWorkItem)
    }
    
/*
결과 - 비동기 작업임에도 0~9 까지 순서대로 모두 같은 스레드에서 처리함
Adding 0 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 1 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 2 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 3 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 4 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 5 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 6 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 7 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 8 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Adding 9 to array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 0 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 1 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 2 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 3 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 4 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 5 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 6 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 7 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 8 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
Removing 9 from array executed on thread: <NSThread: 0x6000037cb340>{number = 6, name = (null)}
*/

장점

  • GCD에서 제공하는 내장 프로퍼티이기 때문에 간편하다.
  • 동시 접근을 제어하는 Queue를 사용하기 때문에, 큐에 대한 접근이 자동으로 순차화된다.
  • 락(lock) 기능을 수행하면서도 다른 작업들을 실행할 수 있기 때문에 성능이 높다. (여기서 말하는 lock은 해당 queue에 대한 lock을 의미, 큐 내부에서 실행되는 작업에 대한 동시 접근은 lock을 보장할 수 없다)

단점

  • 작업 간 실행 우선순위를 변경할 수 없다.
  • 큐 내에서 동작하기 때문에, 다른 큐 간의 실행 순서는 보장하지 않는다.
  • 큐에 대한 동시 접근을 제어할 수 있지만, 큐 안에서 처리되는 작업에 대한 동시 접근은 제어하지 못한다.

 

- Semaphore

공유 자원에 대한 접근을 제한하기 위해 접근 가능한 스레드 수를 제한.

이를 통해 동시에 접근을 허용하는 스레드의 개수를 1개로 제한한다.

https://kswift.tistory.com/18 - Semaphores 부분 참조

func performSemaphore() {
        var array = [Int]()
        
        let queue = DispatchQueue(label: "com.example.arrayQueue", qos: .background, attributes: .concurrent)
        let semaphore = DispatchSemaphore(value: 1)
        
        for i in 0 ..< 10 {
            let addWorkItem = DispatchWorkItem() {
            
            	// 해당 블록 실행 종료시 count +1 함
                defer { semaphore.signal() }

		// semaphore의 count를 확인하여 0이면 현재스레드를 대기상태로, 아니면 실행하고 count -1 함
                semaphore.wait() 
                array.append(i)
                print("Adding \\(i) to array executed on thread: \\(Thread.current)")
            }
            queue.async(execute: addWorkItem)
        }
        
    }
    
/* 
결과 - 동시에 접근할 수 있는 스레드의 개수만 제어하기 때문에 스레드는 제각각일 수 있다.
Adding 0 to array executed on thread: <NSThread: 0x600003d20000>{number = 5, name = (null)}
Adding 1 to array executed on thread: <NSThread: 0x600003d285c0>{number = 9, name = (null)}
Adding 2 to array executed on thread: <NSThread: 0x600003d6ff00>{number = 6, name = (null)}
Adding 3 to array executed on thread: <NSThread: 0x600003d78500>{number = 4, name = (null)}
Adding 4 to array executed on thread: <NSThread: 0x600003d70680>{number = 3, name = (null)}
Adding 5 to array executed on thread: <NSThread: 0x600003d20a00>{number = 10, name = (null)}
Adding 6 to array executed on thread: <NSThread: 0x600003d20940>{number = 11, name = (null)}
Adding 7 to array executed on thread: <NSThread: 0x600003d208c0>{number = 12, name = (null)}
Adding 8 to array executed on thread: <NSThread: 0x600003d0fd00>{number = 13, name = (null)}
Adding 9 to array executed on thread: <NSThread: 0x600003d0f880>{number = 14, name = (null)}
/*

장점

  • 여러 큐에서 실행되는 작업들 간의 우선순위를 지정할 수 있다.
  • 큐 안에서 처리되는 작업에 대한 동시 접근 제어뿐만 아니라, 변수에 대한 접근 제어도 가능하다.

단점

  • semaphore는 lock과 같은 기능을 제공하지 않고 자체적인 카운팅을 통해 동시접근을 제어한다. 따라서 카운팅에 대한 연산이 필요하기 때문에 lock을 사용하는 방식보단 비교적 낮은 성능을 보일 수 있다.

 

- Lock

자물쇠처럼 특정 코드 블록에 대한 접근을 lock, unlock 하여 접근을 제어한다.

WWDC 2017 - Modernizing Grand Central Dispatch Usage

Fair lock, Unfair lock에 대한 설명: WWDC 2017 - Modernizing Grand Central Dispatch Usage -https://kswift.tistory.com/23

// Fair Lock을 사용한 동기화
let fairLock = NSLock()

DispatchQueue.concurrentPerform(iterations: 10) { index in
    fairLock.lock()
    print("Thread \(index) acquired fairLock")
    fairLock.unlock()
}

// Unfair Lock을 사용한 동기화
private var unfairLock = os_unfair_lock_s()

DispatchQueue.concurrentPerform(iterations: 10) { index in
    os_unfair_lock_lock(&self.unfairLock)
    print("Thread \(index) acquired unfairLock")
    os_unfair_lock_unlock(&self.unfairLock)
}

장점:

  • Lock은 코드 블록에 대한 접근을 제어하므로, 코드 블록 내에서 동작하는 작업들의 순서를 보장한다.
  • 다른 스레드에서 해당 락에 접근하려고 하면 대기하므로, 스레드 안정성을 보장한다.

단점:

  • 락(lock)을 사용하면 동시 접근을 제어할 수 있지만, 락을 사용하는 코드가 복잡하고 가독성이 떨어질 수 있다.

 

·  원자성(Atomic) 보장하기

원자성 보장을 구현하기 위해선 임계구역(멀티 스레드 환경에서 공유되는 데이터에 접근하는 부분)은 하나의 스레드만 접근할 수 있고 다른 스레드는 대기하도록 보장해야 한다.

즉, 임계구역에서 공유 데이터에 접근하여 사용하는 권한은 하나의 스레드만 가지게 하고, 권한을 사용중일 때엔, 나머지 스레드는 대기하는 기능을 구현해야 한다.

 

원자성의 구현을 확인하기 위해서 공유 데이터 역할을 할 전역변수 value = 0 를 생성하고, 각 메서드에서 concurrent queue에서 비동기적으로 value에 +1씩 100번 더해줄 것이다. 그리고 이를 여러 번 관찰하기 위하여 메서드를 100번 반복할 것이다. 만약 원자성을 구현했다면 정상적으로 value = 100 이 100번 반복될 것이고, 아니라면 반복 중 100이 나오지 않을 수도 있을 것이다. 

- Semaphore

var value = 0

for i in 1 ... 100 {
     performAtomicWithSemaphore(count: i)
	}

func performAtomicWithSemaphore(count number: Int) {
        let queue = DispatchQueue(label: "com.example.arrayQueue", qos: .background, attributes: .concurrent)
        let semaphore = DispatchSemaphore(value: 1)
        value = 0
        
        let workItem = DispatchWorkItem {
            DispatchQueue.concurrentPerform(iterations: 100) { index in
                semaphore.wait()
                self.value += 1
                semaphore.signal()
            }
        }
        queue.async(execute: workItem)
        workItem.wait()
        print("\(number)번째 시도: \(value)")
    }
    
/* 결과
1번째 시도: 100
2번째 시도: 100
3번째 시도: 100
...
98번째 시도: 100
99번째 시도: 100
100번째 시도: 100
*/

위에서 동기화를 구현한 방법과 같이 value를 1로 설정하여 하나의 스레드만 접근을 허용했다. 

추가로 임계구역을 semaphore 내부 메서드인 semaphore.wait(), semaphore.signal()를 통해 구현했다. 

임계구역을 제거하고 다시 실행해 보았을 땐, 당연히 100번의 시도 모두 100이 나오지 않고 제각각 다른 value가 나왔다.

 

- Serial queue

var value = 0

for i in 1 ... 100 {
     performAtomicWithSerialQueue(count: i)
	}

func performAtomicWithSerialQueue(count number: Int) {
        let queue = DispatchQueue(label: "com.example.arrayQueue", qos: .background)
        value = 0
        
        let workItem = DispatchWorkItem {
            DispatchQueue.concurrentPerform(iterations: 100) { index in
                queue.sync {
                    self.value += 1
                }
            }
        }
        DispatchQueue.global().async(execute: workItem)
        workItem.wait()
        print("\(number)번째 시도: \(self.value)")
    }

/* 결과
1번째 시도: 100
2번째 시도: 100
3번째 시도: 100
...
98번째 시도: 100
99번째 시도: 100
100번째 시도: 100
*/

Serial Queue는 작업을 순차적으로 실행하기 때문에 동일한 Serial Queue에 추가된 작업들은 순차적으로 실행된다.

따라서 특별한 임계구역을 지정하지 않아도 서로 영향을 미치는 데이터에 대해서 원자성을 보장할 수 있다.

 

- Lock

var value = 0
var unfairLock = os_unfair_lock_s()

for i in 1 ... 100 {
     performAtomicWithLock(count: i)
	}

func performAtomicWithLock(count number: Int) {
        
        let group = DispatchGroup()
        
        value = 0
        
        let addingWorkItem = DispatchWorkItem() {
            self.value += 1
        }
        
        for _ in 0 ..< 100 {
            group.enter()
            
            queue.async {
                os_unfair_lock_lock(&self.unfairLock)
                addingWorkItem.perform()
                os_unfair_lock_unlock(&self.unfairLock)
                group.leave()
            }
        }
        
		group.wait()
        print("\(number)번째 시도: \(self.value)")
    }

/* 결과
1번째 시도: 100
2번째 시도: 100
3번째 시도: 100
...
98번째 시도: 100
99번째 시도: 100
100번째 시도: 100
*/

임계구역을 지정할 unfairLock을 생성하고 이를 lock, unlock 하는 메서드와 함께 구성하여 value에 접근하는 부분에 임계구역을 지정했다. 

 

 

 

 

참고
https://vaibhavsingh-54243.medium.com/how-to-use-quality-of-service-classes-317854791e10

 

How does QOS Propagation work in iOS?

This article aims to develop an understanding of the quality of service classes, and how using them we can appropriately direct resources…

vaibhavsingh-54243.medium.com