41. 비동기 송신의 동기 완료 경로 처리 (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개꼴로만 주입하므로 회복이 느리다. 존 틱 지연 → 큐 적체 → 더 깊은 재귀로 이어지는 악순환이 된다.
- 재현 조건:
- 클라와 서버가 같은 머신(루프백)이거나 수신자가 계속 여유가 있는 상태,
- 한 세션의 송신 큐에 수천 개 이상이 한꺼번에 적재.
PumpSend+OnSendCompleted두 프레임이 한 번에 대략 수백 바이트를 먹으므로, 기본 1MB 스택이라면 수천 회 반복만으로 소진된다. 브로드캐스트 한 번이면 충분한 양이다.
- 근본 원인:
- "완료 처리"와 "다음 송신 시작"이 서로를 호출하는 상호 재귀다. 종료 조건은 "큐가 빔" 하나뿐인데, 큐는 다른 스레드가 계속 채운다. 사실상 종료 조건이 없다.
- 동기 완료는 본질적으로 반복(loop) 인데 호출(call) 로 표현했다. 반복으로 풀면 스택이 자라지 않는다.
- 재진입 깊이 상한도, 한 스레드가 한 세션에서 처리할 항목 수 상한도 없다.
- 같은 지점의 추가 결함 — 부분 송신(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하는 평범한 상황. 창이 좁아 개발 환경에서는 며칠에 한 번, 실서버에서는 시간당 수십 건 발생한다. - 근본 원인:
Send의if (_sending) return; _sending = true;는 전형적인 check-then-act 인데 락 밖에 있다.Interlocked도 아니다._sending이volatile조차 아니다. JIT 가 레지스터에 캐시할 수 있어 다른 스레드의 변경을 영영 못 볼 수도 있다.- 큐 상태와 펌프 상태가 서로 다른 임계 구역에서 갱신된다. 두 상태는 하나의 불변식 ("큐가 비지 않았다면 펌프가 돌고 있다")을 이루므로 같은 락 아래에서 함께 바뀌어야 한다.
SocketAsyncEventArgs는 한 번에 하나의 작업만 지원한다. 세션당 하나 재사용은 정석이지만 "동시에 하나만"을 코드가 보장해야 하는데 그 보장이 (1)~(3) 때문에 무너져 있다.- 곁가지:
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를 비교하지 않으면 프레임이 잘린 채 다음 패킷이 이어 붙어 나간다.
해설 — 비동기 송신의 동기 완료 경로 처리 (C++ / IOCP)
난이도: 최상
요약
IOCP 에서 WSASend 가 0 을 반환하면 "즉시 완료"지만, 완료 패킷은 여전히 완료 포트에 큐잉된다.
SetFileCompletionNotificationModes(..., FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) 를 켜지 않는 한
그렇다. 이 코드는 정반대로 이해해서 SendResult::Completed 를 받으면 onSendCompleted 를
그 자리에서 직접 호출한다. 결과는 두 겹이다. ① 상대가 계속 받아주는 동안
pumpSend → onSendCompleted → pumpSend → … 가 한 스택 안에서 반복되어 가드 페이지를 밟고
SIGSEGV 로 죽고, ② 나중에 도착하는 진짜 완료 통지가 같은 항목을 한 번 더 처리해 두 개의
펌프가 동시에 돌기 시작한다. 여기에 세션당 하나뿐인 Overlapped 를 in-flight 중에 덮어쓰고,
송신 버퍼를 send 직후 곧바로 풀에 반납한다 — 커널이 아직 읽고 있는 메모리를 남에게 넘기는
전형적인 use-after-free 다.
문제점
(A) 즉시 완료를 스택 위에서 이어붙인다 — 재귀 소멸과 이중 처리
- 분류: 무한 재귀(스택 소진) · 완료 통지 이중 처리 · 스레드 기아
- 증상:
- 스택 소진.
pumpSend()가Completed를 받으면onSendCompleted()를 호출하고, 그 안에서 다시pumpSend()를 부른다. 큐가 빌 때까지 이 사슬이 한 스택에서 이어진다. 리눅스 기본 스레드 스택 8MB, Windows 기본 1MB 기준으로 프레임당 100~200바이트면 수천~수만 회에 소진된다. 필드 보스 처치 후 브로드캐스트 한 번이면 도달하는 규모다. C# 과 달리 예외가 아니라 SIGSEGV(또는 EXCEPTION_STACK_OVERFLOW) 이고, 스택 가드 페이지를 밟은 시점에는 이미 스택이 없어 핸들러조차 제대로 못 돈다. 코어 덤프의 콜스택은 같은 두 프레임이 수만 번 반복된 모습이라 원인은 명확하지만, 그때는 이미 프로세스가 통째로 죽은 뒤다. - 이중 처리. IOCP 는 즉시 완료에도 완료 패킷을 큐잉한다(모사 계층 주석에도 명시돼 있다).
따라서 인라인 호출로 이미 처리한 항목에 대해 완료 포트 워커가 다시
onSendCompleted를 호출한다.totalSent_가 두 배로 잡히는 건 사소하고, 진짜 문제는pumpSend()가 두 번 불려 두 개의 펌프가 서로 다른 스레드에서 동시에 도는 것이다. 이때부터 (B)의 모든 고장이 상시 발생한다. - 스레드 점유. 재귀에 빠진 완료 포트 워커는 그 세션의 큐가 빌 때까지 다른 완료를 못 꺼낸다. 워커 수만큼의 세션이 이렇게 눌러앉으면 나머지 수천 세션의 송수신이 통째로 멎는다.
- 스택 소진.
- 재현 조건: 루프백 클라(로컬 QA·부하 테스트) 또는 커널 송신 버퍼 여유가 큰 상태 + 대량
브로드캐스트. 실서비스 원격 클라는
Pending이 섞여 스택이 풀리므로 "로컬에서만 죽는다" 형태로 나타난다. - 근본 원인:
- IOCP 의 완료 통지 규약을 반대로 이해했다. 기본 설정에서 즉시 완료도 통지가 온다.
통지를 생략하려면 소켓에
FILE_SKIP_COMPLETION_PORT_ON_SUCCESS를 명시적으로 켜야 하고, 그때는 반대로 인라인 처리가 필수가 된다. 두 모드는 짝으로만 성립하는데 코드는 섞었다. - 동기 완료는 본질적으로 반복인데 호출로 표현했다. 반복으로 풀면 스택이 자라지 않는다.
- 재진입 깊이 상한도, 한 워커가 한 세션에서 처리할 항목 예산도 없다.
- IOCP 의 완료 통지 규약을 반대로 이해했다. 기본 설정에서 즉시 완료도 통지가 온다.
통지를 생략하려면 소켓에
(B) 공유 자원의 동시 사용과 수명 — OVERLAPPED 재사용, in-flight 버퍼 반납, 비원자 플래그
- 분류: use-after-free · 경쟁 조건(check-then-act) · 자원 수명 관리 실패
- 증상:
- in-flight 버퍼 반납(use-after-free).
pool_->release(item.data)가send()직후Pending인 경우에도 실행된다.WSASend의 요청 버퍼는 완료 통지가 올라올 때까지 유효해야 한다. 반납된 버퍼는 곧 다른 세션이rent해서memcpy로 덮어쓴다. 결과: 전송 중인 바이트열이 도중에 바뀐다 — A 플레이어의 패킷 내용이 B 에게 나가는 정보 유출이자 프레임 손상이다. 풀이 실제로 메모리를 해제하는 구현이라면 곧바로 커널이 해제된 페이지를 읽는 진짜 use-after-free 가 된다. ASAN 없이는 재현이 극히 어렵고, 증상은 "가끔 패킷이 깨진다" 하나뿐이다. - OVERLAPPED 재사용.
ov_는 세션당 하나다. 앞선 송신이Pending인데 다음pumpSend가ov_.buffer/length/owner를 덮어쓴다. IOCP 에서 완료를 구별하는 유일한 키가 OVERLAPPED 포인터인데 같은 포인터가 두 번 in-flight 이면 어느 완료인지 구분할 방법이 없다. 게다가 커널은 완료 시점에 그 구조체에 비동기적으로 기록하므로, 유저 코드가 동시에 쓰면 데이터 경쟁이다. - 비원자 플래그 — 이중 펌프와 lost wakeup.
if (sending_) return; sending_ = true;가 락 밖의 평범한boolcheck-then-act 다. 두 로직 스레드가 동시에send()하면 둘 다 통과해 펌프가 두 개 돌고(위의ov_문제로 직결), 반대로 펌프가 큐를 비워sending_ = false를 쓰기 직전에 다른 스레드가sending_을true로 읽고return하면 큐에 항목이 남았는데 아무도 펌프를 돌리지 않는다. 그 세션은 조용히 송신이 멎고 클라는 무응답으로 튕긴다.bool에 대한 비동기화 읽기/쓰기는 C++ 메모리 모델상 데이터 경쟁 = 미정의 동작이라, 컴파일러가 레지스터에 캐시해 다른 스레드의 변경을 영영 못 보게 만들 수도 있다. - 세션 수명. 완료 핸들러 람다가
this를 날것으로 캡처한다. in-flight 송신이 남은 채Session이 소멸하면 완료 포트 워커가 danglingthis로 진입한다. 접속 종료가 잦은 서버에서 가장 흔한 크래시 유형이다. - 곁가지:
totalSent_/disconnected_도 비원자,disconnect()는 큐에 남은 버퍼를 반납하지 않아 풀이 계속 샌다. 부분 송신(sent < len)도 전혀 처리하지 않아 커널 버퍼가 찬 상태에서 패킷이 잘린 채 다음 패킷이 이어 붙는다.
- in-flight 버퍼 반납(use-after-free).
- 재현 조건: 존 틱 스레드와 채팅 스레드가 같은 세션에 동시에
send()하는 평범한 상황. 창이 좁아 개발 환경에서는 며칠에 한 번, 실서버에서는 시간당 수십 건. - 근본 원인: "한 세션에는 한 번에 하나의 송신만 in-flight" 라는 불변식을 코드가 어디서도 보장하지 않는다. 그리고 in-flight 자원(버퍼·OVERLAPPED·세션 자신)의 소유권이 "요청 시점 ~ 완료 통지 시점"이라는 사실이 코드에 표현돼 있지 않다.
수정안
핵심은 ① 작업(operation)마다 전용 OVERLAPPED + 전용 버퍼 소유권, ② 큐와 펌프 플래그를
같은 락 아래에서 갱신, ③ 즉시 완료도 통지를 기다린다(재귀 제거), ④ 버퍼 반납은 완료
시점 한 곳에서만, ⑤ shared_ptr 로 세션 수명을 in-flight 동안 고정.
아래 코드는 g++ -std=c++17 -pthread -Wall -Wextra 로 검증했다.
class Session : public std::enable_shared_from_this<Session> {
public:
Session(AsyncSocket* sock, BufferPool* pool) : sock_(sock), pool_(pool) {}
void start() {
// this 를 캡처하지 않는다. 세션 수명은 SendOp 가 든 shared_ptr 이 보장한다.
sock_->setCompletionHandler([](Overlapped* ov, std::size_t bytes) {
auto* op = static_cast<SendOp*>(ov->owner);
op->self->onSendCompleted(op, bytes);
});
}
void send(const char* packet, std::size_t len) {
if (disconnected_.load(std::memory_order_acquire)) return;
char* buf = pool_->rent(len);
std::memcpy(buf, packet, len);
bool startPump;
{
std::lock_guard<std::mutex> lk(mtx_);
queue_.push_back(SendItem{buf, len});
// 큐 적재와 펌프 기동 결정을 같은 임계 구역에서 — 이중 펌프/lost wakeup 동시 차단
startPump = !sending_;
if (startPump) sending_ = true;
}
if (startPump) pumpSendOne();
}
private:
struct SendOp {
Overlapped ov;
std::shared_ptr<Session> self; // in-flight 동안 세션 수명 고정
char* buf = nullptr;
std::size_t len = 0;
std::size_t offset = 0;
};
void pumpSendOne() {
SendItem item;
{
std::lock_guard<std::mutex> lk(mtx_);
if (queue_.empty()) { sending_ = false; return; } // 큐 검사와 해제가 원자적
item = queue_.front();
queue_.pop_front();
}
// 작업마다 전용 OVERLAPPED + 전용 버퍼 소유권
auto* op = new SendOp();
op->self = shared_from_this();
op->buf = item.data;
op->len = item.len;
op->offset = 0;
op->ov.owner = op;
op->ov.buffer = op->buf;
op->ov.length = op->len;
std::size_t sent = 0;
SendResult r = sock_->send(&op->ov, op->buf, op->len, sent);
if (r == SendResult::Error) { finishOp(op); disconnect(); return; }
// Completed 든 Pending 든 완료 통지는 반드시 한 번 올라온다.
// 여기서는 버퍼도 OVERLAPPED 도 건드리지 않고 스택을 놓는다(재귀 없음).
}
void onSendCompleted(SendOp* op, std::size_t bytes) {
auto self = shared_from_this(); // finishOp 전에 수명을 로컬로 고정
totalSent_.fetch_add(bytes, std::memory_order_relaxed);
op->offset += bytes;
if (op->offset < op->len) { // 부분 송신: 남은 구간을 다시 건다
op->ov.buffer = op->buf + op->offset;
op->ov.length = op->len - op->offset;
std::size_t sent = 0;
SendResult r = sock_->send(&op->ov, op->buf + op->offset,
op->len - op->offset, sent);
if (r == SendResult::Error) { finishOp(op); disconnect(); }
return;
}
finishOp(op); // 버퍼 반납은 오직 여기
if (disconnected_.load(std::memory_order_acquire)) return;
pumpSendOne(); // 완료 포트 스레드의 새 스택에서 시작
}
void finishOp(SendOp* op) {
pool_->release(op->buf);
delete op;
}
void disconnect() {
if (disconnected_.exchange(true, std::memory_order_acq_rel)) return;
std::lock_guard<std::mutex> lk(mtx_);
for (auto& it : queue_) pool_->release(it.data);
queue_.clear();
sending_ = false;
}
AsyncSocket* sock_;
BufferPool* pool_;
std::mutex mtx_;
std::deque<SendItem> queue_;
bool sending_ = false; // mtx_ 안에서만 접근
std::atomic<bool> disconnected_{false};
std::atomic<std::uint64_t> totalSent_{0};
};
onSendCompleted 첫 줄의 auto self = shared_from_this(); 는 장식이 아니다.
finishOp(op) 가 op 를 지우면 op->self 가 놓이면서 그것이 마지막 참조일 수 있고,
그러면 이어지는 pumpSendOne() 은 이미 소멸한 객체 위에서 실행된다. 로컬 참조로 막아야 한다.
성능을 위해 즉시 완료 통지를 생략하고 싶다면
SetFileCompletionNotificationModes(h, FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) 를 켜고
그때만 인라인 처리를 한다. 단, 반드시 루프여야 하고 스레드 독점 방지 예산이 필요하다.
// --- FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 를 켠 경우에만 유효한 형태 ---
void pumpSendLoop() {
int budget = 32;
while (true) {
SendItem item;
{
std::lock_guard<std::mutex> lk(mtx_);
if (queue_.empty()) { sending_ = false; return; }
item = queue_.front();
queue_.pop_front();
}
auto* op = new SendOp();
op->self = shared_from_this();
op->buf = item.data;
op->len = item.len;
op->ov.owner = op;
op->ov.buffer = op->buf;
op->ov.length = op->len;
std::size_t sent = 0;
SendResult r = sock_->send(&op->ov, op->buf, op->len, sent);
op->offset += sent;
// 즉시 완료인데 부분 송신이면 남은 구간을 같은 자리에서 이어 건다
while (r == SendResult::Completed && op->offset < op->len) {
op->ov.buffer = op->buf + op->offset;
op->ov.length = op->len - op->offset;
sent = 0;
r = sock_->send(&op->ov, op->buf + op->offset, op->len - op->offset, sent);
op->offset += sent;
}
if (r == SendResult::Error) { finishOp(op); disconnect(); return; }
if (r == SendResult::Pending) { return; } // 완료 통지가 이어받는다
// 즉시 완료 + 통지 생략 → 여기서 정리하고 루프로 계속 (스택이 자라지 않는다)
finishOp(op);
if (--budget == 0) { // 한 세션 독점 방지
std::lock_guard<std::mutex> lk(mtx_);
sending_ = false;
return; // 다음 send() 가 다시 기동
}
}
}
더 나은 설계
(1) 배치 전송(scatter/gather): 큐에 쌓인 여러 패킷을 한 번의 WSASend 로 묶는다.
WSABUF 배열(리눅스라면 writev/sendmsg)에 큐의 앞쪽 N개를 그대로 얹으면 시스템 콜 수와
완료 통지 수가 수십분의 1로 줄고, (A)의 재귀 깊이·스레드 점유 압력이 근본적으로 낮아진다.
MMORPG 브로드캐스트처럼 작은 패킷이 폭발적으로 나오는 패턴에서는 사실상 필수다.
- 트레이드오프: 첫 패킷의 지연이 살짝 늘고(묶는 동안 대기), 부분 송신 시 "어느 버퍼의 어디까지 나갔는가"를 추적하는 로직이 복잡해진다. 그래도 이득이 압도적이다.
(2) 송신 버퍼를 세션 전용 링 버퍼(더블 버퍼)로 바꾼다.
std::deque<SendItem> + 풀 대신, 세션마다 쓰기 영역과 전송 영역을 번갈아 쓰는 링 버퍼를 두면
"in-flight 중 반납" 문제가 구조적으로 사라진다(전송 영역은 완료 전까지 아무도 못 만진다).
할당·해제도 없어진다.
- 트레이드오프: 세션당 고정 메모리를 미리 잡아야 해서 동시접속 수만 세션이면 총량이 커진다. 링이 가득 찼을 때의 백프레셔 정책(끊기/차단/드롭)을 반드시 정해야 한다.
(3) 세션당 단일 소유 스레드(액터) 또는 strand 로 직렬화한다.
Asio 의 strand 처럼 한 세션의 모든 콜백을 논리적으로 직렬화하면 sending_ 플래그와
ov_ 경합이 설계상 사라지고, post() 로 넘기므로 재귀도 자연히 끊긴다.
- 트레이드오프:
post큐잉 비용이 매 완료마다 붙고, strand 를 잘못 쓰면 오히려 병목이 된다. 대신 이 문서의 (A)(B)가 통째로 성립 불가능해지므로, 신규 설계라면 강력히 권한다.
(4) 백프레셔와 큐 상한. 느린 클라 하나가 무한히 쌓이는 큐로 서버 메모리를 먹지 못하도록 세션 큐에 바이트/개수 상한을 두고, 초과 시 세션을 끊거나 우선순위 낮은 패킷(주변 이동 동기화 등)을 버린다.
- 트레이드오프: 패킷마다 "버려도 되는가"를 분류해야 하고 정책 실수는 게임 로직 오류로 이어진다. 그러나 상한이 없으면 결국 OOM 이거나 (A)의 재귀로 죽는다.
면접 포인트
- "IOCP 에서
WSASend가 0 을 반환하면 완료 통지가 오는가?" — 온다.SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)를 켰을 때만 생략된다. 이 사실을 반대로 알면 이중 처리(통지가 오는데 인라인도 처리) 또는 영구 정지(통지가 안 오는데 기다림) 중 하나가 반드시 난다. 두 모드는 짝으로만 성립한다. - "비동기 송신의 버퍼와 OVERLAPPED 는 언제까지 살아 있어야 하는가?" — 완료 통지를 받을
때까지. 요청이 끝난 시점이 아니다. 그래서 in-flight 자원은 작업 단위 객체(
SendOp)에 묶고, 그 객체가 세션의shared_ptr까지 함께 들고 있는 패턴이 정석이다. - "동기 완료를 재귀로 처리하면 왜 로컬에서만 죽는가?" — 루프백은 커널 송신 버퍼가 즉시 비워져
거의 모든 send 가 즉시 완료되기 때문이다. 원격 클라는
Pending이 섞여 스택이 풀린다. C++ 에서는 예외가 아니라 가드 페이지 SIGSEGV 라 복구 여지도 없다. - "펌프 플래그를
std::atomic<bool>로 바꾸면 충분한가?" — 부족하다. 이중 펌프는 막아도 lost wakeup 은 남는다. 큐 상태와 플래그를 같은 임계 구역에서 함께 갱신해야 한다. "큐가 비지 않았다면 펌프가 돌고 있다"는 하나의 불변식이므로 하나의 락이 지켜야 한다.