메모리 펜스의 개념은 CPU가 효율성을 극대화하기 위해 비순차 실행을 사용하는 하드웨어 메모리 모델에서 기인합니다. Rust의 std::sync::atomic::fence는 데이터 수정 없이 개별 위치에 대한 메모리 작업 간의 순서 제약을 설정하기 위해 이러한 저수준 원시 요소를 노출합니다. 원자 연산은 데이터 수정과 순서 보장을 결합하는 반면, 펜스는 모든 이전 또는 이후의 메모리 접근에 대한 가시성 규칙을 시행하는 동기화 장벽 역할을 합니다.
일반적인 오해는 원자 변수를 사용하여 Ordering::SeqCst를 사용할 경우 모든 스레드 간의 관계 없는 메모리 위치에 대한 모든 이전 쓰기가 자동으로 동기화된다는 것입니다. 이는 잘못된 것입니다. SeqCst는 원자 연산 자체에 대해서만 전체 순서를 제공하며 다른 데이터에 대한 전이적인 발생-이전 관계는 제공하지 않습니다. 스레드 A가 버퍼에 쓰고 나서 원자 플래그에 대해 Release 저장을 수행하고, 스레드 B가 해당 플래그에 대해 Acquire 로드를 수행할 때, 펜스나 더 강력한 순서가 두 도메인을 연결하지 않는 한 스레드 B는 버퍼 쓰기를 자동으로 보지 않습니다.
해결책으로 **fence(Ordering::Release)**는 프로그램 순서에서 자기 이전의 모든 메모리 작업이 다른 스레드에 대해 보이도록 보장합니다. 반면에 **fence(Ordering::Acquire)**는 모든 메모리 작업이 다른 스레드의 매칭되는 Release 펜스 이전에 작성된 값을 관찰하게 합니다. 이러한 쌍별 동기화는 전체 메모리 상태에서 발생-이전 엣지를 생성하여 개별 원자 변수에 국한되지 않으며, 별도의 제어 및 데이터 채널에 의존하는 비잠금 알고리즘을 가능하게 합니다.
한 스레드가 패킷 데이터를 포함한 공유 링 버퍼를 채우고 헤드 포인터를 업데이트하며, 다른 스레드가 포인터를 읽고 패킷을 처리하는 제로 복사 네트워크 패킷 프로세서를 고려해 보십시오. 생산자는 표준 쓰기(비원자적 작업)를 사용하여 버퍼에 패킷 바이트를 쓰고, 새로운 데이터의 가용성을 알리기 위해 Ordering::Release를 사용하여 헤드 인덱스를 원자적으로 증가시킵니다. 소비자는 인덱스가 변경될 때까지 기다렸다가 버퍼에서 패킷 데이터를 읽습니다.
한 가지 가능한 해결책은 전체 버퍼와 인덱스를 std::sync::Mutex로 보호하는 것이었습니다. 이는 메모리 안전성과 순차 일관성을 보장하지만 심각한 경합을 초래합니다. 모든 패킷 쓰기 시 잠금을 획득해야 하며 생산자를 직렬화하고 캐시 지역성을 파괴합니다. 이러한 접근 방식은 고주파 거래 요구 사항에 대해 수용할 수 없는 수준으로 처리량을 감소시켜 저지연 시스템에는 적합하지 않았습니다.
또 다른 고려된 접근 방식은 헤드 포인터에 대해 Release/Acquire 쌍을 Ordering::SeqCst로 대체하는 것이었습니다. 이는 전역 순서가 버퍼 쓰기를 암시적으로 플러시할 것이라고 가정했습니다. 그러나 이는 실패합니다. SeqCst는 SeqCst 연산 자체 간의 전체 순서만 설정하므로 컴파일러와 CPU는 비원자 버퍼 쓰기를 원자 저장 후에 재배치할 수 있는 자유가 있습니다. 결과적으로, 소비자는 업데이트된 헤드 인덱스를 관찰하면서 오래된 패킷 데이터를 읽을 수도 있으며, 이는 비록 강력한 원자 순서가 있는 것처럼 보이더라도 메모리 안전을 위배합니다.
선택된 해결책은 모든 버퍼 쓰기를 완료한 후, 그러나 생산자 측에서 업데이트된 헤드 인덱스를 저장하기 전에 **fence(Ordering::Release)**를 삽입하는 것이었습니다. 소비자 스레드는 헤드 인덱스를 로드한 직후에 **fence(Ordering::Acquire)**를 배치하고 버퍼 포인터를 역참조하기 전에 이를 수행했습니다. 이 쌍은 인덱스 업데이트가 발표되기 전에 버퍼 쓰기가 전 세계적으로 가시적이도록 보장하며, 소비자는 인덱스가 동기화될 때까지 버퍼를 추측하여 읽지 못하며, 잠금 없이 데이터 경쟁을 제거합니다.
결과적으로 밀리초 단위의 지연으로 초당 수백만 패킷을 처리할 수 있는 잠금 없는 SPSC(단일 생산자-단일 소비자) 큐가 완성되었습니다. 벤치마크는 Mutex 기반 접근 방식에 비해 10배 개선된 결과를 보여주었으며, Miri 및 Loom 동시성 검사 도구에서 데이터 경쟁이 전혀 발생하지 않았습니다. 이는 적절한 펜스 사용이 하드웨어 수준의 성능을 유지하면서 Rust의 안전 보장을 유지할 수 있음을 보여주었습니다.
왜 원자 변수의 독립적인 Acquire 로드가 생산 스레드에서의 이전 비원자 쓰기의 가시성을 보장하지 않습니까? 그 스레드가 같은 변수에 대해 Release 저장을 사용했더라도 말입니다.
독립적인 Acquire 로드는 특정 원자 위치에 대한 Release 저장과만 동기화되며, 해당 변수가 있는 한에 국한된 발생-이전 관계를 생성합니다. 이는 저장 전에 생산자가 작성한 다른 메모리 위치로 확장되지 않습니다. 그러한 쓰기를 동기화하려면 생산자는 저장 전에 Release 펜스를 사용해야 하며, 소비자는 로드 후에 Acquire 펜스를 사용해야 합니다. 이러한 펜스 없이는 컴파일러가 비원자 쓰기를 원자 저장 이후에 재배치할 수 있으며 CPU가 그 가시성을 지연시킬 수 있어 관련 없는 데이터에서 데이터 경쟁이 발생할 수 있습니다.
컴파일러는 Relaxed 원자 작업을 어떻게 최적화하고, 이로 인해 x86_64에서 강력한 하드웨어 메모리 모델에도 불구하고 직관에 반하는 오래된 읽기가 발생할 수 있는 이유는 무엇입니까?
강력한 순서를 제공하는 x86_64에서도 Relaxed 작업은 원자성(찢어진 읽기/쓰기가 없음)만 보장하지만 주변 작업에 대한 순서 제약은 부과하지 않습니다. 컴파일러는 Relaxed 로드 및 저장을 다른 명령으로 재배치하거나 레지스터에 값을 유지할 수 있는 자유가 있어 스레드가 프로그램의 논리적 흐름에 상대적으로 오래된 값을 관찰하게 만듭니다. 후보자들은 하드웨어 일관성을 컴파일러 보장으로 오해하는 경우가 많으며, Relaxed가 컴파일러 최적화에 대해 보호를 제공하지 않음을 잊어버리며, 재배치를 방지하기 위해 Acquire/Release 의미론이 필요합니다.
전역 총 주문의 SeqCst 펜스는 Acquire 및 Release 펜스의 조합과 무엇이 구별되며, 어떤 특정 알고리즘 요구 사항에서 SeqCst의 전역 총 주문이 필수적입니까?
SeqCst 펜스는 모든 스레드 간의 모든 SeqCst 연산에 대해 전 세계적으로 일관된 총 순서를 강제하며, 모든 스레드가 이러한 사건의 동일한 순서를 관찰하도록 보장합니다. 반면 Acquire/Release 펜스는 특정 스레드 및 메모리 위치 간의 쌍별 동기화만 설정하며 전 세계의 합의 없이 원자적입니다. SeqCst는 Dekker의 상호 배제 알고리즘이나 분산 타임스탬프 계수기와 같이 사건의 순서에 대한 전 세계적인 합의가 필요한 알고리즘에 필수적입니다. 여기서 여러 스레드는 관계없는 작업의 상대적인 순서에 대해 독립적으로 같은 결론에 도달해야 합니다. 단순한 생산자-소비자 시나리오에서는 Acquire/Release의 쌍별 동기화가 충분하고 더 성능이 뛰어납니다.