아래 내용은 인프런 - 앨런 iOS Concurrency 프로그래밍 (opens new window) 강의 내용을 요약한 글입니다.

# 기본 개념

GCD(Grand Central Dispatch == 디스패치큐)는 간단한 일, 메서드 위주의 함수를 사용하는 작업에 사용된다. 오퍼레이션큐는 상대적으로 복잡한 기능 구현에 사용된다. 오퍼레이션은 GCD에서 기반하여 만들어진 기능이다. (취소, 순서지정, 일시중지 등)

비동기는 디스패치 큐를 통해 다른 스레드로 보낸 뒤 다시 메인 스레드로 돌아와 다른 작업들을 시작한다. 동기는 다른 스레드로 보낸 뒤 해당 작업이 끝날때까지 기다린다. 논리적으로는 동기처리가 메인스레드에서 동작하는 것과 동일하다.

DispatchQueue.global().async{
    // task
}

직렬(serial) vs 동시(concurrent)

직렬과 동시는 큐의 특성에 관한 개념이다. 직렬 큐는 분산처리 시킨 작업을 다른 한개의 쓰레드에서 처리하는 큐이다. 동시큐는 분산처리 시킨 작업을 다른 여러개의 쓰레드에서 처리하는 큐이다.

직렬 큐는 순서가 중요한 작업을 처리할때 사용한다. 동시 큐는 독립적이지만 유사한 여러 작업을 처리할때 사용한다.(테이블 뷰 셀 내의 컨텐츠 fetch 등)

# GCD

디스패치큐는 메인큐, 글로벌큐, 프라이빗(커스텀)큐 세가지로 이루어진다. 메인 큐는 메인 스레드를 관장하는 큐이다.

글로벌큐는 기본적으로 분산처리시 동시처리를 기본으로 한다. 글로벌 큐는 파라미터로 qos라는 서비스 품질 값을 전달할 수 있다. userInteractive의 디스패치큐가 가장 중요도가 높다.

  1. DispatchQueue.global(qos: .userInteractive) - 거의 즉시
  2. DispatchQueue.global(qos: .userInitiated) - 몇초 (앱내 pdf 오픈)
  3. DispatchQueue.global()
  4. DispatchQueue.global(qos: .utility) - 몇초에서 몇분 (네트워킹)
  5. DispatchQueue.global(qos: .background) - 몇분 이상 (에너지효율성 중시 / 유저의 직접적 인지가 없는 활동)
  6. DispatchQueue.global(qos: .unspecified) - 레거시 API

중요도가 높은 큐에 태스크를 배치하면 내부적으로 더 여러개의 쓰레드를 배치하여 리소스를 집중시킨다.

// 백그라운드 큐는 작업의 영향을 받게 된다.
// 큐를 background로 생성했더라도 작업이 utility이므로 유틸리티 큐로 작업이 진행된다.
let queue = DispatchQueue.global(qos: .background)

queue.async(qos: utility){
    // task
}

커스텀 큐는 아래와 같이 사용한다. label 파라미터를 통해 큐에 이름을 붙인다. 직렬 및 동시처리를 선택할 수도 있다.

let queue1 = DispatchQueue(label: "com.parkjju.serial")
let queue2 = DispatchQueue(label: "com.parkjju.concurrent", attributes: .concurrent)

비동기처리시 주의사항

  1. 메인큐에서 다른큐로 보낼때 sync메서드를 호출하면 절대 안된다. (UI 버벅임)
  2. 현재의 큐에서 현재 큐로 동기적으로 보내서는 안된다. (Deadlock 발생)
    • 쓰레드의 작업을 큐로 보낼때 동기적으로 보냈기 때문에 해당 쓰레드는 lock이 걸린다.
    • 큐에 올라온 작업을 다른 쓰레드로 GCD에 의해 다시 보내질때 기존 쓰레드로 보내게될 경우 해당 쓰레드는 lock이 걸려있으므로 교착상태가 발생한다.
    • GCD가 다른 쓰레드로 작업을 보내는건 내부 알고리즘에 의한 것이므로 반드시 교착상태가 발생한다고 확신할 수는 없지만 일말의 가능성이 있는 코드 자체를 작성하지 않는 것이 중요하다.

