29. 침투적 참조 카운트의 비원자성 (버퍼 풀의 이중 반납·반납 후 사용)
난이도 최상해설 — 침투적 참조 카운트의 비원자성 (버퍼 풀의 이중 반납·반납 후 사용)
난이도: 최상
요약
AddRef의 _refs++ (A)와 Release의 --_refs (B)는 비원자적 read-modify-write다. 여러 스레드가 동시에 호출하면 증감이 유실(torn)되어 카운트가 실제 참조 수와 어긋난다. 그 결과 ① 카운트가 너무 일찍 0이 되어 아직 쓰는 중인 버퍼를 풀로 반납(다른 소비자에게 재대여 → 데이터 오염, "반납 후 사용"), ② 0을 두 스레드가 동시에 관측해 이중 반납, ③ 거꾸로 0에 영영 못 닿아 미반납(누수) 이 발생한다.
문제점
- [비원자적 read-modify-write / 데이터 레이스]
- 증상: 풀이 같은 버퍼를 두 번 나눠주거나(상태 오염·크래시), 카운트가 음수로 내려가거나(이중 반납), 버퍼가 풀로 돌아오지 않는다(누수).
- 재현 조건(유실 증가): 현재
_refs=2. 두 스레드가 동시에AddRef. 둘 다_refs를 2로 읽고 3을 쓴다 → 결과 3(원래 4여야 함). 증가 하나 유실 → 나중에 0에 너무 일찍 도달 → 아직 참조가 남았는데 반납. - 재현 조건(이중 반납):
_refs=1에서 두 스레드가 거의 동시에Release.--_refs가 비원자라 둘 다 "0"을 관측 → 둘 다_pool.Return(this)실행. - 근본 원인
(A)(B):x++/--x는 "읽기 → 증감 → 쓰기"의 세 단계이며, 그 사이 다른 스레드가 끼어들 수 있다. 참조 카운트는 본질적으로 여러 스레드가 동시에 갱신하므로 단일 원자 연산이어야 한다.
수정안
Interlocked로 증감을 원자화하고, "정확히 0을 만든 스레드"만 반납한다.
public void AddRef()
{
// 이미 살아있는 참조를 쥔 상태에서만 호출돼야 한다(0에서 되살리기 금지).
Interlocked.Increment(ref _refs);
}
public void Release()
{
int now = Interlocked.Decrement(ref _refs); // 원자적 감소 + "감소 후 값" 반환
if (now == 0)
_pool.Return(this); // 0을 만든 스레드 단 하나만 반납
// 방어: now < 0 이면 이미 누군가 0 이하로 내렸다는 뜻 → 로직 버그(이중 Release)
}
핵심:
Interlocked.Decrement는 원자적이고 반환값이 "감소 후의 값" 이라, 정확히 한 스레드만now == 0을 보게 된다 → 이중 반납·미반납이 사라진다.Interlocked연산은 .NET에서 전체 메모리 배리어다. 그래서 0을 만든 스레드는 다른 스레드가 감소 직전까지 버퍼에 쓴 내용을 관측한다(C++에서 별도 acquire 펜스가 필요한 것과 달리, 여기선 배리어가 내장).
더 나은 설계
- 0에서 되살리기(resurrection) 금지:
AddRef는 이미 유효한 참조를 가진 코드만 호출해야 한다. 약한 참조(weak)에서 강한 참조를 얻는 경로가 있다면, "0이면 실패"하는 CAS 루프가 필요하다:public bool TryAddRef() { int c; do { c = Volatile.Read(ref _refs); if (c == 0) return false; } while (Interlocked.CompareExchange(ref _refs, c + 1, c) != c); return true; } - 소유권을 타입으로 표현: 수동 AddRef/Release는 누락/중복이 쉽다.
using/IDisposable핸들이나 소유권 래퍼(빌릴 때 +1, Dispose 때 -1)로 짝을 강제하면 사람 실수를 줄인다. - 반납 후 사용 차단: 반납 시 버퍼를 "사용 불가" 상태로 표시(세대 토큰)하고, 사용 시 토큰을 검증하면 use-after-return을 빨리 잡는다.
- 트레이드오프: 원자 연산은 경합 시 캐시라인 핑퐁 비용이 있다. 핫패스라면 스레드 로컬 배칭이나, 애초에 버퍼를 단일 소유(한 스레드가 끝까지 소유)로 설계해 공유 카운트를 없애는 편이 더 빠르다.
면접 포인트
x++가 원자적이지 않은 이유(read-modify-write 3단계)와, 참조 카운트가 왜 반드시 원자 증감이어야 하는지.Interlocked.Decrement의 반환값(감소 후 값)으로 "정확히 한 번 반납"을 보장하는 패턴.- .NET
Interlocked가 전체 배리어라는 점, 그리고 0에서의 되살리기(weak→strong) 문제와 CAS 해법.
해설 — 침투적 참조 카운트의 메모리 순서 (해제 시 acquire 동기화 누락)
난이도: 최상
요약
참조 카운트 자체는 std::atomic<int>라 증감은 원자적이다. 그러나 release의 감소가 (A) memory_order_relaxed이고, 마지막 참조에서 정리하기 직전 (B)에 acquire 펜스가 없다. relaxed 감소만으로는 "다른 스레드가 버퍼에 마지막으로 쓴 내용"과 "이 스레드의 정리(소멸/반납)" 사이에 happens-before가 성립하지 않는다. 그래서 정리하는 스레드가 다른 스레드의 마지막 쓰기를 보지 못한 채(또는 정리가 그 사용보다 먼저 재배열되어) 버퍼를 해제할 수 있다 → use-after-free / 데이터 손상. 표준 shared_ptr 구현은 이를 release 감소 + acquire 펜스(또는 acq_rel)로 막는다.
문제점
- [부족한 메모리 순서 / 동기화 부재]
- 증상: 드물게(특히 약한 메모리 모델 ARM/POWER에서) 해제된 버퍼를 다른 스레드가 아직 읽거나, 소멸자가 다른 스레드의 마지막 쓰기를 보지 못해 자원 정리가 잘못된다. x86에서는 잘 안 보이다가 ARM 서버에서 터지는 전형적 이식 버그.
- 재현 조건: 스레드 T1이 버퍼에 데이터를 쓰고
release(refs 2→1). 스레드 T2가release(refs 1→0)로 마지막을 관측해destroy(). relaxed라서 T1의 쓰기 → T2의 destroy 사이에 happens-before가 없다. T2의 소멸/반납이 T1의 쓰기를 관측하지 못하거나, 컴파일러/CPU가 T2의delete를 T1의 사용보다 앞으로 재배열할 여지가 생긴다. - 근본 원인
(A)(B): 참조 카운트의 마지막 감소는 단순한 카운터 갱신이 아니라 "소유권 이전·자원 해제의 동기화 지점" 이다. 다른 스레드들의 모든 이전 접근이 이 감소에 release로 게시되고, 정리 스레드는 acquire로 그것을 흡수해야 안전하게 해제할 수 있다. relaxed는 그 어떤 순서도 보장하지 않는다.
- (증가는 왜 relaxed 가 허용되나)
add_ref는 이미 유효한 참조를 쥔 채 늘리는 것이라, 그 시점에 객체 생존이 보장되고 새 순서 의존도 없어 relaxed가 정당하다(Boostintrusive_ptr, libstdc++shared_ptr도 add는 relaxed). 결함은 오직 감소·해제 경로에 있다.
수정안
표준 라이브러리의 패턴을 따른다: 감소는 release, 마지막일 때만 acquire 펜스 후 정리.
void release() {
// release: 이 스레드(및 그 이전)의 버퍼 접근을 이 감소에 "게시"한다.
if (refs_.fetch_sub(1, std::memory_order_release) == 1) {
// 마지막 참조를 관측한 스레드만, 다른 스레드들의 release 게시를 acquire 로 흡수한 뒤 정리.
std::atomic_thread_fence(std::memory_order_acquire);
destroy();
}
}
또는 펜스 없이 마지막 감소에 acq_rel 을 직접 줘도 된다(분리형 펜스가 비핵심 경로에서 약간 더 저렴):
if (refs_.fetch_sub(1, std::memory_order_acq_rel) == 1) destroy();
핵심:
- release 감소: 각 스레드의 마지막 버퍼 접근이 그 스레드의 감소 이전에 일어났음을 보장(게시).
- acquire(펜스 또는 acq_rel): 카운트를 0으로 만든 스레드가 다른 모든 스레드의 release를 관측 → 모든 이전 접근 ─happens-before─ 정리. 이제 안전하게 소멸/반납 가능.
- 증가는 relaxed 유지 가능.
더 나은 설계
- 표준 도구 사용: 가능하면
std::shared_ptr(또는 검증된intrusive_ptr)를 써서 이 미묘한 순서를 직접 구현하지 않는다. 표준 구현이 정확히 위 패턴(release/acquire)을 쓴다. - 단일 소유로 설계 회피: 핫패스 버퍼라면 공유 카운트 자체가 캐시라인 핑퐁 비용. "한 스레드가 끝까지 소유 후 명시적 반납"이면 원자 연산도, 순서 고민도 사라진다. 공유가 꼭 필요한 곳에만 refcount.
- 약한 참조/되살리기: weak→strong 승격이 있으면 add 경로도 0 검사 CAS가 필요(되살리기 방지). 강한 참조만이면 불필요.
- 트레이드오프: relaxed 증가/release 감소/acquire 펜스 조합은 정확하면서 최소 비용. 그러나 사람이 직접 쓰면 틀리기 쉬워, 표준 타입이 거의 항상 정답.
면접 포인트
- "참조 카운트의 마지막 감소는 자원 해제의 동기화 지점"이다 — 왜 release 감소 + acquire 펜스가 필요한지 happens-before로 설명할 수 있는가.
- 증가는 relaxed 가능, 감소·해제는 안 됨 — 그 비대칭의 이유.
- x86(강한 순서)에선 안 보이고 ARM/POWER에서 터지는 이유, 그리고
shared_ptr/intrusive_ptr표준 구현이 쓰는 패턴(release + acquire fence).