Avoiding data races
- concurrent program의 가장 어렵고 기본적인 문제.
- 데이터 경합(data race)?
- 두개 이상의 쓰레드(thread)가 동시에 같은 데이터에 접근하고, 이 중 한 쓰레드라도 쓰기(write)작업을 하는 경우에 발생하는 데이터 정합성 문제.
- 발생하는 경우가 드물다. -> 디버깅도 어렵다.
Counter example
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
asyncDetached {
print(counter.increment())
}
asyncDetached {
print(counter.increment())
}
- Counter class 를 생성한다.
- concurrent task로 값을 증가 시킨다.
- 결과는
- 실행되는 타이밍에 따라 다름.
- 원하는 결과 값 : (1, 2) or (2, 1)
- 데이터 경합이 발생한 경우의 결과 값 : (1, 1) or (2, 2)
데이터 경합(data race)를 디버깅하기 어려운 이유
- 원인을 한 곳에서 추론해 나가기가 어렵다.
- 프로그램의 서로 다른 영역이 서로 영향을 받아 발생하기 때문이다.
- OS가 task를 매번 다르게 스케쥴링 하기 때문에 결과도 일관적이지 않다.
Value Semanic
- 데이터 경합이 발생하는 데이터는 mutable state가 존재하는 데이터.
- 절대 변하지 않는 데이터(Constant)나 변수가 동시에 병렬로 실행되는 Task(concurrent task)에서 사용되지 않는다면, 데이터 경합을 걱정할 일이 없다.
- 데이터 경합을 피하는 방법.
- value semantic을 이용해서 데이터가 mutable state 상태로 공유되지 않도록 하는 것.
- *value semantic : 값 복사가 일어나는 특성을 가진 변수를 정의하는 문법. not reference type
- value semantic 인 let 프로퍼티는 로컬 이외의 곳에서 절대로 값의 변화가 일어나기 않는다. == immutable
- 동시에 다른 task에서 접근해도 안전하다는 뜻.
- array example
- 두 array는 초기값은 같지만, 추가로 붙은 값은 각각 다르다.
var array1 = [1, 2]
var array2 = array1
array1.append(3) // [1,2,3]
array2.append(4) // [1,2,4]
- Swift Standard library에 정의된 주요 타입들은 value semantic.(dictionary, array...)
Value Semantic으로 데이터 경합(Data race) 해결하기
- 위 예제에서 Counter의 타입을 Class 에서 Struct로 바꾸면 해결?
- increment() 메소드도 muatating 으로 바꿔야 컴파일이 된다.
- 이제 실행하려 하면, Compile error 가 발생한다.
- counter가 let으로 선언되었는데, 값을 바꾸려고 하기 때문이다.
- 그러면 counter를 var 로 바꾸자?
- counter가 mutable state를 가지는 변수가 되고, 두개의 concurrent task에서 참조하게 되니까 data race 문제가 다시 발생한다. 😨
- compile 에서도 이를 감지하여 Compile error 발생.
- 그러면 counter를 각 비동기 task에 각자 정의해주자?
- 데이터 경합(data race) 없음.
- Compile error 없음.
- 결과는 양쪽 모두 1 (원하던 결과가 아님)
- 결국 counter를 두 개의 비동기 task가 공유해야 한다. -> shared mutable state 필요성.
공유 변수와 비동기 작업을 위한 동기화 장치들
- atomic이나 lock 같은 저수준 동기화 장치 부터 serial dispatch queue 같은 고수준 동기화 장치까지 다양한 장치가 있다.
- 각각 장단점이 있음.
- 여러가지 동기화 장치가 있지만, 어떤 걸 사용해도 올바른 방법으로 주의해서 사용하지 않으면 결국 다시 데이터 경합 문제가 나타나게 된다.
Actor의 등장
- 위 문제들을 해결해 줄 Swift의 새로운 타입.
- 공유 변수를 위한 동기화 매커니즘.
- actor는 상태가 존재하고, 그 상태는 다른 프로그램 영역으로 부터 고립되어 있다.(isolated)
- isolation : actor의 상태에 접근할 수 있는 건 actor 뿐이라는 뜻.
- Actor를 통해 Actor의 상태에 접근하면, actor의 동기화 매커니즘이 동작해서 동시에 접근하는 걸 막아준다.
- lock이나 serial dispatch queue를 사용해서 상호배제 프로퍼티를 만드는 것과 동일한 효과.
- actor를 이용하면, 이런 장치를 명시적으로 사용하지 않아도 기본으로 내장하고 있다.
- 만일 동시 접근을 시도하는 코드가 생긴다면, 컴파일 에러를 발생시키기 때문에 동기화 수행을 빼먹을 일이 없다.
Actor Type
- Actor는 Swift의 새로운 타입.
- Swift의 다른 Type들과 같은 능력이 있음.
- property, method, initializer, subscript등을 가질 수 있다.
- protocol을 구현할 수 있고, extension으로 확장할 수 있다.
- class 처럼 type을 참조하는 것도 가능하다.
In fact, the primary distinguishing characteristic of actor types is that they isolate their instance data from the rest of the program and ensure synchronized access to that data.
actor가 다른 type과 구별되는 가장 큰 특징은 instance data를 프로그램의 다른 부분과 고립 시키고, 그 데이터에 동기화된 접근을 보장한다는 것이다.
Counter Example
- counter를 actor 로 정의.
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
- counter를 actor 로 정의.
- Counter가 struct로 정의 되었을 때와의 차이점은 actor가 동시 접근을 허용하지 않는다는 것!
- 바로 actor 내부의 동기화 매커니즘은 동작이다. 한 task의 increment() 함수 호출이 종료될 때까지 다른 함수가 실행되지 않도록 보장해준다.
- 실행 결과로 원하는 결과를 얻을 수 있다.
- (1, 2) or (2, 1)
Actor에서 실제로 일어나는 일
- Counter 예제에서 counter를 증가시키려는 두개의 비동기 task가 동시에 실행될 때 실제로 일어나는 일.
- 둘 중 한 Task가 먼저 increment() 메소드를 실행한다.
- 남은 Task는 자신의 차례가 될 때까지 기다린다.
- 두번째 task가 바로 실행하지 않고, 기다릴거라는 걸 어떻게 확신 할 수 있나?
- actor에서는 이를 위한 매커니즘을 이미 제공하고 있기 때문이다.
- 이를 위해서는 actor가 외부와 상호작용을 할 때 마다 비동기로 하도록 해야 한다.
- actor와 코드의 상호작용
- actor가 busy 상태라면? -> 상호작용을 원하는 코드는 대기(suspend)한다.
- actor가 free 상태라면? -> 상호작용 하려고 대기하던 코드를 깨워서 실행(resuming execution) 시킨다.
- await 키워드 : actor의 동기와 코드를 호출하면 suspend 상태가 될 수 있다는 걸 의미한다.
let counter = Counter()
asyncDetached {
print(await counter.increment())
}
asyncDetached {
print(await counter.increment())
}
resetSlowly
- resetSlowly() 메소드를 추가해보자.
- actor 상태에 직접 접근해서 value를 0 으로 만든다.
- increment() 호출 예제와 같이 다른 함수와 동시에 호출될 가능성도 있다.
- 이미 actor 내에서 실행되고 있기 때문에 await 키워드는 필요하지 않다. (중요! actor가 보장해주는 중요한 속성.)
- actor 내에서 작성한 코드는 동시성에 대한 영향을 고려하지 않아도 된다.
Synchronous code on the actor always runs to completion without being interrupted.
actor의 동기 코드(synchronous code)는 종료될때까지 절대 방해받지 않는다.
extension Counter {
func resetSlowly(to newValue: Int) {
value = 0
for _ in newValue {
increment()
}
assert(value == newValue)
}
}
Actor reentrancy
- Image Downloader Example
- 하는 일
- 서버에서 이미지 다운로드 받는 작업.
- 같은 이미지를 중복으로 다운 받지 않기 위한 캐싱 작업.
- 비동기 코드(asynchronous code)와 actor 를 설명하기 위한 더 나은 예제.
- 하는 일
- 코드의 논리적 흐름
- 캐시 체크 -> 이미지 다운로드 -> 캐시에 이미지 쓰기.
- Actor 사용으로 데이터 경합 이슈는 문제가 되지 않을 것. (동시 다운로드 가능)
- Actor의 동기화 매커티즘이 캐시 동시 접근을 허용하지 않으므로
- await 키워드가 중요.
- 해당 메소드가 await 키워드 코드에서 대기(suspend)할 수 있다는 뜻.
- 시간이 흐른 뒤(프로그램의 상태가 변경된 뒤)에 다시 실행(resume)될 수 있다.
It is important to ensure that you haven't made assumptions about that state prior to the await that may not hold after the await.
중요한 건 await 이후 코드를 실행할 때, await 이전 상태에 대해 개발자 마음대로 추측하거나 가정하지 말아야 한다는 것이다.
동시에 같은 URL에서 이미지를 가져오는 2 개의 Tasks
여기서 서버의 동작이 조금 예외적이다. 보통 같은 URL에서 같은 image를 내려받지만, 여기서는 상황을 조금 더 잘 보여주기 위해 다른 이미지를 내려 받는 상황으로 설정했다. 같은 URL에 접근해도, URL에 접근하는 타이밍에 따라 웃는 고양이 이미지를 내려주기도 슬픈 고양이 이미지를 내려주기도 한다고 가정했다.
Task1 (웃는 고양이 이미지)
- 캐시에 이미지가 있는지 확인한다.
- 서버로부터 이미지를 다운 받기 시작한다.
- 다운받는 도중 suspend 상태로 전환한다.(await)
Task2 (슬픈 고양이 이미지)
- 캐시에 이미지(Task1이 다운받고 있는 URL로 부터)가 있는지 확인한다.
- 서버로 부터 이미지를 다운받기 시작한다. (Task1에서 아직 캐시에 쓰기 작업을 하지 않았으므로)
- 다운받는 도중 suspend 상태로 전환한다.(await)
Task1 (After a while)
OS 스케쥴링에 따라 Task2-Task2 순서는 바뀔 수 있다.
- Task1 의 다운로드 작업이 다시 시작. resume.
- Task1에서 캐시에 쓰기 작업 후 종료.
캐시에 웃는 고양이 이미지가 저장되었다.
Task2 (After a while)
- Task2 의 다운로드 작업 다시 시작. Resume.
- Task2에서 캐시에 쓰기 작업 완료.
캐시에 웃는 고양이 이미지는 제거되고, 대신 슬픈 고양이 이미지로 덮어 씌워졌다.
We don't have any low-level data races, but because we carried assumptions about state across an await, we ended up with a potential bug.
낮은 수준의 데이터 경합(low-level data race)는 없었다. 하지만 await 전 후 상태가 같다고 가정했기 때문에 결국 잠재적인 버그가 생성되었다.
Assumptions about state across an await
await 이 후에 상태가 같을 거라는 가정이 맞는지, 확인하면 이를 방지 할 수 있다. 여기서는 Task가 다시 실행(resume) 될 때, 캐시가 이미 채워져있는지 확인하는 작업을 해야한다. 더 나은 방법은 중복 다운로드 자체를 하지 않는 것이다.
Actor reentrancy prevents deadlocks and guarantees forward progress, but it requires you to check your assumptions across each await.
actor의 재진입은 데드락(deadlock)을 방지하고 이전에 진행하던 작업을 이어하도록 보장한다. 하지만 개발자가 await 이 후 상태를 확인하는 작업을 해줘야 한다.
Actor reentrancy
- await 이후 상태가 이전과 일관된 상태로 복구 될 수 있도록 신경써야 한다.
- await는 잠재적 대기 지점(potential suspension point)라는 것이다.
- 코드가 대기(suspend) 상태일 때 프로그램 전체는 계속 움직이고 있기 때문이다.
이어서 보기>
StudyInMySpareTime WWDC21 Protect mutable state with Swift actors #2<Swift Isolation, Main Actor>
Watch WWDC21 Original Session Video
WWDC21 Session : Protect mutable state with Swift actors
이 블로그에서 WWDC21 Swift 비동기 프로그래밍 관련 글 보기.
StudyInMySpareTime WWDC21 Explore structured concurrency in Swift
StudyInMySpareTime WWDC21 Meet async/await
StudyInMySpareTime WWDC21 Protect mutable state with Swift actors #2<Swift Isolation, Main Actor>
'iOS' 카테고리의 다른 글
UITests Error가 발생할 때 No target application path specified via test configuration (0) | 2023.02.20 |
---|---|
Xcode14 Apple Clang Compiler gnu++20 (0) | 2023.01.03 |
Xcode Device build. 시스템 키체인을 사용하고자 합니다. (0) | 2022.03.16 |
Xcode에서 자꾸 인증을 하라고 한다. "Authentication failed because no credentials were provided" 대응. (0) | 2022.02.11 |
WWDC21 Explore structured concurrency in Swift (0) | 2021.06.17 |