-
Swift Concurrency - Actors공부 2022. 12. 6. 05:31
Overview
동시성 프로그래밍은 어떤 코드들이 동시에 실행되는 것을 이야기합니다. 이는 CPU코어의 갯수와 같이 물리적으로 프로그램을 동시에 실행하는 개념인 Parallelism(병렬성)과는 맥락을 달리 합니다.
많은 프로그래밍 언어에서는 각자의 방법으로 이 동시성 프로그래밍을 직관적으로 할 수 있도록 돕고 있습니다. Swift 에서는 기존에 GCD라는 개념으로 동시성 프로그래밍에 대한 도구를 제공 해왔습니다. 오늘의 토픽은 아니지만 GCD역시 여전히 중요한 테크닉이기 때문에 꼭 따로 공부하셨으면 좋겠습니다. 이 글에서는 새로 발표된 Swift의 Concurrency 기술들을 가볍게 훑어보고 어떤 개념들을 앞으로 공부해야 하는지 알아보는 시간이 되었으면 좋겠습니다.
이 글의 내용은 [WWDC2021] Protect mutable state with Swift actors의 내용을 기반으로 정리한 것입니다. 본문을 훑어보신 뒤에 이 영상도 꼭 한번 보시길 바랍니다 🙂
Data race
동시성 프로그래밍을 어렵게 만드는 주요한 원인 중 하나는 Data race입니다.
Data race는
- 여러개의 쓰레드에서 같은 데이터에 접근하면서
- 그 중 하나 이상의 쓰레드에서 쓰기 작업이 있는 경우
발생하게 됩니다. 이 현상은 자주 발생 가능한 것에 비해 디버그 하기가 굉장히 까다롭기 때문에 특히 주의해야 합니다.
Data race의 예시로, 위의 코드에서
print(counter.increment())
의 출력값을 보장할 수 없습니다. Counter는 클래스로 선언되어 있는 reference type이기 때문에 서로 다른 스레드에서 코드가 실행되지만 같은 메모리에 접근하게 되고, 이로 인해서 코드만 보고서는 어떤 print문에서 어떤 값이 출력될지 보장할 수 없는 상태가 되어버립니다(심지어 두 print문에서 모두 1 혹은 2가 출력되는 것도 가능합니다). 그래서 Swift 에서는 되도록 Struct와 같은 value type을 사용해서 Data race를 방지하도록 권장하고 있습니다.하지만 우리가 코드를 작성하면서 reference type이 꼭 필요한 경우도 적지 않습니다. 이러한 경우 수정가능한, 그리고 공유될 수 있는 상태값(shared mutable state)을 가져야 합니다. 그리고 이 shared mutable state가 안전하게 실행되기 위해서는 동기화 작업이 필수적으로 필요하고 기존에 존재하는 방법으로 Atomics, Locks, Serial dispatch queues 등의 기술들로 이 문제를 해결해왔습니다. 하지만 이러한 방법들 역시 세심하게 코드를 작성하고 설계하지 않으면 data race를 피할 수 없었습니다.
그래서 Swift 에서는 Actor라는 새로운 개념을 도입해서 이 동기화 기능을 언어 레벨에서 지원할 수 있게 도와줍니다.
Actors
Actor의 주요 특징을 먼저 정리하자면
- Actor는 shared mutable state에 대한 동기화 기능을 제공합니다
- Class, Struct, Enum 등과 같은 하나의 Type으로 사용합니다
- Swift의 다른 타입들과 마찬가지로
- property, method, initializers, subscripts 를 가질 수 있고
- protocol, extension 역시 사용 가능합니다
- 하지만 상속은 지원하지 않습니다
- Actor는 내부의 상태값(state)들을 외부의 다른 프로그램으로부터 격리(isolate)시킵니다
- 상태값에 대한 모든 접근은 actor를 통해서만 할 수 있습니다
- Actor는 상태값에 대한 상호배타적인 접근을 보장합니다
Actor types
Actor는 새로운 타입의 형태로 Swift에서 구현되어 있습니다. Shared mutable state를 관리하기 때문에 당연히 reference type입니다.
actor Counter { var value = 0 func increment() -> Int { value = value + 1 return value } } let counter = Counter() Task.detached { print(await counter.increment()) // (1) } Task.detached { print(await counter.increment()) // (2) }
위의 코드와 같이 actor를 활용해서 구현한 increment함수는 외부에서 사용할 때 비동기함수로 사용되어야 합니다. (1)과 (2)중에서 어떤 코드가 먼저 실행될지는 알 수 없으나, 기존 Class를 사용할 때와 비교해서 확실히 달라지는 것은 (1), (2)중에 나중에 실행되는 코드는 먼저 실행된 코드가 완전히 실행될때 까지 기다렸다가 실행을 한다는 것입니다. 이러한 방법으로 Counter는 내부 상태값인 value에 대한 보호를 받고 data race를 예방할 수 있게 됩니다.
extension Counter { func resetSlowly(to newValue: Int) { value = 0 for _ in 0 ..<newValue { increment() // (1) } assert(value == newValue) } }
또한 위의 코드 (1)과 같이 Actor 내부에서는 함수들을 서로 동기적으로 호출할 수 있습니다.
Actor Isolation
위에서 간단하게 살펴본 코드처럼, Actor isolation은 Actor가 자신의 mutable state를 보호하는 방법입니다.
아래과 같이 정의된 BankAcount Actor가 있습니다.
actor BankAccount { let accountNumber: Int var balance: Double init(accountNumber: Int, initialDeposit: Double) { self.accountNumber = accountNumber self.balance = initialDeposit } } extension BankAccount { enum BankError: Error { case insufficientFunds } func transfer(amount: Double, to other: BankAccount) throws { if amount > balance { throw BankError.insufficientFunds } print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)") balance = balance - amount other.balance = other.balance + amount // error: actor-isolated property 'balance' can only be referenced on 'self' } }
위의 코드 중
func transfer(amount: to other: )
은 아래와 같은 에러를 생성합니다에러를 보면 balance라를 프로퍼티가 Actor-isolated 상태라고 합니다.
실제로 Actor 내부의
- instance method
- instance subscripts
- stored / computed property
는 모두 actor-isolated 상태로 만들어집니다. actor-isolated 상태에서는 격리되어 있는 특정 액터 안에서만 직접 액세스 할 수 있으며 이 경우에는 해당
BankAccount
인스턴스인self
에서만 접근이 가능합니다. 이 코드의 경우 자신이 아닌 다른 인스턴스에서balance
프로퍼티에 접근을 하기 때문에 오류가 발생하게 됩니다.Cross-actor reference
Actor의 외부에서 actor-isolated로 정의된 사항들에 대해서 접근 하는것을 cross-actor reference라고 부릅니다. actor에서는 2가지 방법중에 하나로만 허용됩니다.
1. 불변상태에 대한 접근
let
등의 Immutable State(불변 상태)에 대한 접근 : 불변값에 대해서는 data race가 발생하지 않기 때문에 접근에 제한을 두지 않습니다.- 상기 코드에서의 예시
- 상기한 코드
print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
에서other.accountNumber
접근을 하지만 해당 프로퍼티가 let으로 선언된 불변 값이기 때문에 접근할 수 있습니다.
2. 비동기 함수 형식으로 접근
actor-isolated 정의들에 대해서 비동기 함수를 통해서 접근하는 경우에도 corss-actor reference를 허용받을 수 있습니다.
BankAccount
의 예시에서transfer
함수를 수정하고 새로운 메소드deposite
을 추가 함으로써 이를 구현할 수 있습니다.extension BankAccount { enum BankError: Error { case insufficientFunds } func transfer(amount: Double, to other: BankAccount) async throws { if amount > balance { throw BankError.insufficientFunds } print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)") balance = balance - amount await other.deposite(amount: amount) } } extension BankAccount { func deposite(amount: Double) async { assert(amount >= 0) balance += amount } }
Actor에서 비동기 함수 호출은 해당 Actor가 안전하게 수행할 수 있을 때 실행하도록
message
형태로 바뀝니다. 이 message들을 Actor의mailbox
에 저장되며, Actor가 해당 message를 처리할 수 있을 때 까지 일시 중단(suspend)될 수 있습니다. Actor는 자신의 mailbox에서 한번에 하나의 message만을 처리하게 되며, 이로 인해 프로그램의 동시성이 사라지게 되며 data race를 방지할 수 있게 됩니다.Compile-time Actor-isolation checking
Swift는 컴파일 단계에서 해당 Actor가 Actor-isolation 상태를 유지하고 있는지를 체크하게 됩니다.
- Cross-actor reference 상태가 존재하는지
- 존재한다면 1. 불변 상태의 접근 2. 비동기 함수 형식으로 접근 2가지 경우에 속하는지
여부를 체크하고 컴파일 에러를 발생시킵니다.
Actor Reentrancy
Actor-isolated 함수들은 재진입이 가능합니다. 이러한 재진입성(Reentrancy)으로 인해 actor-isolated 함수들이 대기상태(suspend)에 들어가면 다른 actor-isolated함수(혹은 같은 함수)가 다시 실행될 수 있습니다.
actor ImageDownloader { private var cache: [URL: Image] = [:] func image(from url: URL) async throws -> Image? { if let cached = cache[url] { return cached } let image = try await downloadImage(from: url) // (1) cache[url] = image return image } }
위의 예시 코드는 원시적인 형태의 이미지 다운로더를 Actor를 사용해서 표현한 코드입니다. cache에 저장된 URL의 이미지가 있다면 바로 리턴하고, cache에 존재하지 않는다면
downloadImage(from:)
을 통해서 이미지를 가져온 뒤에 cache에 저장 후 리턴합니다.코드의 동작은 매우 단순하지만 Reentrancy에 의해 (1)의
await
구문이 문제를 일으킬 가능성을 내포하고 있습니다. Task1, Task2 이렇게 두개의 작업에서 같은 URL의 이미지를 요청한다고 생각해봅시다.- Task1이
image(from url:)
함수 내downloadImage(from:)
의 결과물을 기다리면서 (1) 까지 실행된 상태로 멈춥니다. - CPU가 다음 작업을 할 수 있게 되었기 때문에 Task2도
image(from url:)
함수를 호출합니다. - 아직 Task1의
downloadImage(from:)
의 실행이 완료되지 않았기 때문에 cache에 저장된 것이 없으니 Task2 역시 Task1과 동일하게 (1) 코드에서 멈춥니다. - Task1의
downloadImage(from:)
가 실행이 완료되었습니다. cache에 해당 URL을 키값으로 이미지를 저장하고 실행을 종료합니다. - Task2도 위와 같은 식으로 종료됩니다. 이미 cache에는 같은 URL로 캐시된 이미지가 있기 때문에 기존 데이터를 덮어쓰기 합니다.
이런 식으로 코드가 실행되게 된다면 다운로드를 요청할 필요가 없는 이미지들을 다운로드 하게 되어서 비효율을 초래할 수 있습니다. 더 나아가 이렇게 한 함수를 여러개의 쓰레드에서 동시에 접근하고, 해당 함수가 내부 상태값을 변화 시키고 있다면 우리 앱의 로직이 의도한대로 동작 안하게 될 수도 있습니다.
Reenterancy에 의한 문제를 방지하고자 한다면
- 상태값을 바꾸는 코드는 동기적으로 작성하고
- Actor의 상태값들이 await 등의 Suspension 동안에 변경될 수 있다고 생각해야합니다.
- 또한 await를 사용할 경우에는 해당 코드가 실행된 이후에 일어날 수 있는 상황들을 예측해야 합니다.
Conclusion
Swift의 Actor는 새로운 방식으로 Concurrency를 구현할 수 있게 해줍니다. 특히 actor-isolation을 통해 data race를 방지하면서 shared mutable state를 좀 더 안전하게 사용할 수 있도록 도와줍니다.
만약 이 글을 통해서 Actor가 어떤 역할을 하는 것인지 이해가 되셨고 기존 DispatchQueue에서 Actor로 코드를 변환하기를 원하신다면 [WWDC21]Swift Concurrency: Update a sample app 을 보시면서 실습을 해보시면 좋을 것 같습니다.
물론 이 글의 소스인 [WWDC21] Protect mutable state with Swift actors도 꼭 보시기를 추천드립니다.
References
[WWDC21] Protect mutable state with Swift actors
swift-evelution 0306-actors'공부' 카테고리의 다른 글
SwiftUI : (1) SwiftUI는 뭐가 다를까? (0) 2020.02.11