weak self 중첩

클로저 내에서 바깥족 함수에 weak self를 이미 선언했다면 내부 클로저에서는 추가로 weak self 선언을 하지 않아도 된다.

DispatchQueue.global(qos: .utility).async{[weak self] in
    // ...

    DispatchQueue.main.async{ // weak self 선언되어 있는 것과 마찬가지
        // ...
    }
}

# 디스패치 그룹

디스패치 그룹은 유사한 성격으로 묶인 작업들 중 마지막 작업이 끝나는 시점에 비동기처리를 하고 싶을 때에 사용한다. 디스패치 그룹은 group 파라미터에 전달할 수 있다.

해당 그룹의 작업이 끝마치는 시점은 DispatchGroup().notify 메서드를 호출한다.

let group1 = DispatchGroup()

DispatchQueue.global(qos:).async(group: group1){
    // 클로저 내에 태스크 정의
    // 정의한 태스크를 group1로 묶은 뒤 큐에 보냄
    // 쓰레드 전달은 내부적으로 해줌
}

// group1 내부 태스크 마치는 시점에 비동기처리
// notify 함수를 호출한다
group1.notify(queue: DispatchQueue.main){ [weak self] in
    self?.textLabel.text = "모든 작업이 완료되었습니다."
}

wait 메서드

wait 모든 작업이 끝날 때까지 동기적으로 기다리는 메서드이다. (큐 또는 쓰레드를 블록한다) 메인 스레드에서 사용하면 앱이 멈춘다. 대기시간에 대한 타임아웃 파라미터를 설정할 수 있다.

let group1 = DispatchGroup()

DispatchQueue.global(qos:).async(group: group1){

}

DispatchQueue.global(qos:).async(group: group1){

}

// 그룹 작업이 모두 끝날때까지 대기
// timeout 파라미터로 대기시간 지정
group1.wait(timeout: DispatchTime.distantFuture)

타임아웃이 될때까지도 작업이 끝나지 않으면 대기열 블록을 만료시킨다. (작업은 그대로 진행된다)

작업 대기에 대한 만료시간을 DispatchTime의 열거형 케이스로 사용해도 되고 직접 지정할 수도 있다.

if group1.wait(timeout: .now() + 60) == .timedOut{
    print("작업이 만료되었습니다")
}

위의 예시는 디스패치 그룹으로 묶인 태스크들이 모두 동기적일때의 경우이다. 비동기 작업들이 디스패치 그룹으로 묶인 경우, 비동기 작업을 포함하고 있는 태스크에서 다른 큐로 작업을 다시 보내고 쓰레드 재배치가 일어나기 때문에 유의해야한다.

디스패치 그룹의 동기함수들이 모두 마무리 된 시점에서 디스패치 그룹 내의 작업이 모두 끝났다고 인식하게 되지만, 그룹 내의 특정 태스크에서 비동기 작업을 추가로 진행하고 있으므로 사실은 작업이 마무리 되지 않은 것이다.

이러한 경우 사용하는 것이 group1.enter() 메서드와 group1.leave() 메서드이다.

queue.async(group: group1){
    group1.enter() // group1 내에 비동기태스크 하나 추가
    someAsyncMethod{
        // completionHandler 클로저
        group1.leave() // group1 내에 비동기태스크 하나 완료
    }
}

디스패치 그룹 내에서 enter의 숫자와 leave의 숫자가 동일할때 해당 그룹의 작업이 끝났다고 인식하게 된다.

defer

태스크 내에서 동기함수와 비동기 함수가 함께 정의되어 있을때 동기함수를 가장 마지막에 실행하고 싶은 경우 defer 키워드 내에 동기함수를 넣으면 된다.

for name in imageNames {
    group.enter()

    let task = URLSession.shared.dataTask(with: url) { data, _, error in
        // defer로 클로저의 마지막에 사용하도록 등록할 수있음
        // leave가 마지막에 실행되도록
        defer { group.leave() }

        if error == nil, let data = data, let image = UIImage(data: data) {
            downloadedImages.append(image)
        }
    }
    task.resume()
}

