Skip to content

운영체제 — 프로세스(Process) vs 스레드(Thread) / 동시성(Concurrency)

멀티스레딩은 게임 엔진 개발에서 피할 수 없는 주제입니다. Unreal Engine 5는 게임 스레드, 렌더 스레드, RHI 스레드, 물리 스레드 등 여러 스레드를 조율하며 동작합니다. 이 구조를 제대로 이해하려면 운영체제가 제공하는 프로세스·스레드 추상화, 그리고 동시성 프로그래밍의 핵심 문제를 먼저 파악해야 합니다.


프로세스는 실행 중인 프로그램의 인스턴스입니다. OS는 각 프로세스에 독립된 가상 주소 공간을 부여하여, 다른 프로세스의 메모리에 직접 접근하지 못하도록 격리합니다.

높은 주소
┌────────────────────┐
│ 커널 영역 │ OS 전용
├────────────────────┤
│ 스택(Stack) │ 지역 변수, 함수 호출 프레임, 위→아래 성장
├────────────────────┤
│ ↓ ↑ │ (스택·힙 사이 여유 공간)
├────────────────────┤
│ 힙(Heap) │ 동적 할당(new/malloc), 아래→위 성장
├────────────────────┤
│ BSS 세그먼트 │ 초기화되지 않은 전역·정적 변수
├────────────────────┤
│ 데이터 세그먼트 │ 초기화된 전역·정적 변수
├────────────────────┤
│ 코드(Text) 세그먼트 │ 실행 가능 명령어, 읽기 전용
└────────────────────┘
낮은 주소

각 세그먼트의 역할:

영역내용특징
코드(Text)컴파일된 명령어읽기 전용, 공유 가능
데이터초기화된 전역·정적 변수프로세스 수명 내내 존재
BSS초기화 안 된 전역·정적 변수OS가 0으로 초기화
new / malloc 으로 할당직접 수명 관리
스택지역 변수, 리턴 주소, 매개변수함수 호출 시 자동 관리

OS는 각 프로세스를 PCB(프로세스 제어 블록) 구조로 관리합니다.

  • 프로세스 ID (PID)
  • 프로세스 상태 (Ready / Running / Blocked / Terminated)
  • 프로그램 카운터(PC), 레지스터 값
  • 메모리 관리 정보 (페이지 테이블 포인터)
  • 열린 파일 디스크립터 목록
  • 스케줄링 우선순위

스레드는 프로세스 내의 독립적인 실행 흐름입니다. 같은 프로세스의 스레드들은 코드·데이터·힙 영역을 공유하지만, 각자 독립된 스택과 레지스터 세트를 가집니다.

프로세스 주소 공간
┌─────────────────────────────────────┐
│ 공유 영역 │
│ 코드 세그먼트 / 데이터 / 힙 │
├──────────────┬──────────────────────┤
│ 스레드 1 │ 스레드 2 │
│ (스택) │ (스택) │
│ (레지스터) │ (레지스터) │
│ (PC) │ (PC) │
└──────────────┴──────────────────────┘
항목프로세스스레드
메모리 공간독립 (격리)공유 (같은 프로세스)
생성 비용높음 (주소 공간 복사)낮음 (스택만 추가)
통신 방법IPC (파이프, 소켓, 공유 메모리)공유 변수 직접 접근
충돌 격리강함 (프로세스 하나 죽어도 나머지 생존)약함 (한 스레드 crash → 전체 프로세스 종료)
컨텍스트 스위칭 비용높음 (페이지 테이블 교체)낮음 (레지스터·스택 저장만)

OS는 각 스레드를 TCB로 관리합니다.

  • 스레드 ID (TID)
  • 스레드 상태
  • 프로그램 카운터 / 레지스터 스냅샷
  • 스택 포인터
  • 소속 프로세스 PCB 포인터

3. 컨텍스트 스위칭(Context Switching)

Section titled “3. 컨텍스트 스위칭(Context Switching)”

