← System Programming DUJINLABS.COM

Socket · Linux userspace / kernel ABI

socket, bind, listen, accept 수명

listening socket과 accepted socket을 다른 kernel 객체로 보고 bind address, 두 queue, close와 half-close를 구분합니다.

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

accept가 반환한 fd는 listening fd와 어떤 상태를 공유하는가?

listen fd는 local endpoint와 pending connection queue를 소유하고 새 연결을 받는 역할만 한다. accept는 연결 하나의 새 struct socket/file/fd를 만들어 반환하며 이후 byte stream 상태는 accepted fd에 있다.

listener를 close해도 이미 accepted된 connection은 유지된다. 반대로 accepted fd를 close해도 listener와 다른 connection에는 영향을 주지 않는다. fd ownership을 종류별로 나눠야 shutdown이 단순해진다.

구조 그림

그림 1. listening socket이 가진 두 queue와 accepted socket
SYN request queue
10.0.0.2 SYN-RECV10.0.0.3 SYN-RECVfree slot
accept queue
conn A ESTABLISHEDconn B ESTABLISHEDbacklog slot
listener
local :8080TCP_LISTENaccept wait queue
accepted fd table
fd 6 → conn Afd 7 → conn Blistener fd 3 별도

handshake 진행 중 request와 handshake 완료 후 accept 대기 connection은 다른 queue에 있다. accept는 별도 connected socket fd를 만든다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
socket unbound endpoint
bind local address
listen passive + backlog
accept4 connected fd 생성
shutdown/close connection별 종료

SYN 처리 queue와 accept 완료 queue, listening socket과 connected socket을 각각 다른 칸으로 그린다.

그림 3. 커널 내부에서 지나가는 주요 지점
__sys_socket socket/file/fd
inet_bind port hash
inet_listen TCP_LISTEN
inet_csk_accept accept queue
sock_alloc_file new fd install

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

Linux 6.18.37 LTS 소스 위치

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

파일함수·구조체여기서 볼 것
net/socket.c __sys_socket(), do_accept() socket 객체를 file/fd로 노출하고 accepted fd 설치
net/ipv4/af_inet.c inet_bind(), inet_listen() IPv4 local address와 passive state 설정
net/ipv4/inet_connection_sock.c inet_csk_accept() accept queue에서 completed child socket 분리

실행 예제 원본

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

빌드cc -std=c17 -Wall -Wextra -O2 tcp_server.c -o tcp_server
01#define _GNU_SOURCE
02#include <arpa/inet.h>
03#include <netinet/in.h>
04#include <stdio.h>
05#include <stdlib.h>
06#include <sys/socket.h>
07#include <unistd.h>
08
09int main(int argc, char **argv)
10{
11    if (argc != 2)
12        return 2;
13    int listener = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
14    int one = 1;
15    setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
16    struct sockaddr_in address = {
17        .sin_family = AF_INET,
18        .sin_port = htons((unsigned short)strtoul(argv[1], NULL, 10)),
19        .sin_addr = { .s_addr = htonl(INADDR_LOOPBACK) }
20    };
21    if (listener < 0 || bind(listener, (struct sockaddr *)&address, sizeof(address)) < 0 ||
22        listen(listener, 128) < 0)
23        return 1;
24
25    int client = accept4(listener, NULL, NULL, SOCK_CLOEXEC);
26    if (client < 0)
27        return 1;
28    char buffer[1024];
29    ssize_t n = read(client, buffer, sizeof(buffer));
30    if (n > 0)
31        write(client, buffer, (size_t)n);
32    shutdown(client, SHUT_WR);
33    close(client);
34    close(listener);
35    return n < 0;
36}

코드 조각별 설명

실제 코드 13행SOCK_STREAM | SOCK_CLOEXEC

stream socket file을 만들 때 exec 상속을 막는다. nonblocking server라면 SOCK_NONBLOCK도 생성/accept 단계에서 원자적으로 적용한다.

실제 코드 15행SO_REUSEADDR

bind 전에 local address reuse 정책을 설정한다. SO_REUSEPORT와 목적이 다르며 여러 listener 분산은 별도다.

실제 코드 19행htonl(INADDR_LOOPBACK)

외부 interface가 아니라 loopback에만 bind해 예제 노출 범위를 제한한다.

실제 코드 22행listen(listener, 128)

passive state로 바꾸고 completed connection queue 상한의 힌트를 준다. 실제 상한은 kernel 설정과 protocol 상태에 영향을 받는다.

실제 코드 25행accept4(listener

listener에서 연결 하나를 꺼내 별도 fd를 만든다. listener file status flag가 새 fd에 그대로 상속된다고 가정하지 않는다.

세부 동작

01

backlog 하나로 queue 전체를 설명할 수 없다

TCP는 handshake 진행 중 request queue와 handshake가 끝나 accept를 기다리는 queue를 구분한다. syncookies, tcp_max_syn_backlog, somaxconn, application accept 속도가 서로 다른 병목을 만든다.

ss -lnt와 kernel counter로 현재 queue를 확인한다.

02

accept도 반복 가능한 실패가 있다

nonblocking listener는 queue가 비면 EAGAIN을 반환한다. signal이면 EINTR, network pending error가 새 socket에 나타날 수 있다. edge-triggered listener는 EAGAIN까지 accept loop를 돌린다.

EMFILE 상황에서는 listener가 readable 상태로 계속 남아 busy loop가 될 수 있어 reserve fd 같은 복구 전략을 둔다.

03

close와 shutdown은 다르다

shutdown(SHUT_WR)은 FIN을 보내 더 이상 쓰지 않되 peer data를 계속 읽는 half-close를 만든다. close는 fd 참조를 놓고 마지막 socket reference 정책에 따라 protocol 종료가 진행된다.

SO_LINGER는 close blocking/RST 의미를 바꿀 수 있으므로 default를 모른 채 설정하지 않는다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
listening socketsocket-bind-listen에서 준비되고 listener close에서 passive endpoint가 끝난다local addr, state, accept queue
accepted socketaccept에서 생기고 connection close/timewait protocol을 거친다4-tuple, send/receive queue
socket fdsock_alloc_file/fd_install에서 생기고 close에서 file ref를 놓는다CLOEXEC, NONBLOCK, owner

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

겉으로 보이는 현상실제 원인 후보확인 방법
connection은 오는데 accept 없음fd limit 또는 event loop stallss queue, EMFILE log, loop latency
재시작 bind EADDRINUSE다른 listener 또는 address policyss -lptn, reuse option, namespace
client 마지막 data 손실HUP/close에서 receive drain 누락read 0 전 buffer 처리

직접 확인

  1. nc 127.0.0.1 8080으로 연결해 echo와 half-close를 확인한다.
  2. 여러 client를 listener가 accept하기 전에 연결해 ss -lnt의 Recv-Q 변화를 본다.
  3. accept4에 SOCK_NONBLOCK을 추가하고 epoll edge-triggered accept loop를 구현한다.
실행./tcp_server 8080
추적strace -e trace=socket,setsockopt,bind,listen,accept4,read,write,shutdown,close ./tcp_server 8080

원문