Concurrency

Swift는 Asynchronous하고 Parallel하게 코드를 작성할 수 있는 기능이 내장된 언어이다.

프로그램은 한 번에 하나의 동작을 하지만, Asynchronous한 코드는 잠깐 중단되었다가 나중에 실행될 수 있는 코드이다. Asynchronous한 코드는 네트워크를 통해 데이터를 가져오는 작업을 실행하는 와중에 UI 업데이트와 같은 작업들을 계속해서 실행할 수 있도록 한다.

Parallel한 코드는 동시에 여러 작업을 할 수 있는 코드이다. 예를 들어, 4개의 코어가 있는 컴퓨터는 각각의 코어가 하나의 작업을 수행하여 동시에 4개의 작업을 할 수 있다. Asynchronous하고 parallel한 코드는 한 번에 여러 작업을 실행할 수 있으며, Memory-safe하게 코드를 작성할 수 있다.

처음에는 Asynchronous와 Synchronous, Serial과 Parallel의 개념의 혼동이 있었는데, 쉽게 말해서 작업을 실행시키고 그 작업이 끝날 때까지 기다리는 것이 Synchronous, 기다리지 않고 다른 작업을 하는 것이 Asynchronous이며, 한 번에 여러 작업을 하는 것은 Parallel, 한 번에 하나의 작업을 하는 것이 Serial이다.

느리거나 버그가 많은 코드에 동시성을 추가하는 것은 그 코드가 빨라지거나 정확해지는 것을 보장하는 것은 아니다. 사실 동시성을 추가하면 디버깅이 더 힘들어 질 수 있다. 하지만 동시성을 위해 Swift의 언어 레벨의 지원을 사용하는 것은 Swift가 컴파일 타임에 여러 문제들을 발견하는 데 도움을 줄 수 있다는 것을 의미한다.

Swift가 지원해주는 concurrent를 사용하지 않고 concurrent한 코드를 작성할 수는 있지만, 아마 읽기가 굉장히 어려울 것이다.
아래의 코드는 사진 이름의 리스트를 다운받고, 리스트의 첫 번째 사진을 다운로드 받아 사용자에게 보여주는 코드이다.

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

이런 간단한 케이스에서도 코드는 completion handler를 여러 개 작성해야하기 때문에 계속해서 클로져가 중첩될 것이다. 이와 같이 계속해서 중첩이 된다면 코드는 더 복잡해질 것이다.


Asynchronous 함수를 작성해보자

Asynchronous 함수는 실행 중에 중단할 수 있는 함수이다. 따라서 Asynchronous 함수의 내부에 어느 작업을 중단할 수 있는지 표시해야한다.
함수가 Asynchronous 하다는 것을 나타내기 위해 throws 키워드와 비슷하게 함수의 파라미터 뒤에 async 키워드를 작성한다. 만약 함수의 리턴 값이 존재한다면 return 타입 앞에 작성한다.

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

만약 함수가 에러도 발생시키고, Asynchronous 함수임을 나타내려면 throws 전에 async를 작성한다.


Asynchronous 함수를 호출해보자

Asynchronous 함수를 호출할 때, 그 함수가 리턴될 때까지 실행을 중단한다. await 키워드를 코드의 앞에 작성하여 "이 코드는 중단할 수 있는 작업이다" 라는 것을 표시한다. 앞서 포스팅했던 Error Handling에서 에러를 발생시킬 수 있는 함수에서 try 키워드를 앞에 작성했던 것과 매우 유사하다.

Asynchronous 함수 내에서의 실행 흐름은 다른 Asynchronous 함수를 호출했을 때에만 중단된다. 쉽게 말해 실행이 중단될 수 있는 모든 위치에 await 를 작성해야한다는 의미이다.

아래의 코드는 갤러리에 있는 모든 사진들의 이름을 가져오고, 첫 번째 그림을 보여주는 코드이다.

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:) 함수는 네트워크 작업이 필요하기 때문에 완료되기까지 상대적으로 더 많은 시간이 필요하다. Asynchronous하게 처리하면 사진이 준비될 때까지 나머지의 코드는 계속해서 실행될 것이다. 위 코드의 실행 순서는 다음과 같다.

  1. 코드는 첫 번째 줄부터 시작하여 처음 await 키워드가 나올 때까지 실행된다. listPhotos(inGallery:) 함수가 실행되고 이 함수가 리턴될 때까지 기다린다(== 중단한다).
  2. 작업이 중단되는 동안, 다른 동시성 코드(예를 들어 새로운 사진 갤러리의 리스트를 업데이트하는 백그라운드 작업)가 실행된다. 그 코드 또한 다음의 await까지 실행될 것이다.
  3. listPhotos(inGallery:) 함수가 작업을 마치고, photoNames 상수에 리턴값이 할당된다.
  4. sortedNamesname 는 일반 코드와 같이 동작한다.
  5. 다음 await 키워드가 작성된 부분은 downloadPhoto(named:) 함수이다. 이 함수 역시 함수가 완료될 때까지 실행을 중단할 것이고, 다른 동시성 코드가 실행될 것이다.
  6. downloadPhoto(named:) 함수가 완료된 후 photo 상수에 리턴값이 할당되고, 그 후에 show(_:) 함수의 인자로서 전달된다.

