시스템 프로그래밍

[System Programming] Shell

짱일모 2022. 11. 22. 21:17

Shell이란 무엇인지 알아봅시다.

쉘(Shell)은 사용자의 명령을 처리해 주는 응용 프로그램입니다. 다음의 그림으로 쉘과 유저, 쉘과 커널의 관계에 대해 이해해봅시다.

즉 쉘을 통해서, 유저는 커널에 명령을 내리고 커널은 수행결과를 쉘을 통해 유저에게 보여주게 되는 것입니다.
조금 더 거시적인 관점에서 설명하면 하드웨어 <-> 커널 <-> 쉘 <-> 유저의 형태로 서로 communicate하는 것입니다.

Shell의 종류로는 sh - Original Unix Bourne Shell, csh - BSD UNIX C Shell, tcsh - Enhanced C Shell, bash - Bourne-Again Shell이 있습니다.

앞으로 설명에서 사용될 Shell은 Bash Shell입니다.

쉘에서 처리할 수 있는 명령어는 크게 두가지로 분류할 수 있습니다.
첫번째는 Utility 명령어이고, 두번째는 Built-in 명령어입니다. 두 명렁어의 차이점은 Built-in 명령어는 Shell에 내장되어있고, search path에서 찾기 전에 실행한다는 점입니다.

쉘에서 사용하는 추상화 개념인 Job에 대해 알아보도록 합시다.

Job : Shell이 사용하는 추상화로 한 개의 명령어 줄을 실행해서 생성된 프로세스들을 지칭합니다.
어느 한 순간에 Job은 최대 한 개의 foreground job과 0 또는 하나 이상의 background job으로 구성됩니다.
쉘은 각 job 마다 별도의 그룹을 할당합니다.

리눅스에서 Job을 제어하는 명령에 대해 알아봅시다.
foreground job을 정지시키려면 Ctrl+Z(SIGSTOP 시그널을 보냄)
fg => 정지된 작업을 포그라운드 작업으로 실행
bg => 백그라운드 작업으로 실행(SIGCONT 시그널을 보냄)
백그라운드 작업으로 실행파일을 실행시키려면 명령어 뒤에 &를 추가해주면 됩니다.
백그라운드 프로세스는 자식 Shell로 생성되어 부모 Shell과 같이 수행되지만 키보드를 제어하지 않아 부모 쉘에서는 다른 작업을 진행할 수 있습니다.

예제를 통해 백그라운드 프로세스를 실행을 살펴보겠습니다.

위 사진은 Shell에서 sleep 프로그램에 parameter로 10을주고 &를 붙여 백그라운드 프로세스로 실행시킨 모습입니다. 백그라운드로 실행했기 때문에 Sleep이 실행되는 동안에도 키보드 입력이 가능해져 ps 명령어를 실행시킬 수 있습니다. ps 명령어를 통해 현재 실행 중인 프로세스 내역을 보면 sleep 프로세스가 실행 중인 것을 확인할 수 있습니다.

또다른 Background 프로세스 실행 활용의 예를 살펴봅시다. 유저는 대개 한 번에 한 개의 명령을 실행하게 됩니다. 어떤 프로그램은 실행시간이 굉장히 길 수 있습니다. 이럴 때 이 프로그램의 실행시간 동안 기다리는 것을 원치 않을 경우 Background job을 이용하면 됩니다. 구체적인 예시를 봅시다.
sleep 7200; rm/tmp/junk -> 두 시간 뒤에 junk 파일을 제거하라는 명령입니다. 이렇게 입력하면 꼼짝없이 이 명령어가 실행되기까지 2시간을 기다려야 합니다.
하지만, sleep 7200; rm/tmp/junk & -> 백그라운드 작업으로 등록되어 이 명령어가 실행되는 동안에도 다른 작업을 수행할 수 있습니다.

그럼 도대체 Shell은 어떻게 짜여진 프로그램일까요? Shell의 기본 뼈대가 되는 코드를 살펴봅시다.

위 코드가 Shell 프로그램이 돌리는 main 함수의 기본 뼈대입니다. fgets로 명령어를 받아 cmdline에 저장하고 eval 함수로 cmdline에 입력된 명령어를 처리합니다.

eval 함수를 구현해보겠습니다.

다른 부분은 주석을 통해 파악할 수 있다고 생각하고, 파란색 박스 친 부분을 중심으로 살펴보도록 하겠습니다.

