개요
구술 대비 복습을 계속하고 있다. 프로세스와 스레드는 이론 정리 - < 프로세스, 스레드, 데드락 >에서 정의를 적었고, 이론 정리 - < 데이터 레이스, 뮤텍스, 아토믹 >에서 Stack과 Heap의 소속을 거꾸로 잡고 있던 걸 고쳤다.
그런데 09-07 편은 무엇이 공유되는가까지였다. 표는 세웠는데, 그 공유와 분리를 실제로 만들어내는 게 무엇인지는 안 적어뒀다. 그래서 이번엔 같은 주제를 한 층 아래로 끌고 내려가봤다. 시작 질문은 이거다.
프로세스와 스레드는 각각 무엇의 단위인가
여기서 출발해 페이지 테이블, TLB, 권한 비트, Copy-on-Write까지 갔다. 그리고 TLB가 이번이 두 번째다. 같은 O(N)인데 왜 속도가 다른가 2번에서 TLB를 캐시랑 섞어 쓰고 있던 걸 한 번 고쳤는데, 이번엔 다른 방향으로 또 어긋났다. 같은 단어에서 두 번 걸렸다..
( 레지스터 이름은 x86-64 기준으로 적는다 )
1. 무엇의 단위인가부터 못박는다
정의를 “프로세스는 실행 중인 프로그램, 스레드는 그 안의 실행 흐름”으로 외워두면 맞기는 한데, 뒤에 오는 질문에 답할 때 근거가 안 된다. 기준을 단위로 바꿔 적었다.
| 무엇의 단위인가 | 커널이 들고 있는 기록 | |
|---|---|---|
| 프로세스 | 자원 할당 (resource ownership) | PCB |
| 스레드 | 실행·스케줄링 (execution) | TCB |
한 줄로 줄이면 이렇게 된다.
스레드는 주소 공간을 공유하고, 실행 흐름을 나눈다.
이 문장 하나로 뒤에 나올 것들이 거의 다 갈린다. 주소 공간을 공유하니까 Heap과 전역 데이터를 같이 보고, 실행 흐름이 나뉘니까 스택은 따로 있어야 한다. 자원이 프로세스 소유라는 건 fd 테이블도 스레드들이 같이 쓴다는 뜻이 되는데, 이건 다음 편에서 본다.
2. 메모리 영역 다섯 칸
09-07 편에서는 Stack과 Heap 둘만 봤다. 다섯 칸으로 늘려서 다시 세웠다.
| 영역 | 담기는 것 | 프로세스 간 | 같은 프로세스의 스레드 간 |
|---|---|---|---|
| Code(Text) | 기계어 명령 | 분리 | 공유 |
| Data | 초기화된 전역·static | 분리 | 공유 |
| BSS | 미초기화 전역·static | 분리 | 공유 |
| Heap | 동적 할당 | 분리 | 공유 |
| Stack | 지역 변수, 매개변수, 복귀 주소 | 분리 | 스레드마다 하나 |
오른쪽 열에서 공유라고 적힌 칸이 그대로 Data Race가 날 수 있는 칸이다. 09-07 편에서 Data Race와 Race Condition을 갈라뒀던 게 여기에 얹힌다. 표를 외우는 게 목적이 아니라, 공유 칸을 짚으면서 “여기는 동기화 없이 쓰면 안 된다”가 같이 나와야 쓸모가 있다.
Code 영역을 물리 메모리에 한 벌만 두면 충분하다는 것도 여기서 나온다. 같은 실행 파일을 두 번 띄워도 명령어는 바뀌지 않으니, 양쪽 페이지 테이블이 같은 물리 페이지를 가리키게 두면 된다.
3. 스택이 스레드마다 있는 이유
“실행 흐름이 나뉘니까 스택도 나뉜다”는 건 결론이고, 왜 나뉘어야 하는지는 복귀 주소 하나로 설명된다.
함수를 호출하면 스택에 쌓이는 건 세 가지다. 지역 변수, 매개변수, 그리고 복귀 주소(return address) — 이 함수가 끝나면 어디로 돌아갈지다.
스택 하나를 두 스레드가 공유한다고 가정해본다.
A가 foo()의 복귀 주소를 쌓고, 그 위에 B가 bar()의 복귀 주소를 쌓는다. 여기서 A의 foo()가 먼저 return하면, A는 스택 맨 위의 값을 복귀 주소로 꺼내 그 주소로 점프한다. 그런데 맨 위에 있는 건 B의 복귀 주소다. A의 실행 흐름이 B의 코드로 튄다.
스택은 LIFO라 실행 흐름 하나만 감당할 수 있다. 그래서 흐름 수만큼 스택이 필요하다. “스레드는 실행의 단위”와 “스택은 스레드마다 하나”는 다른 두 사실이 아니라 같은 문장이다.
스레드 간에 스택 주소를 넘기면 더 위험하다
같은 주소 공간이니까 A의 지역 변수 주소를 B가 읽을 수는 있다. 문제는 A의 함수가 return한 뒤부터다. RAII와 스마트 포인터 2번에서 정리한 dangling pointer가 그대로 나온다.
단일 스레드보다 악질인 이유가 있다. 단일 스레드에서는 반환 직후의 다음 호출이 그 영역을 덮어써서 비교적 빨리 드러난다. 그런데 멀티스레드에서는 A가 언제 return하는지 B가 알 수 없다. 어떤 실행에서는 값이 멀쩡히 읽히고, 어떤 실행에서는 쓰레기가 읽힌다. 재현되지 않는 버그가 된다.
대응은 두 가지다. 값으로 복사해서 넘기거나, 힙에 올리고 소유권을 명확히 한다(shared_ptr). 여기서도 결국 소유권 이야기로 돌아온다.
4. 페이지 테이블은 프로세스마다 하나다
프로세스마다 주소 공간이 분리된다는 건 알고 있었는데, 그 분리를 만드는 변환 구조는 시스템 전체에 하나일 거라고 생각하고 있었다.
바로 무너졌다. 두 프로세스가 각각 0x400000이라는 가상 주소를 쓴다고 해보자. 표가 시스템에 한 벌이면 0x400000에 대응하는 줄도 한 줄뿐이고, 그러면 둘은 같은 물리 주소를 보게 된다. 애초에 분리가 성립하지 않는다.
정리하면 셋이 다른 물건이다.
| 이름 | 무엇인가 | 개수 |
|---|---|---|
| 페이지 테이블 | 가상 주소 → 물리 주소 변환표 | 프로세스마다 하나 |
| MMU | 변환을 실제로 수행하는 하드웨어 | CPU에 있다 |
| TLB | 변환 결과를 담는 하드웨어 캐시 | CPU에 있다 |
그럼 프로세스를 바꿀 때 무슨 일이 일어나느냐 — CPU에는 지금 쓸 페이지 테이블의 시작 주소를 담는 레지스터가 있다. x86-64에서는 CR3다. 프로세스 전환은 이 레지스터를 바꾸는 일이다.
그리고 여기서 앞 편들에서 말로만 넘어갔던 게 근거를 얻는다. 스레드 전환에는 CR3 교체가 없다. 같은 주소 공간이니 바꿀 이유가 없다. “스레드 전환이 프로세스 전환보다 싸다”는 문장을 이제 무엇을 안 하기 때문인지로 답할 수 있다.
5. TLB와 TCB는 층이 다르다
TLB miss가 나면 어디로 가느냐는 질문에 이렇게 답했다.
TLB에서 못 찾으면 TCB로 올라가고, 거기서도 안 되면 PCB로 올라간다.
TLB, TCB, PCB가 전부 세 글자 약어라서 계층처럼 보였던 것 같다. 그런데 TCB와 PCB는 커널이 스레드와 프로세스의 상태를 적어두는 구조체다. 주소 변환하고는 관계가 없다. 변환은 하드웨어가 하는 일이고, 커널 자료구조를 거치지 않는다.
실제 계층은 TLB → 페이지 테이블(RAM)이다. TLB에서 찾으면 수 사이클로 끝난다. 못 찾으면 RAM에 있는 페이지 테이블을 걸어 내려가는데, 4단계 구성이면 최악의 경우 RAM을 네 번 왕복한다. 이걸 page walk라고 한다.
같은 O(N)인데 왜 속도가 다른가 2번에서는 TLB를 데이터 캐시와 섞어 쓰고 있었고, 이번엔 커널 자료구조와 섞어 썼다. 방향은 다른데 원인은 같다. 그때 “TLB는 주소 번역표의 캐시”라고 고쳐 적었는데, 그 표가 어디에 있는지를 안 적어뒀다. 표가 RAM에 있다는 걸 적어뒀으면 miss 다음 행선지를 커널 구조체로 잡을 일이 없었다.
한 줄로 남겨둔다.
데이터 캐시 미스는 값이 늦게 도착하는 것, TLB 미스는 길을 찾느라 늦는 것.
6. 컨텍스트 스위치는 정확히 무엇 때문에 비싼가
컨텍스트 스위치 비용은 임계 영역과 스레드 풀 때부터 계속 적어왔는데, 늘 “레지스터를 저장하고 복원하니까”에서 멈춰 있었다. 레지스터 몇 개 옮기는 건 실은 싸다. 프로세스 전환에서 비싼 건 다른 데 있다.
- CR3를 교체한다.
- 그런데 TLB에는 이전 프로세스의 변환 결과가 남아 있다. MMU는 페이지 테이블보다 TLB를 먼저 보니까, 그대로 두면 새 프로세스의
0x400000이 옛 프로세스의 물리 주소로 변환된다. - 그래서 TLB를 flush한다.
- 이후 발생하는 TLB miss마다 RAM의 페이지 테이블을 최대 네 번 왕복한다.
비용의 본체는 저장과 복원이 아니라 전환 이후에 따라오는 미스의 행렬이다. 스레드 전환이 싼 이유도 같은 자리에서 나온다. CR3를 바꾸지 않으니 flush할 이유가 없다.
완화 장치도 있다. 현대 CPU는 TLB 항목에 프로세스 식별자 태그를 붙여 전체 flush를 피한다(x86의 PCID, ARM의 ASID). 다만 TLB 용량은 유한하다. 태그로 구분해두더라도 서로의 항목을 밀어내므로 비용이 사라지지는 않는다.
여기서 수치를 적고 싶었는데, flush 비용과 PCID 적용 여부는 CPU 세대와 OS 설정에 따라 다르고 이번에 측정하지 않았다. 구조까지만 적어둔다.
7. code와 data를 가르는 건 커널/유저가 아니다
메모리 영역의 권한을 물었을 때 이렇게 답했다.
code는 커널 영역이고 data는 유저 영역이다.
커널과 유저의 경계는 누구의 코드가 실행되느냐로 갈린다. 내 프로그램의 code와 data는 둘 다 유저 영역이다. 커널 영역에 있는 건 커널의 code와 data다. 이 경계는 운영체제(OS) 편에 이중 모드로 정리해뒀던 그 이야기인데, 영역 구분과 겹쳐 놓고 있었다.
그럼 code와 data는 뭘로 갈리느냐 — 페이지 권한 비트다.
| 영역 | 권한 |
|---|---|
| Code 페이지 | R / X |
| Data 페이지 | R / W |
Data에 X를 안 주는 것도 처음엔 R/W/X라고 답했다가 고친 자리다. Data에 실행 권한이 있으면 어떻게 되는가 — 거기에 기계어를 써넣고 실행 흐름을 그쪽으로 돌리면 된다. 코드 인젝션이 그대로 성립한다.
그래서 쓰기와 실행을 같이 주지 않는다는 원칙이 있다. W^X(Write XOR Execute) — 쓸 수 있으면 실행할 수 없고, 실행할 수 있으면 쓸 수 없다. 하드웨어 지원이 NX bit이고, 인텔은 같은 것을 XD bit라고 부른다.
8. Copy-on-Write — 쓰기를 감지하려면 쓰기 권한을 빼앗는다
fork()를 하면 자식이 부모의 주소 공간을 갖는다. 그렇다고 힙 전체를 즉시 복사하지는 않는다. 안 쓸 수도 있는 페이지까지 다 복사하면 낭비다. 그래서 실제로 쓰려고 할 때 복사한다 — Copy-on-Write.
여기서 쓰려고 하는 순간을 어떻게 아느냐가 문제다. 처음엔 이렇게 답했다.
W 권한이 사용될 때 커널이 복사한다.
그런데 W 권한이 있으면 CPU는 커널을 거치지 않고 그냥 쓴다. 커널이 끼어들 자리가 없다. 감지할 타이밍 자체를 잡을 수 없다.
실제 순서는 정반대다.
- 부모와 자식 양쪽 페이지 테이블에서 해당 페이지를 읽기 전용으로 표시하고, 같은 물리 페이지를 가리키게 둔다.
- 누군가 쓰려고 하면 권한 위반이니 page fault가 발생하고, 제어가 커널로 넘어간다.
- 커널이 물리 페이지를 복사하고, 쓴 쪽의 페이지 테이블만 새 물리 주소와 W 권한으로 갱신한다.
- 실패했던 쓰기 명령을 재실행한다.
프로그램 입장에서는 아무 일도 없었던 것처럼 보인다. 쓰기 한 줄이 그냥 성공한 것으로 끝난다.
권한을 일부러 낮춰서 트랩을 만든 것이다. 쓰기는 원래 커널이 관찰할 수 없는 사건인데, 권한을 빼앗아 page fault로 바꿔놓으면 관찰 가능한 사건이 된다.
관찰할 수 없는 사건은, 권한을 낮춰 트랩으로 바꾸면 관찰할 수 있다.
같은 구조를 쓰는 데가 더 있다. mmap으로 파일을 매핑해두고 처음 접근할 때 읽어오는 것, 스왑에서 되돌리는 것, 디버거의 watchpoint가 전부 이 모양이다. 셋 다 “일어나면 알아야 하는 사건”을 권한 위반으로 바꿔놓는다.
정리
원래 갖고 있던 것
- Code 영역은 물리 메모리에 한 벌이면 충분하다 — 같은 실행 파일을 두 번 띄워도 명령어는 안 바뀐다
- 스택을 공유하면 안 되는 이유의 방향 — 실행 흐름이 나뉘니까 스택도 나뉜다. 근거가 복귀 주소라는 건 이번에 채웠다
- 스레드 전환에는 CR3를 바꿀 필요가 없다
- 스레드 간에 스택 주소를 넘기면 dangling pointer가 된다
틀리게 잡고 있던 것
- 페이지 테이블은 프로세스마다 하나다. 시스템에 한 벌이면 두 프로세스의 같은 가상 주소를 가를 수 없다
- 내 프로그램의 code와 data는 둘 다 유저 영역이다. 둘을 가르는 건 권한 비트다
- Data 권한은 R/W다. X를 주면 코드 인젝션이 성립한다
- TLB miss의 다음 행선지는 RAM의 페이지 테이블이다. TCB와 PCB는 커널 자료구조이고 다른 층에 있다
- COW는 W 권한을 일부러 빼앗아 page fault를 유도한다. 권한을 그대로 두면 감지할 타이밍이 없다
이번에 새로 얹은 것
- 프로세스는 자원 할당의 단위, 스레드는 실행의 단위. 표를 외우는 대신 여기서 유도한다
- 컨텍스트 스위치가 비싼 본체는 TLB flush와 그 뒤의 page walk다. 레지스터 저장과 복원이 아니다
- W^X와 NX bit — 쓰기와 실행을 같이 주지 않는다
-
관찰 불가능한 사건은 권한을 낮춰 트랩으로 바꾼다 — COW,
mmap지연 로딩, watchpoint가 같은 구조
TLB는 두 번 걸렸다. 09-10 편에서 “TLB는 주소 번역표의 캐시”까지 고쳐 적었는데, 그 표가 RAM에 있다는 한 줄을 안 적어뒀다. 고친 문장 자체는 맞았는데, 그 문장에서 다음 질문으로 넘어갈 발판이 없었다. 정의만 고치고 끝내면 각도를 바꿔 물었을 때 또 무너진다.
다음 편은 위쪽 층이다. 여기서 정리한 페이지 테이블과 권한 비트를 근거로 스레드를 몇 개 둘 것인가와 예약과 실제 할당의 차이를 본다. Unity가 워커 스레드의 API 호출을 막는 이유도 거기서 다룬다.
아직 확인 못 한 것
- x86-64의 4단계 페이지 테이블과 사용자 주소 공간 128TB는 일반적인 구성 기준이다. 5단계 페이징 구성에서는 사용자 공간이 약 64PB로 늘어난다. 내 환경이 몇 단계인지는 확인하지 않았다
- TLB flush 비용과 PCID 적용 여부의 정량적 수치 — 측정하지 않았다
참고 자료
- Linux 커널 문서 - x86-64 메모리 레이아웃 (4단계와 5단계 페이징)
- man7 - fork(2)
- man7 - mmap(2)
- 이론 정리 - < 프로세스, 스레드, 데드락 > — 정의를 처음 적어둔 자리
- 이론 정리 - < 데이터 레이스, 뮤텍스, 아토믹 > — Stack과 Heap의 소속, Data Race와 Race Condition
- 이론 정리 - < RAII와 스마트 포인터 > — dangling pointer와 소유권
- 이론 정리 - < 같은 O(N)인데 왜 속도가 다른가 > — TLB를 처음 고친 자리
- 컴퓨터 구조 - < 운영체제(OS) > — 커널 모드와 유저 모드, 가상 메모리와 페이징
소감
생각보다 기존의 지식 깊이가 많이 얕았다는게 체감되었고, 예전에는 구글링으로 몇 시간이나 걸릴지 모를 정도의 지식들을 단순하게 AI 질의응답 형식으로 공부하는 방식이 많이 도움 되었다.