본문 바로가기
iOS/Swift

Swift concurrency: Behind the scenes

by 바등쪼 2023. 9. 13.

https://developer.apple.com/videos/play/wwdc2021/10254/

 

Swift concurrency: Behind the scenes - WWDC21 - Videos - Apple Developer

Dive into the details of Swift concurrency and discover how Swift provides greater safety from data races and thread explosion while...

developer.apple.com

 

Threading model

나만의 뉴스 피드 리더 앱을 만들고 있다고 가정하자

이 앱을 GCD 기반에서 Swift Concurrency로 전환해 보자!

메인 스레드에서는 User Interface를 담당한다.

사용자가 구독하는 뉴스 피드를 추적하는 데이터베이스가 있고 피드에서 최신 콘텐츠를 가져오는 네트워킹 시스템이 있다.

 

GCD 사용

  • 메인 스레드에서 이벤트 제스처를 처리한다.
  • 데이터베이스 작업을 처리하는 직렬 큐에 요청을 비동기적으로 디스패치한다.
    • 시간이 많이 필요한 작업을 다른 큐로 비동기로 보내서 메인 스레드가 사용자 input에 집중할 수 있도록 한다.
    • 직렬 큐는 Mutual exclusion을 보장하기 때문에 데이터베이스에 대한 액세스가 보호된다. (Synchronization)

  • 데이터베이스 큐에 있는 동안 사용자가 구독한 뉴스 피드를 반복해서 살펴보고 각 피드에 대한 콘텐츠를 다운로드하기 위해 URLSession에 네트워킹을 요청한다.

  • 네트워킹 요청 결과가 들어오면 Concurrent Queue인 Delegate Queue에서 URLSession 콜백이 호출된다.
  • 각 Results에 해당하는 Completion Handler에서 각 피드의 최신 request로 데이터베이스를 동기적으로 업데이트하여 나중에 사용할 수 있도록 캐싱한다.
  • 마지막으로 메인 스레드를 깨워 UI를 refresh한다.

 

이러한 방식은 앱을 만들 때 매우 합리적인 것처럼 보인다.

요청을 처리하는 동안 메인 스레드를 차단하지 않았고 네트워크 요청을 concurrently 하게 처리함으로써 프로그램에 내재된 parallelism을 활용했다.

 

코드를 살펴보자!

  1. 먼저 URLSession을 생성했고 delegateQueue를 동시큐로 설정했다.
  2. 업데이트해야 하는 모든 뉴스 피드를 반복하고 각 뉴스 피드에 대해 URLSession에 dataTask를 스케줄링했다.
  3. DelegateQueue에서 호출된 data task의 Completion Handler에서 다운로드 결과를 deserialize(역직렬화)하여 기사로 포맷한다.
  4. 피드에 대한 결과를 업데이트하기 전에 데이터베이스 큐에 sync로 디스패치한다.

이 코드에는 숨겨진 함정이 있다..!!! (성능 문제 발생)

 

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

 

GCD에서는 작업이 큐에 등록되면 시스템에서 해당 작업을 처리하기 위한 스레드를 불러온다.

동시 큐는 한 번에 여러 개의 작업을 처리할 수 있으므로 모든 CPU 코어가 포화 상태가 될 때까지 시스템에서 여러 개의 스레드를 불러온다.

그러나 위의 사진의 첫 번째 CPU 코어처럼 스레드가 차단되고 동시 큐에서 수행해야 할 작업이 더 많으면 GCD는 더 많은 스레드를 불러와 나머지 작업을 소진한다.

 

그 이유는 2가지이다.

 

  1. 프로세스에 다른 스레드를 제공함으로써 각 코어에 주어진 시간에 작업을 실행하는 스레드가 계속 유지되도록 할 수 있다. (프로세스가 멈추지 않고 일을 한다.)
    ➡️ 앱에 지속적으로 우수한 수준의 동시성을 제공한다.
  2. blocked 된 스레드는 더 이상 진행하기 전에 세마포어와 같은 리소스를 waiting 할 수 있다.
    ➡️ 큐에서 계속 작업하기 위해 불러온 새 스레드는 첫 번째 스레드가 waiting 중인 리소스의 차단을 unblock 하는 데 도움이 될 수 있다.

 

