https://developer.apple.com/wwdc23/10170
목차
Task hierarchy
Task cancellation
Task priority
Task group patterns
Task-local values
Task traces
Task hierarchy
- Structured Concurrency를 사용하면 동시적 코드를 추론하는데 도움이된다.
- 동기적 코드에서의 if, for문과 유사한 방식으로 작성할 수 있기 때문
- async let이나 TaskGroup 또는 Task, Task.detached를 사용하여 동시성을 트리거할 수 있다.
- await를 사용하여 suspension point를 나타내고 현재 실행 지점과의 rejoin 시점을 의미한다.
- Structured Concurrency는 async let과 taskGroup으로 만들어진다.
- UnStructured Concurrency는 Task와 Task.detached로 만들어진다.
- 구조화된 작업(Structured)은 작업이 선언된 Scope에서 끝까지 살아남는다. (로컬 변수처럼)
- 그리고 스코프 밖으로 나가면 자동으로 cancel되기 때문에 작업의 수명을 분명하게 알 수 있다.
➡️ 따라서 가능하면 Structured Concurrency를 사용해야 한다.
예제를 살펴보자!
여러 요리사들이 병렬적으로 수프를 만드는 상황이다.
하나의 수프를 만들기 위해서는 여러 과정을 거쳐야 하며 어떤 작업은 동시에 진행될 수 있지만 특정 작업들은 순서에 따라 진행해야 한다.
수프를 만드는 함숭니 makeSoup는 다음과 같이 작성될 수 있다.
Unstructured Concurrency를 사용해 구현한 makeSoup 함수이다.
- 물을 끓이고 재료 손질을 하고 고기를 준비하는 과정을 Task블록을 사용해 동시적으로 진행되도록 했다.
- 물론 동작은 하겠지만 Swift에서 권장하는 Concurrency 이용법이 아니다.
이번에는 Structured Concurrency를 사용해 구현한 makeSoup 함수이다.
- Child Task의 개수가 정해져 있기 때문에 async let을 사용해 동시성을 부여한다.
- 이렇게 하면 각 작업들은 부모 Task와 구조화된 관계를 형성한다.
chopIngredients(_:)는 다음과 같다.
- 재료 리스트를 가져와서 각각의 재료를 동시적으로 손질하는 함수이다.
Structured Concurrency로 구현하면 이처럼 Task Tree를 생성할 수 있다.
Task cancellation
Task Tree는 Task cancellation에 도움을 준다.
Task Cancellation은 앱에 작업 결과가 더이상 필요하지 않을 때 작업을 중지하고 부분적인 결과만을 반환하거나 오류를 발생시켜야 한다는 신호를 보낼 때 쓰인다.
(수프 예제에서는 손님이 떠나거나 주문을 바꾸는 상황에서 Cancel이 필요하다!)
- Structured Concurrency에서는 스코프를 ㅂ벗어나면 암묵적으로 Cancel 된다.
- Task Group에 명시적으로 cancellAll()을 호출하여 활성화된 모든 자식 Task와 앞으로 생길 자식 Task들을 취소할 수도 있다.
- Unstructured Concurrency 에서는 위 그림처럼 직접 cancel() 함수를 호출하여 명시적으로 취소시켜야 한다.
- 부모 작업을 취소하면 자식들도 전부 취소된다.
- Cancellation은 Cooperative하기 때문에 child task가 즉시 중지되지는 않지만 해당 작업에 isCancelled 플래그가 true로 바뀌게 된다.
- 실제로 취소는 코드 안에서 이뤄진다.
- Cancel은 Race여서 검사 전에 작업을 취소하면 위처럼 SoupCancellationError()를 발생시킬 수 있는데
- guard문 이후에 cancel()이 발생하면 makeSoup는 끝까지 실행된다.
- 부분적인 결과를 반환하는 대신 CancellationError를 발생시키고 싶다면 Task.checkCancellation()을 호출하면 된다.
- 이렇게 하면 작업이 취소되었다면 CancellationError를 발생시킨니다.
cost가 높은 작업을 하기 전에 반드시 작업 취소 상태를 검사해야 한다.
취소 검사는 동시적이기 때문에 취소에 반응해야 하는 모든 함수는 동기이든 비동기이든 진행 전에 작업 취소 상태를 검사해야한다.
- isCancelled나 Task.checkCancellation으로 polling하는 것은 작업이 실행 중일 때는 유용하지만 때로는 작업이 일시 중단되어 실행되는 코드가 없을 때 취소에 반응해야 하는 상황이 있다.
- 대표적으로 AsyncSequence를 구현할 때이다.
- withTaskCancellationHandler가 이런 상황에서 유용하다.
예제 코드로 돌아가서 보면
- 요리사는 주문이 들어오는 대로 수프를 만든다.
- 취소가 발생하기 전에 async for문에서 새 주문을 받았다면 makeSoupo함수는 취소를 처리하여 에러를 throws 한다.
- 만약 for문이 주문을 기다리며 작업이 일시 중단됐을 때 취소가 발생한다면 작업이 실행되고 있지 않아서 취소 이벤트를 명시적으로 폴링할 수 없다.
- 그 대신 cancellation handler를 사용해서 취소 이벤트를 탐지하고 async for문에서 빠져나와야 한다.
주문은 AsyncSequence에서 생성되는데 (여기서는 orders 인자) AsynceSequence는 AsyncIterator로 구동된다.
AsyncIterator는 비동기적인 next함수이다.
동기적 반복문과 마찬가지로 next 함수도 Seqeunce의 다음 Element를 반환하거나 nil을 반환하여 시퀀스 끝에 도달했음을 나타낸다.
AsnceSequence는 많은경우 state machine으로 구현된다.
state machine은 시퀀스 실행을 멈추는데 쓰인다.
위 코드에서는 isrunning이 true일 때 시퀀스는 계속 주문을 발생시킨다.
만약 작업이 취소되면 시퀀스가 끝났으니 정지해야 한다고 알려야 한다.
state machine은 동기적으로 cancel() 함수를 호출하면 된다.
cancel handler는 즉시 실행되므로 state machine은 shared mutable state이다. (동시적으로 동작하는 cancel handler와 main body 사이에서!)
따라서 우리는 state mahchine을 보호해야 한다. (synchronization 기법 필요)
Actor는 캡슐화된 상태를 보호하는 데 좋지만 우리는 state machine에서 개별 프로퍼티를 수정하고 읽어야 하므로 Actor는 그다지 적합하지 않다.
그리고 Actor에 실행되는 연산의 순서를 보장할 수 없으므로 취소가 먼저 실행될지도 확실히 알 수 없다.
이 예제에서는 Swift Atomics 패키지에 있는 Atomic을 사용했다.
물론 DispatchQueue나 Lock을 사용해도 된다.
이런 동기화 매커니즘을 사용하면 공유 상태를 동기화하고 경쟁 상태를 피하면서도 실행 중인 state machine을 취소할 수 있다.
(cancellation handler에 unstructured task를 도입하지 않아도)
Task Cancellation 정리
- Task tree는 작업 취소 정보를 자동으로 전파한다.
- 우리는 취소 토큰과 동기화를 신경쓸 필요 없이 Swift 런타임이 알아서 안전하게 처리하도록 두면 된다.
- 취소를 해도 작업이 멈추는 것은 아니며 대신 flag 값을 바꾸기 때문에 코드 레벨에서 취소 이벤트를 핸들링하면 된다.
Task priority
구조화된 task tree가 어떻게 우선순위를 전파하고 priority inversion을 파히는지 살펴보자!
Soup를 만드는 일의 priority가 medium이라고 가정하자!
식당의 vip 고객이 수프를 원한다고 하면 이 작업의 priority는 high일 것이다.
- VIP가 수프를 기다리는 동안 모든 child task의 우선 순위도 높아진다.
- 그래야 우선순위가 높은 작업이 낮은 작업을 기다리는 우선순위 역전이 발생하지 않기 때문이다.
- 주의할 점은 task group의 다음 결과를 awaiting하면 그룹 내 모든 child task들의 우선 순위가 상승한다는 것이다.
- 어떤 작업이 다음으로 완료될 가능성이 높은지 모르기 때문이다.
- Concurrency Runtime은 우선순위 큐(priority queue)로 작업 스케줄을 잡기 때문에 우선순위가 높은 작업은 우선순위가 낮은 작업보다 먼저 선택되어 실행된다.
- 작업의 우선순위가 높아지면 해당 작업이 완료될 때까지 다시 우선순위가 낮아지는 일은 없다.
Task group patterns
Task group으로 동시성을 관리하는 유용한 패턴 몇가지를 살펴보자!!
수프 장사가 성공하여 주문이 폭증하면 수행해야 하는 chop도 증가하게 된다.
만약 리소스를 아끼기 위해 chop하는 재료의 개수를 제한하고 싶을 수 있다.
이렇게 동시에 chop하는 재료의 개수를 제한시킬 수 있다.
이렇게 chopped 재료를 핸들링하는 코드도 수정하여 chop이 끝날 때마다 새로 chop을 호출하도록 한다.
즉, 최대 3개까지의 chop 작업이 동시에 발생하도록 제한한 것이다.
이 아이디어를 패턴화하면 다음과 같다.
처음의 for문은 동시 작업이 너무 많이 발생하지 않도록 최대 개수 만큼만 addTask한다.
그 다음 먼저 시작한 작업이 끝나고 중지 조건을 충족하지 않는다면 새 작업을 생성하여 addTask한다.
요리사들은 교대 근무를 하고 Cancellation을 이용해 근무 시간이 끝나는 것을 알린다고 가정하자!
주방 서비스 코드는 위와 같이 나타낼 수 있다.
- 요리사들은 각자의 작업을 하며 교대 근무를 시작한다. (for문 부분)
- 요리사들이 일할 때 우리는 타이머를 맞춘다. (Task.sleep 부분)
- 타이머가 다 돌아가면 진행 중인 근무를 모두 취소한다. (cancelAll 부분)
보다시피 어떤 작업도 값을 리턴하지 않고 있다.
Swift 5.9부터는 이러한 상황에서 사용 가능한 withDiscardingTaskGroup API가 추가되었다.
- DiscardingTaskGroup은 완료된 자식 작업의 결과를 유지하지 않는다.
- 작업에서 사용한 리소스는 작업이 끝나자마자 자유롭게 쓸 수 있다.
- child task를 자동으로 정리하기 때문에 명시적으로 그룹을 cancel하고 정리할 필요가 없다.
- 자동 sibling cancellation 기능도 있어서 자식 작업 중 하나가 오류를 발생시키면 나머지 작업도 자동으로 취소된다.
주방 서비스 코드에 DiscardingTaskGroup을 적용하면 위와 같아진다.
- 근무 시간이 끝날 때 TimeToCloseError를 발생시키면 모든 요리사의 근무 시간이 자동으로 끝난다.
TaskGroups Patterns 정리
- DiscardingTaskGroup은 작업 하나가 끝날 때마다 리소스를 방출한다.
- 결과를 수집하는 일반 taskGroup과는 다르다.
- 따라서 아무것도 반환할 필요가 없는 작업이 많을 대 메모리 소비를 줄일 수 있다.
- 동시적 작업의 개수를 제한하고 싶다면 앞서 다룬 패턴을 사용하면 한 작업이 끝나야 다른 작업을 시작하도록 할 수 있다.
Task-local value
수프의 생산을 더 증가시키고 싶다면 수프 생산을 서버로 이전할 때이다.
그러면 주문을 처리하면서 추적해야 하는 어려움이 생기는데 그럴 때 task-local value가 도움이 된다.
Task-local value란 주어진 작업, 즉, Task 계층 구조와 연결된 데이터이다.
전역 변수와 비슷하지만 task local 값에 바인딩된 값은 현재의 작업 계층 구조에서만 나올 수 있다.
- Task-local value는 Static property로 선언된다.
- @TaskLocal 프로퍼티 래퍼를 사용하면 된다.
- 기본값을 nil로 두기 위해 옵셔널로 선언하는 것이 좋다.
- task local value는 명시적으로 지정될 수 없고 특정한 스코프에 바인딩되어야 한다.
- 아래의 마지막 logStatus에서 kitchen.cook이 nil이 된 모습을 확인할 수 있다.
- 즉, 스코프가 끝나면 원래 값으로 되돌아간다.
다시 Task tree로 돌아가보면
- 각각의 Task에는 Task-local value에 연결된 자리들이 있다. (회색 공간들)
- 수프를 만들기 전에 Sakura라는 이름을 cook의 task-local 변수에 바인딩했다.
- 이 값을 저장하는건 makeSoup 뿐이며 자식들은 로컬 스토리지에 어떤 값도 저장하지 않는다.
- task-local value에 바인딩된 값을 찾으려면 그 값을 가진 task를 찾을 때까지 부모를 반복해서 탐색해야 한다. (회색 화살표들)
- 부모 task가 없는 root에 도달하면 task local은 바인딩되지 않았으며 원래의 기본 값을 찾게 된다. (nil)
Swift 런타임은 이런 쿼리를 더 빨리 실행하도록 최적화를 했다.
트리를 탐색하는 대신 우리가 찾는 키가 있는 Task를 직접 참조하도록 하여 즉시 task-local value에 바인딩된 값을 찾을 수 있도록 했다.
이제 수프를 만드는 기능은 그대로이지만 서버에서 이것을 처리하는 것으로 바뀌었다.
저문이 시스템을 통과할 때 주문을 관찰할 수 있어야 주문이 제대로 완료되는 걸 확인하고 예상치 못한 실패가 발생할지 모니터링할 수 있다.
수동으로 로깅하는 방법도 있지만
이렇게 id를 넣어야 하는데 전체 order가 들어가버린 것처럼 버그와 오타가 생기기 쉽다.
- 이럴때 사용 가능한 것이 Swift log이다.
- 서버를 변경하지 않고도 필요에 맞는 로깅 백엔드를 구축할 수 있다.
- MetadataProvider는 새로 생긴 API이며 로깅 로직을 쉽게 추상화하여 관련 값에 대한 정보를 일관되게 내보낸다.
- MetadataProvider는 딕셔너리와 비슷한 구조를 사용한다.
이런 식으로 로깅 코드를 넣으면 된다!
사용 예시
Task-local values 정리
- Task-local value를 사용하면 Task hierarchy 구조에 정보를 첨부할 수 있다.
- Detached Task를 제외한 모든 Task는 현재 작업에서 Task-local value를 상속받는다.
- Task들은 주어진 스코프 아넹서 특정한 task tree에 바인딩되어 low-level 빌등 블록을 제공하고 이를 사용하여 task hierarchy를 통해 추가 콘텍스트 정보를 전파할 수 있다.
Task traces
이제 작업 계층 구조와 이것이 제공하는 도구로 동시적 분산 시스템을 추적하고 프로파일링해보자!
역시 Intruments를 사용하여 Structured Task간의 관계를 파악할 수 있다.
HTTP 트래픽 분석 또한 Intruments로 가능하다.
새로 나온 Swift Disttributed Tracing을 사용하는 방법도 있다.
( 이후로는 서버 쪽 구현 이야기라 생략하겠습니다:) )
정리
- Structured Task는 동시적 시스템에서 연산을 자동으로 취소할 수 있는 도구, 우선순위 정보를 자동을으로 전파하는 도구, 복잡한 분산 워크로드를 쉽게 추적하는 도구를 제공한다.
- 이 모든 것은 Swift의 동시성이 구조화되어 있기 때문에 가능하다.
- 따라서 Unstructured Concurrency 대신 Structured Concurrency를 최대한 활용하는 것이 좋다.
'iOS > Swift' 카테고리의 다른 글
Eliminate data races using Swift Concurrency (2) | 2023.11.23 |
---|---|
Swift concurrency: Behind the scenes (0) | 2023.09.13 |
Protect mutable state with Swift actors (0) | 2023.08.31 |
Swift의 분산된 Actor 소개 (0) | 2023.08.22 |
Adopting Swift Packages in Xcode (0) | 2023.06.14 |
댓글