Process Substitution

<( <COMMANDS> )
>( <COMMANDS> )

프로세스 치환은 기존에 명령의 인수로 파일을 사용하던 곳에서 사용될 수 있습니다. 따라서 별도의 파일을 생성하지 않아도 되어 아주 편리한 기능입니다. 프로세스 치환은 데이터가 파이프를 통해 전달되므로 일반 파일과는 달리 random access 를 할 수 없기 때문에 프로그램 내에서 seek 을 하거나 직접 파일 타입을 체킹 하는경우 실행되지 않을 수도 있습니다. 파이프가 가지는 방향성을 > < 문자를 이용해 표시하는 named pipe 파일이라고 생각하면 됩니다.

프로세스 치환은 sh 에서는 사용할 수 없습니다.

# '>( )' 표현식 내의 명령은 subshell 에서 실행되므로 '$$' 값이 같게나온다.
$ { echo '$$1' : $$ >&2 ;} > >( echo '$$2' : $$ )
$$1 : 504
$$2 : 504

# 하지만 '$BASHPID' 는 다르게 나온다.
$ { echo '$BASHPID1' : $BASHPID >&2 ;} > >( echo '$BASHPID2' : $BASHPID )
$BASHPID1 : 504
$BASHPID2 : 22037
........................................................

$ args.sh <( echo 111 ) <( echo 222 ) <( echo 222 )
$0 : /home/mug896/bin/args.sh
$1 : /dev/fd/63
$2 : /dev/fd/62
$3 : /dev/fd/61

$ ls -l <( : ) 
lr-x------ 1 mug896 mug896 64 02.07.2015 22:29 /dev/fd/63 -> pipe:[681827]

$ [ -f <( : ) ]; echo $?  # 일반 파일인지 테스트
1
$ [ -p <( : ) ]; echo $?  # pipe 인지 테스트
0

sleep 명령은 현재 shell 에서 실행되고 표현식 내의 cat 명령은 subshell 에서 실행되는 것을 볼 수 있습니다.

{ sleep 10 ;} > >( cat )

process substitution

현재 shell 과 표현식 에서의 FD

command1 > >( command2 ) 명령의 경우 command1 의 stdout 이 command2 의 stdin 과 연결되며 command1 < <( command2 ) 명령의 경우는 command2 의 stdout 이 command1 의 stdin 과 연결됩니다.
현재 shell pid 를 나타내는 $$ 변수는 subshell 에서도 동일한 값을 가지므로 >( ) 표현식 내에서의 FD 상태를 보기 위해서는 $BASHPID 변수를 이용해야 합니다.

>( . . . )

현재 shell 의 stdout 이 파이프에 연결되어 출력되고 표현식 내의 subshell 에서는 stdin 이 파이프에 연결되어 입력을 받고 있습니다.

개별적으로 명령을 실행하여 파이프 번호가 다르게 나오지만 실제 명령이 실행될 때는 같게 됩니다.

<( . . . )

표현식 내의 subshell 은 stdout 이 파이프에 연결되어 출력되고 현재 shell 에서는 stdin 이 파이프에 연결되어 입력을 받고 있습니다.

개별적으로 명령을 실행하여 파이프 번호가 다르게 나오지만 실제 명령이 실행될 때는 같게 됩니다.

사용예 )

프로세스 치환을 사용하는 이유는 임시 파일을 만들지 않아도 된다는 점입니다. 가령 ulimit 명령의 soft limit 과 hard limit 출력값을 서로 비교한다면 아래와 같이 명령 실행 결과를 임시파일로 만든후 비교해야 합니다. 하지만 프로세스 치환을 이용하면 내부적으로 파이프를 이용해 처리하기 때문에 임시파일을 만들 필요가 없습니다.

$ ulimit -Sa > ulimit.Sa.out

$ ulimit -Ha > ulimit.Ha.out

$ diff ulimit.Sa.out ulimit.Ha.out

프로세스 치환을 사용해 비교

# 임시파일을 만들 필요가 없다
$ diff <( ulimit -Sa ) <( ulimit -Ha )   
1c1
< core file size          (blocks, -c) 0
---
> core file size          (blocks, -c) unlimited
8c8
< open files                      (-n) 1024
---
> open files                      (-n) 65536
12c12
< stack size              (kbytes, -s) 8192
---
> stack size              (kbytes, -s) unlimited