Apple Watch와 같은 2코어 디바이스에서 GCD는 먼저 피드 업데이트 결과를 처리하기 위해 위처럼 2개의 스레드를 불러온다.

 

스레드가 데이터베이스 큐에 대한 액세스를 차단하면 네트워킹 큐에서 계속 작업하기 위해 더 많은 스레드가 생성된다.

그런 다음 CPU는 위의 사진에서 여러 스레드 사이의 흰색 세로선으로 표시된 대로 네트워킹 결과를 처리하는 여러 스레드 간에 Context Switching을 수행해야 한다. (너무 많은 컨텍스트 스위칭으로 인한 오버헤드 발생)

 

즉, 이러한 구조의 뉴스 앱에서는 매우 많은 수의 스레드가 쉽게 발생할 수 있다.

 

사용자가 업데이트해야 하는 피드가 100개인 경우 네트워킹 요청이 완료되면 각 URL Data task는 동시 큐에 Completion Handler를 가지게 된다.

각 콜백이 데이터베이스 큐에 block을 할 때 GCD는 더 많은 스레드를 불러와 앱에 많은 스레드를 보유하게 된다.

 

 

스레드가 많으면 생기는 문제들

앱에 스레드가 많다는 것은 CPU 코어 수보다 많은 스레드가 시스템에 overcommitted 되어 있다는 것을 의미한다.

 

CPU 코어가 6개인 아이폰이라면 앞선 뉴스 피드 앱에서 피드 업데이트가 100개라면 코어보다 16배나 많은 스레드가 아이폰에 오버커밋된다.

 

이것을 Thread Explosion이라고 한다.

 

스레드가 많아지면 데드락의 가능성도 높아지고 메모리 및 스케줄링 오버헤드도 수반된다.

 

위의 사진을 보면 blocked 된 각 스레드는 다시 실행되기를 기다리는 동안 귀중한 메모리와 리소스를 보유하고 있다.

차단된 각 스레드에는 스레드를 추적하기 위한 Stack과 관련된 Kernel 자료 구조가 있다.

 

이러한 스레드 중 일부는 실행 중인 다른 스레드에 필요할 수 있는 것을 lock 하고 있을 수 있다.

이는 진행되지 않는 스레드를 위해 많은 리소스와 메모리를 보유해야 하는 것을 의미한다.

 

스레드 폭발로 인해 스케줄링 오버헤드도 커진다.

 

 

새 스레드가 발생하면 CPU는 새 스레드 실행을 위해 이전 스레드에서 벗어나 Full thread context switching을 수행해야 한다.

차단된 스레드를 다시 실행할 수 있게 되면 스케줄러는 CPU에서 스레드를 timeshare 하여 모든 스레드가 forward progress 되도록 해야 한다. (no-starving을 말하는 듯)

(timesharing = CPU가 컨텍스트 스위칭을 하며 번갈아 가면서 여러 스레드를 실행하는 것)

 

스레드 timesharing은 몇 번만 발생하면 괜찮다.

이것이 바로 동시성의 힘이다.

 

그러나 스레드가 폭발적으로 증가하면 코어가 제한된 기기에서 수백 개의 스레드를 tiemsharing 해야 하므로 과도한 컨텍스트 스위칭이 발생할 수 있다.

이러한 스레드의 스케줄링 대기 시간이 유용한 작업의 양보다 많기 때문에 CPU도 비효율적으로 실행된다.

 

GCD 큐를 사용하는 앱을 작성할 때 스레드 hygiene에 대한 이러한 뉘앙스를 놓치기 쉽기 때문에 성능이 저하되고 오버헤드가 증가한다. (개발자의 역량에 크게 의존)

 

따라서!! Swift는 언어에 동시성을 설계할 때 다른 방식을 취했다.

성능과 효율성을 염두하며 Swift Concurrency를 구축하여 앱이 제어되고 구조화되며 안전한 동시성을 누릴 수 있도록 했다.

 

 

Swift Concurrency

Swift Concurrency에서는 스레드와 컨텍스트 스위칭이 많은 모델에서 위와 같이 새로운 실행 모델로 변경한다.

 

이 사진에서는 2코어 시스템에서 실행되는 스레드가 2개뿐이고 Thread context switching이 없다.