bg = parseline(cmdline, argv); 은 parseline 함수를 살펴봐야합니다. parseline 함수는 쉘랩을 진행하시는 분들이라면 가지고 계시는 tsh.c 파일안에 정의되어있습니다. 간단하게 어떤 함수인지 말씀드리면, 명령어를 저장하는 cmdline을 parsing해서 argv 배열을 만드는 함수입니다. 그리고 parseline함수는 &이 입력됐는지 안됐는지를 확인하여 실행파일을 background로 실행할지, foreground로 실행할지 여부를 반환합니다.

if (!builtin_command(argv)) 분기를 살펴보겠습니다. builtin_command(혹은 builtin_cmd)는 명령어로 입력한 명령이 built-in 명령어인지 아닌지 확인하여 true or false를 반환하는 함수입니다. 즉 built-in 명령어가 아닌 Utility 명렁어라면 안쪽 분기로 넘어갑니다.

if ((pid = fork()) == 0) 분기를 살펴보겠습니다. 이전 포스팅에서도 자주 다루었듯이 부모 프로세스라면 pid 에 자식 프로세스의 ID를 저장하고, 자식 프로세스라면 pid 가 0이 저장됩니다. == 0 이 True라면 자식 프로세스라는 의미가 됩니다.

자식 프로세스의 경우 execve 함수를 수행하게 됩니다. execve는 에러가 발생하지 않으면 리턴하지 않지만, 에러가 발생한 경우 음수가 리턴되어 command not found가 출력될 것임을 알 수 있습니다.

if (!bg) 를 통해 입력된 명령이 백그라운드 작업으로 수행되는 것인지 아닌지 확인합니다.

bg가 false일 때 즉 foreground job으로 명령을 처리할 때, if(!bg)안쪽으로 들어가 waitpid 함수를 수행합니다. pid가 끝날 때까지 부모 프로세스는 정지(Suspend)상태가 됩니다. 그리고 자식 프로세스가 종료되면 자식 프로세스를 제거합니다.

bg가 true일 때 즉 background job으로 명령을 처리할 때 else의 안쪽으로 들어가 process id와 내가 입력한 명령어인 cmdline이 출력됩니다.

지금껏 작성한 쉘의 문제점이 무엇일까요?

위에서 살펴본 쉘은 foreground job은 수행한 뒤 wait 을 통해 프로세스를 제거하므로 좀비가 생기지 않습니다.

하지만 background job을 수행하면 background job을 실행시킨 부모 프로세스가 shell이고 이 shell은 종료되지 않기 때문에 (main 함수의 while(1)을 떠올려봅시다!) background job은 종료된 자식 프로세스로서 정리되지 않고 계속 자원을 점유하는 좀비가 됩니다. 이 background job 을 어떻게 처리할 수 있을까요?

  1. 최신 UNIX는 프로세스 메모리 할당량이 존재하여 이를 초과하면 새로운 명령을 수행하지 못하도록 fork()가 -1을 리턴합니다.
  2. background 작업을 종료시키기 위해 signal이라는 방식을 사용합니다.

이제, Signal이 무엇인지 알아봅시다.

Signal : 시그널은 어떤 이벤트가 시스템에 발생했다는 것을 프로세스에게 알려주는 짧은 메시지입니다. 예외상황과 인터럽트를 커널에서 추상화한 개념입니다. signal은 커널 소프트웨어로서, 커널이 프로세스에게 보내는 메세지입니다. (간혹 다른 프로세스가 요청하는 경우도 있습니다.) 서로 다른 시그널들은 정수 아이디로 구분합니다.(1-30, Exception Table의 index와 유사합니다!) 시그널에 포함된 유일한 정보는 시그널 아이디와 시그널이 도착했다는 사실 두 가지입니다.

다음은 몇가지 시그널에 대한 설명입니다.

시그널을 어떻게 주고 받는 지에 대해 알아보도록 합시다.

시그널의 송신 (Sending a Signal)
커널은 목적지 프로세스의 Context(상태정보) 내 일부 상태를 갱신하는 방법으로 시그널을 목적지 프로세스에 보냅니다. 커널은 다음과 같은 경우에 시그널을 보냅니다.

  • 커널이 divide by 0 (SIGFPE) 나 자식 프로세스의 종료(SIGCHLD)와 같은 시스템 이벤트를 감지했을 때.
  • 다른 프로세스가 kill 시스템 콜을 호출해서 커널이 목적지 프로세스로 시그널을 보낼 것을 요청했을 때

