[Swift] Concurrency

LEEHAKJIN-VV·2022년 5월 30일
0

Study-Swift 5.6

목록 보기
20/22

참고사이트:
English: The swift programming language


Concurrency (동시성)

Swift는 parallel and asynchronous(비동기 및 병렬) 코드를 지원한다. asynchronous 코드는 한 번에 하나의 프로그램만 실행되지만 일시 중단되었다가 다시 실행할 수 있다. Suspending and resuming 코드는 네트워크에서 데이터를 가져오거나 파일 파싱과 같은 장기 작업을 진행하는 동안 UI 업데이트와 같은 단기 작업을 동시에 진행할 수 있다. Paralled code는 동시에 여러 코드를 실행시킬 수 있는 것을 의미한다. 예를 들어 4개의 코어 프로세스가 있는 컴퓨터는 각기 다른 작업을 하는 4개의 코드를 동시에 실행시킬 수 있다. 그러므로 parallel and asynchronous 코드를 사용하는 프로그램은 한 번에 여러 작업을 수행한다.

이순간 이후부터는 asynchoronous and paralled code를 concurrency라는 용어로 지칭한다.

NOTE
이전에 concurrent(동시성) 코드를 작성한 경험이 있다면 스레드에 익숙할 것이다. Swift의 동시성 모델은 스레드 위에서 구축되지만 직접 상호작용하지는 않는다. Swift의 비동기 함수는 현재 실행 중인 스레드를 종료할 수 있고 이 동안 다른 비동기 함수를 같은 스레드 안에서 실행할 수 있다.

Swift의 language support 없이 current code를 작성하는 것은 가능하지만, 이는 가독성을 저하시킨다. 예를 들어 다음 코드는 사진의 이름들을 다운로드한 후, 목록에 첫 번째 이름을 사용자에게 보여준다.

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

간단한 코드에서도 completion handlers를 연속적으로 사용하여야 하므로 nested closure를 사용한 것을 확인할 수 있다. 이는 코드의 가독성을 떨어트린다.


Defining and Calling Asynchronous Functions (비동기 함수의 선언과 호출)

asynchronous 함수와 asynchronous 메소드는 실행 도중 일시 중단될 수 있는 특별한 타입이다. 완료될 때까지 실행되거나, 반환을 하지 않거나 에러를 발생시키는 다른 일반적인 synchronous(동기) 함수와 메소드와는 대조적이다. asynchronous 함수나 메소드는 위의 3가지 중 하나를 실행하지만 무언가를 기다릴 경우 중간에 멈출 수도 있다. 그리고 함수나 메소드 내에서 실행이 일시 중단되는 위치를 표시할 수 있다.

asynchronous 함수나 메소드는 async 키워드를 사용하여 선언한다. 이 키워드는 파라미터 뒤에 작성한다. 만약 반환 값이 있다면 화살표(->) 앞에 작성한다.

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

만약 함수나 메소드가 throws and asynchronous 이면 throws 키워드 앞에 async를 작성한다. asynchronous 메소드를 실행하면 이 메소드가 return 될 때 까지 실행은 멈춘다. possible suspension point(실행이 중단되는 지점)을 표시하기 위해 await 키워드를 작성한다. 이는 try 키워드를 작성하는 것과 비슷하다. asynchronous 메소드 내에서 실행은 오직 다른 asynchronous 메소드가 호출될 때 중단된다. 그리고 이를 암시적으로 생략하지 않고 반드시 명시적으로 await 키워드를 작성하여 중단될 수 있는 지점을 표시해야 한다.

예를 들어 아래 코드는 위 챕터에서 await 키워드를 작성하여 다르게 구현한 것이다.

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

istPhotos(inGallery:)downloadPhoto(named:) 함수는 네트워크에 request 하기 때문에 상대적으로 수행 시간이 길다. 두 함수에 async 키워드를 작성하여 asynchronous으로 만들면 사진이 준비될 때까지 나머지 코드가 실행된다.

