[Swift] Actor 뿌시기

근데 이제 async, Task 를 곁들인..

naljin
62 min readJan 7, 2023

들어가기 전에

개-하!

작년, 아니 이제 2023 이니까.. 재작년 Apple 에서는 Swift Concurrency 를 소개하면서 async/await, Task, structured concurrency, Actor 와 같은 많은 언어적인 특징들을 도입했습니다.

Concurrency : Perform asynchronous and parallel operations

그 중에서 저는 원래 Actor 만 좀 찍먹해보려고 했는데,, Actor 를 좀 보려면 Task 를 알아야하고, Task 를 알려면 async/await 를 알아야하고,, 튼 다들 깊게 얽혀있더라고요?ㅎㅎㅎㅎㅎㅎ

실수로 actor 를 한글로 타이핑할 때가 있었는데, 그때 입력된 글자가.. 딱 액터에 대한 제 심정이었던..^^!

아무튼 그래서 별안간 Get started with Swift concurrency 에 나와있는 모든 WWDC 영상을 보고 (보다 O 이해하다 X), 책까지 무슨 수특마냥 공부하게 되었는데여,,

ㅋㅋ 진짜 수특도 이렇게는 안했었다구요,,,

이렇게 본 내용들을 저의 것으로 받아들였을지 ㅎㅎ 과연 정보들을 구조화 시킬 수 있을지~~ 가보자고여~

웃겨.. 아니 안웃겨..

(아무리 그림을 많이 넣었다지만.. 이거 pdf 로 빼내면 50장이 넘게 나오고, 미디엄에서 읽는 시간이 60분도 넘게 뜨는 진짜 진짜로 긴 글이에요.. 각잡고 스크롤 내리실 준비 되셨나요??? 그럼 진짜 ㄱㄱ!)

비동기 코드

비동기 코드는 멀까요. 동기. 비동기. 우웩 🤢

iOS 15 Programming Fundamentals with Swift 에 나오는 설명을 빌리자면 “비동기 코드”는 나중에 알 수 없는 시간에 호출될 수 있는 코드입니다.

그렇기 때문에 비동기 코드 라인에 도달한다고 해도

  1. 해당 비동기 코드는 바로 실행 되는 것도 아니고
  2. 이후 라인에 있는 코드 실행을 막지도 않습니다

우리가 자주 써왔던 DispatchQueue.main.async {} 를 예로 들어볼까요?

async 함수는 블록 안에 있는 코드를 비동기식으로 실행하도록 예약합니다. 그렇기 때문에 여기서는 print("2") 가 비동기 코드가 됩니다.

이 코드를 가지고 프로그래밍을 처음하는 사람에게 질문해볼게요

👩🏻‍💻 : 프린트 순서가 어떻게 될까요?

🤔 : 뭔가 함정 질문인것 같긴 하지만,, 코드 순서가 1, 2, 3 이니까 이 순서대로 찍히지 않을까요?

👩🏻‍💻 : 앗..! 사실은 1, 3, 2 대로 찍힐거예요

🤔 : ?? 왜요?

👩🏻‍💻 : 비동기라서요!

🤔 : …???????

자 이렇게 쌍방으로 어리둥절한 상황을 막기 위해 아까 설명한 비동기 코드의 특징을 다시 가져와봅시다.

  1. 비동기 코드는 나중에 알 수 없는 시간에 호출될 수 있는 코드이기 때문에, 해당 라인에 도달해도 바로 실행되지 않음
  2. 이후 라인에 있는 코드 실행을 막지도 않음

그러니까 코드를 읽을 때 이런 식으로 생각해야 한단 말이죠

zz 여기서 한번 더 나아가볼까요?

🤔 : 그럼 비동기로 실행되는 코드는 언제 시작되고 끝나는지를 알 수 없는거 같은데,,, 그럼 비동기 코드가 끝나는 시점을 어떻게 잡아서 다음 코드를 실행해여?

👩🏻‍💻 : Swift에서는 보통 클로저를 통해 해당 시점을 알려줍니다. 이를 completionHandler 또는 completion 이라고 해요!

이런 비동기 함수의 호출과 completionHandler 콜백 형식은 봤다시피 읽기 어려울 뿐만 아니라, 아래와 같은 여러가지 문제점들이 있었는데요

이러한 노답 상황을 해결하기 위해 Swift 5.5 부터 async & await 가 야심차게 도입되었습니다.

이전처럼 비동기 코드 완료 시점을 잡기 위해 completionHandler 를 쓴다 뭐한다..할 필요 없이! 아래처럼 비동기 코드도 마치 동기 코드인것처럼 작성할 수 있게 되었습니다.

직-관

비동기 함수는 바로 값을 return 할 수도, error 를 throw 할 수도 있게 되었습니다. 한 비동기 함수를 호출하는 쪽에서는 await 키워드와 함께 호출하기 때문에, 호출하는 함수가 비동기 함수라는것도 바로 추측할 있게 되었습니다.

async/await

async/await 에 대한 자세한 내용은 [Swift] async / await & concurrency 에 예전에 포스팅 해뒀어요!! 장하다 나의 과거!!! 하지만 이어지는 설명을 위해 몇몇 핵심 내용만 긁어와볼게여.

함수 이름 뒤async 가 붙으면 이 함수는 비동기라는 것을 나타냅니다.

async 함수를 호출하기 위해서는 await 키워드가 필요합니다.

여기서 await 로 마킹된 곳은 "potential suspension point(잠재적인 일시 중단 지점)로 지정된다" 를 이해하는 것이 중요합니다.

Suspend 된다는 것은 해당 thread 에 대한 control 을 포기한다는 것입니다. 원래 코드를 돌리고 있던 스레드에 대한 제어권system 에게 가고, 시스템은 해당 스레드를 사용하여 다른 작업을 수행할 수 있게 됩니다. 즉, await 로 인한 중단은 해당 스레드에서 다른 코드의 실행을 막지 않습니다.

한편 스레드 제어권시스템에게 넘기면서, 원래 async 작업에 대한 스케쥴시스템에게 넘어갑니다.

어느 시점에 이르면 (곧바로 될 수도 있겠죠?) 시스템이 일시 중단된 비동기 함수를 재개(resume) 해야겠다고 판단하는 순간이 오고, 이 함수의 수행을 위해 스레드를 할당합니다. 이때 멈췄던 작업을 재개하는데 할당 된 스레드가, await 호출 전에 코드를 실행한 스레드와 동일 스레드라는 보장은 없습니다.

await 키워드를 통해 code block 이 하나의 transaction 으로 처리되지 않을 수 있다는 것을 인식할 수 있어야합니다. function은 suspend 되고, 다른 것들이 먼저 실행 될 수 있기 때문에 일시 중단 되는 동안 앱의 상태가 크게 변할 수 있습니다.

이렇게 스레드를 차단하는 대신, 스레드의 제어권을 포기하고 작업을 중지 / 재개할 수 있는 개념을 도입함으로써 이전에 비해 어떤 점이 나아지게 된걸까요? 먼저 Grand Central Dispatch 를 사용하던 이전의 상황을 살펴보겠습니다

Grand Central Dispatch 에서 작업(work)이 queue에 들어오면 시스템이 스레드를 불러와 해당 work item을 수행합니다. concurrent queue 는 여러 work item 을 한 번에 처리할 수 있기 때문에, 모든 CPU 코어가 포화될 때까지 시스템은 여러 스레드를 불러옵니다.

애플 워치처럼 CPU 가 두개인 듀얼 코어 상황을 가정해볼게여

이때 아래 그림에서 볼 수 있듯이 어떤 이유로 스레드가 차단된 상태에서 concurrent queue 에서 수행해야 할 작업이 더 많은 경우, GCD는 나머지 작업 항목(work item)을 처리하기 위해 스레드를 더 많이 불러옵니다.

이렇게 처리하는 이유는 두 가지입니다.

첫째, 프로세스에 다른 스레드를 제공함으로써 각 코어는 언제든지 작업(work)을 실행 중인 스레드를 계속 갖고 있을 수 있습니다. 이를 통해 어플리케이션은 지속적인 수준의 동시성 (continuing level of concurrency) 을 제공합니다.

