---
title: "프로세스와 스레드, 그리고 갈아 끼우는 값"
description: "스레드를 바꾸는 것과 프로세스를 바꾸는 것은 값이 다릅니다. 무엇을 갈아 끼우는지, 갈아 끼운 뒤에 왜 한동안 느린지, 그래서 워커를 몇 개나 만들어야 하는지 봅니다."
author: "유니얼"
pubDate: 2026-09-17
category: "programming/os"
tags: ["OS"]
canonical: https://blog.unialgames.com/posts/os-context-switch/
---
CPU 는 한 번에 하나만 실행합니다. 코어가 여덟 개면 여덟 개입니다.
그런데 지금 기기에서 도는 스레드는 수백 개입니다. 그래서 OS 가 번갈아 태웁니다.

번갈아 태우려면 **지금 돌던 것의 상태를 어딘가에 적어 두고, 다음 것의 상태를 꺼내 와야** 합니다.
이 일에 값이 붙습니다. 그리고 **프로세스를 바꾸느냐 스레드를 바꾸느냐에 따라 값이 다릅니다.**

## 프로세스는 주소 공간, 스레드는 그 안의 실행 흐름

둘의 경계는 **페이지 테이블을 공유하느냐**에서 갈립니다.

프로세스는 가상 주소를 물리 주소로 번역하는 표를 하나 가집니다.
그 표가 프로세스마다 따로라서 서로의 메모리에 닿을 수 없습니다.

스레드는 **그 표를 같이 씁니다.** 따로 갖는 것은 셋뿐입니다.

- 자기 스택
- 레지스터 값들
- 지금 어느 명령을 실행 중인지

<figure class="diagram">
<svg viewBox="0 0 680 230" role="img" aria-label="한 프로세스 상자 안에 페이지 테이블 하나와 힙이 있고, 스레드 둘이 각자 스택과 레지스터만 따로 가진 그림">
<g fill="var(--bg)" stroke="var(--border)">
<rect x="8" y="24" width="400" height="190" rx="8"></rect>
<rect x="440" y="24" width="232" height="190" rx="8"></rect>
</g>
<rect x="28" y="60" width="170" height="54" rx="6" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<rect x="28" y="132" width="170" height="54" rx="6" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<rect x="222" y="60" width="164" height="54" rx="6" fill="var(--accent2-soft)" stroke="var(--accent2)"></rect>
<rect x="222" y="132" width="164" height="54" rx="6" fill="var(--accent2-soft)" stroke="var(--accent2)"></rect>
<rect x="460" y="60" width="192" height="126" rx="6" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<g font-size="12" fill="var(--text-muted)">
<text x="8" y="16">프로세스 하나</text>
<text x="440" y="16">물리 메모리</text>
<text x="222" y="52">스레드 1</text>
<text x="222" y="124">스레드 2</text>
</g>
<g font-family="var(--font-mono)" font-size="11">
<text x="42" y="82" fill="var(--accent)">페이지 테이블</text>
<text x="42" y="100" fill="var(--accent)">둘이 같이 씁니다</text>
<text x="42" y="154" fill="var(--text-muted)">힙 · 코드 · 열린 파일</text>
<text x="42" y="172" fill="var(--text-muted)">둘이 같이 씁니다</text>
<text x="236" y="82" fill="var(--accent2)">스택 · 레지스터</text>
<text x="236" y="100" fill="var(--accent2)">자기 것</text>
<text x="236" y="154" fill="var(--accent2)">스택 · 레지스터</text>
<text x="236" y="172" fill="var(--accent2)">자기 것</text>
<text x="474" y="126" fill="var(--text-muted)">프레임들</text>
</g>
</svg>
<figcaption>스레드가 나눠 갖지 않는 것은 스택과 레지스터뿐이다.</figcaption>
</figure>

그래서 같은 프로세스의 스레드끼리는 **서로의 데이터에 그냥 닿습니다.** 편한 만큼 위험한 것도 여기서 나옵니다.

## 갈아 끼울 때 실제로 바뀌는 것

