참고로 해당 포스팅은 기존 Concurrency 영상 리뷰 중 역대급으로 내용이 복잡하고 어렵다...!
이전 포스팅을 읽지 않았다면 정독하고 오기를 권장하고, 당장 복잡한 내용을 이해하기 싫다면 스킵하는 것도 나쁘지 않다.
Reference
미리 알면 좋은 운영체제 상식
CPU는 1개 이상의 코어로 이루어져 있다. (멀티 코어 CPU = 하나의 CPU에 여러개의 코어를 갖는 CPU)
1개의 코어는 1개의 Thread를 실행할 수 있다. (hyper threading 기술로 1코어당 스레드 2개씩 돌릴 수 있긴한데.. 여기선 배제한다)
CPU는 1개 이상의 코어로 가지며, 코어의 갯수는 CPU 칩에 따라 다르다.
- Apple watch의 코어 갯수는 2개이다.
- iPhone은 6개의 코어를 가진다.
Serial Queue vs Concurrent Queue
(sync/async 랑은 다른 개념이다.)
Serial Queue
- 하나의 스레드가 큐 구조에 작업을 하나씩 적재하고, 그 큐에서 하나씩 꺼내서 작업을 실행시킨다.
- 반드시 순서대로(큐 구조 = FIFO) 호출되기 때문에 mutual exclusion(= 상호 배제)를 지원한다.
- 스레드 안전성을 확보할 때 사용한다.
- 예시) 데이터베이스에 write 할 때 (write 작업은 반드시 exclusive 해야한다. 특히 write이 오래 걸리는 경우)
Concurrent Queue
- 여러개의 스레드를 동시에 수행한다.
- Serial Queue 와 마찬가지로 큐 구조이기는 하나 여러개의 스레드에서 접근이 가능한 상황이기 때문에 먼저 호출된 함수가 먼저 실행됨을 보장하지 못한다. 즉, mutual exclusion(= 상호 배제)를 지원하지 않는다.
- 데이터에 대한 동시 접근 문제가 없고, 여러 작업을 병렬로 실행해 성능을 최적화할 때 사용한다.
- 예시) 여러개의 이미지를 동시에 다운로드
GCD를 사용한 Serial/Concurrent Queue 사용법
| Serial(직렬) | Concurrent(병렬) | |
| Main Thread | DispatchQueue.main | - |
| Global Thread | DispatchQueue(label: "") | DispatchQueue(label: "", attributes: .concurrent) DispatchQueue.global(qos: .default) |
(참고) Global Concurrent Queue 비교
- DispatchQueue(label: "", attributes: .concurrent)
: 특정 작업들을 label 단위로 그룹핑할 때, 주로 사용한다. - DispatchQueue.global(qos: .default)
: 범용적인 백그라운드 작업을 할 때, 사용한다.
sync vs async
sync
- 작업이 전부 끝날때까지 기다린다.
async
- 작업이 block 되면 다른 작업을 한다. block 된 작업은 추후 언젠지 모르는 시점에 완료된다.
Serial / Concurrent Queue 와 sync / async
| Serial Queue | Concurrent Queue | |
| sync | 스레드 안정성 및 작업 순서 보장 | (거의 사용하지 않음) 모든 작업의 완료를 기다림 |
| async | 작업의 순차 실행 * 하지만 완료되는 순서가 보장되진 않는다. * 스레드 안정성 및 작업 순서 보장되지 않음. |
여러 작업 동시 실행 가능 |
serial / concurrent
- 몇개의 스레드로 실행을 하느냐 (작업 시작 순서가 보장되느냐)
sync / async
- 작업 완료를 기다리느냐 마느냐 (작업 완료 순서가 보장되느냐)
GCD 와 Concurrency 비교
비교를 쉽게하기 위해 아래를 구현해야하는 상황이라고 가정해보자.
- 버튼을 클릭하면 리스트 데이터를 가져와야 한다.
- 단, Database에 리스트 데이터가 없다면, 리스트 데이터(GET API)를 서버에 요청한다.
- 수신된 데이터는 Database에 write한다.
- Database에 리스트 데이터가 있다면, Database에서 read한다.
- 데이터는 UI에 반영한다.
Grand Central Dispatch(= GCD) 사용해서 구현하기
GCD를 사용하면, 아래와 같이 구현될 수 있다.
(왜 이렇게 구현되었는지 까지는 자세히 알 필요 없고, 단순히 아래처럼 구현되었음을 가정하자)
- 버튼 클릭 이벤트는 Main Serial Queue에서 수신된다.
- API 호출은 Global Concurrent Queue(async)에서 실행한다.
- Database write은 Global Serial Queue(sync)에서 실행한다.
- Database read는 Global Serial Queue(async)에서 실행한다.
- UI 업데이트는 Main Serial Queue(async)에서 실행한다.
대략적인 구현된 코드는 아래와 같다.
// 2
let urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: concurrentQueue)
let dataTask = urlSession.dataTask(with: url) { data, response, error in
guard let data else { return }
do {
let list = try deserializeList(from: data)
// 2-1
databaseQueue.sync {
updateDatabase(with: list)
}
} catch { /* ... */ }
}
dataTask.resume()
⚠️ 하지만 이렇게 구현하면, Thread Explosion이 발생할 수 있다..!
우선 Concurrent Queue(= 여러 스레드 사용가능)에 등록된 작업이 많을 때, 스레드가 어떻게 동작하는지 알아야한다.
➡️
async 작업중 세마포어/lock와 같은 이유로 작업중이던 스레드가 block되면,
해당 스레드를 돌리던 CPU코어는 block된 스레드를 따로 저장하고, 새로운 스레드를 생성해 다른 작업을 진행한다.
그렇다면 위의 코드는 무엇이 문제일까?
2-1 작업과 같이 Database에 write하기 위해 Global Serial Queue(sync)로 접근하고 있는데 이 작업으로 스레드가 block될 수 있다.
그러면 위에서 미리 알아봤듯이 block될 때마다 CPU는 새 스레드를 생성해 다른 작업을 하게 된다.
하지만 위 코드가 동시에 여러번 실행된다면 어떻게 될까?
(예를 들어 100개의 url이 있고, 반복문을 사용해 모든 url에 대해 위 코드를 실행한다면?)
아이폰 CPU에는 6개의 코어가 존재하므로, 약 16배(≒ 100/6)나 많은 스레드를 생성하게 되는데, 이것이 바로 Thread Explosion이다!
Thread Explosion(스레드 폭발)
: 시스템이 CPU 코어갯수 보다 더 많은 스레드로 overcommitted된 상태.
문제점
- deadlock(교착상태) 유발
- 일부 스레드가 lock된 상태에서, CPU 점유율이 높아 다른 스레드가 실행될 수 없다면, CPU 작업은 더이상 진전되지 못하고 영구적으로 unlock되지 못할 수 있다.
- 성능 저하
- 메모리 오버 헤드 발생
- block된 각각의 스레드는 stack에 저장된다.
- block된 스레드를 추적하기 위해서는 커널 데이터를 사용해야한다.
- 스케줄링 오버 헤드 발생
- CPU 코어 내에서 작업 스레드가 변경될 때마다 thread context switch가 발생하는데,
과한 context switch는 스케쥴링 지연을 유발한다.
- CPU 코어 내에서 작업 스레드가 변경될 때마다 thread context switch가 발생하는데,
- 메모리 오버 헤드 발생
Concurrency 사용해서 구현하기
GCD에서 발생할 수 있는 Thread Explosion을 보완한 방식이다.
- GCD에서는 스레드를 계속 생성할 수 있었다면,
- Concurrency는 CPU 코어 갯수 만큼의 스레드만 존재한다. (thread context switch가 없는 모델 사용)
Concurrency는 Thread Context Switch를 하는 대신 하나의 스레드 안에서 continuation을 switch 하도록 구현되었다.
(성능상 continuation switch가 thread switch보다 훨씬 가볍다. 함수 호출 비용 만큼만 소요된다.)
단, 스레드가 block되지 않을 것임이 반드시 보장되어야 한다!
(스레드 block을 방지하기위해 지금의 Swift Concurrency 디자인이 되었다)
(이러한 보장을 runtime contract 라고 한다)
GCD에서 구현된 로직을 Concurrency로 구현하면 아래와 같다.
- 2번, 2-1번 작업을 async 함수로 변경하고,
- for문을 withThrowingTaskGroup 로 감싸고,
- 변경된 async 함수를 group.async 내부에서 호출하면 된다.
(async-await을 사용하면서 serial/concurrent 와 async/sync 구분없이 사용하게 됨)
await withThrowingTaskGroup(of: [Article].self) { group in
for feed in feedsToUpdate {
group.async {
// 2
let (data, response) = try await URLSession.shared.data(from: feed.url)
let articles = try deserializeArticles(from: data)
// 2-1
await updateDatabase(with: articles, for: feed)
return articles
}
}
}
Await & Continuation
그런데 어떻게 Concurrency에서는 CPU 코어 수 만큼의 스레드만 존재하며, Thread Context Switch가 없는 모델을 사용할 수 있는걸까? 간단한 예시로 Database에 write 하는 로직은 대충 아래와 같다고 가정하고, 실제로 어떻게 동작하는지 살펴보자.
// await updateDatabase(with: articles, for: feed)
func updateDatabase(with article: Article, for feed: Feed) async {
await feed.add(articles)
}
- 비동기(async) 함수를 호출하기위해 await을 만나면, 스레드는 block되지 않고, 작업을 suspend된다.
- 이 때, await 지점 이후의 코드는 heap에 스택구조(Last-In-First-Out)로 저장된다.
- 그리고 스레드는 suspend된 작업을 내버려두고, 다른 작업을 진행한다.
- 다른 작업을 진행하고 있던 스레드가 해제되고, suspend되었던 작업도 재게될 수 있게되면,
heap에 저장해두었던 continuation을 다시 가져와 resume한다.
➡️ 따라서 Swift Concurrency의 async-await 구조는 스레드를 block하지 않고 비동기 작업을 수행할 수 있다!
Tracking Task Dependency
: Swift 런타임이 코드 내의 task들 사이에 존재하는 실행 순서와 의존 관계를 명확하게 파악하고 관리하는 기능이다.
예를 들어 아래와 같은 케이스에서 Dependency를 확인할 수 있다.
- 위에서 설명했듯이 await 키워드를 만나면, 그 아래 로직(= 코드)은 continuation이 되고,
이 continuation은 await된 async 함수가 완료(= resume)되어야만 실행될 수 있다. - 부모 task가 여러 자식 task를 생성할 경우, 런타임은 부모 task가 진행되기 위해 자식 task들이 모두 완료되어야 한다는 계층적 의존성을 명확히 인식한다.
즉, Swift에서 Task들은 런타임에 등록된 Task(= continuation / child tasks)들만 await할 수 있다.
동일한 스레드 내에서 위 케이스 외의 Task를 수행하는것을 방지하는 효과가 있다.
이러한 Tracking 기능을 통해 Swift는 Forward progress of threads를 보장하는 '런타임 계약(Runtime contract)'을 유지할 수 있다.
Cooperative thread pool
Swift Concurrency의 Runtime contract를 지원하기 위해
새로운 개념인 Cooperative thread pool이 도입되었고, 이는 아래를 보장한다.
- 비동기 코드(예: async 함수, Task, Actor 등)가 실행될 때, 개발자가 별도로 특정 실행 환경(예: MainActor)을 지정하지 않으면 기본적으로 이 Cooperative thread pool 위에서 실행된다.
- GCD와 달리 최대 CPU 코어 갯수만큼만 스레드를 생성할 수 있다.
- Worker thread는 block되지 않는다.
- Thread explosion 및 과도한 Context switching이 발생하지 않는다.
Concurrency 도입시 주의사항
자, 이제 어떻게 Concurrency가 동작하는지 알아봤으니 코드에 적용해보자. 단, 아래 주의사항을 유념해야한다.
Performance
동시성(= Concurrency)에는 비용이 따른다.
- 새로운 Task를 생성하고 관리하는 데는 추가적인 메모리 할당과 런타임 로직이 필요하다.
Task를 생성하고 관리하는 비용보다 이점이 클 때 도입한다.
- 예를 들어,
단순히 UserDefaults에서 정수 값을 하나 읽어오는 것과 같이 아주 사소한 작업을 위해 별도의 Child Task를 만드는 것은 비효율적이다.
Concurrency를 도입했을 때, 실제 성능이 개선되는지 Instruments system trace를 통해 측정하고 검증한다.
await 전후의 원자성(= atomicity)
await 키워드는 atomicity를 파괴하는 키워드이다.
await 이전 코드를 실행한 스레드와, await 이후의 코드(Continuation)를 실행하는 스레드가 다를 수 있다!
따라서
- await를 사이에 두고 락(Lock)을 걸면 안된다.
- 특정 스레드에 저장해 둔 데이터가 유지된다고 보장할 수 없다.
Runtime Contract
코드 작성시, Runtime Contract(= Forward progress of threads)를 해치지 않아야 한다.
그러기 위해선 아래 방법들을 사용할 수 있다.
- await, Actor, Task Group 사용하기 (안전)
- Swift의 Cooperative thread pool 환경에서 스레드가 멈추지 않고 계속 작업을 수행할 수 있다는 '런타임 계약(Runtime contract)'을 위반하지 않는 동시성 제어 도구들이다.
- 의존성이 컴파일 타임에 명확히 파악되기 때문에, Swift 컴파일러가 Runtime Contract가 유지되도록 강제한다.
- os_unfair_lock, NSLock 사용하기 (주의 필요)
- 동기 코드 내에서 데이터 동기화를 위해 사용된다면, 안전하다.
경합 상황에서 스레드를 잠깐 차단(Block)할 수는 있지만, 락(Lock)을 획득한 스레드가 락을 해제하기 위해 항상 앞으로 작업을 진행할 수 있으므로 전진 진행 계약을 위반하지 않는다. - 이 락들은 컴파일러가 올바른 사용을 도와주지 않으므로 주의해서 사용해야 한다.
- 동기 코드 내에서 데이터 동기화를 위해 사용된다면, 안전하다.
- DispatchSemaphores, pthread_cond, NSCondition, pthread_rw_lock, etc.. (안전하지 않음)
- Swift 런타임으로부터 의존성 정보를 숨긴 채로 코드 실행 흐름에 의존성을 만들어내며, 결과적으로 스레드를 다른 스레드가 깨워줄 때까지 무기한 대기(Block)시켜 Runtime Contract를 위반할 위험이 크다.
- 아래 예시처럼 Task 범위를 넘어서 해당 도구들을 사용하지 않아야 한다.
- LIBDISOATCH_COOPERATIVE_POOL_STRICT 환경변수를 1 로 설정하면,
잘못 설정된 blocking 이 있는지 확인 가능하다.
func updateDatabase(_ asyncUpdateDatabase: @Sendable @escaping () async -> Void) {
let semaphore = DispatchSemaphore(value: 0)
Task {
await asyncUpdateatabase()
semaphore.signal()
}
semaphore.wait()
}
동기화 (Synchronization)
그렇다면 Concurrent 환경에서의 동기화는 어떻게 구현할 수 있을까?
Swift의 Actor는 아래의 특성들을 가지며, Concurrent 환경에서의 동기화를 구현할 때 사용한다.
1. 상호 배제 (Mutual exclusion)
- Actor는 한 번에 최대 하나의 메서드 호출만 실행되도록 보장하여 완벽한 상호 배제를 제공한다.
- Actor의 Mutual State에 여러 스레드가 동시에 접근할 수 없게 되어 데이터 경합(Data race)을 원천적으로 방지한다.
🤔 그렇다면 아래와 같이 다른 형태의 Mutual exclusion과는 뭐가 다를까?
아래와 같이 Serial Queue를 예시로 들어보자.
databaseQueue.sync { updateDatabase(articles, for: feed) }
- 경합(contention)이 없는 경우(= 큐가 돌고 있지 않음)라면,
호출된 스레드는 큐에 등록된 작업을 실행하기 위해 context switch 없이 재사용된다. - 경합이 있는 경우(= 큐가 이미 사용되고 있음)라면,
호출된 스레드는 block된다. 이러한 block은 Thread Explosion을 발생시키는 원인이 된다.
이는 Lock을 사용할 때에도 동일하다.
그렇다면 아래처럼 async하게 호출한다면 어떨까?
databaseQueue.async { /* background work */ }
- 경합이 있는 경우에도 block되지 않는다. 따라서 Thread Explosion을 발생시키지 않는다.
- 하지만, 경합이 없는 경우에 async 블록내의 코드를 실행할 때 새로운 스레드를 생성시킨다. (호출된 스레드는 다른 작업을 진행함)
따라서 async를 많이 사용하게 되면, 스레드를 과하게 생성하게 되고 context switch도 빈번하게 일어난다.
👉 이러한 단점들 때문에 Actor를 사용하기를 권장한다.
Actor는 Cooperative Pool에 의해 효과적으로 관리되기 때문에 아래의 이점을 갖는다.
- 경합이 없는 경우에는 호출된 스레드를 재사용하고
- 경합이 있는 경우에도 호출된 스레드는 해당 작업을 suspend할 뿐 스레드가 block되지 않는다.
(= 호출된 스레드는 다른 작업을 하게 된다)
😲 그렇다면 Actor Hopping이 발생할 때에는 어떻게 동작할까?
(Actor Hopping = 코드의 실행 흐름이 한 Actor에서 다른 Actor로 전환되는 과정)
- 경합이 없는 경우
- A액터에서 B액터로 직접 호핑할 수 있다.
- 이 과정에서 A액터를 진행하던 스레드가 유지되며, 스레드가 block되지 않는다.
- 런타임은 A액터의 작업중이던 항목을 suspend하고, B액터에 대한 새 작업 항목을 생성한다.
(이때, suspended actors는 별도로 관리된다)
- 경합이 있는 경우
- A액터가 이미 작업을 처리 중일 때 B액터로 부터 A액터의 작업에 접근하면,
A액터는 이미 활성 작업 항목이 있으므로 새로운 작업 항목을 생성한다. - 하지만 Mutual exclusion의 특성으로 새로 생성된 작업 항목은 바로 실행되지 않고, 보류한다.
- 이 과정에서 B액터에서 작업중이던 항목은 suspend되며, B액터를 호출하던 스레드는 해제되어 다른 작업(ex: C액터의 작업 항목)을 진행한다.
- 이후, A액터에서 작업중이던 항목이 완료되면 런타임은 보류된 A액터의 작업 항목을 실행하거나 suspend된 다른 액터의 작업 항목을 resume하거나 아예 다른 작업을 수행할 수도 있다.
- A액터가 이미 작업을 처리 중일 때 B액터로 부터 A액터의 작업에 접근하면,
2. 재진입성과 우선순위 지정 (Reentrancy and prioritization)
특히나 경합이 많은 경우, 시스템은 우선순위를 따져 무엇을 먼저 실행할지 결정해야한다. (trade-offs 발생)
보통은 유저 인터랙션에 의한 작업인 경우가 백그라운드에서 진행되어야하는 작업보다 높은 우선순위를 가질 것이다.
그렇다면 우선순위는 어떻게 정해지는 걸까?
일단, 기존 GCD의 경우 어떻게 동작하는지 살펴보자.
// User Initiated (우선순위 ⬆️)
databaseQueue.async { fetchLatestForDisplay() }
// Background (우선순위 ⬇️)
databaseQueue.async { backUpToiCloud() }
GCD의 Serial Queue는 엄격한 선입선출(FIFO) 모델을 따랐기 때문에, 중요도가 낮은 작업들이 먼저 큐에 쌓여 있으면,
UI 업데이트 같은 높은 우선순위의 작업이 끝없이 대기해야 하는 우선순위 역전(Priority inversion) 문제가 발생했다.
직렬 큐는 큐의 모든 작업의 우선순위를 높여 이를 해결하려고 하지만, 이는 핵심 문제를 해결하지 못했다.
👉 이러한 문제가 있어 Actor를 사용하기를 권장한다.
액터는 재진입성(reentrancy) 개념 덕분에 시스템이 작업의 우선순위를 잘 지정할 수 있도록 설계되었다.
(액터의 재진입성 = 액터가 일시 중단된 동안 새로운 작업 항목이 진행될 수 있음을 의미)
// User Initiated (우선순위 ⬆️)
await databaseQueue.fetchLatestForDisplay()
// Background (우선순위 ⬇️)
await databaseQueue.backUpToiCloud()
따라서 액터는 엄격한 선입선출(FIFO) 모델을 따르지 않고도 작업 항목을 실행할 수 있다.
즉, 런타임은 높은 우선순위 작업을 저우선순위 작업보다 먼저 실행되도록 선택할 수 있다는 의미이다.
이는 우선순위 역전 문제를 직접 해결하고 더 효과적인 스케줄링 및 리소스 활용을 가능하게 한다. (= Actor reprioritization)
3. 메인 액터 (Main actor)
메인 액터는 시스템의 메인 스레드 개념을 추상화하므로 특성이 다소 다르다.
사용자 인터페이스를 업데이트할 때 반드시 MainActor와 호출을 주고받아야 한다.
메인 스레드는 Cooperative Pool의 스레드와 분리되어 있으므로 Context Switch가 필요합니다.
(= 메인 액터를 실행하는 스레드는 Cooperative Pool에 의해 관리되지 않는다)
@MainActor func updateArticles(for ids: [ID]) async throws {
for id in ids {
// Context Switch 발생!! (Main Actor → Custom Actor)
let article = try await database.loadArticle(with: id) // Custom Actor
// Context Switch 발생!! (Custom Actor → Main Actor)
await updateUI(for: article) // Main Actor
}
}
위와 같이 반복문 내에서 메인 액터와 그 외 액터 간에 자주 호핑하는 코드는
과도한 Context Switch는 발생시키고, overhead를 야기한다.
애플리케이션이 컨텍스트 스위치에 많은 시간을 소비한다면,
아래와 같이 배치(batch) 처리하도록 코드를 재구성해Context Switch 수를 줄여야 한다.
// for문 제거됨
@MainActor func updateArticles(for ids: [ID]) async throws {
// Context Switch 발생!! (Main Actor → Custom Actor)
let articles = try await database.loadArticle(with: ids) // Custom Actor
// Context Switch 발생!! (Custom Actor → Main Actor)
await updateUI(for: articles) // Main Actor
}
Cooperative Pool의 액터 간 호핑은 빠르지만, MainActor와의 호핑은 비용이 많이 들어 주의해야 한다는 사실을 기억하자!
유익했다면 댓글/공감 남겨주세요~~ 작성자에게 큰 힘이 됩니다 ☺️
(이전 글) 👉 Swift Concurrency - Swift Actors로 Mutable State 보호하기 (4)
(다음 글) 👉 TO BE CONTINUE...
'Swift' 카테고리의 다른 글
| Swift Concurrency - Swift Actors로 Mutable State 보호하기 (4) (0) | 2026.02.24 |
|---|---|
| Swift Concurrency - Task cancellation과 hierarchy 심화 (3) (1) | 2026.01.14 |
| Swift Concurrency - Structured Concurrency & Task (2) (0) | 2025.10.01 |
| Swift Concurrency - Async Await (1) (0) | 2025.09.27 |
| Swift Concurrency - 시작하기 전에 (0) (0) | 2025.09.27 |