17 minute read

Computer Systems : A Programmer’s Perspective

프로세서에 처음 전원을 공급할 때부터 전원을 끌 때까지, PC(program counter)는 일련의 값들을 따라 바뀌고, 각 값들은 수행할 instruction 각각의 주소가 된다. 이러한 control transfer들을 control flow, 혹은 flow of control 이라고 부른다.

가장 간단한 형태의 control flow는 연속해서 수행하는 두 instruction이 메모리 상에서 인접한, “smooth”한 경우이다. 이런 smooth flow에 급격한 변화가 생기는 일은 jump, call, return과 같은 instruction에 의해 일어나고 이 때는 연속해서 수행되는 두 instruction이 메모리 상에서 인접하지 않는다. 이렇게 control flow에 변화를 주는 instruction들은 프로그램의 변수로 표현되는 internal program state의 변화에 프로그램이 반응하도록 하는 데 필수적이다.
하지만 우리의 프로그램은 프로그램 내부의 변수들에 의해서만 program state가 바뀌지 않는다.

  • 하드웨어가 일정한 시간 간격으로 변해야 될 때도 있고,
  • 통신에서 패킷은 언제 도착할 지 모르며,
  • 디스크에 요청한 데이터는 언제 로드가 완료될 지 모르고,
  • parent process는 child process를 생성하고 나면 나중에 children이 종료될 때 notify 받아야 하는데, 언제 종료될 지는 아무도 모른다.

이런 상황에 반응해서 control flow에 abrupt change를 만들기 위해 exceptional control flow 를 만들었다. ECF는 computer system의 어떤 layer에서든 일어날 수 있는데, 하드웨어가 감지해서 abrupt control transfer를 만들기도 하고, 운영체제는 kernel이 process간에 context switch를 한다. 어플리케이션에서는 프로세스 간에 signal을 주고받으며 control transfer를 일으키기도 한다.

이처럼 ECF는 꼭 뭔가 잘못된 에러 상황에서만 필요한 게 아니다.

  • ECF를 이해해야 I/O, 프로세스, 가상 메모리를 구현하기 위한 운영체제의 기본적인 매커니즘을 이해할 수 있고,
  • 각 응용 프로그램들이 운영체제와 상호작용하는 것을 이해할 수 있다. 가장 대표적으로 모든 응용 프로그램은 디스크에서 데이터를 읽고 쓰고, 새 프로세스를 만들고 종료할 때 system call 을 통해 커널을 호출하게 된다.
  • concurrency를 이해하기 위해서도 ECF의 이해는 필수적이다. (12장)
  • 마지막으로, Java나 C++과 같은 응용 프로그램에서 exception의 동작 원리를 이해하기 위해서 ECF의 이해가 필요하다.

8.1 Exceptions

Exception은 일부는 하드웨어, 일부는 OS에 의해 수행되는 exceptional control flow이다.
어떤 process의 state 의 변화에 반응하는 control flow의 abrupt change이다. 이 state 는 프로세서 내부에 여러 bit와 signal로 encode되어 있고, 이 state 의 변화를 event 라고 한다. event 는 프로그램이 수행하는 instruction에 의해 생길 수도 있다. vm의 page fault, arithmetic overflow, divide by zero 등이 대표적이다.
반대로 현재 수행중인 instruction과 관련이 없이 생길 수도 있다. 대표적으로 system timer나 I/O 등이 있다.

어떤 경우든 processor가 이벤트를 감지하면 indirect procedure call(=exception)을 부른다. 각 event 마다 어떻게 할 지 써 있는 exception table 이라는 table에 따라서 OS의 exception handler 로 control transfer가 일어난다. exception handler 가 할 일을 끝마치면, event 의 종류에 따라 세 가지 중 하나로 이어진다.

  1. current instruction, 즉 event 가 발생했을 때 수행 중이던 instruction으로 돌아간다.
  2. next instruction, 즉 event 가 발생했을 때 수행 중이던 instruction의 다음 instruction으로 간다.
  3. 그대로 프로그램을 끝낸다.

8.1.1 Exception Handling

