37. 인접 테이블의 락 없는 순회와 손상된 열거자 (읽기는 안전하다는 착각)
난이도 중해설 — 인접 테이블의 락 없는 순회와 손상된 열거자 (읽기는 안전하다는 착각)
난이도: 중
요약
Enter/Leave(A)는 _neighbors(Dictionary)를 락으로 보호하지만, BroadcastSnapshot(B)은 "값을 바꾸지 않으니 안전하다"는 가정으로 락 없이 foreach로 순회한다. Dictionary<K,V>의 열거는 내부 버킷 배열을 직접 훑는 구현이라, 열거 도중 다른 스레드가 삽입(특히 내부 재해시가 트리거되는 삽입)하면 순회 중인 배열의 모양이 통째로 바뀐다. 최선의 경우 InvalidOperationException: Collection was modified로 죽고, 최악의 경우 예외 없이 항목을 건너뛰거나 중복 방문한다.
문제점
1. (동기화 없는 공유 컬렉션 순회) 락 없는 foreach — (B)
- 증상: 브로드캐스트 도중 간헐적으로
InvalidOperationException: Collection was modified; enumeration operation may not execute가 발생하며 해당 틱의 브로드캐스트가 통째로 실패한다. 예외가 상위에서 잡히지 않으면 서버 스레드가 죽는다. - 재현조건: 동접이 많아
Enter/Leave빈도가 높고, 마침BroadcastSnapshot이 순회 중인 순간에 다른 스레드가 삽입/제거를 실행. 특히Dictionary가 용량을 넘겨 **내부 배열을 재할당(rehash급 재구성)**하는 삽입일 때 확률이 크게 뛴다 — 그래서 "동접이 늘면서" 재현 빈도가 늘어난 것처럼 보인다. - 근본원인:
Dictionary<K,V>는 스레드 안전을 보장하지 않으며, 열거자는 내부적으로 버전 카운터(_version)를 캡처해MoveNext마다 비교한다. 다른 스레드의 구조적 변경(삽입/삭제)이 버전을 바꾸면 열거자가 이를 감지해 예외를 던진다 — "읽기 전용 순회니 락이 필요 없다"는 가정이 틀렸다: 순회는 읽기 연산이지만, 컬렉션이 변경되지 않는다는 보장이 있을 때만 락이 필요 없다. 버전 검사가 항상 예외를 던져준다면 그나마 다행이고, 타이밍에 따라 검사를 통과한 채 내부적으로 이미 이동된 버킷을 잘못 읽어(같은 항목 중복 방문/일부 항목 누락) 조용히 잘못된 스냅샷을 보낼 수도 있다 — 예외 없이도 데이터가 틀릴 수 있다는 뜻이라 더 위험하다.
수정안
읽기(순회)도 쓰기와 같은 락으로 보호한다. 락 보유 시간을 최소화하려면 락 안에서는 스냅샷 배열만 뜨고, 실제 전송(네트워크 I/O)은 락 밖에서 수행한다.
public void BroadcastSnapshot()
{
PlayerHandle[] snapshot;
lock (_lock)
{
snapshot = new PlayerHandle[_neighbors.Count];
_neighbors.Values.CopyTo(snapshot, 0); // 락 보유 중에만 복사
}
foreach (var p in snapshot) // 전송은 락 밖에서
{
SendSnapshotPacket(p);
}
}
_neighbors.Values.CopyTo(...)나.ToArray()는 락 안에서 실행해야 의미가 있다 — 락 밖에서ToArray()를 호출하면 그 자체가 다시 보호되지 않은 순회다.- 락 보유 시간이 걱정되면
ConcurrentDictionary<long, PlayerHandle>로 바꿔Enter/Leave/순회를 모두 락 없이 처리하는 방법도 있다. 단,ConcurrentDictionary의 열거는 특정 시점의 원자적 스냅샷을 보장하지 않는다(순회 중 일어난 변경이 일부 반영될 수 있음)는 점은 감안해야 한다 — 브로드캐스트처럼 "약간의 stale은 허용, 예외/충돌만 없으면 된다"는 요구에는 적합하다.
더 나은 설계
- 인접 테이블처럼 "쓰기는 빈번, 읽기(순회)는 주기적"인 구조는
ReaderWriterLockSlim으로 세분화하면 동시 읽기끼리는 블로킹 없이 처리 가능하다. 단, 읽기 락 보유 중 절대 컬렉션을 변경하지 않도록(승급 금지) 주의. - 브로드캐스트 주기가 짧고 인접 수가 많다면, 매 틱 스냅샷 배열을 새로 할당하는 대신 이중 버퍼(더블 버퍼링)로 GC 압박을 줄이는 것도 고려할 수 있다.
- "락 없이 읽기만 하면 안전하다"는 직관은 불변(immutable) 데이터 구조나 원자적 교체(예:
Interlocked.Exchange로 스냅샷 참조 자체를 통째로 교체)에만 성립한다는 점을 팀 규칙으로 명문화해두면 재발을 막을 수 있다.
면접 포인트
- "읽기 전용 순회니까 락이 필요 없다"는 가정은 컬렉션이 그동안 변경되지 않는다는 보장이 있을 때만 성립한다 —
Dictionary의 열거자는 구조적 변경(삽입/삭제)에 취약하며 버전 검사로 예외를 던지거나(운 좋으면), 조용히 잘못된 데이터를 넘길 수 있다(운 나쁘면). - 예외 발생 여부가 재현 빈도에 좌우된다는 사실 자체가 데이터 레이스의 특징이다 — "가끔 터진다"는 보고는 락 누락을 의심할 신호다.
- 대안(락 전체 보호 vs
ConcurrentDictionaryvsReaderWriterLockSlim)마다 트레이드오프가 다르다: 정확한 시점 스냅샷이 필요한지, 락 경합 비용을 얼마나 감당할 수 있는지로 선택 기준이 갈린다.
해설 — 인접 테이블의 락 없는 순회와 반복자 무효화 (읽기는 안전하다는 착각)
난이도: 중상
요약
enter/leave(A)는 neighbors_(std::unordered_map)를 mtx_로 보호하지만, broadcastSnapshot(B)은 "값을 바꾸지 않으니 안전하다"는 가정으로 락 없이 range-for로 순회한다. std::unordered_map은 스레드 안전을 보장하지 않는 컨테이너이며, 표준이 보장하는 것은 "서로 다른 원소에 대한 동시 읽기"뿐이다. 한 스레드가 순회(사실상 반복자를 계속 역참조/증가)하는 동안 다른 스레드가 삽입해 내부 버킷 배열을 재해시(rehash)하면, 순회 중이던 반복자는 이미 해제된 옛 버킷 배열을 가리키는 댕글링 반복자가 된다. 이는 정의되지 않은 동작(UB)이며, 흔히 세그폴트로 나타나지만 항상 그런 것은 아니다.
문제점
1. (동기화 없는 공유 컨테이너 순회, UB) 락 없는 range-for — (B)
- 증상: 브로드캐스트 스레드가 간헐적으로 SIGSEGV로 죽거나, 크래시 없이 이미 재사용된 메모리를
PlayerHandle인 것처럼 읽어 이상한 좌표/ID로 패킷을 잘못된 대상에게 보낸다. - 재현조건: 동접이 많아
enter빈도가 높고,unordered_map의 load factor가 임계값을 넘어서는 삽입이 발생하는 순간(버킷 배열 재할당)과broadcastSnapshot의 순회 구간이 겹칠 때. 삽입 대부분은 재해시를 유발하지 않으므로(기존 버킷에 체이닝) "대개는 멀쩡하다가 가끔 크래시"하는 패턴으로 나타난다. - 근본원인:
std::unordered_map::insert는 재해시가 일어나면 모든 반복자를 무효화한다. 즉 range-for가 내부적으로 유지하는 반복자가 재해시 도중 해제된 옛 버킷 메모리를 계속 가리키게 된다. 락 없이 공유 컨테이너를 변경/순회하는 것 자체가 데이터 레이스이며, C++ 메모리 모델상 이미 UB이므로 "재해시가 없었으니 우연히 괜찮았다"도 표준적으로는 보장되지 않는 동작이다. - C#의
Dictionary는 최소한 버전 카운터로 변경을 감지해InvalidOperationException을 던져주지만(안전망), C++의unordered_map은 그런 안전망이 전혀 없다. 감지 없이 곧바로 UB로 직행하므로 더 위험하다.
수정안
읽기(순회)도 쓰기와 같은 mutex로 보호한다. 락 보유 시간을 최소화하려면 락 안에서는 값만 복사한 스냅샷 벡터를 만들고, 실제 전송(네트워크 I/O)은 락 밖에서 수행한다.
void broadcastSnapshot() {
std::vector<PlayerHandle> snapshot;
{
std::lock_guard<std::mutex> lk(mtx_);
snapshot.reserve(neighbors_.size());
for (auto& kv : neighbors_) {
snapshot.push_back(kv.second); // 락 보유 중에만 복사
}
}
for (auto& p : snapshot) { // 전송은 락 밖에서
sendSnapshotPacket(p);
}
}
- 복사 비용이 부담되면
neighbors_.reserve(...)로 예상 동접 수만큼 미리 버킷을 확보해 재해시 빈도 자체를 낮추는 것도 완화책이 될 수 있지만, 근본 수정은 아니다(락 없는 동시 접근은 재해시가 없어도 여전히 데이터 레이스다). - 락 경합이 문제라면
std::shared_mutex로 바꿔enter/leave는unique_lock,broadcastSnapshot의 복사 구간은shared_lock으로 하면 여러 브로드캐스트(또는 조회)가 동시에 읽을 수 있다.
더 나은 설계
- 쓰기 빈번, 읽기(순회) 주기적인 패턴에는
std::shared_mutex기반 읽기/쓰기 분리가 적합하다. 단 읽기 락 보유 중 절대 컨테이너를 변경하지 않는다(락 승급 금지). - 매 틱 스냅샷 벡터를 새로 할당하는 비용이 부담되면, 스레드 로컬 버퍼를 재사용하거나 이중 버퍼링으로 할당/해제 빈도를 줄인다.
- "락 없이 읽기만 하면 안전하다"는 가정은 데이터가 불변이거나(예: 스냅샷을 통째로
shared_ptr로 원자적 교체) 락프리 자료구조로 명시적으로 설계된 경우에만 성립한다는 것을 팀 규칙으로 명문화한다.
면접 포인트
std::unordered_map은 재해시 시 반복자를 무효화한다. 락 없이 순회 중인 스레드가 있으면 다른 스레드의 삽입이 댕글링 반복자를 만들어 UB(흔히 세그폴트, 때로는 조용한 메모리 오염)로 이어진다.- C++ 표준 컨테이너는 C#의
Dictionary처럼 변경을 감지해 예외를 던져주는 안전망이 없다. "읽기 전용이니 락 불필요"라는 가정이 틀렸을 때 훨씬 더 조용하고 위험하게 실패한다. - 재해시가 드물게만 일어나기 때문에 "가끔만 터진다"는 보고 패턴 자체가 데이터 레이스의 전형적인 특징이며, 재현이 어렵다는 이유로 무시하면 안 된다.