46. 완료 슬롯의 비휘발성 플래그: 무한 스핀과 결과 가시성 결여
난이도 상해설 — 완료 슬롯의 비휘발성 플래그: 무한 스핀과 결과 가시성 결여
난이도: 상
요약
_done 이 일반 bool 이라 (1) Wait 의 while(!_done){} 는 JIT 가 조건을 루프 밖으로 끌어올려
영원히 도는 스핀이 될 수 있고, (2) 소비자가 _done==true 를 보더라도 _result 쓰기가 함께
보인다는 보장이 없어 기본값/오래된 결과를 읽을 수 있다. 발행(release)과 획득(acquire) 동기화가 없다.
문제점
- [가시성 / 스핀 호이스팅] 무한 대기:
_done은 volatile 이 아니므로 JIT 은while(!_done)안에서_done을 매번 다시 읽을 필요가 없다고 판단해 레지스터에 캐시(호이스팅)할 수 있다. 그러면 다른 스레드가_done=true로 바꿔도 소비자는 갱신을 보지 못하고 무한히 돈다(라이브락/멈춤). - [메모리 순서] 결과-플래그 게시 동기화 부재:
(A)는_result를 쓰고_done을 쓰지만, 일반 필드 쓰기에는 release 의미가 없고(B)의 읽기에는 acquire 의미가 없다. 소비자가_done==true를 관측한 시점에_result의 최신 값이 보인다는 happens-before 관계가 성립하지 않는다. 결과가 값 형식(struct)이면 찢긴 읽기, 참조 형식이면 아직 초기화가 끝나지 않은 객체를 볼 수 있다. - [바쁜 대기] 스핀 자체의 낭비: 완료까지 CPU 코어를 태우는 순수 스핀은 응답 지연이 긴 DB/RPC 대기에 부적합하다(코어 점유·전력).
- 재현 조건: Release 빌드(JIT 최적화 활성) + 서버 GC/여러 코어. x86 은 store-store/load-load 재배열이 약해 덜 드러나지만, ARM 서버나 최적화된 호이스팅에서 바로 나타난다.
수정안
플래그를 volatile(또는 Volatile.Read/Write)로 두고 "결과 먼저, 플래그 나중" 게시 + "플래그 먼저,
결과 나중" 획득 순서를 지킨다. 스핀 대신 신호 기반 대기를 쓰는 것이 낫다.
public sealed class CompletionSlot<T>
{
private T _result = default!;
private volatile bool _done; // volatile: 쓰기=release, 읽기=acquire
private readonly ManualResetEventSlim _evt = new(false);
public void Complete(T value)
{
_result = value; // 결과 먼저
_done = true; // volatile 쓰기(release)로 결과 발행
_evt.Set();
}
public T Wait()
{
_evt.Wait(); // 스핀 대신 신호 대기
// _done 을 acquire 로 읽은 뒤 결과 판독
return _result;
}
public bool TryGet(out T value)
{
if (_done) { value = _result; return true; } // volatile 읽기(acquire) 후 결과
value = default!; return false;
}
}
가장 견고한 방법은 바퀴를 다시 발명하지 않고 TaskCompletionSource<T> 를 쓰는 것이다. 이는 결과
게시·완료 신호·대기의 메모리 순서를 모두 올바르게 처리하고 await 와도 자연스럽게 결합된다.
더 나은 설계
TaskCompletionSource<T>/Task<T>: 표준 완료 프리미티브로 대체하면 가시성·대기·취소·예외 전파가 한 번에 해결된다. 게임 루프에서 블로킹이 싫으면await로 논블로킹 대기한다.- 스핀이 꼭 필요하면: 매우 짧은 대기에 한해
SpinWait를 쓰되 플래그는Volatile.Read로 읽는다. - 결과·완료의 단일 발행점: 완료 상태와 결과를 하나의 원자적 게시로 묶으면(예: 참조 교체) 부분 관측을 없앤다.
- 트레이드오프: 이벤트 기반 대기는 컨텍스트 스위치 비용이 있지만 긴 대기에 적합하고, 스핀은 초저지연이지만 코어를 태운다.
면접 포인트
- 비휘발성 플래그 스핀이 왜 JIT 호이스팅으로 무한 루프가 되는가(
volatile/Volatile.Read의 역할). - release/acquire 가 없으면 "완료 플래그는 보이는데 결과는 안 보이는" 부분 관측이 왜 생기는가.
- 직접 만든 동기화 대신
TaskCompletionSource/Task를 쓰는 이유.
해설 — 완료 슬롯의 relaxed 순서: 결과 게시가 동기화되지 않음
난이도: 상
요약
done_ 은 std::atomic<bool> 이라 데이터 레이스 자체는 없지만, (A) 는 memory_order_relaxed
store, (B) 는 relaxed load 다. relaxed 는 result_ 쓰기와 done_ 쓰기 사이에 release/acquire
동기화(synchronizes-with) 를 만들지 않는다. 소비자가 done_==true 를 관측해도 result_ 의 최신
값이 보인다는 보장이 없어 초기화 전/오래된 result_ 를 읽을 수 있다.
문제점
- [메모리 순서] relaxed 로는 결과가 발행되지 않음: happens-before 를 성립시키려면 생산자 쪽
done_.store(true, release)와 소비자 쪽done_.load(acquire)가 짝을 이뤄야 한다. 지금은 둘 다 relaxed 라,result_의 비원자 쓰기가done_관측 시점에 가시화된다는 보장이 없다. 소비자는result_(여기선{value, checksum}두 워드)를 부분적으로/오래된 값으로 읽을 수 있다. - 약한 메모리 모델에서 드러남: x86 은 store/load 재배열이 약해 잘 안 보이지만, ARM/POWER 서버에서는
done_=true가result_쓰기보다 먼저 전파되어 소비자가 쓰레기result_를 읽는 일이 실제로 발생한다. 컴파일러도 relaxed 원자 주변의 비원자 접근을 재배열할 수 있다. - [바쁜 대기] 순수 스핀: 완료까지 코어를 태우는 스핀은 응답 지연이 긴 DB/RPC 대기에 부적합하다 (코어 점유·전력·하이퍼스레드 형제 스레드 방해).
- 재현 조건: 멀티코어(특히 약한 순서 아키텍처)에서 생산자·소비자가 서로 다른 코어일 때.
수정안
store 를 release, load 를 acquire 로 바꿔 result_ 를 올바르게 발행/획득한다. 스핀 대신 조건변수로
대기하는 편이 긴 대기에 낫다.
#include <atomic>
#include <mutex>
#include <condition_variable>
template <class T>
class CompletionSlot {
public:
void complete(const T& value) {
result_ = value; // 결과 먼저
{
std::lock_guard<std::mutex> lk(m_);
done_.store(true, std::memory_order_release); // release: 앞선 result_ 쓰기를 발행
}
cv_.notify_all();
}
T wait() {
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [this]{ return done_.load(std::memory_order_acquire); }); // acquire 로 결과 획득
return result_;
}
bool tryGet(T& out) {
if (done_.load(std::memory_order_acquire)) { out = result_; return true; }
return false;
}
private:
T result_{};
std::atomic<bool> done_{false};
std::mutex m_;
std::condition_variable cv_;
};
순수 스핀이 꼭 필요한 초저지연 경로라면 조건변수 없이 release/acquire 만으로도 정확성은 확보된다
(대기 방식은 성능 선택). 더 단순하게는 std::promise<T>/std::future<T> 로 대체하면 발행·대기·예외
전파가 표준적으로 처리된다.
더 나은 설계
std::promise/std::future또는 실행기(executor)의 완료 토큰을 사용해 직접 구현을 피한다.- 초저지연 스핀이 필요하면:
acquire/release원자 +std::this_thread::yield()/_mm_pause로 스핀 완화(백오프)를 둔다. - 결과 크기가 크면: 결과를
shared_ptr로 담아 원자 포인터 교체(release)로 한 번에 발행하면 부분 관측을 원천 차단한다. - 트레이드오프: 조건변수 대기는 컨텍스트 스위치 비용이 있으나 긴 대기에 적합하고, 스핀은 초저지연이나 코어를 태운다.
면접 포인트
memory_order_relaxed로는 왜 "플래그는 보이는데 데이터는 안 보이는" 부분 관측이 생기는가 (release/acquire 의 synchronizes-with).- 약한 순서 아키텍처(ARM)에서 x86 에선 숨던 버그가 드러나는 이유.
- 직접 만든 래치 대신
std::promise/future를 쓰는 이유와, 스핀 vs 조건변수 대기 트레이드오프.