4. C# 비동기 소켓 종료 처리 순서 (in-flight async 와 Dispose)
난이도 상해설 — C# 비동기 소켓 종료 처리 순서 (in-flight async 와 Dispose)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
완료 기반 비동기 I/O에서 여러 완료가 다른 스레드에서 동시에 올라오는데 종료가
_closed 플래그를 락/원자성 없이 검사하고 즉시 Dispose로 소켓을 해제한다. 이미
커널에 걸려 있던 Receive 완료가 종료 직후 깨어나면 Dispose된 소켓을 건드려
ObjectDisposedException, 두 스레드가 동시에 Close를 통과하면 이중 Dispose가
난다. _pendingIo 카운터는 비원자(++/--)인 데다 실제 자원 수명에 연결돼 있지 않아
무의미하다.
문제점
(B)+(D)+(E) 비원자 close 검사 + 즉시 Dispose → 이중 Dispose / ODE (분류: 동시성/수명관리)
- 증상:
Close는if (_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 → ReceiveAsync로 Dispose된 소켓에 새 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 차단. Dispose도Interlocked.Exchange로 멱등 → 두 경로에서 불려도 한 번만.- in-flight 카운트(
Interlocked)로 수명 결정: "_pendingIo를 0으로 떨어뜨린 마지막 완료" 또는 "_pendingIo==0인 Close" 단 하나만Dispose를 호출 → 모든 in-flight가 끝난 뒤에만 해제되어ObjectDisposedException/UAF 차단. ObjectDisposedExceptioncatch + 종료 중 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를 협조적으로 취소.
면접 포인트
- "비동기 소켓에서 Close 시 즉시
Dispose하면 왜 위험한가?" → 이미 디스패치된 Receive/Send 완료가 같은 소켓을 들고 있어ObjectDisposedException. 수명은 "마지막 in-flight"가 결정해야 하며, in-flight 카운트가 0이 될 때 해제한다. - "종료 도중 뒤늦게 올라온 Receive 완료를 어떻게 안전하게 처리하나?"
→ draining 상태를 두고 완료 시 "데이터 처리는 건너뛰되 in-flight 카운트는 감소".
마지막 in-flight가 빠질 때 Dispose.
_closed는Volatile로 본다. ODE는 catch로 흡수. - "이중 Dispose / 카운터 race를 막는 가장 견고한 방법은?"
→ 종료 전이와 Dispose를
Interlocked.Exchange로 단일 승자화하고, 카운터 증감은 전부Interlocked로. 해제 조건은_pendingIo==0 && _closed==1로 정확히 한 주자만 수행.
해설 — C++ 비동기 소켓 종료 처리 순서 (use-after-free / double-free)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
완료 기반 비동기 I/O에서 여러 완료가 다른 스레드에서 동시에 올라오는데 종료가
closed 플래그를 락/atomic 없이 검사하고 delete this로 자가 삭제한다. 이미 커널에
걸려 있던 Recv 완료가 종료 직후 올라오면 해제된 객체를 건드리는 use-after-free,
두 스레드가 동시에 Close를 통과하면 double-free가 난다. pendingIo 카운터는
실제 객체 수명에 연결돼 있지 않아 무의미하다.
문제점
(A)+(D)+(E) 비원자 close 검사 + 자가 삭제 → double-free / UAF (분류: 동시성/수명관리)
- 증상:
Close는if (closed) return; closed = true;를 락/atomic 없이 수행. 두 스레드(예: Recv 완료가 bytes<=0으로 Close, 동시에 외부 종료 요청 Close)가 동시에closed==false를 읽고 둘 다 통과 →delete this가 두 번 실행 (double-free). 단 한 번 통과해도 다른 in-flight 완료가 같은 객체를 보고 있으면 그쪽이 use-after-free. - 재현조건: 멀티 IO 스레드, 종료와 거의 동시의 완료. 부하/지연이 클수록 잘 터짐.
- 근본원인:
closed가 일반bool이라 원자성·가시성 없음.delete this가 "다른 참조자가 없다"는 보장 없이 실행됨.
(C)+(E) 종료 도중 들어온 Recv 완료 → use-after-free (분류: 수명관리/동시성)
- 증상: 시나리오 요구대로 "Close 도중 뒤늦은 Recv 완료"가 올라온다.
스레드 A가
Close → delete this로 객체를 해제한 직후, 스레드 B의OnRecvComplete가 같은 포인터로pendingIo.fetch_sub,recvBuf.data,ProcessPacket을 호출 → 해제된 메모리 접근(UAF). 심지어PostRecv로 죽은 객체에 새 I/O를 또 건다. - 근본원인: in-flight 완료가 끝나기 전에 객체를 해제. "마지막 참조자가 해제" 규칙이 없음.
pendingIo 카운터가 수명과 분리됨 (분류: 수명관리)
- 증상:
pendingIo로 in-flight 수를 세지만, 해제 결정에 전혀 쓰이지 않는다.Close는pendingIo가 0인지 보지도 않고 즉시delete this. 카운터가 장식품. - 근본원인: "in-flight가 모두 끝난 뒤에만 해제"라는 의도를 코드가 구현하지 않음.
(B) 소멸자에서 버퍼 해제 — 위 문제들과 결합 (분류: 메모리)
- 증상: 객체가 이중 해제되면
~Connection도 두 번 →recvBuf.data도 double-free. - 근본원인: 객체 수명이 망가지면 멤버 자원 수명도 함께 망가짐(연쇄).
수정안
방안 A — refcount/shared_ptr + 완료 토큰으로 수명 고정 (권장)
각 in-flight I/O가 세션의 shared_ptr(또는 intrusive refcount)를 들고 있게 해서,
마지막 완료가 끝날 때 자동으로 해제되게 한다. delete this를 제거한다.
#include <memory>
#include <atomic>
class Connection : public std::enable_shared_from_this<Connection> {
public:
int fd;
Buffer recvBuf;
std::atomic<bool> closed{false};
explicit Connection(int f) : fd(f) {
recvBuf.data = new char[4096];
recvBuf.cap = 4096;
}
~Connection() { delete[] recvBuf.data; } // 모든 참조 사라질 때 단 한 번
// I/O 등록 시, 완료 컨텍스트(IoContext)에 self(shared_ptr)를 담는다.
struct IoContext { std::shared_ptr<Connection> self; /* OVERLAPPED 등 */ };
void PostRecv() {
auto ctx = new IoContext{ shared_from_this() }; // 완료 때까지 self 유지
// 커널에 ctx 와 함께 비동기 Recv 등록 ...
}
// 완료 콜백: ctx 가 self 를 들고 있으므로 콜백 도중 객체가 살아있음을 보장
void OnRecvComplete(IoContext* ctx, int bytes) {
std::shared_ptr<Connection> keepAlive = std::move(ctx->self); // 수명 고정
delete ctx;
if (closed.load(std::memory_order_acquire) || bytes <= 0) {
Close(); // 멱등 — 마지막 ref 가 사라지면 소멸자 호출
return;
}
ProcessPacket(recvBuf.data, bytes);
PostRecv();
// keepAlive 가 스코프를 벗어나며 ref 감소. 다른 in-flight 없으면 여기서 해제.
}
void Close() {
bool expected = false;
if (!closed.compare_exchange_strong(expected, true,
std::memory_order_acq_rel))
return; // 단 한 스레드만 통과 → double 처리 차단
// close(fd); // 소켓만 닫는다. delete this 없음!
// 남은 in-flight 완료들이 각자 들고 있는 shared_ptr 를 놓으면
// 마지막에 refcount 0 → ~Connection 자동 호출.
}
void ProcessPacket(const char* p, int n) { /* ... */ }
};
핵심
delete this제거: 수명은 refcount가 결정. in-flight I/O가 각자 self를 들고 있어 모두 완료될 때까지 객체가 살아있다 → UAF 차단.compare_exchange로 close 단일 승자화 → double-close/double-free 차단.closed를 atomic + acquire/release로 가시성 확보. 완료 콜백은 closed를 보면 데이터 처리를 건너뛴다(소켓은 이미 닫혔으니 새 I/O 미등록).
방안 B — pendingIo 카운터로 명시적 수명 관리 (shared_ptr 없이)
intrusive refcount가 부담스러우면, 마지막 in-flight가 해제를 책임지게 한다.
void Close() {
bool expected = false;
if (!closed.compare_exchange_strong(expected, true)) return;
// close(fd);
if (pendingIo.load() == 0) delete this; // 이미 in-flight 없으면 즉시 해제
// 아니면 마지막 완료 콜백이 해제한다(아래).
}
void OnRecvComplete(int bytes) {
bool isClosing = closed.load(std::memory_order_acquire);
if (!isClosing && bytes > 0) {
ProcessPacket(recvBuf.data, bytes);
PostRecv(); // pendingIo 가 다시 0 이상 유지
}
// 이 완료 처리의 끝에서 카운터 감소. 0 이고 닫힌 상태면 마지막 주자가 해제.
if (pendingIo.fetch_sub(1, std::memory_order_acq_rel) == 1
&& closed.load(std::memory_order_acquire)) {
delete this;
}
}
- 규칙: "pendingIo를 0으로 떨어뜨린 마지막 완료" 또는 "pendingIo==0인 Close"
단 하나만
delete this를 실행. CAS로 closed 전이를 단일화했으므로 경합 시에도 정확히 한 주자만 해제한다. - 트레이드오프: 카운터 증감 순서/메모리 오더를 정확히 맞춰야 해서 실수 여지가 큼. 그래서 실무에선 방안 A(shared_ptr/intrusive ptr)를 선호.
더 나은 설계
1) 세션 상태머신 + "draining" 상태
Active → Closing(draining) → Closed. Closing에 들어가면 새 I/O 등록을 막고 남은 in-flight만 소진(drain). drain 완료 시점에 해제. 종료 도중 들어온 완료는 "draining에서는 데이터 무시, 카운터만 감소"로 처리.
2) 세션을 단일 스레드 잡 큐로 직렬화 (per-connection affinity)
- 한 커넥션의 모든 완료를 같은 스레드/스트랜드(strand, asio 용어)로 몰면 동시 접근 자체가 사라져 락·atomic 경합이 줄고 추론이 쉬워진다.
- 트레이드오프: 스레드 간 부하 불균형 가능 → 커넥션 해시로 스트랜드 분배.
3) delete this/raw OVERLAPPED 대신 RAII 완료 컨텍스트
- 완료 컨텍스트가 세션 스마트포인터를 소유하게 해 "완료=수명 보장"을 타입으로 표현.
IOCP에서
OVERLAPPED를 감싼 구조체에shared_ptr<Connection>을 넣는 패턴이 정석.
면접 포인트
- "비동기 I/O에서
delete this가 왜 위험한가?" → 다른 스레드에 이미 디스패치된 완료가 같은 포인터를 들고 있을 수 있어 UAF. 수명은 "마지막 참조자"가 결정해야 하며, 완료 컨텍스트가 세션 ref를 들게 하는 게 정석. - "종료 도중 뒤늦게 올라온 Recv 완료를 어떻게 안전하게 처리하나?" → draining 상태를 두고 완료 시 "데이터 처리는 건너뛰되 in-flight 카운트는 감소". 마지막 in-flight가 빠질 때 해제. closed는 atomic acquire/release로 본다.
- "double-free를 막는 가장 견고한 방법은?"
→ 종료 전이를
compare_exchange로 단일 승자화하고, 해제 자체는 refcount(또는 pendingIo==0 && closed) 조건으로 정확히 한 주자만 수행. ASAN/Valgrind로 검증.