apple/WWDC

[WWDC21] Swift Concurrency: Behind the scenes

lgvv 2025. 4. 26. 15:21

[WWDC21] Swift Concurrency: Behind the scenes

 

 

성능과 효율성을 위해 설계된 이유를 자세히 알아보고, Swift 동시성에 대해서 추론하는 방법과 GCD와 같은 기존 스레딩 라이브러리와 어떻게 다른지 알아볼 것임.

 

  • Treading model
    • Swift 동시성의 기반이 되는 threading model에 대해서 알아보고 GCD와 비교.
  • Synchroization
    • actor를 통한 Swift Concurrency의 동기화, actor가 내부적으로 어떻게 동작하는지를 설명.
    • Serial Dispatch Queue와 같은 기본 Synchronization 요소와 어떻게 비교되는지
    • actor를 사용하여 코드를 작성할 때 알아두어야 할 몇 가지 사항에 대해 알아보자

 

Treading model

 

 

DB에 접근할 때는 두가지 이유로 별도의 Serial Queue를 활용함.

1. 잠재적으로 많은 양의 작업이 발생할 때 기다리는 동안에도 메인 스레드가 사용자 입력에 계속 응답할 수 있도록 보장

2. 직렬 큐는 상호배제를 보장하므로 데이터베이스에 대한 액세스가 보호됨.

 

 

네트워크는 Concurrent Queue를 통해 동작하며, 응답이 도착하면 DB를 동기적으로 업데이트함.

위 흐름에 따르면 메인스레드를 블락하지도 않았고 네트워크는 비동기로 처리했으며 데이터베이스를 안전하게 직렬화하여 업데이트함.

 

해당 동작에 대한 아래 코드를 보자

 

위 흐름으로 진행했지만, 위 코드에는 몇 가지 숨겨진 성능 함정이 존재함.

이러한 성능 문제에 대해 더 자세히 알아보려면 GCD Queue에서 작업을 처리하기 위해 스레드가 어떻게 생성되는지 알아보아야 함.

GCD에서는 작업이 Queue에 추가되면, 시스템이 해당 항목을 서비스하기 위해 스레드를 불러옴.

 

 


Concurrent Queue는 여러 작업을 동시에 처리할 수 있음. 시스템은 모든 CPU 코어가 포화 상태에 도달할 때까지 여러 스레드를 실행.


만약 첫 번째 스레드가 차단되고, Concurrent에서 수행해야 할 작업이 더 많은 경우 GCD는 남아 있는 작업을 비우기 위해서 더 많은 스레드를 가져오는데, 두 가지 이유가 존재함.

 

첫째, 프로세스에 또 다른 스레드를 제공함으로써 각 코어가 언제든지 작업을 실행하는 스레드를 계속 갖도록 할 수 있음.

이를 통해서 애플리케이션이 지속적으로 양호한 수준의 동시성을 유지할 수 있음.

 

 

둘째, 차단된 스레드는 더 이상 진행하기 전에 세마포어와 같은 리소스를 기다리고 있을 수 있음.

Queue에서 작업을 계속하기 위해서 불러온 새로운 스레드는 작업을 위해 불러온 새로운 스레드는 첫 번째 스레드가 기다리고 있는 리소스를 해제하는데 도움이 됨.

 

 

 

2 코어 기기에서는 네트워크 동작을 할 때 코어는 여러 스레드를 사용하고, CPU는 네트워킹 결과를 처리하는 여러 스레드 사이에서 컨텍스트를 전환해야 하는데, 이는 다양하 스레드 사이에 있는 흰색 수직선으로 표현됨

 

즉, 위에 코드에서 Concurrent Queue는 쉽게 매우 많은 수의 스레드가 생성될 수 있음.

 

만약 네트워크의 비동기 Concurrent 작업이 100개가 수행되면 100개의 완료 블록이 생성되고,

이를 DB의 Serial Queue에 넣을 때 Queue에서 차단되면 GCD는 더 많은 스레드를 가져오므로 앱에 많은 스레드가 생김

 

 

