프로세스와 스레드라는 운영체제 기초부터 GCD, 그리고 Swift Concurrency의 actor·Task까지 — 같은 '동시성'이라는 주제를 CS 원리와 iOS 구현 두 층위에서 오간다. 각 질문은 30초 답변으로 요지를 먼저 잡고, 원리 → iOS 매핑 → 실험 순으로 파고든다.
Q1. 프로세스와 스레드는 무엇이며 어떤 자원을 공유하는가?
프로세스는 실행 중인 프로그램의 인스턴스로, 독립된 가상 주소 공간·열린 파일·권한을 소유하는 '자원 단위'다. 스레드는 그 프로세스 안에서 CPU가 실제로 실행하는 '흐름 단위'로, 자기만의 스택·레지스터·프로그램 카운터(PC)를 갖되 코드(text)·전역 데이터·힙·열린 파일은 같은 프로세스의 스레드끼리 공유한다. 그래서 스레드 간 통신은 공유 메모리로 값싸지만, 동기화가 없으면 그 공유가 곧 data race의 통로가 된다. 반대로 프로세스 사이는 메모리가 격리돼 안전하지만 IPC 비용이 든다.
CS 원리
프로세스는 '주소 공간 + 자원 소유', 스레드는 '스케줄링(실행) 단위'로 나눠 이해하면 정확하다. 여러 스레드가 공유하는 것은 text·data·heap·열린 파일 테이블·시그널 핸들러 등 프로세스에 귀속된 자원이고, 스레드마다 고유한 것은 스택·레지스터 상태(PC, SP 포함)·TLS(thread-local storage)·커널 스케줄링 상태다. 논리적으로 '스택은 스레드 전용'이지만, 같은 주소 공간이라 하드웨어가 물리적으로 막아주지는 않는다.
iOS에서는
Darwin/XNU에서 프로세스는 Mach task, 스레드는 Mach thread로 표현된다. task가 가상 메모리 맵과 port right 같은 자원을 소유하고, thread가 실제 실행 단위다. POSIX pthread는 이 Mach thread 위에 얹힌 얇은 층이다. iOS 앱은 보통 샌드박스당 하나의 프로세스로 돌고, App Extension은 host 앱과 별개의 프로세스로 실행돼 메모리가 격리된다(그래서 확장이 죽어도 앱 본체는 살아남는다). 화면 갱신은 메인 스레드가 전담한다.
실험 · 도구
LLDB에서 thread list·bt all로 스레드별 스택을 확인하고, Instruments의 System Trace로 스레드 상태 전이를, vmmap으로 프로세스 주소 공간을 관찰할 수 있다. 전역 변수를 두 스레드에서 락 없이 증가시키고 Thread Sanitizer를 켜면, 공유 영역에서 data race가 잡히는 것을 눈으로 볼 수 있다.
프로젝트 적용
- 스레드 간 공유 mutable 상태는 예외 없이 동기화(actor·lock·serial queue)로 감싼다.
- 크래시 격리가 중요하면 무거운 작업을 App Extension 등 별도 프로세스로 분리하는 것을 고려한다.
- TLS는 편의상 남용하기 쉽지만 스레드 재사용 풀에서는 예측 못 한 값이 남을 수 있으니 최소화한다.
"스레드는 메모리를 전부 공유한다"는 부정확하다. 스택은 스레드마다 고유하다. 다만 '고유'는 논리적 소유일 뿐, 같은 주소 공간이므로 한 스레드가 다른 스레드의 스택 주소에 포인터로 접근하는 것은 물리적으로 가능하다 — 하드웨어가 보호하지 않는다. 이 미묘함이 스택 변수 주소를 다른 스레드에 넘겼을 때의 버그를 만든다.
꼬리 질문
- 스택에 있는 지역 변수 주소를 다른 스레드로 넘겨 접근하면 어떤 문제가 생기나?
- 멀티스레드 프로세스에서
fork()를 호출하면 자식에는 스레드가 몇 개 복제되나? - App Extension과 host 앱은 메모리를 공유하나? 데이터를 주고받으려면 무엇을 쓰나?
Q2. 동시성과 병렬성은 무엇이 다른가?
동시성(concurrency)은 여러 작업을 논리적으로 겹쳐 진행할 수 있게 만드는 '구조·설계'의 문제이고, 병렬성(parallelism)은 여러 작업을 물리적으로 같은 순간에 실행하는 '실행'의 문제다. 싱글 코어에서도 시분할로 동시성은 성립하지만, 병렬성은 코어가 둘 이상이라야 가능하다. Rob Pike의 표현대로 "동시성은 여러 일을 다루는 것, 병렬성은 여러 일을 동시에 하는 것"이다. 동시성 있게 설계한 프로그램이 병렬 하드웨어를 만나 병렬성으로 실현된다.
CS 원리
동시성은 작업들의 실행이 시간적으로 interleaving 될 수 있는 구조를 뜻하고, 병렬성은 실제로 동시에(simultaneously) 실행됨을 뜻한다. 병렬성은 동시성이 있어야 성립하는 실행 형태이지, 동시성이 곧 병렬성은 아니다. 병렬화로 얻는 속도 향상에는 상한이 있는데, 순차 부분의 비율이 전체 속도 향상을 제한한다(Amdahl's law).
iOS에서는
GCD의 concurrent queue에 작업을 넣으면 '동시성'을 표현한 것이고, 실제로 병렬 실행될지는 시스템이 코어 수·QoS·부하를 보고 스레드 풀에서 결정한다. Swift Concurrency에서는 async 함수 하나가 동시성을 주지만 그 자체로 병렬은 아니며, async let이나 TaskGroup으로 여러 작업을 동시에 띄워야 병렬 실행 후보가 된다. 실제 병렬 폭은 cooperative thread pool의 너비(대략 코어 수)로 제한된다. Apple Silicon은 성능(P)·효율(E) 코어가 섞여 있어 QoS에 따라 배치가 달라진다.
실험 · 도구
ProcessInfo.processInfo.activeProcessorCount로 가용 코어 수를 확인하고, CPU 바운드 작업을 DispatchQueue.concurrentPerform(iterations:)으로 돌리며 코어 수에 따라 처리 시간이 스케일되는지 관찰한다. Instruments의 CPU 사용률·Points of Interest로 여러 코어가 동시에 점유되는지 눈으로 확인할 수 있다.
프로젝트 적용
- I/O 바운드(네트워크·디스크)는 동시성(async)만으로 충분하다 — 스레드를 더 늘려도 빨라지지 않는다.
- 병렬화는 CPU 바운드 작업에서만 이득이며, 코어 수를 넘는 병렬 스레드는 컨텍스트 스위칭·캐시 미스로 오히려 손해다.
- 순차 의존 구간이 크면 아무리 병렬화해도 상한에 막힌다는 점(Amdahl)을 성능 목표 설정에 반영한다.
"async를 쓰면 병렬로 돌아 빨라진다"는 오해다. async는 동시성을 줄 뿐 자동 병렬이 아니다. 하나의 async 함수 체인은 여전히 순차적으로 진행되며, 병렬은 별도의 Task나 TaskGroup으로 명시해야 생긴다.
꼬리 질문
- 코어가 하나뿐인 기기에서도 동시성 설계가 주는 이득은 무엇인가?
- 병렬화했는데 오히려 느려지는 대표적 원인 두 가지는?
- Amdahl's law는 병렬화로 얻을 수 있는 최대 속도 향상에 어떤 제약을 주나?
Q3. Race condition과 Data race는 무엇이 다른가?
Data race는 둘 이상의 스레드가 동기화 없이 같은 메모리에 접근하고 그중 최소 하나가 쓰기일 때 발생하는, 언어 메모리 모델상 '정의되지 않은 동작(UB)'이다. Race condition은 실행 순서(타이밍)에 따라 결과가 달라지는 더 넓은 논리적 결함으로, 모든 연산이 atomic이어서 data race가 전혀 없어도 발생할 수 있다. 즉 둘은 겹치되 독립적이다 — data race는 메모리 안전 문제, race condition은 정확성(로직) 문제. 그래서 "atomic으로 감쌌으니 안전"이라거나 "TSan을 통과했으니 스레드 안전"이라는 결론은 둘 다 성급하다.
CS 원리
Data race는 형식적으로 정의된다 — 동시에·동기화 없이·같은 위치에·최소 하나의 쓰기. C/C++/Swift 메모리 모델은 이를 UB로 규정하므로, 컴파일러 최적화가 개입해 예측 불가능한 결과를 낼 수 있다. Race condition은 결과가 스케줄에 의존한다는 더 일반적 성질이며, 대표적으로 '확인 후 행동(check-then-act, TOCTOU)'은 각 연산이 원자적이어도 두 연산 사이에 상태가 바뀌면 깨진다.
// 두 연산이 각각 원자적이어도, 그 사이에 다른 스레드가 끼어들 수 있다
if cache[key] == nil { // (1) 확인
cache[key] = compute() // (2) 행동 — 그 사이 다른 스레드가 이미 채웠을 수 있음
}iOS에서는
Swift 6의 complete concurrency checking은 data race를 컴파일 타임에 제거하는 것을 목표로 한다. Sendable과 actor isolation으로 '경계를 넘는 접근'을 정적으로 검증하기 때문이다. 하지만 race condition(로직 순서 결함)은 컴파일러가 잡지 못한다 — actor 안이라도 await 지점에서 다른 호출이 끼어들어(actor reentrancy) 불변식이 깨질 수 있다.
실험 · 도구
Thread Sanitizer(TSan)는 happens-before 관계를 추적해 data race를 런타임에 검출한다. 그러나 TSan은 race condition을 잡지 못한다 — 논리 결함은 스트레스 테스트·불변식 검증·순서에 대한 단위 테스트로 걸러야 한다.
프로젝트 적용
- TSan을 CI에 상시로 켜고 data race 0을 하한선으로 둔다.
- 불변식(예: 잔고는 음수가 될 수 없다)은 원자 변수 하나가 아니라 하나의 임계구역/actor 메서드 안에서 트랜잭션처럼 지킨다.
- actor의
await경계 이후에는 앞서 읽은 상태를 다시 확인한다.
"atomic 변수를 쓰면 race condition이 사라진다"와 "TSan을 통과했으면 스레드 안전하다"는 둘 다 틀렸다. atomic은 data race만 없앨 뿐 TOCTOU 같은 논리 race는 그대로 남고, TSan은 data race만 볼 뿐 로직 결함은 못 본다. 두 문제는 별개의 도구·기법으로 각각 방어해야 한다.
꼬리 질문
- atomic counter만으로는 정확성이 깨지는 구체적 예를 하나 들어보라.
- actor reentrancy가 race condition을 만드는 시나리오는?
- happens-before 관계란 무엇이고 TSan은 이를 어떻게 활용하나?
Q4. Critical section이란 무엇이며 어떻게 보호하는가?
Critical section(임계구역)은 공유 자원에 접근해 한 번에 한 스레드만 실행해야 하는 코드 구간이다. 보호하려면 상호배제(mutual exclusion)를 제공하는 도구 — mutex/lock, semaphore, serial queue, actor — 로 그 구간을 감싼다. 좋은 보호는 상호배제·진행성(progress)·유한 대기(bounded waiting)를 만족해야 하며, 실무에서는 구간을 최대한 짧게 유지하고 그 안에서 블로킹 호출이나 외부 콜백을 부르지 않는 것이 핵심이다.
CS 원리
임계구역 문제는 Dijkstra가 정식화한 상호배제 문제다. 해법은 계층적이다 — 하드웨어 atomic 명령(CAS, test-and-set) 위에 OS primitive(mutex, semaphore)가 서고, 그 위에 언어 수준 추상(monitor, actor)이 있다. 올바른 해법은 세 조건을 지켜야 한다: 동시에 한 스레드만 들어가고(mutual exclusion), 아무도 못 들어가는 교착이 없고(progress), 무한히 기다리는 스레드가 없어야 한다(bounded waiting).
iOS에서는
Apple 플랫폼에서 대표 도구는 os_unfair_lock(과거 스핀락을 대체하며 priority inversion을 회피), NSLock, DispatchSemaphore, serial DispatchQueue, 그리고 Swift Concurrency의 actor(컴파일러가 강제하는 임계구역)다. os_unfair_lock은 재귀 잠금을 지원하지 않으며, iOS 16+에서는 값 복사 위험이 없는 OSAllocatedUnfairLock을 권장한다.
let counter = OSAllocatedUnfairLock(initialState: 0)
counter.withLock { $0 += 1 } // 임계구역: 짧게 · 이 안에서 await·동기 대기 금지실험 · 도구
공유 카운터를 락 없이/락으로 증가시키며 TSan으로 차이를 확인한다. Instruments의 System Trace로 lock contention(락을 기다리며 blocked 상태로 쌓이는 스레드)을 관찰하고, os_signpost로 임계구역 체류 시간을 측정하면 병목을 정량화할 수 있다.
프로젝트 적용
- 임계구역 안에서
await·동기 대기·외부 콜백 호출을 하지 않는다(deadlock·priority inversion의 씨앗). - 락 하나가 보호하는 상태의 범위를 문서화하고, 여러 락을 잡아야 하면 잠그는 순서 규칙을 정해 deadlock을 예방한다.
os_unfair_lock은 struct라 값으로 복사되면 보호가 깨진다 — 반드시 포인터나OSAllocatedUnfairLock으로 다룬다.
"락만 걸면 안전하다"는 위험한 단순화다. 락 범위가 불변식 전체를 덮지 못하거나, 여러 락의 잠금 순서가 스레드마다 엇갈리면 deadlock이 난다. 게다가 os_unfair_lock을 값 타입처럼 복사해 쓰면 서로 다른 락 인스턴스가 되어 보호가 아예 무효가 된다.
꼬리 질문
- spinlock과 mutex는 각각 어떤 상황에서 유리한가?
- 재귀 락(recursive lock)이 필요한 상황과 그 위험은?
- priority inversion이란 무엇이고
os_unfair_lock은 이를 어떻게 다루나?
Q5. Serial queue가 항상 하나의 고정된 Thread를 사용하는가?
아니다. Serial queue가 보장하는 것은 "한 번에 하나의 작업만, 제출한 순서대로" 실행한다는 것이지, 매번 같은 물리 스레드에서 실행한다는 것이 아니다. GCD는 관리형 스레드 풀에서 그때그때 스레드를 배정하므로, 같은 serial queue의 서로 다른 블록이 서로 다른 스레드에서 돌 수 있다. 유일하게 스레드가 고정되는 예외는 main queue(항상 메인 스레드)뿐이다. 그래서 특정 스레드 식별이나 TLS에 의존하는 코드는 serial queue 위에서 깨질 수 있다.
CS 원리
serial queue의 계약은 '직렬화(순차성) + 상호배제'이지 'thread affinity(스레드 고정)'가 아니다. 큐는 무엇을 어떤 순서로 실행할지 정하는 정책이고, 스레드는 그것을 돌리는 실행 자원이다. 순차성만 지켜지면 어떤 스레드가 그 일을 하든 계약 위반이 아니다.
iOS에서는
GCD serial queue는 한 시점에 한 스레드만 이 큐의 작업을 꺼내 실행하지만, 작업과 작업 사이에 그 스레드가 풀로 회수되고 다음 작업은 다른 스레드가 집어들 수 있다. DispatchQueue.main만 main 스레드에 고정된다. 현재 실행 위치를 확인하려면 스레드가 아니라 큐를 식별해야 하며, 이를 위해 DispatchQueue.setSpecific(key:value:)/getSpecific과 dispatchPrecondition이 제공된다.
실험 · 도구
사용자 serial queue에 여러 블록을 제출하고 각 블록에서 pthread_self() 또는 Thread.current를 찍어보면, 값이 블록마다 달라질 수 있음을 확인할 수 있다. 반면 main queue에서는 항상 같은 스레드다. 큐 판별이 필요하면 queue-specific key로 확인한다(스레드 식별이 아님).
프로젝트 적용
- "이 serial queue니까 항상 이 스레드"라는 가정을 코드에 심지 않는다.
- 현재 실행 컨텍스트 확인은
Thread.isMainThread(메인 한정) 또는 queue-specific key로 하고, 임의 스레드 식별에 의존하지 않는다. - 큐 컨텍스트 저장 용도로 TLS를 쓰지 않는다 — 스레드가 재사용/교체되면 값이 어긋난다.
"serial queue는 전용 스레드 하나를 갖는다"는 오해다. 보장되는 것은 순서와 상호배제이지 스레드 고정이 아니다. 스레드가 고정되는 큐는 main queue뿐이며, 그것도 UI 프레임워크가 메인 스레드를 요구하기 때문이지 serial이라서가 아니다.
꼬리 질문
- 왜 main queue만 스레드를 고정해야 하나?
- serial queue에서 TLS에 값을 저장하면 어떤 종류의 버그가 생기나?
- queue-specific key로 '지금 이 큐 위인지'를 안전하게 판별하는 방법은?
Q6. GCD Queue와 Thread는 왜 같은 개념이 아닌가?
Queue는 '무엇을 어떤 순서·정책으로 실행할지'를 담는 작업 대기열이고, Thread는 그 작업을 실제 CPU에서 돌리는 실행 자원이다. GCD의 핵심은 개발자가 스레드를 직접 만들지 않고 큐에 작업을 제출하면, 런타임이 스레드 풀에서 스레드를 붙였다 뗐다 하는 것이다. 그래서 큐 개수와 스레드 개수는 무관하며, 하나의 스레드가 여러 큐의 작업을, 하나의 큐가 시간에 따라 여러 스레드를 거칠 수 있다.
CS 원리
이것은 '작업(task)'과 '실행자(worker)'를 분리하는 스레드 풀 패턴이다. 큐는 생산자-소비자 대기열에 스케줄 정책(serial/concurrent, 우선순위)을 얹은 논리적 개념이고, 스레드는 커널이 스케줄하는 물리적 실행 단위다. 이 분리 덕분에 수많은 작업을 소수의 스레드로 처리할 수 있다.
iOS에서는
GCD는 전역 concurrent queue와 사용자 큐를 관리형 워커 스레드 풀에 매핑한다. 문제는 스레드 폭증(thread explosion)이다 — concurrent queue에 제출된 작업이 동기 블로킹으로 워커를 막으면, GCD는 남은 작업을 처리하려고 새 스레드를 계속 만든다. Swift Concurrency는 이를 개선해 cooperative pool의 스레드 수를 대략 코어 수로 묶는다. qos_class는 워커 스레드의 우선순위에 반영된다.
실험 · 도구
global concurrent queue에 DispatchSemaphore.wait() 같은 동기 대기 작업을 다수 제출하고 Thread.activeCount를 관찰하면 스레드 수가 급증하는 것을 볼 수 있다. Instruments의 System Trace로 스레드 생성 폭증과 그로 인한 스케줄링 오버헤드를 확인한다.
프로젝트 적용
- 블로킹 작업을 global concurrent queue에 대량 제출하지 않는다 — thread explosion으로 오히려 병목/교착이 생긴다.
- 블로킹이 불가피하면 전용 serial queue로 몰거나 동시 실행 개수를 세마포어로 제한한다.
- QoS를 작업 성격(사용자 대기 vs 백그라운드)에 맞게 지정해 우선순위 역전을 줄인다.
"큐를 많이 만들면 스레드가 많아진다" 또는 "큐 하나 = 스레드 하나"는 둘 다 틀렸다. 큐는 논리적 대기열이고 스레드는 물리적 자원이며, 그 매핑은 런타임이 관리한다. 스레드가 폭증하는 진짜 원인은 큐 개수가 아니라 워커를 붙잡는 동기 블로킹이다.
꼬리 질문
- thread explosion은 정확히 어떤 조건에서 유발되고 어떻게 막나?
- Swift Concurrency의 cooperative pool이 GCD 스레드 관리와 다른 점은?
- QoS는 실제 스레드 스케줄링에 어떻게 반영되나?
Q7. DispatchQueue.sync가 Deadlock을 만드는 경우는 언제인가?
현재 스레드가 이미 어떤 serial queue에서 실행 중인데, 그 스레드에서 같은 serial queue로 sync를 호출하면 즉시 deadlock이다. sync는 제출한 블록이 끝날 때까지 호출 스레드를 블록하는데, 그 블록은 자기가 점유한 바로 그 serial queue가 비어야 실행되므로, 서로가 서로의 완료를 영원히 기다린다. 대표 사례가 메인 스레드에서의 DispatchQueue.main.sync {}다. concurrent queue나 다른 큐로의 sync는 일반적으로 이 self-deadlock이 나지 않는다.
CS 원리
sync는 '제출 + 완료까지 호출 스레드 블로킹'이다. serial queue는 한 번에 하나만 실행하므로, 자기 자신을 기다리는 순환 대기(circular wait)가 만들어진다. deadlock의 네 조건 중 hold-and-wait와 circular wait가 여기서 성립한다 — 스레드는 자원(자신의 실행)을 쥔 채 큐가 비기를 기다리고, 큐는 그 스레드가 끝나기를 기다린다.
iOS에서는
// 이미 Main Thread(= Main Queue의 실행 흐름) 위에서
DispatchQueue.main.sync {
print("절대 실행되지 않음") // 여기 도달 못 함 → 앱 정지
}GCD serial queue는 재진입(reentrant)을 지원하지 않아 이 상황이 곧바로 교착이 된다. target queue로 서로 연결된 큐 계층에서도 같은 원리로 발생할 수 있다. 방어 수단으로 dispatchPrecondition(condition: .notOnQueue(queue))를 두면, 잘못된 컨텍스트에서의 sync를 디버그 빌드에서 조기에 잡는다.
프로젝트 적용
- serial queue로
sync하기 전에 현재 그 큐 위가 아닌지dispatchPrecondition으로 방어한다. - 값을 돌려받으려고 습관적으로
sync를 쓰지 말고, 구조를 async + continuation으로 바꾸는 것을 우선 검토한다. - 락과 큐를 섞어 쓸 때는 '큐 진입 → 락' 같은 획득 순서를 프로젝트 전역에서 일관되게 고정한다.
"sync는 어떤 큐로 호출해도 deadlock 위험이 있다"는 과장이다. self-deadlock의 핵심은 '자기가 이미 실행 중인 serial queue로 다시 sync'라는 조건이다. concurrent queue로의 sync는 새 실행 컨텍스트를 얻을 수 있어 이 자기 교착이 나지 않는다(물론 다른 자원을 서로 기다리는 일반적 deadlock은 별개 문제다).
꼬리 질문
- concurrent queue로 sync해도 deadlock이 나는 경우가 있나?
- target queue로 연결된 큐 계층에서 sync가 교착을 만드는 시나리오는?
- sync가 async보다 오히려 유리한 경우(스레드 도약 회피 등)는?
Q8. Swift Concurrency의 Task는 Thread인가?
아니다. Task는 비동기 작업의 논리적 단위 — 실행할 코드에 우선순위·취소 상태·task-local 값이 묶인 것 — 이지 스레드가 아니다. Task는 실행되는 동안 cooperative thread pool의 스레드를 잠깐 빌려 쓰다가, await로 suspend되면 그 스레드를 반납한다. 그래서 하나의 Task가 생애 동안 여러 스레드를 거칠 수 있고, 수천 개의 Task가 코어 수만큼의 스레드 위에서 돌아간다. Task는 값싸고 Thread는 무겁다는 것이 실무적 함의다.
CS 원리
Task는 continuation 기반 비동기의 스케줄 단위로, 개념적으로는 코루틴(협력적 경량 실행)에 가깝다. suspension point에서 실행 상태를 continuation으로 저장하고 스레드를 양보하기 때문에, 스레드를 점유한 채 대기하는 커널 스레드와는 자원 소모가 근본적으로 다르다.
iOS에서는
Swift 런타임은 너비가 대략 코어 수인 cooperative thread pool에서 Task를 executor에 스케줄한다. Task {}, Task.detached, 그리고 구조적 동시성의 async let·TaskGroup이 Task를 만든다. 취소·우선순위·task-local 값이 부모에서 자식으로 구조적으로 전파된다는 점이 원시 Thread와 크게 다르다.
실험 · 도구
수천 개의 Task를 만들어도 실제 스레드 수는 코어 수 근처에 머무는 것을 Thread.activeCount로 확인할 수 있다. async 함수에서 await 앞뒤로 Thread.current를 찍으면 스레드가 바뀔 수 있음이 드러난다. Instruments의 Swift Concurrency 템플릿으로 Task·continuation 상태를 추적한다.
프로젝트 적용
- CPU 바운드 무한 루프를 Task에서 돌리면 cooperative pool 스레드를 독점해 다른 작업을 굶긴다 — 중간에
await Task.yield()를 넣거나 별도 처리로 분리한다. - Task는 스레드가 아니므로 특정 스레드나 TLS에 의존하는 코드와 섞지 않는다.
- 가능하면 구조적 동시성(async let·TaskGroup)을 쓰고,
Task.detached는 취소·우선순위 전파가 끊기므로 꼭 필요할 때만 쓴다.
"Task를 만들면 새 스레드가 생긴다", 또는 "Task.detached는 새 스레드다"는 둘 다 틀렸다. 두 경우 모두 공유 cooperative pool에서 실행되며, detached는 스레드가 아니라 '부모의 컨텍스트(취소·우선순위·actor·task-local)를 상속하지 않는' Task라는 뜻이다.
꼬리 질문
- Task에서 취소 확인 없이 CPU 바운드 루프를 돌리면 다른 Task에 무슨 일이 생기나?
Task {}와Task.detached는 컨텍스트 상속 관점에서 어떻게 다른가?- async 함수의 suspension point 전후로 스레드가 유지된다는 보장이 있나?
Q9. async/await 중 Thread가 block되는가?
제대로 설계된 async 코드에서 await는 스레드를 block하지 않고 suspend한다 — 실행 상태를 continuation으로 저장하고 스레드를 pool에 반납해 다른 작업이 그 스레드를 쓰게 한다. 이것이 async의 핵심 이점이다. 반대로 async 함수 안에서 동기 블로킹 API(DispatchSemaphore.wait(), 동기 I/O, Thread.sleep)를 호출하면 그 순간 실제 스레드가 막혀 cooperative pool의 소중한 워커 하나가 죽고, 최악의 경우 전체 concurrency가 기아에 빠진다. 즉 non-blocking은 공짜가 아니라 API 선택의 결과다.
CS 원리
핵심은 suspend(양보)와 block(점유한 채 대기)의 차이다. await는 전자라 스레드를 놓아주지만, blocking syscall은 후자라 스레드를 붙잡는다. cooperative pool은 스레드 수가 코어 수로 제한되므로, 스레드를 붙잡는 블로킹은 GCD 시절보다 오히려 더 치명적이다.
iOS에서는
Swift 런타임은 cooperative pool의 스레드가 항상 '전진(forward progress)'한다고 가정한다 — 그래서 그 위에서 블로킹하면 안 된다. 콜백 기반 API를 감쌀 때는 withCheckedContinuation으로 continuation을 정확히 한 번 resume한다. 대기가 필요하면 블로킹인 Thread.sleep이 아니라 논블로킹인 Task.sleep을 쓴다.
// ❌ async 안에서 실제 스레드를 막는다 → 협력 풀 기아 위험
func loadBad() async -> Data {
let sema = DispatchSemaphore(value: 0)
var result = Data()
legacyLoad { data in result = data; sema.signal() }
sema.wait() // 스레드 점유
return result
}
// ✅ suspend: 스레드를 반납하고 콜백에서 재개
func loadGood() async -> Data {
await withCheckedContinuation { cont in
legacyLoad { data in cont.resume(returning: data) }
}
}프로젝트 적용
- async 컨텍스트에서
semaphore.wait·동기 I/O·Thread.sleep같은 블로킹 API를 호출하지 않는다. - 블로킹뿐인 레거시는
withCheckedContinuation+ 전용 스레드/큐로 격리해 감싼 뒤 노출한다. - 지연은
Task.sleep으로 처리한다(취소도 인지한다).
"await는 스레드를 block한다", 또는 반대로 "async면 알아서 non-blocking이 된다"는 둘 다 틀렸다. await 자체는 논블로킹 양보지만, 그 안에서 블로킹 API를 부르면 스레드는 그대로 막힌다. 논블로킹은 async 키워드가 주는 게 아니라 논블로킹 API를 골라 쓴 결과다.
꼬리 질문
withCheckedContinuation의 continuation을 두 번 resume하거나 한 번도 resume하지 않으면?- cooperative pool이 고갈됐는지 어떻게 진단하나?
- MainActor 위에서 블로킹하면 UI에 무슨 일이 벌어지나?
Q10. actor와 MainActor는 무엇을 보장하는가?
actor는 자신의 mutable 상태에 대한 접근을 직렬화해서, 컴파일러가 강제하는 상호배제(한 번에 하나의 실행만 내부 상태에 접근)를 보장한다 — 즉 low-level data race를 정적으로 없앤다. MainActor는 특정 코드를 항상 메인 스레드에서 실행하도록 격리하는 전역 actor다. 둘 다 '동시 접근으로 인한 data race 없음'은 보장하지만, await 경계에서 다른 호출이 끼어드는 actor reentrancy 때문에 논리적 원자성(불변식 유지)까지 보장하지는 않는다.
CS 원리
actor는 monitor(상태 + 그 상태를 지키는 직렬 실행자)의 현대적 형태다. actor isolation은 외부에서 상태에 직접 손대지 못하게 하고, 접근을 serial executor로 줄 세운다. 다만 reentrancy가 허용된다 — actor 메서드가 await로 양보하는 동안 같은 actor의 다른 메서드가 실행될 수 있으므로, await를 사이에 둔 두 지점 사이에서 상태가 이미 바뀌었을 수 있다.
actor Bank {
var balance = 100
func withdraw(_ amount: Int) async {
guard balance >= amount else { return } // (1) 확인
await notifyAudit() // await → 다른 호출이 끼어들 수 있음
balance -= amount // (2) 그 사이 balance가 바뀌었을 수 있다
}
}iOS에서는
actor는 기본적으로 자체 serial executor를 갖는다. MainActor는 UIKit/SwiftUI 갱신을 위해 메인 스레드 실행을 보장하는 전역 actor로, @MainActor를 타입·메서드·프로퍼티에 붙여 격리한다. actor 외부에서의 접근은 서로 다른 격리 도메인을 넘기 때문에 await가 필요하고, 격리가 필요 없는 순수 멤버는 nonisolated로 열어줄 수 있다.
실험 · 도구
여러 Task에서 actor의 카운터를 동시에 증가시켜도 TSan이 깨끗하게 나오는 것으로 data race 제거를 확인할 수 있다. 반대로 위 Bank 예처럼 await 앞뒤로 상태를 로깅하면, reentrancy로 값이 어긋나는 상황을 재현할 수 있다.
프로젝트 적용
- UI를 만지는 코드는
@MainActor로 격리한다. - actor 메서드 안에서
await전에 검사한 상태는await이후 다시 확인한다(reentrancy 방어). - actor를 '긴 임계구역'처럼 쓰지 않는다 — await가 등장할 때마다 재진입 창이 열린다.
"actor면 모든 race와 deadlock이 사라진다"는 과신이다. actor는 data race를 없애지만, reentrancy로 인한 논리 race(불변식 깨짐)는 그대로 남는다. 흔히 반대로 오해하는데, reentrancy는 오히려 서로를 await 하는 actor들이 락처럼 맞물려 굳는 교착을 막아주는 장치다 — actor는 await 동안 executor를 붙잡지 않기 때문이다. 다만 영영 resume되지 않는 continuation이나 서로의 결과만 기다리는 의존 사이클 같은 논리적 교착은 이와 별개로 여전히 가능하다. 또 "MainActor = 특정 스레드 하나"는 대체로 맞지만, 미래 호환을 위해 '메인 executor'라는 개념으로 이해하는 편이 정확하다.
꼬리 질문
- actor reentrancy가 실제 버그를 만드는 구체적 시나리오는?
MainActor.assumeIsolated는 무엇을 하고 언제 안전한가?- actor에 custom executor를 지정하면 무엇이 달라지나?
Q11. Sendable은 무엇을 보장하고 무엇은 보장하지 않는가?
Sendable은 "이 타입의 값을 여러 동시성 도메인(actor·Task) 사이로 넘겨도 data race가 나지 않는다"는 성질을 타입 시스템에 표시하고 컴파일 타임에 검증하는 마커 프로토콜이다. 값 타입이면서 내부가 전부 Sendable이거나, 불변이거나, 내부적으로 스스로 동기화하는 타입만 안전하게 Sendable일 수 있다. 보장하는 것은 '경계를 넘는 전달의 안전성'이지, 그 값을 쓰는 로직이 옳다거나 race condition이 없다는 것이 아니다. @unchecked Sendable은 컴파일러 검증을 끄고 개발자가 책임지겠다는 선언이라, 실제 동기화가 없으면 거짓 약속이 된다.
CS 원리
경계를 넘는 값이 안전하려면 공유 mutable 상태가 없거나(불변·값 복사) 있어도 보호돼야 한다. Sendable은 이 속성을 타입 시스템에 인코딩한 것으로, value semantics·불변성·내부 동기화가 그 근거가 된다.
iOS에서는
Swift 6의 complete checking 아래에서 struct/enum(멤버가 모두 Sendable), 불변 저장 프로퍼티만 가진 final class, 그리고 actor(항상 Sendable)가 안전하게 Sendable이 된다. 클로저에는 @Sendable을 붙인다. 락 등으로 직접 보호한 참조 타입은 @unchecked Sendable로 표시하되 그 정당성을 개발자가 책임지며, 레거시 모듈은 @preconcurrency import로 점진 도입한다.
실험 · 도구
가장 강력한 도구는 컴파일러 자신이다. 빌드 설정을 -strict-concurrency=complete로 올린 뒤 non-Sendable 값을 actor 경계로 넘기면 진단이 뜬다. 이 진단을 CI 경고 0 정책으로 강제하면 data race의 상당수를 코드가 돌기 전에 걸러낸다.
프로젝트 적용
- 경계를 넘나드는 모델은 값 타입 + 불변을 지향한다.
@unchecked Sendable은 반드시 실제 내부 동기화를 동반하고, 그 근거를 주석으로 남긴다.- 참조 타입 공유를 최소화하고, 레거시는
@preconcurrency로 단계적으로 편입한다.
"Sendable이면 스레드 안전하고 아무 문제 없다"는 오해다. Sendable은 '전달 안전성'만 보장한다 — Sendable한 값을 받아도 그것을 쓰는 순서·로직의 race condition은 별개 문제다. 더 위험한 오해는 "@unchecked Sendable을 붙였으니 안전하다"는 것으로, 이는 오히려 컴파일러 검증을 꺼둔 상태다.
꼬리 질문
- final class를 안전하게 Sendable로 만들 수 있는 조건은?
@unchecked Sendable을 정당하게 쓰는 예와, 오용했을 때의 위험은?@Sendable클로저에는 어떤 캡처 제약이 붙나?
Q12. Task cancellation은 자동으로 실행 중인 작업을 멈추는가?
아니다. Swift의 취소는 협력적(cooperative)이다. cancel()은 Task에 '취소됨' 플래그를 세울 뿐, 실행 중인 코드를 강제로 중단하거나 스레드를 죽이지 않는다. 작업이 실제로 멈추려면 코드가 스스로 Task.isCancelled를 확인하거나 try Task.checkCancellation()을 호출하거나, 취소를 인지하는 async API가 던지는 에러를 처리해야 한다(Task.sleep은 CancellationError를, URLSession async는 URLError(.cancelled)(코드 -999)를 던진다 — API마다 에러 타입이 다르므로 catch 시 주의). 취소를 전혀 확인하지 않는 CPU 루프는 cancel 이후에도 끝까지 돈다.
CS 원리
협력적 취소는 강제 종료(preemptive termination)와 대비된다. 스레드를 강제로 죽이면 잡고 있던 락이 풀리지 않거나 불변식이 반쯤 깨진 채 남는 등 자원 누수·상태 손상 위험이 크기 때문에, 현대 런타임은 '멈춰야 할 안전한 지점에서 스스로 멈추는' 협력식을 택한다. 구조적 동시성에서는 취소가 부모→자식으로 트리를 따라 전파된다.
let task = Task {
for item in items {
try Task.checkCancellation() // 협력 지점: 취소되면 여기서 throw
process(item)
}
}
task.cancel() // 실행 중인 루프를 멈추지 않는다 — 플래그만 세운다iOS에서는
도구는 Task.isCancelled, Task.checkCancellation(), 그리고 정리 로직을 붙이는 withTaskCancellationHandler다. async let·TaskGroup 같은 구조적 동시성에서는 부모 취소가 자식에게 자동 전파된다. Task.sleep은 취소를 인지해 CancellationError를 던지고, URLSession async API는 URLError(.cancelled)(코드 -999)를 던지지만(에러 타입이 다르므로 catch 시 주의), Task.detached는 부모의 취소를 상속하지 않는다.
실험 · 도구
취소 확인이 없는 무한 루프 Task를 만들어 cancel()을 호출해도 로그가 계속 찍히는 것으로 '안 멈춤'을 재현하고, 루프 안에 try Task.checkCancellation()을 넣으면 즉시 종료되는 것을 확인한다. 정리 동작은 withTaskCancellationHandler의 핸들러 호출 시점을 로깅해 관찰한다.
프로젝트 적용
- 긴 루프나 단계 사이에
try Task.checkCancellation()또는Task.isCancelled검사를 넣는다. - 취소 시 자원 해제·롤백은
withTaskCancellationHandler로 처리한다. - 취소는
CancellationError로 다루되, 사용자에게는 '실패'가 아니라 '취소됨'으로 구분해 노출한다. Task.detached는 취소가 전파되지 않는다는 점을 팀 규칙으로 명시한다.
"cancel()을 부르면 즉시 멈춘다" 또는 "스레드가 죽는다"는 오해다. cancel은 플래그를 세울 뿐이며, 코드가 협력하지 않으면 아무 일도 일어나지 않는다. 반대로 "취소는 예외처럼 알아서 전파되니 아무것도 안 해도 된다"는 것도 절반만 맞다 — 자동 전파는 취소를 인지하는 API와 구조적 동시성 안에서만 성립한다.
꼬리 질문
withTaskCancellationHandler의 핸들러는 정확히 언제 실행되나(이미 취소된 상태로 진입하면)?- TaskGroup에서 자식 하나가 에러를 던지면 나머지 자식은 자동으로 취소되나?
- 취소된 Task가 그때까지의 부분 결과를 반환해야 한다면 어떻게 설계하나?