CPU와 프로세스/스레드

mingu Lee·2025년 11월 20일

CS

목록 보기
4/21

1. CPU


1-1. CPU의 핵심 구성 요소


CPU에는 3가지 핵심 부품이 존재한다.

1. 컨트롤 유닛 (Control Unit, CU)

다음 작업 지시(명령어)를 가져오고(Fetch), 그게 무슨 뜻인지 해석(Decode)한다.

그리고 그 작업을 위해 어떤 기계를 돌려야 할지 지시하고 조율한다.

2. 산술/논리 연산 장치 (Arithmetic Logic Unit, ALU)

CU의 지시를 받아 실제 작업을 수행한다.

3. 레지스터 (Register)

CPU가 지금 당장 사용하는 데이터를 임시로 보관하는, CPU 칩 내부에 존재하는 가장 빠른 저장 공간이다.

단일 작업 지시, 혹은 함수 실행 중 아주 짧은 순간 동안만 유지된다.

1-2. CPU와 캐시(Cache)


CPU가 작업을 하기 위해 RAM까지 가서 레지스터로 가져오는 것은 굉장히 시간이 많이 걸리는 작업이다.

따라서 레지스터와 RAM 사이에 L1, L2, L3 '캐시'라는 저장 공간이 존재한다.

이름비유속도 (접근 시간)크기
레지스터작업자 손 (작업대)즉시 (0.2 ns)극도로 작음 (KB)
L1 캐시작은 공구함 (개인)매우 빠름 (1 ns)매우 작음 (e.g., 128KB)
L2 캐시중간 부품 선반 (개인)빠름 (4 ns)작음 (e.g., 1MB)
L3 캐시공장 임시 창고 (공유)조금 느림 (15 ns)중간 (e.g., 32MB)
RAM (메인)대형 부품 창고 (외부)매우 느림 (100 ns)매우 큼 (GB)

CPU는 두 가지 '경험 법칙'에 의존해 캐시를 채워둔다.

시간 지역성 (Temporal Locality)


방금 사용한 데이터는 곧 다시 사용할 확률이 매우 높다

데이터가 한 번 사용되면(RAM -> 캐시로 로드), CPU는 이 데이터가 '쓸모있다'고 판단하여 캐시에서 이 데이터를 버리지 않고 최대한 오래 보관하려고 한다.

예를 들어 다음 같은 코드가 있다고 가정해보자.

int TotalGold = 0; 
TArray<FMonsterData> Monsters; // (1000개짜리 배열)

for (int i = 0; i < 1000; i++)
{
    TotalGold += Monsters[i].Gold; 
}

만약, 시간 지역성이 없을 경우

  1. i=0, TotalGold를 RAM에서 읽어옴 -> 더함 -> TotalGold를 RAM에 씀(RAM 왕복)
  2. i=1, TotalGold를 RAM에서 읽어옴 -> 더함 -> TotalGold를 RAM에 씀(RAM 왕복)
  3. ...

즉, RAM을 2000번이나 왕복해야한다.

하지만, 시간 지역성이 있을 경우

  1. i=0, TotalGold를 RAM에서 읽어와 캐시로 가져옴 -> 더함 -> 결과를 다시 캐시에 씀
  2. i=1, 캐시를 확인하여 TotalGold를 확인(매우 빠름) -> 더함 -> 결과를 다시 캐시에 씀
  3. i=2 ~ i=999, 모든 접근이 캐시 히트

즉, RAM 접근은 1번으로 줄고, 나머지 접근은 RAM보다 빠른 L1/L2 캐시에서 처리된다.

공간 지역성 (Spatial Locality)


방금 사용한 데이터의 바로 옆에 있던 데이터들도 곧 사용할 확률이 매우 높다

RAM에 데이터를 가지러 갔을 때, 메모리 컨트롤러는 원하는 데이터 딱 하나만 주지 않는다.