UIView.animate 함수 그룹화

animte 메서드를 확장 후 그룹 파라미터를 받도록 오버로딩한다.

extension UIView {
    static func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, group: DispatchGroup, completion: ((Bool) -> Void)?) {
        // 비동기 디스패치 그룹: enter(입장), leave(퇴장) 구현
        group.enter()
        animate(withDuration: duration, animations: animations) { (success) in
            completion?(success)
            group.leave()
        }
    }
}

# Dispatch WorkItem

디스패치 워크아이템은 작업을 객체화 하는 클래스이다. (빈약한)취소 또는 (빈약한)순서 기능을 제공한다.

빈약한 취소라고 함은 DispatchWorkItem 객체 내에 cancel() 메서드가 존재하여 큐에서 대기중인 작업을 제거할 수 있다는 것을 의미한다. 작업의 제거는 작업이 큐에서 대기할 때에만 해당하며, 쓰레드에서 작업이 실행중인 경우 isCancelled 속성만 true로 설정되고 실행중인 작업을 멈추는 것은 아니다.

let item1 = DispatchWorkItem(qos: .utility){
    print("작업1")
    print("작업2")
}

// 작업1의 isCancelled속성이 True로 설정
item1.cancel()

let queue = DispatchQueue(label: "레이블")

// item1의 작업은 실행되지 않는다 (이미 큐에서 제거)
queue.async(execute: item1)

// 작업 실행
queue.async(execute: item2)

DispatchWorkItem의 인스턴스 메서드로 notify가 있다. 디스패치 워크아이템의 작업을 마친 뒤 이어서 할 작업을 직접 지정할 수 있다.

let item1 = DispatchWorkItem(qos: utility){
    print("작업1")
    print("작업2")
}

let item2 = DispatchWorkItem{
    print("작업3")
    print("작업4")
}

// notify로 큐와 다음 작업 지정
item1.notify(queue: DispatchQueue.global(), execute: item2)

// item1 작업 실행 뒤에 item2가 자동으로 실행된다.
queue.async(execute: item1)

# 세마포어

세마포어는 고수준 동기화 메커니즘이다. (High-level synchronization mechanism) 공유 리소스에 접근 가능한 작업 수를 제한할 때에 사용한다.

let semaphore = DispatchSemaphore(value: 3)

queue.async(group:group1){
    group1.enter()
    semaphore.wait()
    someAsyncMethod{
        group1.leave()
        semaphore.signal()
    }
}

세마포어 value파라미터에 전달된 개수만큼 큐에서 태스크를 쓰레드로 보내게 된다. 태스크마다 wait 세마포어 객체의 wait 메서드를 호출하여 큐에 대기하는 함수가 대기하도록 하고, 메서드 종료 시 signal함수를 호출하여 세마포어 형태로 큐에서 작업 대기중인 작업 하나를 다시 불러오게 된다. (교체하는 형태)

let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInteractive)

// 공유리소스에 접근가능한 작업수를 4개로 제한
let semaphore = DispatchSemaphore(value: 4)


for i in 1...10 {
    group.enter()
    semaphore.wait()
    queue.async(group: group) {
        print("시뮬레이팅 \(i)시작")
        sleep(3)
        print("시뮬레이팅 \(i)종료★")
        semaphore.signal()
        group.leave()
    }
}

group.notify(queue: DispatchQueue.global()) {
    print("=====모든 일이 종료됨=====")
//    PlaygroundPage.current.finishExecution()
}

위 예제코드를 보면 세마포어 waitsignal 메서드를 호출하는 것을 볼 수 있다. 세마포어 코드를 주석처리해보고 코드를 실행해보면 세마포어가 하는 역할에 대해 쉽게 이해할 수 있게 된다.

# 동시성 문제

여러 쓰레드를 사용하면서 발생할 수 있는 문제들을 다룬다. 데이터에 동시적으로 접근하는 상황 가운데 발생할 수 있는 상황이다. 이러한 문제들을 예방하는 코드를 쓰레드-세이프(Thread-Safe)한 코드라고 한다.

