Hyokyun Yim bio photo

Hyokyun Yim

Koreatech Computer Science Engineering undergraduate 4 grade.

Facebook Github

프로세스가 어떻게 생성되고 제거되는지 알아보자.

Overview

복습

  • 프로세스가 하드웨어 인터럽트에 의해 CPU를 빼앗기게 되면 Preemptive scheduling 이라고 한다.(time interrupt) -> 자기 의지와 상관없이 CPU를 빼앗기는 스케줄링
  • 반면 Non-preemptive scheduling 은 소프트웨어 인터럽트에 의해 일어난다. -> 내가 I/O를 기다리는데 I/O가 끝나라면 오래 걸릴 것 같다. 결국 내가 지금 CPU를 점유하고 있는 게 별 의미가 없다. 이럴경우 OS에게 소프트웨어 인터럽트를 걸어서 CPU를 넘기는 것
  • 그리고 인터럽트와 인터럽트 서비스 루틴을 이용하여 Context switching 이 일어난다.

일반적인 프로세스 생성 방법

프로세스가 생성이 되려면 OS가 그 프로세스의 Context들을 다 만들어 줘야한다.(ex:Fake stack 생성 등..)

  1. 먼저 프로세스가 생성이 되려면 파일 시스템의 그 대상이 되는 exe파일이 있어야 한다. 이 exe파일의 Path를 OS에게 전달 해야한다.
  2. OS는 실행 가능한 파일들의 코드를 읽어 들인다. -> 즉 Memory Context의 한 부분인 Code 세그먼트로 exe 파일의 코드들을 읽어 들인다.
  3. 또 exe파일에는 전역 변수의 선언과 정보 들이 있다. 이 정보들을 기반으로 Data 세그먼트에 알맞게 영역을 잡아준다.
  4. 이렇게 Code 세그먼트와, Data 세그먼트로 exe파일들을 불러들이는이 작업을 프로그램을 로드 한다고 말한다.
  5. 스택 세그먼트와, 힙 세그먼트는 아직 아무것도 없으므로 초기화만 시킨다.
  6. 그리고 그 프로세스의 정보를 담은 PCB(Process control block)를 Malloc 해서 필요한 정보를 채워 넣는다.
  7. 이렇게 생성돤 PCB를 레디큐에 장착시킨다.

UNIX 계열의 프로세스 생성

  • 그런데 유닉스 계열의 OS는 시스템이 부팅 할 때 0번 프로세스만 위와 같은 방법으로 생성하고 나머지 프로세스는 다른 방법으로 생성한다. -> 복제라는 특별한 기법을 사용한다.
  • 복제하는 역할을 하는 System call 이 fork() 이다.
  • 유닉스에서는 어떤 프로세스가 생성이 되려면 그 프로세스를 생성하라고 명령을 내려주는 다른 프로세스가 있어야한다. 나를 만들어주는 프로세스를 부모 프로세스라고 하고 그리고 만들어진 프로세스를 차일드 프로세스라고 한다.
  • Parent Process : Process Cloning을 초래하는 기존의 Process
  • Child Process : Parent Process로부터 만들어지는 새로운 Process

fork() 시스템 콜

  • Parent Process가 Child Process를 만들기 위해서는 fork() 라는 시스템 콜을 호출한다.
  • fork() 라는 시스템 콜이 호출되면 OS가 fork()를 호출한 부모프로세스를 중단시키고 부모프로세스가 가지고 있던 context 들을 사진을 찍어서 그 사진 찍은 context를 그대로 복사한다. 이런 과정으로 자식 프로세스를 생성한다.
  • 그렇지만 프로세스 ID는 다른 값을 같게 된다. But 프로세스 ID를 제외한 모든 값을 그대로 복제하기 때문에 부모 프로세스, 자식 프로세스는 똑같은 형태로 생성된다.
  • 그리고 나서 자식 프로세스의 PCB를 레디 큐로 보내고 나면 fork() 시스템 콜의 수행은 끝나고 다시 부모 프로세스로 돌아간다.