CPU가 int 하나를 요청해도, RAM은 그 데이터가 포함된 64바이트 한 상자(Cache Line)를 통째로 캐시에 쏘아준다.

공간 지역성TArray 같은 연속 메모리 구조가 왜 그렇게 빠른지, 포인터 배열이 왜 그렇게 느린지를 증명한다.

다음과 같은 코드가 있다고 가정해보자.

TArray<int> MyScores; // (1000개 가정)

void CalculateTotalScore()
{
    int Total = 0;
    for (int i = 0; i < 1000; i++)
    {
        Total += MyScores[i];
    }
}

루프가 실행될 때 캐시의 움직임은 다음과 같다.

1. i=0

  • CPU: "MyScores[0] 필요해" -> 캐시 미스
  • CPU가 RAM(창고)에 MyScores[0]의 주소를 요청
  • RAM: MyScores[0]이 포함된 64바이트 '한 상자'를 보냄
  • 캐시에 MyScores[0]부터 MyScores[15]까지 (총 16개의 int)가 한꺼번에 로드

2. i = 1:

  • CPU: "MyScores[1] 필요해"
  • 캐시 확인 -> 캐시 히트 (MyScores[0] 가져올 때 같이 가져옴)

3. i = 2 ~ i = 15:

  • CPU: "MyScores[2]...[15]" 모두 캐시 히트

4. i = 16:

  • CPU: "MyScores[16] 필요해" -> 캐시 미스 (첫 번째 상자에는 없음)
  • RAM: "MyScores[16]이 포함된 두 번째 64바이트 상자 보냄"
  • 캐시에 MyScores[16]부터 MyScores[31]까지가 로드

5. i = 17 ~ i = 31:

  • 전부 캐시 히트

MyScores에 1000번 접근했지만, 실제 RAM 접근은 1000 / 16번 밖에 발생하지 않는다.

캐시 지옥 TArray<AActor*> 순회


AActor 객체들은 힙(Heap) 영역에 new(언리얼에서는 SpawnActor)를 호출할 때마다 여기저기 흩어져 생성된다.

다음 예시를 보면 왜 포인터 배열이 느린지 알 수 있다.

// 1. 1000명의 적(Enemy)이 Heap 영역 곳곳에 흩어져 스폰됐다고 가정
// (데이터 위치: 0x1000A000, 0x3000C000, 0x5000F000, ...)
TArray<AEnemyCharacter*> EnemyActors; // '포인터'(데이터 위치 주소)의 배열

void UpdateAllEnemies_Bad(float DeltaTime)
{
    for (AEnemyCharacter* Enemy : EnemyActors)
    {
        // 'Enemy' 포인터(주소) 자체를 읽는 것은 빠름. (TArray는 연속적이므로)
        // 하지만 그 주소가 가리키는 데이터에 접근할 때
        
        // 1. Enemy 1 (0x1000A000)의 Health에 접근 -> 캐시 미스
        //    (RAM에서 0x1000A000 근처 64바이트(한 상자)를 가져옴)
        
        // 2. Enemy 2 (0x3000C000)의 Health에 접근 -> 캐시 미스
        //    (아까 L1에 가져온 64바이트는 0x1000A000 근처 데이터라 아무 쓸모가 없음)
        
        // 3. Enemy 3 (0x5000F000)의 Health에 접근 -> 캐시 미스
        
        // ... (최악의 경우 캐시 미스 1000번 발생)
        if (Enemy->Health < 0) { }
    }
}

TArray<int>와 비교했을 때 EnemyActor 순회는 1000 / 16번이 아니라 1000번의 캐시 미스를 유발할 수도 있다.

하지만 AActor는 언리얼 엔진에서 사용하지 않을 수가 없다.

다형성을 사용하기 위해서는 반드시 포인터로 다뤄야만 하기 때문이다.

따라서 공간 지역성AActor처럼 월드에 존재하는 유일한 개체가 아닌, 순수한 데이터 묶음을 다룰 때 의미가 있다.

