출처
Randal E. Bryant & David R. O'Hallaron. (2015). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.
2. 프로세스
예외는 운영체제 커널이 프로세스라는 개념을 제공할 수 있게 해주는 기본 요소이다. 현대 운영체제에서 프로그램을 실행할 때 우리는 우리의 프로그램이 시스템에서 현재 실행 중인 유일한 프로그램이라는 환상을 경험한다. 프로그램은 프로세서와 메모리를 독점해서 사용하고 있는 것처럼 보인다. 프로그램의 코드와 데이터가 시스템 메모리에 있는 유일한 object인 것처럼 보인다. 이러한 환상은 프로세스라는 개념에 의해 제공되는 것이다.
프로세스의 정의는 실행 중인 프로그램의 인스턴스이다. 시스템에 있는 모든 프로그램은 어떤 프로세스의 컨텍스트 context에서 실행된다. 컨텍스트는 프로그램이 정확하게 실행되기 위해 필요한 상태 state들로 이루어져 있다. state로는 메모리에 저장되어 있는 코드와 데이터, 스택, 일반 목적 레지스터의 콘텐츠, 프로그램 카운터, 환경 변수 그리고 열려 있는 파일 디스크립터의 집합이 있다.
사용자가 shell에 실행 가능한 object의 이름을 입력함으로써 프로그램을 실행시킬 때마다 shell은 새로운 프로세스를 생성하고 새로운 프로세스의 컨텍스트에서 실행 가능한 object 파일을 실행시킨다. 애플리케이션 프로그램 또한 새로운 프로세스의 컨텍스트에서 새로운 프로세스를 생성하고 자신의 코드나 다른 애플리케이션을 실행시킬 수 있다.
운영체제가 어떻게 프로세스를 구현하고 있는지에 대한 구체 사항은 우리의 범위를 넘어선다. 대신 우리는 프로세스가 애플리케이션에게 제공하는 핵심 추상화에 집중할 것이다.
- 프로그램에게 프로세서를 독점적으로 이용하고 있다는 환상을 제공하는 독립적인 논리적 제어 흐름 logical control flow
- 프로그램에게 메모리를 독점적으로 이용하고 있다는 환상을 제공하는 사적 가상 주소 공간
(1) Logical Control Flow
프로세스는 프로그램에게 프로세서를 독점적으로 이용하고 있다는 환상을 제공한다. 시스템에서 일반적으로 여러 다른 프로그램이 동시에 실행됨에도 말이다. 프로그램의 실행을 한 단계씩 살펴보기 위해 디버거를 사용해보면 프로그램의 실행 가능 object 파일과 런타임에 동적으로 링크된 공유 object에 있는 명령을 가리키는 여러 프로그램 카운터 (PC) 값들을 관찰할 수 있다. PC 값의 시퀀스는 logical control flow, 간단하게는 logical flow라고 부른다.
3개의 프로세스를 실행시키는 시스템이 있다고 해보자.
프로세서의 하나의 물리적인 제어 흐름은 3개의 논리적인 흐름으로 나누어진다. 하나의 프로세스당 하나의 논리적 흐름을 가진다. 세로선은 프로세서의 논리적 흐름의 부분을 나타낸다. 위 그림에서 3개의 논리적 흐름이 교차되고 있는데 프로세스 A가 잠깐 실행되다가 B가 실행되어서 완료되었고 프로세스 C가 잠깐 실행되다가 A가 실행되어 완료되었고 마지막으로 C가 실행되어 완료되었다.
이 그림에서 핵심은 프로세스가 프로세서를 번갈아 가면서 이용한다는 점이다. 각 프로세스는 자신의 플로우의 부분을 실행한 다음 다른 프로세스가 차례를 받는 동안 선점당한다 preempted (일시적으로 중단된다). 프로세스의 컨텍스트에서 실행 중인 프로그램에게는 프로세서를 독점적으로 사용하고 있는 것처럼 보인다. 하지만 각 명령의 지속 시간을 정확하게 측정해보면 CPU는 프로그램의 명령과 명령 사이에서 주기적으로 멎는다. 프로세서가 중간중간 멈추기는 하여도 곧바로 프로그램의 메모리 위치나 레지스터에 있는 콘텐츠의 변화 없이 프로그램을 재개한다.
(2) Concurrent Flows
논리적 흐름은 컴퓨터 시스템에서 다양한 형태를 띤다. exception handler, 프로세스, signal handler, 스레드, 자바 프로세스는 모두 논리적 흐름의 한 예이다.
다른 흐름과 시간적으로 겹치는 논리적 흐름을 동시 흐름 concurrent flow라고 한다. 이때 두 흐름이 동시에 실행된다 run concurrently라고 말한다. 더 정확하게 말하자면 Y가 시작되고 나서 끝나기 전에 X가 시작되거나, X가 시작되고 나서 끝나기 전에 Y가 시작될 경우에 X와 Y의 흐름이 동시적인 것이다.
위 그림에서 프로세서 A와 B, A와 C는 동시에 실행되지만 B와 C는 그렇지 않다. 왜냐하면 C의 첫 번째 명령 전에 B의 마지막 명령이 실행됐기 때문이다.
여러 개의 흐름이 동시에 실행되는 현상을 동시성 concurrency라고 말한다. 프로세스가 프로세서를 번갈아 가면서 차지하는 것은 멀티태스킹 multitasking이라고 한다. 멀티태스킹은 time slicing이라고 불리기도 한다. 예를 들어, 위 그림에서 프로세스 A는 두 개의 time slice로 이루어져 있다.
동시 흐름은 프로세서 코어의 수나 컴퓨터와 관계없는 별개의 개념이다. 실행 중인 두 개의 흐름이 시간적으로 겹친다면 두 흐름이 같은 프로세서에서 실행 중일지라도 두 흐름은 동시적이라고 말한다. 하지만 때때로 parallel flow 병렬 흐름이라는 concurrent의 유형을 구분해서 말하는 것이 유용할 때가 있다. 만약 서로 다른 프로세서 코어나 컴퓨터에서 두 개의 흐름이 동시에 실행 중이라면 두 흐름을 병렬 흐름이라고 말하고 두 흐름이 병렬적으로 실행된다, 병렬 실행 parallel execution을 가진다고 말한다.
(3) 사적 주소 공간 private address space
프로세스는 프로그램에게 프로그램이 시스템의 주소 공간을 독점적으로 사용하고 있다는 환상을 제공한다. n비트 주소를 사용하는 머신에서 주소 공간은 2^n만큼의 주소를 가진다. 프로세스는 프로그램에게 사적 주소 공간을 제공한다. 이 공간은 다른 프로세스에 의해 읽거나 작성될 수 없는 공간의 주소와 관련된 메모리 바이트라는 점에서 사적이라고 말한다.
비록 각각의 사적 주소 공간과 관련된 메모리의 콘텐츠는 모두 다르지만 이러한 공간은 모두 같은 구조를 가진다. 예를 들어, x86-64 Linux 프로그램은 다음과 같은 주소 공간 구조를 가진다.
주소 공간의 아랫부분은 사용자 프로그램을 위해 예약된 곳이다. 이곳에는 코드, 데이터, 힙, 스택 세그먼트가 있다. 코드 세그먼트는 항상 0x400000으로 시작한다. 주소 공간의 윗부분은 커널을 위해 예약된 공간이다. 이 부분에는 커널이 프로세스를 대신해서 명령을 수행할 때 (애플리케이션이 시스템 콜을 실행시켰을 때) 사용하는 코드, 데이터, 스택이 있다.
(4) 사용자 모드와 커널 모드 user and kernel modes
운영체제 커널이 올바른 프로세스 추상화를 제공하기 위해서 프로세서는 애플리케이션이 실행할 수 있는 명령을 제한하는 메커니즘과 애플리케이션이 접근할 수 있는 주소 공간의 부분을 제공해야 한다.
프로세서는 일반적으로 어떤 컨트롤 레지스터에 있는 모드 비트 mode bit를 통해 이러한 기능을 제공한다. 모드 비트는 프로세스가 현재 사용할 수 있는 특권 privilege을 특정짓는다. 모드 비트가 설정됐을 때 프로세스는 커널 모드에서 실행된다. (때때로 supervisor mode라고 불리기도 한다.) 커널 모드에서 실행되는 프로세스는 모든 명령 집합을 실행할 수 있으며 시스템에 있는 모든 메모리 위치에 접근할 수 있다.
모드 비트가 설정되지 않았을 때 프로세스는 사용자 모드에서 실행된다. 사용자 모드에 있는 프로세스는 privileged 명령은 실행할 수 없다. 이러한 명령의 예로는 프로세스를 중단시키기, 모드 비트 변경하기, 입출력 작업 시작하기가 있다. 또한 사용자 모드에 있는 프로세스는 주소 공간의 커널 영역에 있는 코드와 데이터를 직접적으로 참조할 수 없다. 이러한 시도가 이루어지면 치명적인 protection fault가 나게 된다. 사용자 프로그램은 시스템 콜 인터페이스를 통해 커널의 코드와 데이터에 간접적으로 접근해야 한다.
애플리케이션을 실행하는 프로세스는 초기에 유저 모드에 있다. 프로세스가 유저 모드를 커널 모드로 바꿀 수 있는 유일한 방법은 interrupt, fault, trapping system call과 같은 예외를 통하는 방법이다. 예외가 발생하고 제어가 exception handler에게 넘어갔을 때 프로세서는 모드를 유저 모드에서 커널 모드로 바꾼다. 핸들러는 커널 모드에서 실행된다. 핸들러가 애플리케이션 코드로 return하면 프로세서는 커널 모드를 다시 사용자 모드로 바꾼다.
Linux는 /proc 파일 시스템이라고 불리는 영리한 메커니즘을 제공한다. /proc 파일 시스템은 사용자 모드 프로세스가 커널 자료 구조의 콘텐츠에 접근할 수 있게 해준다. /proc 파일 시스템은 사용자 프로그램이 읽을 수 있는 계층적인 텍스트 파일 형태로 많은 커널 자료 구조의 콘텐츠를 내보낸다. 예를 들어, /proc 파일 시스템을 이용하여 CPU 타입 (/proc/cpuinfo)이나 특정 프로세스에 의해 사용 중인 메모리 세그먼트와 같은 시스템 속성들을 알아낼 수 있다. Linux 2.6 버전은 /sys 파일 시스템을 도입했다. /sys는 시스템 버스와 장치에 대한 추가적인 저수준 정보를 내보낸다.
(5) 컨텍스트 스위칭
운영체제 커널은 컨텍스트 스위칭 context switching이라고 불리는 고수준 형태의 예외적인 제어 흐름을 이용하여 멀티태스킹을 구현한다. 컨텍스트 스위칭 메커니즘은 저수준 메커니즘 위에 구축된 것이다.
커널은 각각의 프로세스에 대해 컨텍스트를 유지한다. 컨텍스트란 커널이 preempt되었던 프로세스를 재시작하기 위해 커널이 알아야 하는 state이다. 컨텍스트는 일반 목적 레지스터, 부동 소수점 레지스터, 프로그램 카운터, 사용자 스택, 상태 레지스터, 커널의 스택 그리고 주소 공간에 특성을 부여해주는 페이지 테이블과 현재 프로세스에 대한 정보를 담고 있는 프로세스 테이블, 프로세스가 open한 파일에 대한 정보를 담고 있는 파일 테이블과 같은 다양한 커널 자료 구조들을 포함한다.
프로세스가 실행되고 있는 어느 한 시점에 커널은 현재 프로세스를 preempt하고 (중단시키고) 이전에 preempt 되었던 프로세스를 재시작해야겠다고 결정한다. 이러한 결정은 스케줄링 scheduling이라고 알려져 있으며 scheduler라고 불리는 커널 코드에 의해 수행된다. 커널이 다음에 실행시킬 새로운 프로세스를 선택하는 것은 커널이 그 프로세스를 스케줄링했다고 말한다. 커널이 실행시킬 새로운 프로세스를 스케줄링하고 나면 커널은 현재 프로세스를 preempt하고 context switch라고 불리는 메커니즘을 이용하여 제어를 이동시킨다. 컨텍스트 스위칭은 (1) 현재 프로세스의 컨텍스트를 저장하고, (2) 저장해두었던 이전에 선점된 프로세스의 컨텍스트를 복구하고, (3) 새롭게 복구된 프로세스에게 제어를 전달한다.
컨텍스트 스위칭은 커널이 사용자를 대신해서 시스템 콜을 실행시킬 때 발생할 수도 있다. 시스템 콜이 어떤 이벤트가 발생되기를 기다리는 동안 block된다면 커널은 현재 프로세스를 sleep시키고 다른 프로세스로 스위칭을 한다. 예를 들어, read 시스템 콜이 디스크 접근을 요구한다면 커널은 컨텍스트 스위칭을 수행해서 디스크로부터 데이터가 오기를 기다리는 동안 다른 프로세스를 실행시키는 방법을 선택할 수 있다. 또다른 예시로는 sleep 시스템 콜이 있다. sleep은 sleep을 호출한 프로세스를 sleep하게 만드는 명시적인 요청이다. 일반적으로 시스템 콜이 block되지 않아도 커널은 시스템 콜을 호출한 프로세스에게 제어를 return하기보다 컨텍스트 스위칭을 수행하기로 결정할 수도 있다.
컨텍스트 스위칭은 interrupt의 결과로 발생할 수도 있다. 예를 들어, 모든 시스템은 주기적인 타이머 인터럽트를 생성하는 메커니즘을 가지고 있다. 일반적으로 매 1ms 또는 10ms마다 타이머 인터럽트를 생성한다. 타이머 인터럽트가 발생할 때마다 커널은 현재 프로세스가 충분히 오래 실행되어서 새로운 프로세스로 스위칭되어야 한다고 판단한다.
다음은 프로세스 A와 B 사이에 컨텍스트 스위칭이 일어나는 예를 보여준다.
read 시스템 콜을 실행함에 따라 프로세스가 커널로 trap될 때까지 초기 프로세스 A는 사용자 모드에서 실행된다. 커널에 있는 trap handler는 디스크 컨트롤러에게 DMA 이동을 요청하고 디스크에게 디스크로부터 메모리로 데이터 전송을 마치면 디스크 컨트롤러가 프로세서를 interrupt하도록 만든다.
디스크는 데이터를 가져오는 데 상대적으로 긴 시간이 걸린다. (수십 밀리 세컨즈 정도) 그래서 중간에서 아무것도 안하고 기다리는 대신에 커널은 프로세스 A에서 프로세스 B로 컨텍스트 스위칭을 수행한다. 스위칭 전에는 커널이 사용자 모드에서 프로세스 A를 위하여 명령을 실행한다. (즉, 별도의 커널 프로세스가 있는 것이 아니다.) 스위칭의 첫 번째 부분에서 커널은 커널 모드에서 프로세스 A를 위하여 명령을 실행한다. 그다음에 커널은 프로세스 B를 위하여 명령을 실행하기 시작한다 (여전히 커널 모드에 있다.) 스위칭 이후에 커널은 사용자 모드에서 프로세스 B를 대신하여 명령을 실행한다.
프로세스 B는 디스크에서 메모리로 데이터가 모두 이동되었다고 알리는 interrupt를 디스크가 보낼 때까지 사용자 모드에서 한참 동안 실행된다. 커널은 프로세스 B가 충분히 오래 실행되었다고 판단하고 프로세스 B에서 A로 컨텍스트 스위칭을 수행한다. 이때 프로세스 A에 있는 read 시스템 콜 바로 뒤에 있는 명령으로 제어를 return한다. 프로세스 A는 다음 예외가 발생할 때까지 계속 실행한다.
3. 시스템 콜 예외 처리 System Call Error Handling
Unix 시스템 레벨 함수가 에러를 만났을 때, 함수는 주로 -1을 return하고 전역 정수 변수 errno를 설정하여 어떤 것이 잘못됐는지를 표시한다. 개발자는 항상 에러를 확인해야 하지만 코드량을 늘리고 코드 가독성이 안 좋아진다는 이유로 많은 개발자가 예외 확인을 스킵한다.
예를 들어, Linux의 fork 함수를 호출할 때 다음과 같이 에러를 체크해야 한다.
if ((pid = fork()) < 0) {
fprintf (stderr, "fork error: %s\n", strerror (errno));
exit (0);
}
strerror 함수는 특정 errno 값과 관련된 에러를 설명하는 문자열을 return한다. 다음과 같은 에러 보고 함수를 정의하면 이러한 코드를 간단하게 만들 수 있다.
void unix_error (char *msg) /* Unix-style error */
{
fprintf(stderr, "%s: %s\n", msg, strerror (errno));
exit (0);
}
이 함수를 사용하면 fork 호출은 4줄에서 2줄로 줄어들 수 있다.
if ((pid = fork ()) < 0)
unix_error ("fork error");
맨 앞글자를 대문자로 바꾸고 동일한 인자를 받는 error-handling wrapper 함수를 만들면 코드를 더 간단하게 만들 수 있다. (Stevens가 이의 선구자이다.) wrapper 함수는 기반이 되는 함수를 호출하고, 에러를 체크하고, 문제가 있다면 (애플리케이션을) 종료시킨다. 예를 들어, fork 함수에 대해 다음과 같이 wrapper 함수를 만들 수 있다.
pid_t Fork (void)
{
pid_t pid;
if ((pid = fork ()) < 0)
unix_error ("Fork error");
return pid;
}
이 wrapper를 이용하면 fork 함수는 한 줄로 줄어든다.
pid = Fork ();
교재의 나머지 부분에서는 이러한 error-handling wrapper 함수들을 사용할 것이다. 이들은 csapp.c에 정의되어 있고 프로토타입은 csapp.h에 정의되어 있다.
'Computer System > CSAPP' 카테고리의 다른 글
[CSAPP] 8장 예외적인 제어 흐름 (4/4) Signal, Nonlocal Jump (0) | 2025.01.22 |
---|---|
[CSAPP] 8장 예외적인 제어 플로우 (3/4) Process Control (1) | 2025.01.22 |
[CSAPP] 8장 예외적인 제어 플로우 (1/4) Exception (0) | 2025.01.17 |
[CSAPP] 11장 네트워크 프로그래밍 (4/4) (1) | 2025.01.16 |
[CSAPP] 11장 네트워크 프로그래밍 (3/4) (0) | 2025.01.16 |