이 차이가 그대로 전환 비용이 됩니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "스레드끼리", "text": "같은 프로세스 안에서 스레드만 바꿉니다. 주소 공간이 그대로라 번역에 쓰던 것들이 전부 살아 있습니다.", "stats": [["갈아 끼우는 것", "레지스터와 스택"], ["페이지 테이블", "그대로"], ["TLB", "살아 있음"], ["대략", "1마이크로초 안팎"]], "tone": "good" },
    { "label": "프로세스끼리", "text": "주소 공간이 통째로 바뀝니다. 앞의 것에 더해 번역의 기준이 되는 표를 바꿔 끼우고, 그 표로 만들어 둔 번역 결과를 버려야 합니다.", "stats": [["갈아 끼우는 것", "레지스터와 스택 + 페이지 테이블"], ["페이지 테이블", "바꿔 끼움"], ["TLB", "쓸모없어짐"], ["대략", "그 위에 더"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

TLB 는 최근 번역 결과를 담아 두는 CPU 안의 작은 캐시입니다.
이게 왜 있고 없으면 얼마나 비싼지는 [번역도 공짜가 아니다](/posts/os-tlb-page-walk/)에 적었습니다.
표가 바뀌면 그 안에 담긴 번역은 **다른 프로세스의 답**이라 쓸 수 없습니다. 그래서 버립니다.

버린 다음에는 다시 채워야 합니다. 새 프로세스는 한동안 **번역을 매번 표에서 찾아 가며** 돕니다.

## 값은 갈아 끼운 다음에 옵니다

레지스터를 저장하고 복원하는 것 자체는 몇백 나노초짜리 일입니다.
문제는 그다음입니다.

- **캐시가 식습니다.** 새로 올라온 스레드가 쓰는 데이터는 캐시에 없습니다
- **TLB 가 비어 있습니다.** 프로세스가 바뀐 경우에 특히 그렇습니다
- **분기 예측이 헛돕니다.** 전에 배워 둔 것이 다른 코드의 습관입니다

그래서 전환 직후의 코드는 **같은 코드인데 몇 배 느립니다.** 프로파일러에는 전환 비용으로 안 잡힙니다.
그 시간은 전부 "그 함수가 느림" 으로 기록됩니다.

<x-calc>
<script type="application/json">
{
  "label": "한 프레임에 전환이 몇 번이면",
  "inputs": [
    { "id": "n", "label": "한 프레임의 전환 횟수", "options": [10, 100, 1000], "default": 100, "suffix": "회" },
    { "id": "c", "label": "전환 하나의 값", "options": [["스레드끼리", 1], ["식은 캐시까지 치면", 5]], "default": 1 }
  ],
  "bars": [
    { "label": "전환에 쓴 시간", "expr": "n * c / 1000", "unit": "ms", "accent": true },
    { "label": "한 프레임 예산", "expr": "16.6", "unit": "ms" }
  ],
  "verdict": { "expr": "bars[0] > 1.66", "yes": "프레임의 십분의 일을 전환이 먹음", "no": "티가 안 나는 크기" },
  "note": "전환 하나를 1마이크로초로 잡았습니다. 기계와 상황에 따라 크게 다르니 자릿수로만 보세요."
}
</script>
</x-calc>

## 그래서 스레드를 많이 만들면 안 됩니다

흔한 오해가 "스레드를 늘리면 일이 빨리 끝난다" 입니다.
**코어 수를 넘어서면 그때부터는 번갈아 태우는 것**이라 일이 더 늘어납니다.

- 코어 여덟 개에 워커 여덟 개면 각자 자리를 잡고 끝까지 돕니다
- 코어 여덟 개에 워커 백 개면 백 개가 번갈아 타면서 서로의 캐시를 밀어냅니다

그래서 엔진의 잡 시스템은 **코어 수에 맞춰 워커를 만들고 그 위에 일감을 얹습니다.**
스레드를 일감마다 만드는 것이 아니라 스레드는 고정해 두고 일감만 갈아 끼우는 것입니다.

모바일은 한 겹 더 있습니다. 코어가 성능이 다른 두 종류로 나뉩니다.
같은 스레드가 작은 코어에 올라가면 **같은 코드가 몇 배 느립니다.**
프레임이 들쭉날쭉한데 원인이 안 보이면 이걸 의심해 볼 값이 있습니다.

## 게임에서 보이는 자리

**메인 스레드가 자리를 비우면 그 프레임은 끝입니다.** 다른 스레드가 대신 돌아도 화면은 메인 스레드가 끝내야 나옵니다.

메인 스레드가 자리를 비우는 흔한 경우들입니다.

- 락을 기다릴 때. 커널이 그 스레드를 재우고 다른 것을 올립니다. 깨어날 때 전환 값이 또 붙습니다
- 파일을 읽을 때. 읽기가 끝날 때까지 재워집니다
- `Sleep` 을 부를 때. 정확히 그 시간만큼 자는 것이 아니라 **다시 태워 줄 때까지** 잡니다

마지막 것이 프레임 페이싱에서 자주 물립니다. 1밀리초를 자라고 했는데 스케줄러가 다음에 태워 주는 시점이
그보다 늦으면, 그 차이가 그대로 프레임에 실립니다.

## 정리

- 프로세스와 스레드를 가르는 것은 **페이지 테이블을 공유하느냐**입니다. 스레드가 따로 갖는 것은 스택과 레지스터뿐입니다
- 스레드끼리 전환은 레지스터를 갈아 끼우는 값입니다. **프로세스끼리 전환은 거기에 표를 바꾸고 TLB 를 버리는 값이 얹힙니다**
- 진짜 값은 갈아 끼운 다음에 옵니다. 캐시와 TLB 가 식어서 **같은 코드가 한동안 느립니다**
- 그 시간은 프로파일러에 전환 비용으로 안 잡히고 **함수 시간으로 기록됩니다**
- 코어 수를 넘는 스레드는 일을 늘립니다. 스레드는 고정해 두고 **일감을 갈아 끼우는 쪽**이 맞습니다