← 문제로

46. 세션 송신 펌프의 동시 진입 (같은 소켓 중첩 SendAsync·프레임 오염, C#)

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

해설 — 세션 송신 펌프의 동시 진입 (같은 소켓 중첩 SendAsync·프레임 오염, C#)

난이도: 상

요약

_sending 플래그의 검사(A)와 설정(B)이 원자적이지 않다. 두 스레드가 동시에 Enqueue → TrySend에 진입하면 둘 다 _sending==false를 관측하고 각자 BeginSend를 호출해 같은 소켓에 두 개의 SendAsync가 동시에 진행된다. TCP는 바이트 스트림이므로 두 비동기 쓰기의 바이트가 인터리빙되어 수신 측 프레임 경계가 깨진다(길이 프리픽스가 어긋나 이후 전체 파싱이 붕괴). Enqueue와 완료 콜백 OnSendCompleted가 겹쳐도 동일하다.

문제점

  • 분류태그: check-then-act 데이터 레이스 / 비직렬화 소켓 쓰기(overlapping writes)
  • 증상: 부하가 높을 때 간헐적으로 클라이언트가 "깨진 패킷"을 받고 연결이 끊긴다. 재현이 어렵고 로그엔 정상으로 남는다.
  • 재현조건: 서로 다른 스레드의 동시 Enqueue, 또는 Enqueue와 OnSendCompleted의 동시 실행 — 둘 다 _sending==false 창을 관측.
  • 근본원인: _sending을 락 없이 읽고 쓴다(플래그 자체도 비휘발성이라 가시성 문제까지). 송신 시작 결정이 원자적이지 않아 "동시에 하나만"이라는 불변식이 깨진다.

수정안

송신 시작/완료 처리를 세션 단위 락으로 직렬화한다. 완료 콜백은 락 밖에서 다음 시작만 트리거하도록 구조화한다.

private readonly object _sendGate = new object();

public void Enqueue(byte[] packet)
{
    _sendQueue.Enqueue(packet);
    lock (_sendGate)
    {
        if (_sending) return;
        _sending = true;
    }
    BeginDrain();
}

private void OnSendCompleted(object? s, SocketAsyncEventArgs a)
{
    lock (_sendGate)
    {
        if (_sendQueue.IsEmpty) { _sending = false; return; }
    }
    BeginDrain(); // 아직 보낼 게 있으면 _sending=true 유지한 채 계속
}

핵심은 _sending 검사~설정을 원자화해 동시에 오직 하나의 송신만 시작되도록 보장하는 것이다. 여러 패킷을 한 번의 시스템콜로 묶는 gather write(SendAsync에 버퍼 리스트)도 함께 쓰면 효율이 오른다.

더 나은 설계

  • **세션당 단일 실행 컨텍스트(직렬 큐/스트랜드)**로 모든 송신 관련 콜백을 처리하면 락 없이도 "동시에 하나"가 구조적으로 보장된다(예: System.Threading.Channels로 세션당 송신 루프 하나). 트레이드오프: 세션마다 루프/채널 관리.
  • 송신 큐에 백프레셔(상한)를 두어 폭주 시 무한 적재를 막는다.

면접 포인트

  • (연계) problem41은 비동기 송신의 동기 완료 경로(SendAsync가 즉시 완료 반환 시 재귀·스택)를 다룬다. 본 문제는 그와 구별되는 송신 시작 플래그의 비원자성으로 인한 중첩 송신 자체에 초점을 둔다.
  • TCP에서 같은 소켓에 겹치는 쓰기가 왜 프레임을 깨는가(스트림 인터리빙, 길이 프리픽스 붕괴).
  • "boolean 하나 CAS로 바꾸면 되지 않나?" — Interlocked.CompareExchange로 0↔1 전이를 원자화하는 접근도 가능하지만, 완료 콜백과 enqueue의 상호작용(큐 재확인)을 함께 설계해야 유실/중복 시작이 없다.