PCB와 컨텍스트 스위칭
운영체제가 프로세스마다 유지하는 PCB에 무엇이 기록되는지 살펴보고, 실행 중인 프로세스를 다른 프로세스로 바꾸는 컨텍스트 스위칭의 과정을 작은 모델로 따라갑니다. 전환의 직접 비용과 캐시, TLB에서 생기는 간접 비용을 구분하고, 전환 횟수를 재는 방법과 준비 큐, 대기 큐의 역할을 정리합니다.
책을 읽다가 잠시 덮어야 할 때 책갈피를 끼워 두면, 나중에 다시 펼쳐 같은 쪽에서 이어 읽을 수 있습니다. 운영체제도 같은 일을 합니다. CPU 하나로 여러 프로세스를 번갈아 실행하려면, 실행을 멈추는 프로세스가 어디까지 실행했고 어떤 값을 계산하던 중이었는지 기록해 두었다가, 다음에 그 프로세스를 실행할 때 그대로 되살려야 합니다. 프로세스는 자기가 멈췄었다는 사실을 몰라야 하므로, 이 기록은 아주 정확해야 합니다.
이를 위해 운영체제가 프로세스마다 유지하는 자료구조를 PCB(process control block, 프로세스 제어 블록)라고 하고, CPU에서 실행 중인 프로세스를 다른 프로세스로 바꾸는 과정을 컨텍스트 스위칭(context switching), 우리말로 문맥 교환이라고 합니다. 이 글에서는 PCB에 무엇이 담기는지, 전환이 어떤 순서로 일어나며 어떤 비용이 드는지 살펴봅니다.
PCB: 프로세스마다 남겨 두는 기록
PCB는 커널이 프로세스 하나를 관리하는 데 필요한 정보를 모아 둔 자료구조입니다. 프로세스를 만들 때 함께 만들어지고, 프로세스가 끝난 뒤 부모가 종료 상태를 거두어 갈 때 사라집니다. 그래서 앞 글에서 본 좀비 프로세스는 실행은 끝났지만 PCB의 일부가 아직 남아 있는 상태라고 할 수 있습니다. PCB에 담기는 정보는 쓰임새에 따라 다음과 같이 나눌 수 있습니다.
| 분류 | 담기는 정보 | 쓰이는 곳 |
|---|---|---|
| 식별 | 프로세스 ID(PID), 부모의 PID(PPID), 실행한 사용자와 그룹의 ID(UID, GID) | 프로세스를 찾고, 파일 접근이나 신호 전송을 허락할지 판단할 때 |
| CPU 상태 | 프로그램 카운터, 범용 레지스터, 스택 포인터, 플래그 레지스터, 부동소수점과 벡터 레지스터 | 실행을 멈추었다가 이어 갈 때 |
| 스케줄링 | 프로세스 상태, 우선순위, 지금까지 쓴 CPU 시간 | 다음에 실행할 프로세스를 고를 때 |
| 메모리 | 페이지 테이블의 위치, 코드, 데이터, 힙, 스택 영역의 범위와 권한 | 주소를 변환하고 잘못된 접근을 막을 때 |
| 파일과 신호 | 열린 파일 디스크립터 표, 현재 작업 디렉터리, 신호 처리 함수와 막아 둔 신호 | 입출력 요청과 신호를 처리할 때 |
Linux에서 우선순위는 일반 프로세스의 경우 nice 값으로 -20(가장 높음)부터 19(가장 낮음)까지이고, 실시간 정책(SCHED_FIFO, SCHED_RR)의 프로세스는 1부터 99까지의 실시간 우선순위를 씁니다. 페이지 테이블의 위치는 x86에서 프로세스가 실행될 때 CR3 레지스터에 실립니다. PID는 양의 정수로, 쓸 수 있는 최댓값은 시스템마다 다르며 /proc/sys/kernel/pid_max에서 확인할 수 있습니다.
교과서의 PCB는 개념이고, 실제 커널은 이 정보를 여러 구조체에 나누어 둡니다. Linux에서는 실행 단위마다 task_struct라는 구조체가 있는데, 커널 소스의 include/linux/sched.h에 정의되어 있고 필드가 수백 개에 이릅니다. task_struct는 메모리 정보를 담은 mm_struct, 열린 파일 표를 담은 files_struct 같은 구조체를 가리키는 포인터를 가지고 있어, 같은 메모리나 파일 표를 여러 실행 단위가 함께 쓸 수 있습니다. 레지스터 값도 모두 task_struct 안에 들어 있는 것은 아닙니다. 사용자 모드의 레지스터는 커널에 들어올 때 그 실행 단위의 커널 스택에 저장되고, 아키텍처마다 다른 나머지 상태는 별도의 구조체에 보관됩니다. 이 장에서 PCB라고 할 때는 이 모든 것을 묶은 개념을 가리킵니다.
더 깊이: 커널 스택은 작고 크기가 고정되어 있다
사용자 모드의 스택은 필요한 만큼 자라지만, 커널 스택은 처음 정한 크기에서 늘어나지 않습니다. x86-64 Linux의 커널 스택 문서에 따르면 실행 단위마다 하나씩 있는 커널 스택의 크기는 THREAD_SIZE, 곧 페이지 네 개이므로 4KiB 페이지에서는 16KiB입니다. 시스템 콜과 예외를 처리하는 커널 코드는 모두 이 작은 공간 안에서 실행되어야 합니다.
장치가 보낸 외부 인터럽트는 이 스택을 쓰지 않습니다. 외부 인터럽트와 softirq는 CPU마다 하나씩 있는 인터럽트 스택에서 처리되며, 중첩되지 않은 첫 인터럽트가 올 때 소프트웨어가 이 스택으로 바꿉니다. 그래서 인터럽트가 어느 실행 단위를 실행하던 중에 왔든, 인터럽트 처리가 그 실행 단위의 작은 커널 스택을 나누어 쓰지 않아도 됩니다.
지금 쓰는 스택을 믿을 수 없는 사건에는 또 다른 스택이 있습니다. x86-64에는 정해진 사건이 오면 하드웨어가 미리 지정된 스택으로 바꾸게 하는 IST(interrupt stack table)라는 기능이 있으며, CPU마다 최대 일곱 개의 IST 스택을 둘 수 있습니다. Linux는 이중 폴트(double fault), NMI, 디버그 예외, 머신 체크 예외에 IST 스택을 씁니다. 예를 들어 커널 스택 포인터가 망가져 이중 폴트가 나더라도 처리 코드는 별도의 스택에서 실행되므로, 커널은 많은 경우 오류 보고(oops)를 출력할 수 있습니다.
/proc에서 PCB 들여다보기
Linux는 커널이 관리하는 프로세스 정보를 /proc이라는 가상 파일 시스템으로 보여 줍니다. 디스크에 저장된 파일이 아니라, 읽을 때마다 커널이 그 순간의 정보를 만들어 내는 파일입니다. /proc/<PID>/ 디렉터리 하나가 프로세스 하나에 해당하며, 셸에서 $$는 그 셸 자신의 PID이므로 다음 명령으로 지금 셸의 정보를 볼 수 있습니다.
cat /proc/$$/status # 이름, 상태, PID, 메모리, 전환 횟수 등
ls -l /proc/$$/fd # 열린 파일 디스크립터와 가리키는 대상
cat /proc/$$/cmdline | tr '\0' ' '; echo # 실행할 때의 명령줄status 파일은 이름: 값 형식의 줄로 이루어져 있으며, 앞에서 본 PCB의 분류와 대응시켜 읽을 수 있습니다. 주요 필드는 다음과 같고, 전체 목록과 정확한 뜻은 proc_pid_status(5)에 있습니다.
| 필드 | 뜻 | PCB의 분류 |
|---|---|---|
Name, Pid, PPid | 실행 파일 이름, 자신과 부모의 PID | 식별 |
Uid, Gid | 실제, 유효, 저장, 파일 시스템 사용자와 그룹 ID | 식별 |
State | 현재 상태. S (sleeping)처럼 글자와 설명으로 표시 | 스케줄링 |
VmSize, VmRSS | 가상 메모리 전체 크기, 실제 물리 메모리에 올라와 있는 크기 | 메모리 |
Threads | 이 프로세스에 속한 스레드 수 | 식별 |
voluntary_ctxt_switches, nonvoluntary_ctxt_switches | 스스로 CPU를 내놓은 횟수, 빼앗긴 횟수 | 스케줄링 |
fd 디렉터리에는 열린 파일 디스크립터 번호마다 심볼릭 링크가 하나씩 있고, 각 링크는 그 디스크립터가 가리키는 파일이나 터미널, 소켓을 보여 줍니다. 터미널에서 연 셸이라면 0, 1, 2번이 모두 터미널 장치를 가리킬 것입니다. 다른 사용자의 프로세스는 권한에 따라 일부 파일을 읽을 수 없습니다. ps와 top이 보여 주는 정보도 결국 이 파일들을 읽어 정리한 것입니다.
컨텍스트 스위칭의 과정
프로세스 A가 사용자 모드에서 실행 중일 때 타이머 인터럽트가 와서, 운영체제가 프로세스 B로 전환하는 과정을 따라가 보겠습니다. 여기서 컨텍스트(context)는 프로세스가 실행을 이어 가는 데 필요한 CPU의 상태, 곧 프로그램 카운터와 레지스터 값, 스택 위치를 뜻합니다.
- 타이머 인터럽트가 와서 CPU가 커널의 인터럽트 처리 코드로 들어갑니다. 이때 A의 사용자 모드 레지스터가 A의 커널 스택에 저장됩니다.
- 커널은 A가 받은 실행 시간이 다 되었음을 확인하고, 스케줄러를 불러 다음에 실행할 프로세스를 고르게 합니다. 스케줄러는 준비 상태의 프로세스 가운데 B를 고릅니다.
- 커널은 A의 커널 실행 문맥, 곧 나머지 레지스터와 커널 스택 위치를 A의 기록에 저장합니다.
- B가 A와 다른 주소 공간을 쓴다면, 페이지 테이블을 B의 것으로 바꿉니다. x86에서는 CR3 레지스터에 B의 페이지 테이블 주소를 싣습니다. A와 B가 같은 프로세스의 스레드라면 주소 공간이 같으므로 이 단계를 건너뜁니다.
- B의 기록에서 커널 스택 위치와 레지스터를 되살립니다. 이제 CPU는 B가 예전에 멈추었던 커널 코드의 지점부터 실행합니다.
- B가 커널에서 할 일을 마치면, B의 커널 스택에 저장되어 있던 사용자 모드 레지스터를 되살려 사용자 모드로 돌아갑니다. B는 멈추었던 명령어부터 이어서 실행합니다.
아래 단계 개념도는 이 과정을 문맥 저장, 다음 프로세스 선택, 주소 공간 준비, 문맥 복원의 네 단계로 묶어 보여 줍니다.
A의 문맥을 보존하고 B의 문맥으로 이어서 실행합니다
재개할 위치와 값을 보존한다. B의 값과 PC로 실행을 이어간다. 각 단계의 값과 전환 이유를 비교합니다.
컨텍스트 스위칭 · 실행 재개 조건
A의 문맥을 보존하고 B의 문맥으로 이어서 실행합니다
태스크 A에서 B로 전환하는 개념 순서입니다. 저장 영역은 커널 스택·아키텍처 문맥·연결된 관리 구조를 포함하며 모든 레지스터가 task_struct에 직접 들어가는 것은 아닙니다.
A · 재개할 상태 보존
필요한 레지스터·스택 문맥을 저장합니다. A가 다시 선택되면 이 상태를 사용합니다.
스케줄러 · 실행 가능한 B 선택
Ready 후보 중 정책에 따라 고릅니다. 인터럽트 진입이 매번 다른 태스크 선택을 뜻하지는 않습니다.
B · 주소 공간과 문맥 준비
주소 공간이 다르면 필요한 페이지 테이블 처리를 수행합니다. 같은 공간을 공유하는 스레드는 일부 변경을 생략할 수 있습니다.
B의 저장 문맥을 복원하여 중단되었던 실행 지점에서 이어갑니다.
사용자 모드로 돌아갈 차례라면 이후 복귀합니다. 전환이 모든 캐시·TLB 항목을 없애지는 않습니다. ASID/PCID와 작업 집합에 따른 간접 비용은 따로 측정합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 A 문맥 저장부터 B 문맥 복원까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 데스크톱과 동일한 다섯 노드와 YES·NO 분기를 유지한다. 같은 주소 공간 분기는 페이지 테이블 준비를 생략해 B의 문맥 복원으로 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 문맥 저장 재개할 위치와 값을 보존한다. |
|
|
| B 선택 정책으로 실행 가능한 B를 고른다. |
|
|
| 주소 공간 준비 주소 공간이 다른 대표 경로를 진행한다. |
|
|
| B 문맥 복원 B의 값과 PC로 실행을 이어간다. |
|
|
A의 문맥을 보존하고 B의 문맥으로 이어서 실행합니다
재개할 위치와 값을 보존한다. B의 값과 PC로 실행을 이어간다. 각 단계의 값과 전환 이유를 비교합니다.
컨텍스트 스위칭 · 실행 재개 조건
A의 문맥을 보존하고 B의 문맥으로 이어서 실행합니다
태스크 A에서 B로 전환하는 개념 순서입니다. 저장 영역은 커널 스택·아키텍처 문맥·연결된 관리 구조를 포함하며 모든 레지스터가 task_struct에 직접 들어가는 것은 아닙니다.
A · 재개할 상태 보존
필요한 레지스터·스택 문맥을 저장합니다. A가 다시 선택되면 이 상태를 사용합니다.
스케줄러 · 실행 가능한 B 선택
Ready 후보 중 정책에 따라 고릅니다. 인터럽트 진입이 매번 다른 태스크 선택을 뜻하지는 않습니다.
B · 주소 공간과 문맥 준비
주소 공간이 다르면 필요한 페이지 테이블 처리를 수행합니다. 같은 공간을 공유하는 스레드는 일부 변경을 생략할 수 있습니다.
B의 저장 문맥을 복원하여 중단되었던 실행 지점에서 이어갑니다.
사용자 모드로 돌아갈 차례라면 이후 복귀합니다. 전환이 모든 캐시·TLB 항목을 없애지는 않습니다. ASID/PCID와 작업 집합에 따른 간접 비용은 따로 측정합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 A 문맥 저장부터 B 문맥 복원까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 데스크톱과 동일한 다섯 노드와 YES·NO 분기를 유지한다. 같은 주소 공간 분기는 페이지 테이블 준비를 생략해 B의 문맥 복원으로 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 문맥 저장 재개할 위치와 값을 보존한다. |
|
|
| B 선택 정책으로 실행 가능한 B를 고른다. |
|
|
| 주소 공간 준비 주소 공간이 다른 대표 경로를 진행한다. |
|
|
| B 문맥 복원 B의 값과 PC로 실행을 이어간다. |
|
|
A의 처지에서 보면 이 모든 일이 일어나는 동안 자신은 잠깐 멈추었다가 같은 자리에서 이어서 실행될 뿐입니다. A의 코드에는 레지스터를 저장하고 되살리는 부분이 전혀 없는데도 계산이 틀어지지 않는 것은 커널이 그 일을 대신 하기 때문입니다. 물론 A가 시각을 재 보면 그 사이에 시간이 흘렀다는 것은 알 수 있습니다.
저장과 복원을 작은 모델로 확인하기
레지스터는 CPU에 한 벌뿐이고 여러 프로세스가 번갈아 씁니다. 그래서 전환할 때마다 저장과 복원이 꼭 필요합니다. 다음 Python 코드는 이 과정을 단순하게 흉내 냅니다. CPU에는 프로그램 카운터 pc와 계산 중인 값 acc라는 레지스터 두 개만 있고, 프로세스 A와 B는 각자 숫자 목록을 더합니다. pcb는 프로세스마다 저장해 둔 문맥입니다.
programs = {
"A": [1, 3, 5], # A는 1 + 3 + 5를 계산한다
"B": [10, 20], # B는 10 + 20을 계산한다
}
pcb = {name: {"pc": 0, "acc": 0} for name in programs} # 프로세스마다 저장해 둔 문맥
cpu = {"pc": 0, "acc": 0} # CPU의 레지스터는 한 벌뿐
def run(name, steps):
cpu.update(pcb[name]) # 복원: 저장해 둔 문맥을 레지스터에 싣는다
for _ in range(steps):
if cpu["pc"] >= len(programs[name]):
break
cpu["acc"] += programs[name][cpu["pc"]]
cpu["pc"] += 1
pcb[name] = dict(cpu) # 저장: 레지스터를 이 프로세스의 기록에 옮긴다
print(f"{name} 실행 뒤: pc={cpu['pc']}, acc={cpu['acc']}")
run("A", 2)
run("B", 1)
run("A", 2)
run("B", 2)
print("결과:", pcb["A"]["acc"], pcb["B"]["acc"])A 실행 뒤: pc=2, acc=4
B 실행 뒤: pc=1, acc=10
A 실행 뒤: pc=3, acc=9
B 실행 뒤: pc=2, acc=30
결과: 9 30A는 두 단계를 실행해 1 + 3 = 4까지 계산한 상태에서 멈춥니다. 이때 pc=2, acc=4가 A의 기록에 저장되고, B가 실행되면서 CPU의 acc에는 B의 값 10이 들어갑니다. A가 다시 실행될 때 저장해 둔 acc=4와 pc=2가 복원되므로, A는 세 번째 숫자 5를 더해 9를 얻습니다. 두 프로세스가 같은 레지스터를 번갈아 썼는데도 결과는 각자 따로 실행한 것과 같습니다. 복원하는 줄을 빼면 어떻게 되는지는 연습 문제에서 확인해 봅니다.
실제 커널이 하는 일도 원리는 같습니다. 다만 저장할 레지스터가 훨씬 많고, 저장하는 장소가 커널 스택과 아키텍처별 구조체로 나뉘며, 주소 공간을 바꾸는 단계가 더해집니다.
전환에 드는 비용
컨텍스트 스위칭을 하는 동안 CPU는 어떤 프로세스의 일도 진척시키지 못합니다. 그래서 전환은 멀티태스킹을 위해 치르는 부가 비용(overhead)이며, 이 비용은 두 가지로 나누어 볼 수 있습니다.
직접 비용은 전환 자체에 드는 시간입니다. 커널에 들어가고 나오는 시간, 레지스터를 저장하고 되살리는 시간, 스케줄러가 다음 프로세스를 고르는 시간이 여기에 들어갑니다. 이 시간은 CPU와 커널, 보안 완화 설정에 따라 크게 다르므로, 특정 숫자로 외우기보다 자기 환경에서 측정해야 합니다.
간접 비용은 전환 뒤에 나타납니다. A가 실행되는 동안 캐시에는 A가 쓰던 데이터가 채워져 있는데, B가 실행되면 B의 데이터가 캐시를 채우면서 A의 데이터 일부를 밀어냅니다. 나중에 A가 다시 실행되면 밀려난 데이터를 메모리에서 다시 가져와야 하므로 한동안 느려집니다. 주소 변환 결과를 저장해 두는 TLB(translation lookaside buffer)도 비슷합니다. 주소 공간이 바뀌면 이전 프로세스의 변환 결과는 쓸 수 없으므로, x86의 PCID나 ARM의 ASID처럼 TLB 항목에 주소 공간의 번호를 붙이는 기능이 없다면 TLB를 비워야 합니다. 이런 기능이 있으면 항목을 남겨 두었다가 다시 쓸 수 있습니다. 간접 비용은 두 프로세스가 쓰는 데이터의 양과 캐시 크기에 따라 달라지며, 경우에 따라 직접 비용보다 커지기도 합니다.
비용의 규모를 가늠하는 계산을 해 보겠습니다. 논리 CPU 하나에서 1초에 1,000번 전환하고 전환마다 직접 비용이 10μs라고 가정하면, 1초 가운데 1,000 × 10μs = 10ms, 곧 1%를 전환에 씁니다. 이것은 가정한 값으로 한 계산일 뿐 측정한 결과가 아니며, 간접 비용은 이 1%에 들어 있지 않습니다.
같은 프로세스에 속한 스레드 사이의 전환은 주소 공간을 바꿀 필요가 없으므로 페이지 테이블 교체와 그에 따른 TLB 비용을 줄일 수 있습니다. 그렇다고 스레드 전환이 늘 몇 배 빠른 것은 아닙니다. 레지스터 저장과 스케줄러의 비용은 그대로 들고, 스레드들이 서로 다른 데이터를 많이 쓴다면 캐시 경쟁도 여전히 일어납니다. 스레드는 4장에서 다룹니다.
전환 횟수 재기
Linux에서는 시스템 전체와 프로세스 하나의 전환 횟수를 모두 볼 수 있습니다. 아래에서 vmstat 1과 pidstat -w 1은 1초마다 새 줄을 출력하며 Ctrl+C로 멈출 때까지 계속 돌고, 가운데 두 줄은 셸 변수 pid에 PID를 넣은 뒤 그 프로세스의 /proc 파일에서 전환 횟수가 든 줄만 골라냅니다. 앞의 두 도구는 초당 값을, /proc의 파일은 프로세스가 시작한 뒤 쌓인 누적 값을 보여 준다는 차이가 있습니다.
vmstat 1 # 1초마다 시스템 전체 통계를 출력
pid=1234 # 살펴볼 프로세스의 PID로 바꾼다
grep ctxt /proc/$pid/status # 그 프로세스의 누적 전환 횟수
pidstat -w 1 # 프로세스별 초당 전환 횟수(sysstat 패키지)vmstat의 출력에서 system 묶음 아래의 in은 초당 인터럽트 수, cs는 초당 컨텍스트 스위칭 수입니다. 첫 줄은 부팅한 뒤 지금까지의 평균이고, 둘째 줄부터가 1초 동안의 값이므로 지금 상황을 보려면 둘째 줄부터 읽어야 합니다. 열의 뜻은 vmstat(8) 매뉴얼에 있습니다.
프로세스의 status에 있는 두 값은 전환의 원인을 나누어 보여 줍니다. voluntary_ctxt_switches는 프로세스가 입출력이나 잠금을 기다리느라 스스로 CPU를 내놓은 횟수이고, nonvoluntary_ctxt_switches는 아직 실행할 수 있었는데 선점되어 CPU를 빼앗긴 횟수입니다. 자발적 전환이 많다면 그 프로세스는 무언가를 자주 기다리고 있는 것이고, 비자발적 전환이 많다면 CPU를 두고 다른 프로세스와 경쟁하고 있는 것입니다. 다만 횟수만으로 낭비된 시간을 알 수는 없고, 무엇을 기다리는지나 누구와 경쟁하는지는 다른 도구로 더 확인해야 합니다. 스레드가 여러 개인 프로세스라면 스레드별 값을 /proc/<PID>/task/<TID>/status에서 볼 수 있습니다.
준비 큐와 대기 큐
운영체제는 프로세스를 상태에 따라 여러 줄에 세워 관리합니다. 준비 큐(ready queue)에는 CPU만 받으면 실행할 수 있는 프로세스들이 있고, 스케줄러는 여기서 다음에 실행할 프로세스를 고릅니다. 이름은 큐지만 실제 구현이 단순한 선입선출 줄인 경우는 드뭅니다. 우선순위별로 줄을 나누기도 하고, Linux처럼 트리를 쓰기도 합니다. Linux의 일반 프로세스 스케줄러였던 CFS는 프로세스들을 실행한 시간을 기준으로 정렬한 레드-블랙 트리로 관리했고, Linux 6.6부터는 EEVDF라는 방식으로 바뀌었습니다. 그래서 준비 큐는 특정 자료구조의 이름이라기보다 실행할 수 있는 후보들의 모임이라고 이해하는 것이 정확합니다.
대기 큐(wait queue)는 어떤 사건을 기다리는 프로세스들의 목록이며, 기다리는 사건마다 따로 있습니다. 어떤 디스크 요청의 완료를 기다리는 프로세스들, 어떤 소켓에 데이터가 도착하기를 기다리는 프로세스들이 각각 다른 대기 큐에 있습니다. 사건이 일어나면 커널은 해당 대기 큐의 프로세스를 깨워 준비 큐로 옮깁니다. 앞 글에서 본 대기에서 준비로의 전이가 바로 이 동작입니다.
결국 어떤 프로세스가 먼저 CPU를 받고, 얼마나 오래 실행하며, 얼마나 빨리 응답을 받는지는 이 큐들을 어떻게 구성하고 스케줄러가 어떤 규칙으로 고르느냐에 달려 있습니다. 이 규칙들은 5장의 CPU 스케줄링에서 다룹니다.
정리
- PCB는 커널이 프로세스마다 유지하는 기록으로, 식별 정보, CPU 상태, 스케줄링 정보, 메모리 정보, 파일과 신호 정보를 담습니다. Linux는 이 정보를
task_struct와 여기에 연결된 여러 구조체, 커널 스택에 나누어 둡니다. /proc/<PID>/status와/proc/<PID>/fd로 실행 중인 프로세스의 PCB 정보를 읽을 수 있습니다.- 컨텍스트 스위칭은 실행 중인 프로세스의 문맥을 저장하고, 다음 프로세스를 고르고, 필요하면 주소 공간을 바꾼 뒤, 다음 프로세스의 문맥을 되살리는 과정입니다.
- 전환에는 레지스터 저장과 스케줄링에 드는 직접 비용과, 캐시와 TLB를 다시 채우는 간접 비용이 듭니다. 같은 프로세스의 스레드 사이의 전환은 주소 공간을 바꾸지 않아 일부 비용을 줄입니다.
- 자발적 전환은 프로세스가 기다리느라 CPU를 내놓은 것이고, 비자발적 전환은 선점된 것입니다. 준비 큐와 대기 큐는 프로세스를 상태에 따라 관리하는 줄입니다.
연습 문제
1. 전환의 직접 비용 계산하기
논리 CPU 하나에서 1초에 1,500번 전환하고, 전환마다 직접 비용이 8μs라고 가정합니다. 1초 가운데 직접 비용은 몇 ms이고 몇 퍼센트인가요? 이 숫자에 캐시와 TLB의 간접 비용도 들어 있다고 볼 수 있을까요?
해설 보기
1,500 × 8μs = 12,000μs = 12ms이고, 12ms ÷ 1,000ms × 100 = 1.2%입니다. 이것은 주어진 가정으로 계산한 직접 비용일 뿐입니다. 간접 비용은 전환 뒤에 캐시와 TLB를 다시 채우느라 프로세스의 실행이 느려지는 형태로 나타나므로, 전환하는 순간에 드는 시간에는 잡히지 않습니다. 간접 비용의 크기는 프로세스들이 쓰는 데이터의 양과 하드웨어에 따라 다르므로, 실제 작업으로 측정해야 하며 1.2%를 전체 비용으로 볼 수 없습니다.
2. 복원을 빠뜨린 커널
앞의 Python 모델에서 run 함수의 첫 줄 cpu.update(pcb[name])을 지우고 같은 순서로 실행하면 출력이 어떻게 바뀔지 예상해 보세요. 그리고 실제 커널에서 복원을 빠뜨리면 어떤 일이 생길지 설명하세요.
해설 보기
복원을 빼면 각 프로세스는 자기 문맥이 아니라 직전에 실행된 프로세스가 남긴 레지스터 값으로 실행을 시작합니다. 출력은 다음과 같습니다.
A 실행 뒤: pc=2, acc=4
B 실행 뒤: pc=2, acc=4
A 실행 뒤: pc=3, acc=9
B 실행 뒤: pc=3, acc=9
결과: 9 9B는 A가 남긴 pc=2에서 시작하는데, B의 프로그램은 숫자가 두 개뿐이라 pc=2는 이미 끝을 지난 위치입니다. 그래서 B는 자기 명령을 하나도 실행하지 못하고, A가 남긴 acc=4를 자기 결과로 저장합니다. 마지막에도 A의 값 9를 그대로 이어받아 B의 결과가 30이 아니라 9가 됩니다. 실제 커널에서 복원을 빠뜨리면 프로세스가 다른 프로세스의 프로그램 카운터나 스택 포인터로 실행을 이어 가게 되므로, 엉뚱한 주소의 명령을 실행하다가 곧바로 비정상 종료되거나, 더 나쁘게는 다른 프로세스의 데이터를 읽고 쓸 수 있습니다.
3. 전환 횟수로 상황 짐작하기
두 프로세스의 /proc/<PID>/status를 10초 간격으로 두 번 읽어 차이를 구했더니, 프로세스 P는 자발적 전환이 50,000번, 비자발적 전환이 20번 늘었고, 프로세스 Q는 자발적 전환이 30번, 비자발적 전환이 8,000번 늘었습니다. 각 프로세스가 어떤 상황일지 짐작하고, 짐작을 확인하려면 무엇을 더 봐야 할지 설명하세요.
해설 보기
P는 스스로 CPU를 내놓은 횟수가 많으므로, 짧은 입출력이나 잠금, 네트워크 응답을 자주 기다리는 프로세스일 가능성이 높습니다. 작은 단위로 자주 읽고 쓰는 서버나 잠금 경합이 심한 프로그램이 이런 모습을 보입니다. Q는 실행할 수 있는데도 CPU를 빼앗긴 횟수가 많으므로, 계산을 많이 하면서 다른 프로세스와 CPU를 두고 경쟁하고 있을 가능성이 높습니다. 다만 횟수만으로는 원인도, 낭비된 시간도 알 수 없습니다. P에 대해서는 strace나 프로파일러로 무엇을 기다리는지 확인하고, Q에 대해서는 vmstat의 r 열(실행 중이거나 CPU를 기다리는 프로세스 수)이나 top의 Tasks: 줄에 나오는 running 수로 CPU 경쟁이 심한지, top의 프로세스 목록에서 우선순위가 더 높은 프로세스가 있는지를 확인합니다.
다음 글 프로세스 생성과 종료에서는 fork(), exec(), wait()가 실제로 어떻게 동작하는지, 그리고 좀비와 고아 프로세스가 왜 생기는지 살펴봅니다.