← 문제로

26. 단일 송신 큐의 Head-of-Line 블로킹 (대량 전송이 시급 패킷을 막음)

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

해설 — 단일 송신 큐의 Head-of-Line 블로킹 (대량 전송이 시급 패킷을 막음)

난이도: 중상

요약

모든 채널이 (A)에서 하나의 FIFO 큐에 도착 순서대로 들어가고, 송신 루프는 (B)에서 꺼낸 메시지를 끝까지 다 보낸 뒤에야 다음으로 넘어간다. 그래서 큰 Bulk 메시지가 앞에 있으면 그 전체가 나갈 때까지 뒤의 High 패킷이 줄을 서서 기다린다 — 전형적인 head-of-line(HoL) 블로킹. 우선순위도, 큰 메시지를 잘게 쪼개 끼워 넣는 인터리빙도 없다.

문제점

  • [Head-of-Line 블로킹 / 우선순위 부재]
    • 증상: 인벤토리·스냅샷 같은 대량 전송이 시작되면, 그 동안 발생한 이동/피격 패킷의 도착이 수십~수백 ms 밀린다. 클라에서 캐릭터가 순간이동/고무줄 현상.
    • 재현 조건: 2MB Bulk를 EnqueueSend 한 직후 32바이트 High 패킷을 넣으면, High는 FIFO상 Bulk 에 있다. (B)WriteAsync는 2MB가 소켓으로 다 빠질 때까지(소켓 송신 버퍼가 가득 차면 TCP가 비워줄 때까지) 완료되지 않으므로, High는 그 시간만큼 대기한다.
    • 근본 원인: ① 모든 채널이 한 큐를 공유해 우선순위가 없다. ② 한 메시지를 원자적으로 끝까지 전송해 선점/인터리빙이 없다. 큰 메시지 하나가 전송 자원(이 연결의 와이어)을 독점한다.
  • [공정성/굶주림]
    • Bulk가 연달아 쌓이면 High/Normal이 계속 밀려 사실상 굶는다. 반대로 단순히 "High 먼저"만 하면 이번엔 Bulk가 영영 못 나갈 수 있다.

수정안

채널별 우선순위 레인을 두고, Bulk는 경계 크기(chunk)로 쪼개 High/Normal과 인터리빙한다. 매 사이클에 High를 먼저 비우되 Bulk도 청크 단위로 조금씩 전진시켜 굶주림을 막는다.

public sealed class SessionSender
{
    private const int BulkChunk = 16 * 1024;     // Bulk 1회 전송 상한
    private readonly Stream _stream;
    private readonly ConcurrentQueue<OutMessage> _high = new();
    private readonly ConcurrentQueue<OutMessage> _normal = new();
    private readonly ConcurrentQueue<(byte[] buf, int off)> _bulk = new();
    private readonly SemaphoreSlim _signal = new(0);

    public void EnqueueSend(OutMessage m)
    {
        switch (m.Channel)
        {
            case Channel.High:   _high.Enqueue(m); break;
            case Channel.Normal: _normal.Enqueue(m); break;
            default:             _bulk.Enqueue((m.Payload, 0)); break;  // 진행 오프셋 보관
        }
        _signal.Release();
    }

    public async Task SendLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await _signal.WaitAsync(ct);
            // 1) 시급 먼저 모두 비운다.
            while (_high.TryDequeue(out var h))
                await _stream.WriteAsync(h.Payload, ct);
            // 2) 일반 하나.
            if (_normal.TryDequeue(out var n))
                await _stream.WriteAsync(n.Payload, ct);
            // 3) Bulk 는 한 청크만 보내고 남으면 다시 큐에 — High 가 끼어들 틈을 준다.
            if (_bulk.TryDequeue(out var b))
            {
                int len = Math.Min(BulkChunk, b.buf.Length - b.off);
                await _stream.WriteAsync(b.buf.AsMemory(b.off, len), ct);
                int next = b.off + len;
                if (next < b.buf.Length) { _bulk.Enqueue((b.buf, next)); _signal.Release(); }
            }
            await _stream.FlushAsync(ct);
        }
    }
}

핵심: ① 채널별 레인으로 우선순위를 부여하고, ② Bulk를 청크로 쪼개 매 사이클 High가 끼어들 수 있게 하며, ③ Bulk도 매 사이클 한 청크씩 전진해 굶지 않게 한다(가중 라운드로빈). 프레이밍은 각 청크에 채널/길이 헤더를 붙여 수신 측이 재조립한다.

더 나은 설계

  • 가중 공정 큐(WFQ)/DRR: High:Normal:Bulk에 가중치(예: 8:2:1)를 주어 굶주림 없이 비율 제어. 청크 크기·가중치는 틱 예산에 맞춰 튜닝.
  • 애플리케이션 레벨 다중화가 근본 한계: 한 TCP 연결은 바이트 스트림이라 TCP 자체의 HoL(손실 시 재전송 대기)은 못 피한다. 진짜 독립 스트림이 필요하면 QUIC/HTTP3(스트림별 독립 전달) 또는 채널별 별도 연결을 검토.
  • 트레이드오프: 청크가 작을수록 시급 지연↓ 이지만 헤더 오버헤드·시스템콜↑. 별도 연결은 HoL을 없애지만 연결 수·핸드셰이크·혼잡제어 분리 비용.

면접 포인트

  • HoL 블로킹의 정의와, "한 메시지를 끝까지 전송"이 왜 시급 패킷을 막는지 설명할 수 있는가.
  • 우선순위만으로는 굶주림이 생긴다 — 가중 라운드로빈/DRR로 우선순위와 공정성을 동시에.
  • 애플리케이션 다중화의 한계(TCP 자체 HoL)와 QUIC가 푸는 지점.