# 비동기 개념

멀티쓰레드 기반에서 비동기는 특정 쓰레드의 태스크 하나를 외부 스레드로 보내고 다시 돌아와 다음 태스크들을 바로 처리하기 시작하는 방법이고, 동기(Concurrency)는 태스크를 특정 쓰레드로 보내어 처리하더라도 해당 태스크 처리가 끝날때까지 다른 태스크들이 현재의 스레드로 들어오지 못하게 블락하는 방식이다. (안기다림 vs 기다림)

직렬처리 vs 동시처리

직렬처리는 특정 쓰레드의 태스크를 분산처리 할 때에 한 쓰레드에만 보내는 방식이고 동시처리는 여러 쓰레드에 보내는 방식이다.

비동기/동기적 처리는 태스크 분산처리시에 태스크를 보내는 쓰레드의(기다림 vs 안기다림) 입장에서의 이야기이고, 직렬/동시처리는 태스크 분산처리시에 태스크를 받는 쓰레드의(한개 또는 여러개) 입장에서의 이야기이다.

# 앱의 시작과 동작원리

  1. 앱 아이콘 클릭
  2. main함수 실행 (어플리케이션 시작의 엔트리포인트 - 메모리 주소를 찍고 시작하게 됨)
  3. main함수에서 UIKit이 관리하는 UIApplicationMain() 객체를 생성
  4. 생성된 객체는 UI화면을 준비
  5. 초기화, UI상태값 처리
  6. 앱 활성화
  7. 이벤트 루프 생성 (런루프)

iOS는 이벤트를 포트(특정 프로세스를 식별하는 논리 단위)로 쪼개어 이벤트 큐에 이벤트를 쌓는다. 이후 하나씩 메인 런루프 객체에 이벤트 큐의 이벤트를 하나씩 던져준다.

앱 객체에서 메인 런루프의 이벤트를 받아 알맞은 함수가 실행된다. 알맞은 함수를 고르는 것도 메인 런루프가 하는 일임 (@IBAction 등)

함수 실행 이후 화면 업데이트 처리를 위한 사이클이 돌아간다. 화면 리렌더링은 메인 쓰레드에서 담당한다. (리렌더링 기준은 나중에)

메인쓰레드에서 소요 시간이 긴 태스크를 할당하게 되면 화면 렌더링에 버벅임이 발생하게 된다.

iOS에서 동시성을 처리하는 방법은 작업을 큐에 보내면 iOS가 알아서 여러 쓰레드로 나누어 분산처리를 해준다. (자동) 태스크 큐는 FIFO로 동작. 큐는 그때그때 알아서 비워짐

쓰레드는 큐에 태스크를 할당함에 따라 그때그때 생성된다.

# DispatchQueue

iOS에는 큐에 디스패치큐와 오퍼레이션 큐가 있음. (오퍼레이션 큐는 어렵다고 함)

디스패치큐는 GCD(Grand Central DispatchQueue)라고도 불림

물리적인 쓰레드 == 소프트웨어 쓰레드?

물리적인 쓰레드는 소프트웨어적인 쓰레드와 1대1 매칭되는 개념은 아니다. 물리적인 쓰레드가 OS로 인해 NSThread라는 객체로써 소프트웨어 내에서 사용되는 개념이다. 물리적인 쓰레드 1개당 여러개의 쓰레드 객체가 생성될 수 있음.

소프트웨어 내에서 사용되는 쓰레드들을 모아놓은 공간을 쓰레드 풀(Thread pool)이라고 한다. 메인쓰레드에서 큐에 할당한 태스크에 따라 쓰레드 풀에서 쓰레드를 가져와 태스크를 할당한다. (쓰레드 풀에서 쓰레드를 소프트웨어에 할당하는 것도 자동으로 처리해줌)

병렬 vs 동시성

병렬은 물리적인 쓰레드에서 실제 동시에 일을 하는 개념을 말한다. (개발자가 관리 X)

동시성은 메인 쓰레드가 아닌 다른 소프트웨어적 쓰레드에서 동시에 일을 하는 개념이다. (개발자가 관리 O)

동기/비동기를 CPU 제어권 관점에서 바라보면 아래와 같은 경우로 나뉜다.

  1. 동기 & Blocking - CPU제어권을 다른 쓰레드에 넘기고 블록, 태스크가 마무리되면 다시 반환
  2. 동기 & Non-Blocking - CPU제어권을 넘겼다가 부분적으로 태스크를 처리하고 다시 돌려받고.. 주고받고 하며 다른 쓰레드의 작업 완료 여부를 지속적으로 체크. (busy-waiting, 스위프트에서는 해당 없다고 생각해도 무방)
  3. 비동기 & Blocking - 양립 불가능한 개념
  4. 비동기 & Non-Blocking - 권한 반납, 권한 회수는 콜백이 트리거가 됨.

