[Swift] 동시성 - Concurrency

LeeEunJae·2023년 5월 1일
0

Swift

목록 보기
2/2

동시성 Concurrency

비동기 동작을 수행.
Swift 는 구조화된 방식으로 비동기(asynchronous)와 병렬(parallel) 코드 작성을 지원합니다
비동기 코드(Asynchronous code)는 일시적으로 중단되었다가 다시 실행할 수 있지만 한번에 프로그램의 한 부분만 실행
프로그램에서 코드를 일시 중단하고 다시 실행하면 UI 업데이트와 같은 짧은 작업을 계속 진행하면서 네트워크를 통해 데이터를 가져오거나 파일을 분석하는 것과 같은 긴 실행 작업을 계속할 수 있습니다
병렬 코드(Parallel code)는 동시에 코드의 여러부분이 실행되는 것
예를들어 4코어 프로세서의 컴퓨터는 각 코어가 하나의 작업을 수행하므로 코드의 4부분을 동시에 실행할 수 있습니다

비동기 함수 정의와 호출

비동기 함수(asynchronous function) 또는 비동기 메서드(asynchronous method)는 실행 도중에 일시적으로 중단될 수 있는 특수한 함수 또는 메서드
동기 함수(synchronous function), 동기 메서드(synchronous method) : 완료될 때까지 실행되거나 오류가 발생하거나 반환되지 않는 일반적인 함수 또는 메서드 와 대조

함수 또는 메서드가 비동기임을 나타내려면 던지는 함수(throwing function) 를 표현하기 위해 throws 키워드를 사용하는 것과 유사하게 파라미터 뒤의 선언에 async 키워드를 작성합니다. 함수 또는 메서드가 값을 반환한다면 반환 화살표(->)전에 async 를 작성

// for 구문이 시간이 오래 걸리는 작업이라고 가정(ex. 네트워크 통신) 하고, getNumbers(Int)라는 비동기 함수는 다음과 같이 정의합니다.
func getNumbers(count: Int) async -> [Int] {
    var result = [Int]()
    for i in 1...count{
        result.append(i)
        try! await Task.sleep(nanoseconds: 10_000_000) // 0.01초 지연
    }
    result.shuffle()
    return result
}

비동기와 던지기 둘 다인 함수 또는 메서드는 throws 전에 async 를 작성합니다.

비동기 메서드를 호출할 때 해당 메서드가 반환될 때까지 실행이 일시 중단 됩니다.
중단될 가능성이 있는 지점을 표시하기 위해 호출 앞에 await 를 작성합니다.
이것은 에러가 있는 경우 프로그램의 흐름을 변경 가능함을 나타내기 위해 에러를 전파하는 함수를 호출 할 때, try를 작성하는 것과 같습니다.
비동기 메서드 내에서 실행흐름은 다른 비동기 메서드를 호출할 때만 일시 중단됩니다.
중단은 암시적이거나 선점적이지 않습니다.
이것은 가능한 모든 중단 지점이 await로 표시된다는 의미 입니다.

예를 들어 다음 코드는 모든 숫자를 가져온 후 첫번째 숫자를 가져옵니다.
숫자를 가져오는 작업을 임의로 지연시켜 오래 걸리는 작업이라 가정했습니다.

func whatIsNumber(_ n: Int) async -> String {
   try! await Task.sleep(nanoseconds: 1_000_000_000) // 1초 지연
   return "이 숫자는 \(n) 입니다."
}
// getNumbers 함수가 반환할 때까지 실행을 일시 중단
// 이 코드의 실행이 일시 중단되는 동안 같은 프로그램의 다른 동시 코드가 실행 됩니다.
let numbers = await getNumbers(count: 100)
// 일반적인 동기 코드는 위에서 아래로 순차적으로 실행됩니다.
// getNumbers 가 반환해서 numbers 변수에 할당하고 출력
print(numbers)
let sortedNumbers = numbers.sorted()
let firstNumber = sortedNumbers[0]
// whatIsNumber 함수가 반환할 때까지 실행을 다시 일시 중단, 다른 동시 코드에 실행할 기회 제공
let numberDescription = await whatIsNumber(firstNumber)
print(numberDescription)
/*
[90, 18, 63, 4, 81, 77, 82, 45, 39, 93, 34, 22, 17, 21, 100, 8, 26, 32, 54, 7, 52, 47, 68, 67, 38, 71, 3, 23, 69, 83, 41, 87, 72, 70, 86, 58, 15, 10, 88, 66, 92, 9, 5, 44, 29, 1, 85, 51, 16, 78, 25, 97, 31, 62, 65, 43, 36, 40, 50, 64, 61, 49, 27, 35, 89, 56, 76, 95, 13, 2, 60, 14, 74, 96, 28, 84, 30, 33, 24, 73, 19, 98, 55, 53, 59, 42, 99, 11, 91, 6, 37, 94, 80, 20, 79, 48, 57, 46, 75, 12]
이 숫자는 1 입니다.
*/

