안녕하세요.
이번엔 structured concurrency에 대해 공부해 볼게요.
# Structured Programming
Structured Concurrency에 대해 알아보기에 앞서 Structured Programming이란 무엇일까요?
Structured Programming이란 위에서 아래로 동작이 흘러가는 프로그래밍 방식을 의미합니다.
기존 비동기 코드는 Structured programming 하곤 거리가 멀었어요.
비동기 동작 결과가 completion handler로 전달되기 때문에 언제 데이터가 올지 모르며 error throw를 할 수 없고 동일한 비동기 동작을 수행하기 위한 반복문을 사용할 수 없었어요.
# async/await을 사용한 비동기 코드
async/await을 사용하면 Structured programming 방식으로 아름답게 구현할 수 있어요.
근데, 위 코드는 단점이 하나 있습니다.
만약 다운받아야 할 이미지가 엄청 많다면, 비동기로 동작은 하지만 순차적으로 하나씩 이미지를 다운받습니다.
이건... 우리가 원하는 그림이 아니죠..ㅠㅠ
이럴 땐 Task를 사용합니다.
# Task
Task는 비동기 코드를 실행할 새로운 실행 context(?)를 제공하며, 각 Task는 다른 실행 context와 동시에 실행됩니다.
한 가지 주의할 점은 비동기 함수를 호출한다고 해서 새로운 Task가 생성되는 것이 아니라, Task는 명시적으로 생성해야 한다는 점입니다.
## Async-let tasks
Task 중 하나인 async-let task에 대해서 알아볼게요.
평범한 let binding의 경우 비동기 처리가 다 끝나고 값을 할당한 후에 다음 코드를 실행하기 때문에 비동기 처리가 오래 걸리는 작업의 경우 성능이 떨어질 수 있습니다.
async-let을 사용한 concurrent binding 방식의 경우, 비동기 작업을 수행함과 동시에 비동기 작업 결과가 실제로 필요하기 전까지 다른 작업을 수행합니다.
구체적인 동작은 아래와 같습니다.
1) Swift가 새로운 child task를 만듦.
2) child task는 비동기 작업을 수행. parent task는 placeholder value를 변수에 할당.
3) child task가 비동기 작업을 수행하는 동안 parent task는 이후 작업을 수행.
4) 결과값이 실제로 필요한 상황인 경우, parent task는 child task 작업이 완료될 때까지 기다림.
async-let을 사용하는 경우 변수를 실제로 읽는 곳에서 try await을 붙여줘야 합니다.
위 코드를 예로 들면, data를 가져오는 child task와 metadata를 가져오는 child task가 만들어질 거예요.
만약 metadata를 읽는 중에 error가 발생했다면, parent task가 즉시 종료되는 것이 아닌 다른 child task를 cancel로 표시한 후에 해당 작업이 완료될 때까지 기다립니다. (task가 cancel 되면 해당 task의 하위 task도 모두 cancel 됩니다.)
[참고] cancel이 전파되는 구조는 task가 leaking 되는 것을 방지합니다.
## Task Cancellation
[Task Cancel 특징]
1) cancel 되었다고 해서 곧바로 task가 종료되는 것은 아님.
2) 현재 task 어디에서든 cancel 상태를 알 수 있음.
3) 작업이 긴 task의 경우, cancel을 염두에 두고 구현해야 함.
Task.checkCancellation() API를 사용해서 cancel이 됐을 때 error를 던질 수도 있고
Task.isCancelled property를 사용해서 도중에 멈추게 할 수도 있어요.
상황에 맞게 적절하게 사용하면 됩니다ㅎㅎ
## Group tasks
Group tasks는 async-let tasks보다 더 많은 유연성을 제공합니다.
async-let은 비동기 처리할 작업의 수가 고정적일 때 유용해요.
아래 예시를 보면 반복문을 돌면서 비동기로 얻은 UIImage를 Dictionary로 넣어주기 때문에 비동기 작업이 끝나야지 다음 루프가 반복되는 것을 알 수 있어요. => 비효율!!
[참고] 반복문 안에서 async-let 사용하면 안됨?
안됨.
비동기로 UIImage를 받아서 바로 Dictionary에 넣어주고 있기 때문(=변수를 실제로 읽음)에 async-let을 사용할 수 없음.
위 예시처럼 동적으로 비동기 작업의 수가 결정되는 경우에는 TaskGroup을 사용하면 좋습니다.
withThrowingTaskGroup 클로저 안의 group을 사용해서 동적으로 task를 추가할 수 있습니다.
group.async method를 사용해서 group에 child task를 추가할 수 있는데요, child task가 추가되면 추가된 순서와는 상관없이 곧바로 실행된다고 합니다.
근데 이대로는 Data-race 이슈가 발생할 수 있습니다.
(각각의 child task가 동일한 Dictionary를 수정하기 때문이죠)
Data-race을 예방하려면 크게 3가지를 만족해야 합니다.
1) Task가 수행할 작업의 클로저가 @Sendable 타입이어야 함.
2) @Sendable 클로저 내부에서 클로저가 시작된 후 수정될 수 있는 변수를 캡처하면 안됨.
3) Task에서 캡처하는 값이 Data-race로부터 안전해야 함. (value type, Actor 클래스 등)
위 예시에선 2번을 위반했기 때문에 Data-race가 발생할 수 있는 거죠.
Data-race를 막기 위해선 아래처럼 수정하는 것이 좋습니다.
(1) child task에서 Dictionary를 직접 수정하지 않고 parent task가 처리할 데이터 return
(2) parent task가 처리할 데이터 타입 정의
(3) parent task에선 for-await를 사용해서 순차적으로 child task한테 데이터를 전달받을 때마다 Dictionary 수정
-> Sequence 하게 돌아가기 때문에 Data-race로부터 안전함!
## Unstructured tasks
async-let이나 TaskGroup처럼 Structured task만 있는 것은 아닙니다.
Structured task를 사용할 수 없는 상황이 여럿 있어요. 있대요.
- 비동기 코드에서 비동기 task를 하기 위한 parent task가 없을 수 있음.
- task lifetime이 특정 scope나 특정 함수에 제한되지 않을 수 있음.
- ex) 특정 인스턴스의 method에서 task를 시작하고 다른 method에서 task를 cancel 시켜야 하는 경우
이런 상황은 주로 UIKit의 delegate 패턴에서 발생합니다.
이럴 땐 Unstructured task를 사용합니다.
Unstructured task의 특징은 다음과 같습니다.
1) context가 시작된 actor를 상속받음. (우선순위도 함께 상속받음)
2) lifetime은 scope에 종속되지 않음.
3) 원본은 async일 필요가 없음. 어디서든 unscoped task를 생성할 수 있음.
4) canel과 error는 전파되지 않으며, 명시적으로 action을 주지 않는 한 자동으로 await 하지 않음.
예를 들어, CollectionView가 화면에 보일 때 thumbnail을 가져오도록 구현한다고 해볼게요.
delegate는 비동기 함수가 아니니 delegate 안에서 await을 할 수 없어요.
이럴 땐 아래처럼 async Task 클로저 안에 비동기 작업을 넣어서 Unstructured task를 생성할 수 있습니다.
좀 더 완벽하게 구현하려면 CollectionView가 scroll out 되었을 땐 task를 cancel 시켜줘야겠죠?
(Structured task와는 다르게 Unstructured task는 cancel이 전파되지 않으니 직접 구현해야 합니다.)
각 task를 Dictionary 안에 넣고
Cancel 시켜주면 됩니다ㅎㅎ
## Detached tasks
Unstructured task는 어디에서든 선언할 수 있고 lifecycle도 scope에 종속되지 않지만 작업이 원본 context를 상속 받습니다.
원본 context로부터 아무것도 상속받고 싶지 않으면 Detached task를 사용해야 합니다.
# 정리
- 정해진 수의 child task가 필요하다면 async-let tasks를 사용
- child task의 수가 동적으로 정해진다면 task group 사용
- origin context와 연관되었지만 scope에 종속되지 않았다면 unstructured task 사용
- origin context로부터 아무것도 상속받고 싶지 않다면 detached task 사용
# 참고
이번 글은 여기서 마무리.
'WWDC' 카테고리의 다른 글
[WWDC24] 키노트 (0) | 2024.07.14 |
---|---|
[WWDC21] Distribute apps in Xcode with cloud signing (1) | 2024.06.10 |
[WWDC21] Meet AsyncSequence (0) | 2024.03.13 |
[WWDC21] Meet async/await in Swift (1) | 2024.03.09 |
[WWDC22] NavigationStack, NavigationSplitView (0) | 2024.03.02 |