SP - 1.3 Process Control - Basic Concepts

hyeok's Log·2022년 3월 23일
2

SystemProgramming

목록 보기
3/29
post-thumbnail

Process

  지난 1.1, 1.2 포스팅을 통해 우리는 프로세스의 정의를 아래와 같이 확인한 바 있다.

Process : Instance of a Running Program

Process : 프로그램이 메모리에 '명령어의 시퀀스'로 적재된 후, CPU를 동작시킬 수 있는 상태가 되었을때, 그 프로그램을 일컫는 용어

  프로그램이 '명령의 집합'이고, 이를 순차적으로 컴파일, 어셈블, 링크, 로드하면 메모리에 적재된다. Program Counter가 메모리 내의 '프로그램 Code 부분'을 가리키고, CPU는 이를 차례대로 실행하게 된다.

~> 이때, 마치 여러 프로세스가 각각의 CPU를 독점하고 있는 것처럼 보이지만, 실상은 단일 CPU가 Time-Sharing을 하면서 여러 프로세스를 처리하고 있다. (단일 CPU 기준)

~> 예를 들어 10ms의 Time Quantam을 기준으로 한다면, CPU가 Context Switch를 해가며 각 프로세스를 순차적으로 부분부분씩 처리하고, 이를 반복한다.

  이러한 프로세스는, 다음과 같은 두 종류의 추상화를 제공한다.

  • Logical Control Flow with Context Switch
  • Private Address Space with Virtual Memory of OS

~> 이 중, Logical Control Flow에 대해 조금 더 자세히 파헤쳐보자.


Logical Control Flow

  만약, 프로세스 p1과 p2가 있고, 단일 CPU가 있다고 해보자.

  • CPU 안에는 Register Set이 있고, 그 안에는 PC(Program Counter), IR(Instruction Register), 범용 레지스터(ex. EAX, EBX, ..)와 같은 레지스터들이 있다.

  • p1이 수행되고 있다고 하면, CPU의 PC는 p1의 가상 메모리 부분에서 code 영역의 명령을 가리키게 된다.

    • 이때의 Context는 p1인 것이다.
    • 그렇게 쭉 p1의 명령을 수행하다가, 일정 시간의 Time Quantam이 지나가면 비동기적인 Timer Interrupt에 걸리게 된다. (외부의 Timer Chip이 신호를 주기 때문에 Async!)
  • 인터럽트가 걸리면, OS 커널 공간 안에 있는 Timer Interrupt Handler가 수행되어, 이 핸들러가 'Context Switch'를 수행한다.

    • p1과 관련된 각 레지스터의 저장물들을 커널 내의 특정 메모리 공간(Saved Registers)에 Save한다. (Snapshot 개념)
  • 이제 p2가 수행되기 시작한다. 커널 내의 p2와 관련된 저장물들이 있는 메모리 공간(Saved Registers)에서 정보들을 가져와 CPU의 Register Set에 Update한다. (p2를 복구하는 것)

    • 이어서 p2의 명령을 수행하게 된다.

~> 마치 프로세스들이 각각 자신만의 메모리와 CPU를 가진 것처럼 추상화되어 동작한다.
(Logical Control Flow + Private Address Space)


※ 메모리 : Kernel Space + User Space

  User Space에는 프로그램이 적재되어 Stack-Heap-Data-Code 등의 부분이 각 프로세스마다 할당되고, Kernel Space에는 Context Switch에 필요한 Saved Registers 부분이 있다.


Concurrent & Sequential Processes

  프로세스의 코드 부분이 수행될 때, 만약 프로세스의 명령어가 100개 있다면, CPU는 단순하게 그 100개의 명령어를 순차적으로 읽으면서 수행한다. 'Physical Control Flow(Fetch->Decode->Execute->WriteBack)'를 지켜가면서 말이다.

  한편, 만약 두 프로세스를 돌릴 때는 어떨까?

Concurrent Processes : 두 개의 프로세스의 시작부터 끝날때까지의 시간 간격이 겹치는 경우, 이 두 프로세스의 관계를 Concurrent Processes라고 한다.

Sequential Processes : 반대로, 두 개의 프로세스의 시작-끝 시간 간격이 서로 겹치지 않는 경우, 두 프로세스의 관계를 Sequential Processes라고 한다.

~> 즉, 두 프로세스 간의 관계를 나타내는 용어이다.

  • A와 B, A와 C는 시간 간격이 겹치고 있으므로 Concurrent Processes이다.
  • B와 C는 시간 간격이 겹치지 않고 있으므로 Sequential Processes

