티스토리 뷰

🍏/Swift

[Swift] Concurrency

eungding 2021. 12. 14. 19:43
반응형

Swift Docs > Concurrency  의 내용을 요약한 글로,  더 자세한 내용은  Meet Swift Concurrency 를 참고하시길 추천드립니다. 


[ 요약 ] 

 

# 개념

✔️ Asynchronous Function  - 일시중단될 수 있으며 그동안 다른 비동기 함수가 해당 스레드에서 실행될 수 있음. 

✔️ Asynchronous Sequence - collection의 element를 하나씩 기다릴 수 있음.  with  for-await-loop

✔️ Asynchronous Function in parallel - 순차적 진행이 아니라 아니라 병렬로 작업을 진행할 수 있음. with async-let 

✔️ Tasks and Task Groups - 비동기 코드의 우선순위, 취소 처리에 대해 더 많이 컨트롤 할 수 있음.

(TaskGroup에 Task를 추가하며 사용하는 경우를 structured concurrency,  단독 Task를 사용하는 경우를  unstructured concurrency 라고 부름)

✔️ Actor - mutable state에 안전하게 접근할 수 있음.

 

 

# 키워드

✔️ async - function이 asynchronous 하다는 것을 나타내기 위해 작성

✔️ await - possible suspension point 를 나타내기 위해 작성 

 

 

[1] Intro 

Swift는 structured way로 asynchronous and parallel code 를 작성할 수 있도록 built-in support 를 하고 있습니다. 

 

Asynchronous code (비동기 코드)는 일시중단되었다가 다시 실행될 수 있는 코드입니다. 

Suspending and resuming code 는 data fetching,  file parsing 같은  long-running operations 을 실행하는 동안에 

UI 업데이트 같은 short-term operations 을 진행할 수 있게 해줍니다. 

 

Parallel code (병렬 코드) 는  multiple pieces of code 가 동시에 돌아갈 수 있는 것을 의미합니다.

예를들어 4코어 프로세서가 장착된 컴퓨터는 four pieces 코드를 동시에 실행시킬 수 있으며 각각의 코어는 하나의 작업을 수행합니다.

 

이 챕터의 나머지 부분에서는  asynchronous and parallel code 를  concurrency 라는 용어를 사용해서 지칭하겠습니다.

 

참고로 Swift의 language support 없이 concurrent code를 작성하는 것은 가능하지만, 그 코드는 읽기 더 어렵습니다.

예를들어 아래와 같은 simple case 에도 코드는 completion handlers 의 연속으로 작성되어야하므로 

nested closures 가 됩니다.  deep nesting 코드는 다루기 어려워집니다. 

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

 

[2] Defining and Calling Asynchronous Functions

asynchronous function 은 실행 중에 일시 중단할 수 있는 special function 입니다. 

완료될 때 까지 실행되거나 (run to completion), 에러를 던지거나 (throw an error), never return 이거나 하는 일반적인 synchronous function과는 대조됩니다.

asynchronous function은 위의 세가지 중 하나를 수행하지만, 무언가를 기다리고 있을 때 중간에 멈출 수 도 있습니다. 

asynchronous function 의 body 안에 실행을 일시중단할 수 있는 위치를 표시할 수 있습니다. 

 

function이 asynchronous 하다는 것을 나타내기 위해 async 키워드를 작성할 수 있습니다. (return arrow (->) 앞에  작성)

throwing function 임을 표시하기 위해 throws 키워드를 작성했던 것과 유사합니다. 

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

 

만약 asynchronous and throwing function 이라면 throws 앞에 async 를 작성해주면 됩니다. 

asynchronous function을 호출하면, 실행은 return 될 때까지 일시중단 됩니다. 

가능한 중단 지점 (possible suspension point) 을 표시하기 위해 콜하는 앞부분에 await 을 표시해줄 수 있습니다. 

이것은 throwing function 을 콜할 때 앞에 try 를 작성했던 것과 유사합니다. 

suspension은 절대 암묵적이지 않습니다. 모든 가능한 정지지점이 await으로 표시되어있습니다. 

 

asynchronous method 안에서 실행 흐름은 다른 asynchronous method 를 호출할 때만 일시 중단 됩니다.

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

 

listPhotos(inGallery:) 와 downloadPhoto(named:) function은 둘다 network request를 하기 때문에

