GGURUPiOS
동시성 프로그래밍 - Concurrency (3) Actor 본문
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 를 구현한다고 가정
로직 흐름
- 캐시를 확인
- 이미지 다운로드
- 이미지를 리턴하기 전에 캐시에 저장
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를 사용하여 메인 스레드에 있어야 하는 코드가 항상 메인스레드에서 실행되도록 하자
'Swift > 동시성 프로그래밍' 카테고리의 다른 글
동시성 프로그래밍 - Concurrency (2) Structured Concurrency (async-let, Task) (0) | 2023.04.24 |
---|---|
동시성 프로그래밍 - Concurrency (1) async/await (0) | 2023.04.24 |
동시성 프로그래밍 - Operation (0) | 2023.04.24 |
동시성 프로그래밍 - GCD (2) (0) | 2023.04.24 |
동시성 프로그래밍 - GCD (1) (1) | 2023.04.19 |