← 문제로

27. 전역 락을 쥔 채 하는 블로킹 I/O와 재진입 콜백 (락 호위 / 순회 중 변경)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 전역 락을 쥔 채 하는 블로킹 I/O와 재진입 콜백 (락 호위 / 순회 중 변경)

난이도: 상

요약

Broadcast 는 전역 락(A)을 쥔 채 블로킹 소켓 전송(B) 을 돈다. 느린 클라이언트 하나가 송신에서 오래 막히면, 그 동안 락을 기다리는 모든 AddSession/Disconnect/다른 Broadcast 가 줄줄이 정체된다 — 락 호위(lock convoy). 즉 한 느린 클라가 서버 전체의 진행을 막는다. 게다가 전송 실패 시 Disconnect(C)를 호출하는데, C#의 lock(Monitor)은 같은 스레드에 재진입 가능이라 데드락은 안 나지만, Disconnect(D)가 _sessionsRemoveforeach 순회 중인 컬렉션을 변경한다 → 다음 반복에서 InvalidOperationException: Collection was modified. 즉 재진입이 "데드락을 피해 준" 대신 순회를 깨뜨린다.

문제점

1. (락 호위 / 가용성) 임계영역에서 블로킹 I/O — (A)(B)

  • 증상: 느린/백프레셔 클라 하나의 송신이 막히는 동안 전역 락이 잡혀 있어, 접속/해제/다른 브로드캐스트가 전부 대기. 한 클라가 전체 처리량을 인질로 잡는다.
  • 재현조건: 동접 많고 일부 클라 수신이 느림(버퍼 풀). broadcast 빈도 높음.
  • 근본원인: 공유 자료구조 보호용 락을, 외부로 나가는 느린 호출(네트워크 전송) 동안까지 쥐고 있다. 임계영역이 "데이터 보호"를 넘어 "I/O 대기"까지 포함.

2. (순회 중 컬렉션 변경) 재진입 콜백의 부작용 — (C)(D)

  • 증상: 전송 실패 → Disconnectforeach 가 순회 중인 _sessionsRemoveInvalidOperationException. 브로드캐스트가 중간에 터져 나머지 수신자에게 전송 안 됨.
  • 근본원인: 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) 또는 동시성 컬렉션으로 두어 읽기(브로드캐스트)와 쓰기(접속/해제)를 분리.

면접 포인트

  1. 락을 쥔 채 블로킹 I/O 를 하면 락 호위 — 가장 느린 참여자가 전체를 직렬화한다. 락 안에서는 스냅샷만, I/O 는 밖에서.
  2. C#의 lock(Monitor)은 재진입 가능이라 자기 교착은 피하지만, 그 대가로 순회 중 컬렉션 변경 같은 버그를 숨긴다 — 재진입이 안전을 보장하지 않는다.
  3. 브로드캐스트/팬아웃은 per-session 비동기 송신 큐로 백프레셔를 개별 격리해, 한 느린 클라가 서버 전체를 인질로 잡지 못하게 한다.