2026. 3. 12. 10:32ㆍ운영체제(OS)
스레드(Thread)와 멀티 스레딩
스레드가 무엇인지, 프로세스와 어떻게 다른지, 그리고 스택 메모리와 Context Switching이 스레드에 어떻게 연결되는지 정리한다.
1. 스레드란
프로세스는 크게 두 부분으로 나뉜다. 제어 흐름부(실행 주체, 명령부)와 실행 환경부(메모리, 열린 파일, 각종 자원)다. 스레드는 이 중 제어 흐름 부분만 분리한 것으로, 프로세스 실행의 기본 단위가 된다.
비유하자면 이렇다. 워드프로세서로 문서 3개를 동시에 작성할 때, 프로세스를 3개 복사해서 쓰면 메모리와 자원이 3배로 필요하다. 하지만 검정 볼펜 1개로 3개 문서를 번갈아 쓰듯, 자원은 공유하고 실행 흐름만 여러 개 두면 훨씬 효율적이다. 그 실행 흐름이 바로 스레드다.
결국 프로세스는 자원을 담는 컨테이너이고, CPU가 실제로 실행하는 단위는 항상 스레드다. 현대 OS의 CPU 스케줄러도 프로세스가 아닌 스레드 단위로 스케줄링한다.
실제 실행 흐름 1
실제 실행 흐름 2
2. 스레드와 스택 메모리
스레드는 자신만의 스택1과 레지스터, 프로그램 카운터(PC)를 독립적으로 소유한다. 스택이 스레드마다 따로 있어야 하는 이유는 간단하다. 스레드마다 실행 흐름이 다르기 때문에, 함수 호출 순서와 로컬 변수도 각자 따로 관리해야 한다.
반면 힙2, 코드 영역, 전역변수, 열린 파일 등은 같은 프로세스 내 모든 스레드가 공유한다. 이 공유 덕분에 스레드 간 데이터 전달이 빠르고, 프로세스를 여러 개 만드는 것보다 메모리 낭비가 적다.
| 메모리 영역 | 소유 주체 | 특징 |
|---|---|---|
| 스택 | 스레드 개별 소유 | 로컬 변수, 함수 호출 기록. 스레드마다 독립 |
| 힙 | 프로세스 전체 공유 | new로 동적 할당된 객체. GC 관리 대상 |
| 코드 영역 | 프로세스 전체 공유 | 실행할 명령어들 |
| 전역변수 | 프로세스 전체 공유 | static 변수 등 |
Unity에서 C# 코드로 작성하는 일반적인 게임 로직 — Start(), Update(), 로컬 변수 선언 등 —
은 전부 메인 스레드의 스택에서 동작한다.
Job System 워커 스레드에서 실행되는 Job은 해당 워커 스레드의 스택을 따로 사용하고,
new GameObject() 같은 힙 할당은 프로세스 전체가 공유하는 힙 영역에 올라간다.
// 메인 스레드의 스택에 올라가는 것들
void Update()
{
int x = 5; // 로컬 변수 → 메인 스레드 스택
Vector3 dir = Vector3.up; // 로컬 변수 → 메인 스레드 스택
}
// 힙에 올라가는 것들 (모든 스레드가 공유)
void Start()
{
var obj = new GameObject(); // 힙 할당 → GC 관리
}
3. Context Switching — 프로세스만의 이야기가 아니다
Context Switching3은 프로세스 전환뿐 아니라 스레드 전환 시에도 발생한다. 현대 OS에서 CPU 스케줄러가 실제로 교체하는 단위가 스레드이기 때문이다. CPU가 스레드 A를 실행하다가 스레드 B로 넘어갈 때, A의 스택·레지스터·PC를 저장하고 B의 것을 불러오는 과정이 곧 Context Switching이다.
그런데 전환 비용은 경우에 따라 다르다. 같은 프로세스 내 스레드끼리 전환할 때는 메모리 공간(코드, 힙, 전역변수)이 그대로이므로 스택·레지스터만 교체하면 된다. 반면 다른 프로세스로 전환할 때는 메모리 공간 전체를 교체해야 하므로 비용이 훨씬 크다. 멀티스레딩이 멀티프로세싱보다 빠른 핵심 이유가 여기에 있다.
+ 메모리 공간 전체 교체
+ TLB 플러시 등 추가 비용
메모리 공간은 그대로
교체 비용 최소
4. CPU 코어 수와 스레드 수의 관계
스레드를 몇 개 만들지는 OS가 결정하는 게 아니라 프로세스(개발자 또는 엔진 코드)가 결정한다. OS는 만들어달라는 요청을 받아 커널 스레드를 생성하고, 어떤 CPU 코어에 배분할지 스케줄링할 뿐이다. 스레드 수 자체는 메모리가 허용하는 한도 안에서 프로세스가 원하는 만큼 만들 수 있다.
다만 CPU 코어 1개는 한 순간에 스레드 1개만 실행할 수 있다. 코어 4개짜리 CPU에서 스레드가 100개라면, 어느 순간에도 실제로 실행 중인 건 4개뿐이고 나머지 96개는 준비 상태로 대기한다. OS 스케줄러가 빠르게 교체해줘서 100개 전부 돌아가는 것처럼 보일 뿐이다.
나머지 96개 스레드 → 준비 상태 대기 중
스레드를 코어 수보다 훨씬 많이 만들면, 스케줄러가 짧은 시간 안에 수십 개의 스레드를 계속 교체해야 한다.
교체할 때마다 Context Switching 비용이 발생하기 때문에 오히려 전체 처리 속도가 떨어진다.
그래서 Unity Job System이 워커 스레드를 CPU 코어 수 - 1개로 제한하는 것도 이 이유다.
메인 스레드 자리를 빼고 나머지 코어를 워커가 꽉 채울 때 Context Switching 없이 가장 효율적으로 돌아간다.
5. 사용자 수준 스레드 vs 커널 수준 스레드
스레드는 누가 관리하느냐에 따라 두 종류로 나뉜다. 핵심 차이는 단 하나, OS(커널)가 이 스레드의 존재를 아느냐 모르느냐다.
커널 수준 스레드 — OS가 직접 관리
C#의 new Thread(), Task가 대표적이다. 스레드를 생성하는 순간 OS에 시스템 콜을 날려 커널 스레드를 만들고,
OS 스케줄러가 이 스레드를 직접 추적·관리한다. 덕분에 다른 CPU 코어에 배분돼 진짜 병렬 실행이 가능하다.
하나가 I/O로 블록돼도 다른 스레드는 계속 돌아간다. 단, 생성·전환 비용이 크고 Unity API는 메인 스레드 전용이라 워커에서 직접 호출할 수 없다.
사용자 수준 스레드 — OS가 모른다
커널 비인식 스레드로, 런타임이나 라이브러리가 직접 실행 흐름을 관리한다. OS 입장에서는 스레드가 1개인 프로세스처럼 보인다. 생성·전환 비용이 매우 싸지만, 멀티코어를 활용하지 못한다는 치명적인 한계가 있다. 순수한 형태의 사용자 수준 스레드는 현대 범용 OS에서 거의 쓰이지 않는다.
다만 개념 자체가 사라진 건 아니다. Go 언어의 Goroutine이나 Java 21의 Virtual Thread처럼, 런타임이 M:N 매핑4으로 경량 실행 흐름을 직접 관리하는 방식으로 진화해 다시 주목받고 있다. 수백만 개의 동시 작업을 저비용으로 처리해야 하는 서버 환경에서 특히 유효하다.
| 커널 수준 스레드 | 사용자 수준 스레드 | |
|---|---|---|
| OS 인식 | ✅ 직접 관리 | ❌ 모름 |
| 병렬 실행 | ✅ 멀티코어 활용 | ❌ 불가 |
| 생성 비용 | 높음 (시스템 콜 필요) | 낮음 |
| I/O 블록 시 | 해당 스레드만 대기 | 프로세스 전체 블록 |
| 현대 사례 | C# Thread, Task, Job System | Goroutine, Virtual Thread |
6. 멀티프로세싱 vs 멀티스레딩
여러 작업을 동시에 처리하는 방법은 두 가지다. 멀티프로세싱5은 독립된 프로세스를 여러 개 띄우는 방식이고, 멀티스레딩은 하나의 프로세스 안에서 스레드를 여러 개 운용하는 방식이다. 둘 다 CPU를 병렬로 활용한다는 목적은 같지만, 자원을 공유하느냐 격리하느냐에서 근본적으로 다르다.
멀티프로세싱은 각 프로세스가 독립된 메모리 공간을 갖기 때문에 하나가 죽어도 나머지에 영향이 없다. 반면 프로세스끼리 데이터를 주고받으려면 IPC6 같은 별도 통신 수단이 필요하고, 프로세스 전환 시 Context Switching 비용도 크다. 멀티스레딩은 같은 프로세스의 메모리를 공유하기 때문에 데이터 공유가 쉽고 Context Switching 비용도 작다. 단, 하나의 스레드가 잘못된 메모리를 건드리면 같은 프로세스의 다른 스레드도 영향을 받는다.
프로세스 B → 독립 메모리
프로세스 C → 독립 메모리
✅ 하나 죽어도 나머지 안전
❌ Context Switching 비용 큼
❌ 데이터 공유 어려움 (IPC 필요)
├─ Thread #1
├─ Thread #2 → 힙·코드 공유
└─ Thread #3
✅ Context Switching 비용 작음
✅ 데이터 공유 쉬움
❌ 한 스레드 오류 → 프로세스 전체 위험
Race Condition — 공유의 대가
멀티스레딩에서 여러 스레드가 같은 데이터를 동시에 읽고 쓰면 의도치 않은 결과가 나온다. 이를 Race Condition(경쟁 조건)7이라 한다. 어떤 스레드가 먼저 실행될지 OS 스케줄러가 결정하기 때문에 순서를 보장할 수 없어서 생기는 문제다. 이를 해결하려면 뮤텍스(Mutex), 세마포어(Semaphore) 같은 동기화 기법이 필요하다.
Unity Job System은 이 문제를 컴파일 타임에 차단한다.
[ReadOnly] / [WriteOnly] 어트리뷰트를 통해 여러 Job이 같은 데이터를 동시에 쓰려 하면 에러를 낸다.
덕분에 개발자가 동기화 코드를 직접 짜지 않아도 안전하게 병렬 처리를 쓸 수 있다.
// Job System — 컴파일 타임에 Race Condition 차단
struct MyJob : IJob
{
[ReadOnly] public NativeArray<float> input; // 읽기만 허용
[WriteOnly] public NativeArray<float> output; // 쓰기만 허용
public void Execute()
{
// 같은 배열에 동시에 읽기+쓰기 시도하면 에러
output[0] = input[0] * 2f;
}
}
| 멀티프로세싱 | 멀티스레딩 | |
|---|---|---|
| 메모리 | 프로세스마다 독립 | 프로세스 내 공유 |
| Context Switching | 비용 큼 | 비용 작음 |
| 데이터 공유 | IPC 필요 (느림) | 메모리 직접 공유 (빠름) |
| 안정성 | 하나 죽어도 나머지 안전 | 한 스레드 오류 → 전체 위험 |
| Race Condition | 없음 (메모리 독립) | 발생 가능 → 동기화 필요 |
| Unity 사례 | 게임 + 별도 서버 프로세스 | 메인 스레드 + Job System |
📎 용어 설명
- 스택(Stack) — 함수 호출 시 로컬 변수, 매개변수, 반환 주소를 저장하는 메모리 영역. LIFO(후입선출) 구조로 관리되며, 스레드마다 독립적으로 할당된다.
- 힙(Heap) —
new키워드로 동적 할당되는 메모리 영역. 프로세스 내 모든 스레드가 공유하며, C#에서는 GC(가비지 컬렉터)가 관리한다. - Context Switching(문맥 교환) — CPU가 현재 실행 중인 스레드/프로세스를 교체할 때, 현재 상태(스택 포인터, 레지스터, PC 등)를 저장하고 다음 것의 상태를 복원하는 과정. 이 시간 동안 CPU는 유용한 작업을 하지 않으므로 순수한 오버헤드다.
- M:N 매핑 — M개의 사용자 수준 실행 흐름을 N개의 커널 스레드에 대응시키는 방식. M > N이므로 커널 스레드 수를 줄이면서도 많은 동시 작업을 처리할 수 있다.
- 멀티프로세싱(Multi-Processing) — 독립된 프로세스를 여러 개 실행하는 방식. 각 프로세스는 별도의 메모리 공간을 가지므로 서로 격리되어 있다.
- IPC(Inter-Process Communication) — 프로세스 간 통신. 서로 다른 프로세스는 메모리를 공유하지 않으므로, 파이프·소켓·공유 메모리 등의 별도 수단으로 데이터를 주고받아야 한다.
- Race Condition(경쟁 조건) — 여러 스레드가 공유 데이터에 동시에 접근·수정할 때, 실행 순서에 따라 결과가 달라지는 문제. 동기화 기법(Mutex, Semaphore 등)으로 해결한다.
'운영체제(OS)' 카테고리의 다른 글
| [OS] 병행 프로세스, 임계구역, 상호배제 - 8 (0) | 2026.03.17 |
|---|---|
| [OS] CPU 스케줄링 - 7 (0) | 2026.03.12 |
| [OS] 프로세스 개요와 PCB, 상태 전이 - 5 (0) | 2026.03.11 |
| [OS] CPU 구조와 파이프라인 - 4 (0) | 2026.03.11 |
| [OS]운영체제 발전 흐름 - 3 (0) | 2026.03.11 |