[Swift] Concurrency Programing


Concurrency Programing in iOS


동시성 프로그래밍

비동기 vs 동기

  • 비동기 (Asynce)
    • 작업을 다른 쓰레드에서 하도록 시킨 후, 그 작업이 끝나길 기다리지 않고 다음 일을 진행
      (기다리지 않아도 다음 작업을 생성할 수 있음)
    • 결과 값을 기다리지 않고, 비순차적 실행 -> Non-Blocking : 제어권 바로 반환
  • 동기 (Sync)
    • 작업을 다른 쓰레드에서 하도록 시킨 후, 그 작업이 끝나길 기다렸다가 다음 일을 진행
      (기다렸다가 다음 작업을 생성할 수 있음)
    • 결과 값을 기다리고, 순차적 실행 -> Blocking : 제어권 바로 반환하지 않음

직렬 vs 동시

  • 직렬(Serial) 처리
    • 보통 메인에서 분산처리 시킨 작업을 다른 한개의 쓰레드에서 처리
    • 순서가 중요한 작업을 처리할 때 사용
  • 동시(Concurrent) 처리
    • 보통 메인에서 분산처리 시킨 작업을 다른 여러개의 쓰레드에서 처리
    • 각자 독립적이지만 중요도나 작업의 성격 등이 유사한 여러개의 작업을 처리할 때 사용
  • 비동기동시는 같은 말이 아님
  • 비동기는 작업을 보내는 쓰레드에 관련된 개념
    • 물리적인 쓰레드에서 실제 동시에 일을 하는 개념
    • 내부적으로 알아서 동작하기 때문에 개발자가 전혀 신경쓸 필요가 없는 영역
  • 동시 처리는 메인 쓰레드에서 다른 쓰레드로 작업을 보낼 때
    • 메인 쓰레드가 아닌 다른 소프트웨어적인 쓰레드에서 동시에 일을 하는 개념
    • 물리적인 쓰레드를 알아서 switching하면서 엄청나게 빠르게 일을 처리

네트워크 통신

서버에 데이터를 요청하는 일은 부하가 많이 걸리는 일

  • 비동기 처리를 하지 않았다면 -> 테이블뷰를 스크롤 할 때 마다 자연스럽게 스크롤 되지 않고 뚝뚝 끊기며 버벅이게 됨

쓰레드 Thread

물리적인 쓰레드 (CPU 코어의 쓰레드) vs 소프트웨어적인 쓰레드 (NSThread 객체)

앱의 시작과 화면을 다시 그리는 원리 (메인 쓰레드의 역할)

  • 앱의 시작
    • 앱 객체 생성
    • 화면 준비
    • 런 루프를 생성
  • 앱이 실행중인 동안
    • 이벤트 발생 (터치 발생, 핀치 줌, 더블 터치 등등)
    • 메인 런루프(객체) : 이벤트 핸들링 객체
    • 앱 객체
    • 업데이트 사이클 (화면 표시)
  • 실제 화면을 다시 그리는 일은 Thread 1(메인 쓰레드)에서 담당
  • 1초에 60번(60Hz) 랜더링 프로세스(코어애니메이션 -> 렌더서버 -> GPU -> 표시)에 따라 화면을 다시 그림
  • 앱이 시작될 때 앱을 담당하는 메인 런루프(반복문)가 생김
  • 이벤트 처리를 담당 -> 어떤 함수를 실행시킬 것인지 선택 / 실행
  • 함수 등의 실행의 결과를 화면에 보여줘야함 -> 화면 다시 그림

iOS에서 동시성을 처리하는 방법

작업(Task)을 대기행렬(Queue)에 보내기만 하면 iOS(운영체제시스템)가 알아서 여러 쓰레드로 나눠서 분산처리(동시적 처리)를 함

iOS 프로그래미의 2가지 대기열

  • DispatchQueue (GCD - Grand Central DispatchQueue)
  • OperationQueue
  • 직접적으로 쓰레드를 관리하는 개념이 아닌, 대기열(Queue)의 개념을 이용해서, 작업을 분산시키고, OS에서 알아서 쓰레드 숫자(갯수)를 관리
  • (쓰레드 객체를 직접 생성시키거나 하지 않는) 쓰레드보다 더 높은 레벨/차원에서 작업을 처리
  • 메인쓰레드가 아닌 다른 쓰레드에서 오래걸리는 작업(ex.네트워크 처리)들과 같은 작업들이 쉽게 비동기적으로 동작하도록 함
