← 문제로

4. C# 비동기 소켓 종료 처리 순서 (in-flight async 와 Dispose)

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

해설 — C# 비동기 소켓 종료 처리 순서 (in-flight async 와 Dispose)

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

완료 기반 비동기 I/O에서 여러 완료가 다른 스레드에서 동시에 올라오는데 종료가 _closed 플래그를 락/원자성 없이 검사하고 즉시 Dispose로 소켓을 해제한다. 이미 커널에 걸려 있던 Receive 완료가 종료 직후 깨어나면 Dispose된 소켓을 건드려 ObjectDisposedException, 두 스레드가 동시에 Close를 통과하면 이중 Dispose가 난다. _pendingIo 카운터는 비원자(++/--)인 데다 실제 자원 수명에 연결돼 있지 않아 무의미하다.


문제점

(B)+(D)+(E) 비원자 close 검사 + 즉시 Dispose → 이중 Dispose / ODE (분류: 동시성/수명관리)

  • 증상: Closeif (_closed) return; _closed = true;락/Interlocked 없이 수행. 두 스레드(예: Receive 완료가 bytes<=0으로 Close, 동시에 외부 종료 요청 Close)가 동시에 _closed==false를 읽고 둘 다 통과 → Dispose두 번 실행. 단 한 번 통과해도 다른 in-flight 완료가 같은 소켓을 보고 있으면 그쪽이 ObjectDisposedException.
  • 재현조건: 멀티 스레드 풀, 종료와 거의 동시의 완료. 부하/지연이 클수록 잘 터짐.
  • 근본원인: _closed가 일반 bool이라 원자성·가시성 없음. Dispose가 "다른 사용자가 없다"는 보장 없이 실행됨.

(C)+(F) 종료 도중 들어온 Receive 완료 → ObjectDisposedException (분류: 수명관리/동시성)

  • 증상: 시나리오 요구대로 "Close 도중 뒤늦은 Receive 완료"가 올라온다. 스레드 A가 Close → Dispose로 소켓을 해제한 직후, 스레드 B의 ReceiveLoopAsync가 깨어나 _pendingIo--, ProcessPacket, 그리고 PostRecv → ReceiveAsyncDispose된 소켓에 새 I/O를 또 건다 → ObjectDisposedException (혹은 _recvBuf를 죽은 컨텍스트에서 읽음). await _socket.ReceiveAsync가 Dispose로 인해 예외/취소로 깨어날 수도 있는데 그 예외 처리도 없다.
  • 근본원인: in-flight 완료가 끝나기 전에 자원을 해제. "모든 in-flight가 끝난 뒤에만 해제" 규칙이 없음.

_pendingIo 카운터가 비원자 + 수명과 분리됨 (분류: 동시성/수명관리)

  • 증상: _pendingIo++/--비원자 연산이라 다중 스레드에서 read-modify-write가 꼬여 카운트가 어긋난다(lost update). 게다가 Close_pendingIo가 0인지 보지도 않고 즉시 Dispose → 카운터가 장식품.
  • 근본원인: "in-flight가 모두 끝난 뒤에만 해제"라는 의도를 코드가 구현하지 않음. 카운터 증감을 Interlocked로 하지 않아 그 자체로 race.

(A) 버퍼/소켓 수명 결합 — 위 문제들과 연쇄 (분류: 메모리/수명관리)

  • 증상: _recvBuf를 in-flight Receive가 참조 중인데 객체가 일찍 정리되면, GC가 버퍼를 회수하거나(핀 해제) 풀에 반납된 버퍼를 죽은 I/O가 덮어쓰는 식의 손상.
  • 근본원인: 객체 수명이 망가지면 멤버 자원 수명도 함께 망가짐(연쇄).

수정안

핵심: 종료 전이를 CAS로 단일화 + in-flight 카운트(Interlocked)로 마지막 주자가 Dispose

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

public sealed class Connection : IDisposable
{
    private readonly Socket _socket;
    private readonly byte[] _recvBuf = new byte[4096];
    private int _closed = 0;       // 0=open, 1=closing
    private int _pendingIo = 0;    // in-flight 카운트(반드시 Interlocked)
    private int _disposed = 0;     // Dispose 멱등 가드

    public Connection(Socket socket) { _socket = socket; }

    public void PostRecv()
    {
        if (Volatile.Read(ref _closed) == 1) return;   // 종료 중이면 새 I/O 미등록
        Interlocked.Increment(ref _pendingIo);
        _ = ReceiveLoopAsync();
    }