둘째, 차단된 스레드는 추가 진행을 위해 세마포어와 같은 리소스를 기다리고 있을 수 있습니다. queue 에서 작업을 계속하기 위해 실행되는 새 스레드는, 첫 번째 스레드에서 대기 중인 리소스의 차단을 해제 (unblock) 하는 데 도움이 될 수 있습니다.

이렇게 스레드를 새로 생성하고 작업을 수행함으로써 생기는 이점도 있지만, 만약 block 되는 스레드가 많아지고, 남아 있는 작업들을 수행하기 위해 스레드가 계속 생성된다면 어떨까요? 여전히 괜찮은 상황일까요? 아니면 나쁜 상황일까요?

애플리케이션에 스레드가 많다는 것은 시스템이 CPU 코어보다 더 많은 스레드로 가진다는 것을 의미합니다 (overcommitted).

만약 6개의 CPU 코어가 있는 아이폰에 100 개의 스레드를 갖게 된다면, 코어보다 약 16배나 많은 스레드로 아이폰을 overcommit 했다는 의미입니다. 이러한 현상을 우리는 “thread explosion” 라고 부릅니다.

Thread Explosion 은 데드락을 포함해 메모리 및 스케줄링 오버헤드 등의 여러 문제점을 야기합니다. 간단히 알아볼까요?

메모리 오버헤드차단된 각 스레드는 재실행을 기다리는 동안 메모리와 리소스를 보유하고 있습니다. 각 스레드는 스택 및 스레드 추적을 위한 커널 데이터 구조를 갖고 있고, 일부는 실행 중인 다른 스레드에 필요한 lock 을 가지고 있을 수도 있습니다. 이렇듯 멈춰있는 스레드에서 많은 리소스와 메모리를 유지하고 있습니다.

스케줄링 오버헤드 — CPU 는 이전 스레드에서 새 스레드로 전환을 실행하기 위해 전체 스레드 컨텍스트 스위치 (full thread context switch) 를 수행해야 합니다. 스레드가 폭발적으로 증가할 경우에는 과도한 context switch 가 발생할 수 있습니다.

지금까지 살펴본 바와 같이, GCD queue로 애플리케이션을 개발할 때는 이러한 스레드 관점의 생각을 놓치기 쉬우므로 성능이 저하되고 오버헤드가 더 커질 수 있습니다.

따라서 새로운 동시성을 설계할 때, Swift 는 다른 접근 방식을 취했습니다. 앱의 실행을 스레드와 컨텍스트 스위치가 많은 이전 모델에서, 스레드 간 컨텍스트 스위치가 없고, continuation 으로 구성된 모델로 변경하고자 했습니다.

새로운 Swift Concurrency 에서는 최대로 실행되는 스레드 개수가 코어 개수와 동일하며 스레드 간의 context switch가 없습니다.

스레드의 차단(block)은 모두 사라지고, 대신 작업의 재실행(resumption of work)을 추적하는 continuation 으로 알려진 가벼운 객체가 있습니다. 스레드는 full thread context switch 를 수행하는 대신, 같은 스레드 내에서 continuation 를 전환합니다. 이는 이제 우리가 함수 호출 비용 정도만 감당하면 된다는 사실을 의미합니다.

이렇게 Swift concurrency 에서 지향하는 바는

  1. CPU 코어의 수만큼스레드를 만들고
  2. 스레드를 차단하지 않고도, 저렴하고 효율적으로 작업을 전환할 수 있도록 하는 것입니다.

Swift Concurrency을 지원하기 위한 새로운 협력 스레드 풀(cooperative thread pool)CPU 코어 수만큼의 스레드만 생성합니다. 이를 통해 애플리케이션에 필요한 동시성을 제공하는 동시에, 과도한 동시성의 위험을 방지할 수 있습니다.

여기까지 async 와 await 에 대한 간단한(?) 설명이었는데요, 뭐라는 거임?? 하시는 분들은 꼭 [Swift] async / await & concurrency 글을 보고 오세요!

이제 suspension point 는 뒤로하고, 아까 async 함수는 await 키워드와 함께 호출하면 된다고 말했는데요, 진짜 await 만 명시해주면 될까요?

푸핰 어림없지 바로 에러나버리기!

그래 어떻게 고치라는지 보자.. 하고 Fix 를 누르면?! async 함수를 부르는 함수 (callAsyncFunction) 자체를 또 비동기 함수로 만들라고 합니다

바로 이렇게 말이죠

🤔 : 그럼 결국 callAsyncFunction 을 호출하는 곳도 또 다시 async 로 만들어줘야 할테고,,?? 계속 계속 모든 함수를 async 로 만들어줘야한다구??? viewDidLoad 같은 일반 메서드에서는 도대체 async 함수 호출을 어떻게 시작하라는거임?!

맞아여. async 함수를 호출하기 위해, 다른 함수도 async 로 만드는건 async 호출에 대한 문제를 위로 미루는 방법이라고 할 수 있습니다. 언젠가는 async 함수를 호출하기 위한 근본적인 방법이 필요합니다.

여기서 바로 Task 가 등장합니다.

기본적으로 async 코드는 동시(concurrent) 컨텍스트 에서만 실행 가능합니다. 즉, 다른 async 함수 내에서, 또는 Task {} 를 통해 수동으로 concurrent context 를 제공할 때 그 안에서 가능합니다.

Task 는 항상 명시적으로 생성해야합니다. 함수를 async 로 표시 한다고 해서 새 Task 가 생성되는 것은 아닙니다. 한 비동기 함수에서 다른 비동기 함수를 호출할 때는 동일한 Task 가 호출을 실행하는 데 사용됩니다

그럼 Task 를 알아보러 가볼까여?

Task

Task 는 비동기 작업 단위 (A unit of asynchronous work) 입니다.

따라서 위의 코드의 실행 결과도, 이전 비동기 코드 섹션에서 살펴본 것과 같이 1->3->2 순서로 프린트 됩니다

Task 라는 비동기 context 안에서 우리는 async 함수를 호출 할 수 있습니다.

이렇게 Task비동기 코드를 실행하기 위한 새로운 실행 맥락을 제공합니다.

새로운 비동기 Task 를 생성하고, 이 안에서 비동기 함수를 호출하는 작업은 global dispatch queue 에서 async 를 호출하는 것과 매우 유사합니다 (사실 MainActor 에서 실행하면 main queue 에서 돌리는 것과 비슷할텐데, 요건 좀 이따 Actor 쪽에서 더 자세히 설명하겠습니다).

즉, 각 Task임의의 스레드에서 다른 실행 맥락과 함께 동시에 실행됩니다.

그림과 같은 여러 개의 Taskconcurrent code 의 기본 작업 단위 (basic unit of work in concurrent code) 입니다. 즉, 코드를 병렬로 실행하는 기본 메커니즘으로, 각 Task는 다른 Task 와 함께 동시(concurrent)에 작업을 실행할 수 있습니다 (하나의 Task 자체가 concurrency 를 의미하는 것은 아닙니다).

여러 개의 Task 를 만들면 동시에 여러 개의 작업을 수행할 수 있기 때문에, 각 Task 를 바다를 떠다니면서 각자의 일을 독립적으로 수행하는 보트라고 생각 할 수 있습니다.

그럼 하나의 Task 에 대해 조금 더 자세히 살펴보겠습니다.

위에서 Task 의 block 자체는 비동기로 실행된다고 말했습니다. 그래서 아래 코드는 1->3->2 순서로 프린트가 된다는 것을 확인했습니다.

이렇게 Task 가 제공하는 비동기 context 덕분에, 이 안에서는 async 함수를 호출할 수도 있었습니다.

한편 Task 안에서의 작업은 처음부터 끝까지 순차적으로 실행됩니다. await 를 만나면 작업은 몇번이고 중단될 수는 있지만, 실행 순서가 변경되지는 않습니다.

마지막으로 Task 는 독립적입니다. 각 Task 는 자체 리소스가 있어서 다른 Task 들과 독립적으로 동작할 수 있습니다. 즉, Task 를 보트로 표현할 때, 바다에 있는 다른 보트들과 독립적으로 동작할 수 있습니다.

이렇게 독립적으로 작동하고 있던 Task 들이, 서로의 데이터를 공유하고 싶다면 어떻게 될까요?