# 1. Race Condition

var a = 1
DispatchQueue.global().async{
    sleep(1)
    print("FIRST!")
    a = 2
}

DispatchQueue.global().async{
    sleep(1)
    print("SECOND!")
    a = 3
}

위의 코드출력 결과에서 FIRST가 출력되는지 SECOND가 출력되는지 확신할 수 없는 상황이다.

Two concurrent process (or threads) access a shared resource without any synchronization. -> Creates a race condition. The result is non-deterministic and depends on timing.

# 2. Deadlock(교착상태)

데드락은 2개 이상의 쓰레드가 2개 이상의 배타적인 자원 사용으로 인해 서로 점유하려고 하면서 자원사용이 막히는 상태를 말한다. 다음 두 프로세스의 작업을 고려해보자.

// Process1
file_lock(A); // A 리소스 잠금
file_lock(B); // B 리소스 잠금

file_unlock(B); // B 리소스 잠금 해제
file_unlock(A); // A 리소스 잠금 해제
// Process2
file_lock(B); // B 리소스 잠금
file_lock(A); // A 리소스 잠금

file_unlock(A); // A 리소스 잠금 해제
file_unclock(B); // B 리소스 잠금 해제

프로세스 1, 2에서 각각 함수 file_lock(A), file_lock(B)를 호출하여 교차하는 형태로 리소스 A와 B에 접근 후 이를 다른 곳에서 접근하지 못하도록 점유하게 된다.

교착상태에 빠지는 시나리오는 다음과 같다.

  1. 프로세스1에서 A 리소스를 점유한다.
  2. concurrent 처리에 따라 프로세스 2의 처리가 시작되는데, 이때 B 리소스를 점유한다.
  3. 프로세스 1에서 B 리소스에 접근하려고 했더니 프로세스 2에서 이미 점유중이므로 lock이 해제될때까지 대기한다.
  4. 프로세스 2에서 A 리소스에 접근하려고 했더니 프로세스 1에서 이미 점유중이므로 lock이 해제될때까지 대기한다.
  5. 무한 대기상태에 돌입한다.

이러한 문제는 iOS상에서 시리얼 큐로 해결 가능하다. 세마포어와 같은 제한된 리소스 접근 시 주의하여 설계해야 한다.

# 3. Priority Inversion(우선순위 역전)

CPU 스케줄링 방식에서 가장 중요한 점은 바로 우선순위 개념이다. 내부적인 기준에 따라 우선순위가 높은 프로세스부터 처리가 되어야 한다.

하지만 낮은 우선순위의 프로세스가 자원을 점유하는 lock 코드가 걸려있는 경우에 어떨까? 우선순위 역전 문제를 다음과 같이 정의한다.

A situation where a higher-priority job is unable to run because a lower-priority job is holding a resource it needs, such as a lock.

iOS 앱 설계 과정에서 발생할 수 있는 우선순위 역전 문제는 아래와 같다.

  1. lock, 세마포어 코드 작성에 따라 높은 우선순위 리소스를 점유하는 경우
  2. 높은 우선순위 작업이 낮은 작업에 의존하는 경우 (Operation)

우선순위 역전은 1차적으로 GCD가 리소스 lock 처리를 자동으로 해준다고 한다. (강의내용)

각 비동기 태스크는 iOS에서 qos값으로 설정할 수 있는데, userInteractive, default 등 여러가지가 존재한다. 이때 우선순위 역전 문제가 발생하는 상황은 default 우선순위를 가진 태스크가 처리되는 과정에서 userInteractive 태스크가 공유된 리소스에 접근하는 경우이다.

원칙적으로는 lock이 걸려있으므로 userInteractive 태스크가 대기를 하게 되지만, GCD에서 내부적으로 userInteractive 태스크가 공유 리소스에 접근하기 이전에 default 태스크의 공유 리소스 lock을 풀도록 작업 처리를 해둔다고 한다.

