[Concurrency] Actor Defeats the DataRace

정유진·2022년 8월 17일
0

swift

목록 보기
10/24
post-thumbnail

들어가며 🤔

Datarace에 대해 들어본 적이 있는지? array와 같은 자료구조가 메모리 위에 존재한다고 가정했을 때, 하나의 thread가 아니라 여러 thread에서 해당 array에 append를 동시에 시도한다면 어떻게 될까? 우리가 100번 element를 append하려 시도했을 때 array 사이즈의 기댓값은 100이지만 실제로는 100에 미치지 못한다! 이러한 datarace를 방지하기 위해서는 여러가지 방안이 있지만 (NSLock 또는 DispatchGroup) ios 13+ 환경이라면 Actor를 사용하는 쪽이 간편하고 확실하다.

dataRace 🏎..🏎

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이 나올 때도 있지만 일관성이 없다.)

Thread Safe 보장하기 ⛑

1) NSLock

클래스 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
    }
}

2) TaskGroup (주의)

일관된 결과가 나오는 것으로 보이지만 코드의 연산이 단순해서 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

https://www.hackingwithswift.com/quick-start/concurrency/what-is-an-actor-and-why-does-swift-have-them

  • actor 키워드는 class, struct, enum과 같은 nominal type이다.
  • class와 같이 reference type
  • 성질 또한 class와 공유한다. - property, method, initializer, and subscript, conform to protocol, generic
  • 상속되지 않기 때문에 convenience init 없고 final, override도 없음
  • 기본적으로 Actor 프로토콜을 따른다.
  • 다른 타입들과 비교했을 때의 가장 큰 차이점은

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
        }
    }
  • actor는 외부로부터 message를 받으면 자신의 serial queue를 통해 request를 한 번에 하나씩 순차적으로 실행한다.
  • actor의 state에 접근할 수 있는 코드 또한 one by one
  • 따라서 property를 읽는 일과 method를 호출하는 일은 잠재적인 suspension, 중단 포인트이므로 await 키워드를 반드시 사용해야 한다.
  • actor의 내부에서는 await 사용하지 않고 read/call 할 수 있다.
  • 주의할 점, 외부에서 writing property는 불가능 하다.

정리하며 📦

actor에 대해 공부하며 구조체 자체가 thread safe하게 관리될 수 있다는 면에서 datarace를 피할 수 있는 좋은 방안이라는 판단이 들었다. actor만 안다고 쓸 수 있는 것은 아니고 swift 5.7에서 소개된 concurrency 전반에 대한 조화로운 이해가 뒷받침되어야 할 것이다. 당장 위의 코드만 보더라도 자연스럽게 taskgroup과 async-await를 사용하고 있기 때문이다. iOS 13+ 이상에서 지원하기 때문에 일단은 toy project에 적용해볼 예정이다.

profile
느려도 한 걸음 씩 끝까지

0개의 댓글