예를 들어 아래 코드에서는 태스크가 반환하는 파인애플 값이, 해당 값을 기다리는 태스크에 제공됩니다.

이 상황을 그림으로 표현하자면 요렇게 되는거져

만약 이때 pineapple 이 struct 와 같은 value type 이라면 그냥 복사해주고 헤어지면 됩니다. 한 보트에서 다른 보트로 파인애플 복사본을 전달하고, 각 보트는 각자의 복사본을 챙겨서 떠납니다.

이후 각 보트에서 mutating 메서드를 통해 파인애플을 변형하는 경우에도, 다른 복사본에는 아무런 영향을 미치지 않습니다. Swift 는 바로 이런 이유로 항상 값 유형을 선호해왔습니다. 변형(mutation) 이 국소적인 영향(local effect)만 미치니까요.

하지만 만약 class 로 모델링된 닭을 공유한다면 어떻게 될까요?

class 는 reference type 이기 때문에, 복사가 아닌 특정 객체에 대한 참조를 제공합니다.

두 보트(Task)는 동시(Concurrently)에 자신의 일을 하고 있지만 둘 다 동일한 닭 객체를 참조하기 때문에 독립적이지 않습니다. 하지만 아까 말했죠? 각 Task 는 자체 리소스가 있어서 다른 Task 들과 독립적으로 작동해야한다고!!! 동일한 닭에 대해 한 보트는 먹이를 주고싶어하고, 다른 살을 빼고 싶어하면 닭은 매우 혼란스러워집니다. 있어보이는 말로 Data Race 상황이라고 할 수 있져.

그럼 보트(Task) 사이에서 파인애플을 공유하는건 안전해도, 닭은 안전하지 않다는걸 어떻게 알 수 있을까요? 닭과 같은 안전하지 않은 타입이 다른 보트로 옮겨지지 않도록 Swift의 컴파일러에서부터 확인할 수 있을까요?

여기서 “동시(concurrently)에 사용해도 안전한 타입”을 지칭하는 개념이 등장합니다. 바로 Sendable 프로토콜 입니다.

protocol Sendable {}

Sendable 프로토콜은 Data race를 생성하지 않고, 서로 다른 격리 도메인 간에 안전하게 공유할 수 있는 타입을 설명합니다. 즉, 복사를 통해 값을 동시성 도메인에 안전하게 전달할 수 있는 타입입니다.

빌드 세팅의 Strict Concurrency Checking 을 "Complete" 로 체크 (Swift 6 에서 동작 예정) 하고 코드를 돌려보면

전에는 보이지 않았던 ‘ChickenSendable 하지 않기 때문에 안전하지 않다’는 경고가 표시됩니다.

Sendable 과 관련된 이러한 경고는 사실 ‘Task 의 Success result type 이 Sendable protocol 을 준수한다’는 Task 자체의 정의에서 유래합니다.

Sendable 프로토콜을 conform 할 수 있는 대략적인 리스트는 다음과 같습니다. 더 자세한 조건은 Sendable 문서를 참고해주세요.

  • 값 타입
  • mutable storage 가 없는 참조 타입
  • 내부적으로 상태에 대한 액세스를 관리하는 참조 타입
  • @Sendable로 표시된 함수 및 클로저

참조 타입인 닭과 달리, 파인애플은 값 타입입니다. 즉 Sendable 을 conform 하기 때문에, 위에서 에러가 났던 동일한 코드를 닭에서 파인애플로 변경하면 아무런 에러도 뜨지 않습니다.

지금까지 살펴본 내용을 정리해보자면 Task 는 아래와 같은 특징을 갖고 있습니다.

  • 격리되어있고 (isolated), 독립적으로(independently) 비동기 작업을 수행합니다.
  • 값이 공유될 상황이 있을때는 Sendable 체킹을 통해 Task 가 격리된(isolated) 상태로 남아있는지 체크합니다

그러나 Task 간에 공유할 수 있는 가변 데이터(shared mutable data)가 아예 없다면 Task 를 의미있게 사용하기 어렵습니다. 따라서 데이터 레이스를 도입하지 않으면서도, Task 간에 안전하게 가변 데이터를 공유할 방법이 필요합니다.

여기서 바로 Actor 가 등장합니다. (드디어 왔네요 ㅎㅎ 액터.. ㅎㅎㅎ 왜 Actor 를 찍먹하려고 했는데 Task 와 async/await 부터 알아야했던건쥐,, 이해되시져,,?)

사실 저는 Actor 가 Swift Concurrency 에서 새로 등장한 용어인줄 알았는데 친구가 “엇 스프링에도 actor 있는디?” 라고해서 찾아보니까 한 50년 전에 등장한 일반적인 개념이더라구요 ^^,,?! 쩝..ㅎ

Actor

Actor공유 데이터에 접근해야 하는 여러 Task 를 조정합니다. 외부로부터 데이터를 격리하고, 한 번에 하나의 Task만 내부 상태를 조작하도록 허용함으로써 동시 변경으로 인한 데이터 레이스를 피합니다.

🤔 : ??? 뭐라고?? 코드로 어떻게 사용하는건데;;

actor 는 그냥 class, struct, enum 과 같은 object type 이에요! 그래서 이렇게 선언할 수 있습니다.

actor MyActor {}

만드는 방식이 꽤나 익숙하져?

actor 의 목적이 공유되는 가변 상태(shared mutable state)를 표현하는 것이기 때문에 class 와 마찬가지로 reference 타입입니다. 따라서 actor instance 에 하나 이상의 참조가 걸릴 수 있어요.

참고로 Actor type 은 class 와 다르게 상속은 지원하지 않고, 암시적으로 Sendable 입니다. 아까 말했져? Sendable 은 값을 동시성 도메인에 안전하게 전달할 수 있는 타입이라구요! Actor 도 이에 해당합니다. 참조 타입인 주제에 어떻게 동시성 도메인에서 전달될 때 안전할 수 있는지는 이 글을 읽다보면 알게 될겁니다 ㅎ

그럼 이렇게 생성된 actor 가 도대체 어떻게!!! 데이터 레이스를 도입하지 않으면서도, Task 간에 데이터를 공유할 수 있도록 하는지 알아봅시다.

자 근본적인 의문으로 돌아와볼게요. 데이터 레이스는 왜 생길까요?

간단히 말하자면, 두 개 이상의 스레드를 사용하면서 동일한 메모리 접근하는 작업으로 인해 발생할 수 있습니다. 엄밀히 말하면 read 만하면 문제가 없어요. 근데 write 때문에 중간에 데이터가 바뀔 수 있으니까 문제져.

🤔 : 흠.. 그럼 공유 데이터에 한번에 하나씩만 접근하게 한다면, 즉 여러 곳에서 동시에 접근하는 상황을 막으면 데이터 레이스를 막을 수 있나?

맞습니다! 이게 바로 정확히 actor 의 특징 중 하나예요! actor 에는 동시에 하나의 Task 만 접근 할 수 있습니다. 따라서 actor 의 변경 가능한 프로퍼티도 여러 스레드에서 동시에 접근되지 않습니다.

actor 의 주된 특징은 인스턴스 데이터를 프로그램의 나머지 부분에서 격리(isolate)하고, 해당 데이터에 대한 동기화된 액세스를 보장한다는 것인데요, actor 의 모든 특징은 이 핵심적인 생각들로부터 나옵니다.

흠.. 설명이 길었죠? 바로 actor 는 어떻게 생겼나 드루가봅시다

actor SharedWallet {
let name = "공금 지갑"
var amount: Int = 0

init(amount: Int) {
self.amount = amount
}

func spendMoney(ammount: Int) {
self.amount -= ammount
}
}

🤔 : 생긴게 class랑.. 비슷한데..?

맞아여 class 와 actor 의 주된 차이점은 외부 사용법에 있는데, 어떤 차이가 있는지 주석으로 살펴볼게요

Task {
let wallet = SharedWallet(amount: 10000)
let name = wallet.name // 1. 상수는 변경 불가능하기 때문에 어느 스레드에서 접근해도 안전함. actor 외부에서도 바로 접근 가능
let amount = await wallet.amount // 2. actor 외부에서 변수 접근시 await 필요
await wallet.spendMoney(ammount: 100) // 3. actor 외부에서 메서드 호출시 await 필요
await wallet.amount += 100 // 4. ❌ 컴파일 에러. actor 외부에서 actor 내부의 변수를 변경할 수 없음
}