물리적인 ThreadOS 영역 (Thread Pool)소프트웨어적인 Thread
실제 물리적인 계산을 실행물리적인 Thread와 1대1 매칭이 아님
NSThread 객체의 모음
대기열 Queue를 통한 작업 분배

GCD

  • DispatchQueue(GCD) 디스패치큐
    • (글로벌) 메인큐 : DispatchQueue.main
      • 유일한 한개
      • 직렬
      • 메인 쓰레드(1번 쓰레드)를 의미
    • 글로벌 큐 : DispatchQueue.global()
      • 종류가 여러개
      • 기본 설정 동시
      • 6종류의 QoS(Quality Of Service)
    • 프라이빗(Custom) 큐 : DispatchQueue(label: “…”)
      • 커스텀으로 만드는 큐
      • 기본 설정 직렬(Serial)
      • QoS(설정 가능)
  • OperationQueue 오퍼레이션큐
    • 메인큐 : OperationQueue.main
    • 프라이빗(Custom) 큐 : OperationQueue()
큐의 종류
생성 코드
특 징
직렬 / 동시
Dispatch
Queue
(GCD)
(Class)
.main
DispatchQueue.main
메인큐 = 메인쓰레드 (1번 쓰레드)
(UI 업데이트 내용 처리하는 큐)
Serial
직렬
.global()
DispatchQueue.global()
6가지 QoS (작업에 따라 QoS 상승 가능)
(시스템이 우선순위에 따라 더 많은 쓰레드를 배치하고, 배터리를 더 집중해서 사용하도록 함)
Concurrent
동시
custom
(프라이빗)
DispatchQueue(label: "...")
QoS 추론 / QoS 설정 가능
디폴트 : Serial
둘다 가능
(attributes로 설정)
OperationQueue
(Class)
let opQ = OperationQueue()
디폴트: .background
기반(underlying) 디스패치 큐에 영향 받음
(unspecified를 제외한 5가지)
디폴트 : Concurrent
둘다 가능
(maxConcurrentOperationCount로 사용할 쓰레드 갯수 설정 가능)

큐의 서비스 품질(Quality Of Service)

서비스품질 수준사용 상황소요 시간
.userInteractive유저와 직접적 인터렉티브: UI 업데이트 관련(직접X), 애니메이션, UI 반응 관련 어떤 것이든
(사용자와 직접 상호 작용하는 작업에 권장. 작업이 빨리 처리되지 않으면 상황이 멈춘 것처럼 보일만한)
거의 즉시
.userInitiated유저가 즉시 필요하긴 하지만, 비동기적으로 처리된 작업
(ex. 앱 내에서 pdf 파일을 여는 것과 같은, 로컬 데이터베이스 읽기)
몇 초
.default일반적인 작업-
.utility보통 Progress Indicator와 함께 길게 실행되는 작업, 계산
(ex. IO, Networking, 지속적인 데이터 feeds)
몇 초에서 몇 분
.background유저가 직접적으로 인지하지 않고 (시간이 중요하지 않은) 작업
(ex. 데이터 미리 가져오기, 데이터베이스 유지보수, 원격 서버 동기화 및 백업 수행)
몇 분 이상
(속도보다는 에너지 효율성 중시)
.unspecifiedlegacy API 지원
(쓰레드를 서비스 품질에서 제외시키는)
-

GCD 사용시 주의사항

  • 메인 큐에서는 항상 비동기적으로 보내야함
    • 메인큐에서는 다른큐로 보낼때 sync 메서드를 호출하면 절대 안됨
    • UI와 관련되지 않은 오래걸리는 작업(네트워크)들은 다른 쓰레드에서 일을 할 수 있도록 비동기적(async)으로 실행하여야 하며, 동기적으로 시키면 UI가 멈춰서 유저한테 반응을 늦게 하고 버벅거릴 수 있음
  • 현재의 큐에서 현재의 큐로 동기적으로 보내서는 안됨
    • 현재의 큐를 블락하는 동시에 다시 현재의 큐에 접근하기 때문에 교착상황(DeadLock)이 발생

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

  • 메인 Thread : 화면을 다시 그리는 역할
    • UI 관련 일들은 다시 메인쓰레드로 보내야 함