스레드가 많다는 것은 시스템이 CPU 코어보다 많은 스레드로 과도하게 할당된다는 것을 의미.

iPhone의 6개의 CPU가 존재하고, 100개의 스레드를 할당하면 16배의 스레드를 할당하게 되므로 이를 Thread explsion(스레드 폭발)이라고 부름.

 

스레드 폭발에는 메모리와 스케줄링 오버헤드도 수반됨.

 

 

 

차단된 각 스레드는 다시 실행될 때까지 귀중한 메모리와 리소스를 유지.

차단된 각 스레드는 스레드를 추적하기 위한 스택을과 관련 커널 데이터 구조가 있음.

이러한 스레드 중 일부는 다른 스레드에서 필요한 수 있는 lock을 유지하고 있을 수도 있음.

 

이는 진행되지 않는 스레드를 위해 많은 양의 리소스와 메모리를 어딘가에 보관해야 함을 의미함.

스레드 폭발로 인해서 스케줄링 오버헤드도 커짐. 새로운 스레드가 생성되면, CPU는 기존 스레드에서 벗어나 새로운 스레드를 실행하기 위해 전체 스레드 컨텍스트 전환을 수행해야 함.

 

 

 

차단된 스레드가 다시 실행 가능해지면, 스케줄러는 CPU에서  스레드를 타임셰어하여 모든 스레드가 계속 진행할 수 있도록 해야 함.

이런 일이 몇 번만 일어난다면 스레드의 타임쉐어링은 괜찮음.

이 지점이 바로 동시성의 힘이지만, 스레드 폭발이 발생하면 제한된 코어를 가진 장치에서 수백 개의 스레드를 타임셰어해야 하므로 과도한 컨텍스트 전환이 발생할 수 있음. 

이러한 스레드의 스케줄링 지연은 실제로 수행할 수 있는 유용한 작업의 양을 능가해서, CPU의 효율성도 떨어짐.

 

GCD를 사용할 때는 스레드가 폭발적으로 늘어나는 이런 미묘한 차이를 놓치기가 쉬움.

그로 인해 성능이 저하되고 오버헤드가 늘어날 수 있음.

 

이러한 문제로 인하여 Swift 언어에서 동시성을 설계할 때 다른 방식을 취했음.

안전한 동시성을 누릴 수 있도록 성능과 효율성을 염두에 두고 Swift Concurrency를 구축함.

 

 

 

Swift Concurrency에서는 2개의 코어에서 2개의 스레드만 존재하여 스레드 컨텍스트 전환이 발생하지 않음.

차단된 스레드는 모두 사라지고 대신 작업 재개를 추적하는 가벼운 객체가 존재함.

Swift Concurrency에서 스레드가 작업을 실행할 때, 전체 컨텍스트 전환을 수행하는 대신 연속 작업 간에만 전환함.

 

이로써 함수 호출 비용만 지불하면 되며, Swift Runtime에서 스레드는 CPU 코어 수만큼의 스레드만 생성하고, 스레드가 차단되었을 때 작업 항목 간에 저렴하고 효율적으로 전환할 수 있도록 함.

 

 

운영체제에는 스레드가 차단되지 않는 Runtime contract 필요하며, 언어가 해당 Runtime contract을 제공할 때에만 가능함.

Swift Concurrency의 모델과 이를 둘러싼 의미론은 이러한 목표를 염두하여 설계.

 

 

 


이런 부분을 Swift의 두 가지 언어 수준 기능에 대해 자세히 알아볼 것.

 첫 번째는 await의 의미론에서 비롯되고, 두 번째는 Swift Runtime에서 작업 종속성을 추적하는데서 비롯함.

 

 

await은 비동기를 기다리는 동안 현재 스레드를 차단하지 않음. 대신 해당 함수가 일시 중단되고 스레드가 해제되어 다른 작업을 실행할 수 있음. 

 

 