우선 actor 의 프로퍼티 변경은 actor 내부에서만 가능합니다. 그래서 마지막 라인과 같은 wallet.amount += 100 코드는 바로 컴파일 에러가 발생합니다.

또한 actor 는 언제 접근이 허용되는지 확실하지 않기 때문에 actor 의 변경 가능한 데이터에 대한 비동기 액세스를 생성 합니다. 이미 다른 Task 들이 actor 에 접근해서 코드 실행하고 있으면 해당 actor 에 대한 다른 코드는 실행되지 못하고 기다려야합니다. 따라서 await 라는 키워드를 쓰는게 적절해보이져?

Actor serialization

actor 는 여러 스레드에서 동시에 실행되지 않습니다. 즉 actor 에 대한 접근은 직렬화(serialized) 됩니다.

아래와 같이 각 Task 에서 Counter actor 의 increment method 를 호출하고 값을 프린트할 때, 1 이나 2 가 프린트 되는 곳은 달라질 수 있지만 두 Task 모두 같은 값을 얻을 수 는 없습니다 (1,1 이나 2,2 같이 말이죠).

이는 actor 의 내부적인 동기화 매커니즘이 다른 호출이 시작되기 전에, 진행되고 있던 호출을 완료하도록 보장하기 때문입니다.

뭔가 직렬화되어 작업을 수행한다는 점에서 GCD 의 serial queue 랑 비슷하져? 하지만 actor 는 재진입(reentrant)이 가능한데, 이 개념은 조금 이따 보도록 하겠습니다. 우선은 직렬화가 어떻게 수행되는지 조금 더 자세히 살펴볼까요?

사실 actor는 Actor protocol 를 implement 한 class 를 위한 syntax sugar 입니다. (참고 - How Actors Work Internally in Swift)

public protocol Actor: AnyObject, Sendable {
nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

보다시피 Actor protocol 안에는 unownedExecutor 가 있습니다.

이게 뭔지 파악하기 위해 먼저 Executor protocol 을 알아봅시다.

@available(SwiftStdlib 5.5, *)
public protocol Executor: AnyObject, Sendable {
func enqueue(_ job: UnownedJob)
}

음.. 얘는 “job”을 수행할 수 있는 object 를 정의한다네여! enqueue method 를 필수적으로 요구합니다.

또한 Excutor 를 conform 하는 SerialExecutor 프로토콜은 job들을 serially 하게 수행할 수 있는 object를 정의합니다.

@available(SwiftStdlib 5.5, *)
public protocol SerialExecutor: Executor {
func asUnownedSerialExecutor() -> UnownedSerialExecutor
}

마지막으로 Actor protocol이 실제로 요구하는 UnownedSerialExecutor 는 방금 살펴본 SerialExecutor 에 대한 unowned reference입니다. 최적화 관련한 이유로 존재합니다.

actor method를 호출하면 내부적으로 Swift는 actor 내부 executor의 enqueue(_:) method를 호출합니다. 데이터 경합을 방지하기 위해 순차적으로 접근할 수 있도록 동기화 접근이 필요하기 때문에 이처럼 serial executor를 사용하는 듯 합니다.

어휴 설명이 너무 길었네여. 지금까지 쭉 설명한 Actor 의 특징을 그림으로 다시 이해해봅시다.

위에서 Task 를 동시성 바다에서 둥둥 떠다니면서 각자 할일 수행하는 보트라고 설명했다면, Actor 는 동시성 바다에 있는 섬입니다.

보트(Task)처럼 각 섬(Actor)은 독립적이며 바다의 모든 것들로부터 격리되어 자신만의 상태를 가지고 있습니다.

섬(Actor)의 상태에 접근하기 위해서는 섬(Actor)에서 코드를 실행해야합니다. 그리고 코드를 섬에서 실행하기 위해서는 보트(Task)가 필요합니다. 즉, Actor 에서 실제로 코드를 실행하려면 Task 가 필요합니다.

보트(Task)는 섬(Actor)에서 코드를 실행하기 위해 섬(Actor)을 방문해야합니다.

사실 보트가 섬을 방문한다는 것은 마치 섬으로 가는 지도를 가진 것과 같습니다. 섬을 방문하기 위해 그 지도를 사용할 수 있지만, 그 상태에 액세스하려면 여전히 도킹 절차를 거쳐야합니다.

한번에 하나의 보트(Task)만 섬(Actor)을 방문해 코드를 실행할 수 있으므로, 여러 보트(Task)가 섬(Actor) 상태에 동시에 접근하는 일이 없도록 보장합니다. 만약 섬(Actor)을 방문하고자하는 다른 보트(Task)가 있다면 자신의 차례를 기다려야(await) 합니다.

보트(Task)가 섬(Actor)을 방문할 기회를 얻기까지는 오랜 시간이 걸릴 수 있습니다. 따라서 섬(Actor)에 들어가는 것은 await 키워드와 함께 잠재적인 중단 지점(suspension point)으로 표시됨을 알 수 있습니다. 일단 섬(Actor)에서 코드를 수행하는 보트(Task)가 없어야 다른 보트(Task)가 방문할 수 있습니다.

이렇게 Actor 는 모든 속성과 코드를 격리하고 동시 접근을 방지하기 때문에 서로 다른 격리 도메인에서 Actor 를 참조해도 안전합니다.

설명이 뭐 이리 추상적이야 😮‍💨 하시는 분들은 “Task 가 Actor 에 접근할 때는 다른 Task 들과 함께 동시에 실행될 수 없음!! Actor 라는 격리된 도메인에 접근하기 위해 기다리는 절차를 거쳐야함!” 정도로 이해하면 될 것 같습니다.

아까 동시성의 바다에서 둥둥 떠다니고 있던 보트(Task)들 간의 관계처럼, 보트(Task)와 섬(Actor) 사이에도 Sendable 하지 않은 타입통과하지 않도록 하여 서로의 격리를 유지해야 합니다.

예를 들어 보트(Task)에 있는 닭(class) 한마리를 섬(Actor)에 추가하려고 시도할 수 있습니다. 그러면 서로 다른 격리 도메인에서 동일한 닭 객체에 대한 참조가 각각 생성되므로 Swift 컴파일러는 이를 거부합니다. 즉 actor 에 격리된 method 에서 Sendable 하지 않은 파라미터를 거부한 것입니다.

마찬가지로 우리가 섬(Actor)에 있는 닭을 보트(Task)에 싣고 가려고하면 Sendable 검사를 통해 데이터 레이스를 방지합니다.

한편 Actor 에 격리된 코드..를 어떻게 구분할 수 있을까요? actor scope 안에 있으면 무조건 해당 actor 에 격리된 코드일까요? Actor의 격리는 우리의 context 에 따라 결정되는데요, 하나씩 알아보도록 합시다.

우선 actor 의 instance propertymethod 는 해당 actor 로 격리됩니다.

reduce 로 전달된 클로저와 같이 Sendable 하지 않은 클로저는 actor-isolated context 에 있을때 actor-isolated 됩니다.

Task initializer 또한 context 에서 actor isolation 을 상속하므로 생성된 Task 는 처음 시작된 actor 와 동일한 actor 에 대해 schedule 됩니다. 따라서 코드를 보면 await 없이 actor 내부 프로퍼티인 flock 에 대한 접근을 허용합니다.

반면에 detached 된 Task 는 context 에서 actor isolation 을 상속하지 않습니다. 즉 actor 로 격리되지 않습니다. 클로저에 있는 코드는 actor 외부에 있는 것으로 간주되므로 actor 에 격리된 food 프로퍼티를 참조하려면 await 를 사용해야 합니다

detached Task 와 비슷하게 actor 내부에 있는 함수를 명시적으로 비격리로 만들기 위해서 nonisolated 키워드를 사용할 수 있습니다.

이곳에서 actor 의 격리된 상태를 읽고 싶다면 await 를 사용해야합니다.

그리도 동시에 await 를 사용할 수 있는 context 를 제공하기 위해 함수를 async 로 만들던가 해야겠져?

하지만 이렇게 nonisolated 안에서 actor 에 접근하기 위해 await 를 쓴다고 모든 문제가 해결 될까요?

non-isolated async 코드는 항상 글로벌 협동 풀(cooperative pool) 에서 실행됩니다. 그러니까 격리된 섬(actor)을 떠나 바다 위에서 실행되는 상황이기 때문에, Sendable 한 데이터만 가지고 있는지를 컴파일러에서 확인합니다.

아래 코드는 Sendable 하지 않은 닭(class instance)이 섬을 떠나려고 하는 상황입니다. actor 에서 필요한 상태의 복사본을 얻어야하는데 웬 참조만 얻은 셈이져. 이는 즉 잠재적 데이터 레이스 상황이므로 컴파일러에서 워닝을 내뿜습니다.

이렇게 nonisolated 키워드도 컴파일러가 허용해야 사용할 수 있습니다.

참고로 nonisolated 함수에서 만약 actor 의 immutable state 에 접근한다면 굳이 await 를 사용하지 않아도 됩니다.

여기서 “nonisolated 가 있으면 그럼 isolated 도 있나? 🤔” 라는 의문이 들 수 있는데요,,, 진짜 있습니다요.

원래는 아래와 같이 actor 의 property 나 method 에 접근하기 위해 await 를 사용했다면

대신 파라미터의 타입 앞에 isolated 키워드를 붙여서, “이 메서드를 해당 actor 의 도메인으로 격리한다” 는 의미를 나타낼 수 있습니다. 그러면 actor 의 property 나 method 에 접근하기 위해 await 키워드를 사용하지 않아도 됩니다. 자연스럽게 함수를 async 로 마킹할 필요도 없겠죠.

하지만 함수를 async 로 마킹하지 않는대도, 호출하는 곳에서 await 를 붙여야 한다는 사실은 동일합니다.

더 자세한 내용은 Actor-isolated parameters 을 참고하세여

actor 내부에서 작성된 코드가 아닌, 일반적인 Swift 의 코드어떻게 격리 되는지도 한번 살펴 보겠습니다.

보통 우리가 작성하는 대부분의 Swift 코드는 actor 와 관련 없는 비격리(non-isolated) 코드이며, 동기(synchronous)식으로 작동하고, 오직 주어진 파라미터에 대해서만 동작합니다. 대체로 Task 나 Actor 또는 Concurrency 에 대해 아무것도 모릅니다.

func greet(_ friend: Chicken) {}

위의 greet 함수를 actor 에 격리 된 greetOne 함수에서 호출해보겠습니다. 이때 greet 함수는 actor 에서 호출 되었기 때문에, 마찬가지로 actor 에 격리 됩니다. 따라서 actor에서 flock 을 파라미터로 넘겨 받아 자유롭게 사용할 수 있습니다.

actor Island {
var flock: [Chicken]
}

extension Island {
func greetOne() {
if let friend = flock.randomElement() {
greet(friend)
}
}
}

반면 greetAny 와 같은 비격리(non-isolated), 비동기(async) 에서 greet 를 호출한다면, 이 함수는 동시성의 바다에 떠다니고 있는 보트(Task)에서 실행될 것입니다.

func greetAny(flock: [Chicken]) async {
if let friend = flock.randomElement() {
greet(friend)
}
}

이렇게 greet 와 같은 일반적인 코드는, 호출하는 쪽에서 격리 도메인이 있다면 동일한 격리 도메인에 머무르고, 없다면 격리되지 않고 자유롭게 존재합니다.

Actor reentrancy

계속해서 언급했듯 actor 는 한번에 하나만의 Task 실행을 허용합니다. 하지만 actor 자체에서 await 를 통해 actor 의 실행을 중지하는 동안에는, 다른 Task 에서 actor 로 진입해 코드를 실행할 수 있습니다.

무슨 말인지 예시로 한번 볼까요? 아래 코드에서는 actor 의 메서드의 중간에서 downloadImage 라는 async 함수를 호출합니다.

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) // <-- suspension point
// 캐시에 이미지 저장
cache[url] = image
return image
}
}