예를 들어 게임에 아이템 데이터가 필요하고, 이 아이템은 스탯 버프 3개와 이름을 가진다고 가정해보자.

1. 모든 걸 UObject로 (유연하지만, 캐시 지옥)

UStatBuffUObject로, UItemDataUObject로 만든다.

UCLASS()
class UStatBuff : public UObject { /* ... */ };

UCLASS()
class UItemData : public UObject
{
public:
    UPROPERTY() FString Name;

    UPROPERTY() TObjectPtr<UStatBuff> Buff1; // 힙 어딘가에 생성 (e.g., 0x1000)
    UPROPERTY() TObjectPtr<UStatBuff> Buff2; // 힙 어딘가에 생성 (e.g., 0x3000)
    UPROPERTY() TObjectPtr<UStatBuff> Buff3; // 힙 어딘가에 생성 (e.g., 0x5000)
};

UPROPERTY()
TArray<TObjectPtr<UItemData>> Inventory;

위의 경우

Inventory[i]에 접근 -> 캐시 미스 -> Inventory[i]->Buff1에 접근 -> 캐시 미스 -> ...->Buff2 -> 또 캐시 미스

같은 형태로 작동할 것이다.

장점으로는 UStatBuff를 상속받는 다형성 구현이 쉽고, GC가 알아서 관리해준다.

단점으로는 데이터가 메모리에 흩어져서 캐시 효율이 0에 수렴한다.

2. 데이터 덩어리는 UStruct로 (캐시 천국)

FStatBuffFItemDataUSTRUCT로 만든다.

USTRUCT(BlueprintType)
struct FStatBuff { /* ... */ };

USTRUCT(BlueprintType)
struct FItemData
{
    GENERATED_BODY()
public:
    UPROPERTY() FString Name;

    UPROPERTY() FStatBuff Buff1; // FItemData 메모리 안에 '포함됨'
    UPROPERTY() FStatBuff Buff2; // FItemData 메모리 안에 '포함됨'
    UPROPERTY() FStatBuff Buff3; // FItemData 메모리 안에 '포함됨'
};

UPROPERTY()
TArray<FItemData> Inventory;

위의 경우

Inventory[i]에 접근 -> 캐시 미스

하지만 FItemData 구조체(Name, Buff1, Buff2, Buff3)가 '한 덩어리'이므로, 캐시 라인(64바이트 상자)에 통째로 같이 딸려온다.

즉, Inventory[i]->Buff1에 접근 -> 캐시 히트 -> ...->Buff2 -> 캐시 히트

같은 형태로 작동할 것이다.

장점으로는 완벽한 공간 지역성을 가지기에 많은 아이템을 순회해도 빠르게 작동한다.

단점으로는 FStatBuff는 상속(다형성)이 불가능하기에 유연성이 떨어진다.

결론

AActor처럼 월드에 존재하는 유일한 개체나 다형성이 필요하면 포인터 배열을 사용해야 한다.

하지만 아이템 데이터나 스탯 값처럼 순수한 데이터 묶음을 다룰 때는, UObject 대신 USTRUCT를 사용하는 것이 캐시 성능에 압도적으로 유리하다.

1-3. CPU 코어/멀티 코어


CPU의 코어는 간단히 말하자면 일꾼이다.

예전에는 'CPU 1개 = 일꾼 1명'이었지만, 지금은 하나의 CPU 안에 여러 개의 코어가 존재하는 형태이다.

비유를 하자면 다음과 같다.

  • CPU: 공장 건물
  • 코어 (Core): 공장 안에서 실제로 일하는 작업 라인(또는 일꾼)
    • 즉, 1개의 코어는 CU + ALU + 레지스터 세트를 가짐 (보통 L1, L2 캐시도 코어마다 전용으로 가짐)
  • 멀티 코어 (Multi-Core):
    • 1개의 공장 건물(CPU) 안에, 여러 개의 작업 라인 (코어)가 들어있는 형태
    • 실제로 동시에 여러 개의 다른 작업을 처리할 수 있음 (병렬성)

