소켓에 둘이 동시에 쓰면 프레임이 섞인다
Esc

    소켓에 둘이 동시에 쓰면 프레임이 섞인다

    –C#동시성

    틱 스레드가 몬스터 위치를 보냅니다. 동시에 패킷 핸들러 스레드가 공격 결과를 보냅니다. 둘 다 같은 플레이어의 소켓에 씁니다.

    await Socket.SendAsync(프레임, SocketFlags.None, ct);

    이 한 줄이 원자적이면 문제가 없습니다. 그런데 아닙니다.

    SendAsync 는 요청한 바이트를 다 못 보낼 수 있습니다. 그 순간 다른 프레임이 끼어듭니다.

    부분 전송이 무엇을 망가뜨리나

    TCP 는 메시지 단위가 없습니다. 바이트가 흐를 뿐입니다. 그래서 어디까지가 한 패킷인지 우리가 정해야 하고, 흔한 방법이 앞에 길이를 붙이는 것입니다.

    frame = new byte[4 + length];
    Array.Copy(lengthBytes, 0, frame, 0, 4);   // 앞 4바이트 = 뒤에 올 길이
    Array.Copy(protobufData, 0, frame, 4, length);

    받는 쪽은 4바이트를 읽어 길이를 알아내고 딱 그만큼 더 읽습니다.

    byte[] lengthBytes = await ReceiveExactAsync(4, ct);
    int length = BitConverter.ToInt32(lengthBytes, 0);
    byte[] protobufData = await ReceiveExactAsync(length, ct);

    이 규칙은 바이트 순서가 어긋나지 않는다는 전제 위에 서 있습니다. 그 전제가 깨지면 이렇게 됩니다.

    보내려던 것[len 12]A 의 12바이트[len 20]B 의 20바이트A 가 5바이트만 나가고 B 가 끼어들면[len 12]A 1바이트[len 20]B 의 20바이트A 나머지 11바이트받는 쪽은 12 를 읽고 그 뒤 12바이트를 한 패킷으로 봅니다.그 안에 B 의 길이 헤더가 들어 있습니다. 여기서부터 모든 경계가 어긋납니다.
    한 바이트만 어긋나도 그 연결은 이후 전부 쓰레기가 됩니다.

    한 프레임이 깨지고 끝나는 게 아닙니다. 스트림 전체가 어긋납니다. 받는 쪽은 쓰레기를 길이로 읽고, 그다음 읽기 위치가 또 틀리고, 계속 그렇습니다.

    제 코드 주석에 이 문장이 그대로 있습니다.

    이게 없으면 여러 스레드가 같은 소켓에 SendAsync 를 동시에 걸고, 부분 전송이 일어나는 순간 한 프레임 중간에 다른 프레임 바이트가 끼어들어 길이-프리픽스 파싱이 깨진다(치명).

    왜 lock 으로 안 하나

    lock (Socket) { await Socket.SendAsync(...); } 로 하면 될 것 같습니다. 그런데 안 됩니다.

    lock 블록 안에서는 await 를 못 씁니다. 문법적으로 막혀 있습니다. 자물쇠는 스레드가 잡는 것인데 await 뒤는 다른 스레드에서 이어질 수 있어서, 놓을 사람이 사라지기 때문입니다.

    SemaphoreSlim 을 쓰면 await 가 되긴 합니다. 그런데 앞에서 본 원칙을 어깁니다. 잠근 채로 남을 부르면 안 됩니다. 네트워크 전송은 가장 오래 걸리는 종류의 일입니다.

    그리고 더 큰 문제가 있습니다. 이 서버에서 보내는 쪽 중 하나는 틱 스레드입니다. 틱 스레드가 소켓 하나를 기다리며 서 있으면 그 방의 모든 몬스터가 같이 멈춥니다. 느린 클라이언트 한 명이 방 전체를 세웁니다.

    그래서 줄을 세운다

    기다리게 하는 대신 넣고 바로 돌아가게 만듭니다. 실제로 쓰는 사람은 따로 한 명 둡니다.

    private readonly Channel<byte[]> _sendQueue = Channel.CreateBounded<byte[]>(
        new BoundedChannelOptions(SendQueueCapacity)
        {
            SingleReader = true,   // SendLoop 하나만 읽는다 = 프레임 원자성의 근거
            SingleWriter = false,  // 틱·패킷 스레드가 함께 넣는다
            FullMode = BoundedChannelFullMode.Wait,
        });

    Channel<T> 는 스레드 안전한 큐입니다. 넣는 쪽과 꺼내는 쪽이 서로 다른 스레드여도 됩니다.

    SingleReader = true 가 이 글의 핵심입니다. 읽는 사람이 하나라고 선언하면 소켓에 실제로 쓰는 스레드가 하나로 고정됩니다. 그러면 프레임이 섞일 수가 없습니다. 한 스레드만 만지게 하기를 소켓에 적용한 것입니다.

    보내는 쪽은 이렇게만 합니다.

    // 틱 스레드가 생산자라 절대 블록시키지 않는다 → TryWrite.
    if (!_sendQueue.Writer.TryWrite(frame))
    {
        // 큐가 가득 찼다
    }

    TryWrite 는 기다리지 않습니다. 넣거나 실패하거나 둘 중 하나고 즉시 돌아옵니다. 틱 스레드가 여기서 멈출 일이 없습니다.

    꺼내는 쪽은 하나뿐이고 한 프레임을 끝까지 쓴 다음 다음 것을 씁니다.

    private async Task SendLoopAsync(CancellationToken ct)
    {
        while (await _sendQueue.Reader.WaitToReadAsync(ct))
        {
            while (_sendQueue.Reader.TryRead(out var frame))
            {
                int offset = 0;
                while (offset < frame.Length)
                {
                    int sent = await Socket.SendAsync(
                        new ArraySegment<byte>(frame, offset, frame.Length - offset),
                        SocketFlags.None, ct);
    
                    if (sent == 0)
                        throw new SocketException((int)SocketError.ConnectionReset);
    
                    offset += sent;
                }
            }
        }
    }

    안쪽 while (offset < frame.Length) 가 부분 전송을 처리합니다. SendAsync 가 5바이트만 보냈으면 남은 것을 이어서 보냅니다. 이 루프를 도는 동안 다른 프레임은 아예 시작되지 않습니다. 읽는 사람이 나뿐이니까요.

    순서는 보존된다

    큐를 끼우면 순서가 흐트러질 것 같은데 그렇지 않습니다.

    한 스레드가 연달아 넣은 것은 넣은 순서대로 나갑니다. 소비자가 하나라 꺼내는 순서가 곧 넣은 순서입니다. 그래서 “입장 시 로스터를 여러 패킷으로 나눠 보내는” 것 같은 기존 코드가 그대로 동작합니다.

    다만 서로 다른 스레드가 넣은 것끼리의 순서는 보장되지 않습니다. 그건 원래도 보장이 없었으니 잃은 게 아닙니다.

    잃은 것도 있다

    정직하게 적어 둘 것이 하나 있습니다. 이 구조로 바꾸면서 메서드의 의미가 바뀌었습니다.

    await session.SendPacketAsync(packet);

    전에는 이 await 가 끝나면 전선에 나갔다는 뜻이었습니다. 지금은 큐에 들어갔다는 뜻입니다.

    그래서 보내자마자 연결을 끊으면 그 패킷은 유실됩니다. 제 코드에서는 그런 호출부가 없어서 넘어갔지만, 있었다면 그 자리를 전부 고쳐야 했습니다.

    성능을 얻으면서 보장 하나를 내려놓은 것입니다. 이런 건 주석에 적어 두는 편이 낫습니다. 나중에 그 보장을 믿고 코드를 짜는 사람이 나옵니다.

    어디서 또 쓰나

    이 모양은 소켓만의 것이 아닙니다. 여럿이 쓰는데 한 명만 써야 하는 자원이면 전부 같습니다.

    • 로그 파일 하나에 여러 스레드가 쓸 때
    • 콘솔 출력
    • 시리얼 포트나 하드웨어 핸들
    • 유니티에서 백그라운드 작업의 결과를 메인 스레드로 넘길 때

    마지막이 특히 그렇습니다. 유니티 API 는 메인 스레드에서만 부를 수 있으니 다른 스레드에서 만든 결과를 큐에 넣고 Update() 에서 꺼내 쓰는 방식이 자주 쓰입니다. 같은 발상입니다.

    정리

    • SendAsync 는 요청한 만큼 다 못 보낼 수 있습니다. 그 틈에 다른 프레임이 끼어듭니다
    • 길이 프리픽스는 바이트 순서를 전제로 합니다. 한 바이트만 어긋나면 그 연결은 끝입니다
    • lock 안에서는 await 를 못 씁니다. 세마포어를 써도 잠근 채로 네트워크를 기다리게 됩니다
    • 틱 스레드를 기다리게 하면 방 전체가 멈춥니다
    • Channel 로 줄을 세우고 소비자를 하나로 고정하면 프레임이 섞일 수 없습니다
    • 넣는 쪽은 TryWrite 로 기다리지 않습니다
    • 대신 “보냈다” 의 의미가 “큐에 넣었다” 로 바뀝니다. 그건 적어 둬야 합니다