actor 내부에서 await 를 사용해서 downloadImage의 결과를 기다리는 동안, 바깥에서 actor 를 사용하기 위해 await 로 기다리고 있는 다른 코드가 actor 에 접근해서 실행 될 수 있습니다. 이러한 특성을 actor 의 reentrancy 라고 합니다.

actor 는 이렇게 프로그램이 진전될 수 있도록 보장함으로써 데드락의 가능성을 제거합니다.

또한 actor 는 전체 시스템의 응답성을 유지하기 위해 가장 우선 순위가 높은 작업을 먼저 실행합니다. 이렇게 하면 동일한 actor 에서 우선 순위가 낮은 작업이 우선 순위가 높은 작업보다 먼저 수행되는 우선 순위 역전이 제거됩니다. 이는 완전히 선입 선출 순서로 실행되는 직렬 디스패치 큐와는 상당한 차이가 있습니다.

actor 의 reentrancy 를 이해하는 것은 중요하기 때문에 reentrancy 가 의미하는 바와, 이것이 우선 순위의 조정과 어떻게 연관되어 있는지 그림으로 살펴보겠습니다.

우리의 어플리케이션에 여러 actor가 있다고 가정해볼게여. 그림을 보면 database actor 도 있고, sports feed, weather feed, health feed actor 등 .. 다양하죠?

지금은 database actor 가 스레드를 잡고 실행 중이네여

그러다 database actor 에서 어떤 작업에 대해 await 하면서 actor 는 suspend (D1) 됩니다. 곧바로 자유로워진 thread 에서 sports feed actor 가 이어서 작업을 실행합니다.

잠시 후, sports feed actor 에서 database actor 의 save 함수를 호출합니다.

현재 database actor 는 suspend 상태로, 실행 중이 아닙니다(uncontended). 따라서 스레드에 보류 중인 작업 항목(D1)이 하나 있더라도, sports feed actor 에서 database actor 로 바로 이동 및 실행할 수 있습니다. 그림에서 보다시피 이를 위한 (save 작업을 수행) database actor 의 새 작업 항목(D2)이 생성되었습니다.

이것이 actor 의 reentrancy 가 의미하는 바입니다. actor 에 대한 새로운 작업그 전 작업이 중단되는 동안 진행될 수 있습니다.

잠시 후 D2 가 완료됩니다. D2D1 이후에 생성되었음에도 불구하고 D1 이전에 실행을 마쳤습니다. 이는 즉 actor reentrancy 의 개념을 통해 actor의 항목을 선입 선출이 아닌 순서로 실행할 수 있다는 것을 의미합니다.

Actor reprioritization

그럼 actor reentrancy 가 우선 순위의 조정과 어떻게 연관되어있냐구여?

먼저 GCD 에서는 우선 순위를 어떻게 처리하는지 살펴보겠습니다.

database 를 위한 serial queue 가 있고, 여기에 우선 순위가 다른 작업들이 들어온다고 가정해보겠습니다.

초록색이 우선 순위가 높고, 보라색이 우선 순위가 낮은 작업을 나타냅니다.

Dispatch queue 는 선입선출을 따릅니다. 따라서 A 가 실행되고 난 후, 바로 우선 순위가 높은 B 가 실행되지 않습니다.

B 를 실행하기 전에, 5개의 우선 순위가 낮은 항목들을 먼저 실행해야합니다. 이렇게 우선 순위가 낮은 작업이, 우선 순위가 높은 작업보다 먼저 수행되는 현상을 priority inversion 이라고 합니다.

Serial queue 는 우선 순위가 높은 작업보다 앞에 있는 작업들의 우선 순위를 높임으로써 priority inversion 을 방지합니다.

이렇게 하면 실제로 queue 에 있는 작업이 더 빨리 완료되긴 하겠지만, B 실행 전에 아이템 1부터 5까지 완료해야 하는 문제는 해결되지 않습니다.

이 문제를 해결하려면 선입 선출 모델을 변경해야 합니다.

그리고 아까 말했죠? actor reentrancy 는 actor 가 선입 선출이 아닌 순서로 항목을 실행할 수 있음을 의미한다고요!!

database 에 대한 serial queue 대신 actor 를 사용해서 이전 예제를 다시 살펴보겠습니다.