getNumbers()와 whatIsNumber() 함수 모두 오래걸리는 작업(네트워크 요청)이기 때문에 완료하는데 오랜시간이 걸릴 수 있습니다.
반환 화살표 전에 async 를 작성하여 둘 다 비동기 함수로 만들면 이 코드는 숫자가 준비될 때까지 기다리는 동안 앱의 나머지 코드가 계속 실행될 수 있습니다.

await 로 표시된 코드의 중단이 가능한 지점은 비동기 함수 또는 메서드가 반환되기를 기다리는 동안 현재 코드 부분이 실행을 일시적으로 중단할 수 있음을 나타냅니다
Swift가 현재 쓰레드에서 코드의 실행을 일시 중단하고 대신 해당 쓰레드에서 다른 코드를 실행하기 때문에 이것을 쓰레드 양보(yeilding the thread)라고 표현

await 가 있는 코드는 실행을 일시 중단할 수 있어야 하므로 프로그램의 특정 위치에서만 비동기 함수 또는 메서드를 호출 할 수 있습니다 :

  • 비동기 함수, 메서드 또는 프로퍼티의 본문에 있는 코드
  • @main 으로 표시된 구조체, 클래스, 또는 열거형의 정적 main() 메서드에 있는 코드
  • 구조화되지 않은 하위 작업의 코드

비동기 시퀀스(Asynchronous Sequences)

위에서 비동기적으로 모든 배열의 요소가 준비된 후에 전체 배열을 한번에 반환합니다
또 다른 접근 방식은 비동기 시퀀스(Asynchronous Sequences) 를 사용하여 한번에 콜렉션의 한 요소를 기다리는 것입니다.

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

일반적인 for-in 루프 대신에 위 코드는 for 다음에 await 을 작성함 비동기 함수 또는 메서드 호출할 때와 마찬가지로 await 작성은 가능한 중단 지점을 나타냅니다 for - await - in 루프는 다음 요소를 사용할 수 있을 때까지 기다리고 각 반복이 시작될 때 잠재적으로 실행을 일시 중단합니다

비동기 함수 병렬 호출

await 를 사용하여 비동기 함수를 호출하면 한번에 코드의 한 부분만 실행됩니다
비동기 코드가 실행되는 동안 호출자는 코드의 다음 라인을 실행하기 위해 이동하기 전에 해당 코드가 완료될 때까지 기다립니다
예를 들어 다음과 같이 세개의 숫자를 가져오려면 다음과 같이 whatIsNumber() 함수에 대한 세번의 호출을 기다릴 수 있습니다.

 let number1 = await whatIsNumber(numbers[0])
 print("number1 : \(number1)")
 let number2 = await whatIsNumber(numbers[1])
 print("number2 : \(number2)")
 let number3 = await whatIsNumber(numbers[2])
 print("number3 : \(number3)")

 let numberStore = [number1, number2, number3]
 print("numberStore : \(numberStore)")

하지만 이 방식은 비효율적입니다.
세개의 숫자를 동시에 가져와도 문제가 되지 않지만, 위 코드에서는 한번에 하나씩 가져오고 있습니다.
이런 작업은 기다릴 필요 없이 동시에 가져오도록 처리하면 되겠죠.

비동기 함수를 호출하고 주변의 코드와 병렬로 실행하려면 상수를 정의할 때 let 앞에 async 를 작성하고, 상수를 사용할 때마다 await 을 작성합니다

async let number1 = whatIsNumber(numbers[0])
async let number2 = whatIsNumber(numbers[1])
async let number3 = whatIsNumber(numbers[2])

let numberStore = await [number1, number2, number3]
print("numberStore : \(numberStore)")

위 코드에서 whatIsNumber() 을 호출하는 세가지는 모두 이전 호출이 완료될 때까지 기다리지 않고 실행됩니다.
사용할 수 있는 시스템 자원이 충분하다면 동시에 실행할 수 있습니다.
코드가 함수의 결과를 기다리기 위해 일시 중단되지 않기 때문에 이러한 함수 호출 중 어느 것도 await 로 표시하지 않습니다.
대신 numberStore에 number1, number2, number3 가 포함된 배열을 할당하기 위해서는 세 개의 숫자가 모두 리턴될 때까지 실행을 일시 중단해야 하므로 await 를 작성합니다.

