QUESTION
write(fd, buf, len)가 성공하면 len byte가 모두 기록됐는가?
성공 반환값은 요청 길이가 아니라 실제 처리 byte 수다. regular file은 대부분 크게 진행되지만 pipe, terminal, socket, resource limit, signal 조건에서는 짧은 반환이 정상이다.
read의 0은 EOF이고 nonblocking fd의 -1/EAGAIN은 지금 당장 데이터가 없다는 뜻이다. 둘을 같은 '아무 데이터 없음'으로 처리하면 연결 종료와 일시적인 backpressure를 혼동한다.
STRUCTURE
구조 그림
write가 6을 반환하면 앞 6 byte만 kernel이 소비했다. 다음 호출은 buffer+6에서 remaining 10 byte를 넘겨야 한다.
CALL PATH
호출 흐름
loop는 syscall을 반복하는 코드가 아니라 byte ownership을 갱신하는 코드다. pointer와 remaining length를 실제 반환값만큼만 움직인다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
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이 명확한 예 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 copy_loop.c -o copy_loop01#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}
CODE NOTES
코드 조각별 설명
size_t total = 0아직 쓰지 않은 구간의 시작을 추적한다. ssize_t 반환값을 양수인지 확인한 뒤 size_t에 더한다.
buffer + total재시도할 때 처음부터 보내지 않고 미처리 byte부터 시작한다. 중복 데이터 방지에 직접 연결된다.
length - totalpointer 이동과 길이 감소를 같은 total 값에서 계산해 buffer 끝을 넘지 않는다.
else if (n == 0)stdin writer가 모두 닫힌 EOF다. EAGAIN과 달리 poll로 기다려도 같은 stream에서 새 byte가 오지 않는다.
errno != EINTRread가 signal 전에 진행되지 않은 경우만 재시도한다. nonblocking 입력을 지원하려면 EAGAIN에서 poll/epoll로 돌아가는 별도 분기가 필요하다.
DETAILS
세부 동작
blocking은 전체 완료를 뜻하지 않는다
blocking flag는 operation이 당장 진행할 수 없을 때 잠들 수 있다는 뜻이지, 요청 길이 전체를 원자적으로 완료한다는 뜻이 아니다. signal과 대상별 최대 transfer, 현재 capacity가 반환 길이를 줄인다.
regular file write도 RLIMIT_FSIZE나 storage error 앞에서 일부 byte 뒤 오류가 날 수 있다.
EAGAIN에서는 busy loop를 만들지 않는다
O_NONBLOCK fd가 준비되지 않았을 때 같은 syscall을 즉시 반복하면 CPU를 소모한다. epoll/poll에 관심 event를 등록하고 readiness가 왔을 때 남은 구간을 다시 시도한다.
readiness는 다음 syscall이 절대 block하지 않는다는 영구 보장이 아니라 현재 관찰된 상태다.
내구성은 write 성공과 별개다
write 반환은 kernel이 데이터를 받아들였다는 뜻이며 storage media에 영구 기록됐다는 뜻이 아니다. fsync/fdatasync, directory fsync, rename protocol을 durability 요구에 맞게 배치한다.
network send 성공도 peer application이 읽었다는 acknowledgement가 아니다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
user buffer | 호출자가 할당하고 syscall이 반환할 때까지 유효해야 한다 | base, length, processed |
iov_iter | kernel I/O 호출 동안 현재 segment와 남은 길이를 추적한다 | count, iov offset |
file position | positioned I/O가 아니면 struct file에 남아 다음 호출과 공유된다 | f_pos, append mode |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| 출력 중복 | 짧은 write 뒤 buffer 처음부터 재전송 | pointer/remaining log |
| EOF에서 CPU 100% | read 0을 재시도 | read 반환값과 poll HUP |
| nonblocking에서 CPU 100% | EAGAIN busy loop | strace 호출 빈도와 epoll 사용 |
LAB
직접 확인
- 큰 파일을 pipe로 복사하고 strace -e read,write에서 요청 길이와 반환 길이를 비교한다.
- stdout을 천천히 읽는 프로그램에 연결해 pipe backpressure와 write blocking을 관찰한다.
- stdout을 O_NONBLOCK으로 바꾸고 EAGAIN 때 poll을 쓰는 write_all 변형을 구현한다.
./copy_loop < input.bin > output.binstrace -e trace=read,write ./copy_loop < input.bin > output.binPRIMARY REFERENCES