................................................

$ gcc -Q -O2 --help=optimizers > opt2
$ gcc -Q -O3 --help=optimizers > opt3
$ diff opt2 opt3 | grep enabled

$ diff <(gcc -Q -O2 --help=optimizers) <(gcc -Q -O3 --help=optimizers) | grep enabled
>   -fgcse-after-reload                         [enabled]
>   -finline-functions                          [enabled]
>   -fipa-cp-clone                              [enabled]
. . .

위의 프로세스 치환을 이용한 비교는 다음과 동일하다고 볼 수 있습니다.

 mkfifo fifo1
 mkfifo fifo2
 ulimit -Sa > fifo1 &
 ulimit -Ha > fifo2 &
 diff fifo1 fifo2
 rm fifo1 fifo2
$ echo hello > >( wc )
      1       1       6

$ wc < <( echo hello )
      1       1       6

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

# 입력과 출력용 프로세스 치환을 동시에 사용
$ f1() {
    cat "$1" > "$2"
}

$ f1 <( echo 'hi there' ) >( tr a-z A-Z )
HI THERE

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

# --log-file 옵션 값으로 입력 프로세스 치환이 사용됨
$ rsync -avH --log-file=>(grep -v '\.tmp' > log.txt) src/ host::dst/

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

# tee 명령을 이용해 결과를 3개의 입력 프로세스 치환으로 전달하여 처리
$ ps -ef | tee >(awk '$1 == "tom"' > toms-procs.txt) \
               >(awk '$1 == "root"' > roots-procs.txt) \
               >(awk '$1 == "httpd"' > httpd-procs.txt)

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

# dd 명령에서 입력 파일로 사용
$ dd if=<( tr -dc A-Z < /dev/urandom ) of=outfile bs=1M count=10 iflag=fullblock

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

# 링커에 전달하기 위한 버전 스크립트를 따로 파일로 생성할 필요 없이 프로세스 치환을 이용
$ gcc -fPIC -shared -Wl,--version-script=<( cat <<\@ 
LIBFOO_1.0 { 
    global: 
        libfoo_init; libfoo_doit; libfoo_done;

    local:
        *;
};
@
) -o libfoo.so foo.c

# 또는 
$ cat <<\@ | gcc -fPIC -shared -Wl,--version-script=<(cat) -o libfoo.so foo.c
LIBFOO_1.0 { 
    global: 
        libfoo_init; libfoo_doit; libfoo_done;

    local:
        *;
};
@

스크립트 작성 시에 명령 실행 결과를 받아서 처리하고자 할때 파이프를 사용하는데요. 파이프는 연결된 모든 명령들이 각자의 subshell 에서 실행되어 parent 변수에 연산 결과를 저장할 수가 없습니다. 이때 프로세스 치환을 이용하면 문제를 해결할 수 있습니다.

i=0
sort list.txt | while read -r line; do
  (( i++ ))
  ...
done

echo "$i lines processed"  

# 파이프로 인해 parent 변수 i 에 값을 설정할수 없어 항상 0 이 표시된다.
0 lines processed

------------------------------------
i=0
while read -r line; do
  (( i++ ))
  ...
done < <(sort list.txt)

echo "$i lines processed"   

# 프로세스 치환을 이용해 while 문이 현재 shell 에서 실행되어 i 값을 설정할수 있다.
12 lines processed

명령 실행 결과 stderr 만 전달하려고 할 때 프로세스 치환을 이용하면 쉽게 할 수 있습니다.

$ command 2> >( command ... )

# 파이프를 이용할 경우
$ command 2>&1 > /dev/null | command ...

# cmd1 은 stdout 을 처리, cmd2 는 stderr 를 처리
$ command > >( cmd1 ) 2> >( cmd2 )

sudo 명령에서는 사용할 수 없다.

sudo 명령은 기본적으로 stdin, stdout, stderr 를 제외하고 모든 FD 를 close 한 후에 주어진 명령문을 실행합니다. 프로세스 치환은 먼저 bash 에 의해 해석된 후에 FD 형태로 sudo 명령에 전달되므로 sudo 명령문에서는 프로세스 치환을 사용할 수 없습니다.

