Swift프로그래밍Swift 개발자

Swift의 런타임이 TaskGroup 내에서 부모-자식 작업 관계를 유지하기 위해 사용하는 기본 기록 유지 전략은 무엇이며, 이것이 원자적 취소 전파를 어떻게 촉진합니까?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

Swift의 동시성 모델은 Swift 5.5에서 구조화된 동시성을 도입하여 구식 Grand Central Dispatch 패턴을 대체하며, 이로 인해 고아 작업 및 자원 누수가 빈발하게 발생했습니다. 이전에는 개발자들이 수동으로 DispatchGroup 인스턴스를 관리하여 동시 작업을 추적하고, 취소 중 경쟁 조건을 방지하기 위해 명시적인 동기화를 요구했습니다. TaskGroup 추상화는 부모-자식 관계 트리를 기본적으로 캡슐화하도록 설계되어 런타임이 라이프사이클 메타데이터를 유지하도록 보장합니다.

핵심 문제는 부모 작업이 전역 레지스트리나 수동 약한 참조 배열을 통해 탐색하지 않고도 모든 자손에게 신뢰성 있게 취소를 신호할 수 있는 결정론적 계층을 유지하는 것입니다. 전통적인 방법인 OperationQueue는 완료 핸들러의 명시적인 등록 및 등록 해제를 요구하며, 이로 인해 조기 종료로 인해 완료 핸들러가 건너뛰어질 경우 상태 관리가 취약해집니다. 또한, 취소를 전파하기 위해서는 복잡한 원자 플래그 폴링이 필요하며, 이로 인해 응답성이 지연되거나 CPU 사용량이 과도하게 증가하게 됩니다.

Swift는 각 작업의 컨텍스트 내에 부모를 가리키는 작업 기록을 포함시켜 TaskGroup에 뿌리를 두는 침투형 링크드 리스트를 형성함으로써 이 문제를 해결합니다. addTask가 호출될 때 런타임은 이 리스트에 자식 작업 레코드를 삽입하고 부모의 취소 핸들러와 원자적으로 등록합니다. 취소 메커니즘은 상태 기계를 활용합니다: cancelAll()이 호출되면 런타임은 이 리스트를 탐색하여 각 자식 작업의 메타데이터에서 isCancelled 플래그를 설정하고 일시 중지된 실행기를 깨어나게 합니다. 이는 O(n) 전파를 보장하며, 여기서 n은 트리의 깊이입니다. 전역 잠금을 피할 수 있습니다.