비동기 함수가 어떻게 동작되는지 알아보기 전에 비동기가 아닌 함수가 어떻게 작동하는지 알아보고자 함.

실행 중인 프로그램의 각 스레드에는 하나의 스택이 존재하는데 이를 사용하여 함수 호출에 대한  상태를 저장함.

 

 

스레드가 함수 호출을 실행하면 새로운 프레임에 스택에 함수가 푸시됨.

새로 생성된 스택 프레임은 로컬 변수, 반환 주소 및 필요한 다른 정보를 저장하는 데 사용할 수 있음.

함수가 실행을 마치고 반환되면 스택 프레임이 팝됨.

 

 

이제 비동기 함수를 살펴볼 것임.

 

 


이 단계에서 가장 최근의 스택. 프레임은 add(_:)를 위한 것이며, 스택 프레임은 add가 들어있음.

중단 지점 전체에서 사용할 필요가 없는 로컬 변수를 저장함.

 

 

await이라고 표시된 이 지점에서는 로컬 변수인 ids와 atricles는 정의된 후 중간에 중단 지점 없이 for 루프의 본문에서 즉시 사용됨.

따라서 이런 값들은 스택 프레임에 저장됨.

 

 

또한 힙에는 두 개의 비동기 프레임이 존재하는데, 하나는 udpateDatabase용 다른 하나는 add용임.

또한 비동기 프레임은 서스펜션 지점 전체에서 사용할 수 있어야 하는 정보를 저장함.

 

 

 

 

 


newActicles 인수는 await보다 먼저 정의되었지만, await보다 나중에 사용할 수 있어야 함.

즉, add에 대한 비동기 프레임은 newArticels을 추적함.

 

 

스레드가 계속 실행된다고 가정해 보면 save 함수가 실행되기 시작하면 add에 대한 스택 프레임이 save에 대한 스택 프레임으로 대체

새로운 스택 프레임을 추적하는 대신 앞으로 필요할 변수가 이미 비동기 프레임 목록에 저장되어 있으므로 가장 위에 있는 스택 프레임을 교체함.

 

 

저장 기능도 비동기 프레임을 사용하여 사용할 수 있게 됨.

데이터 베이스에 데이터가 저장되는 동안 스레드가 차단되는 대신 다른 작업을 수행할 수 있다면 더 좋을 것.

 

 


저장 함수의 실행이 중단되었다고 가정해 보자.

스레드는 차단되는 대신 다른 작업을 수행하는데 재사용됨.

 

 

 

중단 지점에서 유지되는 모든 정보는 힙에 저장되므로 이후 단계에서 실행을 계속하는 데 사용할 수 있음.

비동기 프레임 목록은 Continuation의 Runtime의 표현임.

 

 

데이터베이스의 요청이 완료되고, 일부 스레드가 해제되었다고 가정해 보자.

이 스레드는 이전 스레드와 동일할 수도 있고 다를 수도 있음.

 

 

이후 저장 함수가 다시 실행된다고 가정해 보자. 실행이 완료되고 일부 ID가 반환되면 저장을 위한 스택 프레임은 다시 추가를 위한 스택 프레임으로 대체될 것임

 

 

그 후 스레드는 zip을 실행할 것임. zip은 배열을 압축하는 함수로 비동기가 아니므로 새로운 스택 프레임이 생성.

Swift는 OS 스택을 계속 사용하므로 비동기 및 비동기 Swift 코드 모두 C와 obj-c 모두 효율적으로 호출할 수 있음.

C와 objc도 비동기가 아닌 Swift 코드를 계속해서 효율적으로 호출할 수 있음.

 

 

 

zip 함수가 완료되면 스택 프레임이 팝 되고 실행이 계속됨.

 

지금까지 await가 어떻게 효율적인 일시 중단과 재개를 보장하고, 스레드의 리소스를 확보하여 다른 작업을 수행하도록 설계되었는지 설명.

 

작업 간 종속성을 추적하는 Runtime 기능인 Laguage feature 기능에 대해 설명할 것.

