리눅스의 fork는 부모 프로세스의 메모리를 복사하지 않는다. 코드 영역도 데이터 영역도 힙도 복사하지 않고, 부모와 자식이 같은 물리 페이지를 함께 가리키게 해 둔 다음 누군가 쓰기를 시도할 때 그 페이지만 복사한다. Copy-on-Write다
그렇다면 512MiB를 쥐고 있는 프로세스를 fork하는 비용과 아무것도 없는 프로세스를 fork하는 비용은 같아야 한다. 재보면 67배 차이가 난다. 프로세스 생성 비용이 복사하지도 않는 메모리의 크기를 따라가는 이유가 이 글의 절반이고, 그 비용을 피하려고 나온 것이 스레드다
프로그램이 프로세스가 되는 과정, PCB와 프로세스 상태, 컨텍스트 스위칭, fork와 exec, 좀비 프로세스, 스레드까지를 다룬다. 주장에는 가능한 한 실행 결과를 붙였다
이 글에서 자주 나오는 용어
- 프로그램(Program): 어떤 목적을 이루기 위해 진행되는 일련의 순서, 계획, 또는 컴퓨터가 특정 작업을 수행하도록 명령어들을 순서대로 나열한 것을 뜻한다
- 프로세스(Process): 메모리에 올라와 실행 중인 프로그램. 운영체제가 관리하는 단위다
- PCB(Process Control Block): 운영체제가 프로세스 하나를 관리하려고 들고 있는 정보 묶음. 리눅스에서는
task_struct다 - 페이지 테이블(Page Table): 프로세스가 쓰는 가상 주소를 실제 물리 주소로 옮기는 표
- Copy-on-Write(CoW): 복사를 미뤄 두고, 누군가 쓰기를 시도하는 순간 그 부분만 복사하는 기법
- RSS(Resident Set Size): 그 프로세스의 주소 공간 중 지금 물리 메모리에 올라와 있는 크기. 공유 페이지도 포함해서 센다
- 컨텍스트 스위칭(Context Switching): CPU가 실행하던 대상을 다른 대상으로 바꾸는 작업
- 좀비(Zombie): 종료했지만 부모가 아직 종료 상태를 거두어 가지 않은 프로세스
- 스레드(Thread): 한 프로세스 안에서 각자 실행 흐름을 갖는 단위. 스택은 따로, 나머지는 대부분 공유한다
프로그램은 파일이고 프로세스는 그 파일이 살아 움직이는 상태다
프로그램은 저장장치에 놓인 명령문의 집합이다. 하드디스크나 SSD의 공간만 차지하고 스스로 아무것도 하지 않는다. 이 파일이 메모리에 올라가 CPU를 할당받기 시작하면 프로세스라는 다른 이름으로 불린다. 프로세스는 메모리를 쓰고, 스케줄러가 배정하는 CPU를 쓰고, 필요하면 입출력을 요청한다. 같은 프로그램 파일 하나로 프로세스를 열 개 만들 수도 있다
메모리에 올라간 프로세스는 네 구역으로 나뉜다
높은 주소 ┌──────────────┐ │ 스택 │ 지역 변수, 함수 호출 정보 (아래로 자란다) ├──────────────┤ │ ↓ │ │ ↑ │ ├──────────────┤ │ 힙 │ malloc으로 잡는 공간 (위로 자란다) ├──────────────┤ │ 데이터 영역 │ 전역 변수, static 변수 ├──────────────┤ │ 코드 영역 │ 실행할 기계어 └──────────────┘ 낮은 주소
코드 영역과 데이터 영역은 실행 파일 안에 이미 들어 있는 내용을 그대로 올린 것이다. 스택과 힙은 실행 파일에 없고 프로세스를 만들 때 빈 상태로 할당된다. 이 구분은 뒤에서 fork가 무엇을 공유하고 무엇을 각자 갖는지를 볼 때 다시 필요하다
소스 파일이 실행 파일이 되기까지 네 단계를 거친다
C 코드가 실행 파일이 되는 과정은 한 번에 일어나지 않는다. 전처리, 컴파일, 어셈블, 링크 네 단계이고, gcc는 단계마다 멈출 수 있다. 다음 코드로 각 단계의 결과물을 직접 뽑아 봤다
#include <stdio.h>
#define MY_NUMBER 100
int main(void) {
int num1 = 50;
printf("%d\n", num1 + MY_NUMBER); // 합을 출력한다
return 0;
}
전처리기는 #으로 시작하는 지시문을 처리한다. #include가 가리키는 파일 내용을 그 자리에 붙이고, #define으로 정의한 매크로를 값으로 바꾸고, 주석을 지운다
$ gcc -E test.c -o test.i
$ wc -l test.i
822 test.i
$ tail -6 test.i
int main(void) {
int num1 = 50;
printf("%d\n", num1 + 100);
return 0;
}
9줄짜리 소스가 822줄이 됐다. 늘어난 813줄은 stdio.h가 끌고 들어온 선언들이다. 그리고 MY_NUMBER가 100으로 바뀌었고 주석은 사라졌다. 매크로는 컴파일러가 아니라 전처리기가 처리하는 단순 치환이라는 사실이 결과물에 그대로 드러난다
다음은 컴파일이다. C 코드를 어셈블리어로 바꾼다
$ gcc -S test.i -o test.s $ sed -n '/^main:/,/ret/p' test.s main: pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $50, -4(%rbp) movl -4(%rbp), %eax addl $100, %eax movl %eax, %esi leaq .LC0(%rip), %rax movq %rax, %rdi movl $0, %eax call printf@PLT movl $0, %eax leave ret
movl $50, -4(%rbp)가 지역 변수 num1을 스택에 넣는 명령이다. %rbp는 현재 함수의 스택 기준점이고 -4는 거기서 4바이트 아래라는 뜻이다. 지역 변수가 스택에 저장된다는 설명이 어셈블리 한 줄로 확인된다. 그 아래 addl $100, %eax에는 MY_NUMBER가 아니라 100이 박혀 있다
어셈블러는 어셈블리어를 기계어로 바꾸고, 링커는 여러 오브젝트 파일과 라이브러리를 묶어 실행 파일을 만든다
$ gcc -c test.s -o test.o
$ file test.o
test.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ gcc test.o -o test
$ file test
test: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
리눅스에서 실행 파일의 형식은 ELF이고 확장자는 없다. .exe는 윈도우의 PE 형식이 쓰는 확장자다. 두 file 출력의 차이도 볼 만하다. test.o는 relocatable이라 아직 어느 주소에 놓일지 정해지지 않았고, 링크를 마친 test는 pie executable이면서 dynamically linked, 즉 실행 시점에 ld-linux-x86-64.so.2가 라이브러리를 붙여 준다. 링크가 끝났는데도 아직 붙지 않은 것이 남아 있다는 뜻이다
각 단계의 파일 크기를 나란히 보면 무슨 일이 벌어졌는지가 한눈에 들어온다
| 파일 | 단계 | 크기 |
|---|---|---|
test.c | 소스 | 157 B |
test.i | 전처리 | 21,334 B |
test.s | 컴파일 | 762 B |
test.o | 어셈블 | 1,512 B |
test | 링크 | 15,960 B |
전처리에서 136배로 부풀었다가 컴파일에서 다시 줄어든다. 헤더가 끌고 온 것 대부분은 선언이라 실제 코드를 만들지 않기 때문이다. 링크에서 다시 열 배로 커지는 것은 실행에 필요한 ELF 헤더와 각종 섹션이 붙기 때문이다
이 실행 파일을 실행하면 운영체제가 ELF 안의 코드 영역과 데이터 영역을 프로세스의 같은 이름 영역에 올리고, 스택과 힙을 빈 상태로 할당하고, PCB를 만들고, 프로그램 카운터를 코드 영역의 시작 주소로 맞춘다. 여기서부터 프로세스다
운영체제는 프로세스마다 PCB를 만들어 관리한다
메모리에 프로그램을 하나만 올리던 시절에는 관리랄 것이 없었다. 여러 개를 동시에 올리기 시작하면서 용어가 갈라진다
| 용어 | 뜻 |
|---|---|
| 유니프로그래밍 | 메모리에 프로세스 하나만 올린다. I/O 중에는 CPU가 논다 |
| 멀티프로그래밍 | 메모리에 여러 프로세스를 올린다. 하나가 I/O를 기다리면 다른 것을 실행한다 |
| 멀티태스킹 | I/O와 무관하게 짧은 시간씩 번갈아 실행해 동시에 도는 것처럼 보이게 한다 |
| 멀티프로세싱 | CPU(또는 코어)가 여러 개인 환경에서 실제로 동시에 처리한다 |
앞의 세 개는 소프트웨어가 만드는 착시이고 마지막 하나만 물리적으로 동시다. CPU가 하나면 어떤 순간에도 실행 중인 프로세스는 하나뿐이다
여러 프로세스를 번갈아 실행하려면 멈춘 지점을 어딘가에 적어 둬야 한다. 그 자리가 PCB다. 프로세스 식별자, 현재 상태, 다음에 실행할 명령어의 주소를 담은 프로그램 카운터, 레지스터 값, 메모리 위치 정보, 스케줄링 정보가 들어간다. 리눅스에서 이 구조체의 이름은 task_struct이고, 프로세스가 종료되면 커널이 이 항목을 목록에서 뺀다
프로세스 상태는 보통 다섯 가지로 배운다
생성 ──▶ 준비 ◀──────── 실행 ──▶ 완료
▲ 스케줄러 │
│ │ I/O 요청
└──── 대기 ◀───┘
I/O 완료
생성은 PCB를 만들고 메모리 적재를 요청한 상태, 준비는 CPU를 기다리는 상태, 실행은 CPU를 받아 도는 상태, 대기는 입출력이 끝나기를 기다리는 상태, 완료는 종료된 상태다. 실행 상태인 프로세스의 수는 CPU 개수를 넘지 못한다
리눅스가 실제로 쓰는 상태 코드는 이보다 잘게 나뉜다. ps가 쓰는 코드는 다음과 같다(ps(1) man page)
| 코드 | 뜻 |
|---|---|
R | 실행 중이거나 실행 큐에서 대기 중 |
S | 인터럽트 가능한 대기 (이벤트를 기다린다) |
D | 인터럽트 불가능한 대기 (보통 I/O) |
T | 작업 제어 시그널로 정지됨 |
t | 디버거가 추적 중이라 정지됨 |
Z | 종료했지만 부모가 거두어 가지 않음 |
X | 죽음 (보일 일이 없다) |
다섯 상태의 “대기”가 S와 D 둘로 갈리는 것이 실무에서 중요하다. S는 시그널로 깨울 수 있어서 kill이 통하지만, D는 커널이 진행 중인 입출력을 중간에 끊을 수 없어 kill -9조차 통하지 않는다. NFS가 끊긴 마운트에 접근한 프로세스가 안 죽는 상황이 여기다. 지금 이 머신의 상태 분포는 이렇다
$ ps ax -o stat= | cut -c1 | sort | uniq -c | sort -rn
36 I
33 S
1 R
I는 놀고 있는 커널 스레드이고, S는 이벤트를 기다리는 프로세스다. 실행 중인 것은 이 명령을 실행한 ps 자신 하나뿐이다. 수십 개가 “동시에 실행 중”으로 보이지만 실제로 CPU를 쥔 것은 거의 항상 한둘이다
컨텍스트 스위칭은 자발적인 것과 강제된 것으로 나뉜다
실행 중인 프로세스를 멈추고 다른 프로세스로 갈아 끼우는 작업이 컨텍스트 스위칭이다. 커널은 현재 CPU의 레지스터 값과 프로그램 카운터를 지금 프로세스의 PCB에 저장하고, 다음 프로세스의 PCB에서 같은 값을 꺼내 CPU에 채워 넣는다. 프로그램 카운터가 저장돼 있으므로 다음 프로세스는 자기가 멈췄던 명령어부터 이어서 실행한다
전환이 일어나는 이유는 크게 둘이다. 하나는 프로세스가 입출력을 요청하면서 스스로 CPU를 내놓는 경우, 다른 하나는 배정된 시간을 다 써서 스케줄러가 강제로 빼앗는 경우다. 리눅스는 이 둘을 따로 세어 /proc/PID/status에 적어 둔다(proc_pid_status(5))
두 종류를 갈라 보려고 대조군을 만들었다. 한쪽은 CPU 연산만 하는 프로세스 두 개를 코어 하나에 묶어 서로 경쟁시켰고, 다른 쪽은 계속 입출력을 기다리게 했다
$ taskset -c 0 ./swtch2 cpu CPU-bound 2개가 코어 1개 공유 voluntary=1 nonvoluntary=1034 $ taskset -c 0 ./swtch2 io I/O 대기를 반복하는 프로세스 voluntary=39868 nonvoluntary=5
voluntary가 스스로 내놓은 횟수, nonvoluntary가 빼앗긴 횟수다. CPU만 쓰는 쪽은 스스로 내놓은 적이 사실상 없고 1,034번 빼앗겼다. 입출력을 기다리는 쪽은 반대로 39,868번 스스로 내놓고 빼앗긴 것은 다섯 번뿐이다. 두 숫자의 비율만 봐도 그 프로세스가 CPU를 쓰는 일을 하는지 기다리는 일을 하는지 알 수 있다. 다시 실행해도 자릿수는 그대로였다
이 구분은 다음 편의 CPU 스케줄링에서 다시 쓰인다. 스케줄러가 어떤 프로세스에 얼마만큼의 시간을 줄지 판단할 때 보는 것이 정확히 이 신호다
fork는 주소 공간을 복사하지 않고 공유부터 시킨다
새 프로세스는 무에서 만들어지지 않는다. 기존 프로세스를 복제해서 만든다. 터미널에서 vim을 실행하면 셸(Bash) 프로세스가 fork로 자기를 복제하고, 복제된 자식이 exec로 자기 자신을 vim으로 갈아 끼운다
pid_t pid = fork();
if (pid == 0) { /* 자식: fork는 0을 돌려준다 */
execl("/usr/bin/vim", "vim", NULL);
exit(-1); /* execl이 성공하면 여기 오지 않는다 */
} else { /* 부모: 자식의 PID를 돌려받는다 */
wait(NULL); /* 자식이 끝날 때까지 기다린다 */
printf("자식 종료\n");
}
fork는 성공하면 부모에게 자식의 PID를, 자식에게 0을 돌려주고, 실패하면 부모에게 -1을 돌려준다(fork(2)). 같은 코드가 두 프로세스에서 실행되는데 반환값이 다르므로 if 하나로 갈래를 나눌 수 있다
exec 계열 함수의 이름은 오해를 부르기 쉽다. execve(2) 문서는 이 점을 직접 짚는다
execve() ... is sometimes described as "executing a new process". This is a highly misleading description: there is no new process; many attributes of the calling process remain unchanged (in particular, its PID).
출처는 execve(2)다. exec는 프로세스를 만들지 않는다. 지금 프로세스의 코드·데이터·스택·힙을 새 프로그램의 것으로 덮어쓸 뿐이고 PID는 그대로다. 프로세스를 만드는 것은 fork, 그 안에 다른 프로그램을 채우는 것은 exec로 역할이 갈린다
그럼 fork가 복제할 때 부모의 512MiB를 실제로 복사할까. 부모가 512MiB를 전부 만져 놓은 상태에서 fork하고, 자식이 아무것도 건드리지 않은 시점과 512MiB 전체에 쓴 시점을 각각 관측했다
fork 전 MemAvailable : 6920 MiB
fork 직후 MemAvailable : 6920 MiB (자식은 아직 아무것도 쓰지 않음)
자식 /proc/812/smaps_rollup
Rss: 524664 kB
Shared_Dirty: 524364 kB
Private_Dirty: 44 kB
자식이 512 MiB 전체에 쓴 뒤
MemAvailable : 6415 MiB
자식 /proc/812/smaps_rollup
Rss: 524732 kB
Shared_Dirty: 76 kB
Private_Dirty: 524336 kB
smaps_rollup은 그 프로세스의 모든 매핑에 대한 합계다(kernel proc 문서). 위아래 두 덩어리에서 봐야 할 것은 세 줄이다
fork 직후 자식의 Rss는 524,664kB, 즉 512MiB로 잡힌다. 그런데 시스템의 MemAvailable은 6,920MiB에서 1MiB도 줄지 않았다. 자식이 512MiB를 “쓰고 있다”고 보고되는데 물리 메모리는 하나도 더 쓰이지 않았다. 답은 그 아래 줄에 있다. 524,364kB가 Shared_Dirty, 즉 부모와 공유하는 페이지이고 자식 혼자 쓰는 Private_Dirty는 44kB뿐이다. RSS가 공유 페이지를 포함해서 센다는 점을 모르면 이 숫자를 잘못 읽게 된다
자식이 512MiB 전체에 쓴 뒤에는 두 값이 뒤집힌다. Shared_Dirty가 76kB로 떨어지고 Private_Dirty가 524,336kB가 됐다. 쓰기가 일어난 페이지마다 커널이 복사본을 만들어 자식 전용으로 돌린 것이다. 그리고 그제서야 MemAvailable이 505MiB 줄었다. 복사 비용은 fork 시점이 아니라 쓰기 시점에 청구된다
그런데 fork 비용은 부모의 메모리 크기를 따라간다
메모리를 복사하지 않는다면 fork 비용은 부모가 얼마를 쥐고 있든 같아야 한다. 나는 그렇게 생각하고 확인차 재봤고, 결과는 반대였다. 부모의 힙 크기만 바꿔 가며 fork 후 즉시 종료를 1,000번 반복하고, 같은 횟수의 pthread_create와 나란히 놓았다
힙 0 MiB | fork+exit 1000회 170.9 ms ( 170.9 us/회) | pthread 1000회 52.3 ms (52.3 us/회) 힙 64 MiB | fork+exit 1000회 2048.3 ms (2048.3 us/회) | pthread 1000회 49.7 ms (49.7 us/회) 힙 512 MiB | fork+exit 1000회 11477.1 ms (11477.1 us/회) | pthread 1000회 52.7 ms (52.7 us/회)
fork 한 번이 171마이크로초에서 11.5밀리초로, 67배 늘었다. 반면 pthread_create는 부모가 512MiB를 쥐고 있든 말든 50마이크로초 언저리에서 움직이지 않는다. 힙 0MiB에서 4배였던 격차가 512MiB에서는 218배가 된다
이유는 fork(2) 문서에 한 문장으로 적혀 있다
Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child.
복사하지 않는 것은 데이터이고, 복사하는 것은 페이지 테이블이다. 512MiB를 4KiB 페이지로 나누면 131,072개이고 그만큼의 항목을 자식용으로 새로 만들어야 한다. 데이터 자체는 안 옮겨도 그 데이터를 어디서 찾는지 적어 둔 표는 옮겨야 한다. 그 표의 크기가 부모의 주소 공간 크기에 비례하니 fork 비용도 따라 커진다
스레드는 이 표를 복사하지 않는다. 같은 주소 공간을 그대로 쓰기 때문이다. 위 표의 오른쪽 열이 평평한 이유가 그것이고, 스레드가 등장한 이유이기도 하다. “프로세스를 복사하는 편이 새로 만드는 것보다 싸다”는 말과 “그래도 스레드보다는 비싸다”는 말은 둘 다 참이다
측정은 2코어 가상 머신에서 한 것이고 절대값은 하드웨어마다 다르다. 두 번 실행해 같은 자릿수가 나오는 것까지 확인했다. 다만 pthread_create를 만들자마자 pthread_join으로 회수하는 구조라, 스레드를 많이 띄워 두고 오래 쓰는 상황의 비용은 이 측정에 들어 있지 않다
좀비 프로세스는 메모리를 차지하지 않는다
자식이 종료하면 커널은 종료 상태를 보관해 두고 부모가 wait로 가져가기를 기다린다. 부모가 가져가지 않는 동안 그 자식은 좀비다. 이 상태의 프로세스가 무엇을 붙들고 있는지 직접 봤다. 자식에게 256MiB를 만지게 한 뒤, 살아 있을 때와 종료 후 부모가 wait하기 전, 그리고 wait한 뒤를 각각 찍었다
== 자식이 256 MiB를 잡고 살아 있을 때 == PID PPID STAT RSS VSZ COMMAND 840 839 S 262836 264848 zombie2 == 자식 종료 후, 부모가 wait() 하기 전 == PID PPID STAT RSS VSZ COMMAND 840 839 Z 0 0 zombie2 == 부모가 wait() 한 뒤 (종료 코드 7) == PID PPID STAT RSS VSZ COMMAND (ps가 아무 행도 내지 않으면 프로세스가 사라진 것이다)
살아 있을 때 262,836kB였던 RSS가 좀비가 되는 순간 0이 된다. 가상 메모리 크기인 VSZ도 0이다. /proc/PID/status를 보면 더 분명하다
$ grep -E '^(Name|State|VmSize|VmRSS|Threads):' /proc/<좀비의 PID>/status Name: z3 State: Z (zombie) Threads: 1 $ grep -E '^(Name|State|VmSize|VmRSS|Threads):' /proc/self/status Name: grep State: R (running) VmSize: 3680 kB VmRSS: 1972 kB Threads: 1
살아 있는 프로세스에는 있는 VmSize와 VmRSS 행이 좀비에는 아예 없다. 필드 값이 0인 것이 아니라 필드 자체가 사라진다. 주소 공간이 이미 회수됐기 때문이다. 이 두 행의 부재는 내가 관측한 것이고, man 페이지에 좀비의 예외로 문서화된 내용을 찾지는 못했다
좀비가 붙들고 있는 것은 메모리가 아니라 프로세스 테이블의 자리다. wait(2) 문서는 이렇게 적는다
A child that terminates, but has not been waited for becomes a "zombie". The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) ... As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes.
출처는 wait(2)다. 좀비가 쌓여서 나는 고장은 “느려짐”이 아니라 “새 프로세스를 못 만듦”이다. 커널 프로세스 테이블이 먼저 차기 때문이고, 그 상한은 PID 상한으로 드러난다. 이 머신에서는 32,768이다
$ cat /proc/sys/kernel/pid_max 32768
3만 개가 넘는 좀비를 쌓아 놓으면 fork가 실패하기 시작한다. 그 전까지는 좀비 하나가 커널 메모리에서 차지하는 몫이 수백 바이트 수준이라 체감이 없다. 컴퓨터를 오래 켜 두면 느려지는 것은 실행 중인 프로그램이 늘고 캐시와 메모리 압박이 쌓여서이지 좀비 때문이 아니다
부모가 자식보다 먼저 죽어도 좀비는 남지 않는다. 같은 문서가 이어서 적기를, 부모를 잃은 좀비는 init(또는 가장 가까운 subreaper)이 입양하고 init이 자동으로 wait을 수행해 정리한다. 그래서 실무에서 좀비가 쌓이는 경우는 부모가 살아 있으면서 wait을 부르지 않는 프로그램일 때다
여기서 프로세스 계보를 한 번 정리해 둘 필요가 있다. 모든 사용자 프로세스는 PID 1의 자손이다. PID 0은 사용자 공간에 존재하지 않는다
$ ps -p 0 -o pid,comm
error: process ID out of range
$ ps -eo pid,ppid,stat,comm --sort=pid | head -3
PID PPID STAT COMMAND
1 0 SLl process_api
2 0 S kthreadd
ps는 PID 0을 범위 밖이라고 거절한다. PID 0은 커널의 유휴 태스크라 프로세스 목록에 나오지 않고, PPID가 0으로 찍힌 것은 부모가 커널이라는 표시다. 사용자 공간에서 가장 먼저 뜨는 것은 PID 1이고(일반적인 리눅스에서는 systemd나 init, 이 컨테이너에서는 process_api), PID 2인 kthreadd는 커널 스레드들의 부모다
스레드는 스택만 빼고 거의 전부 공유한다
한 프로세스 안에서 실행 흐름을 여러 개 두는 것이 스레드다. 무엇을 공유하고 무엇을 따로 갖는지가 전부이므로 그것부터 확인했다. 스레드 세 개를 만들어 PID, 스레드 ID, 힙 주소, 스택 주소를 찍었다
메인 PID=877 스레드 2 PID=877 TID=880 &shared[0]=0x5648ee9422a0 &local=0x7ffa763fce9c 스레드 0 PID=877 TID=878 &shared[0]=0x5648ee9422a0 &local=0x7ffa773fee9c 스레드 1 PID=877 TID=879 &shared[0]=0x5648ee9422a0 &local=0x7ffa76bfde9c 스레드 0 가 읽은 shared 값 = 42 스레드 1 가 읽은 shared 값 = 42 스레드 2 가 읽은 shared 값 = 42 /proc/877/task 목록: 877 878 879 880 PID TID STAT COMMAND 877 877 Sl thr 877 878 Sl thr 877 879 Sl thr 877 880 Sl thr
세 스레드의 PID는 877로 같고 TID는 878, 879, 880으로 다르다. 운영체제가 스케줄링하는 단위는 PID가 아니라 이 TID다. 힙에 잡은 변수의 주소 0x5648ee9422a0은 셋 다 동일하고, 0번 스레드가 거기에 42를 쓰자 나머지 둘이 그대로 읽었다. 반면 지역 변수 주소는 0x7ffa763fce9c, 0x7ffa773fee9c, 0x7ffa76bfde9c로 8MiB씩 떨어져 있다. 스레드마다 자기 스택을 따로 받았다는 뜻이다
/proc/877/task 아래에 네 항목이 있는 것도 같은 이야기다. 리눅스는 스레드를 별도의 자료구조로 두지 않고 프로세스와 같은 태스크로 다루며, 한 프로세스의 스레드들이 이 디렉터리에 모인다
POSIX 기준으로 공유되는 것과 스레드마다 따로인 것은 이렇게 갈린다(pthreads(7))
| 공유한다 | 스레드마다 따로다 |
|---|---|
| 주소 공간 (코드·데이터·힙) | 스레드 ID |
| PID, PPID, 프로세스 그룹 | 스택 |
| 열린 파일 디스크립터 | 레지스터와 프로그램 카운터 |
| 시그널 처리 방식, umask | errno |
| 현재 디렉터리, 리소스 한계 | 시그널 마스크, CPU 친화도 |
왼쪽 열이 스레드의 장점과 단점을 동시에 만든다. 주소 공간을 공유하므로 스레드끼리 데이터를 주고받는 데 별도 통신이 필요 없고 생성 비용도 낮다. 같은 이유로 한 스레드가 잘못된 주소에 접근해 프로세스를 죽이면 그 안의 스레드가 전부 함께 죽고, 여러 스레드가 같은 데이터를 동시에 건드리면 결과가 실행 순서에 따라 달라진다. 뒤쪽 문제가 동기화이고 4편에서 다룬다
errno가 오른쪽 열에 있는 것은 눈여겨볼 만하다. 전역 변수처럼 생겼지만 스레드마다 따로다. 그렇지 않으면 A 스레드가 낸 오류 번호를 B 스레드가 읽는 사고가 난다
| 기준 | 프로세스 | 스레드 |
|---|---|---|
| 안정성 | 하나가 죽어도 나머지는 산다 | 하나가 프로세스를 죽이면 전부 죽는다 |
| 생성 비용 | 주소 공간에 비례해 커진다 | 거의 일정하다 |
| 데이터 공유 | IPC가 필요하다 | 그냥 같은 변수를 쓴다 |
| 동시 접근 사고 | 잘 안 난다 | 나기 쉽다 |
브라우저는 탭 하나에 프로세스 하나를 쓰지 않는다
프로세스와 스레드의 트레이드오프를 실제 제품이 어떻게 푸는지 보기 좋은 예가 브라우저다. 다만 “크롬은 탭당 프로세스 하나, 파이어폭스는 프로세스 몇 개에 나머지는 스레드”라는 도식은 두 브라우저 모두에 맞지 않는다
크로미움의 데스크톱 기본값은 사이트 단위 격리(site-per-process)다. 렌더러 프로세스 하나가 한 사이트의 문서만 담당하도록 잠근다. 탭과 프로세스가 1:1이 아닌 방향이 두 가지다. 한 탭 안에 다른 사이트의 iframe이 있으면 그 iframe은 별도 프로세스로 나가므로 탭 하나가 프로세스 여러 개를 쓴다. 반대로 프로세스 수에는 상한이 있어서 상한을 넘으면 여러 탭이 프로세스를 나눠 쓴다. 메모리가 2GB 미만인 안드로이드 기기에서는 사이트 격리 자체를 끈다(Process Model and Site Isolation, Chromium Docs)
파이어폭스도 같은 방향으로 왔다. 사이트 격리 프로젝트인 Fission이 데스크톱 파이어폭스 95에 들어갔고(Project Fission, MozillaWiki), 지금은 사이트별로 webIsolated= 유형의 콘텐츠 프로세스를 따로 쓴다. 파이어폭스 소스 문서는 탭과 프로세스가 1:1로 대응하지 않으며 여러 탭이 콘텐츠 프로세스 하나를 공유할 수 있다고 적는다(Process Model, Firefox Source Docs). 탭이 콘텐츠 프로세스 안의 스레드가 되는 구조는 아니다
두 브라우저가 같은 자리에 도달한 이유는 성능이 아니라 보안이다. Spectre 이후로 같은 프로세스 안에 있는 다른 사이트의 데이터는 격리를 신뢰할 수 없게 됐고, 그래서 경계를 OS 프로세스로 올렸다. 메모리를 더 쓰더라도 프로세스로 가르는 쪽을 택한 것이다. 안정성을 위해 프로세스를 쓴다는 교과서 문장이 실제로는 보안 요구로 다시 쓰였다
숫자는 직접 재지 못했다. 이 글의 측정에 쓴 환경에는 GUI 브라우저가 없어서 탭을 열어 가며 프로세스 수를 세어 볼 수 없었다. 각자의 컴퓨터에서는 확인할 수 있다. 파이어폭스는 주소창에 about:processes를 치면 프로세스별 목록이 나오고, 크롬은 창 메뉴의 작업 관리자에서 같은 것을 볼 수 있다. 탭 열 개를 열고 프로세스가 열 개가 되는지 세어 보면 된다
마무리
프로세스는 비싸다. 비싼 이유는 데이터를 복사해서가 아니라 주소 공간을 따로 갖기 때문이고, 그 대가로 서로를 망가뜨리지 못한다는 성질을 얻는다. fork의 Copy-on-Write는 그 비용에서 데이터 복사분을 걷어낸 것이고, 스레드는 주소 공간을 공유해 남은 비용까지 걷어낸 대신 격리를 포기한 것이다
무엇을 공유하고 무엇을 따로 두느냐가 비용과 안전을 동시에 정한다. 프로세스와 스레드 중 무엇을 쓸지는 이 한 축 위에서 결정된다
다음 편 예고
다음 편은 CPU 스케줄링이다. FIFO, SJF, 라운드 로빈, MLFQ가 어떤 문제를 하나씩 풀면서 등장했는지 따라가고, 오늘날 리눅스가 실제로 쓰는 스케줄러가 무엇인지까지 본다
출처와 범위
이 시리즈는 인프런의 그림으로 쉽게 배우는 운영체제(감자) 강의를 따라 정리한 것이고, 강의 전체를 일곱 편으로 나눈 두 번째 편이다. 개념 구성과 설명 순서의 출처는 강의이며, 값과 동작을 말하는 문장은 본문에 건 man 페이지와 벤더 문서로 확인해 지금 기준으로 맞췄다. 코드 블록에 실은 gcc, ps, /proc, smaps_rollup 출력과 fork·pthread_create 측정값은 2코어 x86-64 리눅스 가상 머신에서 직접 실행한 결과 그대로이고, 물리 장비에서 다시 재면 절대값은 달라진다. 측정에 쓴 C 프로그램은 본문에 인용한 부분 외에는 싣지 않았고 공개 저장소도 없다
참고 자료
- fork(2) — Linux manual page
- execve(2) — Linux manual page
- wait(2) — Linux manual page
- pthreads(7) — Linux manual page
- ps(1) — Linux manual page
- proc_pid_status(5) — Linux manual page
- The /proc Filesystem — The Linux Kernel documentation
- Process Model and Site Isolation — Chromium Docs
- Process Model — Firefox Source Docs
- Project Fission — MozillaWiki
- Introducing Site Isolation in Firefox — Mozilla Security Blog