GGURUPiOS

동시성 프로그래밍 - Concurrency (3) Actor 본문

Swift/동시성 프로그래밍

동시성 프로그래밍 - Concurrency (3) Actor

꾸럽 2023. 4. 24. 16:37

Actor

등장배경

동시성 프로그래밍을 작성할 때 어려운 문제 중 하나는 Data races를 피하는 것임

Data Races? → 두 개의 개별 스레드가 동시에 동일한 데이터에 액세스하고 액세스 중 하나 이상이 쓰기인 경우 발생

Data Races 는 디버깅하기 매우 어려움

예를 들어보자

Counter 라는 클래스를 생성하고,

클래스 내부의 value 값을 증가시키는 increment 함수를 생성함

기대하는 출력 값은 1,2 혹은 2,1 이지만 두 Task 모두 0을 읽고 1을 증가 시키면 1,1 혹은 2,2 가 출력될 수 있음

race를 유발하는 액세스가 다른 부분에 있을 수 있기 때문에 추론이 필요함

→ 디버깅이 어려움

data race는 공유 가변 상태(shared mutable state)에 의해 발생 됨

데이터가 변경되지 않거나 여러 동시 task 간에 공유되지 않으면 race 상황이 발생되지 않음

데이터 경쟁을 피하는 한 가지 방법은 값타입을 사용하여 공유 가변상태를 제거하는 것임

값 타입이 변수인 경우 모든 변환은 로컬임

또한 값 타입의 let 속성 또한 불변하므로 다른 동시 task 에서 액세스 하는것이 안전 함

결국, Struct, Dictionary, Array 같은 값 타입은 data races 를 해결할 수 있음.

그렇다면 위의 코드를 Struct 로 작성하여 data races 를 피해보자

  • value 를 수정할 수 있도록 mutating 으로 선언
  • counter 의 선언을 let 에서 var 로 바꿔줌

그러나 위의 코드에서 counter 가 두개의 멀티 스레드에서 참조되기 때문에 여전히 data races 를 피할 수 없음 ( 컴파일러에서 자동으로 에러를 뱉어줌 )

따라서, counter 를 let 으로 다시 선언하고,

counter 를 task 내부로 옮겨서 var로 선언하여 아래와 같이 만드는 것이 매력적으로 보임

Data races를 피한것 처럼 보이지만, 우리가 기대 했던 결과는 1, 2 혹은 2, 1 인것에 반해

1,1 이 출력될 것임 (struct 가 값타입이기 때문에 var 로 내부에서 선언 시 값이 copy 되는 것이지 원본을 참조하는 것은 아니기 때문 )

→ 원하는 동작이 아님

공유 가변 변수는 동기화가 필요한데, 스위프트에서는 위에 사진 처럼 Atomics, Locks, Serial dispatch queues 등을 제공함

위의 요소들은 각각 다양한 장점을 가지지만, 모두 동일한 약점이 있음

→ 정확한 사용을 위해서는 신중하게 사용해야 함 (데이터 경쟁을 추론해서 사용)

이러한 점 때문에 actor 가 등장

Actor

actor 는

  • 공유된 가변 상태에 대한 동기화 매커니즘임
  • 프로그램의 나머지 부분으로 격리되는 상태를 가짐
  • actor에 접근하는 유일한 방법은 actor를 통해서만 가능함
  • actor에 접근할 때, actor의 동기화 매커니즘에서 다른 코드가 동시에 접근하는 것을 막음

actor 는 참조유형이며

프로퍼티, 메서드, 이니셜라이저, 서브스크립트를 작성할 수 있음

프로토콜 또한 준수하고 익스텐션을 이용해 확장할 수 있음

위의 예제를 actor 로 바꿔보자

위에 예제에서,

Task 중 하나가 먼저 actor 에 도착하면 그 task는 작업을 실행합니다

먼저 도착한 task 가 완료되면 actor 는 다시 자유로워짐

그 때 actor 는 기다리던 두번째 task 의 작업을 실행함

이런식으로 actor 가 data races를 제거하기 때문에 원하는 결과값 (1,2 혹은 2,1) 을 얻을 수 있음

actor의 동기 코드

만약 익스텐션을 통해 아래와 같은 동기 메서드를 추가한다면

메서드 안에서 value = 0 으로 직접 값을 초기화 하고, value 값을 바꾸는 increment() 함수도 호출하지만

우리는 함수가 actor 안에서 동작한다는 사실을 알기 때문에 await 키워드가 필요없음 (a 와 resetSlowly 모두 같은 스레드)

→ actor 의 동기코드는 항상 완료될때 까지 동작함

→ actor의 상태에 대한 동시성에 대해 고려할 필요가 없음

하지만 actor 는 종종 동기 코드가 아닌 다른 비동기 코드와 상호작용할 수 있고, 여기선 고려해야할 점이 있음


Actor reentrancy

영상에서 reentrancy 가 무엇인지 설명 안하고 바로 예제로 들어가서 뭔지 찾아봤다..