하드웨어가 하는 일과 소프트웨어가 하는 일을 나눠서 살펴보자.
가능한 exception의 각 종류마다 고유의 nonnegative integer가 부여되어 있는데, 이를 exception number 라고 부른다. 일부는 processor를 만든 사람이 할당했고, 일부는 OS의 커널을 만든 사람들이 할당해 놓았다.
시스템이 처음 켜지면 OS는 exception table 을 초기화하고 할당한다. 그러면 table의 각 entry k는 k번 exception의 handler의 주소값을 가지게 된다. Run time에 processor가 event를 감지하고 k번째 exception이구나 하고 판단하게 된다. 그러면 exception table base register 라고 불리는 특별한 CPU register에다가 exception number를 더해서 table의 올바른 entry를 찾아가 해당 exception handler의 주소를 찾아가게 된다.
이 과정은 기존의 procedure call과 비슷해 보이지만, 다른 점은 다음과 같다.

  • Procedure call은 return address를 stack에 저장해 놓는다. 하지만 exception은 return address가 exception의 종류에 따라 current/next instruction이다.
  • Exception에서는 processor가 스택에 추가적인 processor state를 저장한다. 이는 handler가 return해서 중단됐던 프로그램이 재시작될 때 필요한 정보이다.
  • Kernel로 transfer될 때는 이런 정보들이 user의 stack이 아닌, kernel의 stack에 저장된다.
  • Exception handler는 모든 system resource에 접근 가능한 kernel mode로 실행된다.

하드웨어가 exception을 trigger하면, 그 다음 일은 소프트웨어의 몫이다. OS가 exception handling을 끝내면 특별한 “return from interrupt” instruction을 수행해서 stack에 저장해 놨던 것들을 pop해서 되돌아 간다.

8.1.2 Classes of Exception

Exception은 네 가지 클래스로 나눌 수 있다.

1. Interrupts

Interrupt는 나머지 셋과 달리 ascynchronic하게 일어난다. 즉 I/O signal과 같은 외부적인 요인의 결과로 발생한다. Hardware interrupt는 이런 관점에서 asynchronous한데, instruction의 결과로 생기는 게 아니기 때문이다. 그래서 hardware interrupt에 대한 exception handler는 interrupt handler라고도 불린다.
I/O 장치는 processor의 특별한 pin을 trigger하고(1로 만들고), system bus에 해당 exception number를 태워서 보낸다. 그 때 실행 중이던 instruction이 끝나면, processor는 pin이 high인 것을 감지하고 system bus에서 exception number를 읽고 해당 interrupt handler를 부르게 된다.
Exception handler가 return하면, 아무 일도 없었다는 듯 다음 instruction을 수행한다.

2. Trap

Trap는 고의로 발생시킨 exception이다. Interrupt handler처럼 trap handler는 처리가 끝나면 next instruction으로 돌아간다.
Trap의 가장 중요한 사용처는 system call 이다. 파일을 읽거나(read), 새 process를 만들거나(fork) 새 프로그램을 로드하는 것(execve)은 커널의 권한이 필요하다. syscall instruction을 실행하면 trap이 작동해서 exception handler가 그에 맞는 kernel routine을 부르게 된다. 사용자가 보기에는 이 과정은 일반적인 함수를 부르는 것과 다를 게 없지만, 실제로는 user mode에서 kernel mode로 넘어가기 때문에 큰 차이가 있다.

3. Faults

Fault는 unintentional하지만, recoverable한 exception이다. Fault handler는 fault를 처리한 후, current instruction을 다시 수행하거나 프로그램을 abort한다.
대표적인 예시로 page fault가 있다. vm의 특정 주소의 page가 memory에 없을 때, fault handler는 disk에서 해당 페이지를 불러 온 후 current instruction으로 control을 넘긴다. 그러면 이제 메모리에 해당 page가 있으므로 fault가 작동하지 않고 정상적으로 instruction이 실행된다.

4. Aborts

Abort는 unintentional하고, unrecoverable한 exception이다. 주로 parity error 등의 hardware error같이 치명적인 에러로 인해 생긴다. Abort handler는 fault처럼 처리 후 instruction으로 되돌아가지 않고 그대로 프로그램을 종료한다.