내부적인 처리에 의존하기보다, 애초에 공유 리소스에 접근하는 코드를 작성하는 경우 qos값을 동일하게 처리하는것이 좋다.

# Thread-safe 코드 작성

Thread-safe코드란 여러 쓰레드가 동시에 사용되어도 안전한 코드이다. TSan(Thread Sanitizer Tool) 툴을 사용하면 잠재적 경쟁 상황을 찾을 수 있다. TSan은 엑스코드 프로젝트 상단에서 시뮬레이터 선택란 왼쪽의 앱 아이콘 영역을 클릭하면 드롭다운 형태로 edit scheme 등의 메뉴가 노출되는데, 이때 edit scheme 클릭 후 Diagnostic 메뉴에 들어가면 Thread Sanitizer 옵션을 켤 수 있다.

해당 옵션을 켠 후 앱을 빌드하면 (시간이 좀 더 소요된다) 코드상에 잠재적 경쟁상황이 발생하는 부분을 찾아서 알려준다.

공유리소스 접근으로 인한 경쟁상황 발생은 뷰컨트롤러에서 한 객체에 동시다발적으로 접근하는 코드가 동시적 처리를 요구할때 발생할 수 있다.

private let serialQueue = DispatchQueue(label: "..")
private var _count = 0
public var count: Int{
    get {
        _count
    }
    set {
        _count = newValue
    }
}

객체 내에 계산속성으로 count가 있을때 일반적인 형태로 코드를 작성하면 위와 같이 만들어진다. 뷰컨트롤러에서 해당 객체 계산속성으로 동시처리를 하게 되면 경쟁상황이 발생한다. 위 코드를 개선하면 다음과 같은 형태를 갖는다.

private let serialQueue = DispatchQueue(label:"..")
private var _count = 0
public var count: Int{
    // 읽기 연산 자체만 구현되면 thread-safe한 상태
    get{
        return serialQueue.sync{
            _count
        }
    }
    set {
        serialQueue.sync{
            _count = newValue
        }
    }
}

직렬큐에 넣고, sync 메서드까지 사용해야 한다. 직렬 큐는 태스크를 한 개의 쓰레드에서만 처리하는 것이고, sync 메서드는 태스크 처리가 완료될때까지 자신의 스레드에서 태스크를 블록하는 것이다.

sync처리 하지 않았을때 발생할 수 있는 진짜 문제는 아래와 같다.

DispatchQueue.global().async{
    serialQueue.sync{
        _count
    }

    _count // 읽는 작업이 async 처리됨
}

만약 위 코드에서 _count의 get 연산이 sync처리 되어 있지 않다고 가정했을때 get연산이 다시 비동기처리 되기 때문에 count변수에 대한 경쟁상황이 연출될 수 있다.

따라서 비동기 클로저 내에서 객체 속성에 접근할때 sync처리를 하여 읽기/쓰기작업이 이루어지는 동안 다른 쓰레드로부터의 리소스 접근을 블록해야한다.

sync메서드는 메인 쓰레드가 아닌 다른 쓰레드에서 사용할때 경쟁상황 예방에 유용하게 사용된다.

# 디스패치 배리어(Dispatch Barrier)

디스패치 배리어는 동시큐 내의 여러 쓰레드 중 배리어 작업을 처리해야 할 때 한개의 쓰레드만 사용하여 직렬로 실행하도록 하는 방법이다. 다른 쓰레드들의 작업들을 블록해버린다. 읽기연산은 async처리하고, 쓰기연산에 대해서만 배리어 작업으로 큐에 보내는 식으로 활용 가능하다.

async 메서드의 flags 파라미터 값을 .barrier로 지정하면 된다. BarrierThreadSafePerson 객체를 여러번 참조하는 경우 동시큐를 통해 여러 쓰레드에서 쓰기 작업이 이루어질 수 있지만 디스패치 배리어 처리가 되어 있기 때문에 쓰기 작업을 하는 동안 나머지 쓰레드는 블록된다.

class BarrierThreadSafePerson: Person {

    let newConcurrentQueue = DispatchQueue(label: "com.inflearn.person.newConcurrent", attributes: .concurrent)