await 키워드가 명시된 코드는 Swift가 현재 쓰레드에서 코드의 작업을 중단하고, 다른 코드를 실행하기 때문에 "yielding the thread" 라고 불린다.

Asynchronous 함수를 호출할 수 있는 특정 위치에만 작성할 수 있다.

  • Asynchronous 함수, 메소드나 프로퍼티의 내부
  • @main으로 명시된 class, struct, enum"static main()" 메소드
  • "Unstructured Concurrency" 와 같은 구조화되지 않은 하위의 작업

Asynchronous Sequences란 무엇인가?

앞서 예시를 들었던 listPhotos(inGallery:) 함수는 배열의 요소들이 준비가 되면 비동기적으로 배열을 리턴한다. 이와는 조금 다르게 Asynchronous sequence를 사용하여 한 번에 하나의 요소를 기다리는 것이 있다. 반복을 통한 Asynchronous sequence의 예시는 다음과 같이 나타낼 수 있다.

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

일반적인 for-in 반복문 대신에 for 뒤에 await를 작성한다. for-await-in 반복문은 각 반복이 시작될 때, 다음 요소를 사용할 수 있을 때까지 작업을 중단한다.
Sequence 프로토콜을 준수함으로써 커스텀 타입을 for-in 반복문에서 사용할 수 있는 것처럼, AsyncSequence 프로토콜을 준수하여 커스텀 타입을 for-await-in 반복문에서 사용할 수 있다.


Parallel하게 Asynchronous 함수를 호출해보자

Asynchronous 함수는 한 번에 하나의 작업을 실행할 수 있다. Asynchronous 작업이 실행되는 동안 함수를 호출한 친구는 다음 줄의 코드를 실행하기 전에 그 코드가 완료될 때까지 기다린다.
이는 아래의 코드와 같이 작성될 수 있다. 갤러리에서 3장의 사진을 가져오려면, downloadPhoto(named:) 함수를 3번 기다려야한다.

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)

이런 식으로 코드를 작성해도 괜찮아보이지만 전혀 그렇지 않다.
여기에 힌트가 담겨있다.

"갤러리에서 3장의 사진을 가져오려면, downloadPhoto(named:) 함수를 3번 기다려야한다."

사진 다운로드가 Asynchronous하게 진행되고, 동시에 다른 작업이 실행될 수 있지만, downloadPhoto(named:) 함수는 한 번에 하나씩만 실행할 수 있다는 것(=="함수를 3번 기다려야한다")이 좀 거슬린다.

각 사진은 다음 사진이 다운로드 되기 전에 다운로드가 완료된다. 하지만 그렇다고 각 사진이 다운로드 되는 동안 다음 사진의 다운로드가 이를 기다릴 필요는 없다. 각 사진의 다운로드는 독립적이기 때문이다.

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:) 함수의 호출은 각각 독립적으로 실행된다. 만약 시스템의 자원이 충분하다면 3개의 함수 동작은 동시에 실행될 수 있다. 각각의 코드는 이전 함수의 동작의 결과를 기다리지 않기 때문에(독립적이기 때문에) 각각의 함수 앞에 await 키워드가 작성되지 않았다. 대신에, 마지막에 모든 사진을 받아올 때까지 기다려야하기 때문에 await 키워드를 작성해야한다.

  • 이전의 함수의 결과에 연관이 있다면, 다시 말해 독립적이지 않다면, await 키워드로 Asynchronous 함수를 호출한다. 이것은 결과적으로 순차적인 작업을 이끌어낸다.
  • 이전의 함수의 결과와 상관없을 경우 async-let 키워드로 Asynchronous 함수를 호출한다. 이것은 위와 반대로 병렬적인 작업을 이끌어낸다.
  • awaitasynce-let 모두 작업을 중단하고 다른 작업을 할 수 있도록 한다.
  • 또한 두 가지 방법을 섞어서 사용할 수도 있다.