8.1.3 Exceptions in Linux/x86-64 Systems

x86-64 system에는 256개의 exception이 존재한다. 0~31번은 Intel architects에서 정의된 exception이기 때문에 어느 x86-64 system이든 동일하고, 32~255번은 OS가 정의한 interrupt와 trap이다.
몇 가지 예시는 다음과 같다.

Exception number Description Exception class
0 Divide error Fault
13 General protection fault Fault
14 Page fault Fault
18 Machine check Abort

Linux/x86-64 Faults and Aborts

  1. Divide error 는 프로그램에서 0으로 나누려고 하거나 결과 값이 너무 클 때 발생한다. Unix는 그냥 프로그램을 abort하고, Linux shell은 “Floating exception”이라고 메시지를 띄운다.
  2. General protection fault 는 여러 가지 이유로 발생하는데, 주로 프로그램이 vm의 정의되지 않은 곳을 참조하거나, 읽기 전용인 부분에 쓰려고 할 때 발생한다. Linux는 이 fault를 recover하지 않고, Linux shell은 그 익숙한 “Segmantation faults”를 출력한다.
  3. Page fault, Machine check 는 본문 내용과 같다.

Linux/x86-64 System calls

Linux는 커널에게 요청할 수 있는 수백 개의 system call을 제공한다. C에서 syscall 함수를 이용해서 직접 system call을 부를 수는 있지만 보통 그렇게는 하지 않고, C 표준 라이브러이에서 system call을 편하게 쓸 수 있는 wrapper function을 제공해 준다.
이 부분의 자세한 내용은 x86-64 어셈블리어를 몰라서 패스.

8.2 Processes

Exception은 운영 체제의 kernel이 process 를 제공하기 위한 basic building block이다. process 의 고전적인 정의는 an instance of a program in execution 이다. 우리가 executable object file을 shell에 쳐서 프로그램을 실행시키면 shell은 process를 만들어서 거기서 프로그램을 돌린다. 프로그램도 그 안에서 새로운 process들을 만들기도 한다. Process는 개별 프로그램이 시스템의 CPU와 메모리를 독점적으로 사용하는 듯 한 illusion을 제공한다. 또한, 각 프로그램이 instruction들이 끊기지 않고 순서대로 쭉 실행되는 듯한 illusion을 제공한다.

8.2.1 Logical Control Flow

Process는 각 프로그램이 CPU를 독점적으로 사용하는 듯한 illusion을 주지만, 실제로는 여러 프로그램들이 동시에(concurrently) 실행 중이다. 각 프로그램들의 logical control flow는 잠시 중단되었다가 다시 실행되고, 다시 중단되었다가 다시 실행되며 여러 process가 동시에 진행된다. 프로그램 입장에서는, 중단되기 직전의 state가 그대로 복구되기 때문에 자신이 CPU와 메모리를 독점적으로 사용하는 것으로 인식한다.

8.2.2 Concurrent Flows

두 logical flow가 시간상으로 서로 overlap 되면 concurrent flow라고 부른다. X가 1초에 시작해서 3초에 중단됐다가 다시 5초에 시작해서 8초에 끝나고, Y는 3초부터 5초까지 진행되고, 8초부터 10초까지 Z가 실행된다고 할 때, X와 Y는 concurrent하다. 이 경우 X는 2개의 time slice 로 구성되고, X와 Y의 multitasking 이 일어나고 있는 것이다.

8.2.3 Private Address space

Process는 private address space를 통해 각 프로그램이 전체 메모리를 독점적으로 사용하는 듯한 illusion을 준다. Process는 한 프로그램에게 다른 프로세스에서 읽고 쓸 수 없는 virtual address space를 제공한다.

8.2.4 User and Kernel Modes