모든 blocked thread들이 사라지고 대신 작업 재개를 추적하기 위해 continuation이라는 경량 객체가 생겼다.

 

스레드가 Swift Concurrency 하에서 작업을 실행할 때 전체 스레드 컨텍스트 스위칭을 수행하는 대신 Continuation 간에 전환을 수행한다..

즉, 이제 함수 호출에 대한 비용만 지불하면 된다.

 

Swift Concurrency에서 원하는 런타임 동작은 CPU 코어 수만큼만 스레드를 생성하고, 스레드가 차단되었을 때 저렴하고 효율적으로 작업 아이템 간에 스위칭을 할 수 있도록 하는 것이다.

 

추론하기 쉽고 안전하고 제어된 동시성을 제공하는 직선형 코드를 작성할 수 있다.

이러한 동작을 위해서는 운영체제에서 스레드가 차단되지 않는 런타임 contract가 필요하며, 이는 언어가 이를 제공할 수 있을 때 가능하다. ➡️ Swift Concurrency model과 관련된 semantics는 이 목표를 염두하며 설계되었다.

 

이를 위한 Swift의 language-level 기능 중 2가지를 살펴보자!

첫 번째는 await의 의미론에서 비롯된 것이고 두 번째는 Swift 런타임 태스크 디펜던시에서 비롯된 것이다.

 

뉴스 앱을 Swift Concurrency로 전환한 코드이다.

 

  • 우선 updateDatabase 함수를 async로 만들었다.
  • Concurrent DispatchQueue에서 네트워킹 요청의 결과를 처리하는 대신 task group을 사용하여 동시성을 관리한다.
  • Task group에서 업데이트해야 하는 각 피드에 대한 child tasks를 만든다.
  • 각 child task는 shared URLSession을 사용하여 피드의 URL에서 다운로드를 수행한다.
  • 다운로드 결과를 역직렬화하여 문서로 포맷한다.
  • 비동기 함수를 호출하여 데이터베이스를 업데이트한다. (updateDatabase 호출)

여기서 await 키워드는 asynchronous wait이다.

즉, 비동기 함수의 결과를 기다리는 동안 현재 스레드를 차단하지 않는다.

대신 함수가 일시 중단(suspend)되고 다른 작업을 실행할 수 있도록 스레드가 해방된다.

 

 

이를 위해 Swift Runtime에서 내부적으로 수행되는 작업들

1. await and non-blocking of threads

동기 함수의 작동 원리

  • 실행 중인 프로그램의 모든 스레드에는 함수 호출을 위한 상태를 저장하기 위한 Stack이 하나씩 있다.
  • 스레드가 함수 호출을 하면 새 프레임이 스택에 push 된다.
  • 이렇게 생긴 스택 프레임은 함수에서 로컬 변수, return address 및 기타 필요한 정보를 담고 있다.
  • 함수가 실행을 완료하고 return 되면 스택 프레임이 pop 된다.

 

비동기 함수의 작동 원리

  • updateDatabase 함수에서 add(_:) 메서드를 호출하는 스레드가 있다고 가정하자 (초록색 영역)
  • 가장 최근 스택 프레임은 add(_:)에 해당된다.
  • 스택 프레임은 일시 중단 지점 전체에서 사용할 필요가 없는 로컬 변수를 저장한다.
  • add(_:)의 body에는 await로 표시된 하나의 suspension point (일시 중단 지점)이 있다.
  • 로컬 변수인 id와 article은 정의된 후 중간에 중단 지점 없이 바로 for문에서 사용된다. 
  • 따라서 id와 article은 스택 프레임에 저장된다.

  • Heap에는 updateDatabase과 add를 위한 두 개의 비동기 프레임이 생기게 된다.
  • 비동기 프레임은 suspension point에서 사용할 수 있는 정보를 저장한다.
  • newArticles 아규먼트는 suspension 전에 정의되지만 suspension 후에 사용할 수 있어야 한다.
  • 즉, add에 대한 비동기 프레임은 newArticles를 추적한다.
  • 따라서, newArticles는 heap에 저장된다.

  • save 함수가 실행되면 add용 스택 프레임이 save용 스택 프레임으로 대체된다.
  • 새로운 스택 프레임을 추가하는 대신, 향후에 필요할 변수가 이미 비동기 프레임에 저장되어 있기 때문에 (heap 영역) 최상위 스택 프레임이 대체된다. (add용 스택 프레임이 사라진다는 의미)
  • save 함수도 비동기 프레임을 얻는다.
  • 문서가 데이터베이스에 저장되는 동안 스레드가 차단되는 대신 유용한 작업을 수행할 수 있다.

 

