[승용] Explore structured concurrency in Swift - BoostSwiftUI/SwiftUI GitHub Wiki
개요
-
처음 프로그래밍이 나왔을 떈 코드를 읽기 어려웠음
- 명령어의 시퀀스로 이루어져 있어 제어 흐름이 여기저기로 이동함
-
요즘은 그런걸 보기 힘듬
- 구조화된 프로그래밍을 사용해 제어 흐름을 더욱 단일화시킴
- ex) if-then
-
구조화된 프로그래밍은 자연스럽게 nested 및 sequenced 될 수 있다.
- 이는 프로그램을 위에서 아래로 자연스럽게 읽을 수 있도록 도와줌
-
이것들이 구조화된 프로그래밍의 중요한 요소들!
-
그러나 요즘 프로그램들은 비동기적이고 동시적인 코드들을 제공하는데 이들은 구조화된 프로그래밍을 사용해 더 쉽게 코드를 작성할 수 없었다.
-
unstructured 비동기적 코드 예시
- 에러 핸들링 사용 불가
- 반복문 사용 불가
-
async-await을 사용해 개선
- 에러 핸들링 사용 가능
- 반복문 사용 가능
- 반환하는 값이 있음
Task
- asyn-await으로 비동기적 작업은 개선했는데, 여러 개의 작업을 동시적으로 작업하고 싶다면?
- Task 사용
- 각 task들은 다른 실행 흐름과 동시적이게 실행되고, 안전하고 효율적일 때 자동적으로 실행되도록 시스템에 의해 스케줄된다.
- Swift에 깊이 통합되어 있기 때문에 컴파일러가 동시성 문제를 감지할 수 있음
- async 함수를 호출한다고 task가 생성되는 것이 아님.
- Task 호출을 통해 명시적으로 task를 생성함
Concurrent binding
- async let
-
await 비동기적 작업을 concurrent하게 실행하고 싶을 때 사용 가능
-
binding을 만나면 swift는 child task를 생성
-
child task는 데이터 다운로드 즉시 시작
-
parent task는 result 변수에 임시 값(placeholder)으로 즉시 바인딩
-
이 parent task는 이전에 진행되던 작업과 동일한 흐름이기 때문에 그 아래 구문들을 이어서 실행함
-
그러나 임시 값을 대체할 실제 값이 필요한 구문에서는 child task의 완료를 기다림
-
- 적용 전
- 적용 후
- try awiat은 실제 변수를 사용할 때에만 적용하면 됨
Task Tree
- Swift에서 생성되는 모든 child task는 Task Tree라는 계층구조의 일부이다.
- 이는 단순한 내부 구현 사항이 아닌, structured concurrency의 중요한 개념이다.
- task tree는 task의 속성(cancellation, 우선순위, task-local 변수 등)에 영향을 미친다.
- async 함수에서 다른async 함수를 호출할 때에는 동일한 Task가 호출을 실행하기 위해 사용된다.
- 따라서 fetchOneThumbnail 함수는 해당 Task의 모든 attribute들을 상속받는다.
- async-let 등을 사용해 새로운 structured task를 생성하면, 현재 함수가 실행되고 있는 task의 child task가 된다.
- Task는 특정 함수의 child는 아니지만, lifetime이 함수의 범위에 묶여 있을 수는 있다.
- Task Tree는 parent task와 각 child task와의 연결로 이루어져 있다.
- 이 연결은 다음과 같은 규칙을 강제한다:
- 부모 태스크는 자식 태스크가 모두 완료되기 전에는 작업을 끝낼 수 없다.
- 이 규칙은 child task가 await되지 않는 비정상적인 흐름에서도 유지된다.
- 위 코드에서 metadata를 먼저 await하고, data를 나중에 await한다고 가정했을 때 metadata task가 오류를 던지면서 완료되면 fetchOneThumbnail 함수도 즉시 오류를 던지고 종료해야 한다.
- 그렇다면 data task는 어떻게 될까?
- 비정상적인 종료가 발생하면 Swift는 자동으로 await되지 않은 task를 canceled로 표시하고, 종료 전에 해당 task가 완료되기를 기다린다.
- task의 cancel이 task의 중지를 의미하지는 않는다.
- task에게 그 결과가 더이상 필요하지 않음을 알리는 신호만을 보낸다.
- cacel되는 task의 자손 task들도 자동적으로 cancel된다.
- task의 cancel이 task의 중지를 의미하지는 않는다.
- 따라서 fetchOnceThumbnail이 생성한 모든 structured task들이 완료된 후에 에러를 던지며 종료될 것이다.
- 이는 structured concurrency의 핵심 개념으로, task 누수를 방지하고 task의 수명을 자동으로 관리한다.
- 이는 ARC가 자동으로 메모리를 관리하는 방식과 유사하다.
그럼 태스크는 언제 완전히 멈추는가?
- Swift의 task cancellation은 협력적(cooperative)인 방식으로 이루어진다.
- 코드가 명시적으로 취소 상태를 확인하고 상황에 맞게 실행을 정리해야 한다.
- cancellation을 염두에 두고 코드를 작성해야 한다.
- task는 취소되는 순간에 즉시 멈추지 않는다.
- task의 취소는 어디서든 확인 가능하다.
- 따라서 반복적으로 이미지를 다운로드하는 코드에서 cancellation이 발생한다면 그 이미지를 다운로드하지 않고 건너뛰는 코드를 명시적으로 추가해줄 수 있다.
- 여기까지 Async-let task에 대해 알아보았다.
Group task
- async-let보다 더 유연하다.
- async-let은 고정된 크기의 concurrency가 있을 때 잘 작동하고, 변수처럼 범위 내에서만 동작한다.
- 위 예시의 fetchOneThumbnail은 하나의 id당 두 개의 child task를 생성한다.
- 이러한 task들은 다음 반복이 시작되기 전에 완료되어야 한다.
- 그러나 동적 동시성, 즉 ID 배열 크기에 따라 동시성 수준을 조정하고 싶다면 Task Group을 사용하는 것이 좋음
- task group은 동적인 갯수의 concurrency를 제공하기 위해 설계된 structured concurrency의 한 형태
- withThrowingTaskGroup 함수를 사용해 작업 그룹 생성 가능
- 이 작업 그룹이 정의된 범위를 벗어나면 모든 작업이 자동으로 완료될 때 가지 기다린다.
- group task들의 child task들이 생성되고... 그것들이 모두 끝날 때 까지 기다린 후 thumbnails를 return할 수 있음
- 다만 위 코드는 경쟁 조건 문제가 있음
- thumbnails에 동시적으로 여러 스레드들이 접근해 작업 가능
- 아래와 같이 개선할 수 있다:
-
타입이 AsyncSequence를 준수한다면 for-await을 사용해 반복 가능
-
Task group은 structured concurrency의 한 형태이지만 async-let과는 약간 다르게 동작한다.
-
만약 task group 안에서 실행 중인 task 중 하나가 오류를 발생시킨다면 이 오류는 그룹 블럭 밖으로 throw되고 그룹 내의 다른 모든 task는 암시적으로(implicitly) cancel된 후 완료될 때 까지 기다리게 된다.
- 이 부분은 async-let과 똑같이 동작
-
그러나 task group이 정상적으로 블록을 빠져나갈 때는 cancellation이 implicit하게 일어나지 않는다.
- 이는 fork-join(분기 후 결합) 패턴을 표현하기 쉽게 만들어준다.
- 작업들은 await될 뿐 cancel되지 않는다.
-
group의 cancelAll 메소드를 사용해 수동으로 모든 작업을 취소할 수 있음
Unstructured tasks
- 모든 작업들이 structured pattern에 들어맞지는 않음
- non-async 코드에서 task가 시작될 경우
- task를 시작할 때 부모 task가 없는 경우
- task의 수명이 단일 범위 또는 단일 함수의 제한이 맞지 않는 경우
- 객체를 활성화하는 메소드에 task를 시작하고, 객체를 비활성화하는 다른 메소드 호출에 task를 종료하고 싶을 수도 있음
- 예시
- collection뷰의 델리게이트에서 fetchThumbnail 함수를 사용하고 싶음
- 그런데 UI와 관련된 코드이기 때문에 @MainActor 적용해 메인 스레드에서 실행되는 코드
- 여기서 await을 그냥 사용할 수는 없음 - 오류 발생
- Swift는 이러한 상황을 위해 unstructured task 생성을 지원함
- 런타임에 Task를 만나면 Swift는 task를 원래 범위와 같은 actor에서 실행되도록 스케줄링한다.
- 한편 제어권은 즉시 호출자에게 반환된다.
- fetchThumbnails 태스크는 메인 스레드에서 실행되지만, 델리게이트에서 메인 스레드를 블록하지 않고 실행할 기회가 있을 때 실행된다.
- unstructured task의 특징
- 시작 context의 actor를 상속받음
- 원본 task의 우선순위와 기타 특성을 상속받음
- 범위가 지정되지 않음(unscoped)
- task가 시작된 범위(scope)에 수명이 제한받지 않음
- non-async 함수 등 어느 곳에서나 생성 가능
- cancellation 및 에러 전파와 await을 수동으로 관리해야 함
- 아래는 스크롤 범위 바깥으로 벗어난 thumbnail fetch task들을 명시적으로 cancel하는 예시
Detached tasks
-
상위 task로부터 아무런 속성도 상속받고 싶지 않을 때
-
context로부터 완전히 독립적임
-
unstructured task의 일종
-
시작된 범위에 수명이 묶이지 않음
-
시작된 범위의 attribute(actor, 우선순위 등)을 상속받지 않음
-
우선순위 등에 대해 기본값을 가지고 시작하지만 커스텀 매개변수를 넣어줄 수도 있음
-
캐싱하는 detached Task 예시
- priority 설정 가능
- thumbnail 실패해도 얘는 cancel 안 됨 -> 독립적인 실행
- 따라서 fetch 실패해도 일단 캐싱은 해놓고 싶은 목적으로 사용할 때 적절함
-
detached task와 지금까지 살펴본 요소들 조합 가능
-
detached task 안에 structured task 적용 가능
- 그러면 자동적으로 cancellation을 전파할 수 있고, priority를 하위 task들에게 전파할 수 있어 관리가 쉽다는 장점이 있음