    private async Task ReceiveLoopAsync()
    {
        try
        {
            int bytes = await _socket.ReceiveAsync(_recvBuf, SocketFlags.None);
            if (Volatile.Read(ref _closed) == 1 || bytes <= 0)
            {
                Close();
                return;
            }
            ProcessPacket(_recvBuf, bytes);
            PostRecv();
        }
        catch (ObjectDisposedException) { /* 종료 경합 — 정상 종료 흐름으로 흡수 */ }
        catch (SocketException)         { Close(); }
        finally
        {
            ReleaseIo();   // 어떤 경로로 끝나도 in-flight 감소 + 마지막이면 해제
        }
    }

    public async Task SendAsync(byte[] data)
    {
        if (Volatile.Read(ref _closed) == 1) return;
        Interlocked.Increment(ref _pendingIo);
        try { await _socket.SendAsync(data, SocketFlags.None); }
        catch (ObjectDisposedException) { }
        catch (SocketException) { Close(); }
        finally { ReleaseIo(); }
    }

    public void Close()
    {
        // 단 한 스레드만 종료 전이 성공 → 이중 처리 차단
        if (Interlocked.Exchange(ref _closed, 1) == 1) return;
        try { _socket.Shutdown(SocketShutdown.Both); } catch { }
        // 여기서 곧장 Dispose 하지 않는다. in-flight 가 모두 빠진 뒤에 해제.
        // 진행 중 I/O 가 없으면 이 호출이 마지막 주자가 되어 해제.
        if (Volatile.Read(ref _pendingIo) == 0) Dispose();
    }

    // in-flight 완료가 끝날 때마다 호출. 0 으로 떨어뜨린 "마지막 주자"가 닫혀 있으면 해제.
    private void ReleaseIo()
    {
        if (Interlocked.Decrement(ref _pendingIo) == 0
            && Volatile.Read(ref _closed) == 1)
        {
            Dispose();
        }
    }

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) == 1) return;  // 멱등
        _socket.Dispose();
    }

    private void ProcessPacket(byte[] buf, int n) { /* ... */ }
}

핵심

  • 종료 전이를 Interlocked.Exchange로 단일 승자화 → 이중 Close/이중 Dispose 차단.
  • DisposeInterlocked.Exchange로 멱등 → 두 경로에서 불려도 한 번만.
  • in-flight 카운트(Interlocked)로 수명 결정: "_pendingIo를 0으로 떨어뜨린 마지막 완료" 또는 "_pendingIo==0인 Close" 단 하나만 Dispose를 호출 → 모든 in-flight가 끝난 뒤에만 해제되어 ObjectDisposedException/UAF 차단.
  • ObjectDisposedException catch + 종료 중 PostRecv 차단으로 경합을 정상 흐름으로 흡수.
  • 모든 카운터 증감을 Interlocked로 → lost update 제거.

더 나은 설계

1) 세션 상태머신 + "draining" 상태

  • Active → Closing(draining) → Closed. Closing에 들어가면 새 I/O 등록을 막고 남은 in-flight만 소진(drain). drain 완료 시점에 해제. 종료 도중 들어온 완료는 "draining에서는 데이터 무시, 카운터만 감소"로 처리.

2) 세션을 단일 스레드 잡 큐로 직렬화 (per-connection affinity)

  • 한 커넥션의 모든 완료를 같은 스트랜드/Channel로 몰면 동시 접근 자체가 사라져 락·Interlocked 경합이 줄고 추론이 쉬워진다.
  • 트레이드오프: 스레드 간 부하 불균형 가능 → 커넥션 해시로 스트랜드 분배.

3) SocketAsyncEventArgs 풀 + SafeHandle/RAII성 해제

  • 완료 컨텍스트가 세션 참조를 들게 해 "완료=수명 보장"을 표현. 버퍼는 ArrayPool로 관리하되 마지막 in-flight가 끝난 뒤에만 반납해 죽은 I/O의 버퍼 덮어쓰기를 방지.
  • CancellationTokenSource로 종료 시 in-flight를 협조적으로 취소.

면접 포인트

  1. "비동기 소켓에서 Close 시 즉시 Dispose하면 왜 위험한가?" → 이미 디스패치된 Receive/Send 완료가 같은 소켓을 들고 있어 ObjectDisposedException. 수명은 "마지막 in-flight"가 결정해야 하며, in-flight 카운트가 0이 될 때 해제한다.
  2. "종료 도중 뒤늦게 올라온 Receive 완료를 어떻게 안전하게 처리하나?" → draining 상태를 두고 완료 시 "데이터 처리는 건너뛰되 in-flight 카운트는 감소". 마지막 in-flight가 빠질 때 Dispose. _closedVolatile로 본다. ODE는 catch로 흡수.
  3. "이중 Dispose / 카운터 race를 막는 가장 견고한 방법은?" → 종료 전이와 Dispose를 Interlocked.Exchange로 단일 승자화하고, 카운터 증감은 전부 Interlocked로. 해제 조건은 _pendingIo==0 && _closed==1로 정확히 한 주자만 수행.