for() 시스템 콜이 가지는 문제점

  • 매번 모든 Context의 복사본을 만드는 것은 매우 비효율 적이다.
  • 컴퓨터 시스템은 맨 처음 만든 프로그램 프로세스(0) 이외에는 다른 프로그램을 동작 할 수 없다. 말이 안 된다. -> 계속 복사되기 때문에…
  • 그래서 UNIX OS 는 fork()를 한 다음 반드시 exec() 라는 시스템 콜을 호출한다.

exec()

  • exec() 는 fork()가 가지는 문제점을 해결하기 위해 만들어진 시스템 콜이다.
  • exec() 파라미터는 바로 생성될 프로그램의 exe파일의 path name 들이다.
  • 생성될 exe 파일의 코드와 데이터들을 이미 만들어진 프로세스(fork()를 이용해 부모 프로세스를 복사해서 가지고 있는 자식 프로세스)에 덮어 씌워버린다(오버라이드).
  • 이 동작을 완료하면 새로운 프로그램으로 출발 시킬 수 있다.

UNIX 계열의 프로세스 생성 과정

  • 부모 프로세스가 fork()를 한다. fork()를 하는 시점에 자식 프로세스는 부모프로세스의 context를 그대로 복사하고 exec()을 수행한다.
  • exec()을 수행함으로 부모 프로세스와 자식 프로세스가 각각 다른 프로그램을 수행 할 수 있도록 된다.
  • 부모 프로세스는 fork() 라는 시스템 콜을 하고 wait() 시스템 콜을 한다. 이 wait() 시스템 콜의 argument(인자)는 자기가 생성한 자식 프로세스의 ID 이다.
  • wait() 는 그 매개변수로 준 프로세스가 수행을 종료 할 때 까지 나를 기다리게 하라 는 것
  • 자식 프로세스는 exec()을 해서 코드를 새로 얻어 수행을 하고 다 수행하면 exit()라는 시스템 콜을 호출한다. 이 exit() 시스템 콜은 프로세스를 종료시키는 시스템 콜이다.
  • 우리는 지금까지 C를 짜면서 exit()를 써본 적이 없다? -> OS가 자동적으로 exit()를 호출해주도록 하여 프로그램을 종료 시키도록 하게 한다.
  • 즉 exit()는 그 프로세스가 가지고 있던 데이터구조, 자원을 다 가져가는 것을 말한다. 하지만 exit()는 종료가 잘 되었나 안 되었나를 확인하는 코드 하나만을 남겨 놓고 이 코드를 wait()에 전달한다.
  • 예전에 유닉스에 좀비 state란 게 있다고 했는데 이 좀비state가 바로 exit()를 마친 프로세스를 말한다. -> 모든 것을 다 가져가는데 딱하나 Exit Status만 남겨진 상태

Zombi state

exit()을 마치고 Parent Process가 자신의 Exit Status를 읽어가기를 기다리는 Process 상태

Shell

  • 요즘은 그래픽 유저 인터페이스가 발전되어 마우스로 OS와 의사소통을 하지만 과거에는 텍스트 기반이었다.
  • Shell Command line Interpreter를 사용하여 OS와 의사소통을 했다. -> OS가 유저에게 명령어를 입력 하세요 라고 말한다. 유저는 명령어를 입력해 Return 키를 치면 명령이 들어가게 된다. OS는 그 명령에 따라 프로세스를 생성시키고 다시 명령을 넣으라고 나왔다. -> 명령어를 넣으세요(Prompt): 리눅스의 경우 프롬프트를 $ 으로 표시한다.
  • Shell 이 어떻게 구성되는지 아래 코드가 보여주고 있다.
