[Swift] Swift 6.2 의 nonisolated(nonsending) 과 @concurrent
feat. NonisolatedNonsendingByDefault
들어가기 전에
개-하! 오늘은 어쩌다가 nonisolated(nonsending) 과 @concurrent 로 돌아오게 되었습니다
분명 Task inherit context 같은.. 이거랑은 관련 없는 주제를 보고 있었던거 같은데 어쩌다 여기까지 흘러왔는지는 모르겠고요??
지금까지 저만 몰랐던거 같지만 미래의 저도 다시 모를거 같기기 때문에 거두절미하고 ㄱㄱㄱㄱㄱ
nonisolated
우선 이 글을 보고 계신 분들이라면 nonisolated 는 알고 계시져? 특정 actor 에 격리되지 않도록 해주는 키워드인데여!
아래와 같이 MainActor 에 격리된 start 함수에서 -> 아무곳에도 격리되지 않은 nonisolatedSync 를 호출하는 코드를 작성해보겠습니다.
class MyClass {}
@MainActor
class MainActorClass {
let myClass = MyClass()
// 1. MainActor 격리 함수
func start() async {
print("start: \(Thread.current)")
nonisolatedSync(myClass)
}
// 2. 아무 actor 에도 격리되지 않은 함수
nonisolated
func nonisolatedSync(_ data: MyClass) {
print("nonisolated: \(Thread.current)")
}
}이제 await MainActorClass().start() 호출을 때려보면 프린트 결과는??
start: <_NSMainThread: 0x600001708040>{number = 1, name = main}
nonisolated: <_NSMainThread: 0x600001708040>{number = 1, name = main}nonisolatedSync 함수가 async 는 아니기 때문에 caller (start) 와 동일한 스레드에서 동작하는걸 확인할 수 있습니다.
여기서 만약 nonisolated 함수에 async 를 붙이면 어떻게 될까요?
nonisolated
func nonisolatedAsync(_ data: MyClass) async {
print("nonisolated async: \(Thread.current)")
}결과는 달라지는데여, 더 이상 caller 와 동일한 스레드에서 실행되지 않습니다
start: <_NSMainThread: 0x600001708040>{number = 1, name = main}
nonisolated async: <NSThread: 0x600001786c40>{number = 6, name = (null)}🤔 : nonisolated 키워드 자체가 현재 actor context 에 격리되지 않는다는건데, sync / async 에 따라 실행되는 context 가 달라진다???
: ㅇㅇ 혼란스럽지? nonisolated(nonsending) 도입해주겠음
nonisolated(nonsending)
우선 요 키워드의 사용방법은 간단합니다. 그냥 똑같이 함수 앞에 붙여주면 돼여 ㅇㅇ
nonisolated(nonsending)
func nonisolatedAsyncNew(_ data: MyClass) async {
print("nonisolated(nonsending): \(Thread.current)")
}이렇게 하면 nonisolated async 함수일지라도 caller 와 동일한 actor 에서 계속 함수가 실행됩니다
start: <_NSMainThread: 0x600001708040>{number = 1, name = main}
nonisolated(nonsending): <_NSMainThread: 0x600001708040>{number = 1, name = main} : 이 기능은 nonisoalted 의 async / non-async 동작 통합 뿐만 아니라, 동시성과 복잡성도 낮춰줌 ㅇㅇ
🤔 : 뭔 소리?
그러니까.. 원래 nonisolated async 함수는 사실상 아예 새로운 isolation domain 을 생성해서 실행하겠다는 의미였잖아여? 그래서 이 안에 전달되는 상태도 당연히 Sendable 이어야 했습니다.
예를 들어 아래와 같이 MainActor 에 격리된 myClass 프로퍼티를 nonisolated async 함수에 파라미터로 전달하려고 하면 에러가 났단 말이죠?
@MainActor
class MainActorClass {
let myClass = MyClass()
func start() async {
await nonisolatedAsync(myClass) // ❌ 에러
}
nonisolated
func nonisolatedAsync(_ data: MyClass) async {}
}이런 에러요 ㅇㅇ
Sending main actor-isolated 'self.myClass' to nonisolated instance method 'nonisolatedAsync' risks causing data races between nonisolated and main actor-isolated uses그런데 nonisolated(nonsending) 함수는 (액터가 있다면) 호출자의 액터(caller’s actor)에서 실행됩니다.
즉
- 우리가 호출 지점(call-site)에서
nonisolated(nonsending)함수로 상태를 전달할 때 - 그 상태는 새로운 isolation context 로 전달되는 것이 아니라
- 우리가 시작했던 동일한 context 에 머물기 때문에
- 파라미터도
Sendable할 필요가 없습니다
class MyClass {}
@MainActor
class MainActorClass {
let myClass = MyClass()
func start() async {
await nonisolatedAsyncNew(myClass) // ✅ 정상
}
nonisolated(nonsending)
func nonisolatedAsyncNew(_ data: MyClass) async {}
}🤔 : 오.. nonisolated(nonsending) 좋은데? 그럼 기존 nonisolated 로 되어있는거는 일일이 다 고쳐야해?
: Swift 6.2의 NonIsolatedNonSendingByDefault feature flag 를 YES 로 해라
NonIsolatedNonSendingByDefault
NonIsolatedNonSendingByDefault를 YES 로 설정하면 nonisolated인 모든 함수를 nonisolated(nonsending)으로 간주하도록 만들 수 있습니다 (물론 명시적인 키워드 없이, 자동으로 nonisolated 로 추론되던 함수에도 적용됩니다)
만약 해당 옵션이 켜지지 않은 상태에서 아래와 같은 nonisolated async 함수가 있다면, performAsync는 global generic executor 에서 동작을 하겠지만
struct S: Sendable {
// 📍 `nonisolated` 이 디폴트
func performAsync() async {}
}
actor MyActor {
let s: Sendable
func call() async {
await s.performAsync() // 📍 global generic executor 로 이동
}
}해당 옵션을 켠 상태에서는 nonisolated(nonsending) 이 기본값이 되므로 performAsync는 caller 에 해당하는 actor 의 executor 에서 동작을 이어갑니다.
struct S: Sendable {
// 📍 `nonisolated(nonsending)` 이 디폴트
func performAsync() async {}
}
actor MyActor {
let s: Sendable
func call() async {
await s.performAsync() //📍 actor 의 executor 에서 동작
}
}여기서 한가지 의문이 들 수 있는데요!
🤔 : 그럼 진짜로 global executor 에서 동작하게 하고 싶으면 어떻게 해?
: @concurrent 키워드 써라 ㅇㅇ
@concurrent
@concurrent 키워드를 통해 현재 액터로부터 항상 벗어나(switch off) 실행되어야 함을 명시할 수 있습니다.
@concurrent
func concurrentAsync(_ data: MyClass) async { }Task 안에서도 @concurrent in 으로 사용할 수도 있습니다. (❓그럼 이건 Task.detached 랑 뭐가 다른걸까요?? 흠냐링)
Task { @concurrent in }참고로 non-async 함수에는 @concurrent 키워드가 붙을 수 없습니다
@concurrent 의 사용은 NonIsolatedNonSendingByDefault flag 와 함께 사용할 때 가장 의미가 있는데요!
이 플래그가 NO 로 설정되어 있다면, nonisolated async 함수는 global executor 에서 동작하게 됩니다.
// 📍 NonIsolatedNonSendingByDefault - NO
struct S: Sendable {
// nonisolated async 함수 -> `@concurrent` 가 기본 -> global executor 에서 동작
func alwaysSwitch() async {}
}하지만 이 플래그가 YES 로 설정되어 있다면, nonisolated async 함수는 자동으로 nonisolated(nonsending) async 으로 간주되면서, caller actor 의 executor 에서 실행됩니다.
// 📍 NonIsolatedNonSendingByDefault - YES
struct S: Sendable {
// nonisolated(nonsending) async 함수 -> caller actor 의 executor 에서 동작
func alwaysSwitch() async {}
}따라서 의도에 따라 함수를 @concurrent 로 표시하는 것은 코드를 미래에도 유효하게 만들고, 개발자의 의도 또한 명시적으로 표현할 수 있습니다.
마무리
마지막으로 NonIsolatedNonSendingByDefault flag 가 YES 로 설정되어있을때, MyActor 에서 호출하는 각각의 비격리 함수는 어느 executor 에서 실행되는지 주석으로 살펴보고 끝내겠습니다.
struct S: Sendable {
func performSync() {}
// `nonisolated(nonsending)` 가 기본
func performAsync() async {}
@concurrent
func alwaysSwitch() async {}
}
actor MyActor {
let s: Sendable
func call() async {
s.performSync() // actor 의 executor 에서 동작
await s.performAsync() // actor 의 executor 에서 동작
s.alwaysSwitch() // global generic executor 로 전환
}
}만약 NonIsolatedNonSendingByDefault flag 를 NO 로 설정한 상태에서, 위 코드와 동일한 executor 에서 실행시키기 위해서는, 명시적으로 nonisolated(nonsending) 을 작성해야합니다. 또한 @concurrent 는 생략할 수 있습니다.
struct S: Sendable {
func performSync() {}
// 명시적으로 작성하지 않으면 `nonisolated` 가 기본
nonisolated(nonsending)
func performAsync() async {}
// nonisolated async -> `@concurrent` 가 기본
func alwaysSwitch() async {}
}