~> Concurrent Processes의 Control Flow를 보면, 우리 눈에는 동시에 병렬적으로 각각 수행되고 있는 것처럼 보이지만, 실상은 물리적으로 Disjoint하다는 것이다. (Context Switch로 인해!)


Context Switching Overhead

  • Pa와 Pb가 있다. 둘 다 프로세스이고, Code+Stack의 가상 메모리 영역만 가진다고 해보자. Stack에는 지역변수들, Code엔 소스 코드가 들어간다. 두 프로세스는 메인 메모리 어딘가에 상주하고 있다.

  • 이때, 메인 메모리는 User 부분과 Kernel 부분으로 나뉜다고 했다. ★★

    • Kernel 부분은 사용자 입장에서는 사실상 절대 접근할 수 없다.
    • 일반적으로 메인 메모리가 4GB라 하면, 약 1GB 정도는 커널 부분으로 할당된다.
  • 만약 이때 System Call(Interrupt or Trap), 예를 들어서 read를 했다고 해보자.

    • read는 OS의 read System Call을 호출한다. (Interrupt의 예시)
    • 프로그램을 수행하다가, 시스템 콜을 만나면, OS 커널 공간의 read 함수 부분으로 점프를 하는 것이다.
      • 이 과정이, 마치 커널 함수가 프로세스의 하나의 영역인 것처럼 돌아가게 된다.

OS가 '메인 메모리에 상주하고 있는(Memory-Resident)' 부분을 커널(Kernel)이라 하는데, 커널 부분의 코드는 마치 기존의 프로세스에 포함된 코드 영역처럼 돌아가게 된다.

커널 함수 부분과 프로세스 부분이 Not Separte인 것처럼 보인다는 것이다.


  • 한편, Control Flow는 Context Switch를 통해 한 프로세스에서 다른 프로세스로 넘어간다고 했다.

  • 예를 들어, Pa 프로세스가 실행될 때, Time Quantam이 10ms라 하면, 10ms만큼 돌았을 때, Timer Interrupt가 걸려 OS 커널 내의 함수인 Timer Interrupt Handler가 마치 Pa의 일부분인 것처럼 동작한다는 것이다.

    • 이때, OS의 핸들러가 'Next Process'를 Schedule한다.
    • 이러한 '핸들러가 동작하는 시간'을 'Context Switch Overhead'라고 부른다.

~> 즉, Pa 프로세스가 타이머 인터럽트가 걸려 끝나고, Context Switch Overhead가 지나간 다음, Pb(Pnext) 프로세스가 수행된다는 것이다.


Process Control

  이제 프로세스에 대한 개념적인 분석을 넘어, 실제 코드 레벨 분석을 해보자.

"ls -al"

  우리가 Linux Shell에서 'ls -al'이라는 명령을 치면, files와 directories에 대한 Info가 화면에 출력된다.

  • 이때, 'ls'는 무엇일까?
    • ls는 프로그램이다. 현재 디렉토리에 있는 모든 디렉토리와 파일들을 화면에 출력하는 기능을 수행하는 프로그램이다. (Linux 기준 /bin에 들어있다.)
      • OS 안에 File System에 대한 정보들이 있고, ls 프로그램은 System Call들을 여러 번 하여 이 File System 정보를 긁어오고 뿌리는 것이다.
    • /bin 파일에 들어있는 ls라는 프로그램은, 우리가 쉘을 실행해놓고 '> ls'라고 타이핑을 하고 Enter를 치는 순간, CPU를 동작시킬 수 있는 '프로세스'가 되는 것이다.
      • Enter를 치는 순간, ls 프로그램에 대한 정보가 'Code, Stack, Heap, Data'의 부분으로 나뉘어 메인 메모리에 적재된다.
      • CPU의 PC가 ls의 code 부분의 첫 번째 명령부터 Fetch하면서 흐른다.

Shell & fork

  한편, OS System Call 중에는 fork라는 Call이 있다.

fork : 프로세스를 생성하는 System Call이다.

참고로, 'fork'란 '갈라지다/나뉘다'라는 의미이다.     "왜 갈라지는 것일까?"

