Back

프로세스 (2)

PCB, 프로세스 생성, 운영체제 구현

PCB (Process Control Block)

운영체제도 하나의 프로그램이므로, 다양한 정보를 저장하기 위한 자료구조들을 갖고 있다.

그리고 프로세스 관리를 위해 프로세스 관련 정보들을 저장하고 있는 자료구조가 바로 PCB(Process Control Block)이다. 즉, 프로세스는 운영체제 내에서 프로세스의 정보들을 가지고 있는 자료구조로 표현될 수 있다. 따라서 PCB를 Process Descriptor라고 부르기도 한다.

PCB는 프로세스에 관련된 모든 정보들을 모두 저장하고 있다.

  • CPU Register : 현재 프로세스가 CPU에서 돌아가고 있을 경우 현재 CPU의 상태는 Register의 값으로 정의될 수 있으므로 관련 값들을 저장

  • Process Information

    • PID : Process ID
    • PPID : Parent Process ID
    • Process Group
    • Priority : CPU 할당 우선순위
    • Process State : 프로세스 현재 상태
    • Signals
  • CPU 스케줄링 정보

  • 메모리 관리 정보

    . . .

실제 리눅스에서는 task_struct라는 자료구조가 PCB의 역할을 한다. (Linux 3.2.0에서 3248 Byte)

XV6 PCB

UNIX V6를 ANSI C로 이식(Porting)한 교육용 운영체제 XV6에서는 Proc이라는 자료구조가 PCB의 역할을 한다.

PCB의 구조를 살펴보기 전 관련된 몇 가지 자료구조를 살펴보자.

Context

context 구조체는 현재 프로세스의 register 정보를 저장하고 있다.

struct context {
    int eip;	// Index pointer register
    int esp;	// Stack pointer register
    int ebx;	// Called the base register
    int ecx;	// Called the counter register
    int edx;	// Called the data register
    int esi;	// Source index register
    int edi;	// Destination index register
    int ebp;	// Stack base pointer register
}

실행 중인 프로세스가 사용하는 레지스터의 값은 지속적으로 변하게 된다. 따라서 레지스터 값을 저장함으로써, 현재 프로세스의 상태를 스냅샷(Snapshot) 찍듯 저장할 수 있다.

context 구조체는 다음과 같이 활용 가능하다.

  • 특정 프로세스가 Running 상태에서 Ready 상태로 바뀔 때
    • 현재 프로세스 상태를 context 구조체에 저장
  • 해당 프로세스가 다시 Running 상태로 바뀔 때
    • context 구조체에 저장된 정보를 레지스터에 복사
    • 기존에 실행하던 상태를 그대로 이어서 실행 가능

Process State

procstateenumerator 타입으로, 프로세스의 현재 상태를 다음 6가지로 정의하고 있다.

enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

Struct Proc

다음은 Proc 구조체의 코드이다.

 struct proc {
   uint sz;                     // Size of process memory (bytes)
   pde_t* pgdir;                // Page table
   char *kstack;                // Bottom of kernel stack for this process
   enum procstate state;        // Process state
   int pid;                     // Process ID
   struct proc *parent;         // Parent process
   struct trapframe *tf;        // Trap frame for current syscall
   struct context *context;     // swtch() here to run process
   void *chan;                  // If non-zero, sleeping on chan
   int killed;                  // If non-zero, have been killed
   struct file *ofile[NOFILE];  // Open files
   struct inode *cwd;           // Current directory
   char name[16];               // Process name (debugging)
 };

앞서 살펴본 contextprocstate외에도 pid, parent process, file 등 다양한 정보를 저장하기 위한 데이터가 포함되어 있는 것을 볼 수 있다.

Process State Queues

운영체제는 시스템의 원활한 작동을 위해 모든 프로세스의 상태를 추적할 필요가 있다. 이를 위해 사용하는 자료구조가 바로 프로세스 상태 큐(Process State Queue)이다.

  • Ready Queue : 현재 Ready 상태인 프로세스들의 PCB를 저장하는 큐
    • Ready Queue에서 Process를 하나 선택하여 CPU를 할당해 준다.
  • Wait Queue : 특정 이벤트를 기다리는 프로세스들의 PCB를 저장하는 큐