Processor는 mode bit 를 운용해서, process가 kernel mode 에서 돌고 있는지 user mode 에서 돌고 있는지 구분한다. kernel mode 의 process는 모든 instruction을 실행할 수 있고 시스템의 어떤 메모리 주소에도 접근할 수 있다. 반대로 user mode 의 process는 이러한 privileged instruction 에는 접근하지 못한다. 즉, mode bit를 바꾸거나 CPU를 멈추거나 I/O를 수행할 수 없다. 또한 address space의 kernal area의 code와 data에 직접 접근할 수 없다. 직접 접근하지 못하는 대신, kernel에게 system call을 보낸다.
보통 응용 프로그램은 user mode 에서 돌아가기 때문에, kernel mode로 전환하기 위해서는 exception을 이용해야 한다. Exception handler는 kernel mode 에서 작동하고, exception 처리가 끝나면 user mode 로 돌아간다.

8.2.5 Context Switches

운영체제의 커널이 multitasking 하는 방법은 여러 개의 프로세스들을 concurrent하게 진행시키는 것이었다. 여기서 context switch 라는 exception control flow를 사용한다.
먼저, context 란 kernel이 유지하고 있는 각 process를 중단(preempt)했다가 재시작 하기 위해서 필요한 정보이다. General-purpose register, floating-point register, PC, user stack, kernel stack, page table, process table 등등 여러 정보들이 context 에 속한다.
운영체제 커널은 프로세스 수행 중 특정 지점에서 지금의 프로세스를 중단시키고 이전에 돌아가다가 중단된 다른 process를 재시작할지 결정하는데, 이런 결정 과정을 scheduling 이라고 부르고 커널의 scheduler 에 의해 이루어진다. 커널이 새 프로세스를 돌리기로 결정하는 것을 schedule the process 라고 표현하고, 이때 커널은 현재 프로세스의 context를 저장하고, 저장되어 있던 중단된 프로세스의 context를 복구한 다음 이 프로세스게 control을 넘긴다. 이 과정을 context switch 라고 부른다.

Context switch는 커널이 system call을 실행할 때 일어날 수 있다. 시스템 콜이 event를 기다리느라 block하면, 커널은 해당 프로세스를 sleep시키고 다른 프로세스로 switch할 수 있다. 예를 들어 어떤 프로세스가 disk access를 위해 read 라는 system call을 불렀다고 하면, disk에서 해당 데이터를 가져오는 event를 기다리지 않고 context switch 를 통해 다른 프로세스를 실행하는 것을 선택할 수 있다. sleep system call은 명시적으로 해당 프로세스를 멈추게 한다. 일반적으로, 시스템 콜이 read 처럼 꼭 block하는 명령어가 아니더라도 커널은 exception handler의 동작이 끝나도 해당 프로세스에게 바로 control을 돌려주기보다 다른 context switch를 선택한다.

Context switch는 interrupt의 결과로도 발생할 수 있다. 예를 들어서, 모든 시스템은 1ms나 10ms정도 되는 주기로 interrupt를 발생시키는 메커니즘을 가지고 있는데 이 timer interrupt가 발생하면 커널은 해당 프로세스가 충분히 오래 돌았다고 판단하고 context switch를 한다.
Figure 8.14에는 프로세스 A가 시스템 콜 read 를 불러서 trap handler가 disk에게 작업을 요청하는 동안 프로세스 B로 context switch가 일어나고, 이 때는 kernel code가 실행되는 부분이다. Context switch가 끝나면 다시 user code로 프로세스 B가 진행되고, dist가 데이터를 다 불러오고 interrupt를 보내면 다시 kernel code에서 context switch가 일어나 프로세스 A에서 read 직후부터 다시 진행된다.

8.3 System Call Error Handling

Unix의 system-level function은 에러가 발생했을 때 -1을 반환하고, 전역 변수 errno 에 어떤 상황이 생겼는 지 알려주는 값을 설정한다. 그래서 프로그램을 짜는 사람은 항상 에러를 확인해야 하지만 코드를 복잡하게 만들고 가독성을 떨어트리기 때문에 하지 않는 경우가 많다.

if ((pid=fork()) < 0) {
    fprintf(stderr, "fork error: %s\n", strerror(errno));
    exit(0);
}

이렇게 에러가 발생했으면 에러를 출력해 주는데, 간단하게 만들기 위해 error-reporting function 을 만들어 주기도 한다.

void unix_error(char *msg)
{
    fprintf(stderr, "fork error: %s\n", strerror(errno));
    exit(0);
}

그러면 우리는 앞의 fork() 에서