멀티 코어의 위험성 (데이터 공유 문제)

여러 개의 작업을 동시에 처리할 수 있어서 마냥 좋은 것 같지만, 위험성도 존재한다.

만약 코어들이 공유 데이터를 동시에 건드린다면 문제가 발생한다.

G_TotalScore라는 전역 변수가 있다고 가정 후 다음 예시를 살펴보자.

G_TotalScore = 100 일 때

(작업자 1 [코어 1])   |   (작업자 2 [코어 2])
---------------------+----------------------
1. TotalScore 읽음 (100) |
                        | 2. TotalScore 읽음 (100)
3. 100 + 10 = 110 계산   |
                        | 4. 100 + 20 = 120 계산
5. TotalScore에 110 저장! |
                         | 6. TotalScore에 120 저장!

결과: 120 (작업자 1의 +10 연산이 '증발')

이것을 경쟁 상태혹은 동시성 이슈라고 한다.

언리얼에서는 이런 문제를 FCriticalSection(Mutex, 락)이나 TAtomic(원자적 연산)을 사용해 데이터를 잠그는 방식으로 해결하지만, 이 잠금 자체가 성능 저하를 유발할 수 있다.

따라서 개발자는 가급적 G_TotalScore 같은 공유 데이터를 만들지 말고, 스택 변수, 객체 멤버 변수를 사용하도록 코드를 설계하는 것이 좋다.

1-4. 프로세스, 스레드, CPU 스케줄링


프로세스: 독립된 작업장


  • 비유: 거대한, 격리된 작업장
  • 특징:
    • OS는 완벽히 격리된 공간(가상 메모리)를 만들어줌
    • 프로세스끼리는 완벽히 분리되어 있음
    • 프로세스를 생성하는 것은 매우 비싸고 오래 걸리는 작업

스레드: 작업장 안의 일꾼들


  • 비유: 작업장 안에서 일하는 실제 일꾼들

  • 특징:

    • 하나의 프로세스 안에는 최소 1명 이상의 일꾼(스레드)이 존재
    • 이 일꾼들은 작업장 안의 자원(Code, Data, Heap)을 공유
    • 단, 각 일꾼들은 자기만의 개인 Stack 영역과 Register를 가짐(함수 호출 기록 등은 각자 관리해야 하기 때문)
    • 일꾼을 한 명 더 고용하는 건(스레드 생성), 작업장을 새로 짓는 것보다 훨씬 싸고 빠름
  • 언리얼 엔진의 스레드 구조:

    • 게임 스레드 (Game Thread): 대장 일꾼. Tick() 함수, 게임 로직, 액터 관리 담당
    • 렌더 스레드 (Render Thread): 그림 그리는 전문 일꾼, 게임 스레드가 "이거 그려줘"라고 명령하면, GPU에게 작업을 전달
    • 워커 스레드 (Worker Threads): 물리 계산, 애니메이션, 로딩 등 잡일을 돕는 보조 일꾼

CPU 스케줄링 (일꾼 관리)


다음과 같은 상황을 가정해보자.

CPU에 8개의 코어가 있고, 처리해야할 스레드가 500개(게임 스레드, 렌더 스레드, 윈도우 시스템 스레드 등)이 대기 중이다.

8개의 코어는 동시에 딱 8개의 스레드만 처리할 수 있다.

이때 OS의 역할이 중요한데, OS의 작업 분배 행위를 CPU 스케줄링이라고 한다.

OS는 우선순위 결정, Time Slice와 Context Switch 2가지 역할을 하며 스케줄링을 한다.

아주 짧은 시간(Time Slice) 동안 여러 스레드를 번갈아 가며 코어를 넣었다 뺐다 하는 것을 시분할(Time Slicing)이라고 한다.

그리고 스레드를 교체하는 이 비싼 행위(저장하고 복구하는 과정)를 '문맥 교환(Context Switch)'이라고 한다.