우선 작업 항목 A는 우선 순위가 높기 때문에 바로 실행됩니다.

A 를 마치고 나면, 이전과 같은 priority inversion 이 발생합니다.

이때 actor 는 reentrancy 에 대해 설계되었기 때문에, 런타임은 우선순위가 높은 항목을 우선순위가 낮은 항목들보다 먼저 큐의 앞으로 이동하도록 선택할 수 있습니다.

이렇게 actor 는 priority inversion 문제를 직접적으로 해결하여 보다 효과적인 스케줄링과 리소스 활용을 가능하게 합니다.

주의점 1. actor에서 await 호출 이후 내부 상태 가정 금지

물론 actor 의 사용과 관련해서 주의해야할 점도 있는데요, 바로 actor 내부에서 await 를 호출한 이후, actor 의 상태는 크게 변할 수 있음을 알아야 한다는 것입니다. await 로 중지된 동안, 다른 Task 가 와서 actor 의 상태를 이것 저것 변경 시키고 떠났을 수도 있으니까요 (reentrancy 덕분에 말이져)! 따라서 await 이후에는 actor 자신의 내부 상태에 대한 가정을 하지 않는 것이 중요합니다.

아까 async / await 를 설명할때 말한 것과 비슷한 내용이죠?

await 키워드를 통해 code block 이 하나의 transaction 으로 처리되지 않을 수 있다는 것을 인식할 수 있습니다. function은 suspend 되고, 다른 것들이 먼저 실행 될 수 있기 때문에 일시 중단 되는 동안 앱의 상태가 크게 변할 수 있음을 알아야 합니다.

이를 인지하지 못한다면 실제로 데이터가 손상되지 않더라도 프로그램이 예기치 않은 상태에 놓일 수 있기 때문에, 요 내용을 이해하는 것은 중요합니다.

await 를 호출한 이후, actor 의 상태가 변하는 예시를 살펴볼까요? 아래의 actor 를 두 개의 Task 에서 접근한다고 할 때 print 결과가 어떻게 될지 상상해봅시다.

actor MyDownloader {
var counter = 0
func download(url: URL) async throws -> Data {
counter += 1 // counter 1 올리고
let num = counter // counter 를 num 에 할당
let result = try await URLSession.shared.data(from: url) // await 로 URLSession 함수 호출

print("num : " + num.description + ", " + "counter : " + counter.description)
print(num == counter) // counter 를 그대로 num 에 할당했으니까 true.. 일까나?

return result.0
}
}

혹시 둘 다 true 가 프린트 될 거라고 예상하셨나요? 그렇다면 await 이후에도 counter 의 상태가 변하지 않을 것을 가정한 것입니다.

여러분. 가 정 하 지 마 시 라 구 역.

왜냐?! 우리는 await 이후 counter 의 상태를 예상할 수 없기 때문입니다.

Task 1 에서 actor 의 메서드를 실행하던 중, await 를 만나면 actor 의 실행은 중단(suspended) 됩니다. 즉 actor 에서 실행 중인 코드가 당장은 없게 된다는 말이져?! 이때 다른 Task 2 가 actor 에 접근하고 내부 프로퍼티에 뭔짓을 하고 나갈지 모른다구여!

실제로 프린트 된 결과를 보면 이런 결과로 나올 때가 있는데요

num: 1, counter: 2
false

num: 2, counter: 2
true

도대체 어떤 일이 일어난건지,,, 하나씩 추측해봅시다.

  1. 2 개 이상의 Task 에서 MyDownloader 에 접근하려고 함
  2. 먼저 접근한 Task 1 에서 counter 를 1 로 올리고, num 에 1 를 할당함. 그리고 await 에서 actor 의 실행이 중단됨
  3. actor 에서 실행되는 코드가 없는 틈을 타서 Task 2 가 들어옴
  4. Task 2counter 를 2 로 올리고 num 에 2 를 할당함. 그리고 await 에서 actor 의 실행이 중단됨
  5. Task 1 이 actor 에 접근해서 await 한 작업이 먼저 끝남. await 이후의 코드가 마저 실행될 때 num 은 1 이지만, counter 는 2 가 되어있음
  6. 따라서 num == counterfalse
  7. Task 2 가 actor 에 접근해서 await 한 작업이 끝남. await 이후의 코드가 마저 실행될 때 num 은 2, counter 는 2 가 되어있음.
  8. 따라서 num == countertrue

이해가 되시나여?!

또는 이런 결과도 나올 때가 있는데, 이때는 Task 2 에서 실행한 actor 의 await 작업이 Task 1 에서보다 빨리 끝난 경우겠져?

num: 2, counter: 2
true

num: 1, counter: 2
false
  1. 2 개 이상의 Task 에서 MyDownloader 에 접근하려고 함
  2. 먼저 접근한 Task 1 에서 counter 를 1 로 올리고, num 에 1 를 할당함. 그리고 await 에서 actor 의 실행이 중단됨
  3. actor 에서 실행되는 코드가 없는 틈을 타서 Task 2 가 들어옴
  4. Task 2counter 를 2 로 올리고 num 에 2 를 할당함. 그리고 await 에서 actor 의 실행이 중단됨 (여기까지는 위의 상황과 똑같죠?)
  5. Task 2 가 actor 에 접근해서 await 한 작업이 먼저 끝남. await 이후의 코드가 마저 실행될 때 num 은 2, counter 는 2가 되어있음.
  6. 따라서 num == countertrue
  7. Task 1 이 actor 에 접근해서 await 한 작업이 끝남. await 이후의 코드가 마저 실행될 때 num 은 1, counter 는 2 가 되어있음.
  8. 따라서 num == counterfalse

주의점 2. actor 에서 많은 양의 작업을 진행하지 않는다

actor 사용 시 주의할 점이 또 있습니다. 바로 actor 에서 많은 양의 작업을 진행하지 않는다는 것입니다.

만약 여러 태스크가 동일한 액터를 동시에 사용하고, 이 액터에서는 많은 양의 작업을 진행한다면 어떻게 될까요?

actor 는 Task 의 접근을 직렬화(serialize) 합니다. actor 의 접근이 가능해질 때까지 모든 Task 가 대기하는 방식은 병렬 컴퓨팅의 이점을 잃습니다.

이를 ‘Actor 에서 진행하는 작업이 길어질 수록, 다른 TaskActor 접근을 위해 대기하는 시간도 길어지니까 각 Task 는 빨리 못끝남!’ 으로 이해하시면 좋을 것 같아여 (뭔가 아래 그림에서 ‘Task 가 actor 수행을 기다리는 동안, 해당 스레드에서 다른 작업을 실행 못함!’ 처럼 그려져있는거 같은데 그건 아닙니다).

이 문제를 해결하려면 Task 가 actor 의 data 에 접근이 필요한 경우에만 actor 에서 실행되도록 해야 합니다.

(자칫하면 ‘Task 가 Actor 에서 실행된다’는 말이 ‘특정 thread 에서 실행된다’는 것처럼 느껴질 수 있을 것 같은데요,, 왜냐면 제가 그랬거든여 ㅎ 아마 ‘에서’ 라는 단어 때문인것 같은데,,! ‘Task 가 Actor 에서 실행된다’는 것은 ‘Task 에서 actor 의 접근을 위해 await 를 호출하면서 현재의 스레드 제어권을 포기했다가, actor 의 코드 실행이 가능한 시점이 오면 임의의 thread 를 시스템에게서 할당받아 actor 코드를 실행한다’ 의 압축된 표현으로 이해하는게 좋을 것 같습니다.)

우리는 작업을 잘게 쪼개서 actor 에서 실행될 것과 아닌 것을 나누고, actor 에서 분리된 부분은 병렬로 실행시켜 컴퓨터가 전체 작업을 더 빨리 끝내도록 할 수 있습니다.

코드로 예시를 살펴볼까요?

아래 코드에서는 loop 를 돌면서 actor 에서 로그 및 파일 압축을 모두 실행하고 있습니다.

그림으로 살펴보면 logs 프로퍼티 및 compressionFile(url: URL) 메서드가 모두 actor 에 격리되어 있는 형태입니다.

따라서 한 파일의 압축이 끝날 때 까지 다른 파일의 압축은 진행될 수 없습니다.