직렬 큐 vs 동시 큐

직렬 큐는 분산처리 대상 태스크들을 다른 한 개의 쓰레드로만 보내는 방법을 의미하고, 동시 큐는 분산처리 대상 태스크들을 다른 여러 쓰레드에서 처리하는 방법을 의미한다.

직렬 큐는 순서가 중요한 작업을 처리할때 사용한다. 동시 큐는 각 쓰레드가 독립적인 작업을 처리할때 사용한다.

// serialQueue는 직렬 큐가 된다
let serialQueue = DispatchQueue(label: "원하는 문자열")

// 다른 쓰레드에서 순서에 맞춰 실행됨
serialQueue.async{
    task1()
}
serialQueue.async{
    task2()
}
serialQueue.async{
    task3()
}

let concurrentQueue = DispatchQueue.global()

비동기처리에 기본적으로 사용되는 큐는 글로벌 큐이다.

// 클로저 생성, 비동기적 실행
// 동시 큐가 기본이다.
DispatchQueue.global().async {
    func()
}

// 클로저 생성, 동기적 실행
DispatchQueue.global().sync {
    func()
}
DispatchQueue.global().async {
    print("Task1 시작")
    print("Task1-1")
    print("Task1-2")
    print("Task1-3")
    print("Task1 종료")
}

DispatchQueue.global().async {
    print("Task2 시작")
}

위 코드를 실행하면 첫 번째 디스패치 큐에 전달된 작업들도 뒤죽박죽으로 변할까?

그렇지 않다. 디스패치 큐에 전달되는 클로저는 작업의 묶음이므로 동기적으로 처리되고 동기적으로 처리되는 순서 중간중간에 다른 쓰레드의 작업이 삽입될 수는 있다.

func task4() {
    DispatchQueue.global().async {
        print("Task 4 시작")
        sleep(2)
        print("Task 4 완료★")
    }
}

func task5() {
    DispatchQueue.global().async {
        print("Task 5 시작")
        sleep(2)
        print("Task 5 완료★")
    }
}

task4()
task5()
// Task 4 시작
// Task 5 시작
// Task 5 완료 or Task 4 완료 -> 순서는 달라질 수 있음

# GCD 개념 및 종류

디스패치큐(GCD)는 아래와 같은 종류가 있다.

  1. 메인큐 DispatchQueue.main
  2. 글로벌큐 DispatchQueue.global()
  3. 프라이빗(Custom)큐 DispatchQueue(label: "~~")

# 글로벌큐

글로벌 큐도 내부적으로 QoS(Quality of Service)에 따라 6종류로 나누어진다. 이들은 기본적으로 동시 큐이다.

QoS의 중요도가 높을수록 할당되는 쓰레드의 수가 늘어난다. 쓰레드가 많이 할당된다고 해도 작업이 반드시 먼저 끝난다는 것은 아니다.

QoS 수준은 아래와 같이 나누어진다.

  1. .userInteractive - UI업데이트와 관련된 것들, 거의 즉시 처리
  2. .userInitiated - 로컬 DB읽어들이기, pdf파일 열기 등 유저에게 필요한 작업이지만 비동기적으로 처리해도 되는 것. 몇초정도 걸림
  3. .default - 디폴트
  4. .utility - Progress Indicator와 함께 실행되는 작업 (네트워킹, 무한스크롤 등). 몇초에서 몇분
  5. .background - 서버 동기화, 데이터 미리 fetch. 몇분 이상(속도 < 배터리 효율성)
  6. .unspecified - 레거시 API
let userInteractiveQueue = DispatchQueue.global(qos: .userInteractive)
let userInitiatedQueue = DispatchQueue.global(qos: .userInitiated)
let defaultQueue = DispatchQueue.global()  // 디폴트 글로벌큐
let defaultQueue2 = DispatchQueue.global(qos: .default) // 디폴트 글로벌큐
let utilityQueue = DispatchQueue.global(qos: .utility)
let backgroundQueue = DispatchQueue.global(qos: .background)
let unspecifiedQueue = DispatchQueue.global(qos: .unspecified)

디폴트 큐나 유틸리티 큐를 주로 사용하게 될 것임

프라이빗 큐

프라이빗 큐는 기본적으로 직렬 큐이다.

let privateQueue = DispatchQueue(label: "com.inflearn.serial")

// 프라이빗 큐를 동시 큐로 활용하는 코드
let concurrentPrivateQueue = DispatchQueue(label:"custom", attributes: .concurrent)

# GCD 사용시 주의사항

# 1. 반드시 메인큐에서 처리해야하는 작업

UI와 관련된 작업은 다른 쓰레드에서 작업중이었더라도 반드시 메인 쓰레드로 다시 보내줘야 한다.

let url = URL(string: "이미지 URL주소")!

