안녕하세요! 미니입니다. 오늘은 Swift Concurrency를 사용하면서 생긴 궁금증에 대해서 학습한 내용을 공유하려고 합니다. Swift Concurrency의 기본적인 내용은 해당포스팅 에서 확인할 수 있습니다. 저희는 비동기 프로그래밍을 활용하기 위해서 GCD를 많이 활용해왔습니다. 하지만, Swift 팀은 구조화된 Concurrency와 비슷하게 구현하게 되었습니다. 그렇다는건 GCD의 문제점들이 있다는 뜻이겠죠? 그리고 또한, Swift Concurrency는 내부적으로 어떻게 동작하는지에 대해서 알아보았습니다. (해당 내용은 Swift concurrency: Behind the scenes을 기반으로 작성되었습니다.)
저희가 저번에 등장 배경에서 언어적으로 다양한 문제들이 발생한다고 했습니다. 하지만, GCD는 성능적으로도 문제가 발생하기 때문에 등장했다고 이야기할 수 있습니다. GCD는 내부적으로 어떻게 동작을 하고 있는지 잠깐 살펴보려고 합니다.
GCD 내부사항
저희가 앱을 만드는데 다음과 같은 코드들을 자주 생산할 것입니다.
func deserializeArticles(from data: Data) throws -> [Article] { /* ... */ }
func updateDatabase(with articles: [Article], for feed: Feed) { /* ... */ }
let urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: concurrentQueue)
for feed in feedsToUpdate {
let dataTask = urlSession.dataTask(with: feed.url) { data, response, error in
// ...
guard let data = data else { return }
do {
let articles = try deserializeArticles(from: data)
databaseQueue.sync {
updateDatabase(with: articles, for: feed)
}
} catch { /* ... */ }
}
dataTask.resume()
}
코드를 잠깐 살펴보겠습니다. 사용자 인터페이스를 통해서 최신 뉴스를 보여달라는 이벤트가 발생하게 되면, 뉴스를 추적하기 위해서 데이터베이스에 접근하게 되고, 네트워킹 로직을 처리하는 과정을 수행하게 됩니다. 여기서 데이터 베이스 작업을 직렬 대기열에 할당하게 되는 이유는 다른 대기열에 할당하여, Main Thread가 다른 작업을 할 수 있도록 합니다. 또한, 상호배제성을 보장하게 되면서 데이터베이스 접근에 대한 엑세스를 보호할 수 있습니다. Database 대기열에 있는 동안 사용자가 구독한 뉴스 피드에 대한 비동기 작업을 반복적으로 요청하게 됩니다. 이에 대한 결과가 돌아오게 되면, Delegate Queue에서 URLSession 콜백함수가 호출되게 됩니다. 각 데이터에 대해서 데이터베이스큐에서 동기로 업데이트를 수행합니다.
이런 상황에서 GCD가 작업을 수행하는 방식을 한번 알아보죠!
피드의 네트워킹 결과가 오게 되면, 어떤 동작을 할까요? ConcurrenctQueue에서 작업을 수행하기 때문에 동시에 여러가지 작업을 처리하기 위해서 여러개의 쓰레드를 사용하게 됩니다. 이 동작은 CPU의 코어가 가득 찰 때까지 하게 됩니다. 이런 상황에서 databaseQueue는 동기적으로 동작하기 때문에 쓰레드에 대한 접근을 차단하게 될 것입니다. 그렇다면, GCD는 어떻게 할까요? 네트워킹 큐에서 작업하기 위해서 더 많은 쓰레드를 가져오게 됩니다. 쓰레드를 계속해서 가져오게 된다면, 많은 일을 처리할 수 있기 때문에 좋은 것이 아닌가? 라고 생각할 수 있습니다. 하지만, 쓰레드가 지속적으로 증가하는 과정에서 CPU의 코어보다 많은 쓰레드를 가져오게 된다면, 오버 커밋 현상이 발생될 수 있으며, 이는 쓰레드 폭발로 야기될 것입니다. 또한, 쓰레드를 전환하기 위해서는 Context Switching을 수행하여야 합니다. 예를 들어서 100개의 피드를 업데이트 하는 작업을 수행하여야 하고, 6코어를 가진 아이폰에서 동작하게 됩니다. 이 상황에서 16배 많은 쓰레드를 가지게 되는 것입니다.
이는 많은 문제들을 야기시킵니다.차단된 쓰레드는 각각의 작업이 다시 실행되기 위해서 메모리와 리소스를 보유하고 있게 됩니다. 이런 메모리 와 리소스적인 문제뿐만 아니라 쓰레드 폭발로 인해서 스케줄링 오버헤드도 발생하게 됩니다. 물론, 위에서 말한 것처럼 Context Switching의 문제도 있습니다. 이런 문제들을 위해서 Swift Concurrency는 성능과 효율성을 높이기 위해서 등장했습니다. Swift Concurrency가 동작하는 방식을 알아보려고 합니다.
Swift Concurrency
Swift Concurrency는 언어적으로 await
을 통해서 비동적으로 기다리는 것이라고 지난번 포스팅을 통해서 우리 모두 알고 있습니다. 이는 비동기 함수의 결과를 기다리는 동안 현재의 쓰레드를 차단하지 않고, 다른 작업을 수행할 수 있다고 했습니다. 이에 대해서 내부적으로 어떻게 동작하는지 알아보죠!
Concurrency 내부 상황
// on Database
func save(_ newArticles: [Article], for feed: Feed) async throws -> [ID] { /* ... */ }
// on Feed
func add(_ newArticles: [Article]) async throws {
let ids = try await database.save(newArticles, for: self)
for (id, article) in zip(ids, newArticles) {
articles[id] = article
}
}
func updateDatabase(with articles: [Article], for feed: Feed) async throws {
// skip old articles ...
try await feed.add(articles)
}
우선 동작을 알아보기 전에 함수를 실행하게 되면, 저희 모두 스택에 쌓이게 된다는 것을 알 것입니다. (몰라도 괜춘!)
다음 코드를 통해서 어떻게 동작하는지 알아보려고 합니다. updateDatabase
함수가 호출되면, 내부적으로 add
함수가 비동기적으로 수행됩니다. add 함수는 스택 프레임에 쌓이게 됩니다. add 함수도 내부적으로 database에 저장하기 위해서 save 함수를 호출하고, 비동기적으로 동작하고 있습니다. 중단점 이후에 동기적으로 동작하는 곳에서 필요한 id와 article이라는 지역변수를 스택 프레임에 저장하게 됩니다. 또한, 힙 영역에는 save함수와 add함수에 대한 비동기 프레임을 구성하게 됩니다. newArticles이라는 인자는 중단점에서도 활용하고, 중단점 이후에도 활용하여야 합니다. 그렇기 때문에 비동기 프레임이 해당 인자를 추적하게 됩니다. add 함수 내에서 save 함수가 실행되게 되면, add에 대한 스택 프레임은 save에 대한 스택 프레임으로 교체되게 됩니다. 교체가 가능한 이유는 향후에 필요한 모든 변수가 비동기 프레임이 추적하고 있기 때문입니다. save 함수는 실행을 위해서 비동기 프레임을 추가적으로 생산하게 됩니다. save 함수가 실행되는 동안 쓰레드는 차단되지 않고, 다른 작업들을 위해서 재활용됩니다. (역시 Recycling이 중요하군요 ㅎㅎㅎ) 그래서 쓰레드에 대한 제어권을 포기한다는 의미가 정확하게 맞는 것 같습니다. 함수가 쓰레드에 대해서 점유하지 않고, 놓아주게 되면서 쓰레드는 다른 작업을 수행할 수 있게 되는 것입니다.
이제 쓰레드가 어떤 동작을 수행하는 지 알았습니다. 그렇다면, 중단점 함수가 수행되고 난 후에는 어떻게 될까요? save 함수가 종료되고 난 후에는 스택프레임이 다시 add 스택 프레임으로 대체됩니다. 다시 add 함수가 실행되면, 중단 지점 이후 zip 메서드는 비동기 함수가 아니기 때문에 새로운 스택 프레임을 생성하게 됩니다. (이때, add함수는 동일한 쓰레드에서 동작할 수도 있고, 아닐 수도 있습니다.)
해당 과정이 글로 되어 있다 보니 이해가 어려울 수 있을 것 같아서... (저도 어려워서...) 그림으로 좀 만들어봤습니다. 도움이 되면 좋겠습니다!
우선 함수가 작업을 수행하기 위해서 쓰레드를 찾아가게 됩니다. 해당 함수는 중단점을 가지고 있는 비동기 함수라고 명찰을 달고 가겠죠?
쓰레드는 이제 중단점이 존재하기 때문에 메모리에다가 데이터 올려둘게! 끝나면 와 (근데 나 아닐 수 있음..!) 즉, 중단점이 있기 때문에 스택 프레임을 다른 것으로 변경한다고 했죠? 또한, 힙 영역에 중단점 이후에 필요한 변수들을 저장해두게 됩니다.
쓰레드는 이제 작업이 없기 때문에 이제 다른 함수를 수행할 수 있습니다.
이제 비동기 함수가 중단점이 끝나고 난 후 다시 작업을 제기하기 위해서 쓰레드를 찾아갔는데 이미 올려두었던 데이터를 활용하여서 작업을 계속 진행해주게 됩니다.
사실 함수가 직접 쓰레드를 찾아는 것은 아닙니다. 쓰레드를 할당하는 것은 시스템이 할 역할이니까요?
결론
오늘은 Swift Concurrency가 내부적으로 어떻게 이루어지는지에 대해서 알아봤습니다. 다음에는 Swift Concurrency와 함께 등장한 Actor 타입에 대해서 다뤄보려고 합니다.
[참고]
https://developer.apple.com/videos/play/wwdc2021/10254/
https://velog.io/@wansook0316/Swift-ConcurrencyBehind-the-scenes-Part.-01#gcd-and-thread-bring-up
https://engineering.linecorp.com/ko/blog/about-swift-concurrency-performance
https://zoeful-log.tistory.com/129
'IOS > Swift' 카테고리의 다른 글
Swift Concurrency 맛보기 & 맛 평가 (2) | 2023.10.04 |
---|---|
Combine (2) (0) | 2023.09.07 |
Combine (1) (0) | 2023.09.07 |