작업과 작업 그룹

작업(Task) 은 프로그램의 일부로 비동기적으로 실행할 수 있는 작업 단위입니다.
모든 비동기 코드는 어떠한 작업의 일부로 실행됩니다. 위에서 설명한 async - let 구문은 하위 작업을 생성합니다.
작업 그룹(task group)을 생성하고 해당 그룹에 하위 작업을 추가할 수도 있습니다.
그러면 우선순위와 최소를 더 잘 제어할 수 있으며 동적으로 작업의 수를 생성할 수 있습니다.

작업은 계층 구조로 정렬됩니다. 작업 그룹의 각 작업에는 동일한 상위 작업이 있으며 각 작업에는 하위 작업이 있을 수도 있습니다.
작업과 작업 그룹 간의 명시적 관계 때문에 이 접근 방식을 구조적 동시성(structured concurrency) 이라고 합니다.
정확성에 대한 일부 책임을 지고 있지만 작업 간의 명시적 부모(parent) - 자식(child) 관계를 통해 Swift 는 취소 전파(propagating cancellation)와 같은 일부 동작을 처리할 수 있고 Swift 는 컴파일 시간에 일부 오류를 감지할 수 있습니다.

await withTaskGroup(of: String.self, body: { taskGroup in
   let numbers = await getNumbers(count: 100)
   for number in numbers {
       taskGroup.addTask { await whatIsNumber(number) }
   }
})

구조화되지 않은 동시성

위에서 설명한 동시성에 대한 구조화된 접근 방식 외에도 Swift는 구조화 되지 않은 동시성(unstructured concurrency) 을 지원합니다.
작업 그룹의 일부인 작업과 달리 구조화되지 않은 작업에는 상위 작업이 없습니다. 프로그램이 필요로하는 방식으로 구조화되지 않은 작업을 관리할 수 있는 완전한 유연성이 있지만, 정확성에 대한 완전한 책임도 있습니다.
현재 액터(actor)에서 실행되는 구조화되지 않은 작업을 생성하려면 Task.init(priority:operation:) 초기화 구문을 호출해야 합니다.
더 구체적으로 분리된 작업으로 알려진 현재 액터의 일부가 아닌 구조화되지 않은 작업을 생성하려면 Task.detached(priority:operation:) 클래스 메서드를 호출합니다.
이 모든 동작은 서로 상호작용 할 수 있는 작업(task)을 반환합니다. - 예를 들어 결과를 기다리거나 취소하는 경우가 해당

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

액터 (Actors)

프로그램을 동시성 조각으로 분리하기 위해 작업(task) 을 사용할 수 있습니다. 작업은 서로 분리되어 있어 같은 시간에 안전하게 실행될 수 있지만 작업 간에 일부 정보를 공유해야 할 수도 있습니다. 액터는 동시성 코드간에 정보를 안전하게 공유할 수 있게 해줍니다.

클래스와 마찬가지로 액터는 참조 타입이므로 값 타입과 참조 타입의 비교는 클래스 뿐만 아니라 액터에도 적용됩니다.
클래스와 다르게 액터는 한 번에 하나의 작업만 변경 가능한 상태에 접근할 수 있도록 허용하므로 여러 작업의 코드가 액터의 동일한 인스턴스와 상호작용 하는 것은 안전합니다. 예를 들어 다음은 온도를 기록하는 액터 입니다.

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 키워드를 사용하여 액터를 도입하고 중괄호로 정의합니다. TemperatureLogger 액터는 액터 외부의 다른 코드가 접근 할 수 있는 프로퍼티가 있으며 액터 내부의 코드만 최대값을 업데이트 할 수 있게 max 프로퍼티를 제한합니다.

구조체와 클래스와 같은 초기화 구문으로 액터의 인스턴스를 생성합니다.
액터의 프로퍼티 또는 메서드에 접근할 때 일시 중단 지점을 나타내기 위해 await 을 사용합니다.

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

위 코드에서 logger.max 에 접근하는 것은 일시 중단 지점으로 가능합니다 .
액터는 한 번에 하나의 작업만 변경 가능한 상태에 접근할 수 있도록 허용하므로 다른 작업의 코드가 이미 로거와 상호작용하고 있는 경우 이 코드는 프로퍼티 접근을 기다리는 동안 일시 중단됩니다.