UNIX 계열의 OS는 '모든 프로그램'에 대해 fork라는 System Call을 하여 프로세스화한다.

  • 위의 예시에서, ls 프로세스는 '>' 프로세스가 fork하여 만들어진 것이다.

    • '>'는 ls의 parent process, ls는 '>'의 child process이다.

      '>'는 Shell이라는 프로세스이다.

      • Shell은 Command 입력을 기다리는 단순한 프로그램이다.
      • 그렇다. 사실 메인메모리에는 이미 Shell이라는 프로세스가 돌고 있던 것이다.
      • 우리가 Shell에서 ls라는 프로그램을 만드는 순간, 두 프로세스가 Concurrent Processes가 된 것이다.
  • 그렇다면, 이 Shell 프로세스는 누가 생성한 것일까?

    • UNIX 계열에는 가장 Ancestor에 해당하는 프로세스인 'Init'이라는 프로세스가 존재한다.
      • Init이 '프로세스 트리'를 형성하면서 쭉 프로세스를 만들어가게 되는 것이다.
      • 이러한 트리 구조에는, Parent-Child 관계가 형성되어 있고, 우리 컴퓨터에서 돌아가는 수많은 프로세스는 모두 이 Init이라는 하나의 조상으로부터 뻗어나온 것이다.


Understanding of 'Fork'

  fork에 대해 자세히 확인하기에 앞서, 잠시 Programming Skill에 대한 언급을 하겠다. 우리가 앞으로 계속해서 사용하게 될 Skill이기 때문에 소개한다.

System Call Error Handling

  일반적으로, 오류가 발생하면 Linux System에서는 함수의 반환값으로 -1을 부여하고, 전역 변수 'errno'에 에러의 원인을 명시한다.
~> 그래서, 시스템을 다루는 프로그래밍을 할 경우, 우리는 항상 에러 상황을 고려해야한다.

System-Level Function의 return값은 '에러 유무'를 알려주므로, 항상 이 반환값에 주의해야한다.

if ((pid = fork()) < 0) {			// 이 fork가 System Call이다.
	fprintf(stderr, "fork error: %s\n", strerror(errno));	// 에러 확인!
	exit(0);
}

~> 위에서 언급한 fork함수를 사용하는 간단한 코드이다. fork는 System Call, 즉, System-Level Function으로, 언급한 것처럼 반환값을 체크해 에러 여부를 확인하고 있음을 주목하자.


Error-Reporting Function Skill

  시스템 콜 관련해서 에러가 날 경우, 이를 아예 명시적으로 "어떠한 에러가 났다!"를 화면에 출력하는 프로그래밍 스킬이 있다.
~> 이를 위해선, 일반적으로 아래와 같은 꼴의 'Error-Reporting Function'을 정의한다. (UNIX 계열 시스템 프로그래밍 상황이 기준이다.)

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

~> 이러한 스킬을 적용하면, 위의 예시 코드를 아래와 같이 변형할 수 있다.

if ((pid = fork()) < 0)
	unix_error_report("fork error");

~> 그리고, 이를 이용해 아래와 같이 '에러 보고 기능'을 탑재한 Fork 함수를 만들어 사용할 수 있다.

pid_t Fork(void) {
	pid_t pid;
    
	if ((pid = fork()) < 0)
		unix_error_report("Error in Fork()!");
        
	return pid;
}

~> 우리는 이제 Fork라는 사용자 정의 함수로 fork를 사용해, 에러 핸들링까지 함께 수행할 수 있다. 어떤가, 가독성도 좋고 괜찮지 않은가? 이러한 프로그래밍 스킬은 System Programming에서 상당히 중요한 역량이다. 잘 기억해두자.

여담) 이러한 코딩 스타일을 'Stevens-Style Error-Handling Wrappers'라고 한다. (랩핑을 씌워놓은거 같다고 해서 Wrapper라고 한다.)


fork System Call

int main(void) {
	pid_t pid;
	int x = 0;

	pid = Fork();
    
    /* Child Process */
	if (pid == 0) { 	
		printf("child : x=%d\n", ++x); 
		exit(0);
	}

	/* Parent Process */
	printf("parent: x=%d\n", --x); 
	exit(0);
}

(출력 예시)
parent: x=-1
child: x=1
(또는)
child: x=1
parent: x=-1

  • 프로세스가 Code와 Stack 부분을 가진다고 해보자.

    • pid와 x는 Local Variable이므로 Stack 메모리 부분으로 들어간다.
  • 이때, CPU가 이 프로세스를 실행하는데, 'Error-Handling Wrapper' 처리가 된 Fork 프로시저를 만난다.

    • Fork 내부의 실제 System Call인 fork가 수행된다. ~> System Call이므로, 커널에 있는 fork 함수 부분이 수행되는 것이다. 마치 이 프로세스의 일부분처럼(Trap Exception)!
  • 이때, 커널의 fork는 현재 프로세스(이를 Pa라고 부르자)와 똑같은 코드를 복사해 메인 메모리에 적재한다. Pa를 Parent, 새로 생기는 프로세스를 Child라고 한다.

fork라는 System Call은 현재 프로세스의 코드를 똑같이 복제해 메모리에 적재한다.

