← System Programming DUJINLABS.COM

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

dup3, pipe, 표준 입출력 재배치

shell의 cmd1 | cmd2가 pipe buffer와 fd table을 어떻게 연결하는지, EOF를 위해 어느 끝을 닫아야 하는지 확인합니다.

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

pipe writer가 끝났는데 reader가 EOF를 받지 못하는 이유는 무엇인가?

pipe EOF는 특정 writer process가 종료될 때가 아니라 모든 write-end file reference가 닫힐 때 발생한다. parent가 사용하지 않는 write end를 하나 보유한 채 wait하면 reader는 EOF를 기다리고 parent는 reader 종료를 기다리는 교착이 생긴다.

dup2/dup3는 target fd 번호가 열려 있으면 닫고 source open file description을 그 slot에 연결한다. redirection 뒤 원래 pipe fd도 닫아 reference 개수를 정확히 줄여야 한다.

구조 그림

그림 1. pipeline 안에서 복제되는 pipe end 참조

Writer process

  • fd 1 → pipe write
  • 원래 pipefd[1] close
  • read end close

Pipe object

  • ring buffer
  • readers=1
  • writers=1

Reader process

  • fd 0 → pipe read
  • 원래 pipefd[0] close
  • write end close

Parent

  • read end close
  • write end close
  • 두 child wait

EOF는 write end를 가진 모든 fd가 닫혀야 발생한다. parent에 남은 write end 하나도 reader의 EOF를 막는다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
pipe2 read/write fd 생성
fork writer stdout에 dup3
fork reader stdin에 dup3
parent close 양 끝 참조 해제
EOF/wait pipeline 종료

각 process가 가진 fd 번호를 행으로, 같은 pipe object를 열로 그리면 EOF가 오지 않는 원인을 찾기 쉽다. 닫아야 할 fd는 작업에 쓰지 않는 모든 복제본이다.

그림 3. 커널 내부에서 지나가는 주요 지점
do_pipe2 pipe_inode_info
copy_files fork fd table
do_dup2 target slot 교체
pipe_write/read ring buffer
fput last writer 검사

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

Linux 6.18.37 LTS 소스 위치

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

파일함수·구조체여기서 볼 것
fs/pipe.c do_pipe2(), pipe_read(), pipe_write() pipe object, buffer, reader/writer count
fs/file.c do_dup2(), replace_fd() target fd slot을 원자적으로 교체
kernel/fork.c copy_files() fork 때 fd table 공유 또는 복제

실행 예제 원본

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

빌드cc -std=c17 -Wall -Wextra -O2 pipeline.c -o pipeline
01#define _GNU_SOURCE
02#include <fcntl.h>
03#include <stdio.h>
04#include <stdlib.h>
05#include <sys/wait.h>
06#include <unistd.h>
07
08int main(void)
09{
10    int pipefd[2];
11    if (pipe2(pipefd, O_CLOEXEC) < 0)
12        return 1;
13
14    pid_t writer = fork();
15    if (writer == 0) {
16        if (dup2(pipefd[1], STDOUT_FILENO) < 0)
17            _exit(127);
18        close(pipefd[0]);
19        close(pipefd[1]);
20        execlp("printf", "printf", "alpha\nbeta\n", NULL);
21        _exit(127);
22    }
23
24    pid_t reader = fork();
25    if (reader == 0) {
26        if (dup2(pipefd[0], STDIN_FILENO) < 0)
27            _exit(127);
28        close(pipefd[0]);
29        close(pipefd[1]);
30        execlp("wc", "wc", "-l", NULL);
31        _exit(127);
32    }
33
34    close(pipefd[0]);
35    close(pipefd[1]);
36    int status;
37    waitpid(writer, &status, 0);
38    waitpid(reader, &status, 0);
39    return 0;
40}

코드 조각별 설명

실제 코드 11행pipe2(pipefd, O_CLOEXEC)

두 fd를 만들 때부터 close-on-exec를 설정한다. 필요한 표준 fd로 dup2한 복제본은 기존 STDIN/STDOUT slot 속성을 따로 확인한다.

실제 코드 16행dup2(pipefd[1]

writer의 fd 1이 pipe write end와 같은 open file description을 가리키게 한다. fd 1이 열려 있었다면 교체가 원자적으로 일어난다.

실제 코드 19행close(pipefd[1]);

dup2 뒤 원래 번호는 더 이상 필요 없다. 닫지 않으면 exec 대상에도 reference가 남거나 EOF 시점을 늦출 수 있다.

실제 코드 30행execlp("wc"

reader image를 wc로 교체하지만 fd 0 연결은 CLOEXEC가 없는 표준 slot로 남아 pipeline 입력이 된다.

실제 코드 18행close(pipefd[0]);

parent도 양 끝을 모두 닫아야 한다. child만 닫아서는 pipe object의 reader/writer count가 0이 되지 않는다.

세부 동작

01

pipe는 byte stream이다

message boundary를 보존하지 않는다. 여러 writer의 작은 write는 PIPE_BUF 이하에서 interleave되지 않는 보장이 있지만 reader가 같은 크기로 읽어 주는 보장은 없다.

protocol은 delimiter나 length prefix를 직접 정의해야 한다.

02

dup 뒤에는 open description이 공유된다

fd 번호는 달라도 file status flag와 offset은 source와 target이 같은 struct file을 통해 공유한다. FD_CLOEXEC는 fd slot별 속성이므로 dup2 결과에는 source의 FD_CLOEXEC가 그대로 복사되지 않는다.

새 코드에서는 dup3와 O_CLOEXEC로 의도를 명시할 수 있지만 oldfd와 newfd가 같으면 EINVAL이라는 차이가 있다.

03

pipeline exit status 정책은 shell이 정한다

writer와 reader는 독립적으로 종료한다. parent가 마지막 command만 반환할지 pipefail처럼 하나라도 실패하면 실패로 할지 정해야 한다.

SIGPIPE로 끝난 writer를 무조건 service 장애로 볼지 downstream 정상 종료로 볼지도 pipeline 의미에 달려 있다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
pipe_inode_infopipe2에서 생기고 모든 read/write file이 닫힐 때 해제된다readers, writers, ring head/tail
fd copyfork/dup에서 늘고 각 close/exec에서 줄어든다어느 process가 어느 end를 보유하는가
pipe bufferwrite가 채우고 read가 비우며 capacity에 따라 sleep/EAGAINbytes, slots, wakeup

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

겉으로 보이는 현상실제 원인 후보확인 방법
reader가 EOF를 못 받음어딘가 write end reference가 남음/proc/*/fd와 lsof 확인
writer가 SIGPIPE 종료모든 read end가 닫힘wait status와 signal 확인
pipeline이 가끔 hangfork 실패/close 누락/error path 비대칭strace -ff fd 수명 비교

직접 확인

  1. parent의 close(pipefd[1])를 주석 처리해 wc가 EOF를 받지 못하는 현상을 확인한 뒤 되돌린다.
  2. writer를 큰 데이터를 쓰는 코드로 바꾸고 reader를 지연시켜 pipe capacity backpressure를 관찰한다.
  3. /proc/PID/fd 링크를 각 fork 지점에서 출력해 같은 pipe inode를 가리키는 fd 복제본을 표로 만든다.
실행./pipeline
추적strace -f -e trace=pipe2,clone,dup2,close,read,write,wait4 ./pipeline

원문