완료를 위해서 상대적으로 긴 시간이 걸립니다. 

두 function에 async 를 표시해서 asynchronous function으로 만들어주면 사진이 준비될 때까지 앱의 나머지 코드는 계속 실행됩니다.

 

아래는 위의 예제에 대해 가능한 실행 순서 중 하나입니다. 

 

1. 코드는 첫번째 라인에서 시작되고 첫번째 await 을 만납니다.  listPhotos(inGallery:) 함수를 호출하고 해당 함수가 return 되기를 기다리는 동안 실행을 일시중단합니다.

 

2. 이 코드의 실행이 일시중단되는 동안, 같은 프로그램 안에 있는 다른 concurrent code 가 실행됩니다. 

예를들어 long-running background task 가 포토 갤러리의 목록을 계속 업데이트 하고 있을 수 있겠습니다.

위 task 도 마찬가지로 다음 suspension point (await이 표시된)를 만날 때 까지 실행될 것 입니다. 

 

3. listPhotos(inGallery:) 가 return을 한 후에, 코드는 이 시점부터 실행을 계속합니다. photoNames 프로퍼티에 반환된 값을 할당합니다.

 

4. sortedNames과 name을 정의하는 코드는 일반적인 synchronous code 입니다. 이 라인들에는 await 표시된 곳이 없으므로 가능한 suspension points 가 없습니다.

 

5. downloadPhoto(named:) function 을 콜할 때, await 이 표시되었습니다. 이 코드는 function이 return 될 때 까지 실행을 다시 중단합니다. 다른 concurrent code 한테 실행될 기회를 주면서! 

 

6. downloadPhoto(named:) 가 return 된 후에, return value가 photo 프로퍼티에 할당되고 photo를 argument로 pass 하며 show(_:) 를 콜합니다. 

 

 

코드에서 possible suspension points 는 await 으로 marked 되고 

이는 asynchronous function이 return될 때 까지 current piece of code가 실행이 중지될 수 있음을 나타냅니다.

이것은 yielding the thread (스레드 양보?) 라고 불릴 수 도 있습니다.

뒷단에서 Swift는 current thread에서 해당 코드의 실행을 중지하고 다른 코드를 돌릴 수 있기 때문입니다. 

 

await 은 실행을 중단시킬 수 있기 때문에 아래와 같은 프로그램의 특정 위치에서만 asynchronous function을 호출할 수 있습니다.

 

  • 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.
  • Code in a detached child task, as shown in Unstructured Concurrency below.

 

Task.sleep(_:) method 는 concurrency 동작을 학습하기 위해 simple code를 작성할 때 유용합니다. 

아래처럼  메소드는 network operation 을 기다리는 것을 sleep을 사용해서 시뮬레이션 할 수 있습니다. 

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

 

[3] Asynchronous Sequences

listPhotos(inGallery:) 함수는 array의 모든 element가 준비된 후 전체 array를 한번에 return 합니다. 

또 다른 접근법으로 asynchronous sequence 를 이용하여 한번에 하나의 element를 기다릴 수 있습니다.

아래는 asynchronous sequence 를 iterating 하는 예제입니다. 

import Foundation

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

 

이 예제에서 일반적인 for-in loop 가 아니라 for 뒤에 await 을 함께 작성해줬는데요, 

asynchronous function 을 콜할 때, await 을 작성해서 possible suspension point 를 표시한 것과 같습니다. 

for-await-in loop 는 각 iteration이 시작될 때 next element 를 사용할 수 있을 때 까지 기다리면서 

실행을 잠재적으로 일시 중단합니다. 

 

Sequence protocol 을 conform 하는 타입에서 for-in loop 를 사용할 수 있었던 것 처럼

AsyncSequence protocol 을 conform 하는 타입에서 for-await-in loop 를 사용할 수 있습니다. 

 

 

[4] Calling Asynchronous Functions in Parallel

await과 함께 asynchronous function 을 호출하면 한번에 하나의 코드 조각만 실행됩니다.

asynchronous code가 실행되는 동안, caller는 next line으로 이동하기 전에 해당 코드가 완료될 때 까지 기다립니다.

예를들어 세 장의 사진을 fetch 해오려면  downloadPhoto(named:) 를 세번 콜하는 것을 기다릴 수 있습니다. 

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)

 

이 접근은 중요한 문제점을 가지고 있습니다. download가 비동기식으로 진행되는 동안 다른 작업이 진행될 수 있지만, 

