46. 세션 송신 펌프의 동시 진입 (같은 소켓 중첩 SendAsync·프레임 오염, C#)
난이도 상해설 — 세션 송신 펌프의 동시 진입 (같은 소켓 중첩 SendAsync·프레임 오염, C#)
난이도: 상
요약
_sending 플래그의 검사(A)와 설정(B)이 원자적이지 않다. 두 스레드가 동시에 Enqueue → TrySend에 진입하면 둘 다 _sending==false를 관측하고 각자 BeginSend를 호출해 같은 소켓에 두 개의 SendAsync가 동시에 진행된다. TCP는 바이트 스트림이므로 두 비동기 쓰기의 바이트가 인터리빙되어 수신 측 프레임 경계가 깨진다(길이 프리픽스가 어긋나 이후 전체 파싱이 붕괴). Enqueue와 완료 콜백 OnSendCompleted가 겹쳐도 동일하다.
문제점
- 분류태그: check-then-act 데이터 레이스 / 비직렬화 소켓 쓰기(overlapping writes)
- 증상: 부하가 높을 때 간헐적으로 클라이언트가 "깨진 패킷"을 받고 연결이 끊긴다. 재현이 어렵고 로그엔 정상으로 남는다.
- 재현조건: 서로 다른 스레드의 동시
Enqueue, 또는Enqueue와OnSendCompleted의 동시 실행 — 둘 다_sending==false창을 관측. - 근본원인:
_sending을 락 없이 읽고 쓴다(플래그 자체도 비휘발성이라 가시성 문제까지). 송신 시작 결정이 원자적이지 않아 "동시에 하나만"이라는 불변식이 깨진다.
수정안
송신 시작/완료 처리를 세션 단위 락으로 직렬화한다. 완료 콜백은 락 밖에서 다음 시작만 트리거하도록 구조화한다.
private readonly object _sendGate = new object();
public void Enqueue(byte[] packet)
{
_sendQueue.Enqueue(packet);
lock (_sendGate)
{
if (_sending) return;
_sending = true;
}
BeginDrain();
}
private void OnSendCompleted(object? s, SocketAsyncEventArgs a)
{
lock (_sendGate)
{
if (_sendQueue.IsEmpty) { _sending = false; return; }
}
BeginDrain(); // 아직 보낼 게 있으면 _sending=true 유지한 채 계속
}
핵심은 _sending 검사~설정을 원자화해 동시에 오직 하나의 송신만 시작되도록 보장하는 것이다. 여러 패킷을 한 번의 시스템콜로 묶는 gather write(SendAsync에 버퍼 리스트)도 함께 쓰면 효율이 오른다.
더 나은 설계
- **세션당 단일 실행 컨텍스트(직렬 큐/스트랜드)**로 모든 송신 관련 콜백을 처리하면 락 없이도 "동시에 하나"가 구조적으로 보장된다(예:
System.Threading.Channels로 세션당 송신 루프 하나). 트레이드오프: 세션마다 루프/채널 관리. - 송신 큐에 백프레셔(상한)를 두어 폭주 시 무한 적재를 막는다.
면접 포인트
- (연계) problem41은 비동기 송신의 동기 완료 경로(SendAsync가 즉시 완료 반환 시 재귀·스택)를 다룬다. 본 문제는 그와 구별되는 송신 시작 플래그의 비원자성으로 인한 중첩 송신 자체에 초점을 둔다.
- TCP에서 같은 소켓에 겹치는 쓰기가 왜 프레임을 깨는가(스트림 인터리빙, 길이 프리픽스 붕괴).
- "boolean 하나 CAS로 바꾸면 되지 않나?" —
Interlocked.CompareExchange로 0↔1 전이를 원자화하는 접근도 가능하지만, 완료 콜백과 enqueue의 상호작용(큐 재확인)을 함께 설계해야 유실/중복 시작이 없다.
해설 — 세션 송신 펌프의 동시 진입 (같은 소켓 중첩 비동기 쓰기, C++)
난이도: 상
요약
sending_ 플래그의 검사(A)·설정(B), 그리고 send_queue_ 접근이 모두 동기화 없이 이뤄진다. 두 워커가 동시에 Enqueue → TrySend에 들어가면 둘 다 sending_==false를 보고 각자 DoSend(비동기 소켓 쓰기)를 시작해 같은 소켓에 쓰기가 중첩된다. TCP 스트림에서 두 쓰기의 바이트가 인터리빙되어 프레임이 깨진다. 또한 std::deque에 대한 동기화 없는 동시 접근은 자료구조 손상 + UB다.
문제점
- 분류태그: check-then-act 데이터 레이스 / 소켓 쓰기 비직렬화 / 컨테이너 동시 접근 UB
- 증상: 고부하에서 간헐적 프레임 붕괴로 클라 연결 끊김, 드물게
deque손상으로 크래시. - 재현조건: 동시
Enqueue, 또는Enqueue와 다른 스레드의OnSendComplete겹침. - 근본원인: 송신 시작 결정과 큐 조작이 원자적이지 않다. "동시에 하나의 쓰기"라는 불변식을 지킬 상호배제가 없다.
수정안
세션 단위 뮤텍스로 큐 조작과 sending_ 전이를 보호하고, 실제 소켓 쓰기는 락을 놓고 수행한다.
#include <mutex>
class Session {
std::mutex mtx_;
std::deque<Packet> send_queue_;
bool sending_ = false;
// ...
public:
void Enqueue(Packet p) {
bool start = false;
{
std::lock_guard<std::mutex> lk(mtx_);
send_queue_.push_back(std::move(p));
if (!sending_) { sending_ = true; start = true; }
}
if (start) BeginDrain();
}
void OnSendComplete() {
bool more = false;
{
std::lock_guard<std::mutex> lk(mtx_);
if (send_queue_.empty()) sending_ = false;
else more = true; // sending_ 유지
}
if (more) BeginDrain();
}
};
더 나은 설계
- **
strand(직렬 실행 큐)**로 세션의 모든 송신 콜백을 순차화하면(asiostrand, IOCP라면 세션당 송신 워커 하나) 뮤텍스 없이 "동시에 하나"가 보장된다. 트레이드오프: 디스패치 비용. - 여러 패킷을 scatter/gather(
WSASend에 다중 버퍼)로 한 번에 보내 시스템콜과 락 획득 횟수를 줄인다. - 송신 큐에 상한(백프레셔)을 둬 폭주 시 메모리 폭증을 막는다.
면접 포인트
- 왜 같은 소켓에 겹친 비동기 쓰기가 위험한가(스트림 인터리빙), 그리고 IOCP/asio에서 이를 막는 표준 관용구(스트랜드/세션당 단일 송신 in-flight).
- 락 보유 중 blocking I/O를 피하고, 실제 전송은 락 밖에서 수행하는 이유(락 호위·지연).