함수는 await 지점에서 연속으로 분할될 수 있으며 이를 suspension point라고 함.

 

 

연속 함수는 비동기 함수가 완료된 후에만 실행될 수 있음.

이는 Swift Concurrency Runtime에서 추적하는 종속성임.

 

 

마찬가지로 TaskGroup 내에서 부모 작업은 여러 개의 자식 작업을 생성할 수 있으며 부모 작업이 진행되기 전에 각 자식 작업이 완료되어야 함.

이는 TaskGroup의 범위에 따라 코드에서 표현되는 종속성이므로 Swift Complier와 Runtime에서 명시적으로 알 수 있음.

 

 

대신 실행 스레드는 작업 종속성을 추론해 다른 작업을 선택할 수 있음.

스레드가 항상 앞으로 진행할 수 있도록 Runtime contract를 유지할 수 있음.

Runtime contract를 이용해 Swift Concurrency를 위한 통합 OS 지원을 구축함.

 

 

 

이는 Swift Concurrency를 기본 실행자로 지원하기 위한 새로운 협력 스레드 풀 형태임.

새로운 스레드 풀은 CPU 코어 수만큼 스레드를 생성하므로 시스템에 과도한 부담을 주지 않음.

작업 항목이 차단되면 더 많은 스레드를 생성하는 GCD와 Concurrent Queue와 달리Swift는 항상 블락하지 않고 진행할 수 있음.

 

Swift Runtime은 생성되는 스레드 수를 신중하게 제어할 수 있음.

이를 통해 과도한 동시성으로 인한 알려진 함정을 피하면서 앱에 필요한 동시성을 제공할 수 있음.

 

 

GCD는 Subsystem 내에서는 스레드 폭발 위험 없이 동시성을 2개 이상으로 높이기 어려움.

Swift를 사용하면 언어에서 Runtime이 활용한 강력한 불변성을 제공해 기본 런타임에서 보다 제어된 동시성을 투명하게 제공할 수 있음.

 

 

 

Swift Runtime에서는 추가 메모리 할당 및 논리와 같은 Concurrency와 같은 일부 비용에 대해 이야기함.

따라서 코드에 Concurrency 도입하는 비용이 동시성을 관리하는 비용보다 클 때만 Swift 동시성을 사용하여 새 코드를 작성하도록 주의해야 함.

 

Swift Concurrency를 도입하기 전에는 두 가지를 주의해야 함.

첫 번째는 성능, 두 번째는 원자성 개념.

 

 

예를 들면, 단순히 값을 읽기 위해 자식 작업을 생성하는 동시성으로부터 실제로 이점을 얻지 못할 수 있음.

만들고 관리하는 비용으로 인해 감소하기 때문.

 

따라서 Swift Concurrency를 사용하기 위해 성능 특성을 파악하기 위해 Insturments을 활용해 코드를 프로파일링 하는 것이 좋음.

 

 

두번째는 원자성 개념.

Swift는 전에 코드를 실행한 스레드가 contunuation을 가져올 스레드와 동일하다는 것을 보장하지 않음.

await는 작업이 자발적으로 취소될 수 있으므로, 원자성이 깨졌다는 것을 나타내는 명시적인 지점.

 

따라서 await에서 lock을 유지하지 않도록 주의해야 함. 

마찬가지로 스레드별 데이터는 await에서도 보존되지 않음. 스레드의 지역성을 기대하는 코드의 모든 가정은 await의 동작을 설명하기 위해 다시 검토해야 함.

 

 

 

마지막 고려 사항은 Swift의 효율적인 스레딩 모델의 기반이 되는 Runtime Contract와 관련이 있음.

Swift를 사용하면 Thread가 항상 앞으로(forward)만 진행될 수 있도록 Runtime Contract를 준수함.

이 Contract를 기반으로 Cooperative thread pool을 구축.

