Swift에서 값 유형이 있는 데이터 경합

5707 단어 dataraceswiftiosbugs
이번 주에는 클래스에서 값 유형(이 경우 aString)을 조작할 때 잘못된 스레딩 동기화로 인해 interesting discussion around a possible data race condition이 발생했습니다.

버그 코드




final class MyClass {
    var token: String

    init(_ token: String = "") {
        self.token = token
    }

    func myMethod() -> Bool {
        token.isEmpty
    }
}


언뜻 보기에 이것이 올바른 것처럼 보일 수 있습니다. 값 유형인 var 가 있는 Stringtoken 에서 isEmpty 만 호출하는 String 가 비어 있는지 확인하는 메서드가 있습니다. 간단한 코드와 안전한 권리? 뭐, 스레딩을 도입하지 않는 한 괜찮지만, 스레딩을 도입하는 순간은 그렇지 않을 것입니다. 자세히 설명하겠습니다.

시험



Thread Sanitizer를 활성화한 상태에서 이 테스트를 실행하는 경우:

 func test_data_race() {
        let sut = MyClass()

        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            sut.token = "\(i)"
            _ = sut.myMethod()
        }
    }


다음 출력이 표시됩니다.

WARNING: ThreadSanitizer: data race (pid=8329)
  Read of size 8 at 0x000107c1aab8 by thread T2:
    #0 closure #1 in DataTests.test_data_race() DataTests.swift:69 (Tests:arm64+0xde354)
    #1 partial apply for closure #1 in DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde3e4)
    #2 partial apply for thunk for @callee_guaranteed (@unowned Int) -> () <null>:73675156 (libswiftDispatch.dylib:arm64+0x42f4)
    #3 _dispatch_client_callout2 <null>:73675156 (libdispatch.dylib:arm64+0x35dc)

  Previous write of size 8 at 0x000107c1aab8 by main thread:
    #0 closure #1 in DataTests.test_data_race() DataTests.swift:69 (Tests:arm64+0xde374)
    #1 partial apply for closure #1 in DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde3e4)
    #2 partial apply for thunk for @callee_guaranteed (@unowned Int) -> () <null>:73675156 (libswiftDispatch.dylib:arm64+0x42f4)
    #3 _dispatch_client_callout2 <null>:73675156 (libdispatch.dylib:arm64+0x35dc)
    #4 _swift_dispatch_apply_current <null>:73675156 (libswiftDispatch.dylib:arm64+0x43a0)
    #5 @objc DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde448)
    #6 __invoking___ <null>:73675156 (CoreFoundation:arm64+0x11c5ec)

  Location is heap block of size 32 at 0x000107c1aaa0 allocated by main thread:
    #0 __sanitizer_mz_malloc <null>:73675156 (libclang_rt.tsan_iossim_dynamic.dylib:arm64+0x51004)
    #1 _malloc_zone_malloc <null>:73675156 (libsystem_malloc.dylib:arm64+0x1527c)
    #2 DataTests.test_data_race() DataTests.swift:66 (Tests:arm64+0xde07c)
    #3 @objc DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde448)
    #4 __invoking___ <null>:73675156 (CoreFoundation:arm64+0x11c5ec)

  Thread T2 (tid=6246748, running) is a GCD worker thread

SUMMARY: ThreadSanitizer: data race DataTests.swift:69 in closure #1 in DataTests.test_data_race()


따라서 ThreadSanitizer는 토큰에 액세스할 때 코드에서 데이터 경합을 감지합니다.
무슨 뜻인가요? 기본적으로 변수를 잘못 사용하고 있습니다. 읽기 및 쓰기 작업을 동시에 수행하지만 변수 자체는 보호되지 않으며 값 유형이라는 사실은 도움이 되지 않습니다.

이것이 무엇을 일으킬 수 있습니까? 정의되지 않았지만 실제로는 컴파일 최적화가 활성화되면 충돌이 발생할 가능성이 높습니다.

수정



자, 이 간단한 코드는 다른 스레드에서 병렬로 토큰을 읽고 쓸 때 충돌할 수 있습니다! 어떻게 고칠 수 있습니까? 읽기/쓰기를 위해 직렬 액세스를 만들기만 하면 됩니다. 이를 수행하는 방법에는 여러 가지가 있지만(다른 프리미티브 사용) 다음 중 하나일 수 있습니다.

final class MyClass {
    private let syncQueue = DispatchQueue(label: "com.test.myQueue", attributes: .concurrent)
    private var _token: String
    var token: String {
        get {
            syncQueue.sync {
                _token
            }
        }
        set {
            syncQueue.async(flags: .barrier) {
                _token = newValue
            }
        }
    }

    init(_ token: String = "") {
        _token = token
    }

    func myMethod() -> Bool {
        token.isEmpty
    }
}


보시다시피 직렬 쓰기를 강제하여 var를 보호하여 다중 읽기가 발생할 수 있지만 한 번에 하나의 스레드만 쓰기를 실행할 수 있습니다(장벽은 모든 이전 읽기가 완료되고 연기될 때까지 기다립니다) 쓰기가 완료될 때까지 모든 후속 읽기/쓰기 액세스). 결과 코드는 실행 속도가 느리지만 이제 안전합니다.

마지막 생각들



Swift 커뮤니티에서 일반적으로 오해하는 이 문제에 대한 몇 가지 생각을 공유하고 싶습니다.

❌ 값 유형은 스레드로부터 안전합니다.



값 유형에는 복사 시맨틱이 있으므로 본질적으로 데이터 경합으로부터 보호된다고 생각하는 것이 논리적으로 보일 수 있습니다. 그러나 그렇지 않습니다. Swift는 값 유형에서 스레드 안전성을 보장하지 않으므로 여러 스레드에서 anyvar에 액세스하는 것은 잠재적인 데이터 경쟁 조건입니다. 물론 이 문제는 변수let가 변경할 수 없기 때문에 적용되지 않습니다.

❌ 값 유형은 항상 복사됩니다.



그것은 의미론적이지만 후드 아래에서 실제로 일어나는 일은 아닙니다. 값 유형을 전달할 때 Swift 컴파일러는 사본이 필요한지 알고 불필요한 사본을 제거할 만큼 똑똑합니다. 실제로는 CopyOnWrite(COW) 전략을 사용하는데, 값이 수정될 때만 복사본을 만들고 전달될 때는 만들지 않습니다. 결과적으로 대부분의 상황에서 값 유형을 사용하는 경우에도 실제로 동일한 기본 메모리 주소에 대한 포인터를 갖게 됩니다.

❌ 테스트는 항상 프로덕션 코드처럼 작동합니다.



테스트가 충돌하지 않는다는 사실이 프로덕션에서 일부 코드가 충돌할 수 없다고 주장하는 것은 아닙니다. 테스트는 시뮬레이션된 환경에서 실행되며 일반적으로 최종 빌드와 다른 컴파일 옵션이 있습니다. 예를 들어 ARC는 적절한 옵션으로 컴파일할 때 적극적인 최적화를 수행하므로 최종 빌드에서 많은 불필요한 보유/릴리스가 제거됩니다. 이 특별한 경우에는 내 테스트 슈트가 충돌하지 않았고 Thread Sanitizer를 활성화하여 일부 잘못된 사용법만 볼 수 있었습니다.

추가 읽기


  • Understanding Swift's value types thread safety
  • ARC optimizations
  • 좋은 웹페이지 즐겨찾기