시스템에 존재하는 모든 프로세스의 PCB들은 현재 상태에 따라 Ready Queue 또는 Wait Queue에 저장되어 있다. 각 프로세스의 상태가 변하게 되면, 저장된 Queue 또한 달라지게 된다.

Context Switching

CPU가 수행하는 프로세스가 바뀌는 현상이다. 이를 위해서는 다음과 같은 작업들을 수행해야 하므로 오버헤드가 발생한다.

  • 기존에 실행하던 프로세스의 상태 정보를 저장하고 새로운 프로세스의 상태 정보를 복구한다.
  • 메모리 캐시를 비우고(Flush) 다시 불러온다(Reload).
  • 관련된 다양한 자료구조(테이블, 리스트, etc.)들을 업데이트한다.

이러한 오버헤드는 하드웨어에 따라 달라진다.

Context Switch는 초당 100~1000회 정도 수행되므로, Context Switch 오버헤드는 성능에 매우 큰 영향을 미친다.

다음은 Process A에서 Process B로 Context Switching이 발생하는 과정을 나타낸 것이다.

Context Switching among Kernel, Hardware, Processes
Context Switching among Kernel, Hardware, Processes

간단하게 설명하자면, 다음과 같은 과정을 거쳐 Context Switching이 일어난다.

  • Process A가 실행 상태일 때, Timer Interrupt가 발생한다.
  • 운영체제(Kernel)는 CPU를 Process A로부터 회수한다.
  • switch() (Context Switching을 수행하는 routine)를 호출한다.
    • Process A의 레지스터 정보를 A의 PCB에 저장한다.
    • Process B의 PCB로부터 Process B의 레지스터 정보를 복구한다.
  • 하드웨어를 거쳐 Process B를 실행 상태로 변경한다.

Process 생성

유닉스 운영체제에서의 Process 생성은 fork()exec()라는 두 시스템 콜을 사용해 2단계로 이루어진다.

  • fork() : 새로운 프로세스를 생성한다.
    • 기존 프로세스를 복제(Clone)하여 새로운 프로세스를 생성한다.
    • 원본 프로세스를 부모(Parent) 프로세스, 새로 생성된 프로세스를 자식(Child) 프로세스라고 한다.
    • 모든 프로세스는 부모 프로세스를 갖는다.
    • 부모 프로세스로부터 대부분의 정보를 상속받는다.
      • fork() 직후에는 PID를 제외하면 거의 모든 정보가 부모 프로세스와 동일하다.
    • 부모 프로세스는 자녀 프로세스가 종료될 때까지 기다리거나, 혹은 자신이 원래 수행하던 코드로 바로 돌아갈 수도 있다.
  • exec() : fork() 후, 현재 프로세스 이미지를 새로운 프로그램을 로드해 덮어쓴다.

cf. Windows에서는 fork()exec()을 하나로 합친 CreateProcess()라는 API를 사용해 프로세스를 생성한다.

fork()

  • 새로운 PCB를 만들고 초기화한다.
  • 새로운 Address Space를 만들고, 부모 프로세스의 Address Space를 복사하여 초기화한다.
  • 그 밖의 부모 프로세스가 사용하던 정보들도 그대로 복사하여 초기화해 준다.
  • 완성된 PCB를 Ready Queue에 저장한다.
  • fork()는 2번 Return한다.
    • Return to Parent Process : pid of Child Process
    • Return to Child Process : 0
    • Return Value를 보고 어떤 프로세스가 부모인지 구별할 수 있다.

다음 코드를 보자.

// fork.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
    int pid;
    if ((pid = fork()) == 0) // child
        printf("Child of %d is %d.\n", getppid(), getpid());
    else // parent
        printf("I am %d. My child is %d.\n", getpid(), pid);
}

fork()를 수행하면 모든 정보가 복제되므로, 동일한 코드를 실행하는 프로세스가 2개가 된다.

하지만 fork()의 Return Value에 따라 조건문의 어느 부분이 실행될 지가 결정된다. 실행 결과를 보자.

$ gcc -o fork fork.c
$ ./fork
I am 35543. My child is 35544.
Child of 35543 is 35544.
$ ./fork
I am 35545. My child is 35546
Child of 35545 is 35546

