C++ 비대칭 펜스(Asymmetric Fences)의 내부 원리
C++ 동시성 제어에서 드물게 실행되는 경로의 동기화 비용을 늘리는 대신, 빈번하게 실행되는 경로의 오버헤드를 줄여 전체 성능을 최적화하는 '비대칭 펜스(Asymmetric Fences)'의 개념과 작동 원리를 설명합니다. 이 기법은 스레드 풀, RCU 등 고성능 동시성 라이브러리에서 시스템 성능을 극대화하기 위해 활발히 사용되고 있습니다.
비대칭 펜스(Asymmetric Fences)?
동시성(Concurrency) 개념을 공부하기 위해 Folly의 동기화 프리미티브(synchronization primitives)를 살펴보던 중, 코드베이스에서 익숙한 이름 하나가 눈에 띄었습니다. 바로 '비대칭 스레드 펜스(Asymmetric Thread Fence)'였습니다. 저는 std::atomic_thread_fence가 무엇을 위한 것인지 이미 알고 있었지만, 이 구조체는 정확히 무엇을 하는 걸까요? 그리고 대체 무엇이 '비대칭'인 걸까요?
이 질문은 저를 아주 흥미로운 토끼 굴로 이끌었습니다. 비대칭 스레드 펜스의 세부 사항과 내부적으로 실제로 어떤 작업을 수행하는지(적어도 Linux 환경에서) 파헤쳐보게 된 것입니다. 우리는 이 구조체에 대한 C++ 제안서부터 시작해 구현 세부 사항까지 깊게 파고들어 볼 것입니다.
C++ P1202R0 소개
세부 작동 원리는 이후 섹션에서 다루겠지만, 지금은 이 펜스들이 달성하고자 하는 목표를 이해하는 것만으로 충분합니다. 제안서를 참고하여 전체적인 개요를 살펴보겠습니다.
"일부 유형의 동시 알고리즘은 공통 경로(common path)와 비공통 경로(uncommon path)로 나눌 수 있으며, 올바른 실행을 위해 두 경로 모두 펜스(또는 완화되지 않은 메모리 순서를 가진 다른 작업)를 필요로 합니다. 많은 플랫폼에서 비공통 경로에 더 강력한 펜스 유형(memory_order_seq_cst보다 더 강한)을 추가함으로써 공통 경로의 속도를 높일 수 있습니다. 이러한 기법들은 점점 더 많은 동시성 라이브러리에서 사용되고 있습니다. 우리는 이러한 비대칭 펜스를 표준화하고 메모리 모델에 통합할 것을 제안합니다."
본질적으로, 동시성 알고리즘은 양쪽 모두에 펜스가 필요할 수 있습니다. 하지만 한쪽 경로가 다른 쪽보다 훨씬 더 빈번하게 실행된다면, 비공통 경로에 더 무거운 동기화 비용을 전가하고 공통 경로에는 더 가벼운 펜스를 사용하여 공통 경로를 최적화할 수 있습니다. 공통 경로에서 얻는 성능 이점이 비공통 경로의 무거운 펜스로 인한 오버헤드보다 훨씬 크다면 전반적인 성능 향상을 가져올 수 있습니다.
이 패턴은 처음 생각하는 것보다 훨씬 더 흔하게 사용됩니다. Folly의 코드베이스를 살펴보면 이 패턴이 사용된 여러 곳을 찾을 수 있습니다:
folly/synchronization/HazptrDomain.h: 해저드 포인터 (Hazard Pointers) folly/synchronization/detail/ThreadCachedReaders.h: RCU folly/executors/ThreadPoolExecutor.cpp: 스레드 풀 (Thread Pool ExWcutor)
논문에서 발췌한 수정된 예제인 데커(Dekker)의 예제로 시작해 보겠습니다:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 atomic_int x { 0 }, y { 0 }; int r1 , r2 ; // 경로 A: x . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r1 = y . load ( memory_order_relaxed ); // 경로 B: y . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r2 = x . load ( memory_order_relaxed ); // 두 경로가 모두 완료된 후 실행됩니다. assert ( ! ( r1 == 0 && r2 == 0 )); // 결코 실패하지 않습니다.
우리는 이 assert 문이 실패할 수 없다는 것을 알고 있습니다. C++ 메모리 모델하에서 왜 그런지 살펴보겠습니다:
[thread.thread.constr]/6의 스레드 생성과 스레드 함수 시작 간의 동기화 관계(synchronizes-with)로 인해 제약 조건 $(1) \rightarrow (2)$ 및 $(1) \rightarrow (5)$가 발생합니다. 또한 [intro.races]/15에 설명된 쓰기-쓰기 일관성 보장을 통해 X와 Y의 수정 순서(modification order)가 설정됩니다.
제약 조건 $(4) \rightarrow (5)$ 및 $(7) \rightarrow (2)$는 [atomics.order]/3.3의 요구 사항에서 발생하는 일관성 순서(coherence-orderings)입니다. 예를 들어, $(4)$와 $(5)$는 동일한 원자적 읽기-수정-쓰기 작업이 아니며, $(4)$는 $(1)$이 저장한 값을 읽고, $(1)$은 Y의 수정 순서에서 $(5)$보다 앞섭니다.
[atomics.order]/4.4에 따라, memory_order::seq_cst 펜스 $A$는 $(4)$보다 먼저 발생(happens-before)하고, $(5)$는 memory_order::seq_cst 펜스 $B$보다 먼저 발생합니다. 따라서 모든 memory_order::seq_cst 작업에 대한 단일 전체 순서 $S$에서 $(3)$은 $(6)$보다 먼저 와야 합니다. 대칭적인 추론에 의해, 동일한 전체 순서 $S$에서 $(6)$도 $(3)$보다 먼저 와야 합니다. 이는 불가능하므로 모순이 발생합니다. 따라서 r1 == 0 && r2 == 0이 되는 실행은 금지됩니다.
이제 비대칭 스레드 펜스로 돌아가 보겠습니다. 핵심 아이디어는 일부 동시성 알고리즘에서 흔히 발생하는 경로(common path)를 최적화하는 데 중점을 둔다는 것입니다. (대신 흔하지 않은 경로의 비용을 희생하여)