if((pid = fork()) < 0)
    unix_error("Fork error");
return pid;

이렇게만 해 주면 된다.
더 간단하게 error-handling wrapper 를 만들어도 된다.

pid_t Fork(void)
{
    pid_t pid
    if((pid=fork() < 0)
        unix_error("Fork error");
    return pid;
}

이렇게 대문자 F로 구별되도록 fork() 의 wrapper function을 만들어 준 후, pid = Fork() 와 같이 사용하면 된다.

8.4 Process Control

Unix에는 C 프로그램에서 process들을 다룰 수 있도록 여러 system call들을 제공한다. 이 절에서는 중요한 함수들 몇 가지와 사용 예시를 살펴본다.

8.4.1 Obtaining Process IDs

각 process는 고유의 positive non-zero ID, PID 를 갖는다.

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

getpid는 함수를 부른 process의 PID를 반환하고 getppid는 parent process의 PID를 반환한다. 반환값의 자료형은 pid_t로, Linux에서는 types.h에서 int로 정의되어 있다.

8.4.2 Creating and Terminating Process

프로그래머의 관점에서 process는 세 가지 상태 중 하나에 있다고 생각할 수 있다.

  • Running : process가 CPU에서 실행중이거나, kernel에게 schedule 되기를 기다리는 중이다.
  • Stopped : process의 실행이 suspended 되었고, scheduled 되지 않는다. 프로세스는 SIGSTOP, SIGTSTP과 같은 signal에 의해 이 상태가 되며, SIGCONT같은 별도의 signal을 받지 않는 한 계속 이 state에 머무른다.
  • Terminated : 프로세스가 영구적으로 멈췄다. 이 상태가 되는 방법은 세 가지가 있다.
    1. 프로세스를 terminate하는 signal을 받을 경우
    2. main routine으로부터 return해 오는 경우
    3. exit 함수를 부르는 경우
#include <stdlib.h>
void exit(int status);

exit 함수는 인자로 받은 status로 프로세스를 종료한다. 종료하기 때문에 반환값이 없는 함수이다.

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);

fork() 함수는 parent process가 새 child process를 만들게 한다. 새로 형성된 child process는 parent와 아주 거의 동일하다. Child process는 parent의 user-level virtual address space를 복사해서 갖게 되는데, 여기에는 code, data segment, heap, shared libraries, user stack, open file descriptor 등이 모두 포함된다. Parent와 child의 가장 중요한 차이점은 다른 PID를 갖는다는 것이다.
fork() 함수는 한 번 불리지만 두 번 return하게 된다. Parent process에서의 반환값은 child의 PID이고, child process에서의 반환값은 0이다. 그리고 PID는 항상 음이 아닌 정수이기 때문에, fork() 의 반환값으로 현재 process가 child인지 parent인지 확실하게 구별할 수 있다.

int main()
{
    pid_t pid;
    int x = 1;
    
    pid = fork();
    if(pid == 0) {
        printf("Child: x=%d\n", ++x);
        exit(0);
    }
    
    printf("Parent: x=%d\n", --x);
    exit(0);
}

출력 값은 두 줄이 나오게 된다. 이때 parent가 먼저 실행될 지 child가 먼저 실행될 지를 따져 보면, parent process와 child process는 concurrently하게 진행되기 때문에 딱 이 둘만 돌아가면 parent가 먼저 실행되겠지만, 다른 프로그램들도 많아서 커널이 scheduling을 꼬아 버리면 child가 먼저 실행되기도 한다. 즉 정답은 “예상할 수 없다”이다. 또한, 앞서 말했듯이 parent와 child process는 address space를 동일하게, 그러나 별개의 것으로 가지기 때문에 int x=1로 선언한 x는 fork() 된 직후에는 1로 같지만 이후에 다르게 일어나는 일에 대해서는 양쪽의 별개의 x가 값이 달라지는 것을 확인할 수 있다. 이 둘은 file들도 공유하기 때문에 parent가 열어 놓은 stdout file도 child가 그대로 사용하게 되어서 child process의 printf도 같은 화면에 출력되는 것으로 이해할 수 있다.

8.4.3 Reaping Child Processes

