개요
앞 편은 아래로 내려가는 방향이었다. 페이지 테이블, TLB, 권한 비트, Copy-on-Write까지 갔다. 이번 편은 그 위에 올라서서 판단하는 쪽이다.
요청이 10,000개 들어오는 서버에 스레드를 10,000개 띄우면 무엇이 먼저 무너지는가
이 질문에 RAM이라고 답했다가 걸렸다. 그리고 걸린 지점이 계산 실수가 아니라 숫자의 종류를 잘못 읽은 것이었다. 8MB × 1,000 = 8GB라는 계산은 맞는데, 그 8GB가 RAM 8GB가 아니다.
이번 편은 그 구분에서 시작해서 동시성 모델, 스레드 하나가 죽으면 프로세스가 죽는 이유, 그리고 Unity가 워커 스레드의 API 호출을 막는 이유까지 간다. 마지막 절은 용어 정리인데, 이번에 제일 여러 번 헛짚은 자리다.
1. 예약과 실제 할당은 다른 숫자다
메모리를 얼마나 쓰느냐는 질문에 답이 두 개다.
| 구분 | 의미 | 소비하는 자원 |
|---|---|---|
| 예약 / virtual (VSZ) | 이 가상 주소 구간을 쓰겠다고 표시해둔 것 | 주소 공간 |
| 실제 할당 / resident (RSS) | 물리 페이지가 실제로 붙은 것 | RAM |
앞 편에서 정리한 걸 그대로 가져오면 이해가 빨라진다. 페이지 테이블은 가상 주소를 물리 주소로 옮기는 표다. 예약은 그 표에 “이 구간은 유효하다”고 적어두는 일이고, 실제 할당은 그 줄에 물리 페이지 번호가 붙는 일이다. 예약만 해두고 한 번도 안 건드리면 표의 줄은 있는데 붙은 물리 페이지가 없다.
그래서 메모리를 얼마나 쓰느냐를 말할 때 기준은 RSS다. VSZ는 주소 공간을 얼마나 그어뒀는지고, 그건 64비트에서는 거의 무한에 가깝다.
mmap이 하는 일
mmap도 같은 자리에서 다시 읽힌다. mmap이 하는 일은 물리 메모리를 떼어주는 게 아니라 페이지 테이블에 이 구간이 유효하다고 표시하는 것이다. 실제 물리 페이지는 처음 접근할 때 붙는다. 앞 편 8번에서 정리한 그 구조 — 관찰해야 하는 사건을 page fault로 바꿔놓는 것 — 가 여기서도 돈다.
용도는 크게 둘이다.
- 파일 매핑 — 파일을 주소 공간에 붙여서 포인터로 읽는다
- 익명 매핑(anonymous) — 파일과 연결되지 않은 구간. 스레드 스택, 큰 힙 할당이 여기 쓰인다
2. 그럼 스레드 스택 크기는 뭘 보고 정하나
새 스레드의 스택은 생성 시점에 별도 영역을 통째로 잡고, 크기가 고정된다. 실행하면서 늘어나지 않는다. 그래서 크기를 정해줘야 하는데, 여기서 기준을 RAM으로 잡고 있었다. 틀렸다.
기준은 최대 호출 깊이 × 프레임 하나의 크기다. 스택에 쌓이는 건 앞 편 3번에서 본 그것들이다. 지역 변수, 매개변수, 복귀 주소. 깊은 재귀도 없고 큰 지역 배열도 없으면 수백 KB로도 충분하다. RAM이 몇 GB인지는 이 계산에 안 들어간다.
64비트에서는 예약이 넉넉해서 크게 잡아도 티가 잘 안 나는데, 32비트에서는 예약 자체가 압박이 된다. 주소 공간이 4GB뿐이라 스택을 크게 잡으면 스레드 수가 주소 공간에서 먼저 막힌다.
8MB는 어디서 나온 숫자인가
리눅스 스레드 기본 스택을 8MB로 알고 있었는데, 확인해보니 pthread에 박힌 상수가 아니었다. pthread_create(3) 문서 기준으로 NPTL에서는 프로그램 시작 시점의 RLIMIT_STACK soft limit이 unlimited가 아니면 그 값이 새 스레드의 기본 스택 크기가 된다. unlimited면 아키텍처별 값을 쓰는데 대부분 2MB, POWER와 Sparc-64는 4MB다.
흔히 보는 8MB는 배포판의 ulimit -s가 8192KB인 데서 온다. 환경 설정값이지 고정된 기본값이 아니다. 내 환경에서 ulimit -s나 pthread_attr_getstacksize를 직접 찍어보지는 않았다.
3. 스레드 10,000개 — 먼저 무너지는 축
이제 처음 질문으로 돌아간다. 내 답은 이랬다.
8MB × 10,000이면 80GB다. RAM이 먼저 터진다.
계산은 맞는데 그 80GB는 예약이다. 스택을 8MB로 잡아뒀다고 8MB가 전부 RSS로 잡히는 게 아니다. 실제로 건드린 페이지만 물리 메모리를 먹는다. 호출 깊이가 얕으면 스레드 하나가 실제로 쓰는 건 몇 KB 수준이다.
그럼 뭐가 먼저 무너지느냐 — CPU다.
- 스레드 수가 코어 수를 한참 넘으면 스케줄러가 계속 갈아끼운다. 컨텍스트 스위치가 폭증한다
- 앞 편 6번에서 본 것처럼 전환의 실제 비용은 저장/복원이 아니라 캐시와 TLB가 오염되는 것이다. 스레드 전환은 CR3를 안 바꾸니 프로세스 전환보다 낫지만, 수천 개가 돌아가면서 서로의 캐시 라인을 밀어내는 건 막지 못한다
- 결과적으로 일은 안 하고 갈아끼우기만 한다
이건 원리상의 결론이고 이번에 측정하지는 않았다. 다만 “왜 스레드 풀을 쓰는가”에 대한 답이 임계 영역과 스레드 풀 때보다는 구체적으로 나온다. 그때는 “메모리 공간과 컨텍스트 스위칭 비용”이라고 둘을 나란히 적어놨는데, 둘의 무게가 다르다.
4. 동시성 모델 세 층
세 가지를 나란히 놓고 각각이 동시성을 어디서 얻는지로 갈랐다.
| 방법 | 동시성의 근거 | 한계 |
|---|---|---|
| 요청당 스레드 | 스레드 수 | 컨텍스트 스위치와 캐시 오염으로 붕괴 |
| 스레드 풀 | 스레드 재사용 | 전원이 블로킹되면 정지 |
| 비동기 I/O | 기다리는 시간을 스레드가 점유하지 않음 | CPU 바운드 작업에는 효과 없음 |
가운데 줄에서 한 번 걸렸다. 스레드 풀을 “작업을 순서대로 처리하는 것”으로 설명했는데, 순차 처리가 아니다. 풀 안의 스레드들은 동시에 돈다. 풀이 하는 일은 스레드를 요청마다 새로 만들지 않고 돌려 쓰는 것이다. 동시 실행 수가 풀 크기로 제한될 뿐 처리 자체가 직렬이 되지는 않는다.
세 번째 줄이 제일 중요하다. 비동기는 계산을 빠르게 하지 않는다. 비동기가 줄여주는 건 기다리는 동안 스레드가 묶여 있는 시간이다. 계산이 오래 걸리는 일은 아무리 await를 붙여도 그 시간만큼 CPU를 쓴다.
그래서 동시성이 안 나올 때 스레드를 늘리기 전에 병목의 축부터 가른다.
- I/O 대기가 병목 → 비동기
- 계산이 병목 → 스레드 풀, 잡 시스템
Ducktopia 때 Node.js로 만들면서 이 구분 없이 지나간 적이 있는데, 그때는 축이 I/O 쪽이라 비동기로 다 커버된 거였다. 이벤트 루프와 JWT에 적어둔 libuv 이야기가 그 배경이다.
5. 스레드 하나가 죽으면 왜 프로세스 전체가 죽는가
이건 이렇게 답했었다.
OS가 오염이 번지는 걸 막으려고 판단해서 프로세스를 멈춘다.
의도를 붙여서 설명한 건데, 그런 판단이 개입하는 자리가 아니다. 시그널은 프로세스 단위로 배달되고, SIGSEGV의 기본 동작이 프로세스 종료다. 즉 종료의 단위가 애초에 프로세스다. 앞 편 1번의 “프로세스는 자원 할당의 단위”가 여기서 다시 나온다.
한 스레드만 골라 죽이는 게 더 낫지 않느냐는 반문도 답이 나온다. 그 스레드가 잡고 있던 뮤텍스는 풀리지 않는다. 그 스레드가 절반쯤 고쳐놓은 자료구조는 반쯤 고쳐진 상태로 남는다. 나머지 스레드가 그 상태를 계속 쓰게 두는 편이 더 위험하다.
크롬이 탭마다 프로세스를 띄우는 이유가 여기 있다. 종료의 단위가 그 탭 안에서 끝난다. 탭 하나가 죽어도 브라우저 전체가 같이 가지 않는다.
6. fd 테이블은 스레드 간 공유다
스레드를 만들면 fd 테이블이 복제될 거라고 생각했다. 근거는 워커 스레드가 자기 소켓을 들고 있어야 할 것 같아서였다.
그런데 방향이 반대다. 워커 스레드가 다른 스레드가 받은 소켓을 이어받아 처리할 수 있어야 스레드 풀이 성립한다. 그리고 fd는 자원이니까, 앞 편 1번 표에 따라 프로세스 소유다. 스레드들이 같이 본다.
공유면 Data Race가 걸린다. 경로가 구체적으로 나온다. fd 테이블의 각 칸에는 파일 오프셋이 들어 있다. 두 스레드가 같은 fd에 write하면 오프셋 하나를 둘이 갱신하게 되고, 09-07 편의 gold -= 10과 똑같은 읽고-더하고-쓰는 세 걸음이 재현된다. 로그가 섞이거나 한쪽이 덮어써진다.
7. Unity가 워커 스레드의 API 호출을 막는 이유
왜 막느냐는 질문에 이렇게 답했다.
허용하면 race condition을 직접 설계해야 하니까.
두 군데가 어긋났다. 하나는 뒤에서 볼 용어 문제고, 다른 하나는 막는 이유의 방향이다.
먼저 기술적으로는 접근할 수 있다. 스레드는 heap을 공유하고 Transform 객체는 heap에 있다. 주소만 있으면 워커 스레드에서 건드릴 수 있다. 그런데도 막는다.
허용한다고 치면 씬 그래프, 렌더 큐 같은 공유 자료구조 전부에 락을 걸어야 한다. 그리고 두 군데서 터진다.
-
transform.position은 프레임당 수만 번 호출된다. 여기에 락을 걸면 lock contention이 실제 작업 비용을 넘어선다. 60fps면 프레임 예산이 16.6ms인데, 그 안에서 락 획득/해제만 수만 번이다 - 렌더링은 씬을 일관된 한 시점의 스냅샷으로 읽어야 한다. 자료구조 단위로 락을 잘게 걸면 이 일관성이 안 나온다. 그렇다고 씬 전체를 잠그면 그동안 아무도 못 건드리니 결국 싱글 스레드와 같아진다
그래서 Unity의 선택은 락이 아니다. 소유자를 메인 스레드로 못박아서 동기화 자체를 없앴다. 공유하지 않으면 경쟁도 없다.
공식 문서 쪽 표현도 같은 자리에 있다. 코어 런타임이 싱글 스레드이고 UnityEngine·UnityEditor 네임스페이스의 대부분 API는 메인 스레드에서만 호출할 수 있다고 적혀 있다. 백그라운드 스레드에서 GameObject, Transform, Component, 에셋 API를 참조하지 말라는 문장도 그대로 있다. 다만 어디까지가 예외인지 목록으로 적어둔 문서는 못 찾았다. 잡에서는 blittable 타입이나 NativeContainer를 쓰라는 안내까지가 공식 문서에서 확인한 범위다.
DOTS와 Job System이 같은 원칙의 연장이다. 워커 스레드가 Transform을 직접 만지지 않는다. 데이터를 NativeArray로 떼어내 계산하고, 결과를 메인 스레드에서 반영한다.
공유하지 말고, 나눠 가진 뒤 합친다.
FPS가 떨어졌을 때 무엇부터 의심하는가에서 프레임 예산 이야기를 했었는데, 그 예산이 여기서 설계 제약으로 돌아온다. 16.6ms는 최적화할 때만 나오는 숫자가 아니라 API를 어떻게 열지 정할 때의 숫자이기도 했다.
8. 용어 셋을 갈랐다
이번에 헛짚은 게 대부분 여기 모여 있다. 세 단어를 상황에 따라 섞어 쓰고 있었다.
프로세스 간에 메모리를 공유하면 뭐가 문제냐는 질문에 race condition이라고 답했다. 그런데 서로 다른 프로세스에는 타이밍 문제 이전에 더 단순한 문제가 있다. 남의 메모리를 볼 수 있으면 안 된다는 것 — 필요한 단어는 isolation(격리)이었다.
Unity가 막는 이유에도 race condition이라고 답했다. 그런데 race condition은 발생하는 문제이지 설계하는 대상이 아니다. 설계해야 하는 건 그 반대편, 즉 synchronization(동기화)이다.
| 단어 | 층 |
|---|---|
| race condition | 발생하는 문제 |
| isolation | 프로세스가 주는 보장 |
| synchronization | 문제를 막는 수단 |
09-07 편에서 Data Race와 Race Condition을 갈라뒀는데, 그건 문제끼리의 구분이었다. 이번엔 문제 / 보장 / 수단이라는 다른 축의 구분이 하나 더 필요했다.
그래서 프로세스를 언제 쓰느냐에도 답이 생긴다. 프로세스는 스레드보다 비싸다. 그 값을 치르고 사는 건 세 가지다.
- 격리 — 남의 주소 공간을 볼 수 없다
- 장애 전파 차단 — 종료의 단위가 그 안에서 끝난다 (5번의 크롬)
- 보안 경계 — 권한을 프로세스 단위로 그을 수 있다
격리가 필요하면 성능을 포기하고 프로세스를 쓴다.
정리
원래 갖고 있던 것
- 스레드 풀을 쓰는 이유 — 요청마다 만들지 않고 돌려 쓴다
- 프레임 예산 16.6ms — FPS 편에서 쓰던 숫자가 설계 제약으로도 쓰인다
-
워커 스레드에서
Transform을 만지면 안 된다 — 규칙은 알고 있었고, 이유가 없었다
틀리게 잡고 있던 것
- 8MB × N은 예약이지 RSS가 아니다. 메모리를 말할 때 기준은 RSS
- 스레드 10,000개에서 먼저 무너지는 축은 RAM이 아니라 CPU다
- 스레드 풀은 순차 처리가 아니다. 근거는 재사용이지 직렬화가 아니다
- 스레드 스택 크기의 기준은 RAM이 아니라 최대 호출 깊이 × 프레임 크기다
- fd 테이블은 프로세스 소유라 스레드 간 공유다. 복제되지 않는다
- 프로세스가 죽는 건 OS의 판단이 아니다. 시그널이 프로세스 단위로 배달되고 종료의 단위가 프로세스다
- Unity가 막는 이유는 “race condition을 설계해야 해서”가 아니다. 설계 대상은 synchronization이다
이번에 새로 얹은 것
mmap은 페이지 테이블에 유효 표시를 하는 것이지 물리 메모리를 떼어주는 게 아니다- 비동기는 계산을 빠르게 하지 않는다. 병목이 I/O면 비동기, 계산이면 스레드 풀
- Unity는 락을 거는 대신 소유자를 메인 스레드로 못박아 동기화를 없앴다
- race condition / isolation / synchronization은 문제 / 보장 / 수단으로 층이 다르다
두 편을 쓰면서 계속 나온 게 하나 있다. “싸다, 비싸다”를 말할 때 무엇을 안 하기 때문인지로 답하는 것이다. 스레드 전환이 싼 건 CR3를 안 바꿔서 TLB flush가 없기 때문이고, 예약이 싼 건 물리 페이지를 안 붙이기 때문이고, Unity가 빠른 건 락을 안 걸기 때문이다. 셋 다 안 하는 일이 근거였다.
아직 확인 못 한 것
- 내 환경의
ulimit -s와pthread_attr_getstacksize실측 — 안 해봤다 - Unity 메인 스레드 제약의 예외 범위. 잡에서 blittable 타입과
NativeContainer를 쓰라는 안내까지는 공식 문서에서 확인했는데, 어느 API가 워커에서 안전한지 목록으로 적어둔 문서는 못 찾았다 - 스레드 10,000개에서 CPU 축이 먼저 무너진다는 결론 — 원리상의 추론이고 측정하지 않았다
- 다음으로 볼 것: TLS(Thread Local Storage), 스케줄링 — 특히 선점형과 비선점형, 컨텍스트 스위치를 유발하는 주체가 누구인지
참고 자료
-
man7 - pthread_create(3) —
RLIMIT_STACK이 기본 스택 크기를 정한다는 설명 - man7 - signal(7) — 시그널의 기본 동작
- man7 - mmap(2)
- Unity Manual - Unity programming best practices — 코어 런타임이 싱글 스레드라는 것과 백그라운드 스레드 제약
- Unity Manual - Write multithreaded code with the job system
- 이론 정리 - < 프로세스, 스레드, 그리고 가상 메모리 > — 이 글의 앞 편
- 이론 정리 - < 데이터 레이스, 뮤텍스, 아토믹 > — Data Race와 Race Condition의 구분
- 이론 정리 - < 임계 영역과 스레드 풀 > — 스레드 풀을 처음 적어둔 자리
- 이론 정리 - < 이벤트 루프와 JWT > — Node.js의 싱글 스레드와 libuv
- 이론 정리 - < FPS가 떨어졌을 때 무엇부터 의심하는가 > — 프레임 예산
소감
실질적으로 프로세스와 스레드의 정의 뿐만 아니라 이게 내 도메인과 어떻게 연결되는지까지 확인해서 개인적으로 뿌듯한 정리였던 것 같다.