for(;;)
{
	cmd = readcmd();
	pid = fork();
	
	if(pid < 0) 
	{
	  perror("fork failed");
	  exit(-1);
	}
	
	else if(pid ==0)
	{
	// fork() 후 child 프로세스에세 exec() 수행
	if(exec(cmd) < 0) perror("exec failed");
	exit(-1);
	}
	else
	{
	// 부모 프로세스는 자식 프로세스가 exit satus 를 넘길떄까지 대기
	wait(pid)
	}
	

}



  1. readcmd() 함수는 사용자에게 프롬프트를 보내주고 명령어를 넣으라고 말해주는 함수이다. 사용자가 리턴 키를 치면 입력한 라인버퍼를 cmd 에 저장시킨다. -> cmd에는 유저가 실행시키고자하는 exe파일의 path name들이 저장될 것이다.
    • 그 다음 이 Shell은 저장된 커맨드를 실행시킬 차일드 프로세스를 만든다. -> fork() System call
  2. fork() 시스템 콜을 호출하면 리턴 값이 있다.( 음수면 에러, 0이상이면 정상 동작)

  3. 만약 성공하면 pid가 0인지 그렇지 않은지 검사 한다. 여기서 pid는 fork()시스템 콜에 의해 생성된 자식 프로세스의 ID를 말한다.

  4. 그런데 자식 프로세스의 ID가 0인 것이 좀 이상하다. 이것이 가능한 일일까?(ID가 0인 것은 유닉스가 맨 처음 부팅할 때 만든 프로세스 즉 부모 프로세스 이고 나머지는 다 복제하므로 0인 프로세스는 부모 프로세스일 텐데?.. 즉 자식프로세스의 ID로 0을 줄수 없다.) 이 루프를 들어오는 것은 불가능하지 않을까? 일단 이 부분은 무시하고 아래에서 설명하겠다.

  5. pid가 0이 아니면 마지만 else 문에 가서 차일드 프로세스가 끝날 때 까지 부모 프로세스는 wait()를 한다. (콘솔창 에서 명령을 입력하면 수행이 종료될 때 까지 유저는 타이핑을 못한다. 즉 프롬프트를 못하는데 그런 동작이 이 부분이다.)

  6. pid가 0이 되는 부분은 바로 자식 프로세스가 실행되는 부분이다. -> fork()를 호출한 시점에서 부모프로세스 수행이 딱 중단되고 Context 들이 사진 찍히듯이 찍힌다. 그리고 나서 그 모든 내용이 자식 프로세스로 복사가 된다. -> 자식 프로세스의 PCB가 생성이 되고 그 PCB가 레디 큐에 들어가게 된다. -> 결국 자식 프로세스의 PCB가 선택되어서 스케줄링을 받아서 수행된다.

  7. 그러면 자식 프로세스는 부모 프로세스의 코드를 그대로 복사했기 때문에 동일한 코드가 수행될 것이다. 과연 이 자식 프로세스는 복사된 부모 프로세스의 코드 중 어느 부분부터 수행이 될까? -> **바로 fork() 다음부터 수행될 것이다. 왜냐하면 시스템 콜 호출 되면 SW 인터럽트가 발생되고 그리고 리턴 어드레스가 스택에 들어가기 때문이다(리턴 어드레스는 바로 fork() 다음으로 지정 되어있다.) **

  8. 그 결과 자식 프로세스도 한번도 fork()를 호출한 적이 없지만 스택의 리턴 어드레스로 fork() 다음 위치가 기억되 있기 때문에 그 위치로 가게 된다.

  9. OS는 여기서 한 가지 트릭을 더 사용 한다. -> 자식 프로세스에게 리턴 할 때는 리턴 값을 0으로 설정한다.

  10. 그렇다면 이 리턴 값을 어떻게 넘길까? -> CPU 레지스터를 통해 넘기는데 CPU 레지스터 값이 스택에 저장이 되어있기 때문에 , 리턴 값을 담고 있는 CPU레지스터가 저장된 스택 필드에 부모 프로세스인 경우는 자식의 pid를 넣고 자식 프로세스인 경우는 0을 넣는다.

  11. 그래서 자식 프로세스는 fork()를 한번도 수행한적 없지만 fork() 다음을 수행하고 pid가 0이므로 그 분기문안의 명령을 수행한다. -> exec() 시스템 콜 수행

  12. 그리고 나서 자식 프로세스의 수행이 종료가 되면, exit()이 호출되고 exit()에 의해 부모로 시그널이 넘어가서 부모 프로세스가 깨어나게 되고 다시 프롬프트가 실행된다.

