← 문제로

41. 비동기 송신의 동기 완료 경로 처리 (C#)

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

해설 — 비동기 송신의 동기 완료 경로 처리 (C#)

난이도: 상

요약

Socket.SendAsync(SocketAsyncEventArgs) 는 false 를 반환하면 "이미 동기적으로 끝났고 Completed 이벤트는 올라오지 않는다" 는 뜻이다. 코드는 이 경로를 인지하고 있지만, 완료 처리를 호출 스택 위에서 그대로 이어붙였다. 그래서 상대가 계속 받아주는 상황 (로컬 루프백, 커널 송신 버퍼 여유가 큰 상태)에서는 PumpSend → OnSendCompleted → PumpSend → … 가 큐가 빌 때까지 한 스택 안에서 반복되어 스택이 무너지고 프로세스가 즉사한다. 동시에 그 완료 스레드는 한 세션에 눌러앉는다. 여기에 _sending 이 락 밖에서 읽고 쓰이는 평범한 bool 이라, 송신 펌프가 두 개 동시에 돌거나 (공유 SocketAsyncEventArgs 동시 사용) 아예 영영 멈추는(lost wakeup) 두 방향의 고장이 함께 난다.

문제점

(A) 동기 완료를 스택 위에서 이어붙인다 — 무한 재귀와 스레드 점유

  • 분류: 무한 재귀(스택 소진) · 스레드 기아 · 부분 송신 미처리
  • 증상:
    • StackOverflowException 으로 프로세스가 즉시 종료된다. .NET Core 이후 SOE 는 try/catch 로 잡을 수 없고 AppDomain.UnhandledException 도 타지 않는다. 즉 정상 종료 훅도, 세션 저장도 없이 서버가 통째로 사라진다. 덤프 없이는 원인 추적이 어렵다.
    • 필드 보스 처치처럼 BroadcastAll 이 세션당 수천 개를 밀어 넣은 직후, 그리고 QA 로컬 루프백 환경에서 가장 잘 터진다. 실서비스 원격 클라는 커널 버퍼가 금방 차서 Pending 이 섞이므로 오히려 덜 재현되고, 이 때문에 "부하 테스트에서만 죽는다"는 형태로 보고된다.
    • 죽지 않을 만큼만 쌓인 경우에도, 완료를 처리하던 IOCP/스레드풀 워커가 큐가 빌 때까지 그 세션에서 못 나온다. 다른 세션들의 완료 통지가 밀리고, 스레드풀은 워커를 늘리려 하지만 최소 스레드 수를 넘긴 뒤에는 초당 1~2개꼴로만 주입하므로 회복이 느리다. 존 틱 지연 → 큐 적체 → 더 깊은 재귀로 이어지는 악순환이 된다.
  • 재현 조건:
    1. 클라와 서버가 같은 머신(루프백)이거나 수신자가 계속 여유가 있는 상태,
    2. 한 세션의 송신 큐에 수천 개 이상이 한꺼번에 적재. PumpSend + OnSendCompleted 두 프레임이 한 번에 대략 수백 바이트를 먹으므로, 기본 1MB 스택이라면 수천 회 반복만으로 소진된다. 브로드캐스트 한 번이면 충분한 양이다.
  • 근본 원인:
    1. "완료 처리"와 "다음 송신 시작"이 서로를 호출하는 상호 재귀다. 종료 조건은 "큐가 빔" 하나뿐인데, 큐는 다른 스레드가 계속 채운다. 사실상 종료 조건이 없다.
    2. 동기 완료는 본질적으로 반복(loop) 인데 호출(call) 로 표현했다. 반복으로 풀면 스택이 자라지 않는다.
    3. 재진입 깊이 상한도, 한 스레드가 한 세션에서 처리할 항목 수 상한도 없다.
  • 같은 지점의 추가 결함 — 부분 송신(partial send) 미처리. TCP send 는 요청한 바이트보다 적게 보낼 수 있다. 코드는 e.BytesTransferred 를 통계에만 더하고 seg.Count 와 비교하지 않으므로, 1400바이트 중 900바이트만 나갔어도 패킷 하나를 다 보낸 것으로 간주하고 다음 항목으로 넘어간다. 수신 측 프레임은 그 순간부터 어긋난다. 커널 버퍼가 거의 찬 상태 (= 느린 클라, 대량 브로드캐스트)에서 정확히 잘 발생한다.

(B) _sending 이 락 밖의 평범한 bool — 이중 펌프와 lost wakeup

  • 분류: 경쟁 조건(check-then-act) · 원자성/가시성 위반 · 공유 자원 동시 사용
  • 증상 (두 방향으로 갈린다):
    • 이중 펌프. 두 로직 스레드가 동시에 Send 를 호출하면 둘 다 _sending 을 false 로 읽고 둘 다 true 를 쓴 뒤 둘 다 PumpSend 를 호출한다. 두 펌프가 같은 _sendArgs 를 쓰므로 SocketAsyncEventArgs 에 대해 InvalidOperationException: An asynchronous socket operation is already in progress using this SocketAsyncEventArgs instance 가 게임 로직 스레드로 튀어나온다. 존 틱이 죽거나, 잡아먹혀도 그 패킷은 유실된다.
    • 예외를 피해간 타이밍에서는 더 나쁘다. 한쪽이 SetBuffer 로 in-flight 버퍼를 갈아치워 실제로 나가는 바이트열이 두 패킷의 조각이 섞인 것이 된다. 프레임 경계가 깨지고, UserToken 도 덮여 엉뚱한 버퍼가 풀로 반납된다. 같은 배열이 두 번 반납되면 풀 안에 중복 포인터가 생기고, 서로 다른 두 세션이 같은 배열을 동시에 쓰게 된다 — A의 채팅이 B에게 나가는 형태의 데이터 유출로 이어진다.
    • lost wakeup(영구 정지). 더 흔한 쪽이다. 펌프 스레드가 락 안에서 큐가 비어 _sending = false 를 쓰기 직전, 다른 스레드가 Enqueue 를 마치고 락을 놓은 뒤 _sending 을 true 로 읽고 return 한다. 결과: 큐에 항목이 남았는데 아무도 펌프를 돌리지 않는다. 해당 세션은 조용히 송신이 멎고, 클라는 "서버 무응답"으로 튕긴다. 로그에는 아무 것도 남지 않는다.
  • 재현 조건: 존 틱 스레드와 채팅 스레드가 같은 세션에 동시에 Send 하는 평범한 상황. 창이 좁아 개발 환경에서는 며칠에 한 번, 실서버에서는 시간당 수십 건 발생한다.
  • 근본 원인:
    1. Send 의 if (_sending) return; _sending = true; 는 전형적인 check-then-act 인데 락 밖에 있다. Interlocked 도 아니다.
    2. _sending 이 volatile 조차 아니다. JIT 가 레지스터에 캐시할 수 있어 다른 스레드의 변경을 영영 못 볼 수도 있다.
    3. 큐 상태와 펌프 상태가 서로 다른 임계 구역에서 갱신된다. 두 상태는 하나의 불변식 ("큐가 비지 않았다면 펌프가 돌고 있다")을 이루므로 같은 락 아래에서 함께 바뀌어야 한다.
    4. SocketAsyncEventArgs 는 한 번에 하나의 작업만 지원한다. 세션당 하나 재사용은 정석이지만 "동시에 하나만"을 코드가 보장해야 하는데 그 보장이 (1)~(3) 때문에 무너져 있다.
    5. 곁가지: TotalBytesSent += 는 원자적이지 않고, _disconnected 도 비휘발성 bool 이며, Disconnect() 는 in-flight 버퍼와 큐에 남은 버퍼를 반납하지 않아 풀이 계속 새어나간다.

수정안

핵심은 ① 큐와 펌프 플래그를 같은 락 아래에서 함께 갱신, ② 동기 완료를 루프로 풀어 스택을 키우지 않기, ③ 부분 송신을 큐 앞으로 되돌려 재전송, ④ 버퍼 수명을 완료 시점에 묶기.

public sealed class GameSession
{
    private readonly Socket _socket;
    private readonly BufferPool _pool;
    private readonly SocketAsyncEventArgs _sendArgs = new();
    // 앞쪽 되돌리기(부분 송신)가 필요하므로 LinkedList 를 쓴다
    private readonly LinkedList<ArraySegment<byte>> _sendQueue = new();
    private readonly object _queueLock = new object();

    private bool _sending;                 // 반드시 _queueLock 안에서만 접근
    private volatile bool _disconnected;
    private long _totalBytesSent;

    public GameSession(Socket socket, BufferPool pool)
    {
        _socket = socket;
        _pool = pool;
        _sendArgs.Completed += OnSendCompleted;
    }

    public void Send(byte[] packet, int length)
    {
        if (_disconnected) return;

        byte[] buf = _pool.Rent(length);
        Buffer.BlockCopy(packet, 0, buf, 0, length);

        bool startPump;
        lock (_queueLock)
        {
            _sendQueue.AddLast(new ArraySegment<byte>(buf, 0, length));
            // 큐 적재와 펌프 기동 결정을 같은 임계 구역에서 — lost wakeup / 이중 펌프 동시 차단
            startPump = !_sending;
            if (startPump) _sending = true;
        }

        if (startPump) PumpSendLoop();
    }
    // 재귀가 아니라 루프다. 스택 깊이가 큐 길이에 비례하지 않는다.
    private void PumpSendLoop()
    {
        while (true)
        {
            ArraySegment<byte> seg;
            lock (_queueLock)
            {
                if (_sendQueue.Count == 0)
                {
                    _sending = false;      // 큐 검사와 플래그 해제가 원자적
                    return;
                }
                seg = _sendQueue.First.Value;
                _sendQueue.RemoveFirst();
            }

            _sendArgs.SetBuffer(seg.Array, seg.Offset, seg.Count);
            _sendArgs.UserToken = seg;

            // true = 보류. 완료 콜백이 루프를 이어받으므로 여기서 스택을 놓는다.
            if (_socket.SendAsync(_sendArgs)) return;

            // false = 동기 완료. 콜백은 오지 않는다 → 루프로 계속 (스택 증가 없음)
            if (!CompleteOne(_sendArgs)) return;
        }
    }

    private void OnSendCompleted(object sender, SocketAsyncEventArgs e)
    {
        if (!CompleteOne(e)) return;
        PumpSendLoop();                    // 콜백 스레드가 루프를 이어받는다
    }

    // 완료 1건 처리. 계속 펌프해도 되면 true.
    private bool CompleteOne(SocketAsyncEventArgs e)
    {
        var seg = (ArraySegment<byte>)e.UserToken;

        if (e.SocketError != SocketError.Success)
        {
            _pool.Return(seg.Array);
            Disconnect();
            return false;
        }

        int sent = e.BytesTransferred;
        Interlocked.Add(ref _totalBytesSent, sent);

        if (sent < seg.Count)
        {
            // 부분 송신: 남은 구간을 큐 맨 앞으로 되돌린다. 버퍼는 아직 반납하지 않는다.
            var rest = new ArraySegment<byte>(seg.Array, seg.Offset + sent, seg.Count - sent);
            lock (_queueLock) _sendQueue.AddFirst(rest);
            return true;
        }

        _pool.Return(seg.Array);           // 여기서만 반납 — in-flight 중 반납이 없다
        return true;
    }

    private void Disconnect()
    {
        if (_disconnected) return;
        _disconnected = true;
        lock (_queueLock)
        {
            foreach (var s in _sendQueue) _pool.Return(s.Array);
            _sendQueue.Clear();
            _sending = false;
        }
        try { _socket.Shutdown(SocketShutdown.Both); } catch { }
        _socket.Close();
    }
}

한 스레드가 한 세션에 너무 오래 눌러앉는 것까지 막으려면, 루프에 예산을 두고 초과 시 ThreadPool.UnsafeQueueUserWorkItem 으로 나머지를 넘겨 공평성을 확보한다.

int budget = 32;
while (true)
{
    // ... 위 루프 본문 ...
    if (--budget == 0)
    {
        ThreadPool.UnsafeQueueUserWorkItem(static s => ((GameSession)s).PumpSendLoop(), this);
        return;
    }
}

더 나은 설계

(1) System.Threading.Channels + 세션당 단일 송신 루프(await 기반). Socket.SendAsync(ReadOnlyMemory<byte>, SocketFlags) 의 ValueTask 오버로드를 await 하는 while 루프 하나를 세션마다 돌리면, "동기 완료"는 ValueTask 가 이미 완료된 상태로 돌아오는 것으로 자연히 흡수되고 재귀도 플래그도 존재하지 않는다. 송신자는 채널에 쓰기만 한다.

  • 트레이드오프: 세션당 상태 머신·Task 비용이 생기고, ValueTask 가 동기 완료로 계속 돌아오면 await 가 스택을 안 늘리는 대신 같은 스레드를 계속 점유하므로 예산/Yield 는 여전히 필요하다. 대신 코드가 절반으로 줄고 이 문서의 (A)(B)가 구조적으로 불가능해진다. 신규 설계라면 이쪽.

(2) 배치 전송(gather-write): 큐에 쌓인 여러 패킷을 한 번의 send 로 묶는다. SocketAsyncEventArgs.BufferList 또는 미리 큰 버퍼에 이어붙여 보낸다. 브로드캐스트 폭주 때 send 호출 수가 수십분의 1로 줄어 (A)의 재귀 깊이/스레드 점유 문제 자체가 완화된다.

  • 트레이드오프: 첫 패킷의 지연이 살짝 늘고(묶는 동안 대기), 버퍼 관리가 복잡해진다. MMORPG 브로드캐스트처럼 작은 패킷이 폭발적으로 나오는 패턴에서는 이득이 압도적이라 사실상 필수다.

(3) 큐 상한과 백프레셔 정책. 느린 클라 하나가 무한히 쌓이는 큐로 서버 메모리를 먹지 못하도록 세션 큐에 상한(바이트/개수)을 두고, 초과 시 세션을 끊거나 우선순위 낮은 패킷(주변 이동 동기화 등)을 버린다.

  • 트레이드오프: "무엇을 버릴 수 있는가"를 패킷마다 분류해야 하고 정책 실수는 게임 로직 오류로 이어진다. 하지만 상한이 없으면 결국 OOM 이거나 이 문서의 (A)로 죽는다. 둘 중 하나는 골라야 한다.

면접 포인트

  • "SendAsync 가 false 를 반환하면 무슨 뜻인가?" — 동기적으로 이미 완료됐고 Completed 이벤트는 오지 않는다는 뜻이다. 이 경로를 놓치면 펌프가 멈추고, 스택 위에서 이어붙이면 무한 재귀가 된다. 정답은 루프로 흡수하는 것이다(while + if (SendAsync(...)) return;).
  • "동기 완료를 재귀로 처리하면 왜 로컬 테스트에서 더 잘 죽는가?" — 루프백은 커널 송신 버퍼가 즉시 비워져 거의 모든 send 가 동기 완료되기 때문이다. 원격 클라는 Pending 이 섞여 스택이 풀린다. "내 PC 에서만 죽는다"의 전형적 원인이다.
  • "펌프 진행 플래그를 Interlocked 로 바꾸면 충분한가?" — 부족하다. 이중 펌프는 막아도 lost wakeup(큐에 넣은 직후 펌프가 내려앉는 창)은 남는다. 큐 상태와 플래그를 같은 임계 구역에서 함께 갱신하거나, 해제 후 큐를 재확인하고 재기동하는 CAS 루프가 필요하다.
  • "SocketAsyncEventArgs 를 재사용해도 되는가?" — 재사용은 정석이지만 동시에 두 작업을 걸 수 없다. 세션당 하나를 쓰려면 "한 번에 하나의 송신만 진행 중"이라는 불변식을 코드가 보장해야 한다.
  • "부분 송신은 언제 나오는가?" — 커널 송신 버퍼가 거의 찼을 때. 요청 바이트와 BytesTransferred 를 비교하지 않으면 프레임이 잘린 채 다음 패킷이 이어 붙어 나간다.