저장 함수의 실행이 suspend 되었다고 가정해 보자!

  • 스레드는 차단되는 대신 다른 유용한 작업을 수행하는 데 재사용된다.
  • 일시 중단 지점에서 유지되는 모든 정보는 Heap에 저장되므로 이후 단계에서 실행을 계속하는 데 사용할 수 있다.
  • 이 비동기 프레임의 list는 continuation의 런타임 표현이다.
  • 잠시 후 데이터베이스 요청이 완료되고 일부 스레드가 해제되었다고 가정해 보자

  • 다시 save가 실행되는 스레드는 이전과 동일한 스레드일 수도 있고 다른 스레드일 수 있다.
  • save 함수가 초록 스레드에서 실행을 resume 한다고 가정해 보자

  • save의 실행이 완료되고 일부 ID를 리턴하면 save용 스택 프레임이 다시 add용 프레임으로 대체된다.

  • 이제 스레드는 zip 함수를 실행할 수 있다.
  • zip 함수는 동기 함수이기 대문에 새로운 스택 프레임이 생성된다. (비동기 프레임은 생성 X)
  • Swift는 운영체제 Stack을 계속 사용하기 때문에 비동기, 동기 Swift 코드를 모두 C와 Obj-C로 효율적으로 호출할 수 있다.
  • C, Obj-C 코드는 동기 Swift 코드를 계속 효율적으로 호출할 수 있다.

  • zip 함수가 끝나면 스택 프레임이 pop 되고 add의 실행이 계속된다.

 

 

2. Tracking of dependencies in Swift task model

  • 함수는 await 지점에서 continuations으로 분할될 수 있다.
  • 위의 경우 URLSession data task는 비동기 함수이고 그 이후의 나머지 작업은 continuation이다.
  • Continuation은 비동기 함수가 종료된 후에만 실행할 수 있다.
  • 이는 Swift concurrency runtime이 추적하는 디펜던시이다.
  • 마찬가지로, Task Group 내에서 상위 태스크가 여러 하위 태스크들을 생성할 수 있다.
  • 상위 태스크가 진행되기 전에 각 하위 태스크들이 완료되어야 한다.
  • 이는 코드에서 태스트 그룹의 scope로 표현되는 디펜던시이다. (Task Group의 중괄호 내부)
  • 따라서, Swift 컴파일러와 런타임에 명시적(explicitly)으로 알려져 있다.

Swift에서 태스크는 Continuatinons나 Child Task 같이 Swift 런타임에 알려진 태스크들만 대기(await)할 수 있다.

따라서, Swift의 동시성 primitives를 사용하여 코드를 구조화하면 런타임이 태스크 간의 종속성 체인을 명확하게 이해할 수 있다.

 

요약

Swift의 언어적 특성을 이용해 태스크를 suspend 할 수 있다.

대신, 실행 중인 스레드가 태스크 디펜던시를 추론하여 다른 태스크를 대신 선택할 수 있다.

즉, Swift Concurrency로 작성된 코드는 스레드가 항상 Forward progress 할 수 있는 런타임 contract를 유지할 수 있다.

애플은 이러한 Runtime Contract를 활용하여 Swift Concurrency를 위한 통합 OS Support를 구축한 것이다.

 

 

 

Cooperative thread pool

