해당 글에서는 Swift Concurrency의 성능에대해서 알아봅니다.
(WWDC21 Swift Concurrency Behind the Scenes 21분까지의 내용을 정리했습니다.)
두 가지를 중점적으로 볼 것입니다.
- Threading Model (GCD와의 비교)
- Actor를 활용하여 Synchronization을 하는 방법
Threading Model
아래와 같은 앱이 있다고 생각해봅시다.
- Main Thread에서는 유저의 event gesture를 처리
- Main Thread는 비동기 Serial Queue에 loadNewsFeeds()를 호출한다.
Serial Queue에서 Work를 비동기적으로 던지는 이유?
-> 왜냐하면 main thread는 유저의 input을 받을 준비를 해야하며, Serial Queue는 mutual exclusion(상호 배제)을 보장하기 때문에 database의 접근을 막을 수 있어서 안전하다.
loadNewFeeds()는 Network에게 iterator를 통하여 여러 개의 task를 던지게되고, Network는 Concurrent Queue를 활용하여 Result를 return하고 각각이 완성이되면 동기적으로 database를 업데이트하게 됩니다.(순차적으로 업데이트) 그러면 이제 Main Thread가 UI를 refresh하게 됩니다.
위 과정을 코드로 확인해보면 위와 같습니다. 근데, 위와 같은 문제는 퍼포먼스적으로 문제점이 있습니다.
GCD의 Thread 생성 방식
GCD에서 queue에 work item이 들어오면, 시스템은 thread를 가져와서 해당 work들을 해결합니다. 위와 같이 Concurrent Queue일 경우에는 여러 개의 CPU가 thread를 가져와서 work를 해결합니다.
그러나, 만약 위의 화살표와 같이 thread block이 일어나게 되면 남은 work item을 해결하기 위하여 새로운 thread를 가지고 옵니다. 이렇게 구현을 한 이유는 두 가지입니다.
- 각각의 코어가 어느 시점에서든지간에 work item을 해결할 수 있게 하기 위해서 -> Good, Continuing level of Concurrency를 보장(동시성을 보장)
- Block이된 thread는 semaphore와 같이 리소스를 기다릴 수 있는데, 새로 가져온 thread가 첫번째 스레드가 기다리고 있던 resource를 unblock해 줄 수 있기 때문입니다.
Database queue에서 thread는 block이 되므로,(databaseQueue.sync) Networking Queue에서 일하기 위해서 여러 개의 thread를 가져오게 됩니다. 그래서 CPU는 Networking 결과를 처리하기위해서 서로 다른 thread사이에서 context switching을 합니다.
위와 같은 코드는 따라서 수 많은 thread를 생성하게 됩니다. 만약 유저가 수백개의 feed 업데이트를 요청하게 된다면, 각각의 URL Data Task들은 Concurrent Queue에 completion block을 가지게 됩니다. GCD는 각각의 callback들이 Database queue에서 block이 될 때마다 새로운 thread를 가지고 오게됩니다. 이러한 과정을 통하여 결과적으로 수많은 thread를 갖게됩니다. 이것은 시스템이 CPU 코어보다 더 많은 스레드를 가지게 되는 상황을 연출할 수 있습니다. 즉 Thread Explosion을 유발합니다.
Excessive concurrency ( Thread Explosion)
Thread explosion은 CPU 코어 수보다 더 많이 시스템이 thread를 생성하여 일을 처리하는 것입니다. 이러한 Thread explosion은 Memory overhead와 Scheduling overhead를 유발합니다.
메모리 오버헤드
각각의 block이된 스레드는 다시 unblock이 되기 전까지 값이 있는 리소스와 메모리를 가지고 있습니다. 또한 실행 흐름을 저장한 stack과 thread를 추적할 수 있는 연관된 kernel 데이터 구조를 가지고 있습니다. 또한, 어떤 스레드들은 실행되고 있는 다른 thread들이 필요로 할 수 있는 lock을 가지고 있을 수 있습니다. 이와 같이 실제로 진행되고 있지 않은 스레드들이 수 많은 resource들과 정보들을 가지고 있습니다.
스케쥴링 오버헤드
새로운 스레드가 생성되면, 오래된 스레드에서 새로운 스레드로 thread context switch가 일어나야합니다. 즉, 언제 스레드가 처리될 것인 지를 정해야합니다. Block이 되었던 thread가 다시 실행이되면, 스케쥴러는 CPU와 thread를 timeshare함으로써 실제로 일을 진행시킬 수 있습니다. 적당한 TimeShare는 괜찮지만, 제한된 core에서 timesharing이 많아지면 너무 많은 context switching을 유발할 수 있습니다. 결과적으로 이것은 CPU가 비효율적으로 일을 하게 만듭니다.
Concurrency in Swift
Swift Concurrency는 왼쪽 그림과 같이 여러 Thread와 빈번한 context switching에서 벗어나서, 오른쪽 그림과 같이 두 개의 코어에 2개의 스레드만 생성시킬 수 있습니다. (+ Context Switching도 없음)
블럭되는 Thread가 없는 대신, Swift Concurrency는 가벼운 오브젝트인 continuation을 활용합니다. continaution은 work의 재시작점을 트래킹합니다. Swift Concurrency는 Thread 간의 context switching을 하는 것이 아닌, 같은 스레드 내에서 continuation을 switching합니다. 우리는 따라서 함수 호출에 드는 비용만 지불하면 됩니다.
이러한 동작들을 수행하기 위해서는 Thread가 block되지 않을 것이라는 운영체제의 contract가 필요합니다. 그리고 이러한 contract을 위해서는 언어적인 특성이 필요합니다.
Language features
- await의 활용과 non-blocking of threads
- 런타임에 Task의 의존성을 추적하는 것
await의 활용과 non-blocking of threads
- await 키워드는 async 함수를 통해서 result를 기다리는 동안 현재 Thread를 block하지 않습니다.
- 대신에, 함수는 잠시 중지되고 스레드는 다른 일을 하기위해서 잠시 자유로워집니다.
어떻게 이러한 과정이 일어나는 것일까요?
먼저, non async 함수가 어떻게 작동하는 지 알아봅시다.
실행되고 있는 모든 프로그램의 모든 Thread는 stack을 가지고 있습니다. stack은 함수 호출의 상태를 저장합니다. 만약 Thread가 함수 호출을 실행하면, stack에 새로운 프레임이 push됩니다. 이렇게 새롭게 생성된 stack frame은 지역 변수나, return address 또는 필요한 정보를 저장하는 데 사용됩니다. 함수가 끝나면 해당 stack 프레임은 pop이됩니다.
그렇다면 이제 aysnc 함수를 알아봅시다.
add 함수는 await로 찍혀진 곳 외에는 다른 suspension point가 없습니다. 그리고 지역 변수인 id, article는 suspension point를 넘나드는 변수가 아니므로 stack frame에 바로 저장이됩니다.
근데, await는 suspension point를 생성하여 함수가 잠깐 멈추게 됩니다. 따라서 await 앞/뒤의 정보를 저장해야되는 공간이 필요한데, 이러한 공간을 heap에 생성합니다. 위 코드에서는 add를 위한 async frame(비동기 프레임)이 heap 생성이되고, newArticles와 같이 await 전에 정의가 되었지만, await 이후에 zip 함수에서 활용이 되는 정보가 해당 프레임에 저장이됩니다. 다릴 말하자면, add의 async frame가 newArticles를 계속해서 추적한다는 뜻입니다.
그리고 await database.save 함수가 실행되기 시작하면, add의 stack 프레임은 save stack 프레임으로 대체가 됩니다. 미래에 필요할 어떠한 변수들, 즉 위의 예에서는 newArticles와 같은 변수들은 이미 async frame인 add에 저장되어있기에 새로운 stack frame을 생성하는 것이 아니라, 가장 최상단에 있는 stack frame을 대체시키는 것입니다.
그렇다면 save 함수내에서 await 함수가 호출되는 코드가 있다고 가정을 해보겠습니다. 그러면 save 함수도 자신만을 위한 async frame을 가지게 됩니다.
그리고 save 함수 내에 있는 await에서는 suspend가 일어나고, 스레드는 시스템에게 넘어가게됩니다. 그리고 스레드는 블럭이 되는 것이 아니라 다른 유용한 일을 하기 위해서 재사용됩니다. 아까 newArticles와 같이 suspension point를 넘나드는 정보들은 heap에 저장이되므로, 나중에 실행을 재개할 때에 사용이 될 수 있습니다. 이러한 async 프레임의 리스트는 continaution의 runtime representation입니다.
다른 일을 하고 난 후에 thread는 다시 돌아오게 됩니다. 이때 thread는 이전과 동일한 스레드일 수도 있고, 아닐 수 도 있습니다. save 함수가 실행되고, 실행이 끝나면 [ID]를 반환하고 save의 stack frame은 add의 stack frame으로 대체가 됩니다. 그 이후에 스레드는 zip 함수를 실행시킵니다.
zip 함수는 non-async 함수이므로 새로운 stack frame을 생성시킵니다. 그리고 zip 함수가 끝나게 되면 zip stack frame은 pop이 됩니다.
런타임에 Task의 의존성을 추적하는 것
위의 코드에서 URLSession data task는 async 함수입니다. 그리고 그 뒤의 일들은 continuation입니다. Continuation은 async 함수가 끝나고 난 뒤에서만 실행이됩니다. 이것이 Swift concurrency 런타임의 의존성 추적입니다.
즉, continuation을 정리하면 아래와 같습니다.
- 비동기 호출 후에 일어나는 일, await 호출 후의 모든 것
이러한 과정을 통하여 Swift concurrency는 runtime contract을 구현할 수 있었습니다.
WWDC의 21분까지 정리한 내용입니다!..
'Swift' 카테고리의 다른 글
[Swift] 셀 재사용에 따른 중복 binding 이슈(feat.disposeBag) (0) | 2024.04.09 |
---|---|
[Swift] Async, Await(1) (feat. WWDC) (0) | 2023.08.22 |
[Swift] WWDC21 - ARC in Swift: Basics and Beyond (0) | 2023.08.15 |
[Swift] [weak self]는 언제 사용할까? (0) | 2023.08.11 |
[Swift] WWDC16 Understanding Swift Performance(3) (0) | 2023.08.03 |