개요
선점형 스케줄링의 출발점이 하드웨어 타이머 인터럽트라는 설명을 듣다가 거꾸로 생각해봤다.
그럼 유저 코드가 인터럽트를 꺼버리면?
타이머가 울려도 CPU가 안 받으니 커널은 CPU를 되찾을 방법이 없다. 무한 루프 하나로 선점형 스케줄링이 무력화된다. 그러니 인터럽트를 끄는 명령은 유저 모드에서 실행할 수 없게 막아야 한다 — 이게 특권 명령어였다. 특권 명령어가 왜 있어야 하는지를 선점 쪽에서 거꾸로 추론해서 도착했다.
프로세스, 스레드, 그리고 가상 메모리 6절에서는 컨텍스트 스위치가 왜 비싼가를 봤고, 스레드를 몇 개 둘 것인가의 “다음으로 볼 것”에 컨텍스트 스위치를 유발하는 주체를 적어뒀다. 이번 편이 그 자리다.
- 유저 스레드가 도는 동안 커널 코드는 실행되지 않는다. CPU를 되찾는 수단은 인터럽트뿐이다
- 커널에 들어가는 것(mode switch)과 스레드가 바뀌는 것(context switch)은 다르다
- 멈춘 스레드의 레지스터는 그 스레드의 커널 스택에 있고, 전환의 본체는 스택 포인터 교체 한 줄이다
- 비싼 쪽은 저장·복원이 아니라 그 뒤의 캐시 미스다. IOCP의 LIFO가 이걸 노린다
( 레지스터 이름은 x86-64 기준, 커널 내부 구조는 Linux 기준으로 적는다 )
1. 커널은 언제 실행되는가
처음 정리한 건 이 사실이다. 유저 스레드가 코어에서 도는 동안 그 코어에서 커널 코드는 실행되지 않는다. 커널이 뒤에서 계속 감시하는 구조가 아니다. 코어 하나는 한 번에 한 흐름만 실행한다.
그러면 커널이 CPU를 되찾는 길은 둘뿐이다. 스레드가 스스로 커널에 들어오거나, 실행 흐름 바깥에서 하드웨어 신호가 끼어들거나.
| 구분 | 계기 | 출발점 |
|---|---|---|
| 비자발적 (선점) | time slice 소진 | 하드웨어 타이머 인터럽트 |
| 자발적 | blocking 호출 (recv, Sleep, mutex 대기) |
스레드가 직접 syscall을 실행해 커널 진입 |
| 깨어남 | I/O 완료, 타이머 만료 | 장치 인터럽트 → 커널이 Blocked → Ready로 옮김 |
깨어남 줄은 따로 봐야 한다. Ready로 옮겨질 뿐 바로 실행되는 게 아니다. 실행은 스케줄러가 그 스레드를 고를 때다. 이건 4절에서 Sleep(1)로 다시 나온다.
인터럽트가 들어오는 경로는 이렇다.
- 장치가 신호를 보낸다.
- CPU는 명령어와 명령어 사이마다 대기 중인 인터럽트가 있는지 확인한다.
- 있으면 CPU가
RIP/RSP/RFLAGS를 자동으로 저장하고 커널 모드로 바꾼다. - 벡터 테이블(x86은 IDT)에서 핸들러 주소를 찾아 점프한다. 드라이버 코드는 그 뒤에 실행된다.
3번까지가 소프트웨어 없이 하드웨어가 하는 일이다. 커널이 끼어들 자리를 하드웨어가 만들어준다.
2. 인터럽트를 끌 수 있다면 — 특권 명령어
개요에서 적은 추론이 여기서 나왔다. x86에는 인터럽트를 끄는 cli 명령이 있다. 이걸 유저 코드가 실행할 수 있으면 타이머 인터럽트가 무시되고, 1절 표의 첫 줄이 통째로 사라진다.
그래서 cli 같은 명령은 커널 모드에서만 실행되도록 막혀 있다. 이게 특권 명령어이고, 이중 모드(유저/커널)를 나눠둔 이유가 여기서도 나온다.
검사하는 주체도 짚었다. 커널이 아니라 CPU 하드웨어다. 커널은 유저 코드가 도는 동안 실행되지 않으니 검사할 수가 없다. 유저 모드에서 cli를 만나면 CPU가 실행을 거부하고 General Protection Fault를 일으켜 커널로 넘긴다.
가상 메모리 편 8절의 Copy-on-Write와 같은 모양이다. 거기서는 W 권한을 빼앗아 쓰기를 page fault로 바꿨다. 여기서는 모드를 나눠서 위험한 명령을 fault로 바꾼다. 커널이 볼 수 없는 사건을 하드웨어가 트랩으로 넘겨준다.
3. 자발적 전환 — syscall은 같은 스레드다
recv를 부르면 스레드가 syscall 명령을 실행해 커널에 들어간다. 이때 두 가지가 정해져 있다.
- 진입점은 커널이 미리 등록해둔 한 곳뿐이다. 유저 코드가 커널의 아무 주소로나 점프할 수 없다.
- 커널 코드를 실행하는 건 모드만 바뀐 같은 스레드다. 커널 스레드가 따로 나와서 대신 처리하는 게 아니다.
그러면 커널에 들어갔다고 항상 전환이 일어나는 것도 아니다. recv로 들어갔는데 수신 버퍼에 데이터가 이미 있으면 복사만 하고 바로 유저 모드로 돌아온다. 스레드는 한 번도 바뀌지 않는다. 데이터가 없을 때만 스레드가 Blocked로 내려가고 스케줄러가 다른 스레드를 고른다.
4. 깨어남은 실행이 아니다 — Sleep(1)
Sleep(1)이 1ms보다 늦게 깨어나는 이유를 두 단계로 갈랐다.
- 타이머 해상도. 스레드를 깨우는 커널 코드 자체도 타이머 인터럽트가 올 때만 실행 기회를 얻는다. tick 간격이 1ms보다 길면 깨우는 코드가 1ms 시점에 돌 수가 없다.
- Ready 큐 대기. 깨워서 Ready로 옮겨도, 코어가 다른 스레드를 돌리는 중이면 차례를 기다린다.
1번은 Windows에서 timeBeginPeriod로 조정할 수 있는데, 문서를 보니 버전마다 범위가 다르다. Windows 10 2004 이전에는 한 프로세스가 해상도를 올리면 시스템 전체에 적용됐고, 2004부터는 그 함수를 부른 프로세스에만 적용된다. Windows 11에서는 창이 완전히 가려지거나 최소화된 프로세스에 높은 해상도를 보장하지 않는다. 같은 문서에 해상도를 올리면 스케줄러가 더 자주 전환해서 전체 성능과 전력 관리에 손해라는 경고도 있다.
5. mode switch와 context switch
여기까지 오면 두 전환이 갈린다.
| mode switch | context switch | |
|---|---|---|
| 바뀌는 것 | 같은 스레드의 권한 수준 | 코어에서 실행되는 스레드 자체 |
| 스케줄러 | 호출 안 됨 | 호출됨 |
| 관계 | 단독으로 일어날 수 있다 | 항상 mode switch를 먼저 거친다 |
3절의 “버퍼에 데이터가 이미 있는 recv“가 mode switch만 일어난 경우다.
예전 글의 정의를 다시 꺼내봤다.
프로세스 또는 스레드의 상태를 저장하여 나중에 복원하고 실행을 재개할 수 있도록 한 다음, 이전에 저장된 다른 상태를 복원하는 작업 — 서버 프로그래밍 - 네트워크 최적화
틀린 문장은 아닌데, 이 정의로는 두 전환을 구분할 수 없다. 저장과 복원은 mode switch에도 있다. 1절 3번에서 CPU가 인터럽트 진입 시 레지스터를 저장했고, 핸들러가 끝나면 복원한다.
저장해야 하는 이유도 단순하다. 레지스터는 코어당 한 벌이다. 인터럽트 핸들러가 저장 없이 레지스터를 쓰면, 원래 스레드가 계산하던 중간값이 조용히 바뀐다. 인터럽트는 흔적을 남기면 안 된다(transparent).
그래서 기준을 바꿔 적었다.
실행 중인 스레드가 바뀌었는가. 바뀌었으면 context switch, 아니면 mode switch다.
6. 멈춘 스레드는 어디에 남는가
전환당한 스레드의 레지스터 값은 어디 있을까. 레지스터는 코어당 몇 벌로 고정이고 대기 중인 스레드는 수천 개다. 그러니 메모리다. 정확히는 두 곳에 나뉜다.
| 장소 | 담긴 것 |
|---|---|
| 스레드별 커널 스택 | 커널 진입 시 저장한 유저 레지스터 + 스케줄러 안에서 쌓인 레지스터 (내용물) |
스레드별 TCB (Linux task_struct) |
상태, 우선순위 + 마지막 커널 스택 포인터 (찾아갈 주소) |
스레드마다 유저 스택과 별개로 커널 스택이 하나씩 있다. 3절에서 “커널 코드를 실행하는 건 같은 스레드”라고 했는데, 그 스레드가 커널 모드에서 쓰는 스택이 이것이다.
상태가 특정 코어가 아니라 메모리에 있으니, 멈춘 스레드를 다른 코어에서 재개할 수도 있다(마이그레이션). 8절에서 이게 비용 쪽으로 다시 나온다.
7. 전환의 본체는 스택 포인터 한 줄
Linux의 switch_to를 개념만 남겨 줄인 의사코드다. 실제 구현(__switch_to_asm 등)은 아키텍처별 처리가 더 붙고, 그 세부는 이번에 소스로 확인하지 않았다.
// 개념용 의사코드 — 실제 커널 코드가 아니다
switch_to(prev = T1, next = T2):
push callee_saved_regs // T1의 커널 스택에 레지스터를 쌓는다
T1.tcb.sp = rsp // T1의 TCB에 "여기까지 쌓았다"를 적는다
rsp = T2.tcb.sp // 스택 포인터를 T2의 커널 스택으로 바꾼다 ← 핵심
pop callee_saved_regs // T2가 예전에 멈출 때 쌓아둔 값을 꺼낸다
ret // T2가 예전에 switch_to를 call한 지점으로 돌아간다
세 번째 줄 이후로 CPU가 보는 스택이 T2의 것이다. 그래서 pop이 꺼내는 건 T2의 값이고, ret이 꺼내는 복귀 주소도 T2의 것이다.
이렇게 되면 T1이 호출한 switch_to는 T2에서 리턴된다. T1의 호출은 나중에 다른 스레드가 T1으로 전환해줄 때 리턴된다. 함수 하나가 들어간 스레드와 나온 스레드가 다르다.
ret 이후 T2는 이 순서로 풀려 나온다.
-
schedule()의 나머지 - 커널 복귀 경로
- 커널 스택에 저장해둔 유저 레지스터 복원
-
sysret/iret로 유저 모드 복귀
4번을 거쳐야 하는 이유도 있다. 커널 모드에서 ret으로는 유저 코드로 바로 갈 수 없다. ret은 주소만 바꾸지 권한을 내리지 않는다. 권한을 내리는 건 sysret/iret의 일이다.
새 스레드는 어디로 ret하는가
방금 만든 스레드는 switch_to를 call한 적이 없다. 돌아갈 자리가 없다.
그래서 커널은 스레드를 만들 때 커널 스택을 예전에 밀려난 스레드처럼 위조해둔다. 가짜 callee-saved 값, 시작 루틴 주소(Linux에서는 ret_from_fork), 그리고 위조한 유저 레지스터 프레임을 쌓아둔다. 그러면 switch_to는 새 스레드인지 모르고 똑같이 pop과 ret을 하고, 시작 루틴을 거쳐 유저 코드의 첫 줄로 나간다.
전환 경로에 “새 스레드면” 분기를 넣는 대신, 데이터의 모양을 맞춰서 특수 케이스를 없앤 설계다.
8. 비용 — 직접과 간접
가상 메모리 편 6절에서 비용의 본체를 TLB flush와 그 뒤의 page walk로 적었다. 이번엔 항목을 둘로 나눴다.
| 구분 | 항목 |
|---|---|
| 직접 | 커널 진입·복귀, 레지스터 저장·복원(FPU/SIMD 포함), 스케줄러 실행, CR3 교체(프로세스 전환 시), 보안 완화 처리 |
| 간접 | 캐시·TLB·분기 예측기가 다른 스레드의 상태로 채워져 미스가 이어진다 |
간접 비용이 더 크다. 그리고 양쪽 다 손해다. T2가 돌면서 캐시를 자기 데이터로 채우면, 나중에 T1이 돌아왔을 때 T1도 다시 채워야 한다. 6절의 마이그레이션으로 다른 코어에서 재개하면 그 코어의 캐시에는 T1의 흔적이 아예 없으니 더 나빠진다.
스레드 전환이 프로세스 전환보다 싼 건 CR3를 안 바꿔서다. 다만 캐시 오염은 스레드 전환에도 그대로 있다.
KPTI 환경에서는 mode switch에도 CR3가 바뀐다
09-18 편에 “스레드 전환에는 CR3를 바꿀 필요가 없다”고 적었다. 스레드 전환 자체로는 맞는데, Meltdown 대응인 PTI(Page Table Isolation)가 켜진 Linux에서는 syscall·인터럽트·예외의 진입과 복귀마다 유저용/커널용 페이지 테이블을 오가려고 CR3를 바꾼다(Linux 커널 문서). PCID가 있으면 이 교체의 TLB 비용이 줄어든다. 위 표의 “보안 완화 처리”가 이 자리다.
전환 종류로 문제를 가른다
1절 표의 두 줄을 진단에 쓸 수 있다.
- 자발적 전환이 많다 → 스레드가 자꾸 기다린다. I/O나 락 쪽 문제다
- 비자발적 전환이 많다 → time slice를 다 쓰고 쫓겨난다. 코어보다 실행할 스레드가 많다
Linux에서는 /proc/<pid>/status의 voluntary_ctxt_switches / nonvoluntary_ctxt_switches로 둘을 따로 볼 수 있다고 한다. 이번에 직접 찍어보지는 않았다.
IOCP가 하는 두 가지
네트워크 최적화 편에 IOCP가 대기 스레드를 LIFO로 깨우고, 최근 스레드가 캐시에 남아 있을 가능성이 높아서라고 적어뒀다. 이번엔 그게 위 표의 어느 칸인지가 붙었다. 가장 최근에 잠든 스레드를 깨우는 건 간접 비용을 줄이려는 선택이다.
Microsoft 문서를 다시 보니 IOCP가 하는 일이 하나 더 있었다.
| 장치 | 줄이는 비용 |
|---|---|
| LIFO 깨우기 | 간접 비용 — warm cache 재사용 |
동시 실행 수 제한 (NumberOfConcurrentThreads) |
비자발적 전환 — 실행 가능한 스레드 수를 코어 수 근처로 묶는다 |
문서에 나온 예가 이 둘을 합친 모양이다. 동시 실행 수가 1이고 큐에 완료 패킷이 계속 있으면, 실행 중인 스레드가 GetQueuedCompletionStatus를 다시 불러도 잠들지 않고 다음 패킷을 바로 가져간다. 스레드 전환이 한 번도 일어나지 않는다. 3절의 “버퍼에 데이터가 이미 있는 recv“와 같은 구조다.
9. 게임 쪽으로 가져오면
워커 수. 워커 수는 대략 코어 수에서 메인 스레드 몫을 뺀 만큼이다. 넘으면 일은 안 늘고 비자발적 전환만 는다. Unity 잡 시스템의 기본 워커 수는 문서에 “플랫폼과 하드웨어에 따라 런타임이 정한다”고만 되어 있고 구체적인 공식은 못 찾았다.
spin vs block. 락을 기다릴 때 잠들면(block) 전환 비용을 낸다. 잠들지 않고 루프를 돌며 확인하면(spin) 코어를 태운다. spin이 의미 있는 조건은 두 가지가 겹칠 때다.
- 기다림이 전환 비용보다 짧다
- 락을 쥔 스레드가 다른 코어에서 실행 중이다 — 같은 코어라면 내가 spin하는 동안 그 스레드는 돌 수 없다
Windows의 InitializeCriticalSectionAndSpinCount 문서가 이 조건을 그대로 반영한다. 정해진 횟수만큼 spin하다가 안 풀리면 잠들고, 단일 프로세서 시스템에서는 spin count를 0으로 무시한다. 실무 구현이 “잠깐 spin 후 block”인 이유다.
fiber. 7절의 스택 포인터 교체를 유저 모드에서 하는 것이다. 커널에 안 들어가니 mode switch도 스케줄러도 없다. 대가는 선점이 없다는 것이다. fiber가 스스로 넘겨주지 않으면 다른 fiber는 못 돈다(협력형).
Unity 코루틴. 무거운 루프를 코루틴 안에 넣으면 화면이 멈추는 이유를 이 구분으로 정리했다. 메인 스레드는 OS가 선점한다. 그런데 선점은 “어느 스레드가 코어를 쓰는가”만 바꾸고 “그 스레드가 다음에 무엇을 하는가”는 바꾸지 않는다. 메인 스레드가 다시 코어를 받으면 하던 루프를 이어서 돈다. 그 위의 코루틴은 협력형이라 yield를 만나기 전까지 Unity에 제어권을 넘기지 않고, Unity는 다음 프레임을 그릴 수 없다.
정리
원래 갖고 있던 것
- 컨텍스트 스위칭은 상태를 저장하고 복원하는 작업 — 24.12, 25.01 글에 적어둔 정의
- IOCP는 대기 스레드를 LIFO로 깨운다 — 이유가 캐시라는 것까지
- 스레드 전환은 CR3를 안 바꿔서 프로세스 전환보다 싸다
모자랐던 것
- 저장·복원은 mode switch에도 있다. 두 전환을 가르는 기준은 실행 중인 스레드가 바뀌었는가
-
커널에 들어간다고 전환이 일어나지 않는다.
recv할 데이터가 이미 있으면 mode switch만 하고 돌아온다 -
깨어남은 실행이 아니다. Ready로 옮겨질 뿐이고,
Sleep지연은 타이머 해상도와 Ready 큐 대기 두 단계다 - IOCP는 LIFO만이 아니다. 동시 실행 수 제한이 비자발적 전환을 따로 줄인다
- PTI 환경에서는 mode switch에도 CR3가 바뀐다
이번에 새로 얹은 것
- 유저 스레드가 도는 동안 커널은 돌지 않는다. CPU를 되찾는 수단은 인터럽트뿐이다
- 특권 명령어는 선점을 지키려고 있다. 검사하는 건 CPU 하드웨어다
- 멈춘 스레드의 레지스터는 그 스레드의 커널 스택에, 찾아갈 주소는 TCB에 있다
- 전환의 본체는 스택 포인터 교체다. T1이 부른 함수가 T2에서 리턴되고, 새 스레드는 스택을 위조해 같은 경로를 탄다
- 간접 비용이 직접 비용보다 크고, 양쪽 스레드가 다 손해 본다
- 선점은 누가 코어를 쓰는지만 바꾼다. 코루틴이 화면을 멈추는 이유가 여기서 나온다
아직 확인 못 한 것
-
switch_to가 callee-saved 레지스터만 저장해도 되는 이유. 호출 규약상 caller-saved는 부른 쪽이 책임지는데, 인터럽트로 들어온 경우와 어떻게 맞물리는지 답을 못 냈다 -
/proc/<pid>/status의 전환 카운터 관찰, pipe 핑퐁으로 전환 비용 측정 — 안 해봤다 - 직접·간접 비용의 실제 수치 — CPU 세대와 OS 설정에 따라 달라서 측정 전에는 적지 않는다
- Windows 기본 타이머 해상도 값 — 흔히 15.6ms로 알려져 있는데 공식 문서에서 숫자를 확인하지 못했다
- Linux
__switch_to_asm,ret_from_fork, syscall 진입 경로의 실제 코드 - Unity 잡 시스템의 플랫폼별 기본 워커 수
참고 자료
- Microsoft Learn - I/O Completion Ports — LIFO 깨우기와 동시 실행 수
- Microsoft Learn - timeBeginPeriod — 버전별 타이머 해상도 동작
- Microsoft Learn - InitializeCriticalSectionAndSpinCount — spin 후 대기, 단일 프로세서에서 spin count 0
- Linux 커널 문서 - Page Table Isolation (PTI) — 커널 진입·복귀 시 CR3 교체와 PCID
- Unity Scripting API - JobsUtility.JobWorkerMaximumCount
- 이론 정리 - < 프로세스, 스레드, 그리고 가상 메모리 > — 컨텍스트 스위치가 비싼 이유, Copy-on-Write
- 이론 정리 - < 스레드를 몇 개 둘 것인가 > — 이번 주제를 “다음으로 볼 것”에 적어둔 자리
- 서버 프로그래밍 - 네트워크 최적화 — 문맥 교환 정의와 IOCP를 처음 적어둔 자리
- 컴퓨터 구조 - < 운영체제(OS) > — 이중 모드
소감
컨텍스트 스위칭이란 단어에 친숙하고 그냥 비용이 크다 라는 단순한 개념만 갖고 있다가 이렇게 구체적인 구현,작동 방식들을 탐구하니 신기하면서 머리 아팠다 ㅎㅎ;