빌드노트
Bash 백그라운드 작업은 실패했는데 $?는 0이다
Bash에서 & 직후의 0은 자식의 완료 상태가 아니다. 종료 0·7인 자식으로 차이를 확인하고, $!로 저장한 PID에 각각 wait해 실패를 배치 상태로 전달한다. 인자 없는 wait와 여러 PID를 한 번에 기다리는 경우의 한계도 비교한다.
Bash에서 명령 뒤에 &를 붙이고 곧바로 $?를 읽으면, 작업이 7로 실패해도 0이 나올 수 있다. 그 값은 백그라운드 실행 구문의 상태다. 자식이 끝난 결과를 확인하려면 시작 직후 $!를 저장하고, 그 자식을 시작한 Bash에서 wait "$pid"의 상태를 받아야 한다.
시작할 때와 기다린 뒤의 값을 따로 저장한다
Linux나 WSL의 Bash에서 확인할 수 있다. 쓰기 가능한 작업 폴더에 새 single.sh 파일을 만들고 다음 내용을 저장한다. 실제 작업 대신 7로 끝나는 짧은 자식을 실행해, 시작 상태와 완료 상태가 다른지 보는 입력이다.
set -e
bash -c 'exit 7' &
launch_rc=$? pid=$!
if wait "$pid"; then
child_rc=0
else
child_rc=$?
fi
printf 'launch=%s child=%s\n' "$launch_rc" "$child_rc"
bash --noprofile --norc single.sh로 실행한다. Ubuntu 24.04.4 LTS, WSL2 Linux 6.18.40.1, Bash 5.2.21에서 직접 관찰한 출력은 다음과 같다. 운영 작업의 장애 기록이 아니라 합성 자식을 실행한 결과다.
launch=0 child=7
exit 7만 exit 0으로 바꾼 정상 대조에서는 launch=0 child=0이었다. 시작할 때의 0만 비교하면 두 경우를 구별할 수 없지만, PID를 지정한 wait는 각각 7과 0을 돌려줬다.
Debian이 제공하는 GNU Bash 5.2 매뉴얼의 Lists 설명 (새 탭에서 열림)에 따르면 &로 실행한 구문은 자식의 종료를 기다리지 않고 0을 반환한다. 이 0으로 작업 완료나 서비스 준비를 판정해서는 안 된다. 같은 매뉴얼의 Special Parameters에서 $!는 가장 최근에 백그라운드로 보낸 작업의 PID로 정의한다.
launch_rc=$? pid=$!는 두 값을 한 번의 대입 명령에서 저장한다. pid=$!를 먼저 실행하고 다음 줄에서 $?를 읽으면, 직전 대입의 상태를 읽게 된다. 다른 백그라운드 작업을 시작하면 $!도 바뀌므로 PID는 각 시작 직후 보관한다.
이 예제는 set -e가 켜져 있어도 실패한 wait 뒤의 값을 읽는다. wait를 if의 조건에 두었기 때문이다. else에 들어오면 출력 명령보다 먼저 $?를 저장한다. wait "$pid"와 child_rc=$?를 독립된 두 줄로만 두면, -e 때문에 대입 전에 스크립트가 끝날 수 있다. 이 조건은 Bash 매뉴얼의 set -e 설명 (새 탭에서 열림)에도 명시돼 있다.
자기 스크립트에 적용할 때는 bash -c 'exit 7' 자리를 실제 작업 명령으로 바꾸고 PID 저장과 if wait를 유지한다. 위 예제는 상태를 출력하는 데서 끝나므로, 그 스크립트 자체의 종료 상태는 마지막 printf의 0이었다. 호출한 쪽에도 자식의 실패를 전달하려면 파일 마지막에 exit "$child_rc"를 추가한다. 이 한 줄을 추가한 별도 실행에서는 실패 자식일 때 스크립트도 7, 정상 자식일 때는 0으로 끝났다.
인자 없는 wait는 실패를 모아 주지 않는다
작업이 여러 개면 마지막에 wait만 넣고 성공 여부를 검사하기 쉽다. 이번에는 새 bare-wait.sh에 다음을 저장하고 bash --noprofile --norc bare-wait.sh로 실행한다. 한 자식은 7, 다른 자식은 0으로 끝날 때 인자 없는 wait가 무엇을 반환하는지 확인한다.
set -e
bash -c 'exit 7' &
launch_first=$? pid_first=$!
bash -c 'exit 0' &
launch_second=$? pid_second=$!
wait
wait_rc=$?
printf 'launches=%s,%s wait-all=%s\n' "$launch_first" "$launch_second" "$wait_rc"
같은 환경에서 출력은 다음과 같았다.
launches=0,0 wait-all=0
두 자식을 모두 0으로 바꿔도 출력은 같았다. 이 비교는 다른 작업이 없는 새 비대화형 Bash에서, job control을 끄고 신호나 trap 없이 실행했다. Bash의 wait 설명 (새 탭에서 열림)은 인자를 생략해 백그라운드 작업들을 기다리는 경우 반환 상태를 0으로 정한다. 여기서 0은 모든 자식이 성공했다는 집계 결과가 아니다.
wait "$pid_first" "$pid_second"처럼 PID를 한꺼번에 넘겨도 전체 실패 판정이 되지는 않는다. 반환값은 마지막 인자로 기다린 대상의 상태다. 별도 실행에서 첫 자식이 7, 둘째가 0이면 반환값은 0이었고, 첫째가 0, 둘째가 7이면 7이었다. 각 결과가 필요하면 PID마다 한 번씩 받는다.
각 PID를 기다리고, 배치의 실패 여부는 직접 정한다
같은 두 자식을 새 each-wait.sh에서 시작하고 각각 기다린다. 실패가 있어도 나머지 자식까지 기다린 뒤 배치 상태를 정하는 입력이다. 앞의 인자 없는 wait를 실행한 뒤 상태를 다시 읽는 방식으로 이어 붙이지 않는다.
set -e
bash -c 'exit 7' &
pid_first=$!
bash -c 'exit 0' &
pid_second=$!
failed=0
for pid in "$pid_first" "$pid_second"; do
if wait "$pid"; then
child_rc=0
else
child_rc=$?
failed=1
fi
printf 'child=%s\n' "$child_rc"
done
printf 'failed=%s\n' "$failed"
exit "$failed"
bash --noprofile --norc each-wait.sh의 출력은 다음과 같았고, 스크립트는 1로 끝났다.
child=7
child=0
failed=1
두 자식을 모두 0으로 바꾼 대조에서는 child=0이 두 번, failed=0이 나왔으며 스크립트도 0으로 끝났다. 자식들은 두 번의 &로 먼저 시작하고, 반복문은 저장한 PID 순서대로 상태를 읽는다. 출력 순서를 실제 종료 순서로 해석할 필요는 없다.
여기서 1은 하나라도 실패하면 배치를 실패로 처리하도록 직접 정한 값이다. 원래 자식의 7은 child=7에 남는다. 실제 배치에 적용할 때도 각 작업의 PID를 보관하고, 각각의 상태를 기록한 뒤 전체 실패 정책을 정한다. 첫 실패에서 즉시 exit하면 뒤의 자식 상태는 회수하지 못한다.
wait는 자식을 시작한 셸에서 호출해야 한다. 문서상 유효하지 않은 PID에 대한 상태는 127이므로, 예상한 7 대신 127이 나오면 PID 보존과 호출 위치부터 확인한다. 위 실험은 즉시 0 또는 7로 종료하는 자기 자식만 다뤘다. 끝나지 않는 작업의 제한 시간·취소, 신호로 중단된 wait, job control을 켠 대화형 셸과 다른 셸의 동작은 검증하지 않았다.