    // 🎾 쓰기 - 동시 + 배리어(Barrier) 작업으로 설정
    override func changeName(firstName: String, lastName: String) {
        newConcurrentQueue.async(flags: .barrier) {
            super.changeName(firstName: firstName, lastName: lastName)
        }
    }

    // 🎾 읽기 - 동시 + 동기(sync) 작업으로 설정
    override var name: String {
        newConcurrentQueue.sync {
            return super.name
        }
    }
}

# 지연 저장속성

다음 문서 (opens new window)를 참조하자.

지연저장속성은 선언 및 정의 단계에서는 메모리에 로드되지 않고, 첫 접근시에 메모리에 로드된다.

// 코드 출처: https://medium.com/what-i-talk-about-when-i-talk-about-ios-developmen/the-thread-safety-of-lazy-variables-in-swift-b20184ef5a38
class SharedInstanceClass {
    lazy var testVar = {
        return Int.random(in: 0..<99)
    }()
}

let queue = DispatchQueue(label: "test", qos: .default, attributes: [.initiallyInactive, .concurrent], autoreleaseFrequency: .workItem, target: nil)
let group = DispatchGroup()


let instance = SharedInstanceClass()
for i in 0 ..< 10 {
    group.enter()
    queue.async(group: group, qos: .default) {
        let id = i
        print("\(id) \(instance.testVar)")
        group.leave()
    }
}
queue.activate()
group.wait()

위 코드에서 for문을 통해 생성되는 비동기 코드들이 인스턴스의 저장속성에 거의 동시적으로 접근하며 지연 저장속성 변수들이 여러개 생겨버린다.

위 문제를 해결하기 위한 방법들은 여러가지가 있다.

  1. 직렬큐 + 동기처리 (serialQueue + sync method)
  2. 지연저장속성을 디스패치 배리어를 통해 접근
  3. 세마포를 통해 지연저장속성에 접근할 수 있는 쓰레드 수를 1개로 제한
// 1번
class SharedInstanceClass {
    // 시리얼큐 생성
    let serialQueue = DispatchQueue(label: "serial")

    lazy var testVar = {
        return Int.random(in: 0..<99)
    }()

    // 직렬 + 동기처리
    var readVar{
        serialQueue.sync{
            return testVar
        }
    }
}

let group = DispatchGroup()

let instance = SharedInstanceClass()

for i in 0 ..< 10 {
    group2.enter()
    queue.async(group: group2) {
        print("id:\(i), lazy var 이슈:\(instance.readVar)")
        group2.leave()
    }
}
// 2번
class SharedInstanceClass {
    lazy var testVar = {
        return Int.random(in: 0..<99)
    }()
}

let group = DispatchGroup()

let instance = SharedInstanceClass()

for i in 0 ..< 10 {
    group.enter()
    // 큐에 디스패치 배리어 플래그 설정
    // lazy var를 읽는 태스크를 실행하는 경우 나머지 쓰레드가 블록처리된다.
    queue.async(flags: .barrier) {
        print("id:\(i), lazy var 이슈:\(instance.readVar)")
        group2.leave()
    }
}
// 3번
class SharedInstanceClass {
    lazy var testVar = {
        return Int.random(in: 0..<99)
    }()
}

let group = DispatchGroup()

let instance = SharedInstanceClass()

// 세마포어 리소스 접근 제한 수 1로 설정
let semaphore = DispatchSemaphore(value: 1)

for i in 0 ..< 10 {
    group.enter()

    // semaphore를 통해 리소스 점유
    semaphore.wait()

    queue.async(flags: .barrier) {
        print("id:\(i), lazy var 이슈:\(instance.readVar)")
        group2.leave()
        // 태스크 마친 뒤 unlock
        semaphore.signal()
    }
}

애초에 쓰레드세이프 처리 이전에 지연저장속성에 먼저 접근하여 메모리 상에 로드를 먼저 해놓아도 된다.

# Reference

  1. 인프런 - 앨런 동시성 프로그래밍 (opens new window)
  2. The thread safety of lazy variables in Swift (opens new window)