// URL세션은 내부적으로 비동기로 처리된 함수임.
URLSession.shared.dataTask(with: url) { (data, response, error) in

    if error != nil{
        print("에러있음")
    }

    guard let imageData = data else { return }

    // 즉, 데이터를 가지고 이미지로 변형하는 코드
    let photoImage = UIImage(data: imageData)

    // 이미지 업데이트 작업은 메인 쓰레드로 다시 보냄
    DispatchQueue.main.async {
        imageView?.image = photoImage
    }
}.resume()

위 예시 코드에서 URLSession객체의 클로저는 글로벌 큐에서 동작한다. 따라서 내부 코드 중 UI와 관련된 로직은 메인 큐로 다시 보내줘야 하는 것이다.

# 2. 컴플리션 핸들러의 존재 이유

메인 쓰레드에서 특정 작업을 글로벌 큐에 올린 뒤 다른 쓰레드로 태스크들이 옮겨진다.

다른 쓰레드로 옮겨진 태스크들이 마무리되는 시점을 알아내는 것이 매우 중요하다. 또한 태스크가 큐에 올라간 뒤 다시 메인쓰레드로 CPU의 제어권이 바로 넘어오기 때문에 이때 당시의 비동기 태스크들은 리턴값이 정해지지 않은 상태이다.

따라서 데이터를 특정 타입의 리턴형으로 메인 쓰레드에 뿌려주는 것이 아니라 클로저를 전달하여 쓰레드에서 클로저를 호출하는 방식으로 처리해야 한다.

func getImages(with urlString: String) -> UIImage? {

    let url = URL(string: urlString)!

    var photoImage: UIImage? = nil

    URLSession.shared.dataTask(with: url) { (data, response, error) in
        if error != nil {
            print("에러있음: \(error!)")
        }
        // 옵셔널 바인딩
        guard let imageData = data else { return }

        // 데이터를 UIImage 타입으로 변형
        photoImage = UIImage(data: imageData)

    }.resume()

    return photoImage    // 항상 nil 이 나옴
}

자바스크립트의 경우에서도 동일하게 적용되는 이야기이다. 프라미스를 변수에 할당하더라도 충족 / 미충족 상태 등의 프라미스 객체가 나올 뿐 내부 데이터를 받기 위해서는 then을 통해 접근해야 하지 않는가?

func properlyGetImages(with urlString: String, completionHandler: @escaping (UIImage?) -> Void) {

    let url = URL(string: urlString)!

    var photoImage: UIImage? = nil

    URLSession.shared.dataTask(with: url) { (data, response, error) in
        if error != nil {
            print("에러있음: \(error!)")
        }
        // 옵셔널 바인딩
        guard let imageData = data else { return }

        // 데이터를 UIImage 타입으로 변형
        photoImage = UIImage(data: imageData)

        // !!!콜백 호출!!!
        completionHandler(photoImage)
    }.resume()
}

// 올바르게 설계한 함수 실행
properlyGetImages(with: "이미지 URL") { (image) in
    // image 파라미터는 UIImage타입
    // completionHandler 아규먼트를 클로저로 정의
    DispatchQueue.main.async {
    }
}

async/await

프라미스 체이닝과 유사하게 비동기 처리된 다른 쓰레드의 태스크의 콜백에서 이어 비동기 처리가 계속해서 이어질때 코드 가독성이 낮아진다.

func loadWebResource(_ path: String) async throws -> Resource{
    // ...
    // ..
}

func fetchData() async throws -> Image{
    let resource = try await loadWebResource("URL");
}

async/await 패턴은 코드 작성을 리턴 타입으로 처리할 수 있다는 점에서 장점이 있다.

# 동시성 프로그래밍 문제점과 해결

코드, 데이터, 힙 영역은 각 쓰레드 모두로부터 공유되는 자원이다. 멀티쓰레드 환경에서 경쟁상황(Race Condition)이 발생할 가능성이 존재한다. 이를 Thread-safe하지 못하다고 표현한다.

메모리 쓰기 작업시에는 Thread-safe처리를(Lock) 해야 한다.

또한 멀티 쓰레드 환경에서는 데드락(교착상태) 또한 발생할 수 있다. 공유자원의 데이터 몇 가지를 Lock해놓은 상황에서 해당 데이터들의 쓰기 작업이 이루어지는 메서드를 호출하게 되었을때 메서드 종료를 하지 못하는 상황을 의미한다.

경쟁상황 문제점의 해결을 위한 방법 중 하나로 메인 쓰레드의 태스크들을 글로벌 큐를 거쳐 각 쓰레드로 분배한 상황에서, 비동기처리가 완료되는 콜백들을 직렬 큐에 쌓는 방식이 있다.

직렬 큐는 순서가 보장되는 큐이므로 Thread-safe를 보장할 수 있다.

# Reference

  1. 인프런 - 앨런 swift 문법 마스터 스쿨 (opens new window)