프로세스와 API
- 2.1 프로세스란
- 2.2 프로세스 상태
- 2.3 프로세스 자료구조
- 2.4 fork()
- 2.5 exec()
- 2.6 wait()
- 2.7 프로세스 제어와 사용자
프로세스는 실행 중인 프로그램이야. 프로그램은 디스크에 있는 코드에 불과하고, 그게 실행되면 프로세스가 되지. CPU 가상화의 핵심 추상화가 바로 이거야.
CPU가 하나(또는 소수)인데 프로그램은 수십 개를 동시에 돌리고 싶잖아. OS는 이걸 **시분할(time sharing)**로 해결해. 한 프로세스를 잠깐 돌리고, 멈추고, 다른 프로세스를 돌리고, 또 멈추고... 이걸 아주 빠르게 반복하면 사용자 입장에서는 여러 프로그램이 동시에 도는 것처럼 보여. 이 메커니즘을 구현하려면 저수준의 **메커니즘(mechanism, "어떻게")**과 고수준의 **정책(policy, "누구를")**이 필요한데, 이 둘을 분리하는 게 OS 설계의 중요한 원칙이지.
프로세스를 이해하려면 **머신 상태(machine state)**를 알아야 해. 프로세스가 실행되면서 읽고 쓰는 것들의 총합인데, **메모리(주소 공간)**에는 코드와 데이터가 들어 있고, 레지스터에는 프로그램 카운터(PC), 스택 포인터, 프레임 포인터 같은 게 있고, I/O 정보(열린 파일 목록 등)도 포함돼. 이 머신 상태를 통째로 저장하고 복원할 수 있으면 프로세스를 멈췄다가 나중에 이어서 실행할 수 있어. 이게 컨텍스트 스위치의 기본 원리야.
프로세스가 만들어지는 과정도 알아두면 좋아. 먼저 프로그램 코드와 정적 데이터를 메모리에 로드하는데, 현대 OS는 lazy loading으로 실제 필요한 부분만 그때그때 올려. 그다음 스택을 할당하고(main()의 argc/argv로 초기화), 힙 영역도 할당하고, I/O 초기화(유닉스에서는 stdin/stdout/stderr 세 개의 파일 디스크립터가 기본으로 열려), 마지막으로 진입점(main())에서 실행을 시작하지.
프로세스는 세 가지 상태 중 하나에 있어. Running(CPU에서 실행 중), Ready(실행 준비 됐는데 스케줄러가 아직 안 고른 상태), Blocked(I/O 같은 작업을 기다리느라 실행 못 하는 상태). Running 프로세스가 I/O를 요청하면 Blocked로 가고, I/O가 끝나면 Ready로 가고, 스케줄러가 선택하면 Running이 되는 거지.
OS는 각 프로세스의 정보를 **PCB(Process Control Block)**라는 자료구조로 추적해. 레지스터 상태, 프로세스 상태, 메모리 정보, I/O 정보 등이 다 담겨. 프로세스가 멈출 때 레지스터 값들을 PCB에 저장하고, 다시 실행할 때 복원하는 게 컨텍스트 스위치의 실체야.
이제 유닉스에서 프로세스를 실제로 생성하고 제어하는 API를 보자. fork(), exec(), wait() — 이 세 개가 유닉스 프로세스 관리의 삼총사야.
**fork()**는 현재 프로세스의 거의 완벽한 복사본을 만들어. 한 번 호출되지만 두 번 리턴하는 게 핵심이지 — 부모에게는 자식의 PID를, 자식에게는 0을 리턴해. 자식 프로세스는 부모의 주소 공간, 레지스터, PC를 복사해서 가지고, fork() 다음 줄부터 실행을 시작해. 부모와 자식 중 누가 먼저 실행될지는 **비결정적(non-deterministic)**이야 — CPU 스케줄러가 결정하는 거라 매번 다를 수 있거든.
**exec()**는 현재 프로세스의 코드와 데이터를 완전히 다른 프로그램으로 교체해. fork()가 복사본을 만드는 거라면, exec()는 그 복사본을 아예 다른 프로그램으로 변신시키는 거지. exec()가 성공하면 절대 리턴하지 않아. 원래 프로그램의 코드가 새 프로그램으로 덮어씌워졌으니까 돌아갈 곳이 없는 거야. PID는 바뀌지 않아 — 같은 프로세스가 다른 프로그램을 실행하는 것이지.
**wait()**는 부모가 자식 프로세스의 종료를 기다리는 데 쓰여. fork() 후에 비결정적인 실행 순서 문제를 해결할 수 있지. 부모가 wait()를 호출하면 자식이 무조건 먼저 실행을 마치고, 그 후에 부모가 이어서 실행돼. 출력 순서가 **결정적(deterministic)**이 되는 거야.
fork()와 exec()를 왜 분리했을까? 이게 유닉스 설계의 핵심 아이디어야. fork()와 exec() 사이에 뭔가를 할 수 있기 때문이지. 셸의 리다이렉션이 대표적인 예야. wc p3.c > newfile.txt를 실행하면, 셸은 fork()로 자식을 만들고, 자식에서 stdout을 닫고 newfile.txt를 열어서 stdout에 연결한 다음, exec("wc")를 실행해. wc 프로그램은 자기 출력이 파일로 가는지도 몰라. 파이프도 마찬가지야 — cat foo.txt | grep bar | wc -l 같은 파이프라인은 fork/exec 분리가 있어서 가능한 거지.
프로세스 제어에는 kill() 시스템 콜도 있어. 프로세스에 **시그널(signal)**을 보내는 건데, SIGINT(인터럽트), SIGTERM(종료 요청), SIGSTOP(일시 정지) 같은 것들이지. 시그널 핸들러를 등록해서 Ctrl+C를 잡아서 "정말 종료하시겠습니까?"를 물어볼 수도 있어. 보통 유저는 자기가 실행한 프로세스에만 시그널을 보낼 수 있고, **superuser(root)**만 모든 프로세스를 제어할 수 있지.
정리
2장 읽고 기억할 거 세 가지:
- 프로세스 = 실행 중인 프로그램이야. 머신 상태(메모리 + 레지스터 + I/O)를 저장하고 복원할 수 있으면 프로세스를 자유롭게 멈추고 재개할 수 있어. 이게 컨텍스트 스위치의 원리지
- fork()는 복제, exec()는 교체, wait()는 대기야. 이 세 API가 유닉스 프로세스 관리의 전부고, fork/exec 분리 덕분에 리다이렉션과 파이프가 가능해
- 프로세스 상태 전이 — Running, Ready, Blocked 사이의 전환이 OS 스케줄링의 기본 골격이야. 이 흐름을 머릿속에 그릴 수 있으면 OS의 큰 그림이 보여