컨텍스트 스위칭은 CPU가 현재 실행 중인 스레드(또는 프로세스)를 바꿀 때 발생하는 작업입니다.

스레드 A 실행 중
▼ [인터럽트 / 타임슬라이스 만료 / I/O 대기]
OS 스케줄러 개입
├─ 스레드 A의 레지스터·PC·스택 포인터를 TCB A에 저장
├─ TCB B에서 스레드 B의 상태 복원
스레드 B 실행 시작

컨텍스트 스위칭 자체의 오버헤드:

  • 레지스터 저장/복원 (수십~수백 ns)
  • CPU 캐시 오염(Cache Pollution): 새 스레드가 다른 데이터를 사용하면 L1/L2 캐시가 무효화됨
  • TLB(Translation Lookaside Buffer) 플러시 — 프로세스 스위칭 시 특히 심각

게임 개발 관점에서 스레드를 너무 많이 만들면 스위칭 비용이 렌더링 이득을 상쇄합니다. UE5가 스레드 풀(FQueuedThreadPool)과 TaskGraph를 사용하는 이유가 여기에 있습니다.


4. 동시성(Concurrency) vs 병렬성(Parallelism)

Section titled “4. 동시성(Concurrency) vs 병렬성(Parallelism)”

이 두 개념은 혼용되지만 의미가 다릅니다.

여러 작업이 겹쳐서 진행되는 것처럼 보이는 구조 — 물리적으로 동시에 실행되지 않아도 됩니다. 단일 코어에서도 컨텍스트 스위칭으로 구현할 수 있습니다.

코어 1개
────────────────────────────────────────▶ 시간
[Task A] [Task B] [Task A] [Task B] [Task A]
← 컨텍스트 스위칭 →

여러 작업이 물리적으로 동시에 실행됩니다. 다중 코어가 필요합니다.

코어 1 ──[Task A]──[Task A]──[Task A]──▶ 시간
코어 2 ──[Task B]──[Task B]──[Task B]──▶ 시간
구분설명예시
동시성구조적 문제 해결 방식I/O 대기 중 다른 작업 진행
병렬성실제 동시 실행멀티코어 물리 연산

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. — Rob Pike (Go 언어 설계자)

UE5는 이 두 가지를 모두 활용합니다. 게임 스레드는 논리적 동시성(I/O, 이벤트 처리)을, ParallelFor와 TaskGraph는 물리적 병렬성을 활용합니다.


5. 경쟁 조건, 데드락, 뮤텍스/세마포어

Section titled “5. 경쟁 조건, 데드락, 뮤텍스/세마포어”

5.1 데이터 경쟁(Data Race)과 경쟁 조건(Race Condition)

Section titled “5.1 데이터 경쟁(Data Race)과 경쟁 조건(Race Condition)”

데이터 경쟁: 두 스레드가 같은 메모리 위치에 동시에 접근하고, 최소 하나가 쓰기일 때 발생합니다. 미정의 동작(Undefined Behavior)을 유발합니다.

// 잘못된 예시 — 데이터 경쟁 발생
int Counter = 0;
void Worker()
{
for (int i = 0; i < 100000; ++i)
{
++Counter; // 읽기 + 증가 + 쓰기 — 원자적이지 않음!
}
}
// 두 스레드가 동시에 실행하면 Counter < 200000

경쟁 조건: 실행 순서에 따라 결과가 달라지는 논리적 버그입니다. 데이터 경쟁 없이도 발생할 수 있습니다.

// 경쟁 조건 예시 — 은행 계좌
// 스레드 A: if (Balance >= Amount) { Balance -= Amount; }
// 스레드 B: if (Balance >= Amount) { Balance -= Amount; }
// 둘 다 잔액 확인 후 차감하면 잔액이 음수가 될 수 있음

Mutual Exclusion — 한 번에 하나의 스레드만 임계 구역(Critical Section)에 진입하도록 보장합니다.