DispatchQueue.global(qos: .utility).async {
    // 코드
    self.textLabel.text = "New posts updated!"
}
  • 에러 발생
    • UI와 관련된 작업들은 메인 쓰레드에서 처리하지 않으면 에러가 발생
      (메인 쓰레드가 아닌 쓰레드는 그림을 다시 그리지 못함)
DispatchQueue.global(qos: .utility).async {
    // 코드

    // UI 관련 일이기 때문에 그림을 다시 그리는 작업은 메인 큐에서
     DispatchQueue.main.async {
        self.textLabel.text = "New posts updated!"
    }
}
  • 메인 쓰레드
    • UI와 관련된 작업들을 메인 쓰레드에서 처리할 수 있도록 메인 큐를 통해서, 작업을 다시 메인 쓰레드로 보냄

컴프리션핸들러의 존재 이유 - 올바른 콜백 함수의 사용

  • 일을 시작시키면 작업이 끝날때까지 (1번 쓰레드 입장에서는) 비동기적으로 실행시킨 태스크를 기다리지 않음
    • 결국 비동기 작업이 명확하게 끝나는 시점을 알고, 어떤 작업을 할 필요가 있음
    • @escaping (데이터) -> Void
      • 데이터를 리턴으로 전달하면 안되고, 클로저로 콜백 해주어야 함

잘못된 함수 설계

// 잘못된 함수 설계의 예시 (이렇게 설계하면 절대 안됨)
func getImages(with urlString: String) -> UIImage? {
    
    let url = URL(string: urlString)!
    
    var photoImage: UIImage? = nil
    
    // URL세션은 내부적으로 비동기로 처리된 함수
    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 이 나옴
    // URL 세션은 그 자체로 비동기 처리를 하기 때문
}

getImages(with: "https://...URL...") // 무조건 nil로 리턴함
  • 비동기적인 작업을 해야하는 함수를 설계할 때, return을 통해서 데이터를 전달하려면 항상 nil이 반환

올바른 함수 설계

// 올바른 함수 설계의 예시
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: "https:/...URL...") { (image) in
    
    // 처리 관련 코드
    
    DispatchQueue.main.async {
        // UI 관련 작업의 처리
    }
    
}
  • 비동기적인 작업을 해야하는 함수는 항상 클로저를 호출할 수 있도록 함수를 설계해야 함

Weak, Strong 캡쳐의 주의

  • 강한 참조
    • 캡쳐리스트 안에서 weak self로 선언하지 않으면 강한 참조
      • 서로를 가리키는 경우 메모리 누수 (Memory Leak) 발생 가능
      • (메모리 누수가 발생하지 않아도) 클로저의 수명주기가 길어지는 현상이 발생할 수 있음
  • 약한 참조
    • 대부분의 경우, 캡쳐리스트 안에서 weak self로 선언하는 것을 권장
// (캡쳐리스트 + 약한참조) 선언하지 않으면 기본적으로 강한 참조
DispatchQueue.global(qos: .utility).async { 
    // 코드
    DispatchQueue.main.async {
        self.textLabel.text = "New posts updated!"
    }
}

// 클로저이므로 (캡쳐리스트 + 약한참조) 선언 해야 함
DispatchQueue.global(qos: .utility).async { [weak self] in
    guard let self = self else { return }
    // 코드
    DispatchQueue.main.async {
        self.textLabel.text = "New posts updated!"
    }
}

Async / await

  • Swift 5.5부터 도입
  • 비동기 함수를 이어서 처리하는 코드상의 불편함 해결
// 작업을 오랫동안 실행하는 비동기 함수가 있다고 가정
func longtimePrint(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        print("프린트 - 1")
        sleep(1)
        print("프린트 - 2")
        sleep(1)
        print("프린트 - 3")
        completion(7)
    }
}

// 비동기함수의 일이 종료되는 시점을 연결하기 위해, 끊임없는 콜백함수의 연결이 필요
func linkedPrint(completion: @escaping (Int) -> Void) {
    longtimePrint { num in
        // 코드
        longtimePrint { num in
            // 코드
            longtimePrint { num in
                // 코드
                longtimePrint { num in
                    // 코드
                    completion(num)  // 모든 비동기함수의 종료시점을 알려줌
                }
            }
        }
    }
}
  • 함수에서 async (비동기 함수)로 정의, 리턴 방식 사용
  • 실제 사용 시, async로 정의된 함수는 await 키워드를 통해 리턴 시점을 기다릴 수 있음

