-
Swift 6 - sending parameter and result valuesSwift 2024. 8. 15. 10:38
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 Swift 6 버전에서 새로 나온 sending이라는것에 대해 알아보려고 합니다 🙋🏻
한번 Swift Evolution에서 기재된 Swift 6의 sending 부분 문서를 보며, 알아보시죠 😀
Introduction
sending이라는 키워드는 Swift 6에서 소개되었는데요.
영역 격리를 확장해 함수 매개변수와 결과에 대한 명시적인 주석의 적용을 가능하게 해줍니다.
즉, Swift 6에서 동시성 모델에서 중요한 개념으로 소개되고 있어요.
목적 자체는 sending은 함수 매개변수나 결과값이 안전하게 isolation 경계를 넘어 전송될 수 있음을 나타냅니다.
Motivation
Swift 6의 SE-0414의 도입에서는 지역 격리라는 개념을 도입했어요.
이는 non-Sendable 타입의 값들이 격리 경계를 안전히 넘을 수 있게 하는것이 목적이였습니다.
대부분의 경우, 함수의 인자와 결과값은 호출 시 같은 지역으로 병합됩니다.
이로 인해서 non-Sendable 타입의 매개변수 값은 절대 전송될 수 없는것이죠.
// Compiled with -swift-version 6 class NonSendable {} @MainActor func main(ns: NonSendable) {} func trySend(ns: NonSendable) async { // error: sending 'ns' can result in data races. // note: sending task-isolated 'ns' to main actor-isolated // 'main' could cause races between main actor-isolated // and task-isolated uses await main(ns: ns) }
해당 코드에서 trySend 함수는 non-Sendable 타입의 ns를 main 함수로 전달하고 있죠.
하지만, 이는 데이터 레이스를 발생시킬 수 있어 swift 6 모드에서 컴파일러가 에러를 발생시킵니다.
Actor 이니셜라이저는 매개변수 값을 actor 인스턴스의 격리 지역으로 전송해야 합니다.
Actor 이니셜라이저는 nonisolated이기에 이니셜라이저 호출은 격리 경계를 넘지 않습니다.
class NonSendable {} actor MyActor { let ns: NonSendable init(ns: NonSendable) { self.ns = ns } } func send() { let ns = NonSendable() let myActor = MyActor(ns: ns) // okay; 'ns' is sent into the 'myActor' region } func invalidSend() { let ns = NonSendable() // error: sending 'ns' may cause a data race // note: sending 'ns' from nonisolated caller to actor-isolated // 'init'. Later uses in caller could race with uses on the actor. let myActor = MyActor(ns: ns) print(ns) // note: note: access here could race }
send 함수에서는 ns가 MyActor의 지역으로 전송되어 문제가 없지만, invalidSend 함수에선 ns를 Actor로 전송한 후에도 사용하려고 하니 에러가 발생하는 것이죠.
만약 send 함수의 ns가 지역변수가 아닌 함수 매개변수라면, 이를 MyActor의 지역으로 전송하는것은 유효하지 않습니다.
왜냐면, send() 호출자가 함수 반환 후에도 해당 인자 값을 사용할 수 있기 때문이죠.
Actor 이니셜라이저는 매개변수를 actor의 격리 지역으로 전송할 수 있는 기능을 가지곤 있지만, 다른 함수나 메서드에선 이런 동작을 명시적으로 지정할 수 없습니다.
@MainActor var mainActorState: NonSendable? nonisolated func test() async { let ns = await withCheckedContinuation { continuation in Task { @MainActor in let ns = NonSendable() // Oh no! 'NonSendable' is passed from the main actor to a // nonisolated context here! continuation.resume(returning: ns) // Save 'ns' to main actor state for concurrent access later on mainActorState = ns } } // 'ns' and 'mainActorState' are now the same non-Sendable value; // concurrent access is possible! ns.mutate() }
해당 코드에선 withCheckedContinuation을 이용해 비동기 작업을 수행하죠.
@MainActor Task 내에서 NonSendable 인스턴스를 생성하고 해당 인스턴스를 resume을 통해 nonisolated 컨텍스트로 전달합니다.
동시에 같은 인스턴스를 mainActorState에 저장합니다.
문제는 NonSendable 타입의 값이 main actor에서 nonisolated 컨텍스트로 전달되는것이 격리 경계를 넘는 행위입니다.
ns와 mainActorState가 같은 non-Sendable 값을 참조하게되어, 동시 접근이 가능해지죠.
즉, 잠재적인 데이터 레이스 조건을 만들어내는것입니다.
현재 Swift에서도 resume(returning:) 메서드는 인자에 대해 Sendable 요구사항을 강제하지 않습니다.
따라서, strict-concurrency를 complete로 사용해도 데이터 레이스 안전성 진단을 하지 않죠.
resume(returning:)의 매개변수 타입에 Sendable을 모두 요구하는것이 너무 제한적일 수 있죠.
그래서 대신, non-Sendable 타입의 값이 disconnected region에 있고, resume(returning:) 호출 후 해당 region의 모든 값들이 다시 사용되지 않는다면 안전하게 전달할 수 있습니다.
Proposed solution
결국 이런 문제들을 해결하기 위해서 sending 키워드가 제안된것입니다.
sending 키워드를 통해 특정 매개변수나 반환값이 isolation 즉, 격리 경계를 안전하게 넘을 수 있음을 명시적으로 표현할 수 있게 됩니다.
public struct CheckedContinuation<T, E: Error>: Sendable { public func resume(returning value: sending T) } public func withCheckedContinuation<T>( function: String = #function, _ body: (CheckedContinuation<T, Never>) -> Void ) async -> sending T
Sendable Values and Sendable Types
Sendable 프로토콜을 준수하는 타입은 스레드 안전 타입입니다.
해당 타입의 값은 데이터 레이스를 일으키지 않고 한번에 여러 동시 컨텍스트에서 안전히 공유되고 사용될 수 있죠.
값이 Sendalbe을 준수하지 않는 경우 Swift는 값이 동시에 사용되지 않도록 해야 하죠.
이런 값들도 동시성 컨텍스트 간 전송될 수 있지만, 전체 영역의 완전한 전송이어야 합니다.
이는 소스 컨텍스트에서 모든 사용이 끝나고 나서야 목적지 컨텍스트에서 사용을 시작할 수 있음을 의미하죠.
Swift는 값이 disconnected region에 있도록 요구함으로써 이 특성을 달성합니다.
이런 값을 sending 값이라고 부릅니다.
func f() async { // This is a `sending` value since we can transfer it safely... let ns = NonSendable() // ... here by calling 'sendToMain'. await sendToMain(ns) }
기존 region과 연결되지 않은 새로 생성된 값은 항상 sending 값입니다.
actor MyActor { var myNS: NonSendable func g() async { // 'ns' is initially a `sending` value since it is in a disconnected region... let ns = NonSendable() // ... but once we assign 'ns' into 'myNS', 'ns' is no longer a sending // value... myNS = ns // ... causing calling 'sendToMain' to be an error. await sendToMain(ns) } }
sending 값은 다른 isolation region에 병합될 수 있습니다.
병합 후, 해당 region이 disconnected가 아니면 값은 더 이상 sending값이 아닙니다.
func h() async { // This is a `sending` value. let ns = Nonsending() // This also a `sending value. let ns2 = NonSendable() // Since both ns and ns2 are disconnected, the region associated with // tuple is also disconnected and thus 't' is a `sending` value... let t = (ns, ns2) // ... that can be sent across a concurrency boundary safely. await sendToMain(ns) }
sending 값의 isolation region이 다른 disconnected isolation region과 병합되면, 결과적으로 새로운 비연결 지역이 형성되어 여전히 sending 값으로 간주됩니다.
그래서 위 코드는 에러가 나지 않고 안전히 전송이 가능하죠.
즉, 계속 핵심인데 sending 값의 개념은 특히 non-Sendable 타입의 값을 동시성 컨텍스트 간에 안전히 전송할 수 있게 해주는 핵심입니다.
sending Parameters and Results
sending 키워드가 붙은 함수 매개변수는 인자 값이 disconnected region에 있어야 함을 요구합니다.
함수 호출 시점에서 이 disconnected region이 더 이상 호출자의 isolation 도메인에 있지 않아요.
이로 인해 피호출자는 매개변수 값을 호출자에게 불투명한 지역으로 전송할 수 있습니다.
@MainActor func acceptSend(_: sending NonSendable) {} func sendToMain() async { let ns = NonSendable() // error: sending 'ns' may cause a race // note: 'ns' is passed as a 'sending' parameter to 'acceptSend'. Local uses could race with // later uses in 'acceptSend'. await acceptSend(ns) // note: access here could race print(ns) }
acceptSend 함수는 sending NonSendable 타입의 매개변수를 받습니다.
sendToMain 함수에서 ns를 acceptSend에 전달하려고 하면 에러가 발생하죠.
이는 ns가 sending 매개변수로 전달된 후 로컬에서 사용되고 있기 때문이죠.
피호출자는 인자 값으로 무엇을 하는지 호출자에게 불투명하죠.
피호출자는 다른곳으로 값을 보낼수도 있고, 다른 매개변수의 isolation region에 병합할 수도 있죠.
즉, sending 결과를 반환하는 함수는 disconnected region에 있는 값을 반환해야 합니다.
@MainActor struct S { let ns: NonSendable func getNonSendableInvalid() -> sending NonSendable { // error: sending 'self.ns' may cause a data race // note: main actor-isolated 'self.ns' is returned as a 'sending' result. // Caller uses could race against main actor-isolated uses. return ns } func getNonSendable() -> sending NonSendable { return NonSendable() // okay } }
getNonSendableInvalid는 에러를 발생시키죠.
self.ns는 메인 액터에 격리된 값이기에 sending 결과로 반환할 수 없습니다.
getNonSendable은 새로운 인스턴스를 생성해 반환하기에 정상 동작합니다.
@MainActor func onMain(_: NonSendable) { ... } nonisolated func f(s: S) async { let ns = s.getNonSendable() // okay; 'ns' is in a disconnected region await onMain(ns) // 'ns' can be sent away to the main actor }
sending 결과를 반환하는 함수의 호출자는 반환된 값이 disconnected region에 있다고 가정할 수 있습니다.
이로 인해서 non-Sendable 타입의 결과 값도 actor isolation 경계를 넘을 수 있습니다.
Function subtyping
sending과 관련된 함수의 서브타이핑 규칙에 대해 알아볼께요.
기본 개념 자체는 임의의 타입 T에 대해선 sending T는 T의 서브타입입니다.
sending은 매개변수 위치에서 반공변적입니다.
이는 구체적인 타입(seding T)이 더 일반적인 타입(T)를 대체할 수 있음을 의미하죠.
func sendingParameterConversions( f1: (sending NonSendable) -> Void, f2: (NonSendable) -> Void ) { let _: (sending NonSendable) -> Void = f1 // okay let _: (sending NonSendable) -> Void = f2 // okay let _: (NonSendable) -> Void = f1 // error }
f1을 (sending NonSendable) -> Void 타입에 할당하는거은 당연히 가능하죠.
f2도 sending NonSendable이 NonSendable의 서브타입이기에 가능하구요.
그러나 마지막은 서브타입을 요하는데 NonSendable을 전달하니 불가능합니다.
sending은 결과 위치에서는 공변적입니다.
이 말은, sending T를 반환하는 함수의 결과를 T로 취급할 수 있음을 의미하죠.
func sendingResultConversions( f1: () -> sending NonSendable, f2: () -> NonSendable ) { let _: () -> sending NonSendable = f1 // okay let _: () -> sending NonSendable = f2 // error let _: () -> NonSendable = f1 // okay }
매개변수 때와 반대라고 보시면 됩니다.
이처럼 서브타이핑 규칙은 sending 키워드르 사용할 때 타입 안정성을 보장하면서 유연성을 제공해줍니다.
Protocol conformances
프로토콜 요구사항에 sending 매개변수나 결과 어노테이션을 포함할 수 있어요.
protocol P1 { func requirement(_: sending NonSendable) } protocol P2 { func requirement() -> sending NonSendable }
P1은 sending NonSendable 타입의 매개변수를 가진 함수를 요하죠.
P2는 sending NonSendable 타입을 반환하는 함수를 요합니다.
struct X1: P1 { func requirement(_: sending NonSendable) {} } struct X2: P1 { func requirement(_: NonSendable) {} }
P1을 채택할때 이렇게 모두 서브타이핑 규칙에 따라 매개변수 위치이니 모두 가능합니다.
sending T가 T의 서브타입이니까요.
struct Y1: P1 { func requirement() -> sending NonSendable { return NonSendable() } } struct Y2: P1 { let ns: NonSendable func requirement() -> NonSendable { // error return ns } }
그러나 반환 값일 경우, 이렇게 Y2는 불가능하겠죠?
이는 위에서 살펴본 서브타이핑 규칙을 생각하면 됩니다.
inout sending parameters
inout과 sending을 함께 사용할 수도 있습니다.
이는 매개변수가 함수 내에서 수정될 수 있고, 동시에 isolation 경계를 넘나들 수 있음을 의미하죠.
함수 호출 시 인자 값은 반드시 비연결 영역에 있어야하고, 함수 반환 시에는 매개변수 값이 반드시 비연결 영역에 있어야 합니다.
inout sending 매개 변수는 actor-isolated 피호출자와 병합될 수 있습니다.
또, 다른 곳으로 전송될 수도 있죠.
func modifyAndSend(_ value: inout sending NonSendable) { // 함수 시작 시 'value'는 disconnected region에 있음 someActor.useValue(value) // actor-isolated 메서드로 전송 가능 value.modify() // 값 수정 가능 sendToAnotherActor(value) // 다른 actor로 전송 가능 value = NonSendable() // 함수 종료 전 새로운 disconnected 값으로 재할당 }
이렇게 예시처럼 사용할 수 있어 유연성이 증가하고 안정성도 보장해주죠.
다만 함수 종료 시 매개변수가 비연결 영역에 있도록 하지 않으면 에러가 발생할 수 있어요.
Ownership convention for sending parameters
sending 매개변수로 인자를 전달하면, 호출자는 피호출자 함수가 반환된 후 해당 인자 값을 사용할 수 없습니다.
기본적으로 sending은 피호출자가 매개변수를 소비하는것을 의미해요.
consuming 매개변수와 마찬가지로, sending 매개변수도 피호출자 내에서 재할당될 수 있습니다.
consuming 매개변수와 달리, sending 매개변수는 기본적으로 암시적 복사를 허용합니다.
sending은 consuming이나 borrowing 소유권 수정자 키워드와 함께 사용할 수 있어요.
func sendingConsuming(_ x: consuming sending T) { ... } func sendingBorrowing(_ x: borrowing sending T) { ... }
첫번째 조합은 매개변수가 소비되며 암시적 복사가 없음을 명시적으로 나타내죠.
즉, 피호출자는 매개변수의 소유권을 완전히 가져가며 값을 자유롭게 수정할 수 있습니다.
두번째는 대여 개념이라 암시적 복사가 없음을 나타내죠.
피호출자는 매개변수를 수정할 수 없지만, 다른 곳으로 전송은 가능합니다.
함수가 반환될 때 매개변수는 여전히 유효해야 합니다.
주의할 점은 borrowing 어노테이션은 항상 암시적 복사 금지를 의미합니다.
즉, sending 매개변수의 기본 소유권 규칙을 변경하면서 암시적 복사를 허용하는 방법은 없어요.
func example1(_ x: sending T) { ... } // 기본: 소비, 암시적 복사 허용 func example2(_ x: consuming sending T) { ... } // 소비, 암시적 복사 금지 func example3(_ x: borrowing sending T) { ... } // 대여, 암시적 복사 금지
Adoption in the Concurrency library
Concurrency 라이브러리에서 sending 매개변수를 채택하고 있는 API들이 여럿 있습니다.
일부 API들은 매개변수를 isolation 경계를 넘어서 전송하지만, Sendable의 모든 보장을 필요로 하진 않습니다.
그렇기에 대신해 sending 매개변수를 채택해둔 것이죠.
요런 친구들~ 😁
Source compatibility
Swift 5 언어 모드에선 sending 진단은 최소 동시성 검사에서 억제되고, 엄격한 동시성 검사에서 경고로 진단됩니다.
Swift 6 언어 모드에선 오류가 되구요.
당연히 알겠지만, Swift 6로 sending을 사용해 리팩토링을 하고 Swift 6를 지원하지 않는 Xcode에서 Swift 5 모드로 컴파일하면 sending을 찾을 수 없습니다.
반대로, Swift 6를 지원하는 Xcode 16버전 부턴 Swift 5 모드로 돌려도 이상이 없습니다.
ABI compatibility
해당 sending은 기존 코드의 컴파일 방법을 변경하지 않습니다.
Implications on adoption
매개변수에 sending을 채택하는건 호출자에서 더 제한적이고 호출 수신자에선 더 표현력이 뛰어나죠.
반환값에서 채택은 그 반대구요.
sending을 도입하고 사용해야 하는 이유를 간단히 정리해보면 이렇습니다.
sending은 결국 Swift의 동시성 모델을 개선하기 위해 도입된 키워드로 non-Sendable 타입의 값을 isolation 경계 사이에서 안전히 전송할 수 있게 해줍니다.
sending이 필요한 이유는 기존의 Sendable 프로토콜만으론 모든 동시성 시나리오를 효과적으로 처리할 수 없기 때문이죠.
특히, 어떤 값이 일시적으로만 다른 isolation 도메인으로 전송되어야 하는 경우, sending은 이를 안전하고 명확하게 표현할 수 있게 해줍니다.
이를 통해서 우리는 더 유연하게 동시성 코드를 작성할 수 있고 컴파일러는 더 정확하게 타입 검사를 할 수 있어 잠재적인 데이터 레이스와 같은 동시성 에러를 방지할 수 있습니다.또한, 소개에서 앞으로의 방향에 대해서도 말해주고 있어요.
Disconnected types
현재 sending은 함수 경계에서 매개변수와 결과값이 비연결 지역에 있어야 함을 요구하죠.
하지만, 저장 프로퍼티, 컬렉션, 함수 호출 등을 통해 값이 비연결 지역에 있음을 유지할 방법이 없는것이 현재 한계입니다.
그렇기에, Disconnected 타입을 컨커런시 라이브러리에 도입하는것을 고려하고 있다고 합니다.
Disconnected 타입의 특징은 ~Copyable을 통해 복사를 억제하고 Sendable 프로토콜을 준수합니다.
해당 인스턴스를 생성할 때 래핑 값이 비연결 지역에 있어야 하고 해당 타입의 값은 다른 isolation region에 병합될 수 없습니다.
해당 타입의 이점은 sending T 매개변수를 받아서 Disconnected<T> 컬렉션에 저장하고 나중에 이 값들을 컬렉션에서 제거해 sending T 결과로 반환하는 중요한 패턴을 가능하게 해줍니다.
일부 AsyncSequence 타입이 non-Sendable 타입의 버퍼링된 요소를 sending으로 반환할 수 있게 해줍니다.
이를 통해서 구현에서 안전하지 않은 옵트아웃을 사용하지 않아도 되는것이죠.
잠재적으론 비동기 버퍼나 큐와 같은 데이터 구조를 안전하게 구현할 수 있고, 좀 더 복잡한 동시성 패턴을 쉽게 표현하고 관리할 수 있을거라 예상합니다.
Alternatives considered
sendable 대신 tranferring이나 sending으로 네이밍을 사용하려는 시도도 있었으며,
UnsafeContinuation에선 sending을 적용하는것에 대해 제외하는것도 고려되었습니다.
물론 대안이였기에 합당한 이유로 기각되었죠 😃
합당한 이유는 공식 문서를 자세히 보시는걸 추천드립니다!
마무리
아... 어렵네요ㅋㅋㅋ
Swift 6로 마이그레이션 하는 도중 sending을 사용할 일이 있어 파봤는데 핥아본 정도네요 🥲
여러번 봐야겠어요 흑흑..
레퍼런스
'Swift' 카테고리의 다른 글
Demystify explicitly built modules (feat. WWDC 2024) (25) 2024.08.22 What's new in Swift6 (feat. WWDC 2024) (26) 2024.08.19 XMLParser (73) 2024.05.20 What's new in Swift 5.10 (64) 2024.03.21 New access modifier - package (78) 2024.03.04