$ cat <(echo hello)                  $ sudo cat <(echo hello)
hello                                cat: /dev/fd/63: No such file or directory

$ env cat <(echo hello)              $ sudo bash -c 'cat <(echo hello)'
hello                                hello

redirection

$ cat test.sh
#!/bin/bash

echo yes1              # yes 는 stdout 으로 출력되는 메시지
echo error1 >&2        # error 는 stderr 로 출력되는 메시지
echo yes2
echo error2 >&2
------------------

# test.sh 명령의 stderr 출력이 sed 명령으로 전달되어 stdout 으로 출력.
# 따라서 현재 command line 은 출력이 모두 stdout 이됩니다.
$ ./test.sh 2> >( sed 's/.*/X & X/' ) 
yes1
yes2
X error1 X
X error2 X

$ { ./test.sh 2> >( sed 's/.*/X & X/' ) ;} > /dev/null
$

# sed 에서 처리된 error 메시지가 stderr 로 출력되려면 >&2 를 붙입니다.
$ { ./test.sh 2> >( sed 's/.*/X & X/' >&2 ) ;} > /dev/null
X error1 X
X error2 X

프로세스 치환의 특징

프로세스 치환에서 실행되는 명령은 asynchronous, non-interactive, non-job 프로세스가 됩니다.

  1. background 프로세스와 같이 메인 프로세스와는 별개로 실행되는 asynchronous 프로세스입니다. 따라서 메인 프로세스가 먼저 종료될 수 있습니다.

  2. non-interactive 프로세스이므로 터미널로부터 read 할 수 없습니다.

  3. job 프로세스가 아니므로 job table 에 등록되지 않습니다.

# 프로세스 치환에서는 터미널로부터 read 할 수 없다.
$ cat <( echo 111; read line; echo $line; echo 222 )                  
111

222

표현식 내 명령은 background 로 실행됩니다.

다음을 보면 exec 명령 실행 후에 tr 명령이 subshell 에서 background 로 실행되고 있는 것을 볼 수 있습니다.

메인 프로세스와 PGID 가 다르다.

다음 그림은 프롬프트 에서 join <(sleep 10) <(sleep 10) 명령을 실행했을 때의 ps 를 나타내는데요. 파이프로 명령을 실행했을 때는 연결된 명령들이 같은 PGID 를 갖는데 반해 프로세스 치환은 메인 명령에 해당하는 join 과 PGID 가 다른 것을 볼 수 있습니다.

따라서 다음과 같이 시간이 소요되는 명령을 실행 중에 Ctrl-c 로 종료를 시도한다면 join 프로세스는 바로 종료되겠지만 large_data 파일을 처리중인 sort 는 종료되지 않고 남아있게 됩니다.

join -t, -1 1 -2 3 <(sort -t, -k1,1 keys) <(sort -t, -k3,3 large_data)

종료 상태 값

프로세스 치환에서 발생하는 오류는 메인 프로세스에서 오류로 취급되지 않습니다.

$ cat <( date -@ )
date: invalid option -- '@'
Try 'date --help' for more information.

$ echo $?
0

$ echo 111 > >( date -@ )
date: invalid option -- '@'
Try 'date --help' for more information.

$ echo $?
0

Quiz

프로세스 치환에서 사용되는 명령은 background 로 실행되므로 >( ... ) 에서 실행되는 명령이 시간이 오래 걸릴경우 메인 프로세스가 먼저 종료할 수 있습니다. 다음 코드를 실행해 보면 메인 프로세스가 먼저 종료된 상태에서 >( ... ) 에서 실행되는 명령의 출력이 발생하는 것을 볼 수 있는데요. 어떻게 하면 >( ... ) 에서 실행되는 명령이 모두 종료한 후에 메인 프로세스가 종료되게 할 수 있을까요?

#!/bin/bash

while read line; do
    case $line in
        aaa* ) echo "$line" >& $fd1 ;;
        bbb* ) echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( while read line; do echo "$line" | sed 's/x/y/g'; sleep 1; done ) \
    {fd2}> >( while read line; do echo "$line" | sed 's/x/z/g'; sleep 2; done ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} );

다음과 같이 >( ... ) subshell 과 메인 프로세스에서 실행되는 cat 명령을 파이프로 연결해 주면 됩니다. 이것은 >( ... ) 출력이 터미널로 될 때뿐만 아니라 sed s/x/y/g > file 와 같이 파일로 출력을 할 경우도 모두 가능합니다.

