본문 바로가기
비동기와 반응형/Swift Concurrency (고수준 비동기 처리)

비동기 작업 cancel 꼬꼬무해보기 (with LLDB 디버깅)

by lody.park 2024. 10. 5.

오늘은 ViewModel 등에서 Swift Concurrency Task의 라이프 사이클을 관리하면서 겪은 트러블 슈팅, 그리고 그 과정에서 공부한 내용을 정리해보려고 한다.

 

Combine, Rxswift 두 반응형 프레임워크는 이벤트 스트림을 비동기적으로 처리할 수 있도록 해준다.

그리고 일반적으로 ViewModel등에서 스트림 이벤트를 처리할때 ViewModel의 메모리에서 해제되는 시점에 스트림을 즉 작업을 취소(cancel)할 수 있게, 이벤트(또는 작업)들을 보관하고 관리할 수 있는 disposeBag 또는 cancellableBag 을 만들어 사용한다.

 

그렇다면 Swift Concurrency의 Task는 어떨까?

Swift 공식문서를 보면 Task 또한 취소가 가능하게 cancel을 제공한다고 한다.

 

하지만, Task는 Combine이나, RxSwift처럼 Task들을 보관할 수 있는 확장 메서드가 제공되지 않는다.

그래서 이 커스텀하게 만들어 사용해야한다.

 

어떻게 만들어쓸지 방법을 찾아보던중  민소네님의 포스팅을 참고해 아래와 같이 Task의 인터페이스에 맞춰 추상화된 프로토콜 AnyCancellableTask를 정의했다. Task는 이미 cancel() 메서드를 제공하므로, 프로토콜의 요구사항을 만족한다.

 

그리고 AnyCancellableTask들을 관리하는 AnyCancellableTaskBag라는 클래스를 만들어 사용했다.

 

나는 ViewModel에 AnyCancellableTask를 관리하는 Bag객체 하나를 선언하고

 

 

Combine 다운스트림에서 수행하는 Task를 Bag에 넣어 관리하도록 했다.

 

😱 그런데 사용하다가 런타임 에러가 발생했다.

어디서 에러가 생겼을까

새로운 task를 tasks 배열에 추가하면서 에러가 발생했다.

EXC_BAD_ACCESS와 관련된 오류는 보통 메모리 접근에 문제가 있을 때 발생한다.

CPU가 어떤 명령에서 메모리에 접근하다가 문제가 생겼을까. 문제가 생긴 명령어를 살펴보자.

 

 