Swift Concurrency를 채택하면서 Cooperative thread pool이 최적으로 작동할 수 있도록 코드에서도 이 Contract를 유지하는 것이 중요하며, 코드의 종속성을 명시적이고 알 수 있게 하는 안전한 기본형을 사용하여 Cooperative thread pool 내에서도 이 Contract를 유지할 수 있음.

 

 

 

  • await, actor, task groups와 같은 Swift Conccurency의 기본 요소를 사용하면, 컴파일 시점에 알려짐
    • Swift 컴파일러는 이를 강제로 적용하고 Runtime Contract를 보존하는데 도움을 줌.
  • os_unfair_locks 및 NSLock과 같은 기본형도 안전하지만 사용할 때는 주의가 필요하며, ciritical section을 중심으로 동기적으로 동작하는 코드 내에서 사용하면 안전함. 왜냐하면 이는 잠기는 스레드가 언제나 잠금을 해제하는 방향으로 진행되기 때문.
    • 동기적으로 동작하여 잠그고 풀고를 명확하게 반복. 
    • 따라서 함수는 경합 중에 짧은 시간 동안만 스레드를 차단할 수 있지만, 이는 진행 상황에 대한 Runtime Contract를 위반하지 않음. 하지만, Swift Concurrency의 기본 요소들과 달리 lock을 사용하는데, 컴파일러의 도움을 받지 못하며, 개발자의 책임임.
  • 세마포어 등은 Swift Conccurency와 같이 쓰기에 안전하지 않음.
    • Swift Runtime에서 종속성을 숨기지만, 코드 실행 시 종속성을 도입하기 때문.
    • Swift Runtime은 이런 종속성을 알지 못하므로, 올바른 스케줄링 결정을 내리고 이를 해결할 수 없음

 

특히 구조화되지 않은 작업을 생성한 다음 세마포어와 같은 안전하지 않은 기본형을 사용해 작업의 경계를 넘는 기본형을 사용하면 안 됨.

이러한 코드 패턴은 스레드가 다른 스레드가 차단을 해제할 때까지 무기한 차단될 수 있음을 의미.

 

이는 Thread의 forward progress에 대해서 Runtime Contract를 위반.

 


코브 베이스에서 이러한 안전하지 않은 기본 요소의 사용을 식별해 내는데, 도움이 되도록 `LIBDISPATCH_COOPERATIVE_POOL_STRICT=1` 환경 변수를 사용해 앱을 테스트하는 것이 좋음.

이렇게 할 경우 수정된 디버그 런타임에서 앱이 실행되어 진행되며 불변성이 적용됨.

 


이 환경변수는 Xcode의 스킴에서 설정 가능

 

 

 

이 환경변수를 사용할 때 스레드에서 차단된 영역이 나타나면, 안전하지 않은 기본 요소를 사용하고 있음을 나타냄.

 

Swift Concurrency에서 어떻게 상태를 동기화하는 데 사용할 수 있는지 알아볼 것.

 

 

Actor는 상호 배재를 보장함.

Actor는 한 번에 최대 하나의 메서드만 호출만 실행할 수 있음.

 

 

Mutaul exclusion은 하나의 데이터에 대해서 동시에 액세스 하지 않음으로써 데이터 경쟁을 방지

 

Serial Queue sync 

  • 대기하는 작업이 없는 경우에는 스레드를 재사용함.
  • 대기하는 작업이 있는 경우: Block을 유발할 수 있고, 이게 스레드 폭발의 원인이 됨.
  • 따라서 일반적으로는 async를 사용하는 것이 좋음

Serial Queue async

  • 주요 장점은 스레드를 차단하지 않음.
  • 경쟁이 있어도 스레드 폭발로는 이어지지 않음.
  • 단점은 경합이 존재하지 않을 때에도 호출 스레드가 다른 작업을 이어가는 동안에는 새 스레드를 요청해야 한다는 것임.
  • 따라서 이를 자주 사용하면 과도하게 스레드를 만들면서 컨텍스트 전환이 발생할 수 있음.

Actor

  • 스레드를 재사용하며, 블록킹 하지도 않음.

 

 

