> [!abstract] Introduction > 운영체제는 수십에서 수백 개의 [[Process]]를 동시에 관리합니다. 각 프로세스가 어디까지 실행되었는지, 어떤 자원을 사용하고 있는지, 지금 어떤 상태에 있는지를 커널이 항상 파악하고 있어야 프로세스를 중단했다가 다시 이어서 실행할 수 있습니다. 이 모든 정보를 한곳에 모아 관리하는 자료구조가 바로 PCB(Process Control Block)입니다. ## PCB는 왜 필요한가 오늘날 컴퓨터에서는 수많은 프로세스가 CPU를 번갈아 사용한다. [[Scheduler|스케줄러]]가 실행 중인 프로세스를 중단하고 다른 프로세스를 실행하는 [[Context Switch|컨텍스트 스위치]]가 일어날 때, 커널은 중단되는 프로세스의 현재 상태를 어딘가에 저장해 두어야 한다. 나중에 다시 그 프로세스가 실행될 차례가 되면 저장해 둔 상태를 복원함으로써 마치 중단된 적이 없었던 것처럼 실행을 재개할 수 있기 때문이다. 이 역할을 하는 것이 PCB*Process Control Block*다. PCB는 프로세스마다 하나씩 존재하는 자료구조로, 해당 프로세스를 통제하기 위한 모든 정보를 담고 있다. Process Descriptor(프로세스 서술자) 혹은 Task Control Block(TCB)이라 부르기도 한다(Silberschatz, Galvin and Gagne, 2018). ![[PCBLayout.png|PCB의 대략적인 구조]] PCB가 없다면 운영체제는 프로세스를 전환할 수 없고, 따라서 멀티프로그래밍*multiprogramming*[^multiprogramming-definition] 자체가 불가능해진다. PCB는 운영체제가 프로세스를 하나의 관리 가능한 단위로 다루기 위한 핵심 추상화인 셈이다. [^multiprogramming-definition]: 멀티프로그래밍이란 여러 프로세스를 메모리에 동시에 올려두고 CPU가 한 프로세스에서 다른 프로세스로 전환하며 실행하는 기법을 말한다. CPU 이용률을 극대화하기 위한 기본적인 운영체제 설계 원칙이다. ## PCB의 구성 요소 PCB에 담기는 정보는 운영체제마다 세부 사항이 다르지만, 공통적으로 포함되는 핵심 요소들이 있다. ### 프로세스 상태 프로세스가 현재 어떤 상태(new, ready, running, waiting, terminated)에 있는지를 나타낸다. 스케줄러는 이 값을 확인하여 실행 가능한 프로세스만을 선택하고, 대기 중인 프로세스는 해당 이벤트가 발생할 때까지 건너뛴다. 프로세스 상태 전이에 대한 자세한 내용은 [[Process]] 포스트에서 다룬다. ### 프로세스 식별자 각 프로세스를 고유하게 구분하기 위한 번호로, PID*Process Identification Number*라 부른다. 운영체제는 PID를 통해 특정 프로세스를 지목하여 시그널을 보내거나, 자원 사용량을 추적하거나, 부모-자식 관계를 관리한다. ### 프로그램 카운터 CPU가 다음에 실행할 명령어의 주소를 가리키는 레지스터 값이다. 컨텍스트 스위치가 일어날 때 현재 프로그램 카운터 값을 PCB에 저장하고, 프로세스가 다시 실행될 때 저장된 값을 복원한다. 이 덕분에 프로세스는 중단된 지점의 바로 다음 명령어부터 이어서 실행할 수 있다. ### CPU 레지스터 프로그램 카운터 외에도 프로세스가 사용하던 범용 레지스터, 스택 포인터, 상태 레지스터 등의 값을 모두 저장해야 한다. 이들을 통칭하여 하드웨어 문맥*hardware context*이라 하며, 컨텍스트 스위치의 핵심인 저장/복원*save/restore* 작업의 대상이 된다.[^hardware-context-save-restore] [^hardware-context-save-restore]: 저장해야 하는 레지스터의 종류와 개수는 CPU 아키텍처에 따라 다르다. x86-64 환경에서는 16개의 범용 레지스터, 플래그 레지스터, 부동소수점/SIMD 레지스터 등이 포함되며, ARM64에서는 31개의 범용 레지스터와 NEON/FP 레지스터 등이 대상이다. 자세한 내용은 [[Context Switch]] 포스트에서 다룬다. ### CPU 스케줄링 정보 프로세스의 우선순위*priority*, 스케줄링 큐에서의 위치, 스케줄링 정책(FIFO, Round Robin 등)에 관한 정보다. [[Scheduler|스케줄러]]는 이 정보를 바탕으로 어떤 프로세스에게 CPU를 할당할지 결정한다. ### 메모리 관리 정보 프로세스가 사용하는 가상 주소 공간에 관한 정보다. 페이지 테이블의 베이스 주소, 세그먼트 테이블, 메모리 한계 레지스터 값 등이 이에 해당한다. 커널은 이 정보를 통해 프로세스가 접근할 수 있는 메모리 범위를 관리하고, 다른 프로세스의 메모리 영역을 침범하지 못하도록 보호한다. ### 입출력 상태 정보 프로세스에 할당된 입출력 장치 목록, 열려 있는 파일의 목록 등이 포함된다. 프로세스가 종료될 때 커널은 이 정보를 참조하여 해당 프로세스가 점유하고 있던 입출력 자원을 해제한다. ### 자원 사용 정보 프로세스가 CPU를 사용한 시간, 경과 시간, 메모리 사용량 등의 통계 정보다. `Accounting Information`이라고도 부르며, 과금*accounting*, 성능 분석, 자원 한도 관리에 활용된다. ## 태스크 리스트 운영체제는 이러한 PCB들을 하나의 리스트로 조직하여 시스템에 존재하는 모든 프로세스를 관리한다. 이 리스트를 태스크 리스트*task list* 또는 프로세스 리스트*process list*라 부른다. ![[TaskList.png]] 프로세스가 생성되면 해당 프로세스의 PCB가 태스크 리스트에 추가되고, 프로세스가 종료되면 태스크 리스트에서 제거된다. 스케줄러는 태스크 리스트를 순회하며 실행할 프로세스를 선택하고, 커널의 다른 서브시스템도 이 리스트를 통해 특정 프로세스를 찾거나 전체 프로세스에 대한 작업(시그널 전달, 자원 회수 등)을 수행한다. ![[ReadyQueueAndWaitQueue.png]] 한편, 스케줄링의 효율을 위해 태스크 리스트 외에도 상태별 큐가 별도로 운영된다. 실행 준비가 완료된 프로세스는 실행 대기열*ready queue*에, 특정 이벤트를 기다리는 프로세스는 대기열*wait queue*에 각각 들어간다. 이렇게 상태별로 프로세스를 분류하면, 스케줄러가 모든 프로세스를 매번 순회하지 않고도 실행 가능한 프로세스를 빠르게 찾을 수 있다. ## 컨텍스트 스위치와 PCB PCB의 존재 의의는 [[Context Switch|컨텍스트 스위치]]에서 가장 극명하게 드러난다. 스케줄러가 현재 실행 중인 프로세스 $P_0$를 중단하고 프로세스 $P_1$을 실행하기로 결정하면, 다음과 같은 일이 일어난다. ![[contextSwitchWithPCB.svg]] 1. $P_0$의 현재 하드웨어 문맥(레지스터, 프로그램 카운터 등)을 $P_0$의 PCB에 저장한다. 2. $P_1$의 PCB에서 이전에 저장해 둔 하드웨어 문맥을 읽어 CPU 레지스터에 복원한다. 3. $P_1$이 중단된 지점부터 실행을 재개한다. 이 과정에서 PCB는 프로세스의 실행 상태를 보존하는 스냅샷 역할을 한다. PCB에 저장해야 할 정보가 많을수록 컨텍스트 스위치에 걸리는 시간이 길어지므로, PCB의 설계는 필요한 정보를 빠짐없이 담으면서도 크기를 최소화하는 균형을 맞추어야 한다. 컨텍스트 스위치의 세부 절차와 성능 비용에 대해서는 [[Context Switch]] 포스트에서 자세히 다룬다. ## Linux에서의 구현: `task_struct` ### 구조체 개요 리눅스에서 PCB에 해당하는 것은 `include/linux/sched.h`에 정의된 `task_struct` 구조체다(Linux v6.19 기준, 819번째 줄에서 시작). 이 구조체는 약 700줄이 넘는 방대한 크기(약 6~8KB)를 자랑하며, 프로세스에 대한 모든 것을 담고 있다(Love, 2010). ```c struct task_struct { /* ── 식별 ── */ pid_t pid; // 스레드 ID (커널 내부에서 고유) pid_t tgid; // 스레드 그룹 ID (사용자가 보는 PID) char comm[TASK_COMM_LEN]; // 실행 파일 이름 /* ── 상태 ── */ unsigned int __state; // TASK_RUNNING, TASK_INTERRUPTIBLE 등 int exit_state; // EXIT_ZOMBIE, EXIT_DEAD unsigned int flags; // PF_EXITING, PF_KTHREAD 등 /* ── 스케줄링 ── */ int prio; // 실효 우선순위 (0-139) unsigned int policy; // SCHED_NORMAL, SCHED_FIFO 등 const struct sched_class *sched_class; // 스케줄링 클래스 vtable struct sched_entity se; // CFS 스케줄링 엔티티 /* ── 메모리 ── */ struct mm_struct *mm; // 사용자 주소 공간 (커널 스레드는 NULL) struct mm_struct *active_mm; // 현재 하드웨어에 로드된 주소 공간 /* ── 파일 시스템 ── */ struct files_struct *files; // 열린 파일 서술자 테이블 struct fs_struct *fs; // 루트 디렉터리, 현재 작업 디렉터리 /* ── 프로세스 계층 ── */ struct task_struct __rcu *real_parent; // 실제 부모 프로세스 struct list_head children; // 자식 프로세스 리스트 struct list_head sibling; // 형제 프로세스 리스트 /* ── 커널 스택 / 아키텍처 ── */ struct thread_info thread_info; // 아키텍처별 저수준 정보 struct thread_struct thread; // CPU 레지스터 저장 영역 /* ... 700줄 이상의 필드 생략 ... */ }; ``` 앞서 살펴본 PCB의 개념적 구성 요소가 이 구조체 안에 대응됨을 확인할 수 있다. 프로세스 상태는 `__state`, 식별자는 `pid`와 `tgid`, 스케줄링 정보는 `prio`와 `sched_class`, 메모리 관리 정보는 `mm`, 입출력 상태 정보는 `files`에 각각 대응된다. 리눅스에서는 `task_struct` 구조체들이 원형 이중 연결 리스트를 이루며, 이것이 바로 리눅스의 태스크 리스트다. ### `pid`와 `tgid`: 프로세스와 스레드의 구분 리눅스에서는 [[Process]]와 [[Thread|스레드]]를 별도의 자료구조로 구분하지 않는다. 둘 다 동일한 `task_struct`로 표현되며, 차이는 자원을 공유하는 범위에서 비롯된다. 이를 가능하게 하는 것이 `pid`와 `tgid` 두 필드다. `pid`는 커널 내부에서 각 `task_struct`를 고유하게 식별하는 번호로, 스레드 단위로 할당된다. `tgid`*Thread Group ID*는 같은 프로세스에 속한 모든 스레드가 공유하는 번호로, 스레드 그룹의 리더(첫 번째로 생성된 스레드)의 `pid`값과 동일하다.[^pid-tgid-signal-delivery] ![[pidTgidThreadGroup.svg]] 사용자 공간에서 `getpid()`를 호출하면 `current->tgid`가 반환되고, `gettid()`를 호출하면 `current->pid`가 반환된다. 즉 사용자가 인식하는 "프로세스 ID"는 실제로는 스레드 그룹 ID인 셈이다. [^pid-tgid-signal-delivery]: 이 설계 덕분에 `kill(pid, sig)`로 시그널을 보내면 프로세스 전체(스레드 그룹)에 전달되고, `tgkill(tgid, tid, sig)`를 사용하면 특정 스레드에만 전달할 수 있다. `/proc/<tgid>/task/<pid>/` 경로를 통해 개별 스레드의 정보를 확인할 수 있다. ### `thread_info`와 `current` `task_struct`는 슬랍 할당자*slab allocator*[^slab-allocator-efficiency]를 통해 동적으로 할당되기 때문에, 커널이 현재 실행 중인 프로세스의 `task_struct`에 빠르게 접근할 수 있는 방법이 필요하다. 이 역할을 담당하는 것이 `thread_info` 구조체와 `current` 매크로다. `thread_info`는 `task_struct`의 첫 번째 멤버로 자리 잡고 있으며, 프로세스의 커널 스택에서 아키텍처별 저수준 정보에 빠르게 접근할 수 있게 한다. `current` 매크로는 현재 실행 중인 프로세스의 `task_struct` 포인터를 반환하는데, 그 구현은 아키텍처에 따라 다르다. ARM64 환경에서는 `sp_el0` 레지스터에 현재 프로세스의 `task_struct` 포인터를 직접 저장하는 방식을 사용한다. ```c // arch/arm64/include/asm/current.h static __always_inline struct task_struct *get_current(void) { unsigned long sp_el0; asm ("mrs %0, sp_el0" : "=r" (sp_el0)); return (struct task_struct *)sp_el0; } #define current get_current() ``` x86-64 환경에서는 Per-CPU 변수*per-cpu variable*[^per-cpu-variable-safety]를 통해 `current`를 구현한다. 어느 아키텍처든 핵심은 동일하다. 커널이 현재 실행 중인 프로세스의 PCB에 $O(1)$의 비용으로 접근할 수 있도록 보장하는 것이다. [^slab-allocator-efficiency]: 슬랩 할당자란 커널에서 자주 사용되는 크기의 객체를 효율적으로 할당/해제하기 위한 메모리 할당 메커니즘이다. `task_struct`처럼 크기가 고정된 구조체의 생성과 소멸이 빈번한 경우, 슬랩 할당자를 사용하면 메모리 단편화를 줄이고 할당 속도를 높일 수 있다. [^per-cpu-variable-safety]: Per-CPU 변수란 CPU 코어마다 독립적인 복사본을 갖는 변수다. 각 코어가 자신만의 `current` 값을 유지하므로 락*lock* 없이도 안전하게 현재 프로세스에 접근할 수 있다. --- ## 출처 - Silberschatz, A., Galvin, P.B. and Gagne, G. (2018) *Operating System Concepts*. 10th edn. Hoboken: Wiley. - Love, R. (2010) *Linux Kernel Development*. 3rd edn. Upper Saddle River: Addison-Wesley. - Bryant, R.E. and O'Hallaron, D.R. (2016) *Computer Systems: A Programmer's Perspective*. 3rd edn. Boston: Pearson. - Bovet, D.P. and Cesati, M. (2005) *Understanding the Linux Kernel*. 3rd edn. Sebastopol: O'Reilly.