시그널의 수신(Receiving a Signal)
목적지 프로세스가 시그널을 받을 때, 어떤 형태로든 반응을 하도록 커널에 의해 요구될 때, 시그널을 받는다고 합니다.
반응에는 다음과 같이 세 가지 반응이 존재합니다.

  • 무시 (ignore the signal) do Nothing.
  • 대상 프로세스를 종료 (with optional core dump)
  • 시그널 핸들러라고 부르는 유저레벨 함수를 실행하여 시그널을 잡는다. (catch)

시그널 관련 용어들입니다. 잘 숙지해 두도록 합시다.

Pending : 전송하였지만, 아직 수신되지 않은 시그널은 "Pending(대기)" 하고 있다고 이야기합니다. 어느 특정 타입의 시그널에 대해서 최대 한 개의 대기 시그널이 존재할 수 있습니다. <중요!>그리고 시그널은 큐에 들어가지 않습니다. 즉, 만일 어떤 프로세스가 k타입의 대기 시그널을 가지고 있다면, 다음에 이 프로세스로 전달되는 k타입의 시그널들은 무시됩니다.
대기하는 시그널은 최대 한 번만 수신할 수 있습니다. 커널은 대기 시그널들을 나타내기 위하여 비트벡터를 사용합니다.(특정 타입의 시그널에 대해서 최대 한 개의 대기 시그널이 존재할 수 있지만, 시그널은 여러 종류가 있으므로 대기 시그널"들"이 존재할 수 있습니다.)

Block : 프로세스는 특정 시그널의 수신을 블록할 수 있습니다. 이것을 시그널의 거절이라고 이야기합니다. 블록된 시그널들은 전달될 수 있지만, 이 시그널이 풀릴 때까지는 수신될 수 없습니다. 하지만 SIGKILL(9번 시그널)은 블록할 수 없습니다.

이제 시그널이 어떻게 구현되는지 살펴보도록 합시다.

커널은 각 프로세스의 컨텍스트(상태정보)에 pending과 blocked 비트 벡터를 가지고 있습니다.

pending - 대기 시그널들을 표시하는 비트벡터. 커널은 타입 k 시그널이 도착할 때마다 pending 값의 k번째 비트를 1로 set합니다. 그리고 k 시그널을 수신할 때마다 (즉 도착한 시그널을 프로세스가 반응하여 처리하면) pending 값의 k번째 비트를 0으로 set합니다.

blocked - 블록된 시그널들을 표시하는 비트벡터. sigprocmask 함수를 사용하여 응용 프로그램이 1 또는 0 으로 설정합니다.

*비트벡터란 ? : 비트 벡터란 중복되지 않는 정수 집합을 비트로 나타내는 방식입니다. 예를 들어, 0000 0000 이라는 비트벡터가 존재하고 정수 1, 3의 존재를 표현하고 싶으면 0000 1010 으로 표현할 수 있습니다.

이번엔 프로세스 그룹 개념에 대해 살펴보겠습니다. 기본적으로, 자식은 부모와 같은 그룹에 속하게 됩니다. 그리고 Shell은 job(명령어 한 줄로 생성되는 프로세스들)마다 별도의 프로세스 그룹을 만들어서 관리합니다.

getpgrp()함수를 통해 프로세스의 process group 을 알 수 있습니다.
setpgrp() 함수를 통해 프로세스의 그룹을 변경할 수 있습니다.

다음의 그림을 통해 프로세스와 프로세스 그룹의 관계에 대해 알아봅시다.

시그널 송신의 구체적인 사례를 살펴보겠습니다.

kill 명령을 통해 시그널을 프로세스 또는 프로세스 그룹에 보낼 수 있습니다.

예제를 통해 kill 명령의 사용방법을 알아봅시다.

더 구체적인 kill 명령의 사용법을 알아보겠습니다.

다음은 fork10함수에 kill 명령을 가미한 fork12함수입니다.

fork10 함수와 차이점은 자식 프로세스가 exit으로 종료되지 않고 무한 루프를 돌며 계속 동작하다가 부모 프로세스의 호출한 kill 함수의 SIGINT 시그널을 받고 종료됩니다. 자식 프로세스가 종료되면 부모 함수에서 호출된 wait 함수에 의해 자식 프로세스가 정리되고 정지상태에 있던 부모 프로세스는 다시 동작합니다. 이 때 자식 프로세스는 exit을 만나 종료한 것도 아니고, return 에 의해 종료된 것도 아니므로 "Child '자식 프로세스의 id' terminated abnormally"가 출력됩니다.

