51. 재사용 배리어의 세대(phase) 관리 부재로 인한 프레임 섞임·데드락 · C#
난이도 최상해설 — 재사용 배리어의 세대(phase) 관리 부재로 인한 프레임 섞임·데드락 · C#
난이도: 최상
요약
직접 구현한 재사용 배리어는 대기 술어를 공유 카운터 _waiting 값으로만 판단한다(A). 마지막 도착자가 _waiting=0 으로 리셋하고 PulseAll 하지만(B), 카운터는 프레임마다 재사용되므로 어느 프레임의 0인지 구분할 수 없다. 먼저 풀려난 워커가 다음 프레임으로 넘어가 _waiting++ 를 해 버리면, 아직 깨어나지 못한 이전 프레임의 대기자가 술어를 영원히(또는 잘못) 관찰해 데드락하거나 프레임을 건너뛴다.
문제점
배리어 재사용 시 세대(generation) 부재 — 카운터 값을 phase 식별자로 오용
- 증상: 수천 프레임 반복 중 어느 순간 워커 하나가 이전 프레임에 갇히거나(데드락), 반대로 rendezvous가 완성되지 않았는데 통과한다. 재현이 드물고 부하가 높을수록 잘 난다.
- 재현 인터리빙(parties=4):
- 프레임 F: W1,W2,W3 도착 →
_waiting=3, 각자while(_waiting!=0) Wait로 대기. - W4 도착 →
_waiting==4→_waiting=0,PulseAll. PulseAll은 대기자를 깨울 수 있게 표시할 뿐, 이들은_gate락을 다시 얻어야 술어를 재확인한다. 그 사이 W4 는 락을 놓고 프레임 F+1 의 일을 마친 뒤 다시SignalAndWait를 호출해 락을 잡고_waiting++→_waiting=1.- 이제 W1 이 깨어나 락을 얻고 술어
_waiting != 0(==1) 을 확인 → 참이므로 다시 Wait 로 돌아간다. W1 은 프레임 F 를 아직 못 벗어났는데, F+1 의 카운트에 자신의 통과가 걸려 버렸다. - 이런 식으로 대기자가 다음 세대의 카운트 변화에 얽혀 영영 깨어날 조건을 놓치면 데드락. 반대로 타이밍에 따라 정족수 미달인데 술어가 우연히 0이 되어 통과하면 rendezvous 붕괴.
- 프레임 F: W1,W2,W3 도착 →
- 근본 원인:
_waiting은 프레임을 넘나들며 재사용되는데, 대기 술어가 그 값에만 의존한다. "이 프레임의 배리어가 열렸는가"를 판단하려면 프레임을 식별하는 세대 번호가 필요하다. 값이 0이라는 사실은 "지금 프레임이 열렸다"가 아니라 "누군가의 프레임이 방금 리셋됐다"일 뿐이다.
수정안
세대(phase) 번호를 도입한다. 대기자는 자기가 들어온 세대를 기억하고, "세대가 바뀌었는가"를 술어로 삼는다. 마지막 도착자가 세대를 올린다.
public class TickBarrier
{
private readonly int _parties;
private int _waiting = 0;
private long _generation = 0;
private readonly object _gate = new object();
public TickBarrier(int parties) { _parties = parties; }
public void SignalAndWait()
{
lock (_gate)
{
long myGen = _generation; // 이 프레임의 세대를 고정
_waiting++;
if (_waiting == _parties)
{
_waiting = 0;
_generation++; // 세대 전진 → 이 프레임 배리어 개방 확정
Monitor.PulseAll(_gate);
}
else
{
// 내 세대가 끝날 때까지 대기(다음 프레임의 카운트와 무관)
while (_generation == myGen)
Monitor.Wait(_gate);
}
}
}
}
이제 먼저 풀려난 워커가 다음 프레임에서 _waiting++ 를 해도, 이전 세대 대기자의 술어는 _generation == myGen(이미 거짓)이라 영향받지 않는다. .NET 이라면 이 로직을 직접 짜지 말고 표준 System.Threading.Barrier(재사용·phase 관리 내장) 를 쓰는 것이 정답이다.
var barrier = new Barrier(_parties);
// 각 워커: barrier.SignalAndWait();
더 나은 설계
- 표준 프리미티브 우선: 재사용 배리어는 세대·스퓨리어스 웨이크업·예외 처리 등 함정이 많다.
Barrier(C#),std::barrier(C++20),pthread_barrier를 쓰면 검증된 구현을 얻는다. - 세대 기반 술어: 손수 짤 수밖에 없다면 대기 술어는 반드시 "재사용되는 카운터 값"이 아니라 "단조 증가하는 세대"에 걸어야 한다. 이는 seqlock·CountdownEvent 재사용·틱 이중버퍼 등 재사용 동기화 전반의 공통 원칙이다.
- 트레이드오프: 세대 번호는
long이면 사실상 랩어라운드가 없다. 배리어 자체가 전 워커를 직렬화하는 병목이므로, 워크로드가 크면 배리어 횟수를 줄이는(단계 병합) 설계가 성능상 더 중요하다.
면접 포인트
- "조건변수 술어는 재사용되는 상태 값이 아니라 진행을 단조 증명하는 값에 걸어라." 카운터가 0이 되었다가 다시 채워지는 구조에서 0을 신호로 쓰면 다음 라운드와 충돌한다.
Monitor.PulseAll/notify_all은 "지금 깨워라"가 아니라 "깨어나서 술어를 재확인할 수 있게 하라"이다. 깨어나 락을 재획득하는 사이 상태가 또 바뀔 수 있으므로 술어 설계가 정확성의 핵심이다.
해설 — 재사용 배리어의 세대(generation) 관리 부재로 인한 데드락·프레임 섞임 · C++
난이도: 최상
요약
직접 구현한 재사용 배리어는 대기 술어를 waiting_ == 0 으로만 판단한다(A). 마지막 도착자가 waiting_=0 후 notify_all(B) 하지만, waiting_ 은 프레임마다 재사용되므로 "이 프레임이 열렸다"를 식별하지 못한다. 먼저 풀려난 워커가 다음 프레임에서 ++waiting_ 를 하면, 아직 깨어나 술어를 재확인하지 못한 이전 프레임 대기자가 다음 세대의 카운트에 얽혀 데드락하거나 rendezvous가 붕괴한다.
문제점
세대(generation) 부재 — 재사용 카운터를 phase 식별자로 오용
- 증상: 고부하 틱 루프에서 어느 순간 워커가 이전 프레임에 갇히거나(행), 정족수 미달인데 통과한다. 드물고 비결정적이다.
- 재현 인터리빙(parties=4):
- 프레임 F: W1,W2,W3 도착 →
waiting_=3,cv_.wait(lk, waiting_==0)로 대기. - W4 도착 →
waiting_==4→waiting_=0,notify_all. - 대기자들은 깨어나려면
mtx_를 재획득해야 한다. 그 사이 W4 가 락을 놓고 프레임 F+1 작업 후 다시arriveAndWait→mtx_획득 →++waiting_→waiting_=1. - W1 이 깨어나
mtx_획득 후 술어waiting_==0(현재 1) 확인 → 거짓 → 다시wait. W1 은 프레임 F 를 못 벗어났는데 F+1 의 카운트가 그 판단을 오염시켰다. - 남은 대기자들이 서로의 세대 카운트에 얽혀 깨어날 조건을 놓치면 데드락. 타이밍에 따라 술어가 우연히 0이 되어 정족수 미달 통과가 나기도 한다.
- 프레임 F: W1,W2,W3 도착 →
- 근본 원인:
waiting_은 프레임을 넘나들며 재사용되는데 대기 술어가 그 순간값에만 의존한다. C++ 조건변수는 스퓨리어스 웨이크업도 허용하므로, 술어는 "재사용 값 == 특정 수"가 아니라 프레임을 식별하는 단조 세대로 판정해야 한다.
수정안
세대 번호를 도입하고, 대기자는 자기 세대가 지날 때까지 기다린다.
class TickBarrier {
public:
explicit TickBarrier(int parties) : parties_(parties) {}
void arriveAndWait() {
std::unique_lock<std::mutex> lk(mtx_);
unsigned long myGen = generation_;
if (++waiting_ == parties_) {
waiting_ = 0;
++generation_; // 이 프레임 배리어 개방 확정
cv_.notify_all();
} else {
cv_.wait(lk, [this, myGen]{ return generation_ != myGen; });
}
}
private:
int parties_;
int waiting_ = 0;
unsigned long generation_ = 0;
std::mutex mtx_;
std::condition_variable cv_;
};
이전 세대 대기자의 술어 generation_ != myGen 은 다음 프레임의 waiting_ 변화와 무관하므로 얽힘이 사라진다. C++20 이면 표준 std::barrier(재사용·완료 콜백 내장) 를, POSIX 면 pthread_barrier_t 를 쓰는 것이 정석이다.
더 나은 설계
- 세대 기반 술어: 재사용 동기화(배리어, seqlock, 틱 이중버퍼, CountdownEvent 재사용)는 모두 "재사용되는 값"이 아니라 "단조 증가 세대"로 phase 를 식별해야 한다.
- 표준 프리미티브 우선:
std::barrier(C++20),pthread_barrier. 손수 구현은 세대·스퓨리어스 웨이크업·예외 안전을 모두 다뤄야 해 실수하기 쉽다. - 트레이드오프: 세대는
unsigned long이면 실질적으로 랩이 없다. 배리어는 전 워커를 직렬화하는 지점이라, 성능이 중요하면 배리어 호출 횟수를 줄이는 단계 설계가 우선이다.
면접 포인트
- 조건변수 술어는 "재사용되는 카운터가 특정 값인가"가 아니라 "내가 기다리던 사건이 지났는가(단조 증명)"에 걸어야 한다. 재사용 배리어의 세대 번호가 그 단조 증명이다.
notify_all후 대기자가 락을 재획득하는 사이 다른 스레드가 다음 라운드를 진행할 수 있다. 이 "깨어남과 재확인 사이의 창"을 세대로 봉쇄하는 것이 재사용 배리어의 핵심이다.