0x194a351b8 <+16>: ldur x8, [x8, #-0x10

 

명령어를 해석해보면 x8 레지스터 +offset 이 가리키는 메모리 주소에서 값을 읽어와 현재 특정 메모리 주소에서 저장하려고 하는데,

이때 x8 + offset 이 가리키는 메모리 주소가 유효하지 않아서 EXC_BAD_ACCESS가 발생한 것이다.

 

💡 iOS의 CPU는 arm64아키텍쳐를 따른다. ludr은 arm64의 명령어 중 하나이다.

arm64의 명령어중 ldur은 Load Unsigned Register의 약어인데,
특정 메모리 주소에서 값을 읽어와 레지스터에 저장하려는 명령어이다.

명령어 주소 : ldur <destination_register>, [<base_register>, <offset>]
- destination_register: 데이터를 저장할 목적 레지스터
- base_register: 메모리 접근의 기준이 되는 베이스 레지스터.
- offset: 베이스 레지스터에서의 오프셋 값으로, 메모리 접근 위치를 지정.

 

 

지금과 같이 배열에 다루는 상황 속에서 메모리 주소가 유효하지 않을 상황이 뭐가 있을까?

케이스로 나눠서 하나씩 소거법으로 문제를 해결해보자.

  1. 배열의 특정 인덱스를 참조할 때 잘못된 메모리 주소에 접근하려는 경우.
  2. 배열 객체가 이미 해제되었을 경우.
  3. 배열 내부의 객체가 이미 해제되었거나 유효하지 않은 상태인 경우.
  4. 다중 스레드에서 동시에 배열에 접근하여 메모리 상태가 변한 경우.

현재 add메서드에서 문제가 발생했으므로 1번 특정 인덱스를 참조하다 생긴 문제는 아니다. 먼저 소거하자.

 

🤔 tasks 배열이 해제된걸까?

그렇다면 tasks 배열이 메모리에서 해제된 것일까? LLDB의 po(print object)를 이용해 tasks 객체를 확인해보자.

💡 LLDB po가 궁금하다면 WWDC19(beyond "po")를 참고하면 좋다

 

 

오잉? tasks의 값이 정상적으로 출력된다. tasks 배열 자체가 정상적으로 존재한다는것. > tasks 객체는 메모리에서 해제되지 않았다.

2번도 소거하자.

  1. 배열의 특정 인덱스를 참조할 때 잘못된 메모리 주소에 접근하려는 경우.
  2. 배열 객체가 이미 해제되었을 경우.
  3. 다중 스레드에서 동시에 배열에 접근하여 메모리 상태가 변한 경우.

🤔 그렇다면?? 동시성 문제.!

 

 

swift 배열의 append, remove 연산은 기본적으로 MT-safe하지 않다. 즉, race condition이 발생할 수 있다. 여러 쓰레드가 동일한 메모리 주소에 접근하여 데이터를 변경하려고 하면, 메모리 무결성 문제가 발생할 수 있다. 메모리 주소가 변하면서 잘못된 위치에 데이터가 쓰여지거나, 의도한 순서와 다른 순서로 연산이 실행될 수 있다.

 

그렇다..  나는 MT-safe 하지 않게 만든것..!


🤔 엥 그러면 Rxswift나 Combine은 뭐 어떻게 처리를 한거야?

 

그렇다면 다른 반응형 프레임워크에서는 어떻게 처리했을까?

잠시 Combine으로 넘어와보자.

왜 Combine에서는 Publisher를 store할때 race condition 문제가 발생하지 않았을까?

 

Set이 MT-Safe한 구조체인가? 아니다. 그렇지 않다.

Set 또한 Array와 같이 race condition이 발생할 수 있다.

그런데, 왜 문제가 없을까?

 

store 메서드를 까보자.

 

제너릭을 통해 인자로 받은 Set 혹은 Collection에 AnyCancellable을 저장할 수 있도록 구현되어있다.

주목할 부분은 함수의 시그니처가 inout 매개변수로 되어 있다는 점이다. 

 

inout 매개변수는 copy in - copy out 방식으로 동작한다.
즉, 호출 시점에 전달 받은 인자를 함수내부에서 별도로 복사해 관리한다.

원본 데이터와 함수 내부 데이터는 완전 서로 다른 데이터라는 것이다.

 

서로 다른 데이터에서 이미 끝났다.

MT 환경에서 store 메서드에 접근하더라도 쓰기 작업을 수행하는 도중 대상이 다른 쓰레드에 의해 메모리에서 사라질 염려가 없다.

대상 데이터는 서로 다른 데이터이기 때문에 읽기에 대한 경쟁 조건이 발생하지 않는다.

 

그럼 이제 store(in:) 메서드는 MT-safe한가? 그렇다.


다시 본론으로 돌아오자.

 

오류는 Array에 새로운 요소를 append하는 행위가 MT-Safe 하지 않아 발생한 문제였다.

방금 MT-safe 하지 않게 하는 방법은 이제 하나 알았다. inout 을 사용하는 것.

 

하지만 MT 환경에서 데이터 접근이나 함수 호출이 안전하게 이루어질 수 있는지를 의미하는 것일뿐, MT-safe가 data-race 문제를 해결해주지는 않는다.

 

S라는 상태의 데이터를 B와 C라는 쓰레드에서 동시에 copy in 하고

B라는 쓰레드에서 먼저 copy out 한다음 C라는 쓰레드가 copy out 한다면
B의 copy out 결과는 무시된다.

 

그래서 B와 C쓰레드 copy out에 대한 결과를 보장하고 싶은 경우에는 동기화를 구현해야한다.

 

그렇다면 data race 에 대한 완전한 격리성을 보장하려면 어떻게 해야할까.

즉 결과 데이터에 대한 정합성이 보장되려면...?

 

여러가지 방법이 있다.

 

첫째, 고전적인 방법으로 동기화 자료구조를 활용하는 것이다. NSLock또는 동기화Queue로 data race가 발생하는 critical section을 감싸면, 정합성을 보장할 수 있을 것이다.

둘째, 최근에 도입된 swift concurrency의 actor를 이용해 리소스에 대한 접근을 직렬화 시켜주는 것이다.

actor는 리소스에 대한 접근을 내부적으로 직렬화해준다. 즉 리소스를 격리시켜 동시성 문제를 해결해줄 수 있다.

 

어떤 방식으로 이제 커스텀해서 문제를 해결할지는 자유다..


ps.

 

오랜만에 좀 제대로 된 글을 써본다..

의식의 흐름대로 글을 쓰다가도 궁금한게 생겨 이것저것 찾아보게 되는 것 같다.

면접 준비하려고 카페왔는데, 글만 2시간째 쓰고 있다.. 🫠

근데 Swift 언어 줠라 잘 만들었다,,, Combine같은 퍼스트 라이브러리들도 설계 내용을 까보면, Swift를 이렇게 잘 활용하네 싶다...

 

 

https://www.swift.org/blog/swift-5-exclusivity/

 

Swift 5 Exclusivity Enforcement

The Swift 5 release enables runtime checking of “Exclusive Access to Memory” by default in Release builds, further enhancing Swift’s capabilities as a safe language. In Swift 4, these runtime checks were only enabled in Debug builds. In this post, I

www.swift.org

 

참고:

https://minsone.github.io/swift-concurrency-AnyCancelTaskBag

 

[Swift 5.7+][Concurrency] Task의 CancelTaskBag 구현하기

Swift의 Concurrency에서 Task를 이용해서 비동기 작업을 처리합니다. Task { try await Task.sleep(nanoseconds: 10 * 1_000_000_000) print("Hello") } 하지만, Task로 비동기 작업 도중에 Task를 실행한 객체가 사라지거나 할

minsone.github.io

 

https://wlaxhrl.tistory.com/84

 

Swift의 Array가 멀티쓰레드에서 안전하지 않은데 어떻게 하면 될까요? (Stack Overflow)

원문 Adding items to Swift array across multiple threads causing issues (because arrays aren't thread safe) - how do I get around that? 뜻을 이해하기 쉽도록 의역/추가설명을 붙인 부분들이 있습니다. Question Swift 멀티 쓰레드

wlaxhrl.tistory.com