Ctrl + C (Ctrl + Z)을 누르면 SIGINT (SIGSTP) 시그널이 foreground 프로세스 그룹의 모든 작업으로 전송됩니다.
(부연설명)
위에서 시그널은 인터럽트와 Exception을 커널에서 추상화한 개념이라고 이야기 한 바 있습니다. System Programming, Process 포스팅에서 살펴보았듯이 Ctrl + C는 입출력 "Interrupt"입니다. 이 인터럽트를 커널에서 추상화하여 SIGINT로 Implementation 하고 있는 것입니다.

다음 그림을 통해 Ctrl + C의 처리를 이해해봅시다.

시그널 수신의 구체적 사례를 살펴봅시다.

커널이 예외처리 핸들러 (Exception Handler) 에서 돌아오고 있고, 제어권을 프로세스 P로 넘겨줄 준비가 되었다고 가정해봅시다.

커널은 이 때 pnb = pending & ~blocked 를 계산합니다. 의미 그대로, pnb는 pending(대기)되어 있으면서 blocked 되지는 않은 시그널들을 의미합니다.

만약 pnb == 0 이라면, 처리해주어야 할 시그널이 없다는 의미이므로 프로세스 P의 논리적인 제어흐름 상의 다음 인스트럭션으로 제어권을 이동시킵니다.

만약 pnb != 0 이라면 pnb에서 0이 아닌 k번째 비트를 선택하고 프로세스 p가 시그널 k를 수신하도록 합니다. 시그널을 수신한다는 것은 프로세스가 시그널에 반응해야 한다는 의미이므로 프로세스 P는 해당 시그널에 대한 처리 작업을 수행합니다.
pnb의 0이 아닌 모든 비트 k에 대해 시그널을 처리해주면 pnb가 0이 될 것입니다. 따라서 제어권을 프로세스 P의 논리적 제어흐름 상의 인스트럭션으로 넘겨줍니다.

각 시그널은 사전에 정의된 기본동작을 가집니다. (프로세스 종료, 프로세스 종료 후 core file dump, ...)
이 기본동작은 signal() 함수를 이용해서 변경이 가능합니다. 단, SIGSTOP과 SIGKILL은 변경할 수 없습니다. (overriding과 비슷한 개념이라 생각할 수 있습니다.)

기본동작을 수정할 수 있는 signal 함수에 대해 알아봅시다.
handler_t *signal(int signum, handler_t *handler)
signal handler는 프로세스가 signum 시그널을 수신할 때 실행합니다.
signal handler는 handler를 설치하는 기능을 수행합니다.
이때 실행되는 핸들러는 시그널을 "붙잡는다catching" 또는 "처리한다" 라고 부릅니다. 핸들러가 리턴문을 만나면, 제어권은 시그널에 의해 중단되었던 프로세스의 다음 명령으로 돌아갑니다.

두번째 인자인 handler 값으로 들어갈 수 있는 값을 확인해봅시다.
SIG_IGN : signum 타입 시그널을 무시
SIG_DFL : 시그널 타입 signum 의 기본 동작으로 복귀
그 외의 경우, handler는 signal handler의 주소가 됩니다.

시그널 함수의 사용 예를 살펴봅시다.

fork13() 함수는 fork12()에서 SIGINT를 int_handler로 handling 해주는 함수이다. SIGINT 시그널이 프로세스로 온 것을 감지하고 수신하면 int_handler가 SIGINT의 처리를 할 것임을 예상할 수 있습니다. 그 결과로 "Process 'Process ID' received signal 'signal number'가 출력되고 exit을 만나 프로세스가 종료될 것입니다.

다음은 fork13을 실행한 결과입니다.

fork12 함수의 호출결과와 차이점을 대조하며 관찰해봅시다. Killing Process 가 출력되면서 각 Process 에 SIGINT 시그널을 보냈을 때, fork12 에서는 시그널을 수신한 Process는 Exit도 return도 아닌 SIGINT를 처리하여 종료되어 Child process terminated abnormally가 출력되었습니다.
하지만 fork13은 SIGINT를 int_handler로 처리해줄 때 exit을 만나 프로세스를 종료하기 때문에 부모 프로세스에서 Child Process terminated with exit status exit code 를 출력합니다. 즉 시그널 핸들러를 통해서 시그널의 기본 동작과는 다르게 처리된 것을 확인할 수 있습니다.