새로운 Cooperative Thread Pool이 기본 실행기로서 Swift Concurrency를 뒷받침한다.

  • 새 스레드 풀은 CPU 코어 수만큼만 스레드를 생성하므로 overcommit을 막는다.
  • 작업이 블락되면(sync) 더 많은 스레드를 생성하는 GCD의 동시 큐와 달리 항상 forward progress가 가능하다.
  • 이를 통해 애플리케이션에 필요한 동시성을 제공하면서 동시성이 유발하는 문제(함정)들을 피할 수 있다.

  • GCD를 사용한다면 애플리케이션의 동시성을 제어하기 위해 애플리케이션을 별도의 subsystems들로 구성하고 subsystem당 하나의 Serial Dispatch Queue를 유지하는 것이 좋다.
  • 즉, 스레드 폭발의 위험 없이 subsystem 내에서 1보다 큰 동시성을 확보하기 어렵다.
  • Swift를 사용하면 런타임에서 활용할 수 있는 강력한 불변성을 제공하므로 더 잘 제어되는 동시성을 투명하게 활용할 수 있다. (Swift concurrency를 사용할 때를 말하는 듯)

 

Swift Concurrency를 도입할 때 검토 할 사항들

1. 동기 코드를 비동기 코드로 변환할 때 성능 이슈

이 작업을 할 때 Swift 런타임에서 추가 메모리 할당 및 로직과 같은 동시성과 관련된 몇 가지 비용이 있다. (continuation 저장 등)

따라서 Swift Concurrency를 도입하는 데 드는 비용을 잘 측정하여 도입해야 한다.

 

위처럼 간단한 작업은 동시성의 이점을 누리지 못할 수 있다. (Swift Concurrency를 도입하는데 필요한 비용이 더 크다.)

따라서 Swift Concurrency를 도입할 때는 Instruments 시스템을 통해 우리의 코드를 프로파일링 하여 성능을 잘 파악해야 한다.

 

2. Notion of atomicity around an await

  • await를 둘러싼 atomicity 개념을 주의해야 한다.
  • Await 이전의 코드를 실행한 스레드와 continuation 가져와 실행할 스레드가 동일한 스레드라는 것을 보장하지 않는다.
  • 실제로 await는 작업이 자발적으로 descheduled 될 수 있으므로 Atomicity가 깨졌음을 나타내는 명시적인 지점이다.
  • 즉, Task안에 await가 있다면 해당 Task는 atomic 하게 동작하지 않는다.

  • 따라서! across await에서는 lock을 유지하면 안 된다.
  • thread-specific Data도 await 중에 보존되지 않는다.
  • 코드에서 스레드 locality를 기대하는 모든 가정을 재검토하여 await의 일시 중단 동작을 고려해야 한다.

➡️ await 전의 상태와 후의 상태가 바뀌어 있을 수 있기 때문에 이를 고려하여 코딩해야 한다.

 

 

3. Runtime contract

  • 마지막으로 Swift Concurrency를 도입할 때에는 Swift의 효율적인 스레딩 모델의 기반이 되는 Runtime contract를 고려해야 한다.
  • Swift를 사용하면 스레드가 항상 Forward progress 하는 런타임 계약을 유지할 수 있다는 점을 기억하자
  • 이 계약에 따라 Swift는 Cooperative Thread Pool을 구축했다.
  • Swift Concurrency를 도입할 때에도 이 계약을 계속 유지하여 협력 스레드 풀이 최적으로 동작할 수 있도록 해야 한다.

 

Safe primitives

  • 코드의 디펜던시를 명시적으로 알려주고 안전하며 협력 스레드 풀 내에서 컨텍스트를 유지할 수 있다.
  • await, Actor, Task groups이 해당된다.
  • 디펜던시가 컴파일 타임에 알려진다. ➡️ 디버깅이 쉽다.

Caution required primitves

  • os_unfair_lock, NSLock과 같은 primitive가 해당된다.
  • 안전하지만 주의가 필요하다.
  • 동기식 코드에서 ciritical section을 synchronization 할 때 안전하다.
  • Lock을 유지하고 있는 스레드는 항상 Lock을 해제하기 위해 forward progress 하기 때문이다.
  • 따라서 primitive는 경합 중일 때 짧은 시간 동안 스레드를 차단할 수 있지만, forward progress에 대한 런타임 계약을 위반하지는 않는다.
  • 컴파일러의 지원이 없기 때문에 개발자가 실수하지 않고 작성해야 한다. (디버깅이 어려움)

 