#include <mutex>
std::mutex CounterMutex;
int Counter = 0;
void SafeWorker()
{
for (int i = 0; i < 100000; ++i)
{
std::lock_guard<std::mutex> Lock(CounterMutex); // RAII 락
++Counter; // 이제 안전
} // 스코프 종료 시 자동 unlock
}

뮤텍스는 ‘1개’의 자원을 보호하는 반면, 세마포어는 N개의 자원 접근을 제어합니다. C++20부터 std::counting_semaphore / std::binary_semaphore가 표준에 포함되었습니다.

#include <semaphore>
// 동시에 최대 3개의 스레드가 접근 가능
std::counting_semaphore<3> Semaphore(3);
void Worker()
{
Semaphore.acquire(); // 카운트 감소 (0이면 대기)
// ... 임계 구역 ...
Semaphore.release(); // 카운트 증가
}
특성뮤텍스이진 세마포어계수 세마포어
동시 접근 허용 수11N
소유권있음 (잠근 스레드만 해제)없음없음
용도임계 구역 보호신호 메커니즘자원 풀 제어

두 개 이상의 스레드가 서로가 보유한 자원을 기다리며 영원히 멈추는 상태입니다.

스레드 A: MutexX 보유, MutexY 대기 중
스레드 B: MutexY 보유, MutexX 대기 중
→ 영원히 기다림 (데드락)

데드락의 4가지 필요 조건 (Coffman Conditions):

  1. 상호 배제 (Mutual Exclusion)
  2. 점유 후 대기 (Hold and Wait)
  3. 비선점 (No Preemption)
  4. 순환 대기 (Circular Wait)

해결: 락 순서 고정 또는 std::scoped_lock 사용

std::mutex MutexX, MutexY;
// 잘못된 방법 — 데드락 위험
void ThreadA() { MutexX.lock(); MutexY.lock(); /* ... */ }
void ThreadB() { MutexY.lock(); MutexX.lock(); /* ... */ }
// 올바른 방법 1 — 항상 같은 순서로 락
void ThreadA() { MutexX.lock(); MutexY.lock(); /* ... */ }
void ThreadB() { MutexX.lock(); MutexY.lock(); /* ... */ } // 같은 순서
// 올바른 방법 2 — std::scoped_lock (C++17)
void SafeSwap()
{
std::scoped_lock Lock(MutexX, MutexY); // 교착 없이 둘 다 락
// ...
}

#include <thread>
#include <iostream>
// 함수 실행
void ComputeTask(int TaskId)
{
std::cout << "Task " << TaskId << " 실행 중 (스레드 ID: "
<< std::this_thread::get_id() << ")\n";
}
int main()
{
std::thread T1(ComputeTask, 1);
std::thread T2(ComputeTask, 2);
T1.join(); // T1 완료까지 대기
T2.join(); // T2 완료까지 대기
return 0;
}
#include <mutex>
#include <vector>
#include <thread>
std::mutex LogMutex;
std::vector<std::string> LogBuffer;
void AppendLog(const std::string& Message)
{
std::lock_guard<std::mutex> Lock(LogMutex); // 획득
LogBuffer.push_back(Message);
} // 스코프 종료 → 자동 해제
void WorkerThread(int Id)
{
for (int i = 0; i < 10; ++i)
{
AppendLog("Worker " + std::to_string(Id) + " msg " + std::to_string(i));
}
}

뮤텍스 없이 단순 타입을 스레드 안전하게 조작합니다. 락이 없으므로 더 빠릅니다.

