[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.네트워크 처리)들과 같은 작업들이 쉽게 비동기적으로 동작하도록 함
물리적인 Thread | OS 영역 (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(설정 가능)
- (글로벌) 메인큐 : DispatchQueue.main
- 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. 데이터 미리 가져오기, 데이터베이스 유지보수, 원격 서버 동기화 및 백업 수행) | 몇 분 이상 (속도보다는 에너지 효율성 중시) |
.unspecified | legacy API 지원 (쓰레드를 서비스 품질에서 제외시키는) | - |
GCD 사용시 주의사항
- 메인 큐에서는 항상 비동기적으로 보내야함
- 메인큐에서는 다른큐로 보낼때
sync
메서드를 호출하면 절대 안됨 - UI와 관련되지 않은 오래걸리는 작업(네트워크)들은 다른 쓰레드에서 일을 할 수 있도록 비동기적(async)으로 실행하여야 하며, 동기적으로 시키면 UI가 멈춰서 유저한테 반응을 늦게 하고 버벅거릴 수 있음
- 메인큐에서는 다른큐로 보낼때
- 현재의 큐에서 현재의 큐로 동기적으로 보내서는 안됨
- 현재의 큐를 블락하는 동시에 다시 현재의 큐에 접근하기 때문에 교착상황(DeadLock)이 발생
1. 반드시 메인 큐에서 처리해야 하는 작업
- 메인 Thread : 화면을 다시 그리는 역할
- UI 관련 일들은 다시 메인쓰레드로 보내야 함
DispatchQueue.global(qos: .utility).async {
// 코드
self.textLabel.text = "New posts updated!"
}
- 에러 발생
- UI와 관련된 작업들은 메인 쓰레드에서 처리하지 않으면 에러가 발생
(메인 쓰레드가 아닌 쓰레드는 그림을 다시 그리지 못함)
- 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업데이트는 메인쓰레드에서 이루어지도록 설계되었음