일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- requirenativecomponent
- 파노라마 뷰
- 라이브러리 없이
- 앱 성능 개선
- ios
- launchscreen
- SwiftUI
- 명시적 정체성
- React Native
- 리액트
- ssot
- 360도 이미지
- 구조적 정체성
- @sendable
- native
- panorama view
- 뷰 생명주기
- 360도 뷰어
- react-native-fast-image
- 뷰 정체성
- completion handler
- data driven construct
- 스켈레톤 통합
- launch screen
- 네이티브
- 리액트 네이티브
- 360도 이미지 뷰어
- React-Native
- react
- Android
- Today
- Total
Neoself의 기술 블로그
Swift Concurrency: Behind the scenes 정리 본문
기존 Grand CentalDispath에서 사용자가 구독한 뉴스를 URLSession으로 불러와 사용자 뉴스 피드에 보여주고자 할때, 위와 같은 도식의 흐름이 필요했습니다.
메인 스레드는 사용자 입력에 대한 반응성을 유지하도록 하고
상호배제를 보장하는 직렬큐를 통해 추후 URLSession으로 부터 받는 결과들을 관리함으로써, 데이터 베이스 접근 간 상호 배제를 보장할 수 있습니다.
이와 같은 구조에서 네트워크 요청 결과가 도착하면, URLSession의 콜백이 병렬 큐에서 호출되어 UI를 새로 고치게 될 것입니다.
중요한 것은 GCD에서 작업이 대기열에 등록되면, 스레드가 새로 생성되어 작업을 처리한다는 것입니다.
즉, 모든 여유 CPU 코어를 점유할때까지 시스템은 새로 생성한 스레드들을 할당할 것이며, 네트워크 및 I/O 작업, 락과 같은 동기화 매커니즘 사용으로 인해 스레드가 블록될 경우, GCD는 추가 스레드를 생성해
1. 남은 작업을 처리하게끔 설계하여 blocked된 스레드가 다시 unblock되기 위해 필요로 하는 추가 작업을 수행할 수 있도록 하거나,
2.스레드의 자원 대기 문제를 해결하게끔 합니다.
하지만, 설령 사용자가 백개의 피드를 업데이트하는 것과 같이 많은 비동기작업 요청이 발생하면, 각 데이터 작업은 완료 이후 UI 업데이트라는 후속작업을 실행시켜야 하기 때문에, 동시 큐 내부에서 모두 completion block을 갖게 되며, 스레드가 과도하게 생성될 수 있습니다.
결국, 시스템이 CPU 코어수(A17 기준 6코어)보다 더 많은 스레드를 처리하게 되며, 스레드 폭발 현상이 발생합니다.
스레드 폭발
시스템 자원을 고갈시켜, 성능이 급격히 저하되는 현상
- 각 스레드는 약 1MB의 스택 메모리와 커널 리소스를 소비하기 때문에 현재 실행중인 스레드에 필요한 리소스가 고갈될 수 있습니다.
- 새로운 스레드 생성 시, CPU는 기존 스레드에서 벗어나 새로운 스레드를 실행하기 위해 스레드 컨텍스트 전환을 수행해야합니다 .
결국 차단된 스레드가 다시 실행가능해지면, 스케줄러는 CPU에서 스레드를 타임쉐어함으로써, 모든 스레드가 계속 진행될 수 있도록 해야하지만, 스레드 폭발 상황에서는, 수백개의 스레드를 타임쉐어하기 때문에 과도한 컨텍스트 전환이 발생하게 됩니다. 이는 CPU 효율성을 큰 폭으로 저하시킵니다.
여기서 구조적 동시성은 "Continuation"이라는 경량 객체를 사용해 차지하는 메모리와 리소스를 줄인다는 차이점을 갖고 있습니다.
구조적 동시성 아래 작업을 수행하게 될 경우, 완전한 스레드 컨텍스트 스위칭 대신, Continuation 간의 전환만 수행하기에, 메서드 호출 비용만 소모하게 됩니다.
결국, 구조적 동시성은 런타임동안 CPU 코어수만큼만 스레드를 생성하고, 스레드가 블록될 경우 작업 아이템(Work Items)들 간 적은 비용으로 전환될 수 있게끔 보장합니다.
await 키워드를 사용할 경우 비동기 대기를 진행하게 되면서, 비동기 함수를 기다릴 동안 현재 스레드를 차단하지 않고, 일시 중단(스레드 해제)되며 다른 작업이 실행될 수 있도록 열어놓습니다.
Swift 비동기 및 동시성 처리 모델
일반 Stack Frame은 연기 지점(await 키워드 위치) 전체에서 불필요한 지역변수를 저장합니다. 설령, 위 코드의 id와 article은 정의되자마자 for 반복문 내부에서 사용되기 때문에, StackFrame에 저장됩니다.
이와는 별개로 updateDatabase와 add 비동기 메서드를 위한 async frame이 Heap영역에 존재하며, 해당 비동기 프레임은 일시중단 지점 전체에서 사용할 수 있어야 하는 정보를 저장합니다. 설령, newArticles 변수는 일시중단 지점 전후에 모두 사용되어야 하기 때문에, newArticles 상태 추적을 위해 비동기 프레임에 저장되어야 합니다.
이후 스레드가 계속 실행될 경우 add 메서드 내부 database.save 메서드를 호출하게 되는데, 이때 Stack Frame은 새로 생성되어 push되는 것이 아니고, 대체됩니다.
이러한 설계의 이유는 향후 필요한 변수들이 모두 이미 비동기 프레임에 저장되어 있기 때문입니다.
이 때 새로 호출된 save 메서드는 비동기 메서드이기에 이를 위한 새로운 비동기 프레임이 생성됩니다.
만일 이 단계에서, save 메서드가 장기간 Suspend 될 경우, 스레드는 블록되는 대신 다른 작업 수행을 위해 재사용되며, 비동기 프레임에서 일시 중단 지점에 대한 정보가 힙 영역에 보관되고 있기 때문에, 언제든지 실행을 재개할 수 있게 됩니다.
즉, 이 비동기 프레임 목록은 Continuation에 대한 런타임 표현이 됩니다.
함수는 await 지점에서 Continuation으로 분할될 수 있습니다.
좌측 코드는 비동기 메서드인 URLSession DataTask와 Continuation인 나머지 작업으로 분할될 수 있으며, 이 Continuation 작업은 비동기 메서드가 종료된 후에만 실행될 수 있습니다.
우측 코드는 withThrowingTaskGroup을 기점으로 부모 작업이 정의되며, 그 내부 group.async 클로저를 통해 여러 자식 작업이 생성되고 있는데, 여기서도 마찬가지로 여러 자식 작업들이 종료되어야 부모 작업이 진행될 수 있습니다.
이러한 코드의 종속성은 Swift의 동시성 런타임(Concurrency Runtime)에서 알수 있으며, 코드를 통해서도 scope를 통해 명시적으로 파악할 수 있습니다.
즉, Swift 언어기능을 사용해 대기 중에 작업을 일시중단할 수 있으며, 이때 실행중인 스레드는 작업 종속성을 추론하고 대신 다른 작업을 선택할 수 있습니다.
구조적 동시성으로 작성된 코드들은 스레드가 항상 앞으로 진행할 수 있도록 하는 런타임 계약을 제공하며, 새로운 협동 스레드 풀을 통해 이 계약을 최대한 활용해 시스템 리소스를 효율적으로 관리할 수 있습니다.
이는 CPU 코어 수만큼 스레드를 생성하기에, 시스템에 과도한 부담을 주지 않으며, 작업 항목이 차단되면 더 많은 스레드를 생성하던 GCD의 Cocurrent 큐와 달리 항상 진행할 수 있기 때문에, 런타임은 추후 생성되는 스레드 수를 신중하게 제어할 수 있습니다.
스레드 풀: 마치 자전거 대여소같이 미리 생성된 스레드들을 모아 놓은 영역입니다. 필요할 때마다 스레드를 생성하고 제거하는 대신, 풀에서 스레드를 가져와 사용하고 다시 반환합니다.
하지만, 구조적 동시성을 도입하는 것이 항상 좋지만은 않습니다.
1. 성능 저하
async let으로 선언된 isThumbnailView 속성은 매번 호출될때마다 비동기 작업을 수행합니다. 이는 매 호출마다 디스크에서 값을 읽어올 수 있게 됨에 따라 소요시간이 증가될 수 있으며, 현재 컨텍스트가 일시중단됨에 따라 데이터의 일관성이 저해될 수 있습니다.
// 앱 시작 시 한 번만 읽고 메모리에 저장
let isThumbnailView = UserDefaults.standard.bool(forKey: "ViewType")
// 필요할 때 동기적으로 사용
if isThumbnailView {
// Perform thumbnail view layout
} else {
// Perform list view layout
}
때문에, 위와 같이 한번만 읽고 이를 지역변수로서 메모리에 바로 저장하여 사용하는 것이 성능상 유리합니다.
2. 데이터 원자성(안정성) 저하
Await 사용시, 실행했던 스레드가 Continuation을 실행할 것이라는 보장이 없을뿐더러, 일시중단될 동안 앱 전역 상태가 변경될 수 있게 됨에 따라 일관성 또한 준수되지 않게 됩니다.
때문에, await 이전에 lock을 유지할 경우, 다음 스레드가 이전에 실행된 스레드가 아닐 경우, 스레드가 계속 Block될 수 있습니다.
스레드별 데이터는 await에서도 보존되지 않습니다.
3. 런타임 계약 미준수 시 fatal 에러
구조적 동시성 사용시, 런타임 계약을 유지하여야 협력적 스레드 풀이 최적의 상태에서 동작하며 스레드를 지속적으로 수행할 수 있게 됩니다. 다만, 이때 Swift 런타임에 알려진 작업(Continuation, Child Task)들만 협력적 스레드 풀에서 대기할 수 있습니다.
그리고 이러한 런타임 계약을 유지하기 위해선 코드에서 명시될 수 있는 await, Actors, TaskGroup과 같은 구조적 동시성의 기본 요소를 사용해야합니다.
여기서 NSLock과 같은 기본 요소는 사용이 가능합니다만 주의가 필요합니다. 이는 짧은 시간동안 스레드를 차단할 수 있지만 전진 진행이라는 런타임계약을 위반하지 않습니다. 다만, 올바른 사용을 돕는 컴파일러 지원이 없기에 개발자의 책임이 중요합니다. 하지만, DispatchSemaphore와 같은 요소는 런타임에서 의존성 정보를 숨기게 되면서 런타임이 올바른 스케줄링 결정을 내리고 해결할 수 없기 때문입니다.
특히 구조화되지 않은 작업을 생성한 다음, 세마포어를 통해 작업 경계를 넘어 의존성을 소급적으로 도입하면, 다른 스레드가 세마포어를 해제할 수 있을때까지 스레드가 무기한으로 세마포어에 대해 차단될 수도 있게 됩니다.
Swift는 Swift 런타임이 인식할 수 있는 자식 작업이나 Continuation에 대해서만 비동기 대기를 수행할 수 있습니다.
Swift의 액터와 동기화
액터 타입은 동시에 하나의 메서드 호출만 실행할 수 있는 상호 배제를 보장하며, 이로 인해 데이터 레이스를 방지할 수 있습니다.
GCD에서의 직렬화 큐의 경우, 큐가 이미 실행중이면 경쟁상태에 있음을 의미하며 호출 스레드는 블록됩니다. 다만 이는 스레드 폭발을 유발하게 됩니다. 이를 위해 dispatch async가 도입되면서 스레드폭발의 원인인 스레드 차단은 방지할 수 있으나, 경합(Contention)이 없을때, Dispatch가 비동기 작업을 수행할 새 스레드를 요청해야 한다는 것입니다. 이는 과도한 컨텍스트 전환으로 이어집니다.
이에 반해, 액터는 Non-blocking 방식으로 작업을 처리하기에 스레드 폭발을 예방하며, 실행중이 아닌 액터에서 메서드를 호출하면 호출하는 스레드를 재사용하며 메서드 호출을 실행할 수 있습니다.
액터는 협력적 스레드 풀을 활용해 스케줄링을 제공하며, 필요에 따라 호출 스레드를 재사용합니다. 여기서 액터는 다른 액터 간 상호작용할 수 있으며(Actor hopping), 전환 시에도 실행중인 작업을 차단하지 않습니다.
위 도식과 같이 비동기 작업이 많고 경쟁이 치열하면, 시스템은 어떤 작업이 더 중요한지에 따라 트레이드 오프를 해야합니다. 설령, 사용자 인터랙션과 관련된 고우선순위 작업이 백그라운드 작업보다 우선시될 수 있을 것입니다.
액터의 재진입성(Reentrancy)
위 도식에 대해 설명하겠습니다.
Database actor가 await 지점에 도달하여 일시중단되고, Sports Actor가 해당 스레드에서 작업을 이어 실행하고 있으며, 잠시후, Sports Actor가 Database Actor를 호출하여 기사를 저장한다고 가정합니다. 이때 Database Actor는 상호배제를 유지하고 있기 때문에 경쟁상태가 아니며, 바로 Database Actor 접근이 가능해집니다.
Database Actor는 이후, 저장작업 수행을 위해 새로운 작업 항목을 생성하게 되는데 이 작업항목은 이전 작업 항목이 일시중단된 동안에도 진행될 수 있습니다. 이러한 개념이 바로 액터의 재진입성을 의미합니다.
여기서 주의해야할 점은 재진입으로 새로 생성된 작업항목이 이전에 일시중단된 작업보다 먼저 실행을 완료할 수 있다는 점입니다.
좌측의 GCD 직렬큐의 경우 높은 우선순위를 가지는 UI 관련 작업이 낮은 우선순위의 Background 작업 사이에 배치되었기에, UI 반응이 지연되는 상황이 발생할 수 있다는 것을 의미합니다. 하지만, Actor의 경우 런타임이 우선순위가 낮은 항목보다 우선순위가 높은 항목을 큐 앞으로 이동시켜 먼저 실행되게 할 수 있습니다.
메인 액터
이는 시스템의 메인스레드에 대한 개념을 추상화하여 다른 특성을 지니고 있습니다.
매인 액터는 협력적 스레드 풀의 스레드와 분리되어있기 때문에, 문맥 전환을 필요로 합니다.
때문에 위 코드와 같이 글로벌 스레드에서 기사를 로드한 후, 메인액터에서 이를 update하는 로직을 수행하기 위해 루프의 각 반복은 최소
두번의 컨텍스트 스위치를 필요로 합니다. 때문에, 애플리케이션이 컨텍스트 전환에 많은 시간을 소비한다면 메인 액터의 작업을 일괄 처리하도록 코드를 재구성해야합니다.
협력 풀에서 액터 간 이동은 빠르지만, 앱을 작성할 때 메인 액터와의 이동을 항상 염두에 두어야 합니다.
감사합니다.
'개발지식 정리 > WWDC 정리' 카테고리의 다른 글
SwiftUI | FocusState API 정리 (0) | 2025.04.04 |
---|---|
SwiftUI | Beyond scroll views 정리 (0) | 2025.04.04 |
Protect mutable state with Swift actors 정리 (0) | 2025.04.03 |
Meet async/await in Swift 정리 (0) | 2025.04.03 |
Explore structured concurrency in Swift 정리 (0) | 2025.04.01 |