안녕하세요! 오늘은 어제에 이어 'GCD와 Swift Concurrency, 무엇이 다른가?' 2탄으로 돌아왔습니다. 공부하다 보니 3탄까지 가야 할 것 같지만, 우선 오늘 이해한 내용을 정리해 보겠습니다. 🏃♂️
이번 내용은 WWDC 2021의 Swift concurrency: Behind the scenes 세션을 기반으로 합니다.
1탄에서 동시성, 비동기, 병렬 처리를 이야기하며 컨텍스트 스위칭(Context Switching)과 그로 인한 오버헤드(Overhead) 개념이 등장했죠? 여기에 GCD가 가질 수 있는 또 다른 문제점, 바로 스레드 폭발(Thread Explosion)이 있습니다.
과거 우리는 GCD를 사용해 오래 걸리는 작업을 비동기적으로 다른 스레드에 넘겨 처리했습니다. 덕분에 메인 스레드는 UI 렌더링에 집중할 수 있었죠. 하지만 만약 백그라운드 스레드에서 처리하던 작업이 I/O 대기와 같이 다른 결과를 기다리며 차단(Blocking)되면 어떻게 될까요?
❓ "스레드가 블로킹(Blocking) 당했다"
스레드가 특정 작업(예: 네트워크 응답 대기, 파일 읽기)이 끝날 때까지 아무것도 못 하고 멈춰있는 상태를 의미합니다.
CPU 코어는 놀지 않습니다. 멈춰버린 스레드 대신 다른 작업을 처리하기 위해 새로운 스레드를 찾거나 만듭니다. 그런데 그 새로운 스레드마저 블로킹된다면? 이런 일이 100번 반복된다면? 결국 100개의 스레드가 생성됩니다.
단 6개의 코어를 가진 아이폰에서 100개 이상의 스레드가 돌아간다고 상상해 보세요. 이는 약 16배나 많은 스레드이며, 시스템은 수많은 스레드를 관리하기 위해 계속해서 컨텍스트 스위칭을 수행합니다. 결국 엄청난 오버헤드로 이어져 앱 성능이 저하되죠.
이러한 문제를 해결하기 위해 Swift Concurrency (구조화된 동시성)가 등장했습니다.
핵심적인 차이는 스레드를 다루는 방식에 있습니다. Swift Concurrency는 GCD처럼 무작정 스레드를 늘리지 않고, CPU 코어 수와 비슷한 개수의 스레드 풀(Cooperative Thread Pool)을 만들어 작업을 효율적으로 관리합니다.
"어떻게 그게 가능하죠?"
비결은 작업이 스레드를 차단(Block)하는 대신, 스스로를 일시 중단(Suspend)하는 데 있습니다.
await 키워드를 만나면, 현재 실행 중인 작업(Task)은 필요한 결과가 준비될 때까지 잠시 멈춥니다. 중요한 것은, 이때 스레드 자체는 멈추지 않는다는 점입니다. 작업이 스레드에서 잠시 내려오면, 비어있는 스레드는 곧바로 다른 준비된 작업을 가져와 실행합니다.
이 방식 덕분에 불필요한 스레드 생성을 막고, 컨텍스트 스위칭 비용을 최소화할 수 있습니다.
Continuation작업이 잠시 멈췄다가 나중에 다시 실행되려면, "어디까지 실행했고, 어떤 데이터가 필요했는지"와 같은 상태 정보를 어딘가에 저장해야 합니다. 이것이 바로 Continuation의 역할입니다.
async 함수가 호출되면, 해당 함수의 정보는 스택 프레임(Stack Frame)에 쌓입니다.await를 만나면, 작업은 일시 중단 상태가 됩니다.Continuation이라는 경량 데이터 객체에 담겨 힙(Heap) 메모리에 저장됩니다. (마치 탈출 클로저와 비슷하죠?)Continuation을 사용해 멈췄던 지점부터 다시 실행합니다.이 과정을 비유로 설명해 볼게요.
- GCD 방식: 한 방(스레드)에서 작업하다가 다른 방으로 옮길 때, 책상, 의자, 컴퓨터 등 작업 환경 전체를 통째로 옮기는 것과 같습니다. 매우 무겁고 비효율적이죠.
- Concurrency 방식: 다른 방으로 옮길 때, "이따가 돌아와서 25페이지부터 다시 읽기"라고 메모지(
Continuation)에 핵심만 적어두고 몸만 가는 것과 같습니다. 가볍고 빠르죠.
GCD와 Concurrency는 프로그래밍 패러다임에서도 차이를 보입니다.
async를 붙여 "이 함수는 비동기로 동작할 수 있다"고 선언만 하면, 실제 스레드 관리는 시스템이 알아서 최적의 방식으로 처리합니다.actor는 동시성 환경에서 공유 데이터에 안전하게 접근하기 위한 장치입니다. actor 내부의 데이터에 접근하려면 항상 await를 사용해야 하죠.
"왜
await가 필요할까요? 그냥 Lock을 걸면 안 되나요?"
전통적인 Lock 방식은 스레드를 차단(Block)합니다. 만약 하나의 스레드가 Lock을 걸고 데이터에 접근하는 동안 다른 스레드가 접근을 시도하면, 그 스레드는 Lock이 풀릴 때까지 하염없이 멈춰서 기다려야 합니다. 이는 앞서 말한 GCD의 비효율로 이어질 수 있습니다.
하지만 actor는 다릅니다.
actor는 자신에게 들어오는 요청들을 내부적으로 직렬화(Serialization)하여 한 번에 하나씩만 처리합니다. 만약 어떤 작업이 actor의 메서드를 호출했는데 actor가 다른 작업을 처리 중이라면, await 지점에서 현재 작업은 일시 중단(Suspend)되고 대기표를 받습니다. 스레드는 차단되지 않고 다른 일을 하러 가죠. 자기 차례가 오면 중단됐던 지점부터 다시 실행됩니다.
덕분에 스레드를 낭비하지 않으면서도 데이터 무결성을 지킬 수 있습니다.
"Concurrency가 나온 뒤에는 하나의 코어에서 하나의 스레드만 작동하도록 한다는데, 이게 진짜인가요?"
이 부분은 조금 더 명확히 할 필요가 있습니다. 정확히는 "하나의 코어당 하나의 스레드가 가장 이상적인 상태"에 가깝게 시스템이 관리해준다고 이해하는 것이 좋습니다.
메인 스레드는 여전히 하나의 특정 코어(주로 고성능 코어)에서 UI 관련 작업을 전담하고, 나머지 코어들이 백그라운드 작업을 위한 스레드 풀을 공유하며 동작하는 구조에 가깝습니다.
후... 어렵네요. 😅
오늘은 여기까지 하고, 다음에는 더 깊이 있는 내용과 함께 돌아오겠습니다. 읽어주셔서 감사합니다!