자식 프로세스와 부모 프로세스가 fork()의 Return Value에 따라 서로 다른 코드를 실행하는 것을 알 수 있다.

exec()

  • 현재 실행중인 프로세스를 중지한다.
  • 새로운 프로그램을 Disk에서 읽어 와서 해당 프로세스의 Address Space를 덮어쓴다.
  • 관련된 정보들을 업데이트해 준다.
  • 완성된 PCB를 Ready Queue에 저장한다.
  • exec()은 새로운 프로세스를 만들지 않는다.
    • 기존의 프로세스를 새로운 프로그램의 프로세스로 덮어쓴다.
    • 따라서 별도의 Return Value가 없다.

프로세스 계층 구조 (Process Hierarchy)

fork() 의 작동 방식에 의해, 모든 프로세스는 부모-자식 관계를 갖게 된다.

따라서 UNIX 시스템에서는 모든 프로세스를 Tree 구조로 정리할 수 있고, ps명령을 사용하여 전체 프로세스 리스트를 확인할 수 있다. (Linux도 동일하다.)

cf. Windows는 별도의 Tree 구조를 가지고 있지 않고, 작업 관리자(Task Manager)를 통해 현재 시스템의 프로세스를 확인할 수 있다.

Simplified Shell

다음은 간단한 형태의 Shell code이다.

int main(void)
{
    char cmdline[MAXLINE];
    char *argv[MAXARGS];
    pid_t pid;
    int status;
    while (getcmd(cmdline, sizeof(buf)) >= 0) {
        parsecmd(cmdline, argv); // Parse the command
        if (!buildin_command(argv)) {
            if ((pid = fork()) == 0) {
                if (execv(argv[0], argv) < 0) {
                    printf("%s : command not found\n", argv[0]);
                    exit(0);
                }
            }
            waitpid(pid, &status, 0);
        }
    }
}

다음의 과정을 거쳐 작동한다.

  • while : 루프를 돌며 사용자로부터 명령어를 입력받음
  • parsecmd : 입력받은 명령어를 파싱(Parsing)
  • if (!builtin_command(argv)) : 입력받은 명령어가 Shell 자체 명령어인 Built-in Command인지 확인
    • 만약 Built-in Command이면 그대로 실행한다.
  • 그렇지 않은 경우 fork()를 수행하고, 자식 프로세스에서 새로 입력받은 명령어를 수행한다.
    • waitpid : 자식 프로세스의 실행이 종료될 때까지 대기한다.

운영체제 구현

모듈화

운영체제는 매우 거대한 프로그램이므로 전체를 한번에 다 구현하기가 어렵다. 따라서 각 부분을 작은 모듈로 나누어 구현한다.

특정 부분의 수정이 필요한 경우 해당 모듈만 수정하면 되도록, 새로운 기능이 필요하면 새로운 모듈을 덧붙여서 구현 가능한 것이 모듈화의 장점이다.

Policy & Mechanism

  • Policy는 운영체제의 기능을 활용해 무엇을 할 것인지를 결정한다.
  • Mechanism은 운영체제가 기능을 어떻게 수행할 것인지를 결정한다.

CPU를 예로 들어 살펴보면 다음과 같다.

Policy Mechanism (How to do?)
CPU 다음에 수행할 프로세스를 어떻게 결정할 것인가? 어떻게 동시에 여러 프로세스를 실행할 것인가?

Mechanism은 하나만 존재할 수 있지만, Policy는 여럿 존재할 수 있다.

  • Policy와 Mechanism을 구분하는 것이 운영체제 설계의 핵심 원칙이다.
  • Policies는 워크로드에 따라 얼마든지 달라질 수 있고, 누구나 자신이 원하는 방식을 새로 만들어 적용시킬 수 있다.
  • Mechanism은 운영체제에서 동작하는 여러 Policies들이 공통적으로 사용하는 방식이다.
  • Policy와 Mechanism을 구분함으로써 모듈화된 운영체제를 구현할 수 있고, 유지보수도 간편해진다.

앞으로 CPU와 메모리 가상화 등을 다루며 Mechanism과 Policy를 구분하여 살펴볼 것이다.

다음 글에서는 CPU 가상화의 Mechanism인 Direct Execution에 대해 다룬다.

comments powered by Disqus