Pipe

프롬프트 명령행 상에서 모듈 프로그래밍을 할 수 있습니다. 바로 파이프를 이용하는 것인데요. 가령 , 문자로 구분된 필드를 가진 inventory.txt 라는 파일을 읽어들여 3번째 필드를 선택해서 그중 첫 자가 c 로 시작하는 항목들만 뽑아내어 알파벳 순서로 정렬하여 프린트한다면 일반적인 프로그래밍 언어로도 그렇게 간단한 작업이 아닐 수 있습니다. 하지만 명령 행상에서 파이프를 이용한다면 다음과 같이 간단하게 처리할 수 있습니다.

cat inventory.txt | cut -d ',' -f 3 | grep '^c' | sort

먼저 필요한 명령들을 고르고, 적절한 옵션을 준 후에 파이프로 연결을 하면 프로그래밍을 한 것과 같이 훌륭하게 결과를 만들어 냅니다. 유닉스의 기본 철학을 보통 모듈화라고 합니다. 다시 말해서 "각기 독립적인 역할을 하는 프로그램을 만들어놓고 필요에 따라 선택해서 서로 조합하여 전체를 완성한다" 는 것은 바로 이 파이프라는 기능이 있기에 가능하다고 할 수 있습니다.

이것은 요즘 발전하고 있는 microservice 와도 비슷한 개념입니다. 예를 들어 grep 모듈을 개발하는 팀은 cut 이나 sort 모듈을 개발하는 팀과 독립적으로 작업할 수 있습니다.

파이프는 IPC (Inter Process Communication) 의 한 방법입니다. 위의 예제에서 파이프로 연결된 명령들은 각각 독립적인 주소공간을 갖는 프로세스입니다. 그러므로 기본적으로 어느 한 프로세스가 가지고 있는 정보를 다른 프로세스에서 알 수가 없습니다. 이때 프로세스 간 정보를 전달하기 위해서 IPC 방법을 사용하는데 shell 에서는 파일을 이용하거나 위에서처럼 파이프를 이용할 수 있습니다.

C Programming 을 하면 구조체를 이용해 프로그래머가 원하는 형태로 데이터을 받아 사용할 수 있지만 파이프를 통해 전달되는 데이터는 단순 byte stream 입니다 ( TCP socket 도 마찬가지 ). 전달되는 데이터는 NUL 문자를 포함해 어떤 값이던 ( 예를 들면 binary 데이터 ) 전달할 수 있습니다.

파이프의 stdin, stdout, stderr

아래 그림은 파이프로 연결된 명령들이 실행될때 표준입력 (stdin), 표준출력 (stdout) , 표준에러 (stderr) 의 관계를 보여줍니다. program1 의 표준출력은 파이프를 통해 program2 의 표준입력 으로 전달되고 마찬가지로 program2 의 표준출력은 program3 의 표준입력이 되며 마지막으로 program3 표준출력이 터미널로 표시됩니다. 표준에러 같은 경우는 모두 터미널로 연결되어 있는 것을 볼 수 있습니다. 그러므로 명령이 파이프로 연결돼 있더라도 stderr 로 출력을 하면 파이프로 전달되지 않고 바로 터미널로 표시됩니다.

파이프로 연결된 명령은 subshell 에서 실행된다.

아래는 프롬프트 상에서 { echo; sleep 10 ;} | { echo; sleep 10 ;} | { echo; sleep 10 ;} 명령을 실행했을 때의 프로세스 상태인데 파이프로 연결된 세 명령 모두 subshell 이 생성된 후에 그 아래에서 실행되는 것을 볼 수 있습니다. 그러므로 실행 중에 결과를 어떤 변수에 저장한다면 그 값은 파이프 실행이 종료되면 사라지게 됩니다.

다음과 같이 { } 또는 ( ) 명령 그룹이 파이프에 연결되어 실행될 경우는 동일하게 subshell 이 생성되어 실행되므로 실질적으로 아래 두 명령은 차이가 없습니다.

$ { date; date ;} | { date; date ;}      # 첫번째 { } 사용

$ ( date; date ) | ( date; date )        # 두번째 ( ) 사용

------------------------------------------------------------