#!/bin/bash

while read line; do
    case $line in
        aaa* ) echo "$line" >& $fd1 ;;
        bbb* ) echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( while read line; do echo "$line" | sed 's/x/y/g' ; sleep 1; done ) \
    {fd2}> >( while read line; do echo "$line" | sed 's/x/z/g' ; sleep 2; done ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} ) \
| cat    <--------- 메인 프로세스에서 실행되는 cat 명령

위의 예제 같은 경우는 while 문에 의해 매번 sed 명령이 종료되므로 메시지 출력순서가 유지되지만 만약에 다음과 같이 직접 sed 명령이 cat 파이프와 연결된다면 아래 두 번째 예제와 같이 sed 의 버퍼관련 옵션을 설정해 주어야 메시지 출력순서가 유지됩니다.

#!/bin/bash

while read line; do    # >( sed ... ) 로 실행
    case $line in
        aaa* ) sleep .2; echo "$line" >& $fd1 ;;
        bbb* ) sleep .5; echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( sed 's/x/y/g' ) \
    {fd2}> >( sed 's/x/z/g' ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} ) \
| cat

$ ./test.sh    # 메시지 출력순서가 유지되지 않고 잠시후에 종료될때 한꺼번에 출력된다.
bbbzzz
bbbzzz
bbbzzz
bbbzzz
aaayyy
aaayyy
aaayyy
aaayyy
---------------------------------------------------------------------

#!/bin/bash

while read line; do    # >( stdbuf -oL sed ... ) 버퍼 옵션 설정
    case $line in
        aaa* ) sleep .2; echo "$line" >& $fd1 ;;
        bbb* ) sleep .5; echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( stdbuf -oL sed 's/x/y/g' ) \
    {fd2}> >( stdbuf -oL sed 's/x/z/g' ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} ) \
| cat

$ ./test.sh    # 파이프나 파일로 쓰기를 할때는 항상 버퍼관련 옵션을 설정해 주어야 한다.
aaayyy
bbbzzz
aaayyy
bbbzzz
aaayyy
bbbzzz
aaayyy
bbbzzz

만약에 버퍼관련 옵션을 사용할 수 없을 경우에는 다음과 같이 명령 실행전에 stdout 으로 연결되는 FD 를 생성해서 사용하면 됩니다.

#!/bin/bash

exec 3>&1     # FD 3 번을 생성해 stdout 에 연결, 그리고 sed ... >&3

while read line; do
    case $line in
        aaa* ) sleep .2; echo "$line" >& $fd1 ;;
        bbb* ) sleep .5; echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( sed 's/x/y/g' >&3 ) \
    {fd2}> >( exec 1>&3; sed 's/x/z/g' ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} ) \
| cat

| cat 형식을 이용하는 방법의 한 가지 단점은 파이프 사용으로 인해서 오류 종료 상태 값이 무시된다는 것입니다. 예를 들어 위의 코드 마지막에 echo "exitcode : $?" 를 추가하고 sed 이름을 sedd 로 변경해 오류를 발생시키면 exitcode 값이 0 이 됩니다.

$ ./test.sh 
./test.sh: line 16: sedd: command not found
exitcode : 0

따라서 이때는 set -o pipefail 옵션을 설정해 사용하거나 다음과 같은 방법을 사용할 수 있습니다.

#!/bin/bash 

exec 3>&1

X=$(       # '| cat' 대신에 X=$( ... ) 대입 연산을 활용한다. (명령 치환도 파이프 이므로)

while read line; do
    case $line in
        aaa* ) sleep .2; echo "$line" >& $fd1 ;;
        bbb* ) sleep .5; echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( sedd 's/x/y/g' >&3 ) \
    {fd2}> >( exec 1>&3; sed 's/x/z/g' ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} )

)

echo exitcode : $?

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

$ ./test.sh 
./test.sh: line 24: sedd: command not found
exitcode : 141

errexit 옵션을 사용할 경우는 다음과 같이 명령 치환 subshell 에도 설정해야 합니다.

sleep 명령을 sleepp 으로 변경해 오류를 발생시키면 바로 종료됩니다.

