27. 전역 락을 쥔 채 하는 블로킹 I/O와 재진입 콜백 (락 호위 / 순회 중 변경)
난이도 상해설 — 전역 락을 쥔 채 하는 블로킹 I/O와 재진입 콜백 (락 호위 / 순회 중 변경)
난이도: 상
요약
Broadcast 는 전역 락(A)을 쥔 채 블로킹 소켓 전송(B) 을 돈다. 느린 클라이언트 하나가 송신에서 오래 막히면, 그 동안 락을 기다리는 모든 AddSession/Disconnect/다른 Broadcast 가 줄줄이 정체된다 — 락 호위(lock convoy). 즉 한 느린 클라가 서버 전체의 진행을 막는다. 게다가 전송 실패 시 Disconnect(C)를 호출하는데, C#의 lock(Monitor)은 같은 스레드에 재진입 가능이라 데드락은 안 나지만, Disconnect(D)가 _sessions 를 Remove 해 foreach 순회 중인 컬렉션을 변경한다 → 다음 반복에서 InvalidOperationException: Collection was modified. 즉 재진입이 "데드락을 피해 준" 대신 순회를 깨뜨린다.
문제점
1. (락 호위 / 가용성) 임계영역에서 블로킹 I/O — (A)(B)
- 증상: 느린/백프레셔 클라 하나의 송신이 막히는 동안 전역 락이 잡혀 있어, 접속/해제/다른 브로드캐스트가 전부 대기. 한 클라가 전체 처리량을 인질로 잡는다.
- 재현조건: 동접 많고 일부 클라 수신이 느림(버퍼 풀). broadcast 빈도 높음.
- 근본원인: 공유 자료구조 보호용 락을, 외부로 나가는 느린 호출(네트워크 전송) 동안까지 쥐고 있다. 임계영역이 "데이터 보호"를 넘어 "I/O 대기"까지 포함.
2. (순회 중 컬렉션 변경) 재진입 콜백의 부작용 — (C)(D)
- 증상: 전송 실패 →
Disconnect가foreach가 순회 중인_sessions를Remove→InvalidOperationException. 브로드캐스트가 중간에 터져 나머지 수신자에게 전송 안 됨. - 근본원인:
lock의 재진입성 때문에 데드락은 안 나지만, 순회 도중 같은 컬렉션을 구조적으로 변경하는 건 여전히 불법. 재진입이 설계 결함을 숨겨 더 미묘한 버그로 바꾼다.
3. (락 입도) 너무 굵은 임계영역
- 데이터 접근(목록 스냅샷)과 I/O(전송)가 한 임계영역에 섞여 있어, 짧아야 할 락 보유 시간이 네트워크 지연만큼 길어진다.
수정안
락 안에서는 스냅샷만 뜨고, 전송과 정리는 락 밖에서 한다. 콜백을 락 보유 중에 호출하지 않는다.
public void Broadcast(byte[] packet)
{
Session[] snapshot;
lock (_lock) { snapshot = _sessions.ToArray(); } // 락은 스냅샷만, 즉시 해제
List<Session> failed = null;
foreach (var s in snapshot) // 전송은 락 밖에서
{
try { s.SendBlocking(packet); }
catch { (failed ??= new()).Add(s); } // 실패는 모았다가
}
if (failed != null)
foreach (var s in failed) Disconnect(s); // 정리도 락 밖에서, 순회와 분리
}
- 락 보유 시간이 "리스트 복사" 수준으로 짧아져 호위가 사라진다.
- 순회 대상은 스냅샷(불변)이라 도중에
Disconnect해도 순회가 안 깨진다. - 더 나아가 블로킹 전송 자체를 없앤다: 세션별 송신 큐 + 비동기 전송으로 바꿔, 느린 클라는 그 세션 큐만 쌓이고(백프레셔는 개별 처리), 브로드캐스트 스레드는 막히지 않게 한다.
더 나은 설계
- "락을 쥔 채 외부 호출(I/O·콜백·이벤트) 금지" 를 규칙으로. 락은 공유 메모리 보호에만, 가능한 짧게.
- 전송은 per-session 송신 큐 + 논블로킹/비동기 I/O. 느린 소비자는 그 큐만 부풀고(상한 초과 시 그 세션만 드롭/끊기), 전체는 영향 없음 — 백프레셔 격리.
- 세션 목록은 불변 스냅샷(copy-on-write) 또는 동시성 컬렉션으로 두어 읽기(브로드캐스트)와 쓰기(접속/해제)를 분리.
면접 포인트
- 락을 쥔 채 블로킹 I/O 를 하면 락 호위 — 가장 느린 참여자가 전체를 직렬화한다. 락 안에서는 스냅샷만, I/O 는 밖에서.
- C#의
lock(Monitor)은 재진입 가능이라 자기 교착은 피하지만, 그 대가로 순회 중 컬렉션 변경 같은 버그를 숨긴다 — 재진입이 안전을 보장하지 않는다. - 브로드캐스트/팬아웃은 per-session 비동기 송신 큐로 백프레셔를 개별 격리해, 한 느린 클라가 서버 전체를 인질로 잡지 못하게 한다.
해설 — 전역 뮤텍스를 쥔 채 하는 블로킹 I/O와 재잠금 (락 호위 / 자기 교착)
난이도: 상
요약
broadcast 는 전역 뮤텍스(A)를 쥔 채 블로킹 소켓 전송(B) 을 돈다. 느린 클라이언트 하나가 송신에서 오래 막히면, 그 동안 뮤텍스를 기다리는 모든 addSession/disconnect/다른 broadcast 가 정체된다 — 락 호위(lock convoy). 한 느린 클라가 서버 전체 진행을 막는다. 더 치명적으로, 전송 실패 시 disconnect(C)를 호출하는데 그 안에서 같은 std::mutex 를 다시 잠그려 한다(D). std::mutex 는 재귀(재진입) 불가라, 이미 자기가 쥔 뮤텍스를 다시 lock 하면 자기 교착(self-deadlock) — 그 스레드(및 락 대기 중인 모든 스레드)가 영구 정지한다(표준상 미정의 동작).
문제점
1. (자기 교착) 비재귀 뮤텍스 재잠금 — (C)(D)
- 증상:
sendBlocking예외 →disconnect→std::lock_guard가 이미 보유 중인mtx_를 재잠금 시도 → 데드락. 이후 그 뮤텍스를 기다리는 모든 스레드가 멈춘다. - 재현조건: 브로드캐스트 중 한 세션 전송이 예외(끊긴 연결). 단 한 번이면 충분.
- 근본원인: 락을 쥔 함수가 같은 락을 다시 잡는 다른 함수를 호출한다(락 보유 중 콜백/하위 호출).
std::mutex는 비재귀라 즉시 교착.
2. (락 호위 / 가용성) 임계영역에서 블로킹 I/O — (A)(B)
- 증상: 느린 클라의 송신이 막히는 동안 전역 뮤텍스가 잡혀 접속/해제/다른 브로드캐스트가 전부 대기. 한 클라가 처리량을 인질로.
- 근본원인: 공유 자료구조 보호용 뮤텍스를 네트워크 전송 대기 동안까지 보유. 임계영역에 I/O 가 섞임.
3. (반복자/구조 변경 위험) 락이 재귀였다면
- 만약
std::recursive_mutex로 바꿔 교착을 피했다 해도,disconnect의erase가broadcast의 범위 기반 for 가 순회 중인sessions_를 변경 → 반복자 무효화/UB. 즉 재귀 뮤텍스는 교착만 바꿔 또 다른 버그를 만든다.
수정안
락 안에서는 스냅샷만, 전송·정리는 락 밖에서. 락 보유 중 하위 호출(disconnect)을 부르지 않는다.
void broadcast(const std::vector<uint8_t>& packet) {
std::vector<std::shared_ptr<Session>> snapshot;
{
std::lock_guard<std::mutex> lk(mtx_);
snapshot = sessions_; // 락은 스냅샷 복사만, 즉시 해제
}
std::vector<std::shared_ptr<Session>> failed;
for (auto& s : snapshot) { // 전송은 락 밖에서
try { s->sendBlocking(packet); }
catch (...) { failed.push_back(s); }
}
for (auto& s : failed) disconnect(s); // 정리도 락 밖에서 (재잠금 아님)
}
- 자기 교착 제거:
disconnect가 호출될 때broadcast는 이미 락을 놓은 상태. - 호위 제거: 락 보유 시간이 "벡터 복사" 수준.
- 순회는 스냅샷(지역 복사) 대상이라 도중
disconnect해도 무효화 없음. - 근본적으로는 블로킹 전송을 없애고 per-session 송신 큐 + 논블로킹 I/O 로 전환.
더 나은 설계
- "락을 쥔 채 외부로 나가는 호출(I/O·콜백·다른 락 함수) 금지" 를 규칙화. 락은 메모리 보호에만, 짧게.
- 전송은 per-session 송신 큐 + 비동기/논블로킹 I/O 로. 느린 소비자는 그 큐만 부풀고(상한 초과 시 그 세션만 끊기), 전체는 무영향 — 백프레셔 격리.
- 세션 목록은 copy-on-write 스냅샷이나 적절한 동시성 컨테이너로 읽기/쓰기 분리.
recursive_mutex로 "교착을 가리는" 임시방편은 지양(설계 결함을 숨김).
면접 포인트
std::mutex는 비재귀 — 락을 쥔 함수가 같은 락을 다시 잡는 경로(콜백/하위 호출)를 부르면 자기 교착이다. "락 보유 중 외부 호출 금지"가 원칙.- 락을 쥔 채 블로킹 I/O = 락 호위. 락 안에서는 스냅샷만 뜨고 I/O·정리는 밖에서.
recursive_mutex로 교착을 피해도 순회 중 구조 변경(반복자 무효화) 같은 2차 버그가 남는다 — 재귀 락은 해법이 아니라 설계 결함의 은폐다.