# { ... } 괄호를 ( ... ) 로 변경해 실행해 보면 같은 결과를 볼 수 있습니다.
$ strace -qf -e %process sh -c '{ date; date ;} | { date; date ;}' |& grep -E 'clone|vfork'
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fc9dfa9a8d0) = 261786
[pid 261785] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fc9dfa9a8d0) = 261787
[pid 261786] vfork( <unfinished ...>
[pid 261787] vfork( <unfinished ...>
[pid 261787] <... vfork resumed>)       = 261789
[pid 261786] <... vfork resumed>)       = 261788

파이프 에서의 $BASHPID

위에서 파이프로 연결된 명령들은 subshell 에서 실행되는 것을 보이기 위해 { echo; sleep 10 ;} 두 개의 명령을 사용했는데요. 그런데 보통 파이프를 사용할 때는 한 개의 명령이 서로 파이프로 연결됩니다. 이와 같이 subshell 에서 실행될 명령이 하나만 존재할 경우에는 굳이 subshell 을 생성한 후에 거기서 또 해당 명령을 실행하지 않고 바로 명령을 실행합니다. 그리고 이때 $BASHPID 는 해당 명령의 PID 와 같게 됩니다.

다음을 보면 sleep 명령의 인수로 사용된 $BASHPID 값이 sleep 명령의 PID 와 같은 것을 알 수 있습니다.

# Terminal 1
$ tty
/dev/pts/14
$ sleep $BASHPID | sleep $BASHPID | sleep $BASHPID

# Terminal 2
$ ps f -t pts/14
  PID TTY      STAT   TIME COMMAND
 4655 pts/14   Ss     0:02 /bin/bash
27251 pts/14   S+     0:00  \_ sleep 27251   # PID 와 $BASHPID 값이 같다
27252 pts/14   S+     0:00  \_ sleep 27252
27253 pts/14   S+     0:00  \_ sleep 27253

파이프로 연결된 shell 의 FD

다음은 파이프로 연결된 shell 의 FD (File Descriptor) 상태입니다. 현재 shell pid 를 나타내는 $$ 변수는 subshell 에서도 동일한 값을 가지므로 subshell 에서의 FD 상태를 보기 위해서는 $BASHPID 변수를 이용해야 합니다. 첫 번째 명령은 $$ 변수를 사용했으므로 현재 프롬프트 shell 의 FD 상태에 해당하고 나머지는 파이프에 연결된 명령 순서 대로입니다. 첫 번째 명령은 stdout 이 파이프에 연결돼 있고, 두 번째 가운데에 위치한 명령은 stdin, stdout 둘 다 파이프에 연결되어 있습니다. 마지막 명령은 stdin 이 파이프가 연결돼 있는 것을 볼 수 있습니다.

개별적으로 명령을 실행하여 그림에는 파이프 번호가 각각 다르게 나오지만 실제 세 개의 명령이 파이프로 함께 실행될 때는 첫 번째 명령의 stdout 파이프 번호와 두 번째 명령의 stdin 파이프 번호가 같고, 두 번째 명령의 stdout 파이프 번호와 세 번째 명령의 stdin 파이프 번호가 같게 됩니다.

명령이 파이프에서 실행되는지 구분하기

다음은 grep 명령이 파이프에서 실행될 경우 옵션설정을 달리하여 실행하기 위한 함수입니다.

# 함수이름이 우선순위가 높으므로 원본명령 지정을 위해 command 명령을 사용해야 합니다.
# /dev/fd 는 /proc/self/fd 의 심볼릭 링크로 프로세스 자신을 나타냅니다.
# /dev/stdin  -> /proc/self/fd/0
# /dev/stdout -> /proc/self/fd/1
# /dev/stderr -> /proc/self/fd/2
# 이므로 /dev/fd/1 대신에 /dev/stdout 을 사용해도 됩니다.

function grep() { 
    # FD 1번 (stdout) 이 터미널에 연결되어 있을경우 '-n' 옵션으로 실행
    if [ -t 1 ]; then              
        command grep -n "$@"

    # stdout 이 (named, unnamed) pipe 에 연결되어 있을경우
    elif [ -p /dev/fd/1 ]; then   
        command grep "$@"

    # stdout 이 일반 파일에 연결되어 있을경우   
    elif [ -f /dev/fd/1 ]; then
        command grep ...      
    fi
}

