[Swift] Incorrect actor executor assumption 해결하기: Actor와 GCD의 올바른 연결법
AI 98 % + 인간 2 % 정도의 노력으로 작성된 글 ◠‿◠
안녕하세여! 오늘은 Incorrect actor executor assumption 이 나와서 지피티한테 도움받던 중에,, 이 내용은 어디 좀 적어둬야겠다..해서 돌아왔습니다 ㅎㅎ
맞아여 순전히 제 메모장용이고요? 틀린 내용 있다면 알려주시면 감사합니다!?!
문제 상황 — Actor와 Legacy API의 충돌
Swift 5 모듈에 MyClient class 와, 이 안에는 global queue 에서 completion 을 호출하는 networking 함수가 있습니다.
public class MyClient: @unchecked Sendable {
public static let shared = MyClient()
public func networking(completion: ((String)->Void)?) {
DispatchQueue.global().async {
completion?("value")
}
}
}Swift 6 모듈에는 MyActor actor 가 있고, 이 안의 test 함수는 MyClient.shared.networking 함수를 호출하고 completion 으로는 self 를 캡처해서 넘기고 있습니다.
actor MyActor {
var myValue: String = ""
func test() {
MyClient.shared.networking { data in
self.myValue = "hello"
}
}이 상태에서 MyActor().test() 함수를 호출하면 Incorrect actor excutor assumption run time 에러가 발생합니다.
원인 — 실행자(Executor)의 불일치
- 액터 격리:
MyActor의test()함수는self를 캡처하기 때문에 액터 격리 함수로 간주됩니다. 즉, 이 함수는MyActor의 실행자에서만 실행될 것이라고 보장됩니다. MyClient의networking함수: 이 함수는 명시적으로DispatchQueue.global().async를 사용해 글로벌 큐에서completion핸들러를 호출합니다. 이 글로벌 큐는MyActor의 실행자와는 완전히 다른 실행자입니다.- 불일치:
MyClient의networking작업이 완료되면,completion핸들러 내부의 코드가 글로벌 큐의 실행자에서 실행됩니다. 그러나 컴파일러는self를 캡처하는 이 코드가 액터의 실행자에서 실행될 것이라고 기대합니다. 코드의 실제 실행 환경과 컴파일러의 예상 환경이 다르기 때문에 "실행자 가정 불일치(incorrect executor assumption)" 에러가 발생하는 것입니다.
문제 해결 과정에서 발견한 흥미로운 사실들
문제의 원인을 파악하기 위해 다양한 테스트를 진행했을때, 오류가 해소되는 몇가지 케이스를 발견했는데요! 각 사례와 원인은 다음과 같습니다.
MyActor함수에서 completion 으로 block 에self를 캡처하지 않으면 에러가 발생하지 않는다
Task안에self를 캡처하지 않는 경우:클로저가 더 이상 액터의 격리된 상태에 접근할 필요가 없으므로, 컴파일러는 이 코드가 액터의 실행자에서 실행되어야 한다는 가정을 하지 않습니다.
2. MyActor 를 class 로 변경하면 에러가 발생하지 않는다 (단, 코드는 Swift 6 이기 때문에 @unchecked Sendable 을 붙여야한다)
MyActor를class로 변경하는 경우:
class는actor와 달리 격리된 실행자 개념이 없습니다. 따라서 실행자 가정 자체가 성립하지 않으므로, 에러가 발생하지 않습니다.
3. Swift 5 모듈에 있는 MyClient 코드를 Swift 6 모듈로 옮기면 에러가 발생하지 않는다
MyClient코드를 Swift 6 모듈로 옮기는 경우:Swift 6 컴파일러는 정적 분석을 통해
networking함수의completion핸들러가 격리되지 않았다는 것을 인식합니다.self를 캡처하는 순간, 컴파일러는 안전을 위해 액터의 실행자로 전환(hop)하는 코드를 자동으로 삽입합니다. 이 "hop" 덕분에 클로저는 액터의 실행자에서 안전하게 실행되어 충돌을 해결합니다.
4. MyClient 함수에서 completion 을 DispatchQueue.global().sync 에서 호출하면 에러가 발생하지 않는다 (main.sync , main.async, global().async 는 모두 에러 발생)
DispatchQueue.global().sync로completion을 호출하는 경우:
MyActor.test()함수는 액터의 실행자에서 실행 중이고, 그 안에서DispatchQueue.global().sync가 호출되면, 현재의 액터 실행자 스레드는 글로벌 큐의 작업이 끝날 때까지 멈춥니다.그리고 글로벌 큐는 다른 스레드를 생성하여
completion을 호출할 것 같지만,sync안에 들어간 block 은 성능상 가능하면 현재스레드를 사용합니다 (참고 - sync(execute:)). 즉, 최적화에 의해 액터의 실행자가 그대로 유지되어서 현재 스레드에서completion를 호출하기 때문에,self.myValue = ""코드가 실행될 때, 이 코드는 여전히MyActor의 실행자 스레드 위에 있습니다. 컴파일러가 기대하는 실행자(액터의 실행자)와 실제 실행되는 스레드가 일치하므로 "Incorrect actor executor assumption" 에러가 발생하지 않습니다.
main.sync처럼 main dispatch queue 로 보내진 block 은 무조건 메인 스레드를 사용하기 때문에 액터의 실행자 스레드와 달라져 에러가 발생합니다.
5. MyClient 함수의 completion 을 @Sendable 마킹해주면 에러가 발생하지 않는다
public class MyClient {
public init() {}
public func networking(completion: (@Sendable (String)->Void)?) {
DispatchQueue.global().async {
completion?("value")
}
}
}
completion매개변수에@Sendable을 붙이는 경우:
@Sendable을 붙이면 컴파일러는 "이 클로저는 특별히 안전하니, 어떤 실행자에서 호출되어도 괜찮아"라고 판단합니다. 따라서self를 캡처하더라도 실행자 불일치에 대한 경고를 무시하게 되고,self.myValue = ""는 글로벌 큐에서 그대로 실행됩니다.결론적으로,
Sendable은 코드의 실행 위치를 바꾸는 것이 아니라, 실행 위치와 상관없이 데이터 접근의 안전성을 보증하는 역할을 합니다. 이 보증 덕분에 컴파일러의 엄격한 검사를 통과할 수 있게 되는 것입니다.
6. Swift 6 모듈의 language 를 Swift 5 로 수정하면 에러가 발생하지 않는다
Swift 6 모듈의 언어 설정을 Swift 5로 바꾸는 경우:
Swift 5는 Swift 6과 같은 엄격한 동시성 검사 규칙을 적용하지 않습니다. 액터의 격리 및 실행자 규칙이 강제되지 않으므로, 잠재적인 데이터 경쟁이 발생할 수 있음에도 불구하고 컴파일러 에러는 발생하지 않습니다.
추가적으로 import Swift5Module 앞에 @preconcurrency 를 붙여도 에러가 발생하는데 왜그럴까? 에 대한 의문도 있었습니다.
@preconcurrency를 붙여도 에러가 발생하는 이유는@preconcurrency가 다른 종류의 오류를 해결하기 때문입니다.
@preconcurrency는Sendable경고를 억제하는 역할을 합니다. 하지만 당신이 겪는 에러는 실행자 가정(Executor Assumption) 불일치 오류이므로,@preconcurrency는 이 문제를 해결할 수 없습니다.
해결책 — Actor Hop의 명시적 구현
이 문제는 다음과 같은 불일치에 의해 발생했습니다.
MyActor의test()가 액터의 실행자에서 시작됩니다.- 이는 격리 체인을 깨뜨리는 비-액터 함수인
networking을 호출합니다. networking함수는 다른 실행자(글로벌 큐)에서 완료 핸들러를 반환합니다.- 완료 핸들러는 비-액터 실행자에서 액터의 격리된 작업(
self를 캡처함으로써)을 재개하려 시도합니다.
이것이 바로 “잘못된 액터 실행자 가정 (Incorrect actor excutor assumption)”입니다. 컴파일러는 MyActor의 코드가 항상 자신의 보호된 실행자 내에 머물러야 한다고 가정하지만, networking 콜백은 적절한 Swift 동시성 규칙(자동으로 실행자 hop을 처리하는 await 호출 사용 등)을 따르지 않고 다른 실행자로부터 "다시 뛰어들려" 시도하는 것입니다.
이를 위한 해결책으로 액터 홉(Hop)을 명시해야합니다. 즉, 격리된 작업을 계속하기 전에 액터의 실행자로 “다시 뛰어들도록” 컴파일러에게 명시적으로 알려야 합니다. 가장 현대적이고 올바른 방법은 이전 API를 새로운 async 함수로 감싸는 것입니다.
extension MyClient {
public func networkingAsync() async -> String {
return await withCheckedContinuation { continuation in
self.networking { data in
continuation.resume(returning: data)
}
}
}
}
actor MyActor {
func test() async { // test 함수를 async로 변경
let data = await MyClient.shared.networkingAsync() // async 버전 호출 및 await
print("Hello") // 이 코드는 이제 안전하게 액터의 실행자에서 실행됩니다
}
}이러한 리팩토링을 통해 액터의 상태에 대한 모든 상호 작용이 전용의 격리된 컨텍스트 내에서 이루어지도록 보장하여, “Incorrect actor executor assumption” 에러를 방지할 수 있습니다
