본문 바로가기

iOS/Concurrency

Concurrency (3) AsyncSequence & Intermediate Task

이전글 - Concurrency(2) Getting started with async/await

 

Modern Concurrency in Swift 를 읽고 간단 정리


Getting to Know AsyncSequence

 

AsyncSequence는 비동기적으로 Element를 생성할 수 있는 시퀀스를 나타내는 프로토콜

- Swift Sequence와 동일

- 일반적인 Sequence에서는 다음 Element를 즉시 사용 가능

- AsyncSequence에서는 await 키워드를 사용하여 다음 요소가 준비될 때 까지 기다려야 한다.

 

for문

for try await item in asyncSequence {
    // Next item for asyncSequence
}

 

while문

var iterator = asyncSequence.makeAsyncIterator()

while let item = try await iterator.next() {
    ...
}

 

일반 Sequence에서 사용하는 연산자 사용 가능

for try await item in asyncSequence
    .dropFirst(5)
    .prefix(10)
    .filter(10)
    .map { "Item:\($0)" } {
        // Next item for asyncSequence
    }
}

 

 

파일의 내용을 한 번에 모두 읽지 않고, 필요한 만큼만 순차적으로 읽기

let bytes = URL(fileURLWithPath: "myFile.txt").resourceBytes

for await character in bytes.characters {
    ...
}

for await line in bytes.lines {
    ...
}

 

AsyncSequences 프로토콜을 채택하여 커스텀 시퀀스 만들기

struct CountdownAsyncSequence: AsyncSequence {
    typealias Element = Int

    let start: Int

    func makeAsyncIterator() -> CountdownAsyncIterator {
        return CountdownAsyncIterator(current: start)
    }

}

struct CountdownAsyncIterator: AsyncIteratorProtocol {
    var current: Int

    mutating func next() async -> Int? {
        guard self.current > 0 else { return nil }
        let result = self.current
        self.current -= 1
        return result
    }
}

let countdown = CountdownAsyncSequence(start: 5)

for await number in countdown {
    print(number)
}

 

AsyncStream을 활용하여 직접 커스텀 비동기 시퀀스를 만들기

let timerStream = AsyncStream<Date> { continuation in
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        continuation.yield(Date()) // 매 초마다 현재 시간 제공
    }
    
    continuation.onTermination = { _ in
        timer.invalidate() // 스트림 종료 시 타이머 무효화
    }
}

for await date in timerStream {
    print("Current time: \(date)")
}

 

Cancelling Tasks

동시성 모델에서 작업을 취소하는 것은 중요하다.

 

- TaskGroup, async let을 사용하는 경우, 시스템이 필요할 때 작업을 취소할 수 있다.

ㄴ ex. 작업이 더 이상 필요하지 않을 때 (화면을 나가거나 다른 작업 시작)

ㄴ ex. 시스템 리소스 절약 목적

 

- Task API들을 사용하여 직접 task cancellation 전략을 만들 수 있다. 

 

Task.isCancelled

- 작업이 취소되었다면 true

 

Task.currentPriority

- 현재 작업의 우선순위

 

Task.cancel()

- 작업 취소

 

Task.checkCancellation()

- 작업이 취소 됐다면 명시적으로 CancellationError 예외 던짐 

 

Task.yield()

- 현재 작업을 suspend 처리하고, 다른 작업들을 처리할 기회 제공

- 이 과정에서 작업이 자동으로 취소될 가능성도 있다.

 

Manually Canceling Tasks

- task modfiier 안에서 비동기 작업을 진행했다면, 뷰가 disappear 될 때 자동으로 cancel 된다.

- 그렇지 않은 경우 직접 cancel 처리해줄 수 있어야 한다.

- 아래 코드는 downloadTask와 그 하위 task들을 모두 cancel 시켜주는 예시

struct MyView: some View {
  @State private var downloadTask: Task<Void, Error>?

  var body: some View {
    DowloadInfo(downloadWithUpdatesAction: {
      downloadTask = Task {
        ...
      }
    })
    .onDisappaer {
      downloadTask?.cancel()
    }
  }
}

 

Storing State in Tasks

- 각 비동기 task는 자신의 context를 가지며 수행된다.

- 또한 task는 다른 task를 호출할 수 있다.

- task 간에 서로 상호작용할 수 있기 때문에 데이터를 안전하게 격리(isolation)하는 것이 쉽지 않다.

 

이 문제를 해결하는 방법 중 하나가 task-local 이라는 프로퍼티 래퍼.

 

- Swift 뷰에서 하위 뷰에게 environment 를 주입하여 값을 공유하는 것과 동일한 방식

- Task와 그 자식 Task들이 공통된 고유 데이터에 접근하도록 한다.

 

 

책의 설명이 빈약해서 공식문서 참고

 

선언

- @TaskLocal

- static 또는 전역 프로퍼티로 선언되어야 한다.

typealias TraceID = String

enum Example {
    @TaskLocal
    static var traceID: TraceID?
}

// Global task local properties are supported since Swift 6.0:
@TaskLocal
var contextualNumber: Int = 12

 

기본 값

- TaskLocal 값이 설정 또는 바인딩되지 않았을 때 읽으려고 하면, 기본 값이 반환된다.

- 동기 컨텍스트에서도 기본 값

ㄴ 비동기 Task에서 동기 메서드를 호출하면, 동기 메서드 내에서 기본 값이 읽힌다.

ㄴ 이 컨텍스트를 일치 시키려면 withValue 바인딩 필요 

enum Example { 
    @TaskLocal
    static let traceID: TraceID = TraceID.default
}

 

 

바인딩

- TaskLocal 값은 직접 설정할 수 없다.

- 항상 스코프 내에서 바인딩 된다.

- $traceID.withValue()

- Detached task는 task-local 값을 상속받지 않지만, Task { ... } 초기화로 생성된 작업은 task-local 값을 상속받아 사용

func performTask() async {
    await Example.$traceID.withValue("12345") {
        print("1", Example.traceID) // "12345"
        await someAsyncTask()
    }
    print("2", Example.traceID) // nil (스코프 종료로 바인딩 해제)
}

func someAsyncTask() async {
    print("3", Example.traceID) // "12345"
}

await performTask()

// 1 Optional("12345")
// 3 Optional("12345")
// 2 nil

 

 

- 자식 task에게도 바인딩 잘 된다.

await Example.$traceID.withValue("12345") {
    Task {
        print(Example.traceID) // "12345"
    }
}

 

- 비동기 작업 외부에서도 task-local 값을 바인딩하고 사용할 수 있다.

- Swift는 task-local 값을 쓰레드-로컬 저장소에 저장하므로 동기 함수에서도 일관되게 사용할 수 있다.

ㄴ 동기 함수가 비동기 Task 내에서 호출될 때, task-local 값을 통해 작업의 컨텍스트를 공유 할 수 있겠다.

ㄴ 이 함수들이 동일한 컨텍스트 정보를 필요로 할 수 있음.

func enter() {
    Example.$traceID.withValue("1234") {
        print(read()) // always "1234", regardless if enter() was called from inside a task or not:
    }
}

func read() -> String {
    if let value = Example.traceID {
        "\(value)"
    } else {
        "<no value>"
    }
}


// 1) Call `enter` from non-Task code
//    e.g. synchronous main() or non-Task thread (e.g. a plain pthread)
enter()


// 2) Call 'enter' from Task
Task {
    enter()
}