Unsafe primitives

  • Semaphore, condition variable은 Swift concurrency와 함께 사용하기에 안전하지 않다.
  • Swift 런타임에서 디펜던시 정보를 숨기지만 코드 실행에 디펜던시를 도입하기 때문이다.
  • 런타임은 이러한 종속성을 인식하지 못하기 때문에 올바른 스케줄링 결정을 내릴 수 없다.

  • 특히, 위처럼 unstructured task에서 세마포어 같이 안전하지 않은 primitive를 사용하지 말자
  • 이것은 task boundary를 넘어 디펜던시를 소급하여 도입한다.
  • 위의 코드는 다른 스레드가 세마포어를 unblock(signal) 할 수 있을 때까지 스레드가 세마포어를 무기한 block(wait) 할 수 있는 문제를 만든다.

➡️ 스레드의 forward progress에 대한 런타임 계약 위반이다.

 

코드에서 이러한 안전하지 않은 primitive의 사용을 식별하는 데 도움이 되도록 LIBDISPATCH_COOPERATIVE_POOL_STRICT라는 환경변수를 사용하여 앱을 테스트할 수 있다.

이렇게 하면 디버그 런타임에서 앱이 실행되어 forward progress의 불변성을 적용한다.

실행 중에 협력 스레드 풀의 스레드가 중단된 것처럼 보이면 경고를 보내준다.

 

 

Synchronization

Swift Concurrency를 사용하면서 Synchronization을 할 수 있는 방안들에 대해 알아보자!!!

  • Actor가 이러한 동기화를 지원하는 강력한 Primitive이다.
  • Actor는 Mutual exclution을 보장한다. ➡️ No Data race

Mutual exclusion

 

직렬 큐에 sync로 디스패치

  • 큐가 아직 실행 중이 아니라면 경합이 없다.
  • 이 경우 호출 스레드는 컨텍스트 전환 없이 큐에서 새 work item을 실행하는데 재사용된다.
  • 대신 직렬큐가 이미 재사용 중이면 큐가 경합(contention) 중이며 이 상황에서는 호출 스레드가 blocking 된다. (차단하고 기다림)
  • 이것이 Tread Explosion의 원인이다.

 

직렬 큐에 aync로 디스패치

  • Non-blocking이다.
  • 따라서 Contention이 발생해도 스레드 폭발이 발생하지 않는다.
  • 단점은 경합이 없을 때 디스패치가 호출 스레드가 다른 작업을 수행하는 동안 async 태스크를 수행할 새로운 스레드를 요청해야 한다는 것이다.
  • 따라서 비동기 디스패치를 자주 사용하면 과도한 스레드 wakeup 및 컨텍스트 스위칭이 발생할 수 있다.

 

➡️ Actor의 등장!!!

 

Actor

  • 효율적인 스케줄링을 위해 Cooperative pool을 사용
  • 실행 중인 아닌 액터에서 메서드를 호출하면 호출 스레드를 재사용하여 메서드 호출을 실행
  • 호출된 액터가 이미 실행 중이라면 호출 스레드는 실행 중인 함수를 일시 중단하고 다른 작업을 시작 (Non-blocking)

 

뉴스 앱에 Actor를 도입해 보자!

 

GCD를 사용했을 떄
Swift Concurrency를 사용했을 때

  • Database의 직렬 큐는 Database actor로 대체된다.
  • 네트워킹을 위한 동시 큐는 각 뉴스 피드에 대해 하나의 actor로 대체된다. (위 예시에서는 Sports, Weather, Health 3개만 표시했지만 실제로는 더 많은 액터 존재)
  • 이러한 액터는 협력 스레드 풀에서 실행된다.
  • 피드 액터는 데이터베이스와 상호작용하여 데이터를 저장하고 다른 작업을 수행한다.
  • 이러한 상호 작용에는 한 액터에서 다른 액터로 실행 스위칭이 포함된다.
  • 이러한 과정을 Actor Hopping이라고 부른다.

 