#include <atomic>
std::atomic<int> ActiveWorkerCount{0};
std::atomic<bool> bShutdownRequested{false};
void WorkerThread()
{
++ActiveWorkerCount; // 원자적 증가
while (!bShutdownRequested.load(std::memory_order_acquire))
{
// 작업 처리...
}
--ActiveWorkerCount; // 원자적 감소
}
void RequestShutdown()
{
bShutdownRequested.store(true, std::memory_order_release);
}
#include <future>
#include <vector>
#include <numeric>
// 무거운 계산 — 별도 스레드에서 실행
long long HeavySum(std::vector<int>& Data, size_t Start, size_t End)
{
return std::accumulate(Data.begin() + Start, Data.begin() + End, 0LL);
}
int main()
{
std::vector<int> Data(1000000, 1);
size_t Half = Data.size() / 2;
// 두 구간을 별도 스레드에서 병렬 합산
auto Future1 = std::async(std::launch::async, HeavySum,
std::ref(Data), 0, Half);
auto Future2 = std::async(std::launch::async, HeavySum,
std::ref(Data), Half, Data.size());
// 결과 수집 (각 future가 완료될 때까지 대기)
long long Total = Future1.get() + Future2.get();
// Total == 1000000
return 0;
}

UE5는 C++ 표준 스레드 대신 엔진 전용 추상화를 제공합니다. 이 API들은 엔진의 스레드 풀, 플랫폼 추상화 레이어, GC와 연동되어 있으므로 가능하면 이쪽을 사용해야 합니다.

FRunnable전용 OS 스레드를 직접 생성하는 방식입니다. 게임 내내 살아있어야 하는 워커 스레드(소켓 리스너, 파일 I/O 워커 등)에 적합합니다.

MyWorkerRunnable.h
#pragma once
#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"
class FMyWorkerRunnable : public FRunnable
{
public:
FMyWorkerRunnable();
virtual ~FMyWorkerRunnable();
// FRunnable 인터페이스
virtual bool Init() override; // 스레드 시작 전 초기화
virtual uint32 Run() override; // 메인 실행 루프
virtual void Stop() override; // 외부에서 중단 요청
virtual void Exit() override; // Run() 반환 후 정리
void StartThread();
void StopThread();
private:
FRunnableThread* Thread = nullptr;
TAtomic<bool> bShouldStop{false};
};
MyWorkerRunnable.cpp
#include "MyWorkerRunnable.h"
FMyWorkerRunnable::FMyWorkerRunnable() {}
FMyWorkerRunnable::~FMyWorkerRunnable()
{
StopThread();
}
bool FMyWorkerRunnable::Init()
{
UE_LOG(LogTemp, Log, TEXT("워커 스레드 초기화"));
return true; // false 반환 시 Run() 호출 안 됨
}
uint32 FMyWorkerRunnable::Run()
{
while (!bShouldStop.Load())
{
// 주기적 작업 수행
FPlatformProcess::Sleep(0.016f); // ~60fps 간격
}
return 0;
}
void FMyWorkerRunnable::Stop()
{
bShouldStop.Store(true);
}
void FMyWorkerRunnable::Exit()
{
UE_LOG(LogTemp, Log, TEXT("워커 스레드 종료"));
}
void FMyWorkerRunnable::StartThread()
{
Thread = FRunnableThread::Create(
this,
TEXT("MyWorkerThread"), // 스레드 이름 (디버거에 표시)
0, // 스택 크기 (0 = 기본값)
TPri_Normal // 우선순위
);
}
void FMyWorkerRunnable::StopThread()
{
if (Thread)
{
Stop();
Thread->WaitForCompletion();
delete Thread;
Thread = nullptr;
}
}

AsyncTask전역 스레드 풀(FQueuedThreadPool) 에 작업을 제출합니다. 짧은 일회성 백그라운드 작업에 적합합니다. 스레드를 직접 생성하지 않으므로 오버헤드가 낮습니다.

// 백그라운드 스레드에서 실행
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, []()
{
// 무거운 데이터 처리 (게임 스레드 차단 없음)
TArray<FVector> Results = ComputeHeavyGeometry();
// 완료 후 게임 스레드로 결과 전달
AsyncTask(ENamedThreads::GameThread, [Results = MoveTemp(Results)]()
{
// 게임 스레드에서만 안전한 UObject 접근
if (AMyActor* Actor = GetMyActor())
{
Actor->ApplyGeometryResults(Results);
}
});
});