한 행위자에서 다른 행위자로 실행을 전환하는 것은 actor hopping이라고 부름

 

 

스포츠 피드 액터가 데이터 베이스 액터를 사용로 전환할 때 블록킹 없이 전환.

스포츠 피드에서 전환된 데이터베이스 액터는 그대로 수행되며, 여기서 만약 날씨 피드 쪽에서 데이터 베이스에 접근하고자 함.

이때는 D2가 별도로 생성되며, Actor로 만들어진 데이터베이스는 한 번에 활성화될 수 있는 작업은 최대 1개임.

 

 

즉, D1이 이미 있으므로, D2는 서스펜드 되고, 웨더 피드를 실행 중이던 스레드는 이제 다른 작업을 수행할 수 있음.

 


잠시 후 초기에 들어온 데이터베이스 작업이 종료되면, D2를 실행할 수 있게 되며 해당 스레드에서 서스펜션 된 actor 중 하나를 선택해 재개할 수도 있음.

아니면 해제된 스레드에서 다른 작업을 수행할 수도 있음.

 

 

비동기 작업이 많고 경합이 많은 경우 시스템은 어떤 작업이 더 중요한지에 따라서 실행되어야 하며 이상적으로는 User Interaction과 같은 우선순위가 높은 작업이 백그라운드 작업보다 우선 실행되어야 함.

 

actor는 재진입 개념으로 인해 시스템이 작업의 우선순위를 잘 정할 수 있도록 설계되었음.

 

 

 

GCD에서는 queue는 선입선출로 실행됨.

하지만 A가 실행된 후 B가 실행되기까지 우선순위가 낮은 5개의 항목을 처리해야 도달할 수 있는데 우선순위 역전이라고 함.

 

 

Serial Queue에서는 높은 우선순위의 작업보다 앞서 있는 모든 작업의 우선순위를 높여 우선순위 역전을 해결.

이는 실제로 대기 중인 작업이 더 빨리 완료된다는 것을 의미.

 

하지만, B는 결국 앞의 5개의 작업이 실행되어야 실행될 수 있음.

이 문제를 해결하기 위해서는 Queue의 선입선출 방식의 의미론을 변경해야 함.

 

이 지점에서 actordml reentrancy에 대해서 생각해 봄

 

 

데이터베이스 actor가 잠시 중단되어 있을 때 스포츠 피드 액터가 들어오면 스포츠 피드 액터를 수행

스포츠 피드 액터는 데이터 저장을 요청

이때 데이터베이스 액터는 재진입하고, 재진입에 스포츠 피드에 의해 만들어진 데이터베이스 D2가 우선적으로 처리됨.

 

actor의 재진입을 허용한다는 것은 엄격한 Queue FIFO방식이 아닌 순서대로 항목을 수행할 수 있음.

 

 

아래에서 Serial Queue에서 Actor로 만든 동작을 살펴보고자 함.

 

 

동일하게 A를 처리한 후 Swift Runtime에서 우선순위가 높은 작업을 먼저 처리할 수 있음.

이를 통해 우선순위 역전 문제를 직접 해결

 

보다 효과적인 스케줄링과 리소스 활용이 가능해짐. 

 

 

 

Actor의 또 다른 종류인 Main actor가 존재하는데 이는 메인스레드를 추상화함.

 

for 루프를 반복할 때마다 짧은 시간에 2번의 콘텍스트 전환이 필요함. 루프가 적고 실행하는 영역이 무겁지 않다면 괜찮을 수도 있음.

하지만 그렇지 않다면 MainActor로 전환하는 오버헤드가 누적될 수 있음.

 

 

 

 

앱이 콘텍스트 전환에 많은 시간을 소모하는 경우 MainActor의 작업이 한번에 처리되도록 변경해야 함.

배열로 변경하여 작업을 일괄 처리하여 컨텍스트 전환을 줄임.

Cooperative pool 내에서 Actor 간의 전환은 빠르긴 하지만, MainActor와의 이동에는 주의해야 함.