import Foundation func downloadImages(urls: [URL]) async throws -> [Data] { try await withThrowingTaskGroup(of: Data.self) { group in for url in urls { group.addTask { // 자식 작업은 부모의 취소를 자동으로 확인합니다. let (data, _) = try await URLSession.shared.data(from: url) return data } } // 사용자 취소 시뮬레이션 group.cancelAll() var results: [Data] = [] for try await data in group { results.append(data) } return results } }

실제 상황

미디어 처리 애플리케이션이 10,000개의 이미지에 대한 썸네일을 생성하면서 사용자가 중간에 취소할 수 있어야 했습니다. 엔지니어링 팀은 초기에는 DispatchGroup 접근 방식을 사용하여 활동 중인 URLSessionDataTask 객체를 스레드 안전한 NSHashTable에 추적하여 취소를 가능하게 했습니다.

첫 번째 솔루션은 동시성을 제한하기 위해 DispatchSemaphore와 함께 DispatchGroup을 사용했습니다. 기능적으로는 작동했지만 완료된 작업을 취소 집합에서 제거하기 위한 복잡한 논리가 필요했습니다. 취소 신호와 집합 열거 사이에 완료된 작업이 발생하여 앱이 해제된 객체를 참조할 수 있는 경쟁 조건이 발생했습니다. 이 접근 방식은 DispatchGroup 알림이 위임자를 강하게 유지했기 때문에 뷰 컨트롤러가 해제될 때 메모리 누수도 발생했습니다.

두 번째 접근 방식은 취소를 위해 CombineFlatMapPassthroughSubject를 사용했습니다. 이는 더 나은 조합 가능성을 제공했지만 퍼블리셔 체인 할당으로 인해 상당한 메모리 오버헤드를 야기했습니다. 취소 전파를 위해 AnyCancellable 토큰을 수동 정리가 필요한 컬렉션에 저장해야 했습니다. 선언적 추상화는 실제 작업 계층을 숨겨 취소 신호가 연산자 체인을 통해 전파되지 않을 때 디버깅을 어렵게 만들었습니다.

팀은 SwiftTaskGroup으로 마이그레이션했습니다. 이로 인해 런타임이 각 썸네일 생성 작업을 그룹의 취소 도메인에 자동으로 연결했기 때문에 수동 NSHashTable 관리를 없애버렸습니다. 사용자가 취소를 탭할 때, 뷰 컨트롤러는 group.cancelAll()을 호출하여 모든 실행 중인 작업에게 다음 await 일시 중지 지점에서 중지하도록 원자적으로 신호를 보냈습니다. 이 솔루션은 뷰 할당이 해제된 후 고아 작업이 계속 처리되지 않도록 보장하며, withThrowingTaskGroup의 결정론적 범위는 함수가 오류를 발생시켜도 자동 정리를 보장합니다.

취소 지연 시간은 수동 집합 열거를 기다리는 평균 500ms에서 직접 링크드 리스트 탐색으로 10ms 미만으로 줄어들었습니다. 메모리 프로파일링 결과 취소 후 Task 객체가 전혀 누수되지 않았으며, 코드베이스는 동기화 보일러플레이트 40줄이 감소했습니다.

후보들이 종종 놓치는 점

TaskGroup은 자식 작업이 취소를 무시하고 무한히 실행될 경우 어떻게 처리합니까?

후보들은 종종 TaskGroup이 작업을 강제로 종료시키거나 예외를 주입한다고 생각합니다. 실제로 Swift의 취소는 협력적입니다: 런타임은 작업의 컨텍스트 내에서 isCancelled 플래그를 설정하지만, 작업은 일시 중지 지점을 만날 때까지 계속 실행됩니다. 자식 작업은 정기적으로 Task.checkCancellation()를 확인하거나 취소 인식 API에 의존해야 합니다. 작업이 일시 중지 포인트 없이 CPU 집약적 루프를 수행하면 그룹의 완료가 무한정 차단됩니다. 이를 방지하기 위해 장기 실행 계산은 Task.yield()를 사용하거나 작업을 덩어리로 나누어 취소 플래그를 확인해야 합니다.

취소가 발생한 후 TaskGroup에 작업을 추가하면 그 새 작업이 즉시 취소되는 이유는 무엇입니까?

많은 사람들이 cancelAll()이 기존 자식에게만 보내는 일회성 신호라고 추론합니다. 그러나 Swift의 구현은 TaskGroup 자체를 상태 기록에서 취소된 것으로 표시합니다. 이후 addTask가 호출되면 런타임은 작업 생성 중에 그룹의 취소 상태를 원자적으로 확인합니다. 만약 취소된 상태라면, 새 자식 작업이 isCancelled 플래그를 미리 설정하여 생성됩니다. 이는 취소 도메인을 벗어날 수 없도록 보장하며, 취소 범위가 새로운 유효 결과를 생성할 수 없도록 하는 구조적 보장을 유지합니다. 이는 취소가 진행되는 동안 추가된 작업이 빠져나가는 경쟁 조건을 방지합니다.

TaskGroup의 구조화된 동시성과 Task.init을 통해 생성된 Task 간의 근본적인 차이는 무엇입니까?

후보들은 자주 TaskGroup의 자식 작업이 부모 컨텍스트의 액터 격리 및 우선순위를 상속받지만, 더 중요한 것은 캡처된 변수의 수명은 그룹 범위가 종료될 때까지 확장된다는 점을 간과합니다. 반면에 비구조화된 Task 객체는 Task { ... }를 사용하여 생성되며, 생성 범위의 수명을 넘어 지속되어 self를 무기한 캡처할 수 있습니다. 이로 인해 TaskGroup에서 addTaskself를 캡처하면 [weak self]가 필요하지 않습니다. 왜냐하면 작업이 withThrowingTaskGroup 블록보다 먼저 생존할 수 없기 때문입니다. 그러나 개발자들은 종종 비구조화된 작업에서 [weak self] 패턴을 잘못 적용하여 코드를 불필요하게 복잡하게 만든 후, 완료에 대한 self의 존재에 의존하여 nil-참조 버그를 일으킬 수 있습니다.