정리

  • 실제 Child Process의 Pid는 0이 아니고 다른 값이다. Child Process에게 전달되는 fork() 시스템 콜의 리턴값만 0인 것이다. -> 부모 프로세스한테 넘길때는 실제Pid 값, 자식 한테 넘길때는 0으로
  • 위 내용으로 알 수 있듯이 리턴된 값 0 은 프로세스의 id를 의미하는 것이 아니라 단지 리턴이 성공적으로 되었음을 알리기 위함이다.

왜 fork() 와 exec()를 사용할까?

  • CPU가 실제로 발생시키는 주소와 메모리의 Target address가 실제적으론 같지 않다.
  • 그 사이에 이 주소를 변환해주는 장치들이 있다(MMU) 이 MMU에 입력되는 주소를 logical address(논리주소) 그리고 실제 메모리로 가는 주소를 physical address(물리주소)
  • 즉 물리주소는 32bit CPU에 256MB를 꼽았다면 0~256MB 번지까지가 물리주소가 된다.
  • 논리 주소는 뭘까? -> 실제로 존재하지 않지만 그 프로그램이 간주하는 가상적인 메모리 이다.
  • 32bit 머신이 있다고 가정하면 -> 주소공간은 0 ~ 2^32-1 까지 주소공간을 만들 수 있다. 이것이 바로 논리주소공간이다.
  • 이 논리주소공간은 모든 프로세스가 한 개씩 가지고 있다.(MMU로 구현함)
  • 결국 모든 프로세스는 독자적인 논리주소공간을 가진다.

두 프로세스는 어떻게 통신을 할까?

  • MMU가 위 논리주소를 물리주소로 매핑 해준다는 것을 알았다.
  • 그럼 process 0 의 100번지와 process1의 100번지가 같은 주소일까? 다른 주소일까? -> 다른 주소이다.
  • 논리주소 공간이 다르면 주소번지가 같다고 하더라도 다른 주소가 된다.
  • 그런데 이것이 문제가 되는 것이 proc0 과 proc1 이 의사소통을 해야 하는데 할수가 없으므로 별도의 추가적인 방법이 필요하다
  • 결과적으로 파일을 이용해 서로 의사소통 하는데 여기서 fork() 가 사용된다.
  • 아래 소스가 fork() 가 필요한 이유이다.


int fd;     //  파일 시스템변수

foo()
{
      fd = open(pile);     // 파일을 집어 넣는다
     
	   if(fork() = 0)        // 자식일 경우
       {
        read(fd,...);        // 하나의 파일로 서로 의사소통함
       }

       else                  // 부모일 경우
       {
        write(fd,...);       // 하나의 파일로 서로 의사소통함
       }
}



COW(Copy On Write)

  • 부모프로세스를 카피했는데 exec()으로 한번 더 오버라이트 하는 불필요한 문제(성능저하)들을 해결하기 위해 개발자들이 특별한 메커니즘을 만들어냄 -> COW
  • Process Context를 fork() 시점에 복사하지 않고, Data Segmant에 새 값이 쓰여질 때 복사하는 기법
  • 즉 부모가 fork()해서 자신의 context를 카피 할때는 실제적으로 내용을 복사하지 않고 포인터 자료구조만 만들어서 자기의 code 세그먼트, Data 세그먼트 를 자식 프로세스가 가리키게만 한다. stack, heap 은 별도로 만들어야한다.
  • 그리고 exec() 할 때 비로소 복사가 된다.

프로세스 제거

  • exit() 호출
  • abort() 호출 : 부모가 신호를 보내서 프로세스를 죽임