다음으로, 시그널 관련용어 pending에 대해 설명할 때 언급했던 시그널은 큐에 들어가지 않는다에 대해 추가적으로 설명하겠습니다.

구체적인 사례를 보며 문제점을 확인해 보겠습니다. 다음 코드는 signal 함수를 이용해 시그널 핸들러를 이용해 시그널을 처리하는 코드입니다.

얼핏 보면, child_handler를 통해 SIGCHLD 시그널을 처리할 때 자식 프로세스가 종료되면 ccount를 decrement 1 시키고 Received signal 'num of sigchld' from process 'process id' 를 출력하는 정상적인 프로그램 같습니다. 하지만 이 프로그램에는 결함이 있습니다.

그것은 바로 같은 type의 시그널은 큐에 들어가지 않는다는 것입니다. fork14에서 fork() 함수를 통해 자식 프로세스를 생성합니다. 자식 프로세스에서는 sleep을 통해 자식 프로세스를 잠시 정지시켰다가 exit을 통해 종료합니다. 자식 프로세스가 종료되었으므로 커널이 부모 프로세스에 SIGCHLD 시그널을 송신하고 부모 프로세스에는 SIGCHLD signal이 pending되고 부모 프로세스의 next instruction을 수행하기 전에 커널이 pnb를 계산하고 sigchld에 해당하는 bit가 pending 비트벡터에 set되어있으면서 blocked 비트벡터에는 set되어있지 않을 때 프로세스는 sigchld를 처리합니다. 이때 sigchld의 기본 동작이 아니라, signal 함수에 sigchld를 child_handler로 처리하기로 했으므로, child_handler가 호출됩니다.

child_handler 내부를 살펴보면 wait 함수에 의해 자식 프로세스가 종료될 때까지 대기한 후 종료되면 자식 프로세스를 제거합니다. 그리고 ccount를 decrement 1 합니다. "Received signal signum from process pid"를 출력하고 프로세스를 2초동안 정지시킵니다. sigchld 에 대한 처리를 하는 동안 부모 프로세스의 반복문에서는 계속 fork로 자식 프로세스가 생성되고 exit(0) 를 만나 자식 프로세스가 종료되고 있습니다. 자식 프로세스가 종료되는 순간 부모 프로세스에는 계속해서 커널이 sigchld를 송신합니다! 그러나 같은 타입의 signal 은 큐에 들어가지 않으므로 한번 pending된 이후의 시그널들은 버려지게 되는 것입니다. 즉 종료되는 자식 프로세스로 인해 발생하는 모든 sigchld 시그널을 부모 프로세스가 감지하지 못하게 됩니다.

이런 signal handler의 이상동작을 방지하기 위해서는 코드를 다음과 같이 설계 해야합니다.

위의 코드처럼 대개 wait을 반복해서 적용합니다. WNOHANG 옵션을 통해 즉시 리턴을 합니다. 이런 식으로 작성하면 sigchld가 발생할 때 waitpid 함수가 호출되어 종료된 자식 프로세스를 제거하고 while 루프로 waitpid가 반복 호출되면서 종료된 자식 프로세스가 없을 때까지 반복합니다. 이렇게하면 sigchld를 처리하는 handler 가 동작하는 동안 다른 자식 프로세스가 좀비가 되어 부모 프로세스가 sigchld를 handler로 처리하는 동안 자식 프로세스가 죽어서 sigchld를 수신하지 못하여도, 반복문을 통해 waitpid 가 0보다 크게 되므로, 좀비가 된 모든 자식 프로세스들에 대해 sigchld를 handling 하는 것과 같은 결과를 얻을 수 있습니다.

 

핸들러가 다른 핸들러에 의해 중단되는 경우에 대해 살펴봅시다.

 

핸들러에 의해 핸들러가 중단되는 경우에 대한 설명

next instruction 을 수행하기 전에 Signal S를 받고 Handler S를 수행합니다. Handler S를 수행하던 중 signal T를 받으면 hander T 가 수행됩니다. 그리고 handler T를 수행한 후 handler S 로 돌아와서 handler S를 수행합니다. 그리고 다시 next instruction 을 수행합니다. 이렇듯, signal을 handling 하는 도중에 signal이 발생하면 다른 signal 의 handling 을 진행하게 됩니다.

 

