Datarace에 대해 들어본 적이 있는지? array와 같은 자료구조가 메모리 위에 존재한다고 가정했을 때, 하나의 thread가 아니라 여러 thread에서 해당 array에 append를 동시에 시도한다면 어떻게 될까? 우리가 100번 element를 append하려 시도했을 때 array 사이즈의 기댓값은 100이지만 실제로는 100에 미치지 못한다! 이러한 datarace를 방지하기 위해서는 여러가지 방안이 있지만 (NSLock 또는 DispatchGroup) ios 13+ 환경이라면 Actor를 사용하는 쪽이 간편하고 확실하다.
var body: some View {
VStack {
Button(action: testWithDispatchGroup) {
Text("Tap")
}
.frame(width:100, height: 100)
.background(RoundedRectangle(cornerRadius: 8)
.fill(Color.blue))
.foregroundColor(.white)
.padding(.bottom, 20)
Text(String(result))
}
.padding()
}
swiftUI로 간단하게 버튼과 라벨을 그려넣었다.
클래스 Counter는 count라는 property를 가진다.
그리고 addCount() 메서드를 통해 프로퍼티 값을 증가시킬 수 있다.
class Counter {
var count = 0
func addCount() {
count += 1
}
}
버튼을 탭하면 for 반복문을 통해 1000번 동안 addCount 메서드를 call 해보겠다. 그리고 count 프로퍼티를 label에 표시해줄 것이다. 그렇다면 당연히 1000이 출력되겠지?
private func testWithDispatchGroup() {
let totalCount = 1000
let counter = Counter()
let group = DispatchGroup()
for _ in 0..<totalCount {
DispatchQueue.global().async(group: group) {
counter.addCount()
}
}
// all tasks in the group have finished -> executing
group.notify(queue: .main) {
result = counter.count
}
}
1000번의 addCount를 하였지만 결과값은 1000보다 작은 값이 나온다. (물론 1000이 나올 때도 있지만 일관성이 없다.)
클래스 Counter를 수정했다. lock을 통해 1000번의 call이 동기화하여 대기줄을 세운 효과가 난다. call 522번이 count +1 하는 동안 call 523번은 대기한다. call 522번이 작업을 끝내면 unlock() 되면서 call 523번이 실행된다. (defer
블록은 상단에 쓰여져 있지만 무조건 함수 종료 직전에 실행된다.)
class Counter {
var count = 0
let lock = NSLock()
func addCount() {
count += 1
}
func addCountWithLock() {
lock.lock(); defer {lock.unlock()}
count += 1
}
}
일관된 결과가 나오는 것으로 보이지만 코드의 연산이 단순해서 optimizing이 가능한 것일 뿐 thread sanitizer warning이 발생하므로 datarace를 피할 수 없다.
private func testWithTask() {
let totalCount = 1000
let counter = Counter()
Task {
// of는 childTask의 return type
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
taskGroup.addTask {
counter.addCount()
}
}
})
// 1000이 보장되는 것처럼 보이지만 실상은 thread safe하지 않음
result = counter.count
}
}
actor의 외부에서 property 를 read 하거나 / method를 call 할 때 이들은 async이므로
await
키워드를 반드시 사용해야 한다는 것이다.
actor CounterActor {
// actors will protect its mutale state from both read and write access
private(set) var count = 0
func addCount() {
count += 1
}
}
private func testWithActor() {
let totalCount = 1000
let counter = CounterActor()
Task {
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
taskGroup.addTask {
// since counter is now an actor, it will allow only 1 async task to access its mutable state (the count variable) , we must mark both access points with await indicating that these access points might suspend if ther is another task accessing the count variable
await counter.addCount()
}
}
})
result = await counter.count
}
}
외부에서 writing property는 불가능
하다.actor에 대해 공부하며 구조체 자체가 thread safe하게 관리될 수 있다는 면에서 datarace를 피할 수 있는 좋은 방안이라는 판단이 들었다. actor만 안다고 쓸 수 있는 것은 아니고 swift 5.7에서 소개된 concurrency 전반에 대한 조화로운 이해가 뒷받침되어야 할 것이다. 당장 위의 코드만 보더라도 자연스럽게 taskgroup과 async-await를 사용하고 있기 때문이다. iOS 13+ 이상에서 지원하기 때문에 일단은 toy project에 적용해볼 예정이다.