Swift Concurrency 시리즈 (1) Task

오늘부터 새로운 엘런강의를 들으면서 Swift Concurrency에 대해 다시 한번 제대로 공부해 보려고 한다. 그전에 먼저 Task가 뭔지 이전에 그냥 사용해왔던 개념들을 짚어보면서 어떤 방식으로 구현되는지 등 개념적인 측면에서 한번 다시 작성해보고 넘어가보자

 

먼저 가장 기본이 되는 Task에 대해서 정리해보면 Task는 단순 비동기 코드가 아니라 비동기 함수를 실행하고, 그 실행 상태를 보존하며, 중단과 재시작이 가능한 일급 객채! 라고 생각하면 된다.

 

그렇다면 왜 Task가 필요한 걸까

loadUser { user in
    fetchPosts(for: user) { posts in
        updateUI(with: posts)
    }
}

기존에 Swift에서는 Completion handler 기반으로 콜백이 연속적으로 일어나면 가독성은 떨어지고 복잡도는 올라 흐름 추적이 어렵다는 문제를 가지고 있었다.

 

이 문제를 해결하고자 등장한 것이 async/await 이고, 이걸 가능하게 해주는 핵심 역할이 바로 Task 라는 것

 

그렇다고 해서 Task는 단순한 클로저가 아니라는 것!

Task {
    await doSomething()
}

이 코드를 보면 그냥 클로저 처럼 보일 수 있지만 아래와 같은 내용이 구조체 형태로 메모리에 만들어진다.

  • 해당 코드가 어디까지 실행되었는지 기억
  • 중단되면 나중에 다시 이어서 실행
  • 부모/자식 Task 관계 기억
  • 우선순위나 실행 위치(메인인지, 백그라운드인지) 기억

이를 바탕으로 Task는 단순 함수 실행과는 다른 일시정지 가능한 실행 컨텍스트(stateful coroutine)라는 점이다.

그럼 이 Task 구조를 더 살펴보면 다음과 같은 특징들이 있다.

 

Coroutine

함수를 중간에 멈췄다가 다시 시작할 수 있는 기능

func printNumbers() async {
    print(1)
    await Task.yield()  // 여기가 suspend point
    print(2)
}

이 함수는 await에서 실행을 중단하고, 나중에 다시 재개할 수 있도록 설정할 수 있다, 이것이 바로 코루틴!!

Swift에서는 내부적으로 이걸 stackless coroutine으로 구현한다. 즉 스택이 아닌 힙에 상태를 저장하는 방식!!

 

이게 왜 중요한지는 다음 개념에서 확인해보자

 

Continuation

어디까지 실행했는지, 다음에 뭘 해야하는지 기억하는 객체

let continuation = UnsafeContinuation<T, Error>

Swift는 await에서 함수 실행을 멈추고 현재 위치와 변수를 continuation이라는 구조에 저장한다.

 

이 continuation은 쉽게 얘기하자면 “이어서 실행할 코드 덩어리!!” 라고 생각하면 된다. CPS(Continuation Passing Style) 에서 쓰는 다음 단계를 함수로 저장하는 패턴과 동일하다!

 

Structured Concurrency

작업(Task)의 생명주기를 코드 구조와 연결하는 방식!!

func loadAll() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await fetchA() }
        group.addTask { await fetchB() }
    } // 여기서 Task들이 자동 종료됨
}

Swift는 부모 Task가 생성한 자식 Task들을 트리 구조로 관리한다. 부모가 끝나면 자식도 끝나는 구조! 이를 통해서 메모리 누수, 자원 누락 등을 자동으로 방지한다.

 

Heap-Allocated Frame

보통 함수는 실행 중에 로컬 변수, 리턴 주소, 파라미터 등을 스택에 저장하게 되는데 async 함수는 중단 가능해야하기 때문에 스택에 두면 안된다! 왜냐면 스택은 함수 호출이 끝나면 날아가게되기 때문에 await 중에 중단되면, 나중에 다시 호출해야 하기 때문이다. 그래서 이 정보들을 스택이 아닌 힙에 저장하게 된다.

[AsyncTask] --> [Heap Frame]
                      |
                      -> local variables
                      -> resume function pointer
                      -> parent context

Cooperative Multitasking

Task는 await에서만 멈추게 된다. 다르게 말하면 강제로 중단되지 않는다!! 이런 특징은 Race Condition을 줄이는데 유리하고 코드의 실행 순서를 예측하기 쉬워지지만 무한 루프가 돌 수 있다는 한계가 있다.

Task {
    while true {
        doHeavyWork() // await이 없기 때문에 절대 멈추지 않음
    }
}

이 Task에서는 중단 지점이 없기 떄문에 CPU를 계속 점유하면서 다른 Task에게 기회를 주지 않는다. 특히 actor 내부라면 actor가 lock 된 것 처럼 행동한다.

 

Task {
    for i in 1...1_000_000 {
        doWork(i)
        
        if i % 1000 == 0 {
            await Task.yield() // 잠깐 멈추고 다른 Task에게 CPU 양보
        }
    }
}

Task.yield()는 Swift 런타임에게 ‘다른 애들 먼저 좀 실행시켜줘’ 라고 말하는 역할이며 이 덕분에 실행 흐름을 조정할 수 있다.

 

일단 오늘 첫번째 시리즈는 여기까지!! 앞으로 짚어봐야할게 산더미 같다.. ㅠㅠ