← 문제로

30. 비동기 송신 버퍼의 조기 반납: in-flight 중 재사용 (C#)

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

해설 — 비동기 송신 버퍼의 조기 반납: in-flight 중 재사용 (C#)

난이도: 중상

요약

BeginSend비동기 송신을 걸어 놓고, 전송이 끝나기도 전에 그 버퍼를 ArrayPool 에 반납한다. 커널이 아직 이 버퍼를 읽어 소켓으로 내보내는 동안 같은 버퍼가 다른 Send 에 재대여·덮어써져, 전송 바이트가 손상되거나 다른 클라이언트에게 보낼 내용이 섞여 나간다.

문제점

  • 분류 태그: 비동기 I/O 대상 버퍼의 수명 위반(in-flight 버퍼 조기 반납/재사용) · 공유 풀 버퍼 데이터 레이스 · 부분 송신 미처리(부수).
  • 증상: 클라이언트가 깨진 프레임(다른 메시지 조각이 섞인 페이로드)을 받거나, A 에게 갈 데이터가 B 에게 새어 나감(정보 유출). 부하가 높을수록(비동기 완료가 지연될수록) 심해진다.
  • 재현 조건:
    1. T1: Send 가 buf 를 대여·복사하고 (A) BeginSend(buf …) 시작. 커널은 아직 전송 중.
    2. T1: (B) 곧바로 Return(buf) → 버퍼가 풀로 반납됨.
    3. T2: 다른 SendRent()같은 buf 를 받아 자기 메시지를 BlockCopy → 전송 중이던 T1 의 바이트를 덮어씀. 커널은 덮어써진 내용을 전송 → 손상/유출.
  • 근본 원인:
    • (A) BeginSend 는 즉시 반환하지만 전송은 완료 콜백 시점까지 계속 진행된다. 버퍼는 그때까지 살아 있고 변경되지 않아야 한다.
    • (B) 그런데 완료를 기다리지 않고 동기적으로 Return 한다. 반납 = "이제 아무나 재사용해도 됨" 인데 실제로는 in-flight. 소유권이 in-flight 연산으로 넘어가지 않았다. (덤으로 EndSend 의 반환값(부분 송신)도 처리하지 않아 남은 바이트가 유실될 수 있다.)

수정안

버퍼는 완료 콜백에서 반납한다(소유권을 in-flight 연산에 이양). AsyncState 로 넘긴 버퍼를 OnSent 에서 되돌린다.

public void Send(byte[] message, int len)
{
    byte[] buf = ArrayPool<byte>.Shared.Rent(len);
    Buffer.BlockCopy(message, 0, buf, 0, len);
    SendFrom(buf, 0, len);
}

private void SendFrom(byte[] buf, int offset, int len)
{
    // 상태로 (buf, offset, len) 을 넘겨 완료 시점까지 버퍼를 살려 둔다.
    _socket.BeginSend(buf, offset, len, SocketFlags.None, OnSent, (buf, offset, len));
}

private void OnSent(IAsyncResult ar)
{
    var (buf, offset, len) = ((byte[], int, int))ar.AsyncState;
    int sent = _socket.EndSend(ar);
    if (sent < len)                       // 부분 송신: 남은 만큼 이어서
    {
        SendFrom(buf, offset + sent, len - sent);
        return;
    }
    ArrayPool<byte>.Shared.Return(buf);   // 전송이 끝난 뒤에만 반납
}

추가로 한 연결에 대한 송신은 직렬화해야 한다(여러 스레드가 동시에 같은 소켓에 BeginSend 하면 프레임이 교차된다). 연결별 송신 큐를 두고 한 번에 하나씩 in-flight 로 보낸다:

// per-connection: 송신 큐 + "지금 전송 중?" 플래그. 완료 콜백에서 다음 프레임을 이어 보냄.

더 나은 설계 (트레이드오프)

  • 연결별 송신 큐(single in-flight): 프레임 교차와 버퍼 수명 문제를 한꺼번에 해결. 완료 콜백에서 다음 항목을 pop 해 보낸다. 지연이 약간 늘 수 있으나 정합성이 보장된다.
  • SocketAsyncEventArgs 재사용(제로 할당): op 당 하나의 SAEA + 버퍼를 두고 완료 시 재무장. GC 압박이 줄지만 "op 하나당 SAEA 하나(동시 in-flight 개수만큼)" 규칙을 어기면 같은 함정.
  • 불변 스냅샷 전송: 브로드캐스트는 한 번 직렬화한 불변 버퍼(참조 카운트)를 여러 연결이 공유해 보내고, 마지막 완료 시 반납. 복사·할당을 줄이되 참조 카운팅 정확성이 관건.

면접 포인트

  1. 비동기 I/O 에서 버퍼 수명 = "요청 시작"이 아니라 "완료 콜백"까지 라는 원칙. 반납/재사용은 완료 이후에만.
  2. 같은 소켓에 대한 동시 BeginSend 가 프레임을 교차시키는 문제와 연결별 송신 직렬화(single in-flight) 필요성.
  3. EndSend 반환값(부분 송신) 처리와, 풀 버퍼의 소유권을 in-flight 연산으로 이양하는 패턴.