지금까지 Signal을 보내고, 수신한 Signal을 Handling 하는 방법에 대해 알아보았습니다.

 

이번에는 Signal 을 Block 하는 방법에 대해 알아보겠습니다. 

 

먼저, 앞에서도 언급했듯이 signal 을 handling 하고 있을 때, 같은 종류의 signal 이 들어오는 경우 동안 무시됩니다. 이것을 묵시적인 블록 (Implicit Block) 이라고 합니다.

 

두번째로, 명시적 블록 (Explicit Block) 이 있습니다. 명시적 블록은 sigprocmask 함수를 이용해서 수행할 수 있습니다.

int sigprocmask(int how, const sigset_t *set, sigset_t *oldest);

sigprocmask 함수는 첫번째 인자 how 에 따라 동작이 결정됩니다.

  • SIG_BLOCK : blocked = (blocked | set)  <기존 blocked signal + set으로 block할 signal>
  • SIG_UNBLOCK : blocked = blocked & ~set <기존 blocked signal - set으로 unblock 할 signal>
  • SIG_SETMASK : blocked = set

(blocked는 pnb 에서 블록할 시그널들을 나타내는 비트벡터였습니다.)

 

set 도 blocked 을 masking 하기 위한 하나의 비트벡터인데 이 set을 만드는 지원함수는 다음과 같습니다.

  • sigemptyset : 모든 시그널이 비어 있는 비트벡터를 생성합니다.
  • sigfillset : 모든 시그널 번호를 1로 설정한 비트벡터를 생성합니다.
  • sigaddset : 특정 시그널 번호를 1로 설정한 비트벡터를 생성합니다.
  • sigdelset : 특정 시그널 번호를 0으로 설정한 비트벡터를 생성합니다.

다음 예제에서 sigprocmask를 이용하여 일시적으로 시그널 sigint 를 블록하도록 하겠습니다.

sigprocmask로 sigint Block 하기

 

 코드를 살펴보면 mask, prev_mask 라는 비트벡터를 만들고, sigemptyset을 통해 mask의 비트벡터를 모두 0으로 만듭니다. 그리고 sigaddset으로 mask의 sigint에 해당하는 비트만 1로 설정하고 sigprocmask를 통해 기존 blocked 에 sigint 를 b추가로 block 합니다. 그리고 prev_mask에는 이전의 blocked 비트벡터를 저장합니다.

 

이후 sigprocmask 함수를 통해 blocked 비트벡터를 저장해뒀던 이전 blocked 비트벡터로 복원해줍니다.

 

지금까지 시그널 송신하는 법, 시그널 수신 후 handling 하는 법, 시그널을 block 하는 법에 대해 알아보았습니다.

 

시그널의 처리는 리눅스 시스템 프로그래밍에서 가장 까다로운 부분입니다.

핸들러는 메인 프로그램과 동시에 (concurrent) 실행되고, 전역 변수를 공유하며, 그래서 메인 프로그램과 다른 핸들러들과 뒤섞일 수 있습니다. 어떻게, 언제 시그널들이 수신될 수 있는지 종종 직관적이지 않습니다. 다른 시스템들은 다른 시그널 처리방식을 갖습니다.

 

따라서, 안전하고 정확하고 이식성 높은 시그널 핸들러를 작성해야만 합니다.

 

다음 연습 문제를 보며 시그널 핸들러의 동작에 대해 잘 이해했는지 확인해봅시다.

위 코드의 출력은 어떻게 될까요?

먼저, main 함수에서 signal 함수를 통해 sigusr1 함수의 핸들러를 handler1 으로 지정해줍니다.

그리고 global 변수 counter 를 출력합니다. (2가 출력될 것입니다.)

이후 fork()를 통해, 자식 프로세스를 생성합니다. 그리고 자식 프로세스는 while (1) 을 돌게됩니다.

그리고 부모 프로세스는 자식 프로세스에 sigusr1 시그널을 송신합니다. 그리고 waitpid 에 의해 자식 프로세스가 종료될 때까지 부모 프로세스는 suspend 됩니다.