프로세스가 어떤 이유로 terminate되면, 커널은 system에서 이를 바로 지워버리지 않는다. 종료된 프로세스는 그 부모가 reap 할 때까지 terminated state로 남겨져 있다. 부모가 종료된 자식 프로세스를 reap 하면 커널은 자식 프로세스의 exit status를 부모에게 넘기고 종료된 프로세스를 삭제한다. 이렇게 reaped 되기 전의 terminated state로 남아 있는 프로세스를 zombie 라고 부른다.
부모 프로세스가 terminated되면 커널은 init 프로세스로 하여금 종료된 부모 프로세스의 orphaned children을 입양하도록 한다. init 프로세스는 PID가 1로, 시스템이 시작될 때 생성되어서 모든 프로세스의 ancestor같은 절대 종료되지 않는 존재이다. 부모 프로세스가 그의 zombie children을 reap하지 않고 종료해버리면 커널은 init 이 zombie들을 reap하도록 한다. 그러나 shell이나 server같이 long-running하는 프로그램들은 항상 반드시 zombie children을 reap해 줘야 한다. Zombie들은 실행 중이 아님에도 system의 memory resource를 차지하고 있는 중이기 때문이다.
이들을 위해 자식 프로세스가 terminated되는 것을 기다릴 수 있는 함수가 waitpid 함수이다.

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *startusp, int options);

waitpid 는 조금 복잡한데, default로 options=0일 때는 waitpid 를 부른 프로세스는 wait set 에 속한 자식 프로세스가 terminate할 때까지 기다린다. 만약 wait set 의 모든 프로세스가 종료 상태라면 waitpid 는 즉시 -1을 return한다. 반환값은 waitpid 가 return하도록 한 terminated child의 PID가 된다. 이 때가 되면 terminated child는 reaped되고, 커널은 시스템에서 이 프로세스의 모든 흔적을 지운다.
wait set 은 parameter 중에서 pid로 결정한다. pid > 0 인 경우는 해당 PID를 갖는 자식 프로세스가 wait set 이 되고, pid = -1 인 경우는 모든 자식 프로세스들이 wait set 에 속하게 된다. 이 경우에는 wait set 의 자식 프로세스 중 아무거나 terminated 되면 waitpid()가 값을 반환한다.

Modifying the Default Behavoir

waitpid 의 options parameter에 여러 constant를 넣어서 작동 방식을 조정할 수 있다.

  • WNOHANG : 종료된 자식 프로세스가 없으면 바로 0을 반환하고 진행한다. 종료하는 child가 나올 때까지 suspend하는 default와 차이가 있다.
  • WUNTRACED : 자식 프로세스가 terminated되는 것 뿐만 아니라 stopped되는 것에도 반응한다.
  • WCONTINUED : wait set의 running process가 terminated되거나, stopped process가 SIGCONT signal에 의해 resumed될 때까지 기다린다. 이 옵션들을 | 으로 조합할 수 있다.
  • WNOHANG WUNTRACED : wait set의 child들 중에 terminated되거나 stop된 게 없으면 바로 0을 반환하고, 있으면 그렇게 될 때까지 기다린 후 PID를 반환한다.

Checking the Exit Status of a Reaped Child

waitpid 의 세 번째 parameter인 statusp 가 non-NULL이면 waitpid의 return을 일으킨 child의 status information을 encode해서 포인터로 statusp에 넣어 준다. wait.h 파일에는 status를 해석하기 위한 여러 매크로가 정의되어 있다.

  • WIFEXITED : child가 exit()나 return으로 정상적으로 종료됐으면 true
  • WEXITSTATUS : 정상적으로 종료된 child의 exit status를 반환한다. WIFEXITED가 true를 반환할 때만 정의된다.
  • WIFSIGNALED : catch되지 못한 signal에 의해 종료되면 true

등등.. 지금 다 알 필요는 없을 것 같다.

Error Conditions

Calling process가 children이 없으면 waitpid 는 -1을 반환하고, errno 를 ECHILD로 설정한다. waitpid 가 signal에 의해 중단되었으면 -1을 반환하고 errno 는 EINTR로 설정한다.

The wait Function