대조적으로 액터의 내부 코드는 행위자의 프로퍼티에 접근할 때 await를 작성하지 않습니다.
예를 들어 새로운 온도로 TemperatureLogger 를 업데이트 하는 메서드 입니다.

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

update 메서드는 액터에서 이미 실행 중 이므로 max 와 같은 프로퍼티에 대한 접근을 await로 표시하지 않습니다.
이 메서드는 액터가 변경 가능한 상태와 상호작용하기 위해 한 번에 하나의 작업만 허용하는 이유 중 하나를 보여줍니다.
액터의 상태에 대한 일부 업데이트는 일시적으로 불변성을 깨뜨립니다.

TemperatureLogger 액터는 온도 리스트와 최대 온도를 추적하고 새로운 측정값을 기록할 때 최대 온도를 업데이트 합니다
업데이트 도중에 새로운 측정값을 추가한 후 max 를 업데이트 하기 전에 온도 로거는 일시적으로 일치하지 않는 상태가 됩니다.
(measurements 상에는 새로운 measurement가 추가되었는데 max값은 업데이트가 되지 않은 상태)

Swift 액터는 한 번에 해당 상태에 대해 하나의 작업만 허용하고 해당 코드는 await 가 일시 중단 지점으로 표시되는 위치에서만 중단 될 수 있기 때문에 이러한 문제를 방지할 수 있습니다.

클래스의 인스턴스와 같이 액터의 외부에서 프로퍼티에 접근하려고 하면 컴파일 에러가 발생합니다.

print(logger.max) // Error

await 작성 없이 logger.max 에 접근하는 것은 액터의 프로퍼티가 해당 액터의 분리된 로컬 상태의 부분이기 때문에 실패합니다.
Swift 는 액터 내부의 코드만 액터의 로컬 상태에 접근할 수 있도록 보장합니다. 이 보장을 액터 분리(actor isolation) 이라고 합니다.

전송 가능 타입(Sendable Types)

작업(Tasks) 과 액터(Actors) 는 프로그램을 동시에(Concurrently) 안전하게 실행할 수 있는 조각으로 나눌 수 있습니다.
작업 또는 액터의 인스턴스 내에서 변수와 프로퍼티 같은 변경 가능한 상태를 포함하는 프로그램의 일부분을 동시성 도메인(concurrency domain)이라고 부릅니다.
어떤 데이터는 데이터가 변경 가능한 상태를 포함하지만 동시 접근에 대해 보호되지 않으므로 동시성 도메인 간에 공유될 수 없습니다.

한 동시성 도메인에서 다른 동시성 도메인으로 공유될 수 있는 타입을 전송 가능 타입(Sendable Type) 이라고 합니다.
예를 들어, 액터 메서드로 호출될 때 인수로 전달되거나 작업의 결과로 반환될 수 있습니다.
위 예제들은 동시성 도메인 간에 전달되는 데이터는 항상 안전한 간단한 값 타입을 사용하기 때문에 전송 가능성에 대해 논의하지 않았습니다.
반대로 일부 타입은 동시성 도메인 간에 전달하는데 있어서 안전하지 않습니다.
예를 들어, 변경 가능한 프로퍼티를 포함하고 해당 프로퍼티에 순차적으로 접근하지 않는 클래스는 서로 다른 작업 클래스의 인스턴스에 전달될 때 예상할 수 없고 잘못된 결과를 생성할 수 있습니다

Sendable 프로토콜을 선언하여 전송 가능한 타입으로 표시합니다. 이 프로토콜은 어떠한 코드 요구사항을 가지지 않지만 Swift가 적용하는 의미론적 요구사항이 있습니다.
일반적으로 타입을 Sendable로 나타내기 위한 세가지 방법이 있습니다.

  1. 타입은 값 타입이고 변경 가능한 상태는 다른 전송 가능한 데이터로 구성됩니다. - 예를 들어, 전송 가능한 Stored Property 가 있는 구조체 또는 전송 가능한 연관된 값이 있는 열거형이 있습니다.
  2. 타입은 변경 가능한 상태가 없으며 변경 불가능한 상태는 다른 전송 가능한 데이터로 구성됩니다. - 예를 들어, Read-Only Property 만 있는 구조체 또는 클래스
  3. 타입은 @MainActor 로 표시된 클래스나 특정 쓰레드나 큐에서 프로퍼티에 순차적으로 접근하는 클래스와 같이 변경 가능한 상태의 안정성을 보장하는 코드를 가지고 있습니다.
profile
매일 조금씩이라도 성장하자

0개의 댓글