주의: UObject에 접근하는 코드는 반드시 게임 스레드에서 실행해야 합니다. GC가 게임 스레드 기준으로 동작하기 때문에, 다른 스레드에서 UObject 포인터를 역참조하면 GC와의 경쟁 조건이 발생합니다.

7.3 ParallelFor — 데이터 병렬 처리

Section titled “7.3 ParallelFor — 데이터 병렬 처리”

배열의 각 원소를 독립적으로 처리할 때 사용합니다. 내부적으로 TaskGraph를 이용해 코어 수에 맞게 작업을 분배합니다.

TArray<FHitResult> HitResults;
HitResults.SetNum(1000);
// 1000개의 레이캐스트를 병렬로 처리
ParallelFor(HitResults.Num(), [&](int32 Index)
{
FVector Start = GetRayStart(Index);
FVector End = GetRayEnd(Index);
// 물리 쿼리 — 스레드 안전 API 사용
GetWorld()->LineTraceSingleByChannel(
HitResults[Index], Start, End, ECC_Visibility
);
});
// ParallelFor 반환 시 모든 작업 완료 보장

ParallelFor 사용 규칙:

  • 각 반복이 서로 독립적이어야 합니다 (다른 인덱스의 데이터 읽기/쓰기 금지).
  • UObject 생성/삭제는 게임 스레드에서만 수행합니다.
  • GetWorld() 같은 컨텍스트 접근은 캡처 시점에 유효한지 확인합니다.

7.4 TaskGraph — 의존성 기반 태스크 시스템

Section titled “7.4 TaskGraph — 의존성 기반 태스크 시스템”

TaskGraph는 의존성(Dependency) 이 있는 작업들을 그래프로 표현하고 자동으로 스케줄링합니다. UE5 렌더링 파이프라인이 내부적으로 사용하는 핵심 시스템입니다.

// 간단한 TaskGraph 태스크 정의
class FMyComputeTask
{
public:
// 태스크 이름 (프로파일러에 표시)
static const TCHAR* GetTaskName() { return TEXT("MyComputeTask"); }
FORCEINLINE static TStatId GetStatId()
{
RETURN_QUICK_DECLARE_CYCLE_STAT(FMyComputeTask, STATGROUP_TaskGraphTasks);
}
// 어느 스레드에서 실행할지 지정
static ENamedThreads::Type GetDesiredThread()
{
return ENamedThreads::AnyThread;
}
static ESubsequentsMode::Type GetSubsequentsMode()
{
return ESubsequentsMode::TrackSubsequents;
}
void DoTask(ENamedThreads::Type CurrentThread,
const FGraphEventRef& MyCompletionGraphEvent)
{
// 실제 작업 수행
ProcessData();
}
private:
void ProcessData() { /* ... */ }
};
// 태스크 실행
FGraphEventRef TaskEvent = TGraphTask<FMyComputeTask>::CreateTask(
nullptr, // 선행 조건 (없음)
ENamedThreads::GameThread // 현재 스레드
).ConstructAndDispatchWhenReady();
// 완료 대기 (게임 스레드에서 직접 대기 시 데드락 주의)
FTaskGraphInterface::Get().WaitUntilTaskCompletes(TaskEvent);

8. 게임 루프의 멀티스레딩 패턴

Section titled “8. 게임 루프의 멀티스레딩 패턴”
[게임 스레드 (Game Thread)]
│ Tick(), BeginPlay(), 게임 로직, AI, 입력 처리
│ UObject 생성/삭제, GC 수행
├─▶ [렌더 스레드 (Render Thread)]
│ FScene, FPrimitiveSceneProxy, DrawCalls 생성
│ 게임 스레드보다 1~2프레임 뒤처져 실행
├─▶ [RHI 스레드 (Rendering Hardware Interface)]
│ GPU 명령 제출 (DirectX/Vulkan/Metal)
│ 렌더 스레드로부터 명령 큐를 받아 처리
├─▶ [물리 스레드 (Physics Thread — Chaos)]
│ 물리 시뮬레이션 (Chaos Physics)
│ 게임 스레드와 결과를 동기화
└─▶ [오디오 스레드 (Audio Thread)]
사운드 믹싱, DSP 처리