waitpid(-1, &status, 0) 대신 wait(&status)를 사용할 수 있다.

Examples of Using waitpid

#include "csapp.h"
#define N 2

int main()
{
    int status, i;
    pid_t pid;

    /* Parent creates N children */
    for (i = 0; i < N; i++)
        if ((pid = Fork()) == 0) /* Child */
            exit(100+i);

    /* Parent reaps N children in no particular order */
    while ((pid = waitpid(-1, &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally\n", pid);
    }

    /* The only normal termination is if there are no more children */
    if (errno != ECHILD)
        unix_error("waitpid error");
        exit(0);
}

프로그램을 보면, parent는 N개의 child를 만들고 생성된 child process는 고유한 exit status를 가지고 바로 종료된다. 그 다음 while문에서 parent가 수행한 waitpid 는 종료된 child process의 PID를 반환하고 int status에 exit status를 저장한다. 하나씩, 그러나 순서가 없이 모든 child를 reap하고 나면 waitpud 는 -1을 반환하고 errno의 값을 ECHILD로 설정하므로, 정상적으로 모든 child가 reap되었다면 마지막 if문은 불리지 않게 된다.

여기서 중요한 점은 모든 child process들이 concurrent하게 돌기 때문에 reap되는 데는 순서가 없다는 것이다. 이러한 nondeterministic 한 성질 때문에 concurrency의 결과를 확정적으로 말하기는 아주 어렵다. 아래 코드는 생성된 child process의 PID를 저장하고, waitpid의 첫 번째 parameter로 각 PID를 넣어서 child process의 종료를 생성 순서대로 기다리고 reap하게 만든다.

#include "csapp.h"
#define N 2
int main()
{
    int status, i;
    pid_t pid[N], retpid;
    /* Parent creates N children */
    for (i = 0; i < N; i++)
        if ((pid[i] = Fork()) == 0) /* Child */
            exit(100+i);
    /* Parent reaps N children in order */
    i = 0;
    while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n", retpid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally\n", retpid);
    }
    /* The only normal termination is if there are no more children */
    if (errno != ECHILD)
        unix_error("waitpid error");
        exit(0);
}

8.4.4 Putting Processes to Sleep

sleep 함수는 process를 특정 시간 동안 suspend한다.

#include <unistd.h>
unsigned int sleep(unsigned int secs);

반환값은 시간이 다 지났으면 0을, 아니면 남은 seconds를 반환한다. 후자는 signal에 의해 interrput되었을 경우 일어날 수 있는 경우이다.
pause 함수는 비슷한데, process를 signal을 받을 때까지 유지 sleep 상태로 만든다.

#include <unistd.h>
int pause(void);

반환값은 언제나 -1이다.

8.4.5 Loading and Running Programs

execve 함수는 current process의 context에서 새 프로그램을 load하고 실행한다.

#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);

첫 번째 parameter는 실행할 executable object file의 이름이고, 두 번째 parameter는 argument list, 세 번쨰 parameter는 환경 변수 list이다. 정상적으로 잘 실행됐으면 반환값이 없고, 에러가 난 경우는 -1을 반환한다. fork() 와는 다르게 한 번 call하고 return이 없는 함수이다.
*argv는 리스트 자료구조로 되어 있는 argument list의 첫 번째 원소를 가리키는 pointer이다. 이 list는 NULL이 마지막 원소이고, 각 원소는 string에 대한 pointer이다. Convention으로 argv[0]은 excecutable object file의 filename이 들어간다. 환경 변수 리스트도 비슷한 구조로 되어 있는데, NULL이 마지막 원소이고 각 원소는 “name=value” 의 형태를 한 string을 가리키는 pointer이다.
excevefilename 을 load하면 start-up code를 부르게 된다(Section 7.9). 이 code는 stack을 set-up하고 새 프로그램의 main에게 control을 넘겨 준다. 이 main이 바로 우리가 아는 int main(int argc, char *argv[], char *envp[]);이다.
main이 실행되기 시작할 때 user stack은 아래 그림과 같이 세팅되어 있다. image