downloadPhoto(named:) 호출은 한번에 하나만 실행됩니다. 

즉 각 사진은 다음 사진이 다운로드 시작되기 전에 완전히 다운로드가 다 끝나게 되는 것입니다. 

그러나 각 사진을 독립적으로 또는 동시에 다운로드 할 수 있는데, 이런 기다림은 불필요합니다. 

 

asynchronous function 을 호출하여 주변의 코드와 병렬로 실행하려면,

let 을 사용해서 constant 를 정의할 때 그 앞에 async 를 작성해주면 됩니다.

그리고 정의한 constant 를 사용할 때마다 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:) 콜이 이전의 것이 끝나기를 기다리지 않고 시작됩니다. 

충분한 system resources 가 있다면 동시에 실행될 수도 있습니다. 

downloadPhoto(named:)를 콜할 때 await을 작성해주지 않았는데요, 코드가 function의 결과를 기다리기 위해 일시중단되지 않기 때문입니다. 

 

실행은 photos가 있는 라인까지 계속 됩니다. photos 가 있는 라인에서는 프로그램이  asynchronous calls 에 대한

결과를 필요로 하기 때문에 await 을 작성해서 세개의 사진이 모두 다운로드가 끝날 때까지 실행을 일시 중단할 수 있도록 해줍니다.

 

두 접근법이 어떤 차이가 있는 지 아래에서 설명합니다. 

- 다음 줄의 코드가 해당 함수의 결과에 의존할 때, await 을 작성하여 asynchronous function 을 호출합니다.

  이는 순차적으로 수행되는 작업을 만듭니다.

- 코드에서 결과가 바로 필요하지 않을 때, async-let 을 작성하여 asynchronous function 을 호출합니다.

  이는 parallel한 작업을 만듭니다.

- await 과 async-let 모두 일시중단된 동안 다른 코드가 실행되도록 허용합니다. 두 케이스 모두 await 으로 possible suspension point 를 표시해서 asynchronous function 이 return 될 때 까지 실행이 일시 중지 됨을 나타냅니다.

 

같은 코드에 두가지 접근을 믹스해서 사용할 수 있습니다.

 

[5] Tasks and Task Groups 

task는 프로그램의 일부를 비동기적으로 실행할 수 있는 하나의 작업 단위 입니다.

모든 asynchronous code가 some task의 일부분으로 실행됩니다. 

async-let syntax 는 child task 를 생성합니다. 

너는 task group을 만들고 child tasks를 group에 추가할 수 있습니다. 

이는 priority, cancellation 관련해서 더 많은 컨트롤을 주며 동적 task 수를 생성할 수 있게 해줍니다.

 

task는 계층 구조로 정렬됩니다. task group의 각 task 는 동일한 parent task를 가지며, 각 task 에는 child tasks가 있을 수 있습니다.

task와 task group 간의 명시적인 관계 때문에, 이 접근을  structured concurrency 라고 부릅니다. 

너가 약간의 수정을 하더라도, task들 사이의 명시적인 parent-child 관계는 Swift가 propagating cancellation (취소 전파) 와 같은 일부 동작을 처리하고 compile time에 일부 에러를 감지할 수 있도록 해줍니다. 

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

 

task group에 대해 더 많은 정보를 얻으려면 TaskGroup 을 보세요

 

Unstructured Concurrency

위에서 본 structured concurrency 뿐만아니라 Swift는 unstructured concurrency도 제공합니다. 

task group의 일부인 task와 달리, unstructured task 는 parent task 가 없습니다. 

너는 unstructured tasks 를 관리하는데 유연성을 가지게 되지만 정확성에 대한 책임은 전적으로 너에게 있습니다. 

 

현재 actor에서 실행되는 unstructured task 를 만들려면   Task.init(priority:operation:) 이니셜라이저를 사용하세요.

현재 actor의 일부가 아닌 detached task 을 만드려면,  Task.detached(priority:operation:)  class method를 사용하세요.

 

이 두 작업은 모두 task와 interact 할 수 있는 task handle을 반환합니다. 예를들어 결과를 기다리거나 취소 하거나 할 수 있습니다.

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

 

detached tasks 를 매니징하는 것에 대해 더 많은 정보를 얻으려면 Task  를 보세요

 

Task Cancellation

