요즘 Concurrency를 계속해서 공부하고 있습니다. GCD부터 Swift 5.5 modern concurrency까지. GCD를 알고 있지만 modern concurrency async/await로 인스타그램 앱 클론 개발을 해왔었습니다. 낯선 개념은 아니었으나 WWDC 영상 말고 책으로 다시 공부해보고 싶었고 새롭게 알게 된 개념들을 정리 하려고 합니다. Modern concurrency 개념 중 가장 많이 사용한 개념은 async/await이 메인이였습니다. concurrent problem 관련해서 actor는 정말 중요한 개념인 것 같습니다. actor를 사용하기 위해선, Sendable 프로토콜을 준수하는 이유 또한 알아야 합니다.
1. What is modern concurrency and concurrent problem?
Swift modern concurrency는 async/await, actor, async sequence, global actor등의 개념이 있습니다. GCD API에서 해결할 수 없는 문제를 쉽게 감지하고 해결하고자 디자인 됐습니다. 각각의 키워드에 대한 개념은 다른 포스트에서 다룰 것인데, 오늘 주제와 관련된 개념은 Swift 5.5에서 도입된 actor와 연관 있는 Sendable입니다.
Concurrent problem을 해결하기 위한 Swift 5.5 concurrency 여러 개념 중 메인은 actor입니다. 이는 동시간대에 단일 thread에서 단 한 개의 task만 실행합니다. Synchronization의 특징을 갖어 GCD의 serial dispatchQueue와 비슷합니다(차이점도 존재합니다).
Actor는 data race 문제 or 기타 concurrent problem을 컴파일 수준에서 감지하고 방지하기 위해 만들어 졌습니다. 컴파일러가 data race 발생할 수 있는 코드를 판별하는 조건은 actor에 도입된 "state isolation layer", "@Sendable attribute", "Sendable 프로토콜 준수를 하는가?" 여부에 달려 있습니다. Sendable을 준수할 경우 concurrency domain간 안전하게 일을 할 수 있음을 보장합니다.
간단하게 actor에 대한 설명으로 actor의 내부 멤버들은 isolated state를 띄고 concurrent domain간 mutable state가 호출이 될 때serial executor를 통해 안전하게 다뤄집니다. mutable state는 여러 종류의 변경될 수 있는 데이터를 의미하는데 Sendable을 준수한다면 안전하게 다뤄집니다.
결국 data race, deadlock과 같은 concurrent problem은 공유 자원(mutable state)을 어떻게 사용하고 관리하는지에 대한 여부에 달려있습니다. 특히 data race의 경우 동 시간대에 read, write가 일어나기 때문에 예상치 못한 결과가 발생될 수 있습니다. 메모리에 비 정상적인 값이 저장됩니다. 이를 해결하고자 새로 도입된 actor를 사용하거나 NSLock or dispatch barrier, 세마포어 등 여러 알고리즘이 있는데 이들과의 차이점은 actor를 썼을 땐 compiler가 data race가 발생하는 코드인지 감지할 수 있다는 장점 +_+.
하지만 actor로만 concurrent problem을 전부 해결할 수 없습니다. 그래서 추가적으로 알아야 할 개념은 @Sendable attribute, Sendable protocol의 개념입니다.
2. What is @Sendable attribute and Sendable protocol?
어떻게 Swift compiler는 safety check를 하는지, 어떻게 해야 multi thread에서 사용되는 공유 자원을 thread-safety한 concurrency programming을 할 수 있는지 궁금했습니다.
Actor를 사용하면 thread-safety한 코드를 만들 수 있다는데 Actor 또한 Sendable 프로토콜을 준수하고 있습니다. Actor가 thread-safety한 이유는 concurerncy domain간 접근되는 mutate state가 Sendable을 준수하고 있기 때문입니다.
"Sendable" ?....
Sendable은 값을 copying해서 concurrency domain간에 값을 안전하게 전달할 수 있도록 해주는 프로토콜입니다. 이 프로토콜을 준수함으로 인해 sendable type's value는 동시성이 요구되는 시점에서 안전하게 전달할 수 있습니다. 그리고 이 프로토콜을 준수하는 Actor protocol은 concurrent code에서 사용될 때 safe합니다. 즉 thread 간에 Sendable 타입의 value가 안전하게 전달될 수 있다는 것을 의미한다는 개념으로 이해하면 좋을 것 같습니다.
이 프로토콜은 프로퍼티, 메서드를 갖고 있지 않습니다. 채택할 경우 필수적으로 구현해야 하는 것이 없습니다. 그렇다고 Sendable을 채택하거나 @Sendable 표기를 해서 함수, 클로저를 사용하면 되는 것이 아닙니다ㅠㅠ... 단순하게 Sendable로만 명시되어 있지만 이를 준수하기 위해선 규칙이 또 있습니다...
이제 Sendable type의 유형, 허용되지 않는 유형에 대해서 소개하려고 합니다. 공식문서에 잘 나와있지만,, 기본적으로 Sendable을 준수하는 타입이 있고 커스텀으로 만들 수 있는 경우가 있습니다.
Swift's value semantics in standard library
Swift의 표준 라이브러리에 정의된 String, Bool, Int, generic structs, enum, tuples, struct etc... 공통적인 특징은 메모리를 참조하는 게 아닌 값을 copying하는 object들입니다. 그 중에서도 core value에 속하는 String, Bool, Int, 그리고 value type인 genertic collection인 Dictionary<Int,String>등의 타입, Metatype은 concurrency domain간 전달될 때 thread-safe합니다. 일반적인 Sendable type에 속합니다.
enum test: Sendable {
case integer(value: Int)
case string(value: String)
case mutableString(value: NSMutableString) //error: non-sendable type
}
Int, String은 Sendable을 기본적으로 준수합니다. 하지만 NSMutableString은 non-sendable type에 속합니다. 이를 통해서 알 수 있는 것은 NSMutableString은 Sendable을 준수하지 않습니다.
결국 cross actor간 access되는 actor-isolated 함수 의 인자값 등으로 사용될 경우 thread-safety를 보장하지 않습니다. sendable을 준수하지 않기 때문입니다.
물론 struct, enum의 경우 추가 제약사항이 있습니다. Frozen struct, enum + @usableFromInline표기를 하지 않아야 합니다. 다시말해, 멤버변수가 전부 Sendable을 준수할 경우 암묵적으로 Sendable을 준수하게 됩니다.
struct Weather: Sendable {
enum CurrentCondition: Sendable {
case wind
case sunny
case cloudy
}
let temperature: Int
}
이 경우 enum도 Sendable을 준수하고, struct 도 Sendable를 준수합니다.
struct Weather {
...
let day: NSMutableSTring
}
만약, 이 구조체에 Sendable을 준수하지 않는 NSMutableString타입의 변수를 넣을 경우 에러가 뜹니다. Weather에 NSMutableString타입의 프로퍼티를 선언했을 경우
test enum은 Sendable을 준수해야 하기 때문에 case 안에 담길 값 또한 Sendable을 준수해야 합니다. 하지만 Weather의 경우 Sendable을 준수하지 않기 때문에 경고가 경고 메시지가 나타납니다.
위에서 정의된 value type를 사용할 경우 + Sendable을 준수하는 enum, struct의 경우 concurrent domain(== 여러 thread들)간 access할 때 안전합니다.
Immutable Classes
class는 read-only(not write, immutable state)의 경우, mutable storage가 없을 경우 Sendable type으로 지정할 수 있습니다.
@MainActor 어노테이션이 붙은 class or 특정 thread or task를 담당하는 queue에서 serial access가 가능한 class로 분류할 수 있습니다. 그리고 멤버 변수가 전부 immutable state일 때 concurrent domain간 access가 안전합니다(ARC 필요되지 않음).
class MutableRefClass1 {
var box: Int
init(box: Int) { self.box = box }
}
class MutableRefClass2 {
let box: Int
init(box: Int) { self.box = box }
}
final class ImmutableRefClass: Sendable {
let box: Int
init(box: Int) { self.box = box }
}
세 종류의 클래스를 선언할 경우
final class + let 프로퍼티로 구성된 ImmutableRefClass를 제외하곤 다 non-sendable type으로 분류 됩니다. ImmutableRefClass만 Sendable을 채택해서 그렇다고 생각할 수 있습니다.
결국 마찬가지로 Sendable을 준수하지 않기 때문에 나오는 경고입니다.
Class?
참조 타입인 class는 concurrent domain간 값을 copying해서 전달하는게 아니라 메모리에 저장된 데이터를 참조하기 때문에 not thread-safety에 속합니다. Class 내부에 mutable state가 있을 때 serial한 access를 보장하는 알고리즘으로 코드를 구현하지 않을 경우에 값을 예측할 수 없게 됩니다(단, multi thread에서 concurency한 접근할 경우). 이 경우 not thread-safety로 분류됩니다.
Class 경우 Sendable 프로토콜을 준수할 때, 그리고 final mark, stored properties가 값을 변경할 수 없고 sendable 을 준수할 때 read-only일 때, super class 가 없거나 NSObject를 갖지 않는 경우에 Sendable class가 될 수 있습니다.
NSObject 관련 예로 String은 Sendable을 준수합니다. 하지만 NSMutableString은 Sendable을 준수하지 않습니다.
Internally snychronized reference type
개발할 때 참조 타입인 class의 object를 많이 선언하고 사용합니다. concurrenct problem을 고려하지 않은 채로 설계한다면 concurrent domain간 access될 때 concurrent problem 발생 가능성이 분명 존재합니다. 이 경우는 엄밀히 말하면 thread-unsafety 입니다. 하지만 참조 타입의 mutate state가 내부적으로 synchronized된 실행을 할 수 있도록 코드를 짠다면, 이러한 알고리즘(lock,세마포어,barrier 등)을 도입한다면 thread-safety입니다. 이는 곧 여러 thread에서 접근할 때 safe보장이 됨을 의미합니다.
Actor
그 예로 Swift 5.5에서 등장한 actor입니다. actor 내부 멤버들은 state isolation layer 영역으로 분류 됩니다. 각각의 멤버(프로퍼티, 함수 등)는 isolated state를 띄게 됩니다. 이 덕분에 actor는 actor's executor를 통해 synchronized한 처리가 가능합니다. 즉 특정한 시간에 한 thread에서만 access(읽거나 수정하거나..)가 가능하다는 점 입니다. 특정 actor를 접근할 때 해당 actor's executor에 의해 처리되는(self에 의한) task가 아닌 모든 것을 외부에 의한 접근이라고 볼 수 있습니다. 이들은 곧바로 접근할 수 없습니다. 모두가 곧바로 접근하게 된다면 그야말로 data race가 야기될 수 있기 때문입니다. 이러한 특징 때문에 내부적으로 synchronized reference type인 actor는 thread safety하게 처리할 수 있습니다.
function, closure
클로저의 경우 블럭 밖에 있는 변수를 사용해야 할 때 캡쳐하기 때문에 참조 타입입니다(not thread-safery). 함수도 reference type입니다. 하지만 empty capture lists를 갖는 함수의 경우 thread-safety합니다. 고차함수(map,filter...)등을 커스텀으로 구현할 때 @Sendable을 준수해서 만든다면 concurrent domain간 thread safety한 연산이 가능합니다. 조건은 reference type을 capture하지 않고 @Sendable을 준수할 때 입니다.
즉, @Sendable 어노테이션을 마크하는 함수, 클로저의 경우 mutable state를 multi thread에서 concurrency한 접근을 할 때 안전하다는 것을 보장합니다.
Swift 5.5에서 등장한 TaskGroup은 여러 sub task를 group으로 묶어서 관리하는 객체입니다. function, closure가 적용된 예로 살펴볼 수 있습니다.
TaskGroup에 여러 개의 동시에 실행되야 하는 task들을 addTask로 추가합니다. 이때 group에 추가된 각각의 task들은 priority를 갖고 있으며 부여하지 않을 경우 상위 객체의 priority를 상속받습니다. 중요한 것은 addTask에 task를 추가하는데 이 함수는 actor에서도 사용될 수 있습니다. 그렇기 때문에 Sendable을 준수하고 있습니다.
addTask(priority:operation:) 함수의 두번째 인자값을 자세히 보면
operation: @escaping @Sendable () async -> ChildTaskResult
초 간단하게 클로저 형태만 추려내면 () -> ChildTaskResult를 반환합니다. TaskGroup은 Swift 5.5 concurrency에 등장했고 async/await를 사용하면서 group안에 있는 tasks를 비동기적으로 수행합니다. Task group 인스턴스 생성 후 실행하는 경우는 Task{ ... } 공간 안에서 async하게 수행됩니다( () async -> ChildTaskResult ). operation에 추가 될 클로저는 곧바로 실행되지 않은 채 group에 추가됩니다. Group에 추가되자마자 바로 실행되지 않고 바로 반환됩니다( @escaping () async -> ChildTaskResult ). TaskGorup은 actor의 내부 isolated-state 메서드에서 실행될 수 있습니다. ( @escaping @Sendable () async -> ChildTaskResult )
이 클로저의 경우 @Sendable 어노테이션을 표기했기 때문에 내부 클로저 블럭 내부는 Sendable을 준수해야 합니다!!
3. @_marker protocol
@_marcker protocol가 표기된다는 것은 Runtime에 영향을 미치지 않고 static complie tiem에 동작되는 개념입니다. is or as로 타입체크가 불가능하고 non-marcker protocol로부터 상속받을 수 없습니다.
4. When should I use "Sendable"?
기본적인 Sendable type, @Sendable attribute, Sendable protocol을 준수한다는 것은 concurrent domain간 접근 시에 발생할 수 있는 concurrent problem을 방지하기 위함입니다. Swift 5.5의 actor에서 주로 사용됩니다.
Actor는 Sendable을 준수합니다. 결국, actor 내부에 변수를 선언하고 인자값을 사용하는 함수의 로직을 구성할 때 Sendable을 기본적으로 준수할 수 있어야 합니다. 그리고 Sendable을 준수하는지 타입 추론은 Sendable이 @_marcker 표기가 되었기 때문에 compile time에 검사됩니다. 그래서 Sendable 타입을 준수하는 actor를 설계할 때 compiler가 isolated state에 대한 access가 올바른지, concurrent problem이 발생할 수 있는 코드에 대해 사전적으로 체크합니다. Sendable을 준수할 때 multi thread에서 동시적으로 사용되는 mutate state가 thread-safety하게 접근 됩니다.
지금까지 Sendable 타입을 채택하는 경우, Sendable 타입의 개념에 대해 알아봤습니다. 다음 포스트는 Sendable을 준수하는 코드를 구현하는 경우에 대해 작성하려고 합니다. 긴 글 읽어주셔서 감사합니다. 혹시 틀린 내용 발견시 댓글로 알려주시면 감사합니다.
Swift 5.5 다른 포스트
GCD의 문제, Concurrency model 특징과 async/await 개념 정리 포스트
Actor No1. actor, thraed-safe, actor's serial executor 개념 정리 포스트
Actor No2. actor, actor isolation, cross-actor reference 개념 정리 포스트
Structured concurrency 개념 정리 포스트
References
https://developer.apple.com/documentation/swift/sendable/
https://www.hackingwithswift.com/swift/5.5/sendable
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md
https://www.kodeco.com/books/modern-concurrency-in-swift
'iOS > Concurrency' 카테고리의 다른 글
[Swift 5.5] actor, actor isolation, cross-actor reference 개념 완벽 뿌수기 | No2. Actor (0) | 2023.03.04 |
---|---|
[Swift 5.5] actor 개념 뿌수기!! +_+ #Concurrency, thread-safe, actor's serial executor | No1. Actor (0) | 2023.03.03 |
[Swift] GCD 개념 정리 | No7. GCD (0) | 2023.02.01 |
[Swift] DispatchGroup으로 tasks 관리하기 | No6. GCD (0) | 2023.01.31 |