[WWDC23] 구조화된 동시성의 기초를 넘어 (Beyond the basics of structured concurrency)
구조화된 동시성의 기초를 넘어 (Beyond the basics of structured concurrency)
목차
- Task hierarchy
- Task cacnellation
- Task priority
- Task group patterns
- Task-local values
- Task traces
Task hierarchy
구조화된 동시성을 사용하면 동시적 코드를 추론할 수 있음.
실행이 분기하여 동시에 실행되고, 해당 결과가 다시 재합류하는 그 지점을 잘 정의하여 그 지점을 사용할 수 있음.
`if block`과 `for loop`가 동기식 제어 흐름의 동작을 정의하는 방식과 비슷
동시성을 정의하는 방법은 여러가지가 있으나, 모든 작업이 구조화된 것은 아님.
구조화된 작업은 작업이 선언된 스코프에서 끝까지 살아남음.
마치 로컬 변수처럼 동작하며, 스코프 밖으로 나가면 자동으로 취소되므로 작업의 수명이 명확함.
아래 코드 처러 사용하면, 어떤 코드가 동시적으로 동작할지 유추 가능하지만, Swift에서 권장되는 동시성 이용법은 아님.
아래는 구조화된 동시성으로 이렇게 작성할 경우 부모 작업과 구조화된 관계를 형성.
아래 사진처럼 Task trees를 구성할 수 있음.
- 회색선은 부모 작업에서의 자식 작업의 방향
- 색이 있는 것들은 자식 작업
Task cacnellation
Task의 취소는 앱에 작업 결과가 더 이상 필요하지 않으므로, 작업을 중지하고 부분적인 결과만 반환하거나 오류를 반환하고자 할 때 이용
구조화된 작업은 스코프를 벗어나면 암묵적으로 취소됨.
부모 작업을 취소하면 자식 작업들도 모두 취소됨
하지만, 작업을 취소한다고 자식 작업이 곧바로 중지되지는 않음.
해당 작업에 isCancelled라는 플래그가 생기며, 실제 취소는 코드 안에서 이루어짐.
Task의 취소는 경쟁적이라서 검사 전에 작업을 취소하면 에러를 발생시키지만, gaurd문이 실행된 후 취소할 경우에는 그대로 진행됨.
만약 부분적인 결과 반환(커스텀 에러 반환 등) 대신 취소 오류를 발생시키려고 하면
`try Task.checkCancellation()`을 호출하면 작업이 취소되었을 때 `CancellationError`를 방출
비싼 작업을 시작하기 전에 취소상태를 검사하는 것은 중요함.
그래야 결과가 필요한지 지금도 필요한지 검증할 수 있음.
취소 검사는 동기식이므로 취소에 반응해야 하는 모든 함수는 동기식이든 비동기식이든 진행 전에 작업 취소 상태를 검사해야 함.
isCancelled와 checkCancellation()을 통해 폴링 하는 것은 작업이 실행 중일 때는 유용하지만, 작업이 일시 중단되어 실행되는 코드가 없을 때 취소에 반응해야 하는 상황도 있음.
- AsyncSequence를 구현할 때가 취소에 반응해야 하는 예시
이때는 withTaskCacellatuonHandler가 유용함.
위 경우에는 비동기적인 for 루프가 끝나기 전에 취소 이벤트를 받으면 취소 오류를 발생시킴
하지만 위 경우에는 이미 작업이 완료된 상태라 실행되고 있지 않아서 취소 이벤트를 명시적으로 폴링 할 수 없고, 취소 이벤트를 탐지해서 비동기적 for 루프에서 빠져나와야 함.
AsyncSequce는 AsyncIterator로 동작하는데 AsyncIterator는 비동기적인 next 함수임.
next 함수도 시퀀스의 다음 엘리먼트를 반환하거나 nil을 반환하여 시퀀스의 끝에 도달했음을 나타냄
AsnycSequence는 많은 경우 상태 머신으로 구현되는데, 상태 머신은 시퀀스 실행을 멈추는데 쓰임.
위 코드에서 취소가 발생하면 즉시 실행되어 `state`라는 본문(실행 블럭)사이에서 변경 가능한 상태.
상태 머신은 보호해야 함. actor는 캡슐화된 상태를 보호하는데는 좋지만 개별 프로퍼티를 수정하고 읽고자 하므로 액터는 적합한 도구가 아님. 게다가 actor는 실행되는 연산의 순서를 보장할 수 없기에 취소가 먼저 실행되는지도 확실히 알 수 없음.
개별 프로퍼티의 상태를 보호하기 위해서는 Swift Atomics 패키지에 있는 Atomic을 사용해도 되지만, 디스패치 큐나 lock을 사용해도 됨. 이런 메커니즘을 사용하면 공유 상태를 동기화하고, 경쟁 상태를 피하면서 실행 중인 상태 머신을 취소할 수 있음.
이렇게 작성할 경우 취소 핸들러에 비구조화된 작업을 도입하지 않아도 됨.
Task tree는 취소 정보를 자동으로 전파하며, 취소 토큰과 동기화를 신경 쓸 필요 없이 Swift Runtime이 알아서 안전하게 처리하도록 두면 됨.
취소해도 작업 실행이 멈추지 않는다는 것을 반드시 기억하기!
단지, 작업이 취소되었으니 최대한 빨리 작업을 끝내라는 신호를 작업에 보낼 뿐.
취소를 검사하는건 코드에서 직접해야 함.
Task priority
구조화된 Task tree가 어떻게 우선순위를 전파하고 우선순위 역전을 어떻게 피하는지 알아볼 것.
우선순위 역전이란, 우선순위가 높은 작업이 우선순위가 낮은 작업을 기다릴 때 발생
기본적으로 자식 Task는 부모의 Task의 우선순위를 상속받음.
만약 부모의 우선순위가 높아진다면, 자식의 우선순위도 모두 높아짐.
우선순위가 높아지면 Task의 수명 내내 높아지며, 상승한 우선순위를 되돌릴 방법은 없음.
Task group patterns
Task 그룹을 통해 Task를 효율적으로 관리하는 방법에 대해서 알아볼 것.
즉, 처리할 수 있는 Task에는 한계가 있는데 특정 Task가 너무 많이 실행된다면 자원 낭비이므로 실행할 수 있는 최대 개수를 명시적으로 지정
Swift 5.9에서 withDiscardingTaskGroup이 새로 생겼고, 완료된 자식 Task의 결과를 유지하지 않음.
어떤 작업을 처리하기 위해 사용한 Task를 사용한 작업이 끝나자마자 Task를 자유롭게 사용할 수 있음.
아래 이미지에서 기존 코드를 교체할 수 있음.
withDiscardingTaskGroup을 사용하면 명시적으로 그룹을 취소하고 정리할 필요가 없음.
withDiscardingTaskGroup에는 sibling(형제) Task들을 자동 취소하는 기능도 존재해서 자식 Task 중 하나가 에러를 발생시키면 다른 Task들도 자동으로 취소됨.
DiscardingTaskGroup은 Task하나가 끝날 때마다 즉시 완료를 방출함.
즉, 일반적인 TaskGroup과는 다르며, 아무것도 반환할 필요가 없는 작업이 많을 때 메모리 소비를 줄일 수 있음.
Task-local values
Task-local values란 주어진 작업, 즉 더 정확히 표현하자면Task hierarchy와 연결된 데이터.
Task-local values은 전역 변수와 비슷하지만, Task-local values에 바인딩된 값은 현재 Task hierarchy에서만 나타날 수 있음.
@TaskLocal에서 값이 지정되지 않는 경우에는 기본 값을 반환해야 하는데, 옵셔널 (nil)로 설정하기 좋고, 바인딩되지 않은 TaskLocal은 기본적으로 자기 자신의 기본값을 반환하는데, 옵셔널일 경우 nil을 반환함.
TaskLocal 값은 명시적으로 지정할 수 없고, 특정한 스코프에 바인딩되어야 함.
바인딩은 스코프가 지속되는 동안 유지되며, 스코프가 끝나면 원래 되로 돌아감.
TaskLocal 값은 부모 TaskLocal에서 설정되었음.
자식 Task들은 스스로의 TaskLocal 값을 설정하지는 않지만, TaskLocal의 값을 찾기 위해 부모를 탐색함.
만약 더 이상의 부모가 없게 되면, 그 값을 기본값을 뱉어냄.
Swfit Runtime은 이런 쿼리를 더 빠르게 실행하도록 최적화되었음.
트리를 탐색하는 대신, 우리가 찾는 키가 있는 작업을 직접 참조.
Task tree에서 반복(reculsive)하는 성질은 값을 잃지 않으면서, shadowing 하는데 유용
바인딩 한 값을 기준으로 어느 단계에 있는지 추적이 가능함. 왜냐하면 값을 찾으면 거기서 멈추기 때문.
바인딩된 값은 이전 값을 shadowing 할 것임.
MetadataProvider는 SwiftLog 1.5에 생긴 새 API
MetadataProvider를 구현하면 로깅 로직을 쉽게 추상화해 구현할 수 있음.
관련 값에 대한 정보를 확실히 일관되게 내보낼 수 있음.
로거의 메타데이터 값을 저장해서 사용.
로거는 multiplex 함수를 통해 여러 MetadataProvider를 결합해 하나의 객체로 만듦
여러 메타데이터를 결합해서 하나의 로그로 사용할 수 있음.
로그를 추적하는 것이 더 손쉬워짐.
Task-local values를 통해 Task hierarchy에 정보를 추가할 수 있음.
Task 내에서는 모두 값을 상속받음. Task는 주어진 스코프 안에서 특정한 Task tree에 바인딩되어 저수준의 빌딩 블록을 제공하며, 추가 컨텍스트 정보를 전파할 수 있음.
Task traces
Task hierarchy와 분산되어 Task 동작하는 시스템을 프로파일링 할 예정.
애플 시스템에서 동시성을 다룰 때 Instruments는 구조화된 작업 간의 관계를 잘 파악하게 도와줌.
Swift에서 분산 추적 패키지를 활용해 여러 시스템에서 Task tree의 이점을 활용해 성능 특성과 작업 관계를 파악할 수 있음.
분산 추적 시스템은 로컬에서 프로세스를 추적하는 것과는 다름.
함수 하나당 추적을 하나씩 하는 대신, withSpan API를 사용해 Span으로 코드를 계측함.
withSpan은 추가 추적 ID와 기타 메타데이터로 Task에 주석을 달아서 추적 시스템이 Task tree를 단일로 병합할 수 있게 함.
Task의 런타임 성능도 포함됨.
withSpan에는 #function을 사용하며, attributed에 정보를 추가함.
대체로 추적 시스템은 특정 span을 검사하는 동안 속성을 표시함.
작업이 실패해서 오류를 발생시킬 경우 span에도 표시되고 추적 시스템에도 보고됨.
span에는 Task tree에 정보가 들어있어서 타이밍 경쟁이 초래한 오류를 파악하고 오류가 다른 작업에 어떤 영향을 미치는지 파악함.
(참고)
https://www.youtube.com/watch?v=Ce0GsaRCMew