Tasks와 Task Groups은 무엇인가?

  • task는 Asynchronous하게 동작될 수 있는 작업 단위이다.
  • 모든 `Asynchronous 작업은 task의 일부로 실행된다.
  • async-let 구문은 자식 task를 생성할 수 있도록 한다.
  • 또한 task group을 만들 수 있고, 자식 task들을 해당 그룹에 추가하여 task들을 우선순위 별로 제어할 수 있다.
  • task는 계층적으로 정렬된다.
  • task group의 task들은 같은 부모 task를 가지고 있고, 자식 task를 가지고 있을 수 있다. task와 task group 간의 관계 때문에 "structured concurrency" 라고 불린다.
  • task들 사이의 부모-자식 관계는 Swift가 특정 동작을 처리해줄 수 있도록 하고, 컴파일 타임에 에러를 발견할 수 있도록 한다.
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

앞서 언급한 동시성에 대한 구조화된 접근 외에도, Swift는 "Unstructured Concurrency" 를 지원한다. task group의 부분인 task와 달리, unstructured task는 부모 task를 가지고 있지 않다. unstructured task를 관리함에 있어서 유연성을 갖췄지만, Swift의 도움 없이 모든 책임을 져야한다.

unstructured task를 만들려면 Task.init(priority:operation:) 이니셜라이저 또는 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

Actor

actorclass 와 마찬가지로 참조타입이다. class 와 달리 actor한 번에 하나의 mutable한 상태의 작업에 접근할 수 있어서 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
    }
}

actorclass, struct 그리고 enum과 선언하는 방식이 비슷하다. TemperatureLogger 는 외부에서 접근할 수 있는 프로퍼티들을 가지고 있고, max 프로퍼티는 외부에서 set 할 수 없도록 선언되어 있다.

class, struct 와 동일하게 이니셜라이저 구문을 사용하여 actor 의 인스턴스를 생성할 수 있다. 하지만 프로퍼티나 메소드에 동일하게 접근한다면 컴파일 에러가 발생할 것이다.

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

그럼 Actor에 어떻게 접근하지?

actor 의 프로퍼티나 메소드에 접근할 때, await 키워드를 사용하여 접근해야한다.

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

actor 의 프로퍼티는 actor의 "isolated local state"의 일부이기 때문에 actorawait 키워드 없이 접근하는 것은 에러가 발생한다.(사실 뭔 개소리인지 모르겠다.) Swift는 actor 의 내부 코드만이 actor의 local state에 접근할 수 있도록 보장하는데 이를 actor isolation 이라고 한다.

actor 는 한 번에 하나의 mutable한 상태의 작업에 접근할 수 있으므로, 외부의 다른 작업이 먼저 logger 와 상호작용하고 있다면 그 상호작용이 끝날 때까지 기다려야하므로 await 키워드를 사용해야한다.
그렇다고 actor 자체가 자신의 내부 프로퍼티에 접근할 때 await 키워드를 작성하는 것은 아니다.

아래의 코드는 TemperatureLogger 를 새로운 온도로 업데이트하는 함수이다.

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

update(with:) 함수는 왜 actor 는 한 번에 하나의 작업에만 접근하여 상호작용할 수 있는지에 대한 이유를 보여준다.

actor 의 상태를 업데이트하는 것은 불변성을 깨트리기 때문이다.

TemperatureLoggermaxmeasurements 를 추적하면서 새로운 measurement 가 append 되었을 때, max 값을 업데이트한다. 만약 max 값을 업데이트 도중의 아주 찰나의 시간에 새로운 measurement 값이 추가된다면 TemperatureLogger 는 일관성이 없는 상태가 되버린다.

여러 작업이 같은 인스턴스에 접근하여 상호작용하는 것을 막는다면 아래와 같은 문제를 방지할 수 있다.

  1. update(with:) 함수를 호출하여 measurements 배열을 업데이트한다.
  2. 1번에서 measurements 배열을 업데이트하려는 찰나에 (max 값을 업데이트하기 전에) 외부에서 max, measurements 값을 읽는다.
  3. max 값을 업데이트한다.

이 경우, 외부에서 2번처럼 접근한다면 잘못된 데이터를 읽게 된다. 그 잠깐의 찰나의 데이터가 잘못되었을 때 접근했기 때문이다.

actor 는 한 번에 하나의 상태에 대한 작업에 접근하는 것을 허용하기 때문에, 그리고 await 키워드가 있는 라인은 실행을 중단할 수 있기때문에 이 문제를 방지할 수 있다.

profile
iOS 개발자가 되고싶어요

0개의 댓글