Actor reentrancy 란 한 actor 의 메서드 호출이 동시에 발생할 때 해당 actor의 상태에 대한 race condition 이 발생할 수 있는 상황을 의미함

데드락은 발생하지 않지만, 각 await 후의 가정을 확인해야함

여기서 말하는 가정이란, 이 await 과정이 끝나면 어떻게 되어있을 것이다. 이런? 느낌인 것 같음

(아래 예제로 치면 두 task 의 이미지가 같을 것이다. 이런 가정인 듯함)

예제

이미지를 다운받아 캐시에 저장하는 actor 를 구현한다고 가정

로직 흐름

  1. 캐시를 확인
  2. 이미지 다운로드
  3. 이미지를 리턴하기 전에 캐시에 저장

Actor 안에서 동작하기 때문에, 이 코드는 data races로 부터 안전함

또한, Actor의 동기화 매커니즘은 한번에 한 작업만 캐시 인스턴스에 액세스 하는 코드를 실행 할 수 있도록 보장하므로 캐시가 손상될 가능성이 없음

즉, 여기서의 await 키워드는 매우 중요한 것을 전달하는 것임

await가 발생할 때마다 이 시점에서 함수가 일시 중단될 수 있음을 의미함

await 후에 유지되지 않을 수도 있는 해당 상태에 대한 가정을 하지 않았는지 확인하는 것이 중요함

이해가 잘 안되지만, 예를 들어보자

동일한 이미지를 동시에 가져오려는 (같은 URL) 두 가지의 task가 있다고 가정

Task1 은 이미지를 다운로드 하는 코드가 시간이 걸리기 때문에 일시 중단됨

Task1 이 이미지를 다운로딩 하는 동안, 같은 URL에 새로운 이미지가 배포될 수 있음

Task2 가 다운로드 동안 작업을 시작하고 캐시가 없기 때문에 ( Task1의 다운로드가 안 끝났으니) 다운로드를 함

Task1의 다운로드가 끝나면 Task1은 캐시에 저장하고 이미지를 리턴함

Task2의 다운로드가 끝나면 Task2 또한 캐시에 오버라이드하고 이미지를 리턴함

→ 캐시에 이미 이미지가 있지만, 동일한 URL에 대해 다른 이미지가 생성 됨

위의 await 후에 유지되지 않을 수도 있는 상태에 대한 가정이란게 이런 예제인 듯 함

하나의 해결책은 await 이후의 가정을 확인 하는 것임

예를들어, 캐시에 이미 항목이 있는 경우 원래 버전을 유지하고 새 버전을 삭제하는 것

더 나은 해결책은 중복 다운로드를 완전히 피하는 것임

Actor reentrancy를 피하기 위해서는 await 할 때 마다 가정을 확인해야 함

reentrancy를 피하기 위해 잘 설계하려면 동기 코드 내에서 actor상태의 변경을 수행하는 것임

모든 상태 변경이 잘 캡슐화 되도록 동기 함수 내에서 실행하는 것이 이상적임

상태 변경은 일시적으로 actor 를 일관성 없는 상태로 만드는 것을 포함할 수 있음

await전에, 일관성을 복원해야함


Actor isolation (액터 격리)

Actor isolation 은 Actor 타입의 기본적인 행동양식? 이다 ( 동작 방식? )

이 부분에서는 actor isolation 이 프로토콜 준수, 클로저 및 클래스를 포함한 다른 기능과 어떻게 상호 작용하는지에 대해 설명 함

Protocol

다른 타입과 마찬가지로, actor 는 프로토콜을 준수 할 수 있음

static 메서드이기 때문에 self 인스턴스가 없으므로 격리되지 않는다

그러나, actor 의 불변 상태에만 액세스하기 때문에 괜찮음

이번에는 Hashable 프로토콜을 준수하도록 해보자

hasbable을 준수한다는 것은 이 함수를 actor외부에서 호출할 수 있다는 것을 의미하지만 비동기가 아니므로 actor isolation 을 유지할 방법이 없음

해결 → 이 메서드를 nonisolated 로 만들기

Nonisolation란 이 메서드가 actor 내부에 있더라도 actor 외부에 있는 것으로 취급 된다는것을 의미함

Nonisolation 은 Actor 외부에 있는 것으로 취급되므로 actor 의 가변상태를 참조할 수 없음 (위에서는 let 으로 선언된 불변의 ID 번호를 의미하기 때문에 괜찮음)

booksOnLoan에 접근하면 컴파일 오류

Closures

함수와 마찬가지로 클로저는 actor-isolated 되거나 nonisolated 될 수 있음

아래 예시에서는 대출 중인 각 책의 일부를 읽고 읽은 총 페이지 수를 반환 함

reduce 요청에는 클로저가 포함 됨

이 요청에는 readSome을 호출할 때, await 키워드가 없다

→ actor-isolated 함수 read 내에서 형성되는 이 클로저는 그자체가 actor-isolated 이기 때문임

reduce 함수가 동기적으로 실행되고, 클로저는 동시 액세스를 일으킬 수 있는 다른 스레드로 탈출할 수 없으니 안전함

