30. 비동기 송신 버퍼의 조기 반납: in-flight 중 재사용 (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 비동기 송신 버퍼의 조기 반납: in-flight 중 재사용 (C#)
난이도: 중상
요약
BeginSend 로 비동기 송신을 걸어 놓고, 전송이 끝나기도 전에 그 버퍼를 ArrayPool 에 반납한다. 커널이 아직 이 버퍼를 읽어 소켓으로 내보내는 동안 같은 버퍼가 다른 Send 에 재대여·덮어써져, 전송 바이트가 손상되거나 다른 클라이언트에게 보낼 내용이 섞여 나간다.
문제점
- 분류 태그: 비동기 I/O 대상 버퍼의 수명 위반(in-flight 버퍼 조기 반납/재사용) · 공유 풀 버퍼 데이터 레이스 · 부분 송신 미처리(부수).
- 증상: 클라이언트가 깨진 프레임(다른 메시지 조각이 섞인 페이로드)을 받거나, A 에게 갈 데이터가 B 에게 새어 나감(정보 유출). 부하가 높을수록(비동기 완료가 지연될수록) 심해진다.
- 재현 조건:
- T1:
Send가 buf 를 대여·복사하고(A)BeginSend(buf …)시작. 커널은 아직 전송 중. - T1:
(B)곧바로Return(buf)→ 버퍼가 풀로 반납됨. - T2: 다른
Send가Rent()로 같은 buf 를 받아 자기 메시지를BlockCopy→ 전송 중이던 T1 의 바이트를 덮어씀. 커널은 덮어써진 내용을 전송 → 손상/유출.
- T1:
- 근본 원인:
- (A)
BeginSend는 즉시 반환하지만 전송은 완료 콜백 시점까지 계속 진행된다. 버퍼는 그때까지 살아 있고 변경되지 않아야 한다. - (B) 그런데 완료를 기다리지 않고 동기적으로
Return한다. 반납 = "이제 아무나 재사용해도 됨" 인데 실제로는 in-flight. 소유권이 in-flight 연산으로 넘어가지 않았다. (덤으로EndSend의 반환값(부분 송신)도 처리하지 않아 남은 바이트가 유실될 수 있다.)
- (A)
수정안
버퍼는 완료 콜백에서 반납한다(소유권을 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 개수만큼)" 규칙을 어기면 같은 함정.- 불변 스냅샷 전송: 브로드캐스트는 한 번 직렬화한 불변 버퍼(참조 카운트)를 여러 연결이 공유해 보내고, 마지막 완료 시 반납. 복사·할당을 줄이되 참조 카운팅 정확성이 관건.
면접 포인트
- 비동기 I/O 에서 버퍼 수명 = "요청 시작"이 아니라 "완료 콜백"까지 라는 원칙. 반납/재사용은 완료 이후에만.
- 같은 소켓에 대한 동시
BeginSend가 프레임을 교차시키는 문제와 연결별 송신 직렬화(single in-flight) 필요성. EndSend반환값(부분 송신) 처리와, 풀 버퍼의 소유권을 in-flight 연산으로 이양하는 패턴.
해설 · C++
해설 — 비동기 송신 버퍼의 조기 반납: 커널 DMA 중 use-after-free (C++)
난이도: 중상
요약
postSend 로 OS 비동기 송신을 걸어 놓고, 완료 전에 버퍼를 풀로 반납한다. 커널이 아직 이 버퍼(buf->data)를 읽어 NIC 로 내보내는 동안 같은 Buffer 가 다른 asyncSend 에 재대여·memcpy 로 덮어써져, 전송 바이트가 손상되거나 다른 연결로 갈 데이터가 새어 나가고, 반납 후 접근(use-after-free)이 발생한다.
문제점
- 분류 태그: 비동기 I/O 대상 버퍼 수명 위반(in-flight 조기 반납) · 반납 후 접근(use-after-free) · 공유 풀 버퍼 데이터 레이스.
- 증상: 손상된 프레임(다른 메시지가 섞인 페이로드), 타 클라이언트로의 정보 유출, 간헐적 크래시. 완료 콜백이 아직
buf를 만지는데 버퍼가 이미 재사용/재초기화되어 있음. - 재현 조건:
- T1:
asyncSend가buf를 대여·memcpy하고(A)postSend(buf->data …)로 커널에 전송 요청. 전송은 진행 중. - T1:
(B)곧바로pool_.release(buf). - T2: 다른
asyncSend가acquire()로 같은buf를 받아memcpy→ 전송 중이던 바이트를 덮어씀. 커널 DMA 는 덮어써진 메모리를 읽어 전송. 또한 완료 시onSent(buf)가 이미 남이 쓰는 버퍼를 만짐(UAF).
- T1:
- 근본 원인:
- (A)
postSend는 즉시 반환하지만 커널은 완료 콜백 시점까지 비동기로buf->data를 계속 읽는다(overlapped I/O / DMA). 버퍼는 그때까지 유효하고 불변이어야 한다. - (B) 완료를 기다리지 않고 동기적으로 반납한다. 소유권이 in-flight 연산으로 이양되지 않아, 반납된 버퍼가 즉시 재사용된다. C++ 에선 이것이 명백한 use-after-free / 데이터 레이스(UB).
- (A)
수정안
버퍼 수명을 in-flight 연산에 묶는다. 반납을 완료 콜백(onSent)에서 하고, 소유권을 shared_ptr 이나 완료 핸들러 캡처로 이양한다.
#include <memory>
#include <cstring>
class Connection {
BufferPool& pool_;
void postSend(uint8_t* buf, size_t len, std::function<void()> done);
public:
explicit Connection(BufferPool& p) : pool_(p) {}
void asyncSend(const uint8_t* msg, size_t len) {
Buffer* buf = pool_.acquire();
std::memcpy(buf->data, msg, len);
buf->len = len;
// 완료 콜백이 buf 를 소유. 전송이 끝난 뒤에만 반납한다.
postSend(buf->data, len, [this, buf]() {
onSent(buf);
pool_.release(buf); // 완료 이후 반납
});
// 여기서는 buf 를 만지지도 반납하지도 않는다.
}
void onSent(Buffer* buf) { (void)buf; /* 완료 처리 */ }
};
- 부분 송신:
postSend가 보낸 바이트 수를 알려주면sent < len일 때 남은 구간을 이어서 재요청(위 콜백에서 처리). - 송신 직렬화: 같은 연결에 여러 스레드가 동시에
postSend하면 프레임이 교차된다. 연결별 송신 큐를 두고 한 번에 하나만 in-flight, 완료 콜백에서 다음 프레임을 꺼내 보낸다. 이때 큐/플래그 접근은 연결 락 또는 strand 로 보호. - 더 안전하게는
Buffer를std::shared_ptr<Buffer>로 관리해 완료 핸들러가 참조를 쥐게 하면 수명이 자동 보장된다.
더 나은 설계 (트레이드오프)
- 연결별 송신 큐(single in-flight) + strand: 프레임 교차와 버퍼 수명을 함께 해결. 완료 시 다음 항목 전송. 약간의 지연 대신 정합성 확보.
shared_ptr<Buffer>소유권: 완료 핸들러가 참조를 캡처하면 "마지막 사용자" 가 자동으로 반납. 원자 refcount 비용이 있으나 수명 실수를 구조적으로 제거.- scatter-gather(writev/WSABuf 배열): 헤더+페이로드를 복사 없이 벡터 I/O 로 보내되, 모든 조각이 완료까지 살아 있어야 한다는 규칙은 동일.
면접 포인트
- overlapped/DMA I/O 에서 버퍼는 완료 통지 시점까지 유효·불변이어야 한다는 규칙(요청 시작 ≠ 사용 끝).
release/재사용을 완료 콜백으로 미루거나shared_ptr로 소유권을 이양해 UAF 를 막는 패턴.- 동일 연결 동시 송신의 프레임 교차 문제와 연결별 송신 직렬화(single in-flight), 부분 송신 재개.