sigusr1 을 수신한 자식 프로세스는 handler1을 호출합니다. handler1에 의해서 자식 프로세스의 counter는 1 감소하고 이것을 출력합니다. (1이 출력될 것입니다.) 그리고 자식 프로세스는 exit(0)를 만나 종료됩니다. 

자식 프로세스가 종료되었으므로 부모 프로세스는 다시 동작하기 시작합니다. 그리고 부모 프로세스의 counter 는 1 증가하고 이를 출력합니다. (3이 출력될 것입니다.) 왜 출력값이 2(2-1+1)가 아니라 3(2+1)일까요?

왜냐하면 fork()는 부모 프로세스의 context를 완전히 복사(Copy) 하여 자식 프로세스를 만들기 때문입니다. 따라서 부모 프로세스의 global 변수 counter와 자식 프로세스의 global 변수 counter 는 따로 동작하게 되는 것입니다.

마지막으로, 부모 프로세스는 exit(0)를 만나 종료됩니다.

 

이를 프로세스 분기를 나타낸 그림으로 이해해 보겠습니다.

 

위 문제에 대한 프로세스 분기 그림입니다.

아까 시그널 핸들러를 다룰 때 handler와 메인 프로그램은 concurrent 하게 실행된다고 이야기했습니다.

 

다음 코드를 보며 어떤 일이 발생할 수 있는지 생각해봅시다.

Main Program
handler

먼저 메인 프로그램부터 살펴보면 N개의 자식 프로세스를 생성한 뒤 자식 프로세스는 /bin/date 파일을 execve 합니다.

그리고 부모 프로세스는 모든 시그널 블럭을 차단한 뒤 job structure에 자식 프로세스의 아이디를 넣고 다시 시그널 블럭상태를 이전으로 복원합니다.

 

handler 함수를 살펴보면, 먼저 sigchld 의 handling 동안 pending 될 수 있는 sigchld 를 놓치지 않기 위해서 반복문으로 waitpid 를 수행하고 있습니다. 그리고 자식 프로세스가 좀비가 되면 모든 시그널을 block 하고 job structure에서 자식 프로세스의 아이디를 뺍니다. 그리고 다시 시그널의 블럭상태를 이전으로 복원합니다.

 

handler와 main program은 concurrent하게 실행된다는 점에 유의하며 다시 한번 살펴봅시다. Main Program에서 addjob(pid)이 수행되기 전에, 자식 프로세스가 bin/date를 execve 후 종료되어 좀비상태가 될 때마다 커널은 main program에 sigchld를 송신하고 sigchld가 blocked 된 시그널이 아니라면 main program 은 handler를 호출하고 deletejob(pid) 을 수행할 수도 있습니다. 즉, 등록되지 않은 pid를 제거하면서 오류가 발생할 수 있다는 이야기입니다. 이런 상황을 main program과 handler가 race 상태에 있다고 이야기합니다.

 

이런 문제점을 해결하려면 코드를 어떤 식으로 설계해야할까요? 조금 전의 race 현상은 main process 에서 addjob(pid)가 수행되기 전에 sigchld가 발생하여 handler가 concurrent 하게 실행되면서 deletejob(pid)가 addjob(pid)보다 먼저 실행되었던 것을 인지하며 다음 코드를 살펴보도록 합시다.

 

경주(Race)현상의 제거

먼저 main program에서 fork를 통해 자식 프로세스를 생성하기 전에 sigchld를 block 해줍니다. 그리고 자식 프로세스에서는 sigchld가 무시되지 않아도 상관이 없으므로 sigchld의 block을 해제해줍니다. 이 경우 addjob(pid)가 수행되기 전 자식 프로세스가 종료되고 좀비가 되어도, main program은 sigchld를 수신하지 않고, handler가 실행될 일이 없습니다. 그리고 다시, main program은 모든 signal을 block 하고 addjob(pid)를 수행한 뒤 sigchld의 block을 해제해줍니다. 그러므로 main program은 addjob을 수행한 뒤 sigchld 를 handling 하므로, addjob 되지 않는 pid를 deletejob 할 일이 없어집니다.

 

sigchld 를 handling 할 때 명시적으로 핸들러를 기다리는 방식에 대해 알아보겠습니다. 코드는 다음과 같습니다.

sigchld handler, waitpid 를 통해 자식 프로세스를 정리하고 전역변수 pid 에 자식 프로세스의 프로세스 아이디를 초기화

 