이 예제에서 concurrent(동시성)을 이해하기 위해 가능한 실행 순서는 다음과 같다.

  1. 코드는 첫 번째 줄에서 실행된다. await 키워드가 있으므로 listPhotos(inGallery:) 함수를 호출하고 해당 함수가 return 될 때 까지 실행은 중단된다.
  2. 코드의 실행이 중단된 동안 같은 프로그램 내에서 다른 concurrent code는 실행될 수 있다. 예를 들어 long-running background task(장기 백그라운드 작업) 이 사진 갤러리의 목록을 계속 업데이트할 수 있다. 또한 이 task도 다른 suspension point(await)를 만나거나 task가 완료 될 때까지 수행된다.
  3. listPhotos(inGallery:)가 반환된 후에, 실행은 중단된 지점부터 계속 진행된다. 반환된 값은 phtoNames에 할당된다.
  4. 다음 두 줄의 코드는 await 키워드가 없는 일반적인 코드이다. 그러므로 실행이 중단되지 않는다.
  5. downloadPhoto(named:) 함수의 호출에서 await키워드가 작성되었다. 이는 1번과 마찬가지로 한수가 반환될 때까지 실행이 중단된다. 그리고 다른 concurrent code에게 실행될 기회를 준다.
  6. 이 함수가 반환되면 phto에게 반환된 값이 할당되고 show(_:)를 호출할 때 인자로 넘겨준다.

코드에서 await로 표시된 possible suspension points은 asynchronous 함수나 메소드가 반환될 때까지 현재 코드의 실행이 중단될 수 있는 것을 나타낸다. 이를 yielding the thread (스레드 양보)라고 하는 데, Swift는 현재 스레드에서 코드를 중단하고 다른 코드를 실행시키기 때문이다. await가 작성된 코드는 실행 흐름을 변경시킬 수 있기 때문에 아래와 같이 특정한 위치에서만 asynchronous 함수나 메소드를 호출할 수 있다.

  • Code in the body of an asynchronous function, method, or property (비동기 함수, 메소드 프로퍼티 내에서)
  • Code in the static main() method of a structure, class, or enumeration that’s marked with @main (구조체, 클래스 또는 열거형의 @main 키워드가 표시된 static main() 메소드 내에서)
  • Code in an unstructured child task, as shown in Unstructured Concurrency below (아래 Unstructured Concurrency에서 설명된 unstructured child task 코드 내에서)

NOTE
Task.sleep 메소드를 사용하여 concurrency(동시성) 실행의 흐름이 어떻게 흘러가는지 간단하게 배울 수 있다. 이 메소드는 아무 동작도 하지 않지만 인자로 주어진 시간만큼 실행이 멈추게 된다. 아래 코드처럼 network의 요청을 기다리기 위해 sleep메소드를 사용할 수 있다.

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

Asynchronous Sequences (비동기 시퀀스)

이전 챕터에서 listPhotos(inGallery:) 함수는 배열의 모든 element가 준비된 후에, 비동기적으로 전체 배열을 반환한다. 이와 다른 방법은 asynchronous sequence(비동기 시퀀스)를 사용하여 컬렉션에서 한 번에 1개의 element만 기다리는 것이다. 아래 코드는 asynchronous sequence가 실행되는 예제이다.

import Foundation

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

일반적으로 for-in 루프를 사용하지 않고, for 키워드 뒤에 await 키워드를 작성하였다. asynchronous 함수나 메소드를 호출한 것과 마찬가지로, await 키워드를 작성하여 possible suspension point을 나타낼 수 있다. for-wait-in 루프는 다음 element가 준비될 때까지 실행을 일시 중단한다.


Calling Asynchronous Functions in Parallel (비동기 함수 병렬 호출)

await키워드를 사용하여 asynchronous 함수를 호출하면 오직 한 번에 하나의 코드만 실행된다. caller는 asynchronous 코드가 실행이 완료되기 전까지, 다음 줄의 코드로 움직이지 않는다. 다음 예제는 갤러리에서 3개의 사진을 가져온다. 이를 위해 downloadPhoto(named:)함수를 3번 호출할 때 모두 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)

그러나 이 접근법은 중요한 문제가 있다. 다운로드가 비동기적이며, 진행되는 동안 다른 작업을 수행할 수 있지만, 오직 한 번에 하나의 downloadPhoto(named:)함수가 호출된다는 것이다. 즉 현재 사진을 다운로드하는 동안 다음 사진을 다운로드할 수 없다. 각 사진을 동시에 또는 독립적으로 다운로드할 수 있으므로 이는 비효율적인 작업이다.