나중에 책을 읽는 비동기 함수를 추가해보자

detached task 는 actor가 수행 중인 다른 작업과 동시에 (비동기로) 클로저를 실행함

data races를 야기 할 수 있음

read 메서드를 호출하려면 비동기식으로 await 으로 호출해야 함

→ 해당 read() 작업이 끝나기 전에 액터 메서드가 종료될 수 있기 때문에 await 키워드를 사용해 비동기 작업이 완료될 때 까지 작업이 끝나지 않도록 해야 함

위의 예시에서 Book 타입이 실제로 무엇 타입인지에 대해 언급을 피해 왔는데, 일반적으로 우리는 구조체 같은 값 타입일거라고 생각함

구조체는 값 타입이기 때문에 좋은 선택이 될 수 있음

만약 book 을 class로 바꾼다면 복잡해짐 (참조 타입)

이제 actor 는 Book 클래스의 인스턴스를 참조함

그자체로 문제가 되지는 않지만,

만약 무작위로 책을 선택하는 메서드를 호출하면?

이제 actor 의 가변적인 상태에 대한 참조를 가지고 있는데, 이는 actor 외부에서 옴

→ data races 에 대한 잠재적인 문제점이 있음

visit 메서드가 actor 외부에 있기 때문에, 이 변경은 data race 를 야기함

값 타입과 actor 는 동시에 사용해도 안전하지만, 클래스는 여전히 문제점이 있음

이 문제점을 해결하기 위해 Sendable 이라는 새로운 타입이 등장

Sendable (전송가능 타입?)

Sendable은 여러 액터간에 값을 공유할 수 있는 타입임

한 장소에서 다른 장소로 값을 복사할 때 두 장소 모두 서로 간섭하지 않고 해당 값의 복사본을 안전하게 수정할 수 있는 경우가 센더블타입임

말이 어려워서 찾아봄

→ 결국에는, 해당 타입이 전달 되는 동안에도 변경되지 않는 것을 보장해야함 (불변성을 가짐)

→ 그래서 가변 변수에 대해 캡처할 수 없음

센더블 타입의 종류

예를 들어 클래스의 모든 하위 클래스에 불변 데이터만 포함하고 있는 경우

또는 클래스가 안전한 동시 액세스를 보장하기 위해 내부적으로 동기화를 수행하는 경우

위의 Author 가 클래스이기 때문에 에러를 뱉어냄

제네릭의 경우에는 argument가 Sendable 을 준수하면 가능

Sendable 함수의 경우에는 Sendable 타입만 캡쳐가능 함

센더블한 클로저가 동시에 로컬 변수(가변) 에 접근하기 때문에 에러 발생

MainActor

메인 스레드와 상호작용 하는 것은 사실 actor 와 상호작용 하는 것과 매우 비슷함

메인 스레드에서 이미 실행중인 경우 UI 상태에 안전하게 액세스하고 업데이트 할 수 있음

메인 스레드에서 실행되지 않은 경우 비동기적으로 메인 스레드와 상호작용 해야 함

→ actor 의 방식과 비슷함

메인 스레드와 비슷한 특별한 actor 가 있는데, 이것을 MainActor 라고 함

MainActor 는 메인스레드를 대표하는 actor 임

일반적인 actor 와 다른점

  • DispatchQueue의 mainQueue를 이용해 모든 동기화를 수행함 ( 런타임 관점에서, DispatchQueue.main 을 사용하는 것과 호환 됨)
  • 메인 스레드에 있어야 할 코드와 데이터가 곳곳에 흩어져 있음 ( SwiftUI, AppKit, UIKit, 기타 프레임워크 )

Swift Concurrency 에서는, mainactor 로 아래와 같이 함수를 선언 할 수 있으며 항상 메인 액터에서 동작함

만약 main actor 외부에서 호출하면, await 이 필요하며, 호출은 메인스레드에서 비동기적으로 실행 됨

타입도 main actor 에 위치할 수 있으므로, 모든 멤버와 하위 클래스가 main actor 에 배치 됨

→ 메인 스레드에서 실행되어야 하는 UI와 상호작용 해야 하는 코드에 유용함

개별 메소드는 nonisolated 키워드를 통해 선택을 취소할 수 있음

→ UI-facing 타입 및 operation 에 main actor 를 사용하고 다른 프로그램 상태를 관리하기 위한 자체 actor 를 사용함으로써 안전하고 정확한 앱을 설계할 수 있음


정리 (요약)

actor 를 사용하여 Swift 코드에 안전한 동시성을 추가한다

actor 를 구현할 때, 항상 reentrancy 를 고려하여 설계한다. await 키워드는 우리가 짠 가정을 무효화 할 수 있음

value (값)타입 과 actor 는 협력하여 data races 를 제거한다

동기화를 처리하지 않는 일반 클래스와 공유 가변 상태를 다시 도입하는 non-sendable 타입에 주의하자

UI와 상호작용하는 코드에 mainActor를 사용하여 메인 스레드에 있어야 하는 코드가 항상 메인스레드에서 실행되도록 하자