CompletionHandler

  • 작업이 아직 종료하지 않았는데, 해당 값에 접근하면, 잘못된 값을 사용할 확률이 높음
  • 해당 비동기 작업이 끝났다는 것을 정확히 알려주는 시점이 컴플리션핸들러
  • 비동기 함수와 관련된 작업들은 모두 컴플리션핸들러를 가지고 있음
// 작업을 오랫동안 실행하는 비동기 함수가 있다고 가정
func longtimeAsyncAwait() async -> Int {
    // 내부에 따로 DispatchQueue로 보낼 필요는 없음
    print("프린트 - 1")
    sleep(1)
    print("프린트 - 2")
    sleep(1)
    print("프린트 - 3")
    return 7
}

// 콜백함수를 계속 들여쓰기 할 필요없이 반환시점을 기다릴수 있어, 깔끔한 코드의 처리가 가능
func linkedPrint2() async -> Int {
    _ = await longtimeAsyncAwait()
    _ = await longtimeAsyncAwait()
    _ = await longtimeAsyncAwait()
    _ = await longtimeAsyncAwait()
    return 7
}

동시성 프로그래밍의 메모리 구조

  • 코드 / 데이터 / 힙 / 스택
    • 여러개의 스택을 만들어서 비동기 처리 (멀티 쓰레딩)
    • 코드, 데이터, 힙 영역의 메모리는 공유

동시성 프로그래밍 문제점

  • 경쟁 상황 / 경쟁 조건 (Race Condition)
    • 멀티 쓰레드의 환경에서, 같은 시점에 여러개의 쓰레드에서 하나의 메모리에 동시 접근 하는 문제
    • Thread-Safe 하지 않음
    • 같은 시점에 동시에 접근을 하지 못하도록 잠금으로서 해결 가능
  • 교착 상태
    • 멀티 쓰레드의 환경에서, 배타적인 메모리 사용으로 일이 진행되지 않는 문제
    • 서로 잠그고 점유하려 하면서 메서드의 작업이 종료도, 진행도 되지 않는 상태

동시성 프로그래밍의 문제점 해결 방안

// 배열은 여러쓰레드에서 동시에 접근하면 문제가 생길 수 있음

var array = [String]()

let serialQueue = DispatchQueue(label: "serial")

for i in 1...20 {
    DispatchQueue.global().async {
        print("\(i)")
        //array.append("\(i)")
        // 동시큐에서 실행해서 동시다발적으로 배열의 메모리에 접근하면 문제가 생김 (데이터 유실)
        
        serialQueue.async {  // 동시 큐에서 실행하고 직렬 큐에서 접근
            array.append("\(i)")
        }
    }
}

Thread-safe 하지 않을 때 동시 큐에서 직렬 큐로 보내어 처리하면 문제가 생기지 않음

UI를 메인 쓰레드에서 업데이트 해야하는 이유

  • UIKit의 모든 속성을 Thread-safe하게 설계하면, 느려짐과 같은 성능저하가 발생할 수 있기 때문에 그렇게 설계할 수 없음
    (Thread-safe하지 않게 설계한 것은 애플의 의도)
  • 메인 런루프(Runloop)가 뷰의 업데이트를 관리하는 View Drawing Cycle을 통해 뷰를 동시에 업데이트 하는 그런 설계를 통해 동작하고 있는데, (메인쓰레드가 아닌)백그라운드 쓰레드가 각자의 런 루프로 그런 동작을 하게되었을때, 뷰가 제멋대로 동작할 수있음
    (ex. 기기를 회전 했을때, 뷰의 레이아웃이 동시에 재배치되는 그런 동작을 못하게 될 수도 있음)
  • iOS가 그림의 그리는 렌더링 프로세스 (코어애니메이션 -> 렌더서버 -> GPU -> 표시)가 있는데, 여러 쓰레드에서 각자의 뷰의 변경사항을 GPU로 보내면 GPU는 각각의 정보를 다 해석해야하니 느려지거나, 비효율적이 될 수 있음
  • Texture나 ComponentKit이라는 페이스북에서 개발한 비동기적 UI 프레임워크가 있긴 하지만, 그조차도 View Drawing Cycle가 유사한 방식으로 적절한 타이밍에 메인 쓰레드에서 동시에 업데이트 하도록 하고 있음
  • iOS뿐만 아니라 다른 OS에서도 위와 유사한 이유들 때문에 UI업데이트는 메인쓰레드에서 이루어지도록 설계되었음

참고

Ellen Swift Class






© 2021. by hminkim