여기서 이렇게 코드를 아래와 같이 변경해보겠습니다.

우선 Actor 의 compressionFile(url: URL) 함수를 nonisolated 키워드를 통해 actor 에서 분리했습니다. 함수 안에서는 이제 로깅 작업만 await 를 통해 actor 에서 실행되고, 파일 압축은 actor 와 무관하게 실행될 수 있습니다.

또한 Class 에서는 compressionFile(url: URL) 함수를 실행하는 Task 가 actor context (그림에서 나와있진 않지만 main actor 입니다) 를 이어받지 않고, 별도의 thread pool 에서 실행될 수 있도록 Task.detached 를 생성해주었습니다(참고로 detached task 에서는 명시적으로 self 를 캡쳐해야합니다).

결론적으로 compressionFile(url: URL) 함수는 스레드 풀에 있는 어느 스레드에서나 동시에 수행될 수 있습니다.

Main Actor 나 ParallelCompressor Actor 의 state 에 접근해야할 때는 해당 함수도 actor 에서 실행되긴 할테지만, 그 작업이 끝나자마자 바로 Thread Pool 로 돌아와서 자신의 작업을 이어서 실행할 수 있습니다.

Actor Hopping

지금까지는 보통 Task 에서 actor 의 함수를 호출하는 케이스를 예시로 많이 들었는데요, 당연히 actor 내부에서 다른 actor 의 함수를 호출할 수도 있습니다. actor 간에도 async/await 호출을 통해 메시지를 주고 받을 수 있습니다.

actor 의 동작은 cooperative thread pool 에서 수행되는데, 한 actor 에서 다른 actor 로 전환되는 작업을 “actor hopping” 이라고 합니다.

참고로 hop 은 “깡충 뛰다” 라는 뜻인데 이를 “hopping” 또는 “전환” 으로 풀어 설명하겠습니다.

파파고야 넘넘 고마운데!! 혹시 노력 조금만 더 해줄 수 있어?!? ️❤️

그럼 actor hopping 이 어떻게 동작하는지 그림으로 한번 살펴 볼까요?

sport feed actor 가 cooperative pool 의 스레드에서 실행되고 있습니다.

그리고 실행 중간에 database actor 에 기사를 저장하기로 결정합니다.

현재는 database 가 사용되고 있지 않은, 즉 경쟁이 없는 (uncontended) 상황입니다.

따라서 thread 는 sport feed actor 에서 database actor 로 곧장 이동(hop)할 수 있습니다.

여기서 알 수 있는 점이 두가지 있는데요,

  1. actor 를 hopping 하면서 스레드는 block 되지 않았고 (아까 파파고가 번역한 문장이져 ㅋ..)
  2. hopping 은 다른 스레드를 필요로 하지 않았다는 점입니다.

이제 database actor 의 첫번째 작업 (D1) 이 실행되고 있습니다.

이때 weather feed actor 가 database 에 기사를 저장하려고 시도합니다. 이는 database actor 에 대한 새로운 work item 을 생성하는데요 (D2), 이때는 실행 중인 work item (D1) 이 있기 때문에, 새로운 work item (D2) 는 보류 상태가 됩니다.

이 상황에서 weather feed actor 는 suspend 되고 해당 thread 는 다른 일을 실행할 수 있습니다.

시간이 지난 후, D1 작업이 완료 됩니다. 이제 runtime 은 해방 된 스레드에서 보류 중인 database actor 작업(D2)을 실행할건지, 다른 feed actor 를 실행할건지, 아니면 아예 다른 작업을 실행할 건지 결정할 수 있습니다.

이때 우선 순위가 높은 작업을 먼저 수행하는 것이 중요한데, actor 는 아까 말한 reentrancy 의 개념으로 인해 시스템이 일의 우선 순위를 잘 정할 수 있도록 설계되었습니다.

Main Actor

후후 이 정도면 Actor 에 대해 꽤나 많이 살펴본것 같은데여, 마지막으로 Main Actor 라고 하는 특별한 Actor 를 알아보겠습니다.

그 전에 우선 메인 스레드.. 많이 들어봤져..? 이곳은 UI 렌더링 및 사용자 이벤트가 처리되는 곳입니다. UI 와 관련된 작업은 일반적으로 메인 스레드에서 수행해야 합니다.

🤔 : 흠.. 스레드.. 잘 모르겠으니까 그냥 다 메인 스레드에 때려 넣어서 실행시키면 안돼?

넵 안됩니다. 메인 스레드에서 UI 를 처리하기 때문에, 이곳에 너무 많은 작업이나 오래 걸리는 작업까지 처리하게 시키면 UI 가 중지될 수 있기 때문이져.

따라서 보통 우리는 시간이 오래 걸리는 작업 등은 가능한 백그라운드 스레드에서 실행시키고, 메인 스레드에서 실행해야 하는 특정 작업이 있을 때마다 DispatchQueue.main.async 호출을 통해 해당 코드를 메인 스레드에서 동작시켰습니다.

사실, 메인 스레드와 상호 작용하는 것은 actor 와 상호 작용하는 것과 매우 비슷합니다.

이미 코드가 메인 스레드에서 실행 중이라면 UI state 에 안전하게 접근하여 업데이트할 수 있고, 메인 스레드에서 실행 중이지 않다비동기식으로 스레드와 상호 작용해야 합니다.

위에서 살펴본 actor 의 동작 방식과 정확히 일치합니다.

main thread 를 나타내는 특별한 global actor 를 우리는 Main Actor 라고 합니다. 기본적으로 actor 코드는 백그라운드 스레드에서 실행되지만 Main Actor 로 격리된 코드는 무조건 메인 스레드에서 실행됩니다. 따라서 사용자 인터페이스에 대한 작업은 Main Actor 에서 실행해야합니다.

아무리 Main Actor 가 특별하다고 해도, Actor 라는 사실은 여전히 동일하기 때문에 한번에 하나의 작업만 실행합니다. Main actor 에서 오래 걸리는 작업을 수행한다면 Main Actor blocking 발생으로 인해 UI 가 응답하지 않을 수 있습니다. Main Actor 에서 실행 중인 코드는 빨리 완료해서 끝나거나, 일반 actor 또는 detached task 에 넣음으로써 백그라운드로 이동시켜야 합니다.

Main Actor 는 main dispatch queue 를 통해 모든 동기화를 수행합니다. 즉, 아까 살펴봤던 actor 의 executor 관점에서 생각해보자면 main actor 의 executor 가 main dispatch queue 에 해당합니다. 따라서 main actor 는 DispatchQueue.main을 사용하여 교체할 수도 있습니다. 반대로 DispatchQueue.main.async 작업을 MainActor.run 으로 대체할 수 있도 있습니다.

DispatchQueue.main.async {}
await MainActor.run {}

main actor 에서 돌아갈 코드를 MainActorrun block 에서 받습니다. 이때 main thread 에서 동작을 진행할 수 있을 때까지 기다려야 할 수도 있기 때문에 await 와 함께 호출합니다.

계속 말해왔지만, await 가 사용되었다는 것은, 해당 스레드에서 다른 코드가 실행될 수 있도록 실행 중인 함수가 중지될 수 있다는 의미입니다.

await MainActor.run {
// UI 관련 코드 1
}
await MainActor.run {
// UI 관련 코드 2
}

따라서 메인 스레드에서 한꺼번에 작업이 이뤄지길 원하는 경우에는 관련 함수를 run block 에 그룹화해서 해당 함수들 사이에는 일시 중단 없이 호출이 실행되도록 할 수 있습니다.

await MainActor.run {
// UI 관련 코드 1
// UI 관련 코드 2
}

한편 @MainActor 어노테이션을 사용해서 Main Actor 로의 격리를 표현할 수 있습니다. 메인 스레드에서 실행되어야 하는 코드를 메인 액터에 있는 것으로 표시하는 것인데요, 함수, 클로저, 타입 등에 적용될 수 있습니다.

actor NaljinActor {
let id = UUID().uuidString
// property 에 붙으면, 해당 property 는 main thread 에서만 접근 가능
@MainActor var myProperty: String

init(_ myProperty: String) {
self.myProperty = myProperty
}

// method 에 붙으면, 해당 메서드는 main thread 에서만 호출 가능
@MainActor func changeMyProperty(to newValue: String) {
self.myProperty = newValue
}

func changePropertyToName() {
Task { @MainActor in
// block 안에 들어있는 코드도 main actor 에서 실행
myProperty = "naljin"
}
}
}