foreground job 이 종료될 때까지 기다리는 것과 유사합니다.. (fg / bg 구분 하려면 분기문 하나가 더 필요합니다.)

spin loop 부분을 살펴보면, pid 가 0 인 경우에 계속해서 반복문을 돌게됩니다. 그러다가 자식 프로세스가 하나 종료되면 커널에 의해 main program (부모 프로세스) 로 sigchld 를 수신합니다. 그러면 sigchld handler 에서 전역변수 pid 에 자식 프로세스 아이디를 할당시킵니다. 그러므로 while 의 조건문이 끝나고 반복문을 벗어나게 됩니다. 하지만 main program은 sigint를 수신받더라도, handler 에서 아무것도 해주지 않으므로, 계속해서 반복문을 돌게됩니다. 즉 프로그램이 종료되지 않습니다.

 

그러나 이러한 spin loop 방식은 CPU cycle 의 낭비가 큽니다. 우리는 다음과 같은 2가지 방법을 생각해 볼 수 있습니다.

pause 이용 & sleep 이용

pause 함수는 앞서 말했 듯, 자식 프로세스의 종료나 정지가 일어나기 전까지 부모 프로세스를 정지시키는 함수였습니다. spin loop 의 문제가 계속 반복문을 타면서 CPU Cycle을 낭비하여 발생하는 문제니 pause 로 부모 프로세스를 정지시킨다는 아이디어는 꽤 타당합니다. 하지만 이렇게 코드를 짤 경우 race 가 발생합니다. race 는 signal handler 와 main program 이 concurrent 하게 실행되기 때문에 발생했다는 것을 다시 상기해보며 왜 race 가 발생하는지 살펴봅시다.

 

문제가 발생하지 않는, 즉 이 코드를 짠 사람이 의도한 것은 먼저 반복문에서 pid 가 0 임을 체크하고 pause 로 부모 프로세스를 정지시켰다가, 자식 프로세스가 종료되면 sigchld 가 발생하고 sigchld가 발생하면 pause 가 풀리고 handler에 의해 전역변수 pid 가 자식 프로세스의 아이디로 초기화 되어, 반복문을 벗어날 수 있기를 의도한 것입니다.

 

하지만, 반복문에서 pid 가 0 임을 체크하고, pause 로 진입하기 전에 자식 프로세스가 종료되고 sigchld 가 발생하며 pid 가 자식 프로세스의 id로 초기화가 된 이후에 부모 프로세스가 pause가 되면, 부모 프로세스의 pause는 이후에 풀릴 수가 없습니다.

 

그렇다면 두번째 방법인 sleep을 이용하는 것은 어떨까요? pause와 같이 반복문이 풀리지 않을 일은 없지만, 1초 동안이나 부모 프로세스가 동작하지 않는 것을 반복합니다. CPU 의 클럭 수를 생각해보면 (보통 Ghz) 1초는 결코 짧은 시간이 아니게 됩니다.

 

이 두가지 문제점을 모두 해결할 수 있는 방법이 바로 sigsuspend를 이용하는 것입니다. 먼저 sigsuspend 가 무엇인지 알아보겠습니다.

 

int sigsuspend(const sigset_t *mask)

  • blocked = mask 를 수행하고, 프로세스 종료 시그널 또는 핸들러가 필요한 시그널을 수신할 때까지 프로세스가 블록됩니다.
  • 프로세스 종료가 기본동작인 경우에는 곧바로 종료합니다.
  • 핸들러를 돌려야 하는 경우에는 핸들러를 리턴한 후에 sigsuspend 이전의 blocked 로 복원합니다.

아래의 코드를 원자형으로(interrupt 불가능) 구현한 것과 동일합니다.

 위에서 다루었던 spin loop 을 sigsuspend 를 이용하면 다음과 같이 쓸 수 있습니다.

위의 sigsuspend를 다음과 같이 풀어 작성해보겠습니다.

'첫번째 sigprocmask 를 통해 sigchld를 unblock 할 때 sigchld 가 sigchld_handler 에 의해 처리된 후에 pause()가 실행될 경우 무한반복에서 빠져나오지 못하지 않을까?' 라는 의문이 생길 수 있습니다.

 

하지만 sigsuspend 는 원자적으로 실행되기 때문에 pause()로 진입하기 전 sigchld 시그널을 "수신"하지 않습니다. 따라서 우리가 의도했던 대로 잘 작동하게 됩니다.