asynchronous 함수를 호출하여 parallel(병렬) 코드를 실행하려면, let 키워드 앞에 async를 작성하여 상수를 선언하고, 그리고 그 상수를 사용할 때마다 await키워드를 작성해야 한다.

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)

위 코드에서는 이전 작업이 완료되는 것을 기다리는 것 없이 downloadPhoto(named:) 함수를 호출한다. 시스템의 리소스만 충분하다면 동시에 실행이 가능하다. 결과를 기다리기 위해 suspend(중단) 될 필요가 없으므로 함수를 호출할 때 await키워드를 작성하지 않는다. 대신에, 여기서는 photos가 정의된 라인까지 계속해서 실행된다. 이때 프로그램은 이러한 asynchronous 호출의 결과(firstPhoto, secondPhoto, thirdPhoto)가 필요하기 때문에, await 키워드를 작성하여 3개의 사진의 다운로드가 완료되기 전까지 실행을 멈춘다.

다음은 2가지 접근 방법에 대한 차이점을 설명한다.

  • Call asynchronous functions with await when the code on the following lines depends on that function’s result. This creates work that is carried out sequentially: 다음줄의 코드가 현재 코드의 결과에 의존할 때 await를 사용하여 asynchronous 함수를 호출한다. 이는 연속적인 작업을 수행하는 것이다.

  • Call asynchronous functions with async-let when you don’t need the result until later in your code. This creates work that can be carried out in parallel.: 다음줄의 코드가 현재 코드의 결과에 의존적이지 않을 때 async-let 키워드를 사용한다. 이는 병렬적인 작업을 수행하는 것이다.

  • Both await and async-let allow other code to run while they’re suspended.: awaitasync-let모두 일시 중단된 동안 다른 코드가 실행될 수 있다.

  • In both cases, you mark the possible suspension point with await to indicate that execution will pause, if needed, until an asynchronous function has returned.: 두 가지 경우 모두, 필요하다면 await 키워드를 사용하여 possible suspension point을 표시하고, asynchronous 함수가 반환될 때까지 기다린다는 것을 나타낼 수 있다.

또한 같은 코드에서 2가지 접근법을 같이 사용할 수 있다.


Tasks and Task Groups

task는 프로그램의 일부로 비동기적으로 실행할 수 있는 작업의 단위이다. 모든 asynchronous 코드는 task의 일부분으로 실행된다. 이전 챕터에서 설명한 async-let 구문은 child task를 생성한다. 또한 task group을 생성하고 해당 그룹에 child task를 추가할 수 있다. 이는 task들을 관리할 때 task의 우선순위 및 취소를 더 잘 제어할 수 있으며 task를 동적으로 생성할 수 있다.

task는 계층적인 구조이다. task group 내의 각각의 task들은 같은 부모의 task를 가지고, child task들을 가질 수 있다. task와 task group의 명시적인 관계 때문에 이러한 접근은 structured concurrency라고 한다. 정확성에 대한 일부 책임이 있지만 tasks들 사이에서 명확한 부모-자식 관계는 propagation cancellation(전파 취소)와 같은 작업 수행, compile time error를 탐지하는 데 도움을 준다.

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

TaskGroup


Unstructured Concurrency (비정형 동시성)

이전 챕터에서 기술된 concurrency에 대한 정형 접근 이외에도, Swift는 unstructured task를 지원한다. task group의 일부인 task와 달리 unstructured task는 부모 task를 가지지 않는다. 현재 actor에서 unstructured task를 생성하기 위해서 Task.init(priority:operation:) 이니셜라이저를 호출한다. 현재 actor의 일부가 아닌 곳에서 unstructured task를 생성 하려면 Task.detached(priority:operation:) 클래스 메소드를 호출해야 한다. 예를 들어 이러한 작업은 결과를 기다리거나 취소할 수 있는 것처럼 task와 상호작용할 수 있는 task handle을 반환한다.

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

detached task을 관리하는 자세한 정보는 Task를 확인한다.


Task Cancellation

Swift의 concurrency는 cooperative cancellation 모델을 사용한다. 각각의 task는 실행 도중 적절한 시점에서 취소 여부를 확인하고, 적절한 방식으로 취소에 대해 응답을 한다. 수행하는 작업에 따라 일반적으로 다음과 같은 방법으로 취소에 응답한다.

  • Throwing an error like CancellationError: CancellationError와 같은 에러 throwing
  • Returning nil or an empty collection:nil또는 빈 컬렉션 반환
  • Returning the partially completed work: 완료된 작업 부분적으로 반환

