26. 단일 송신 큐의 Head-of-Line 블로킹 (대량 전송이 시급 패킷을 막음)
난이도 중해설 — 단일 송신 큐의 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가 푸는 지점.
해설 — 단일 송신 큐의 Head-of-Line 블로킹 (대량 전송이 시급 패킷을 막음)
난이도: 중상
요약
모든 채널이 (A)에서 하나의 FIFO 큐에 도착 순서대로 들어가고, 송신 스레드는 (B)에서 꺼낸 메시지를 끝까지 다 보낸 뒤에야 다음으로 넘어간다. 큰 Bulk 메시지가 앞에 있으면 그 전체가 빠질 때까지 뒤의 High 패킷이 기다린다 — head-of-line(HoL) 블로킹. 우선순위도, 큰 메시지를 쪼개 끼워 넣는 인터리빙도 없다.
문제점
- [Head-of-Line 블로킹 / 우선순위 부재]
- 증상: 대량 전송 중 이동/피격 패킷이 수십~수백 ms 밀린다(고무줄/순간이동).
- 재현 조건: 2MB Bulk를 넣은 직후 32바이트 High를 넣으면, FIFO상 High가 Bulk 뒤다.
(B)의write_all은 2MB가 모두 소켓에 들어갈 때까지(송신 버퍼가 차면 TCP가 비워줄 때까지) 반환하지 않으므로 High는 그만큼 대기한다. - 근본 원인: ① 한 큐 공유로 우선순위 없음, ② 한 메시지를 원자적으로 끝까지 전송해 선점/인터리빙 없음. 큰 메시지가 와이어를 독점.
- [공정성/굶주림]
- Bulk가 연달아 쌓이면 High/Normal이 굶는다. 단순 "High 우선"만 하면 반대로 Bulk가 영영 못 나간다.
수정안
채널별 우선순위 큐를 두고 Bulk를 청크로 쪼개 High/Normal과 인터리빙한다(가중 라운드로빈).
class SessionSender {
public:
static constexpr std::size_t kBulkChunk = 16 * 1024;
void enqueue_send(OutMessage m) {
std::lock_guard<std::mutex> lk(mu_);
switch (m.channel) {
case Channel::High: high_.push_back(std::move(m.payload)); break;
case Channel::Normal: normal_.push_back(std::move(m.payload)); break;
default: bulk_.push_back({std::move(m.payload), 0}); break;
}
cv_.notify_one();
}
void send_loop() {
while (running_.load()) {
std::vector<std::uint8_t> hi, no;
BulkItem bk; bool has_bulk = false;
{
std::unique_lock<std::mutex> lk(mu_);
cv_.wait(lk, [&]{ return !high_.empty() || !normal_.empty()
|| !bulk_.empty() || !running_.load(); });
if (!running_.load()) return;
// 1) 시급 먼저
if (!high_.empty()) { hi = std::move(high_.front()); high_.pop_front(); }
// 2) 일반 하나
else if (!normal_.empty()) { no = std::move(normal_.front()); normal_.pop_front(); }
// 3) Bulk 는 한 청크만
if (!bulk_.empty()) { bk = std::move(bulk_.front()); bulk_.pop_front(); has_bulk = true; }
}
if (!hi.empty()) write_all(hi.data(), hi.size());
if (!no.empty()) write_all(no.data(), no.size());
if (has_bulk) {
std::size_t len = std::min(kBulkChunk, bk.buf.size() - bk.off);
write_all(bk.buf.data() + bk.off, len);
bk.off += len;
if (bk.off < bk.buf.size()) { // 남으면 다시 큐로 → High 가 끼어들 틈
std::lock_guard<std::mutex> lk(mu_);
bulk_.push_back(std::move(bk));
cv_.notify_one();
}
}
}
}
private:
struct BulkItem { std::vector<std::uint8_t> buf; std::size_t off; };
std::mutex mu_; std::condition_variable cv_;
std::deque<std::vector<std::uint8_t>> high_, normal_;
std::deque<BulkItem> bulk_;
std::atomic<bool> running_{true};
};
핵심: ① 채널별 큐로 우선순위, ② Bulk를 청크로 쪼개 High가 끼어들 틈을 주고, ③ Bulk도 매 사이클 한 청크씩 전진해 굶지 않게. 각 청크에 채널/길이 헤더를 붙여 수신 측이 재조립한다.
더 나은 설계
- 가중 공정 큐(WFQ)/DRR: High:Normal:Bulk 가중치(예: 8:2:1)로 굶주림 없이 비율 제어. 청크 크기는 틱 예산에 맞춰 튜닝.
- 근본 한계: 한 TCP 연결은 바이트 스트림이라 TCP 자체의 HoL(손실 재전송 대기)은 못 피한다. 독립 전달이 필요하면 QUIC(스트림별 독립) 또는 채널별 별도 연결.
- 트레이드오프: 작은 청크 → 시급 지연↓·헤더/시스템콜↑. 별도 연결 → HoL 제거·연결/혼잡제어 비용↑.
면접 포인트
- HoL 블로킹과 "한 메시지 끝까지 전송"이 시급 패킷을 막는 메커니즘.
- 우선순위만으론 굶주림 → DRR/WFQ로 우선순위+공정성 동시 달성.
- 앱 다중화의 한계(TCP HoL)와 QUIC가 푸는 지점.