← System Programming DUJINLABS.COM

File Descriptor / I/O · Linux userspace / kernel ABI

read/write 완료 loop

blocking fd에서도 한 번의 read/write가 요청 전체를 처리한다는 보장이 없는 이유와 EOF, EAGAIN, EINTR을 분리합니다.

Series
12 / 37
Build
cc -std=c17 -Wall -Wextra -O2 copy_loop.c -o copy_loop
Run
./copy_loop < input.bin > output.bin
Kernel
Linux 6.18.37 LTS

write(fd, buf, len)가 성공하면 len byte가 모두 기록됐는가?

성공 반환값은 요청 길이가 아니라 실제 처리 byte 수다. regular file은 대부분 크게 진행되지만 pipe, terminal, socket, resource limit, signal 조건에서는 짧은 반환이 정상이다.

read의 0은 EOF이고 nonblocking fd의 -1/EAGAIN은 지금 당장 데이터가 없다는 뜻이다. 둘을 같은 '아무 데이터 없음'으로 처리하면 연결 종료와 일시적인 backpressure를 혼동한다.

구조 그림

그림 1. 짧은 write 뒤 사용자 buffer의 소유 구간
요청 16 byte
0123456789101112131415
return n=6
처리처리처리처리처리처리남음남음남음남음남음남음남음남음남음남음
다음 호출
buffer + 6length - 6

write가 6을 반환하면 앞 6 byte만 kernel이 소비했다. 다음 호출은 buffer+6에서 remaining 10 byte를 넘겨야 한다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
buffer base + total
read/write remaining length
return n 진행량 분류
retry/wait EINTR/EAGAIN 정책
complete total == requested

loop는 syscall을 반복하는 코드가 아니라 byte ownership을 갱신하는 코드다. pointer와 remaining length를 실제 반환값만큼만 움직인다.

그림 3. 커널 내부에서 지나가는 주요 지점
vfs_write rw_verify_area
file op write_iter/read_iter
iov_iter remaining bytes
device/fs 실제 진행
return ssize_t count

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

Linux 6.18.37 LTS 소스 위치

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

파일함수·구조체여기서 볼 것
fs/read_write.c ksys_read(), ksys_write() fd lookup, file position, 반환 byte 처리
lib/iov_iter.c copy_to_iter(), copy_from_iter() scatter/gather buffer의 진행량 관리
fs/pipe.c pipe_read(), pipe_write() capacity, EOF, EAGAIN이 명확한 예

실행 예제 원본

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

빌드cc -std=c17 -Wall -Wextra -O2 copy_loop.c -o copy_loop
01#include <errno.h>
02#include <stdio.h>
03#include <unistd.h>
04
05static int write_all(int fd, const unsigned char *buffer, size_t length)
06{
07    size_t total = 0;
08    while (total < length) {
09        ssize_t n = write(fd, buffer + total, length - total);
10        if (n > 0) {
11            total += (size_t)n;
12            continue;
13        }
14        if (n < 0 && errno == EINTR)
15            continue;
16        return -1;
17    }
18    return 0;
19}
20
21int main(void)
22{
23    unsigned char buffer[16384];
24    for (;;) {
25        ssize_t n = read(STDIN_FILENO, buffer, sizeof(buffer));
26        if (n > 0) {
27            if (write_all(STDOUT_FILENO, buffer, (size_t)n) < 0)
28                return 1;
29        } else if (n == 0) {
30            return 0;
31        } else if (errno != EINTR) {
32            perror("read");
33            return 1;
34        }
35    }
36}

코드 조각별 설명

실제 코드 7행size_t total = 0

아직 쓰지 않은 구간의 시작을 추적한다. ssize_t 반환값을 양수인지 확인한 뒤 size_t에 더한다.

실제 코드 9행buffer + total

재시도할 때 처음부터 보내지 않고 미처리 byte부터 시작한다. 중복 데이터 방지에 직접 연결된다.

실제 코드 9행length - total

pointer 이동과 길이 감소를 같은 total 값에서 계산해 buffer 끝을 넘지 않는다.

실제 코드 29행else if (n == 0)

stdin writer가 모두 닫힌 EOF다. EAGAIN과 달리 poll로 기다려도 같은 stream에서 새 byte가 오지 않는다.

실제 코드 31행errno != EINTR

read가 signal 전에 진행되지 않은 경우만 재시도한다. nonblocking 입력을 지원하려면 EAGAIN에서 poll/epoll로 돌아가는 별도 분기가 필요하다.

세부 동작

01

blocking은 전체 완료를 뜻하지 않는다

blocking flag는 operation이 당장 진행할 수 없을 때 잠들 수 있다는 뜻이지, 요청 길이 전체를 원자적으로 완료한다는 뜻이 아니다. signal과 대상별 최대 transfer, 현재 capacity가 반환 길이를 줄인다.

regular file write도 RLIMIT_FSIZE나 storage error 앞에서 일부 byte 뒤 오류가 날 수 있다.

02

EAGAIN에서는 busy loop를 만들지 않는다

O_NONBLOCK fd가 준비되지 않았을 때 같은 syscall을 즉시 반복하면 CPU를 소모한다. epoll/poll에 관심 event를 등록하고 readiness가 왔을 때 남은 구간을 다시 시도한다.

readiness는 다음 syscall이 절대 block하지 않는다는 영구 보장이 아니라 현재 관찰된 상태다.

03

내구성은 write 성공과 별개다

write 반환은 kernel이 데이터를 받아들였다는 뜻이며 storage media에 영구 기록됐다는 뜻이 아니다. fsync/fdatasync, directory fsync, rename protocol을 durability 요구에 맞게 배치한다.

network send 성공도 peer application이 읽었다는 acknowledgement가 아니다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
user buffer호출자가 할당하고 syscall이 반환할 때까지 유효해야 한다base, length, processed
iov_iterkernel I/O 호출 동안 현재 segment와 남은 길이를 추적한다count, iov offset
file positionpositioned I/O가 아니면 struct file에 남아 다음 호출과 공유된다f_pos, append mode

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

겉으로 보이는 현상실제 원인 후보확인 방법
출력 중복짧은 write 뒤 buffer 처음부터 재전송pointer/remaining log
EOF에서 CPU 100%read 0을 재시도read 반환값과 poll HUP
nonblocking에서 CPU 100%EAGAIN busy loopstrace 호출 빈도와 epoll 사용

직접 확인

  1. 큰 파일을 pipe로 복사하고 strace -e read,write에서 요청 길이와 반환 길이를 비교한다.
  2. stdout을 천천히 읽는 프로그램에 연결해 pipe backpressure와 write blocking을 관찰한다.
  3. stdout을 O_NONBLOCK으로 바꾸고 EAGAIN 때 poll을 쓰는 write_all 변형을 구현한다.
실행./copy_loop < input.bin > output.bin
추적strace -e trace=read,write ./copy_loop < input.bin > output.bin

원문