8.2 게임 스레드와 렌더 스레드 동기화

Section titled “8.2 게임 스레드와 렌더 스레드 동기화”

UE5는 게임 스레드와 렌더 스레드 간에 프록시(Proxy) 패턴을 사용합니다.

게임 스레드 렌더 스레드
───────────── ─────────────
UStaticMeshComponent FStaticMeshSceneProxy
USkeletalMeshComponent ──▶ FSkeletalMeshSceneProxy
ULightComponent FLightSceneProxy
│ │
게임 로직 데이터 렌더링 전용 데이터 사본
(GC 관리) (게임 스레드와 격리)

게임 스레드의 컴포넌트 데이터가 변경되면, 렌더 스레드에 커맨드를 전달하는 방식으로 동기화합니다.

// 게임 스레드에서 렌더 스레드로 데이터 전달
ENQUEUE_RENDER_COMMAND(UpdateMeshTransformCommand)(
[NewTransform = GetComponentTransform()](FRHICommandListImmediate& RHICmdList)
{
// 이 람다는 렌더 스레드에서 실행됨
// NewTransform은 복사되어 안전하게 전달
UpdateProxyTransform(NewTransform);
}
);
프레임 N 프레임 N+1 프레임 N+2
───────────── ───────────── ─────────────
게임 스레드: [Tick N] [Tick N+1]
렌더 스레드: [Render N] [Render N+1]
RHI 스레드: [GPU N] [GPU N+1]

게임 스레드가 N 프레임을 완료하면, 렌더 스레드는 N 프레임의 씬 데이터를 드로우콜로 변환하고, RHI 스레드는 이를 GPU에 제출합니다. 이 파이프라이닝이 UE5의 성능 기반입니다.

// ✓ 게임 스레드에서만: UObject 생성/삭제, Tick, RPC
// ✓ 렌더 스레드에서만: FSceneProxy 수정, 드로우콜 제출
// ✓ 어떤 스레드에서든: FRunnable::Run(), AsyncTask 콜백, ParallelFor 바디
// 스레드 확인 어서션 (디버그 빌드에서 유용)
check(IsInGameThread()); // 게임 스레드 보장
check(IsInRenderingThread()); // 렌더 스레드 보장
check(IsInParallelRenderingThread()); // 렌더링 병렬 작업

개념핵심 포인트
프로세스독립 주소 공간, 강한 격리, 무거운 생성 비용
스레드공유 주소 공간, 가벼운 생성 비용, 동기화 필요
컨텍스트 스위칭레지스터/PC 저장·복원, 캐시 오염 비용
동시성구조적 개념 — 단일 코어에서도 가능
병렬성물리적 동시 실행 — 다중 코어 필요
데이터 경쟁미정의 동작 유발, 뮤텍스 또는 atomic으로 해결
데드락순환 대기, scoped_lock 또는 락 순서 고정으로 예방
FRunnable장기 실행 전용 스레드
AsyncTask일회성 백그라운드 작업
ParallelFor독립 반복 병렬 처리
TaskGraph의존성 기반 작업 스케줄링
  1. UObject는 게임 스레드에서만 생성·삭제·접근합니다.
  2. 렌더 리소스 수정은 렌더 스레드에서만ENQUEUE_RENDER_COMMAND 사용.
  3. ParallelFor 바디는 side-effect 없이 — 다른 인덱스의 데이터를 수정하지 않습니다.
  4. 스레드 안전 확인이 필요하면 IsInGameThread() 어서션을 추가합니다.
  5. 직접 std::thread 생성보다 FRunnable / AsyncTask를 우선 사용합니다 — 엔진 스레드 풀과 통합되고 플랫폼 추상화가 적용됩니다.