Actor hopping

  • 스포츠 피드에 대한 Actor가 협력 풀의 스레드에서 실행 중이고 일부 article(데이터)를 데이터베이스에 저장하기로 결정했다고 가정하자
  • 지금은 데이터베이스가 사용되지 않고 있었다고 가정하자
  • 이것은 uncontended case이다.

  • 스레드는 스포츠 피드 액터에서 데이터베이스 액터로 directly hop 할 수 있다. (스포츠 피드 액터를 수행하던 스레드에서 그대로 데이터베이스 액터를 수행한다는 의미)
  • 여기서 두 가지를 주목하자!
    1. actor를 hopping 하는 동안 스레드가 blocking 되지 않는다.
    2. hopping하는 동안 다른 스레드가 필요하지 않으며 런타임이 스포츠 피드 액터의 work item을 suspend 하고 데이터베이스 액터에 대한 새 work item을 생성할 수 있다.

  • 데이터베이스 액터가 잠시 실행되었지만 첫 번째 work item을 완료하지 못했다고 가정하자
  • 이때 날씨 피드 액터가 데이터베이스 액터에 접근하려고 한다.
  • 그러면 데이터베이스 액터에 새 work item이 생긴다.

  • 액터는 상호 배제를 보장하며 주어진 시간에 최대 1개의 work item만 활성화한다.
  • 이미 활성 work item인 D1이 있기 때문에 새로운 work item인 D2는 pending 상태로 유지된다.

  • 액터는 Nonblocking이기 때문에 날씨 피드 액터가 suspend 되고 날씨 피드 액터를 수행하던 스레드는 다른 작업을 수행할 여유가 생긴다. ➡️ Health feed actor 수행

  • 잠시 후 초기 데이터베이스 요청(D1)이 완료되면 데이터베이스 엑터에 대한 활성 work item이 제거된다.
  • 이 시점에서 런타임은 데이터베이스 액터에 대한 pending work itme을 실행하도록 선택할 수 있다 (D2 수행)
  • 또는 피드 액터 중 하나를 재개하도록 선택할 수도 있다.
  • 또는 해제된 스레드에서 다른 작업을 수행할 수도 있다.

 

 

Reentrancy and prioritization

비동기 작업이 많고 특히 경합(contention)이 많은 경우, 시스템은 어떤 작업이 더 중요한지에 따라 절충안을 만들어야 한다.

이상적으로는 사용자 상호 작용과 같이 높은 우선순위 가진 작업들이 백업 저장과 같이 낮은 우선순위의 작업보다 우선시 되어야 한다.

 

액터는 재진입(Reentrancy)이라는 개념으로 인해 시스템이 작업의 우선순위를 잘 정할 수 있도록 설계되었다.

 

먼저 GCD가 우선순위를 처리하는 방법을 살펴보자!

 

  • 초록색 task는 high-priority task이다.
  • 보라색 task는 low-priority task이다.
  • Database queue에는 두 종류의 task들이 위의 사진과 같은 순서로 디스패치 되었다고 가정해 보자
  • Database queue에 들어온 task들은 들어온 순서대로 실행된다. (FIFO)
  • 따라서 초로색 Task인 B는 자신보다 우선순위가 낮은 보라색 Task 1~5가 실행되고 나서야 실행될 수 있다.
  • 이러한 문제를 Priority Inversion이라고 한다.

  • 직렬 큐는 우선순위가 높은 작업보다 앞에 있는 모든 작업들의 우선순위를 높여 Priority Inversion을 방지한다.
  • 실제로 이것은 대기열의 작업이 더 빨리 완료된다는 것을 의미한다. (초록색이 된 태스크들은 다른 리소스를 할당받을 때도 기존보다 우대받을 수 있음)
  • 하지만 여전히 B 앞에 1부터 5까지의 태스크가 완료되어야 B가 실행될 수 있다는 주요 문제는 해결되지 않았다.
  • 이 문제를 해결하려면 엄격한 선입선출(FIFO) 방식에서 벗어나 Semantic model을 변경해야 한다.

