← System Programming DUJINLABS.COM

Socket · Linux userspace / kernel ABI

TCP framing과 backpressure

TCP를 message queue로 오해하지 않고 byte stream 위 length-prefix parser와 partial send queue를 만드는 방법을 설명합니다.

Series
32 / 37
Build
cc -std=c17 -Wall -Wextra -O2 frame_codec.c -o frame_codec
Run
./frame_codec
Kernel
Linux 6.18.37 LTS

send 한 번과 recv 한 번은 같은 message 경계를 유지하는가?

TCP는 순서가 보장된 byte stream이다. sender의 두 send가 receiver의 한 recv로 합쳐지거나 한 send가 여러 recv로 나뉠 수 있다. application이 fixed length, delimiter, length prefix 같은 framing을 직접 정의해야 한다.

outbound data가 send buffer보다 빨리 쌓이면 무제한 queue는 memory exhaustion을 만든다. connection별 high-water mark에서 producer를 멈추거나 요청을 거부하고, EPOLLOUT 때 남은 byte를 이어 보낸다.

구조 그림

그림 1. 두 번의 send와 세 번의 recv 사이에서 유지되는 frame 경계
sender calls
len=5alphalen=4beta
TCP byte stream
00000005alpha00000004beta
recv chunks
recv #1: 3Brecv #2: 9Brecv #3: 5B
parser output
frame: alphaframe: beta

TCP는 byte 순서만 보존한다. receiver parser가 length header와 payload byte 수를 누적해 application frame을 복원한다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
message length + payload encode
send queue offset 보존
TCP stream 임의 segment
receive buffer 누적 parse
complete frame length 충족 시 전달

message 상태는 application buffer에 있고 TCP kernel queue에는 byte만 있다. parser가 header와 payload를 얼마나 확보했는지 connection object에 남긴다.

그림 3. 커널 내부에서 지나가는 주요 지점
tcp_sendmsg sk_write_queue
segmentation MSS/TSO
ACK/window 전송 가능량 갱신
tcp_recvmsg receive queue copy
poll wake space/data readiness

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
net/ipv4/tcp.c tcp_sendmsg(), tcp_recvmsg() stream send/receive queue와 partial progress
net/ipv4/tcp_output.c tcp_write_xmit() congestion/window 조건에 따른 segment 전송
net/ipv4/tcp_input.c tcp_data_queue() 수신 byte를 socket receive queue에 연결

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 frame_codec.c -o frame_codec
01#define _GNU_SOURCE
02#include <arpa/inet.h>
03#include <stdint.h>
04#include <stdio.h>
05#include <string.h>
06#include <sys/socket.h>
07#include <unistd.h>
08
09static int send_frame(int fd, const void *data, uint32_t length)
10{
11    uint32_t header = htonl(length);
12    unsigned char frame[4 + 256];
13    if (length > 256)
14        return -1;
15    memcpy(frame, &header, sizeof(header));
16    memcpy(frame + sizeof(header), data, length);
17    size_t total = 0, size = sizeof(header) + length;
18    while (total < size) {
19        ssize_t n = send(fd, frame + total, size - total, MSG_NOSIGNAL);
20        if (n <= 0)
21            return -1;
22        total += (size_t)n;
23    }
24    return 0;
25}
26
27int main(void)
28{
29    int pair[2];
30    if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair) < 0)
31        return 1;
32    send_frame(pair[0], "alpha", 5);
33    send_frame(pair[0], "beta", 4);
34    unsigned char bytes[64];
35    ssize_t n = recv(pair[1], bytes, sizeof(bytes), 0);
36    printf("one recv returned %zd bytes\n", n);
37    close(pair[0]); close(pair[1]);
38    return n < 0;
39}

코드 조각별 설명

실제 코드 11행uint32_t header = htonl

wire format length를 고정 32-bit network byte order로 만든다. size_t 같은 host-dependent type을 그대로 보내지 않는다.

실제 코드 13행if (length > 256)

frame allocation과 parser resource를 length field만 믿고 무제한 확장하지 않도록 protocol 상한을 적용한다.

실제 코드 18행while (total < size)

send가 frame 일부만 받아도 offset을 보존해 나머지를 이어 보낸다. nonblocking fd에서는 EAGAIN 때 queue state를 남기고 반환한다.

실제 코드 19행MSG_NOSIGNAL

peer가 닫힌 socket에서 process 전체 SIGPIPE 종료를 피하고 EPIPE를 반환값으로 처리한다.

실제 코드 35행recv(pair[1]

두 frame을 보냈지만 recv가 한 frame, 두 frame, 일부 header 등 어떤 byte 수로도 반환할 수 있다. parser가 buffer를 누적해야 한다.

세부 동작

01

parser는 incremental state machine이다

먼저 4-byte header가 모일 때까지 기다리고 length를 검증한 뒤 payload length만큼 모이면 frame 하나를 내보낸다. buffer에 다음 frame byte가 남으면 같은 read event에서 계속 parsing한다.

malformed length, integer overflow, allocation 실패에서 connection을 닫는 정책을 명확히 한다.

02

backpressure를 상위 producer까지 전달한다

send queue가 high-water mark를 넘으면 socket EPOLLOUT만 기다리는 것으로 끝나지 않는다. 파일 읽기, upstream RPC, message 생성도 중단해야 memory가 bounded된다.

low-water mark 아래로 줄면 producer interest를 다시 켠다.

03

TCP ACK는 application receipt가 아니다

send 성공과 TCP ACK는 peer kernel이 byte를 받았다는 범위의 의미다. peer application이 frame을 parse하고 durable store에 기록했다는 보장이 필요하면 application acknowledgement를 정의한다.

retry 시 request id와 idempotency 규칙이 필요하다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
outbound frameencode에서 생기고 모든 byte send 또는 connection abort까지 유지된다buffer, total, offset
receive parserconnection 수명 동안 partial header/payload를 보관한다buffered bytes, expected length
TCP send queuesend syscall이 byte를 받아 ACK/abort까지 kernel에 유지한다wmem, unacked, window

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
message가 합쳐짐/잘림TCP에 message boundary 기대incremental parser 상태
memory 계속 증가outbound queue 무제한connection별 queued bytes/high-water
SIGPIPE 종료closed peer에 sendMSG_NOSIGNAL 또는 signal policy

직접 확인

  1. recv buffer를 1~7 byte로 바꿔 두 frame을 항상 정확히 복원하는 parser를 작성한다.
  2. socket send buffer를 작게 하고 receiver를 지연시켜 partial send/EAGAIN과 queue high-water를 관찰한다.
  3. frame length를 최대값보다 크게 보내는 악성 peer test로 allocation 전 거부를 검증한다.
실행./frame_codec
추적strace -e trace=socketpair,sendto,recvfrom,close ./frame_codec

원문