안녕하세요
비동기 프로그래밍에 대해 공부한 내역을 정리해 보려고 작성한 포스팅입니다.
기본적으로 iOS프로그래밍은 2가지 비동기 처리방식을 제공합니다.
바로 GCD와 비교적 최근에 도입된 Swift concurrency입니다.
무언가 새로운 것을 만들어 냈다는 것은 기존의 문제를 해결자는 의지에서 나온다고 생각합니다.
따라사 애플이 Swift concurrency를 통해서 해결하고자 했던 문제가 무엇이었는지에 대해 다뤄 보려고 합니다.
※ 쓰레드는 특정시점에 하나의 작업을 실행할 수 있으며, 작업의 단위는 코드 블록(하위블록 포함)이다.
GCD
오브젝티브C 부터 사용된 비동기 프로그래밍 방식입니다.
GCD는 DispatchQueue를 통해 작업을 조율합니다.
등록된 작업들을 FIFO방식으로 쓰레드를 사용하여 실행합니다.
작업 큐는 대표적으로 3가지로 분류됩니다.
1. 메인 큐 2. 전역 큐 3. 커스텀 큐
메인큐
메인 큐는 메인쓰레드로 작업을 실행하는 큐를 의미합니다.
직렬(Serial) 큐로 정의되어 있습니다.
직렬 큐는 하나의 쓰레드를 사용하여 작업을 처리합니다.
하나의 쓰레드를 사용하기는 하지만 작업을 등록하는 방식(동기/비동기)에 따라
해당 작업의 종료를 기다릴 것인지 말것인지를 결정할 수 있습니다.
메인큐에 등록된 비동기 작업은 아래 그림과 같이 처리됩니다.
현재 등록된 동기 작업들을 먼저 수행하며 비동기 작업은 실행 시점이 연기됩니다.
따라서 사용자의 UI가 갑자기 블록되는 효과는 막을 수 있습니다.
하지만, 비동기 작업을 사용하는 경우에도 결국 해당 작업은 메인쓰레드에서 실행됨으로, 쓰레드를 오랬동안 점유하는 작업은 외부 쓰레드를 사용하는 것이 바람직합니다.
메인큐에서의 비동기 작업은 쓰레드를 오랬동안 점유하지는 않지만 즉각적인 반응은 요구하지 않는 작업이 적당합니다.
간단한 UI업데이트(라벨의 텍스트 변경)를 예로 들 수 있습니다.
전역 큐
전역큐는 DispatchQueue.global()을 통해 접근할 수 있습니다.
메인 큐와 달리 전역 큐는 동시성(Concurrent) 큐입니다.
앞서 말씀드린 것과 같이 메인큐는 하나의 쓰레드를 사용하여 작업들을 처리합니다.
하지만 전역큐는 동시성을 보장하기 위해, 다수의 쓰레드를 사용합니다.
전역큐는 동시성 큐임으로 여러 작업이 동시에 실행될 수 있습니다. 따라서 작업의 우선순위가 필요합니다.
그래서 Quality of Service(이하 qos)라는 개념이 존재합니다.
DispatchQueue.global(qos:)를 사용하여 특정 qos큐에 작업을 등록할 수 있습니다.
동시성 큐들은 자신의 작업을 수행할 수 있는 쓰레드풀(다수의 쓰레드 묶음)을 시스템으로 부터 할당받습니다.
qos의 우선순위가 높을 수록 더 많은 수의 쓰레드를 가지는 풀을 큐에 할당하게됩니다.
앞서 말씀드린 것과 같이 하나의 작업은 하나의 쓰레드에서 실행됩니다.
따라서 쓰레드 수가 많다는 것은 쓰레드에 대한 병목현상이 적다는 것을 의미하며 결과적으로 빠른 작업 처리를 의미하게됩니다.
GCD 사용시 유의해야할 점
동기 작업을 등록하는 작업과 등록되는 작업이 같은 큐에 있으면 안된다.
직렬 큐, 동시성 큐에 상관없이 동기로 등록된 작업은 다음작업들이 해당 작업의 종료를 기다립니다.
그런데 만약 해당 작업을 등록하는 작업이 같은 같은 큐를 사용할 경우 데드락이 발생할 수 있습니다.
아래코드의 경우 동시성 큐에 작업이 등록된 후 작업 등록 작업이 쓰레드1 에서 실행됩니다.
DispatchQueue.global(qos: .background).async {
// 작업 등록 작업
DispatchQueue.global(qos: .background).sync {
// 특정 작업
}
// 등록작업 이후 작업
}
그런데 해당 작업이 큐에 작업을 추가하였고, 큐에 추가된 작업은 작업 등록 작업이 끝나기 전에
쓰레드를 할당 받고 실행됩니다. 해당 작업은 동기 작업이기 때문에 등록작업 이후 작업은 잠시 블로킹 됩니다.
정확하게는 작업을 실행 중이던 쓰레드가 블로킹 됩니다.
그런데 앞전에 말씀드린 것처럼 같은 qos를 가지는 큐의 경우 동일한 쓰레드풀을 공유함으로 블로킹된 쓰레드를 할당받을 수 있습니다.
작업의 완료를 기다리는 쓰레드와 작업을 처리해야하는 쓰레드가 동일한 쓰레드임으로 데드락이 발생합니다.
100%는 아니지만 가능성이 있음으로 지양해야 합니다.
동기작업은 큐에 등록되자마자 실행된다는 특징이 있습니다.
이와 동시에 현재 큐에 등록된 다른 작업들을 대기상태로 만들고
이미 실행중이던 작업의 쓰레드를 블로킹 합니다.
DispatchQueue.global(qos: .default).sync {
print("동기작업 1")
}
for i in 0..<10 {
DispatchQueue.global(qos: .default).async {
print("비동기 작업 \(i)")
}
}
DispatchQueue.global(qos: .default).sync {
print("동기작업 2")
}
위코드의 출력결과는 아래와 같습니다.
동기작업 1
동기작업 2
비동기 작업 0
비동기 작업 1
비동기 작업 3
비동기 작업 6
비동기 작업 4
비동기 작업 5
비동기 작업 9
비동기 작업 8
비동기 작업 2
비동기 작업 7
메인 큐의 경우 단 하나의 쓰레드를 공유함으로 100% 데드락이 발생합니다.
GCD의 문제점
GCD에 대한 간략한 설명을 마치고 문제점을 다뤄보겠습니다.
1. 컨택스트 스위칭
GCD에서 쓰레드는 하나의 작업밖에 처리하지 못합니다.
작업당 하나의 쓰레드를 필요로 함으로, 시스템은 동시성 큐에 있는 비동기 작업들을 처리하기 위해 쓰레드를 계속해서 생성합니다.
하지만, 알다시피 쓰레드는 단순 동시성에 눈속임에 불과합니다.
CPU는 코어당 하나의 일을 처리할 수 있으면 쓰레드는 아래 그림처럼 시분할 처리됩니다.
※ 블럭들은 쓰레드를 의미
쓰레드가 작업을 처리하기 위해선 쓰레드가 보유한 스택과 정보들을 사용해야 합니다.
그림의 색깔이 달라지는 지점마다 해당 정보들이 변경됩니다.
해당 동작을 컨텍스트 스위칭이라고 하며, 오버헤드가 증가하는 작업입니다.
2. 메모리 점유
쓰레드는 자체 스택을 가지며 메모리를 점유합니다. 따라서 작업마다 쓰레드를 생성하는 방식은 불필요한 메모리 점유를 야기할 수 있습니다.
3. 데이터 레이스
동시성 큐의 비동기 작업들이 하나의 리소스를 공유하는 경우 데이터 레이스 현상이 발생할 수 있습니다.
NSLock과 같은 처리를 통해 극복할 수 있지만,
만약 자원사용을 마친 작업이 리소스의 lock을 해제하지 않는 실수를 저지른다면 해당 리소스에 영원히 접근하지 못합니다.
4. 비동기 작업들의 콜백함수
GCD는 비동기 작업의 종료를 알리는 방식으로 콜백함수를 사용합니다.
동기화 해야하는 비동기 작업이 많을 수록 해당 방식은 코드 가독성을 크게 감소시킵니다.
5. 작업의 우선순위 문제
GCD는 qos개념이 큐와 작업에 모두 사용됩니다.
만약 큐에비해 작업의 우선순위가 높은 경우, 해당 큐는 해당 작업이 처리되기 전까지 더 높은 qos로 승격됩니다.
작업에 비해 큐가 높은 경우 작업이 승격됩니다.
이로인해 작업처리의 우선순위 제어하기 어려워집니다.
Swift concurrency
1번, 2번 문제
Swift concurrency는 우선 쓰레드 생성을 최소화 하고 싶었습니다.
해당 목표를 위해 쓰레드의 불필요한 점유를 낮추고 재사용성을 높이는 방법을 사용했습니다.
GCD의 경우 동기 작업에 의해 비동기 작업의 쓰레드가 블로킹될 경우,
해당 쓰레드는 메모리를 점유하지만 말그대로 아무일도 하지 않습니다.
Swift concurrency는 해당 블로킹 과정에서 작업이 쓰레드를 점유하지 않고 쓰레드에 대한 제어권을 시스템에 반납합니다.
그리고 블로킹 과정이 끝난이후, 해당 작업은 새로운 쓰레드를 할당받습니다.
이 것을 가능하게 하기위해 Continuation객체를 사용합니다.
해당 객체는 특정 쓰레드의 실행환경 복원을 위한 정보들을 소유하고 있어
작업이 쓰레드가 다르지만 일관된 환경에서 처리될 수 있도록 합니다.
따라서 불필요한 쓰레드의 생성을 막을 수 있고 이는 곧 컨택스트 스위칭의 빈도역시 줄일 수 있습니다.
3번 문제
3번문제의 근본적인 문제는 데이터 레이스 상황을 런타임에서야 발견할 수 있을지도 모른다 라는 점입니다.
Swift Concurrency는 비동기 작업의 기본 단위로 Task라는 타입을 사용합니다.
해당 타입은 작업을 생성한 쓰레드의 Actor격리성을 상속받아 쓰레드 세이프한 환경을 보장합니다.
그리고 격리된 리소스에 대한 접근을 에러로 처리합니다.
컴파일 타임에 데이터 레이스 상황이 발생될 수 있음을 미리알 수 있다는 강력한 장점이 있습니다.
※ GCD도 Swift6부터 해당 동작을 에러로 처리합니다.
Actor는 근본적으로 Sendable프로토콜을 채택합니다.
Sendable은 특정 매서드나 프로퍼티를 요구하는 프로콜이 아닙니다. 대신에 쓰레드 세이프한 타입을 만들기 위한 가이드를 제공합니다.
https://developer.apple.com/documentation/swift/sendable
공식문서에 해당 가이드에 대해 자세하게 명세되있습니다.
4번 문제
await async문법은 매우 직관적인 동기화 코드 작성을 가능하게 합니다.
5번 문제
비동기 작업의 단위인 Task는 priority를 가집니다.
GCD와 달리 작업은 큐에 종속되지 않고 priority에 따라 시스템의해 스케줄링되어 쓰레드를 할당받습니다.
따라서 priority가 작업의 실행순서를 보장합니다.
그래서 성능은?
Swift concurrency는 그래서 얼마만큼의 성능적 우위가 있는 지에 대해 의문이 있어 찾아본 결과
라인 기술블로그에 글을 찾게되어 공유드립니다.
https://engineering.linecorp.com/ko/blog/about-swift-concurrency
다수의 이미지를 불러오는 것과 같은 비동기 처리에는 다이나믹한 성능적차이는 없습니다.
(URLSession의 비동기 처리방식에 읜존적)
하지만 핸들링 가능한 다수의 로직을 비동기로 실행하는 경우, 성능적으로 확실한 이점이 있음을 확인할 수 있었습니다.
긴글 읽어주셔서 감사합니다. Swift 6가 나온 이후 변경점에 대해서 추후 다른 글에서 자세히 다뤄 보도록 하겠습니다.
'iOS공통' 카테고리의 다른 글
[RxSwift] subscribe(on:)과 observe(on:)의 차이를 아시나요? (0) | 2024.08.24 |
---|---|
[자료구조] 해시 테이블(+Swift) (0) | 2024.08.16 |
[RxSwift] self순환 참조에 대해 (0) | 2024.07.19 |
[iOS공통] Coordinator패턴을 적용한 페이지 전환 구현 (0) | 2024.07.01 |
[iOS] Alamofire request flow (1) | 2024.06.15 |