export -f grep

파이프로 연결된 명령들은 순서대로 실행될까?

보통 파이프에 대해 설명할때 command1 | command2 가 있을경우 command1 의 실행결과가 command2 의 입력으로 들어간다고 말합니다. 그렇다면 command1 이 종료된 후에 command2 가 실행될것 같지만 실은 그렇지 않습니다. 다음을 한번 보시죠

$ ps | grep ".*"
PID TTY          TIME CMD
3773 pts/0    00:00:00 bash
3784 pts/0    00:00:00 ps
3785 pts/0    00:00:00 grep

만약에 ps 실행이 종료되고 그 결과가 grep 명령의 입력으로 들어간다면 최종 결과에는 grep 명령이 보이면 안되겠지만 ps 와 grep 명령이 동시에 나타나고 있습니다. 파이프로 연결된 프로그램들이 실행될때는 순서대로 실행되지 않고 모두 동시에 실행됩니다. 또한 command2 의 상태에 따라 command1 의 실행이 완료되기 전에 종료할 수도 있습니다.

다음과 같은 경우 grep 명령의 실행이 완료되기 전에 결과가 나오는 것을 볼 수 있습니다.

grep pattern very-large-file | tr a-z A-Z

다음과 같은 경우는 head 명령의 실행이 먼저 종료됨에 따라 grep 명령이 실행을 완료하기 전에 종료하게 됩니다.

grep pattern very-large-file | head -n 1

다음의 경우를 보면 t2.sh 에서 read 명령 실행으로 중지된 상태 지만 이미 t3.sh 을 거쳐 정보가 표시되고 있습니다.

--------- t1.sh ----------
#!/bin/bash

echo t1.sh

--------- t2.sh ----------
#!/bin/bash

echo t2.sh
cat             # t1.sh 에서 받은 정보를 전달하기 위한 cat
read -r var < /dev/tty
echo $var

--------- t3.sh ----------
#!/bin/bash

echo t3.sh
cat             # t2.sh 에서 받은 정보를 전달하기 위한 cat

############ 실행결과 ###########

# t2.sh 실행이 끝나지 않은 상태이지만 이미 t3.sh 을 거쳐 정보가 표시된다.
$ ./t1.sh | ./t2.sh | ./t3.sh
t3.sh  
t2.sh  
t1.sh  
       <---- t2.sh 에서 read 입력 대기 상태

time 명령으로 && 연산자와 | 파이프 비교

$ time { sleep 2 && sleep 3 && sleep 4 ;}      # && 연산자

real    0m9.010s       <--- 2 + 3 + 4 = 9 초
user    0m0.005s
sys     0m0.005s

$ time { sleep 2 | sleep 3 | sleep 4 ;}        # | 파이프

real    0m4.005s       <--- 최대값 4 초
user    0m0.010s
sys     0m0.001s

[ 결과적으로 cmd1 | cmd2 와 같이 명령이 실행될 경우 ]

  1. cmd1cmd2 는 동시에 병렬로 실행된다.

  2. cmd1cmd2 보다 빠르면 파이프에 write 은 블록되고 더이상 진행되지 않는다.

  3. cmd2cmd1 보다 빠르면 파이프 로부터의 read 는 블록된다.

  4. cmd1 이 먼저 종료하면 파이프는 close 되고 cmd2 는 End-Of-File 로 인식해 종료한다.

  5. cmd2 가 먼저 종료하면 파이프는 close 되고 cmd1 은 다음번 write 에 SIGPIPE 신호를 받게되고 종료된다.

파이프로 연결된 명령들의 종료 상태 값은?

파이프로 연결된 명령들 간에는 $? 변수로 이전 명령의 종료 상태 값을 확인할 수 없습니다. 그리고 파이프 실행이 종료됐을 때의 종료 상태 값은 마지막 명령의 종료 상태 값이 사용됩니다. 그래서 중간에 false 로 종료된 명령이 있더라도 마지막 명령이 true 로 종료되면 파이프 종료 상태 값은 true 가 됩니다. set -o pipefail 옵션을 설정하면 중간에 false 로 종료된 명령이 있을 경우 파이프 종료 상태 값은 false 가 됩니다. shell 변수로 PIPESTATUS 라는 array 변수가 있는데 이변수는 파이프로 연결된 모든 명령들의 종료 상태 값을 담고 있습니다.