Main Actor 에 격리되지 않은 맥락에서 Main Actor 의 함수를 호출하는 경우, Main Actor 로의 전환실행을 위한 기다림이 필요할 수 있다는 것을 설명하기 위해 await 와 함께 사용해야합니다.

실제로 위와 같이 changeMyProperty 메서드를 MainActor 에 격리하고, NaljinActor 에 격리된 함수 (changePropertyToCity)에서 메인 액터 함수 호출을 시도하면, 메인 액터 함수가 자동으로 async 로 표시되는 것을 확인 할 수 있었습니다.

만약 MainActor 에 격리된 다른 함수(changePropertyToName) 에서 똑같이 메인 액터 함수 호출을 시도하면 동일한 격리 도메인(MainActor)이라 그런지 async 가 표시 되지 않았습니다.

@MainActor 가 class, struct, enum 같은 전체 type 에 붙으면, type 내부에 있는 모든 프로퍼티 및 메서드가 Main Actor 로 격리되고, main thread 안에서 동작합니다.

인터페이스와 관련된 Cocoa class 들은 이미 @MainActor 로 마킹되어있습니다. 왜냐하면 인터페이스 코드는 반드시 메인 스레드에서 돌아가야하기 때문이져!

따라서 아래 ViewController 의 viewDidLoad 메서드는 main thread 에서 동작하는데, 이는 ViewController 는 @MainActor 로 마킹된 UIViewController 의 subclass 이기 때문입니다.

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}

참고로 main actor class 도 다른 actor 들처럼 data 가 격리되어있기 때문에 main actor class 에 대한 참조도 Sendable 합니다 (references to main-actor classes are themselves Sendable, because their data is isolated).

이번에는 Main Actor 안에서 Task 를 생성해 보겠습니다. 아래 코드에서는 어떤 일이 일어날까요?

Task 를 생성하는 시점에 도달하면

Swift는 원래 scope 와 동일한 actor 에서 실행되도록 스케쥴합니다. 여기서는 Main Actor 가 되겠죠? (Task 는 생성된 곳의 Actor-context 를 이어 받습니다)

컨트롤은 호출자에게 즉시 반환되고, Task 는 추후 main thread 에서 실행 될 것입니다.

🤔 : 흠.. 알겠어.. 그럼 MainActor 로 격리된 ViewController 에서 Task 를 생성해도, Task 는 주변 context (surrounding context) 를 이어받기 때문에, Task 안의 코드는 main thread 에서 동작한단 말이지?

class ViewController: UIViewController {
func taskThreadTest() {
Task { // 아래 코드 블럭은 main thread 에서 동작
self.myTextLabel.text = "naljin"
}
}
}

네 맞습니다. 실제 Task 안에서 breakpoint 를 찍고 po Thread.current 를 수행하면 해당 코드를 돌리고 있는 스레드가 메인 스레드임을 확인할 수 있습니다.

<_NSMainThread: 0x600000b882c0>{number = 1, name = main}

🤔 : 음.. 근데 만약 Task 안에서 await 를 만난다면? 아래 코드에서 download 라는 async 함수도 결국 메인 스레드에서 도나??

놉! 이건 전적으로 async 함수가 어디에 격리 되어있냐 (혹은 되어있지 않느냐)!에 따라서 결정됩니다.

그림으로 다시 살펴보자면, main actor 내부에서 생성된 Task 의 block 은 main actor 에 격리됩니다.

여기 안의 코드는 main thread 를 잡고 잘 수행하고 있다가

await 를 만나자마자 main thread 제어권을 놓아버립니다.

그리고 시스템에 의해 download 함수가 진짜 실행되어야 할때, 이 함수가 어디 속해있냐! 를 보면 struct 의 인스턴스 함수네여.

그러니까 어떤 곳에도 딱히 격리 되어있지 않은 함수란 말이져?! 그렇기 때문에 thread pool 의 임의의 thread 에서 Task 의 다운로드 동작을 마저 수행할 수 있습니다.

그러다 download 내부에서도 await 를 만나면 이 동작을 반복하는거져 뭐.

중간에 만난 await 함수가 특정 actor 에 격리되어 있을수도 있을텐데요,

만약 일반 actor 함수를 호출하는 쪽이 main actor 였다면 context switch (main -> background) 를 위해 우선 스레드 제어권을 포기하고 main thread 에서는 다른 작업을 수행하겠죠? (일반 actor 는 보통 cooperative thread pool 의 thread 에서 실행됩니다. 쉽게 말해 background thread 를 사용한단거져)

만약 actor 를 호출한 쪽도 cooperative thread pool 에 있으면서, 호출한 actor 가 실행 중이 아닌 상태 (no contention) 라면, 호출 thread 를 재사용하여 actor 의 메서드를 호출합니다. 만약 actor 가 이미 실행 중 (under contention)인 경우라면 호출 thread 는 현재 함수를 일시 중단하고 다른 작업을 픽업할 수 있습니다 (actor 를 호출 시 스레드 재사용에 관한 내용은 Swift concurrency: Behind the scenes 에서 29:30 정도를 참고하세여).

모든 download 작업이 끝나고, 밑에서 다시 실행되는 코드는 여전히 main actor 에 격리되어있기 때문에 main thread 에서 동작합니다.

실행 코드가 main actor 와 다른 actor 들 간에 전환되는 상황을 스레드 관점에서 좀 더 살펴보겠습니다.

main threadcooperative pool 의 thread 와 분리되어있기 때문에 이들 사이에는 context switch 가 필요합니다.

cooperative pool 에서 actor 간의 전환 (hopping) 은 빠르지만, 코드를 작성할 때는 여전히 main actor 와의 전환을 염두에 둘 필요가 있는데요, 아래 코드로 상황을 살펴보겠습니다.

MainActor 에 격리되어 있는 updateArticle 함수 안에서, 각 루프는 적어도 두 번의 context switch 를 수행합니다. 하나(loadArticle)는 main actor 에서 database actor 로, 또 하나(updateUI)는 그 반대로 넘어오는 경우입니다.

루프마다 두 번의 컨텍스트 스위치가 필요하기 때문에, 짧은 시간 동안 CPU 에서 두 개의 스레드가 반복되는 패턴을 보입니다.

loop 의 반복 횟수가 적고, 각 반복에서 많은 작업이 수행되고 있다면 이런 식의 형태는 큰 문제가 없을 것입니다.

하지만 main actor 로의 전환이 빈번하게 발생한다면, 스레드 전환의 오버헤드가 증가하기 시작할 수 있습니다.

따라서 어플리케이션이 context switching 에 많은 시간을 소비하는 경우, main actor 의 일괄 처리되도록 코드를 재구성해야 합니다.

아래와 같이 loop 문을 loadArticles 에 밀어넣은 후 updateUI 를 호출하는 식으로 작업을 일괄 처리할 수 있습니다.

작업을 일괄 처리하면 context switch 횟수가 줄어듭니다.

마무리

엇 여기까지 스크롤 하시는데 손가락에 하얀 가루.. 묻어있지 않으세여?? 그거 갈려버린 제 뼛가루임여 ㅋ

ㄹㅇ 로다가 3주 걸려버린.. 역대급 역대급..ㅋㅋ.. 내용이 길면 편집 툴에서도 타자 치는데 렉이 걸리더라고요..ㅎㅎ 아니 이건 제 컴퓨터 문젠가..?

튼 연말, 신년 약속 다 취소 때리고,, 크리스마스도, 새해 시작도 내내 함께한 제 피 땀 눈물이 담겨있는 글이었는데요! 여기까지 다 읽고 이 내용을 보고 계시다면..??? 대단 박수 드립니다… Actor 에 대해 조금은 알아가시는 계기가 되었을까요? ㅎㅎ.ㅎ.ㅎㅎㅎㅎ..

그럼 이제는 여러분도 지치고 저도 지쳤으니까 Actor 뿌시기는 여기까지!

동시성의 바다에서 키 꽉잡고 순항하시길 바라요! 그럼 20000!

출처

--

--