스케줄링에는 여러 알고리즘이 있는데 3가지 알고리즘을 살펴보겠다.

1. 선착순 처리 (FCFS)

  • 방식: 먼저 온 작업을 먼저 처리
  • 예시:
    1. '백신 정밀 검사' 작업(10분 소요)가 먼저 도착하여 코어를 잡음
    2. 직후에 '마우스 클릭' 작업(0.001초 소요)이 도착
  • 결과: 마우스 클릭 처리는 백신 검사가 끝날 때까지 10분 동안 대기해야 함
  • 특징: 게임 같은 실시간 반응형 시스템에서는 절대 쓰면 안 되는 방식 (느린 작업 때문에 뒤의 작업들이 모두 막히는 '콘보이 효과'가 발생)

2. 라운드 로빈 (Round Robin)

  • 방식: 모두에게 공평하게 N초씩 할당
  • 상황: '백신', '게임', '음악' 스레드가 번갈아가며 0.01초씩 돌아가며 코어를 사용
  • 결과: 백신 검사는 조금 늦어지겠지만, 게임과 음악은 끊김 없이 동시에 돌아가는 것처럼 보임
  • 특징:
    • 현대 멀티태스킹 OS의 기본 뼈대
    • 단, 너무 자주 교대하면 Context Switch 비용이 커져 효율이 떨어짐

3. 우선순위 스케줄링 (Priority Scheduling)

  • 방식: 급한 작업을 먼저 처리
  • 상황:
    1. 스케줄러는 각 작업에 우선순위(Priority)를 매김
    2. High: 게임 렌더링, 마우스 입력 처리 (실시간성 중요)
    3. Low: 윈도우 업데이트 다운로드, 파일 압축 (천천히 해도 됨)
  • 결과: 게임이 켜져 있을 땐 게임에 코어를 몰아주고, 게임이 최소화되면 백그라운드 작업에 코어를 나눠줌
  • 주의점 (기아 상태, Starvation): 높은 우선순위 작업이 계속 들어오면, 낮은 우선순위 작업은 영원히 코어를 못 받을 수도 있음
    (이 경우 오래 기다린 작업의 우선순위를 올려주는 'Aging' 기법으로 해결)

문맥 교환의 비용 (프로세스 vs 스레드)


OS가 스레드를 교체하는 Context Switch는 공짜가 아니다.

하던 일을 정리하고, 새 일을 준비하는 Overhead가 존재한다.

그런데 이 비용은 '누구와 교대하느냐'에 따라 엄청난 차이가 있다.

스레드 간의 교환 (같은 팀원끼리 교대)

  • 상황: MyGame 프로세스 안에서 Game Thread와 Physics Thread를 교체
  • 비용: 저렴
  • 이유:
    • 같은 프로세스를 쓰기 때문에, 내부의 Code, Data, Heap은 그대로 두면 됨
    • 개인 Stack, Register만 챙겨서 교체하면 끝
    • 캐시 효율: 공유 데이터를 같이 쓰기 때문에, 캐시에 들어있는 데이터가 여전히 유효할 확률이 높음

프로세스 간의 교환 (다른 업체와 교대)

  • 상황: MyGame 프로세스 전체가 작업을 멈추고, Chrome 프로세스가 들어옴
  • 비용: 매우 비쌈
  • 이유:
    • 가상 메모리 공간 전체를 빼야함
    • 캐시 오염:
      • MyGame이 캐시에 가져다 놓은 데이터는 Chrome 입장에서는 완전한 쓰레기
      • Chrome이 실행되면서 캐시를 자기 데이터로 Override
      • 나중에 다시 MyGame이 돌아오면 다시 RAM에서 데이터를 가져오느라 엄청난 렉(Stall)이 발생

요약

  • 스레드 교체: 빠름
  • 프로세스 교체: 매우 느림 & 캐시 초기화
profile
Github: https://github.com/dlalsrn

0개의 댓글