포인터 argv는 스택 최하단부에 있는 command-line arg strings list의 첫 원소를 가리키는 포인터가 담긴 argv[0]를 가리킨다. envp도 역시 환경 변수 strings list의 첫 원소를 가리키는 포인터가 담긴 envp[0]을 가리킨다. 스택의 최상단에는 start-up function인 libc_start_main 을 위한 stack frame과 추후 main을 위한 stack frame이 존재한다.
환경 변수를 검색하고, 설정할 수 있는 getenv(), setenv() 함수도 stdlib.h에 마련되어 있다.

8.4.6 Using fork and execve to Run Programs

Unix shell이나 Web server는 forkexecve 를 아주 많이 이용한다. Shell은 사용자 대신 다른 프로그램들을 실행시켜 주는 interactive application-level program인데, read/evaluate 단계와 terminate 단계로 진행된다. Read 단계에서는 사용자가 입력한 command line을 읽어들이고, evaluate step에서는 command line을 parse하고 실행시킨다.
아래 코드(책 Fig 8.23)은 단순한 shell의 main routine을 나타낸다.

#include "csapp.h"
#define MAXARGS 128
/* Function prototypes */
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);
int main()
{
    char cmdline[MAXLINE]; /* Command line */
    while (1) {
        /* Read */
        printf("> ");
        Fgets(cmdline, MAXLINE, stdin);
        if (feof(stdin))
            exit(0);
        /* Evaluate */
        eval(cmdline);
    }
}

command line에 prompt를 출력하고, stdin으로 들어올 사용자의 command line을 기다리다가 eval() 함수로 command line을 evaluate한다.
아래 코드(책 Fig 8.24)는 eval() 함수의 routine을 보여준다.

/* eval - Evaluate a command line */
void eval(char *cmdline)
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE]; /* Holds modified command line */
    int bg; /* Should the job run in bg or fg? */
    pid_t pid; /* Process id */
    
    strcpy(buf, cmdline);
    bg = parseline(buf, argv);
    if (argv[0] == NULL)
        return; /* Ignore empty lines */
        
    if (!builtin_command(argv)) {
        if ((pid = Fork()) == 0) { /* Child runs user job */
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
        
        /* Parent waits for foreground job to terminate */
        if (!bg) {
            int status;
            if (waitpid(pid, &status, 0) < 0)
                unix_error("waitfg: waitpid error");
        }
        else
                printf("%d %s", pid, cmdline);
        }
        return;
}
    
/* If first arg is a builtin command, run it and return true */
int builtin_command(char **argv)
{
    if (!strcmp(argv[0], "quit")) /* quit command */
        exit(0);
    if (!strcmp(argv[0], "&")) /* Ignore singleton & */
        return 1;
    return 0; /* Not a builtin command */
}

먼저 입력된 command line을 parseline 함수로 parse한다. 공백으로 나눠진 command-line의 arguments로 argv 벡터를 만든다.
argv[0]은 build-in shell command이거나, 어떤 executable object file의 이름일 것이다. int builtin_command() 함수를 보면 built-in command는 exit 하나만 구현한 것을 확인할 수 있다.
built-in command가 아닌 경우는 load해 와서 실행해야 한다. 여기서 fork() 를 이용해서 child process에서 이 프로그램을 실행시킨다. execve 의 반환값이 -1이라면 뭔가 문제가 발생한 것이므로 에러 메시지를 띄우고 종료해 준다.
여기서 중요한게 background에서 돌릴것인지, foreground에서 돌릴 것인지를 결정하는 것이다. 사용자가 command line에서 마지막에 & 를 붙여 주면, 그것은 background에서 실행하겠다는 뜻이다. Shell이 그 프로그램의 완료를 기다리지 않고 바로 다음 명령을 받는다는 의미이다. 이를 위해서 parseline() 함수에서 이를 확인해서 0 또는 1을 반환해 준다.
bg가 false이면, 즉 foreground에서의 실행이면 waitpid 를 이용해 해당 child process가 끝날 때까지 기다린다. background에서의 실행이면 return해서 사용자의 다음 명령을 기다린다.

이 예시는 background children을 reap하는 과정이 빠져 있다. 이를 위해서는 signal이 필요하다.

Tags:

Categories:

Updated: