[Swift5] Concurrency

17685 단어 swiftswift
  • 다음은 Swift 5.6 Doc의 Concurrency 공부 내용을 정리했음을 밝힙니다.

Concurrency

스위프트는 구조화된 방식으로 비동기적, 병렬적 코드를 작성하는 데 도움을 주는 빌트인 기능을 지원한다. 프로그램 코드 실행을 일시 중단(suspending)하고 재개(resuming)하는 비동기적 코드에서는 데이터 패치 및 파싱(긴 텀의 기능) 중 UI 업데이트(텀이 짧은 기능)을 계속할 수 있다. 병렬적 코드란 여러 코드가 동시에 작동함을 의미한다. 동시성을 지원하는 비동기적/병렬적 코드 기능으로 인해 연산 비용이 커진다는 부담도 있다(컴파일 또한 어려워질 수도 있다).

연속성을 보장하는 구체적인 구현 방식은 스레드다. 하지만 스위프트에서는 스레드를 직접적으로 사용하기보다 비동기 키워드를 사용하는 게 가독성이 높다는 점에서 추천된다.

비동기 함수 정의 및 호출

비동기적 함수 및 메소드의 일부 코드는 실행 흐름 도중 일시적으로 멈출 수 있다. 비동기적 코드 바디 내부에 실행 흐름이 일시 멈춤할 수 있는 부분을 마킹해준다.

비동기적으로 작동하는 코드를 작성할 때에는 함수/메소드에 넘겨주는 파라미터 다음에 async 키워드를 써준다.

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)

func listPhotos(inGallery name: String) async throws -> [String] {
    try await Task.sleep(nanoseconds: 2 * 1_000_000_000)  // Two seconds
    return ["IMG001", "IMG99", "IMG0404"]
}

비동기적 메소드를 호출하면 실행 흐름이 메소드가 리턴하기 전까지 일시 멈춤한다. 일시 멈춤이 일어날 수 있는 지점에 listPhotos 함수를 호출할 때 await 키워드를 사용해야 한다. 비동기적 메소드 내부에서는 실행 흐름은 다른 비동기적 메소드를 호출할 때에만 일시 멈춤한다.
listPhotos처럼 downloadPhoto 함수 또한 네트워크 리퀘스트가 필요한 함수라는 점에서 비동기적 기능이 필요하다.

await - 일시 멈춤 코드는 비동기적 함수/메소드가 리턴값을 가져오기를 기다리는 동안 멈출 수 있는데, 스레드를 양보하는 것이기도 하다. 멀티 스레드 환경에서 메인 스레드(즉 현재 일시 멈춤 코드를 실행 중인 실행 흐름)를 멈추고 다른 스레드(비동기적 함수/메소드를 실행하는 실행 흐름)를 실행하는 것이기 때문이다.

listPhotos 함수의 Task.sleep() 메소드를 통해 특정 태스크가 아무 작업을 하지 않는 시간을 정의할 수 있다.

비동기적 시퀀스

listPhotos 함수는 모든 함수 값이 준비된 이후에야 비동기적으로 한 번에 전체 배열값을 리턴한다. 다른 방식으로 배열 값을 준비할 수도 있다. 비동기적 시퀀스를 사용해 한 번에 원소 하나씩 값을 할당받기를 기다리는 것이다.

import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

for-in 반복문 내 await 키워드를 통해 line 값이 할당되기까지 기다릴 수 있다. handle 값에 하나씩 값이 할당되는 시점에서 line을 출력한다.

비동기적 함수 병렬 호출

await 키워드로 비동기적 함수를 호출하는 것은 한 번에 코드의 한 부분만 실행한다. 비동기적 코드가 실행 중일 때, 다음 코드 라인을 실행하기 위해 이동하기 전에 코드가 종료될 때까지 호출하는 블록이 기다려야 한다.

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

firstPhoto, secondPhoto, thirdPhoto에 사진을 다운로드받기 위해 await를 사용하는데, 순서대로 다운로드가 완료될 때까지 기다려야 한다. 비동기 키워드 async를 통해 멀티 스레드 환경을 활용할 수 있다.

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

async를 통해 firstPhoto, secondPhoto, thirdPhoto를 병렬적으로 다운로드받기 때문에 순서대로 다운로드 완료를 기다릴 필요 없다. photos은 배열 값을 await로 기다리고 있기 때문에 위 세 개 값이 모두 할당되는 게 보장된다.

태스크 및 태스크 그룹

태스크는 비동기적으로 실행 가능한 작업 유닛이다. 비동기적 코드는 태스크 일부로 실행되는데, async-let 문법을 통해 자식 태스크를 생성한다. 태스크 그룹을 만들어 자식 태스크를 이 그룹에 추가할 수도 있다. 태스크 그룹을 통해 각 태스크를 더 잘 조작할 수 있고, 다양한 수를 생성할 수 있다.

태스크는 계급 구조로 정리된다. 태스크 그룹 내 태스크는 동일한 부모 태스크를 가지고 있고, 각 태스크는 자식 태스크를 가진다. 이렇게 부모-자식 관계가 드러나는 관계로 인해 구조화된 동시성(structured concurrency)라고 불린다.

await withTaskGroup(of: Data.self) { taskGroup in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        taskGroup.addTask { await downloadPhoto(named: name) }
    }
}

구조화되지 않은 동시성

구조화되지 않은 동시성(unstructured concurrency)이라는 개념은 앞의 부모-자식 태스크 관계가 아니라 부모가 없는 태스크를 지원한다.

현재 실행자 액터(actor)에서 실행되는 구조화되지 않은 태스크를 생성하려면 Task.init() 이니셜라이저를 호출한다. 현재 액터의 일부가 아닌 구조화되지 않은 태스크(분리된 태스크 detached task)를 만들기 위해선 Task.detached()를 사용한다. 이 방법을 통해 태스크와 상호 작용할 수 있다.

let newPhoto = // ... some photo data ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value

태스크 캔슬레이션

태스크는 실행할 때 특정 시점에 삭제되었는지 체크한 뒤 실행된다. 에러 스로잉, 널 값 또는 빈 컬렉션 리턴, 부분적으로 완료된 작업 리턴 등 태스크가 캔슬되었는지 확인하자.

Task.checkCancellation()이 태스크가 캔슬되었으면 CancellationError를 리턴하기 때문에 태스크 상황을 확인할 수 있다. 또는 Task.isCancelled 값을 통해 직접 확인할 수도 있다. 직접 캔슬레이션을 프로파게이트하려면 Task.cancel()을 호출한다.

액터

액터는 클래스와 마찬가지로 참조 타입이다. 액터는 한 번에 태스크 하나만 뮤터블 값에 접근하도록 허용하는 게 특징이다. 동일한 액터 인스턴스에 접근, 프로퍼티 값을 조회/변환할 때 멀티 태스크 작업이 실행된다면 동기화를 보장할 수 없기 때문이다.

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }
}

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"

온도를 기록하는 액터 TemparatureLogger는 액터 외부 코드가 접근 가능한 프로퍼티에 값을 담아두는데, max 값은 최댓값이기 때문에 값 하나만 가지도록 제한된다.

logger 인스턴스의 프로퍼티 max에 접근할 때 일시 멈춤될 수도 있다. logger 인스턴스와 이미 상호 작용하고 있다면 (프로퍼티 값이 그 태스크 작업이 끝난 이후 바뀔 수도 있기 때문에) await를 사용한다.

extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

update 메소드가 실행되는 시점에서 이미 액터 위에서 작동하기 때문에 await를 사용할 필요가 없다. update 함수는 파라미터로 넘겨진 measurement 값이 기록된 max와 비교해 자동으로 큰 값을 유지한다. update 코드 블록에 await가 없기 때문에 다른 코드가 max를 읽어오려면 함수가 종료될 때까지 기다려야 한다. 이때 액터는 기본적으로 스위프트에 의해 동기화를 보장받는다.

액터 외부에서 프로퍼티 값에 접근할 때에는 컴파일 에러가 난다. await 없이 logger.max 등 인스턴스 프로퍼티에 접근할 때 프로퍼티 값이 액터의 고립된 로컬 스테이트(isolated local state)이기 때문이다. 액터 내부 코드만 액터의 로컬 스테이트에 접근할 수 있다.

좋은 웹페이지 즐겨찾기