파이프로 연결된 명령들은 process group 을 형성한다.

파이프를 이용해 여러 명령들을 동시에 실행시키면 process group 이 만들어지는데 이때 파이프로 연결된 명령들 중에서 첫 번째 명령의 PID 가 Process Group ID (PGID) 가 됩니다. 이후 jopspec 을 이용해 job control 을 하게 되면 동일한 process group 에 속한 명령들이 모두 같이 적용을 받게 됩니다.

다음은 프롬프트 상에서 $ cat | grep hello | wc -l 명령을 실행한 예입니다

파이프에서 복수의 명령 사용하기

파이프로 명령을 연결할때 꼭 하나의 명령만 사용할 수 있는것은 아닙니다.

# 다음의 경우는 첫번째 명령의 결과를 중간의 date 명령이 전달하지 못하고 있습니다.
$ echo "What is the date today?" | date | cat
Mon Jul 27 10:51:34 KST 2015

$ echo "What is the date today?" | { cat ; date ;} | cat
What is the date today?
Mon Jul 27 11:01:57 KST 2015

$ echo "What is the date today?" | { date; cat ;} | cat
Mon Jul 27 11:02:05 KST 2015
What is the date today?

$ echo "What is the date today?" | { cat
    if date | grep -Eq '^(Sat|Sun)'; then
        echo "It's weekend !"
    else
        date
    fi
} | sed -E '/weekend/ s/^|$/ @@@ /g'
What is the date today?
 @@@ It's weekend ! @@@

명령이 command1 | command2 와 같이 실행될 경우 command2 에서는 stdin 이 파이프에 연결되어 있어 read 명령으로 사용자로부터 입력을 받을 수 없는데요. 이때는 현재 터미널에 연결되어 있는 stdout 이나 stderr 또는 /dev/tty 를 이용해 사용자로부터 입력을 받을 수 있습니다.

command ... | { 
    read -p 'enter: ' var < /dev/tty
    while read line; do   # while 문은 파이프로 부터 입력을 받음
        ....
    done
}
# 또는
command ... | { read -p 'enter: ' var <&2; command ... ;}

background 프로세스 와 파이프

첫 번째로 background 프로세스의 특징은 parent 프로세스가 종료될 때까지 기다리지 않는다는 것입니다. 따라서 다음과 같은 코드가 실행될 경우 메인 프로세스는 차례로 3 개의 명령을 background 로 실행 시킨 후에 바로 종료하게 됩니다. background 프로세스들은 parent 프로세스의 설정에 따라 stdout 이 모두 outfile 파일로 연결되어 명령 실행 결과를 출력합니다.

{
    command1 &
    command2 &
    command3 &
} > outfile

두 번째로 파이프의 특징은 파이프 양쪽의 프로세스들이 서로 연결된 상태에서 그룹으로 하나의 명령처럼 실행됩니다. 따라서 코드를 조금 바꾸어 아래와 같이 실행하게 되면 3 개의 background 프로세스들과 cat 명령은 서로 파이프로 연결된 상태가 되고, cat 명령은 현재 메인 프로세스에 속하므로 결과적으로 background 프로세스들이 모두 종료될 때까지 메인 프로세스도 종료되지 않게 됩니다.

실제 활용 예제는 참조1, 참조2 를 참고하세요

{
    command1 &
    command2 &
    command3 &
} | cat > outfile

shopt -s lastpipe 옵션

파이프에 연결되어 실행되는 명령들은 subshell 에서 실행이 되므로 기본적으로 현재 shell 의 변수나 환경설정을 변경할 수 없는데요. shopt -s lastpipe 옵션을 사용하면 파이프에 연결된 명령중에 마지막 명령이 현재 shell 에서 실행되게 할 수 있습니다.

# 파이프에 연결된 명령들은 모두 subshell 에서 실행되므로 설정한 값이 현재 shell 에서는 출력되지 않는다.
$ ( echo hello | { cat; echo world ;} | res=$(cat);   echo $res )
$