task가 취소됐을 때 CancellationError 를 throws 하는 Task.checkCancellation()를 호출하거나 또는 Task.isCancelled의 값을 확인하고 자체 코드 내에서 취소를 처리하는 방식으로 취소를 확인할 수 있다. 예를 들어 갤러리로부터 사진을 다운로드하는 task는 부분적으로 다운로드 된 것을 삭제해야 하고 그리고 네트워크의 연결을 닫아야 한다.

취소를 수동으로 전파하는 방법은 Task.cancel()을 사용한다.


Actors

클래스처럼, actors는 참조 타입이다. 그래서 Classes Are Reference Types 내에서 기술되어 있는 value types(값 타입) 그리고 reference types(참조 타입)의 비교는 클래스와 actors에게도 적용된다. 클래스와 달리 actors는 오직 한 번에 하나의 task가 mutable state에 접근하도록 제어하기 때문에, actor의 하나의 인스턴스에 대해 여러 작업이 동시에 접근할 때 안전하다. 다음은 온도를 기록하는 actor이다.

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
    }
}

actor 키워드를 사용하여 actor를 생성한다. TemperatureLogger actor는 actor 내부, 외부에서 접근 가능한 프로퍼티를 2개를 가지고 오직 내부에서만 접근할 수 있는 max프로퍼티를 가진다.

구조체와 클래스와 같은 이니셜라이저 구문으로 actor의 인스턴스를 생성한다. actor의 프로퍼티나 메소드를 접근할 때 await 키워드를 사용하여 potential suspension point를 표시해야 한다.

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

위 예제에서 logger.max는 possible suspension point이다. acotrs는 오직 한 번에 하나의 task만 mutable state에 접근하기 때문에 다른 task가 이미 logger와 상호작용 중이라면, 프로퍼티에 대한 접근을 기다리는 동안 실행이 중단돼야 하기 때문이다.

이와 반대로, actor 코드의 일부가 actor의 프로퍼티에 접근할 때는 await 키워드를 작성하지 않는다.

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

update(with:) 메소드는 이미 actor에서 실행 중이므로, max 프로퍼티를 접근할 때 await를 사용하지 않는다. 또한 이 메소드는 actor가 오직 한 번에 하나의 task가 mutable state에 접근하는 이유를 보여준다. actor의 상태에 대한 업데이트는 일시적으로 invariants(불변성)을 깨트린다. TemperatureLogger actor는 온도 목록과 최고 온도를 저장하며, 그리고 새로운 온도를 기록할 때 온도 최댓값을 업데이트한다. 업데이트 도중에 온도 목록에 새로운 온도를 추가한 후, 최댓값을 업데이트하기 전에 temperature logger는 일시적으로 일치하지 않는 상태(일관성이 없는 상태)가 된다. 여러 task가 동일한 인스턴스에 대해 접근하는 것을 방지하여 다음과 같은 문제의 발생을 예방한다.

  1. Your code calls the update(with:) method. It updates the measurements array first.

  2. Before your code can update max, code elsewhere reads the maximum value and the array of temperatures.

  3. Your code finishes its update by changing max.

위와 같은 경우들에서 다른 곳에서 실행이 되는 코드는 데이터가 일시적으로 유효하지 않는 상태에서 actor에 대해 접근하기 때문에 잘못된 정보를 읽을 수 있다. 동시에 오직 한 번에 작업만 수행되고, await키워드를 작성한 지점에서만 중단되기 때문에 swift의 actor를 사용할 경우, 위 같은 경우들의 문제를 방지할 수 있다.

update(with:) 메소드는 어떠한 suspension point(중단 지점)을 선언하지 않았기 때문에 프로퍼티 업데이트 도중 다른 어떠한 코드도 데이터에 접근할 수 없다.

클래스의 인스턴스처럼 아래와 같이 actor 외부에서 이러한 프로퍼티에 접근하면 comptile-time error가 발생한다.

print(logger.max)  // Error

await 없이 logger.max에 접근하는 것은 실패한다. actor의 프로퍼티는 actor의 고립된 local state이기 때문이다. Swift는 actor의 내부에서만 actor의 local state에 접근하는 것을 보장한다. 이러한 보장을 actor isolation이라고 한다.

0개의 댓글