Swift concurrency 는 협력적인  cancellation model 을 사용합니다. 각 task는 실행 중 적절한 시점에 취소되었는 지 확인하고  적절한 방법으로 취소에 응답합니다.  적절한 방법이란 일반적으로 다음 중 하나를 의미합니다.

 

- Throwing an error like CancellationError

- Returning nil or an empty collection

- Returning the partially completed work

 

취소여부를 확인하려면 Task.checkCancellation() 을 콜하세요. task가 취소되었을 때 CancellationError 을 throw 할 것입니다. 

또는 Task.isCancelled  을 통해 체크하고 자체 코드로 취소를 처리할 수 있습니다. 

예를들어 gallery에서 사진을 다운로드하는 task는 부분적으로 다운로드 된 것을 삭제하고 네트워크 연결을 닫아야할 수 있습니다.

 

취소를 수동으로 전파하려면  Task.cancel() 을 호출하세요.

 

[6] Actors

class와 같이 actor는 reference type 입니다. 그래서 Classes Are Reference Types  에 나오는 'comparison of value types and reference types'  가 class와 마찬가지로 actor 에도 적용됩니다. 

 

class 와 달리 actor는 오직 한번에 하나의 task가 mutable state에 접근할 수 있도록 허락하며,

이로 인해 multiple task가 같은 actor instance와 interact 하는 것이 안전합니다. 

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 는 label, measurements 와 같이 외부의 다른 코드가 access할 수 있는 프로퍼티를 가지고 있으며 내부에서만 update 가능한 max 라는 프로퍼티도 가지고 있습니다.

 

structure와 class 처럼 이니셜라이져를 통해 actor instance를 만들 수 있습니다.

actor의 프로퍼티나 메소드에 접근하려면 await 을 작성해서 potential suspension point 를 표시해줘야합니다.   

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

 

위의 예제에서 logger.max는  possible suspension point 입니다. 

왜냐하면 actor는 한번에 오직 하나의 task만 mutable state에 접근하도록 허용하기 때문에, 

만약 another task의 코드가 이미 logger와 interacting 하고 있다면, 현재 코드는 기다려야하기 때문입니다.

 

반대로 actor 의 일부인 코드가 actor의 프로퍼티에 접근할 때는 await 을 작성하지 않습니다. 

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

 

update(with:) 메소드는 이미 actor에서 돌아가고 있기 때문에, max 같은 프로퍼티에 접근할 때 await을 작성해줄 필요가 없습니다.

이 메소드는 또한 왜 한번에 하나의 task 만 actor의 mutable state와 interact 할 수 있는 지를 보여줍니다.

actor state에 대한 몇몇의 update는 일시적으로 불변성을 깨트리기 때문입니다. 

TemperatureLogger actor는 temperature 목록과 최대 temperature 를 추적하고 새로운 측정을 기록할 때 최대 temperature를 업데이트 합니다.

업데이트 도중, 새로운 측정을 추가한 후 최대값을 업데이트 하기 전에 TemperatureLogger 가 일시적으로  inconsistent 상태가 됩니다. 

multiple task가 동시에 같은 instance와 interacting 하는 것을 방지하면 아래와 같은 sequence of events 같은 문제를 방지할 수 있습니다.

 

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.

 

이 경우 2번을 보면 다른 곳에서 실행 중인 코드는 잘못된 정보를 얻게 되는데,

이는 데이터가 일시적으로 유효하지 않는 동안 actor 에 접근했기 때문입니다. 

 

actor를 사용하여 이런 문제를 예방할 수 있습니다.

actor는 한번에 하나의 상태에서만 작업을 허용하고 코드는 suspension point 를 await으로 표시한 곳에서만 중단될 수 있기 때문입니다. 

 

update(with:) 은 어떠한 suspension points를 가지고 있지 않기 때문에, update 중간에 다른 코드가 접근할 수 없습니다. 

 

actor 밖에서 이렇게 접근한다면 compile time 에러가 발생하게 됩니다.

print(logger.max)  // Error

 

await 없이 logger.max 에 접근하는 것은 실패합니다. actor의 프로퍼티들은 actor의 고립된 local state의 일부이기 때문입니다.

Swift는 actor 내부의 코드만이 actor의 local state에 접근할 수 있음을 보장합니다. 

이런 보장은 actor isolation 이라고 불립니다. 

 

 

 

 

 

 

반응형
댓글