이것이 Actor Reentrancy로 연결된다!!

 

 

  • 스레드에서 Database 액터가 실행되었다가 잠시 suspend 되어 작업을 기다리는 중인 상황이다.
  • 해당 스레드에서는 스포츠 피드 액터가 실행되고 있다.
  • 잠시 후 스포츠 피드 액터가 데이터베이스 액터를 호출하여 일부 article을 저장한다고 가정하자!
  • 데이터베이스 액터는 경합이 없는 상태(다른 스레드에서 사용하고 있지 않음)이므로 보류 중인 작업이 하나(D1) 있더라도 스레드가 데이터베이스 액터로 이동할 수 있다.

  • save 작업을 수행하기 위해 데이터베이스 액터에 대한 새 work item이 생성된다. (D2)
  • Actor reentrancy란 액터에 있는 새 work item이 하나 이상의 이전 work item이 일시 중단된 상태에서 progress 될 수 있다는 의미이다.
  • 액터는 여전히 상호 배제를 유지하므로 주어진 시간 내에 1개의 작업만 진행할 수 있다.

  • 일정 시간이 흘러 D2가 완료된 상태이다.
  • D2는 D1 보다 나중에 생성되었음에도 D1보다 먼저 실행을 완료했다.
  • 따라서 Actor reentrancy를 지원한다는 것은 액터가 엄격한 선입선출이 아닌 순서로 item을 실행할 수 있다는 것을 의미한다.

앞선 GCD에서의 Priority Inversion과 같은 상황의 예시이다.

대신 Actor를 사용했다!

 

먼저 A는 문제없이 실행된다. (First In 및 High priority)

이 작업이 끝나면 낮은 우선순위의 Task 1~5가 큐의 앞에 위치하게 되어 우선순위 역전이 발생한다.

 

액터는 Reentrancy를 위해 설계되었기 때문에 런타임은 우선순위가 높은 항목을 우선순위가 낮은 항목보다 먼저 큐의 앞쪽으로 이동하도록 선택할 수 있다.

 

  • 이렇게 하면 우선순위가 높은 작업을 먼저 수행할 수 있다.
  • 이는 Priority Inversion 문제를 직접적으로 해결하여 보다 효과적인 스케줄링과 리소스 활용을 가능하게 한다.

 

Main actor

또 다른 종류의 액터인 Main actor가 있다.

시스템의 기존 개념인 Main thread를 추상화하기 때문에 그 특성이 조금 다르다.

 

액터를 사용하는 뉴스 앱 예시로 돌아가보자!

  • UI를 업데이트할 때 메인 액터와 호출을 주고받아야 한다.
  • 메인 스레드는 Cooprative pool과 분리되어 있으므로 Context Switching이 필요하다.

  • 데이터베이스에서 article을 load 하고 각 article의 UI를 업데이트하는 MainActor의 updateArticles 함수가 위와 같다고 가정하자
  • 루프의 각 반복에는 메인 액터에서 데이터베이스 액터로 이동하는 Context Switch와 다시 돌아오는 Context Switch 등 최소 2개의 콘텍스트 스위칭이 필요하다.

  • 이러한 루프에 대한 CPU 사용량에 대해 알아보자
  • 각 루프 반복에는 2개의 컨텍스트 스위칭이 필요하므로 짧은 시간 동안 2 개의 스레드가 차례로 실행되는 반복 패턴이 발생한다. (위의 그림처럼!)
  • 루프 반복 횟수가 적고 각 반복에서 각 작업이 완료되면 문제가 없을 수 있다.

  • 그러나 메인 액터를 자주 왔다 갔다 하는 경우 스레드 스위칭으로 인한 오버헤드가 크게 증가할 수 있다.
  • 애플리케이션이 컨텍스트 스위칭에 시간을 많이 소비하는 경우 메인 액터에 대한 작업이 일괄 처리되도록 코드를 재구성해야 한다.

  • loadArticles 및 updateUI 메서드 body로 반복문의 위치를 옮겨서 한 번의 하나의 값 대신 배열을 처리하도록 했다.
  • 이렇게 일괄 처리를 하면 Context Switching 횟수가 줄어든다.
  • Cooperative pool의 액터 간 Hopping은 빠르지만 Main Actor와의 Hopping은 주의하며 코딩하자!

 

원래 Cooperative pool에 속한 액터끼리 hopping 할 때는 전체 thread context switching 대신 continuation을 통한 경량 스위칭을 활용한다.

하지만 Main Actor는 Cooperative pool에 속하지 않았기 때문에 전체 thread block에 대한 context switching이 필요하다.

따라서 Main Actor와 다른 Actor 간의 hopping 시에는 이러한 스위칭을 최소화하는 방안으로 설계하는 것이 좋다!

 

 

 

댓글