# 마지막 명령문인 res=$(cat) 가 현재 shell 에서 실행되어 설정한 값이 출력된다.
$ ( shopt -s lastpipe;   echo hello | { cat; echo world ;} | res=$(cat);   echo $res )
hello world

Quiz

fuser 명령을 이용하면 특정 디렉토리나 마운트된 파일시스템을 current directory 로 사용 중인 프로세스들을 알아볼 수 있고 또는 특정 파일을 사용 중인( 실행하거나 open 한 ) 프로세스들을 알아볼 수도 있습니다. 다음은 /proc 파일시스템을 사용 중인 프로세스들을 출력해 본 것인데요. 여기에는 실제 stdout 출력값과 stderr 출력값이 섞여있습니다. 어떤 값이 stdout 로 출력된 값이고, 어떤 값이 stderr 로 출력된 값일까요?

$ fuser -m /proc
/proc:                 697rc  1080  2415  2719  2922  3009  3317  4019  4599rc
7596rc  7597rc  7600rc  7673  7741rc  7771rc  7861rc  7922rc  7941rc  7953rc  
7971rc  7979rc  8170rc  8189rc  8396rc  8417rc  8447rc  8461rc  8477rc  8503rc

다음과 같이 해보면 stdout 출력값으로 pid 만 남게 되는 것을 볼 수 있습니다. 따라서 위 fuser 명령을 파이프를 이용해 다른 명령들과 연결하게 되면 pid 뒤에 붙은 rc/proc: 같은 문자들은 전달되지 않고 pid 들만 전달되겠죠

$ fuser -m /proc 2> /dev/null
   697  1080  2415  2719  2922  3009  3317  4019  4599
7596  7597  7600  7673  7741  7771  7861  7922  7941  7953
7971  7979  8170  8189  8396  8417  8447  8461  8477  8503  

# pid 만 xargs 명령으로 전달된다.
$ fuser -m /proc | xargs ps

2.

가령 현재 디렉토리에 a.c b.c c.c d.c 4 개의 파일이 있고 각각 XYZ 스트링과 매칭 되는 라인을 가지고 있다고 할 경우 아래의 명령을 실행해 보면 처음 한 라인만 출력이 되고 나머지 grep 명령은 signal 에의해 terminated 되는 것을 볼 수 있습니다. 왜 이런 현상이 발생할까요?

$ find -name '*.c' -exec grep XYZ {} \; | head -1
line XYZ .....
find: ‘grep’ terminated by signal 13
find: ‘grep’ terminated by signal 13
find: ‘grep’ terminated by signal 13

먼저 find 명령의 -exec 옵션을 이용해 명령을 실행하는 방법은 다음과 같이 \;{} + 를 이용하는 2 가지가 있습니다.

# 이것은 grep 명령이 각각의 {} 파일에 대해 4 번 실행됩니다.
$ find -name '*.c' -exec grep XYZ {} \;
# grep XYZ ./a.c
# grep XYZ ./b.c
# grep XYZ ./c.c
# grep XYZ ./d.c

# 이것은 grep 명령이 4 개의 파일을 인수로 갖고 한번 실행됩니다.
$ find -name '*.c' -exec grep XYZ {} +
# grep XYZ ./a.c ./b.c ./c.c ./d.c

# -exec 옵션의 마지막에 '\;' 문자를 붙이는 이유는 명령의 끝을 나타내기 위해서입니다. 
# 그래야 뒤이어서 또 -exec 옵션을 사용할 수 있겠죠
$ find -name '*.c' -exec grep XYZ {} \; -exec stat -c '%n: %s' {} \;

질문의 예제 명령에서 -exec 명령을 \; 로 실행시켰으므로 grep 명령이 find 에의해 4 번 실행 됩니다. 하지만 파이프에 연결된 ( reader 에 해당하는 ) head -1 명령은 첫 라인을 출력하고 종료하므로 이후에는 ( writer 에 해당하는 ) grep 명령들이 close 된 파이프에 쓰기를 하게되어 signal 13 ( SIGPIPE ) 에 의해 terminated 되게 됩니다. ( 파이프 동작에 대한 좀더 자세한 설명은 named pipe 메뉴 참조 )