#!/bin/bash -e
                               # errexit 옵션 설정
exec 3>&1

X=$( set -e; exec 1>&3         # 명령 치환 subshell 에도 설정

while read line; do
    case $line in
        aaa* ) sleepp .2; echo "$line" >& $fd1 ;;
        bbb* ) sleep .5; echo "$line" >& $fd2 ;;
    esac
done \
    {fd1}> >( sed 's/x/y/g' ) \
    {fd2}> >( sed 's/x/z/g' ) \
    < <( for ((i=0; i<4; i++)) { echo aaaxxx; echo bbbxxx ;} )

)

echo exitcode : $?

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

$ ./test.sh 
./test.sh: line 21: sleepp: command not found

2 .

heredoc 을 이용하면 따로 파일로 작성하지 않고도 python 스크립트를 실행시킬 수가 있는데요.

$ python3 <<\@
import platform
print(platform.python_version())
@
3.9.5

하지만 이 방법은 외부 명령의 실행 결과를 파이프를 통해 받아서 처리하고자 할때는 사용할 수가 없습니다. 왜냐하면 heredoc 은 기본적으로 python 명령의 stdin 에 연결되기 때문입니다. ( 파이프도 stdin 에 연결되죠 )

$ echo "foo bar zoo" | python3 <<\@         # <<\@ 는 0<<\@ 와 같다
> print(input())
> @

오류 ...

작성한 스크립트가 정상적으로 실행되게 하려면 어떻게 할까요?

다음과 같이 프로세스 치환을 이용하면 작성한 스크립트가 파일로 인식되기 때문에 오류 없이 실행이 됩니다.

$ echo "foo bar zoo" | python3 <( cat <<\@
line = input()
words = line.split()
chars = "".join(words)
print(f"words: {len(words)}, chars: {len(chars)}")
@
)
words: 3, chars: 9

3 .

diff 명령을 이용하면 간단히 두개의 디렉토리를 서로 비교할 수 있는데요.
비교하려는 디렉토리가 remote 서버에 존재하는 경우는 어떻게 할까요?

$ diff -rq BUILD.1 BUILD.2
Only in BUILD.1/lib: libtlpi.a    # BUILD.1 디렉토리에만 존재하는 파일
Files BUILD.1/lib/pty_fork.o and BUILD.2/lib/pty_fork.o differ
Files BUILD.1/lib/pty_master_open.d and BUILD.2/lib/pty_master_open.d differ

rsync 명령의 --dry-run 옵션을 이용하는 방법이 있지만 여기서는 프로세스 치환을 이용해 보겠습니다. remote 파일을 비교하는 것이므로 파일을 다운로드하지 않고 md5sum 을 이용해 비교합니다. awk 에서 파일 경로를( 파일명 포함 ) array 의 index 로 하고 md5sum 값을 value 로 사용합니다.

$ awk 'BEGIN { 
    # find 명령에 사용된 local, remote 디렉토리를 여기도 입력해줍니다.
    dir1 = length("path/to/local/BUILD")
    dir2 = length("path/to/remote/BUILD")
    while (getline < ARGV[1]) a[substr($2,dir1+2)] = $1  # $1 는 md5sum
    while (getline < ARGV[2]) { i = substr($2,dir2+2); b[i] = $1
        # a[i] 의 값이 존재하지 않는다는 것은 b 에만 존재하는 파일
        if ( !a[i] ) { print ">>> Only " i; delete a[i]; delete b[i]; continue }
        # a[i] 와 b[i] 의 md5sum 값이 다르다는 것은 파일이 변경되었다는 의미
        if ( a[i] != b[i] ) print "Differ " i
        delete a[i]; delete b[i] 
    }
    # 앞서 비교후에 array 원소를 모두 delete 하였으므로 남은것은 a 에만 존재하는 파일
    for (i in a) print "<<< Only " i
}' <( find path/to/local/BUILD -type f -exec md5sum {} + ) \
<( ssh user@remote 'find path/to/remote/BUILD -type f -exec md5sum {} +' ) 

Differ file/filebuff/direct_read.o
Differ timers/real_timer.o
Differ mmap/mmcat.d
>>> Only proc/lib/get_num.o      # remote 에만 존재하는 파일
<<< Only proc/lib/signal.d       # local 에만 존재하는 파일