fork 함수는 parent에겐 0이 아닌 값을, child에겐 0을 반환한다. (구분을 위해)

이때, 두 프로세스에 대한 커널의 Saved Registers 부분에는 PC 값이 '논리적으로 fork 수행 직후에 위치한 명령어'를 가리킨다. 두 프로세스 모두가 말이다.

  • 즉, Pa와 Pa_child의 Context가 커널 공간에 복사되어 있는데, 둘의 PC는 모두 논리적으로 같은 위치에 있다는 것이다. 말그대로, 그냥 복사한 것이다. (그래서 영단어 'fork'인 것)

잊지말자, 현재 우리가 논하는 fork의 목적은 '프로세스 생성'이다.

~> 즉, 우리는 새로운 프로그램을 로딩할 때, fork System Call의 결과로 생긴 Child Process에 원하는 프로그램을 넣는(이 과정은 다음 포스팅에서 설명) 방식으로 수행하는 것이다.

~> 이때, Parent와 Child는 Concurrent Processes가 되므로 Timer 상황에 따라서 parent부터 수행될지, child부터 수행될지 알 수 없다. ★★

=> 그래서, 위의 출력 결과가 두 가지가 가능한 것이다.


Additional Concepts

※ Process ID를 얻는 함수
pid_t getpid(void) : 현재 Process의 PID 반환
pid_t getppid(void) : Parent Process의 PID 반환


States of Process (Programmer's Perspective)

  • Running : 프로세스가 실행중이거나, 또는 프로세스가 커널에 의해 스케쥴(Context Switch의 대상이 됌)된 후, 실행되기 전에 기다리고 있는 상태 (Running includes Ready)

  • Stopped = Blocked = Waited = Skipped : 프로세스가 수행이 잠시 Suspend되고, 아직 언제 실행될 것인지 스케쥴되지 않은 상태

    • Running과 Stopped의 차이 : OS 커널에 의해 실행이 스케쥴되었는가 아닌가로 구분한다.

    실행되기로 예정되었으면 Ready(Running), 아직 스케쥴되지 않았으면 Waited(Stopped)

  • Terminated : 프로세스가 어떠한 Signal에 의해 종료된 상태

  • Done : 프로세스가 수행을 마치고 (정상) 종료된 상태


Process Termination - exit

  • 프로세스가 종료되는 3가지 이유
    • 디폴트 액션이 '종료(Terminate)'인 Signal을 프로세스가 Receive한 경우
    • 프로세스의 main함수가 return한 경우
    • 프로세스가 exit함수를 호출한 경우
  • exit함수 : void exit(int status)
    • 'Exit Status'와 함께 종료한다.
    • 일반적인 종료의 경우 0을 반환하고, 에러 상황의 경우 0이 아닌 값을 반환하는 관습이 존재한다.
      • exit(0);
    • 명시적으로 Exit Status를 특정 정수값으로 나타낼 수 있다.

exit함수의 주요 특징 : return하지 않고 그냥 종료된다. (위에서 반환이라 표현한 것은 status 인자를 변경하는 것을 의미. exit 함수 자체는 반환이 void임.


Process Creation - fork

  • Parent Process가 fork System Call을 이용해 새로운 Child Process를 생성한다.

  • fork함수 : int fork(void)

    • Child Process에겐 0을, Parent Process에겐 0이 아닌 값을 반환한다. ★★
    • Child Process는 Parent Process를 그대로 복사한다.
      • Identical하지만 Separate된 (가상 메모리 주소) 위치에 Child가 존재하게 된다.
      • 하지만, Child의 PID는 Parent와 다르다. ★★

fork함수의 주요 특징 : 한 번 호출되면, 두 번의 반환을 수행한다. ★★★


fork Modeling via 'Process Graph'

  우리는 'Process Graph'를 이용하여 Concurrent Program(Processes)의 Partial Ordering을 표현할 수 있다.

  • 그래프의 각 Vertex는 '명령의 수행'을 나타낸다.

    • printf가 담긴 Vertex의 경우 Output으로 레이블화할 수 있다.
  • 'a -> b'는 a가 b보다 먼저 수행됨을 의미한다.

  • 그래프의 각 Edge는 현재 변수들의 값으로 레이블화할 수 있다.

  • 그래프의 시작 Vertex는 In-Edge가 없다.

~> Process Graph를 어떻게 Topological Sorting하든, 그 결과는 Feasible한 Total Ordering이다.

~> 이때, 이 Partial Ordering은 좌에서 우로 흐르게 그리곤 한다.

  금일 포스팅은 여기까지이다.

0개의 댓글