운영체제 핵심 정리

운영체제는 프로그램과 하드웨어 사이에서 자원을 관리하는 소프트웨어다.

CPU, 메모리, 디스크, 네트워크 같은 하드웨어를 프로그램이 직접 제어하게 두면 프로그램마다 하드웨어 구조를 알아야 하고, 서로 자원을 사용하려는 과정도 복잡해진다. 운영체제는 그 사이에서 공통적인 인터페이스를 제공하고 자원을 관리한다.

게임을 실행한다고 생각하면 조금 이해하기 쉽다.

게임 프로그램은 CPU를 직접 점유해서 실행되는 것이 아니라 운영체제가 관리하는 프로세스와 스레드로 실행된다. 메모리를 사용할 때도 실제 물리 메모리의 주소를 직접 다루기보다는 가상 메모리 주소 공간을 사용한다. 파일을 읽을 때도 디스크 장치를 직접 제어하지 않고 운영체제가 제공하는 파일 시스템과 시스템 콜을 사용한다.

운영체제를 공부할 때는 개별 기능을 외우기보다 프로그램이 CPU, 메모리, 저장장치 같은 자원을 어떻게 사용하는가를 중심으로 보는 편이 이해하기 좋다.


운영체제가 하는 일

운영체제가 관리하는 대표적인 자원은 다음과 같다.

  • CPU -> 프로세스와 스레드 스케줄링
  • 메모리 -> 프로세스별 메모리 공간 관리
  • 저장장치 -> 파일 시스템 관리
  • 입출력 장치 -> 장치 제어 및 추상화
  • 프로세스 간 통신 -> IPC
  • 권한과 보호 -> 프로세스와 사용자 간 자원 접근 제어

결국 운영체제의 역할은 크게 보면 자원 관리와 실행 환경 제공으로 볼 수 있다.

프로그램 입장에서는 CPU가 몇 개 있는지, 메모리가 실제로 어디에 존재하는지, 디스크의 데이터를 어떤 명령으로 읽어야 하는지까지 신경 쓰지 않아도 된다.

운영체제가 그 복잡한 부분을 대신 처리한다.


커널

운영체제에서 실제 자원 관리를 담당하는 핵심 부분을 커널(Kernel)이라고 한다.

우리가 사용하는 애플리케이션이 CPU나 메모리를 직접 제어할 수 있는 것은 아니다. 애플리케이션은 운영체제가 제공하는 인터페이스를 통해 커널에 필요한 작업을 요청한다.

예를 들어 파일을 읽는 작업은 대략 다음과 같은 구조로 생각할 수 있다.

커널은 프로세스 관리, 메모리 관리, 파일 시스템, 네트워크, 장치 제어 등 운영체제의 핵심 기능을 담당한다.


프로세스

프로그램과 프로세스는 같은 개념이 아니다.

프로그램이 디스크에 저장된 실행 파일이라면, 프로세스는 그 프로그램이 실행되고 있는 상태라고 볼 수 있다.

프로세스는 자신만의 가상 주소 공간과 실행 상태를 가진다.

예를 들어 게임을 실행하면 운영체제는 해당 게임을 하나의 프로세스로 관리한다.

프로세스

Code
Data
Heap
Stack
Register 상태
기타 운영체제 관리 정보

프로세스마다 독립적인 주소 공간을 갖기 때문에 일반적인 상황에서는 한 프로세스가 다른 프로세스의 메모리를 마음대로 접근할 수 없다.

이러한 격리는 프로그램 하나의 오류가 다른 프로그램 전체로 퍼지는 것을 막는 데 중요하다.


스레드

프로세스가 실행을 위한 자원과 메모리 공간을 가지고 있다면, 스레드는 실제 CPU에서 실행되는 작업의 단위에 가깝다.

하나의 프로세스에는 여러 스레드가 존재할 수 있다.

같은 프로세스의 스레드들은 일반적으로 코드, 데이터, 힙 같은 주소 공간을 공유한다. 대신 각 스레드는 자신의 스택과 레지스터 상태 등을 가진다.

게임을 예로 들면 하나의 프로세스 안에서 다음과 같이 작업을 나눌 수 있다.

스레드를 여러 개 만든다고 CPU 성능이 무조건 좋아지는 것은 아니다.

스레드가 많아지면 스케줄링 비용과 동기화 문제가 생긴다. 특히 여러 스레드가 같은 데이터를 수정하면 경쟁 조건이 발생할 수 있다.


프로세스와 스레드의 관계

둘의 차이를 단순하게 보면 다음과 같다.

구분프로세스스레드
역할실행 중인 프로그램의 독립된 단위프로세스 내부의 실행 단위
메모리독립적인 주소 공간프로세스의 메모리 공간 공유
생성 비용상대적으로 큼상대적으로 작음
통신IPC 필요공유 메모리를 통한 통신 가능
오류 격리상대적으로 강함같은 프로세스 내부에서 영향 가능

여기서 중요한 것은 “스레드가 프로세스보다 가볍다” 정도만 외우는 것이 아니다.

스레드는 메모리를 공유하기 때문에 빠르게 데이터를 주고받을 수 있지만, 그만큼 동기화 문제가 생긴다.

이 특성이 멀티스레드 프로그래밍의 핵심적인 트레이드오프다.


CPU 스케줄링

CPU 코어가 하나라고 가정하면 동시에 여러 스레드를 실제로 실행할 수는 없다.

운영체제는 실행 가능한 스레드 중 어떤 것을 CPU에서 실행할지 결정해야 한다.

이 역할을 스케줄러가 담당한다.

개념적으로는 다음과 같은 과정이다.

Ready  -> CPU 실행  -> Running  -> 대기 또는 종료

실행 중인 스레드가 I/O를 기다리게 되면 CPU를 계속 잡고 있을 이유가 없다.

운영체제는 해당 스레드를 대기 상태로 보내고 다른 실행 가능한 스레드에게 CPU를 할당할 수 있다.

여러 스레드가 짧은 시간 단위로 CPU를 번갈아 사용하면 사용자 입장에서는 여러 작업이 동시에 실행되는 것처럼 보인다.

멀티코어 CPU에서는 실제 병렬 실행도 가능하다.

CPU Core 0 -> Thread A
CPU Core 1 -> Thread B
CPU Core 2 -> Thread C
CPU Core 3 -> Thread D

따라서 동시성(Concurrency)병렬성(Parallelism)은 구분할 필요가 있다.

동시성은 여러 작업을 번갈아 처리하면서 진행시키는 개념이고, 병렬성은 여러 작업을 실제로 동시에 실행하는 것이다.


Context Switching

CPU가 실행하던 스레드를 멈추고 다른 스레드를 실행하려면 현재 실행 상태를 저장하고 새로운 스레드의 상태를 복원해야 한다.

이 과정을 Context Switching이라고 한다.

Thread A 실행    -> A의 실행 상태 저장    -> Thread B의 실행 상태 복원    -> Thread B 실행

여기에는 비용이 발생한다.

따라서 스레드를 많이 만들면 무조건 좋은 것이 아니다. CPU 코어 수, 작업의 특성, 스케줄링 비용 등을 고려해야 한다.

특히 게임에서는 메인 스레드의 프레임 시간이 중요하기 때문에 작업을 무작정 여러 스레드로 나누기보다 어떤 작업을 병렬화할 수 있는지 판단하는 것이 중요하다.


시스템 콜

애플리케이션은 운영체제의 모든 기능에 직접 접근할 수 없다.

파일을 열거나, 메모리를 할당하거나, 프로세스를 생성하거나, 네트워크를 사용하는 등의 작업은 운영체제가 제공하는 인터페이스를 통해 요청한다.

이런 요청을 위한 인터페이스가 System Call이다.

User Application  -> System Call  -> Kernel  -> Hardware / Kernel Resource

예를 들어 파일을 읽는 코드를 작성할 때 개발자는 디스크의 물리적인 위치를 직접 계산하지 않는다.

운영체제가 제공하는 파일 API를 호출하면 커널이 파일 시스템과 장치 드라이버 등을 거쳐 실제 장치에 접근한다.

시스템 콜은 사용자 프로그램과 커널 사이의 경계라고 볼 수 있다.


User Mode와 Kernel Mode

CPU에는 일반적으로 프로그램이 실행되는 권한 수준이 구분되어 있다.

애플리케이션은 User Mode에서 실행되고, 운영체제의 핵심 기능은 Kernel Mode에서 실행된다.

이런 권한 분리가 필요한 이유는 프로그램이 시스템 전체를 마음대로 조작하지 못하게 하기 위해서다.

만약 일반 프로그램이 아무 제한 없이 물리 메모리나 하드웨어를 직접 수정할 수 있다면 하나의 잘못된 프로그램이 시스템 전체를 망가뜨릴 수 있다.

운영체제는 권한이 필요한 작업을 커널 영역에서 수행하도록 제한한다.


메모리 관리

프로그램이 실행되면 메모리를 사용한다.

문제는 프로그램이 실제 RAM의 물리 주소를 직접 사용하도록 만들면 여러 프로그램을 동시에 실행하기 어렵다는 것이다.

운영체제는 가상 메모리(Virtual Memory)를 사용해 프로세스마다 독립적인 주소 공간을 제공한다.

프로그램은 자신만의 가상 주소 공간을 사용하는 것처럼 동작한다.

같은 가상 주소라도 프로세스마다 실제 물리 주소가 다를 수 있다.

이 덕분에 프로세스 간 메모리를 격리할 수 있고, 물리 메모리보다 큰 주소 공간을 사용하는 것도 가능해진다.


페이지와 페이지 테이블

가상 메모리는 보통 일정한 크기의 Page 단위로 관리한다.

가상 주소 공간을 페이지 단위로 나누고, 각 페이지가 실제 물리 메모리의 어느 위치에 대응하는지를 Page Table이 관리한다.

Virtual Memory

Page 0 -> Physical Frame 3
Page 1 -> Physical Frame 8
Page 2 -> Physical Frame 1
Page 3 -> Physical Frame 5

CPU가 가상 주소를 사용하면 하드웨어의 MMU가 페이지 테이블 등을 이용해 물리 주소로 변환한다.

이 주소 변환은 매우 자주 발생하기 때문에 TLB(Translation Lookaside Buffer) 같은 캐시도 사용한다.

운영체제의 메모리 관리와 CPU의 메모리 관리 하드웨어가 같이 동작하는 부분이다.


Stack과 Heap

프로그램의 메모리를 이해할 때 Stack과 Heap도 자주 등장한다.

Stack은 함수 호출과 지역 변수 같은 실행 정보를 관리하는 데 사용된다.

void Update()
{
    int count = 10;
}

count 같은 지역 변수와 함수 호출에 필요한 정보가 스택에 저장되는 것이 일반적이다.

반면 Heap은 실행 중 동적으로 메모리를 할당할 때 사용한다.

auto* enemy = new Enemy();

C++에서는 new를 통해 동적으로 메모리를 할당할 수 있고, 관리 방식에 따라 직접 해제하거나 RAII 및 스마트 포인터 등을 이용해 수명을 관리한다.

둘의 차이를 단순히 “Stack은 빠르고 Heap은 느리다”로 외우는 것은 부족하다.

스택은 구조적으로 함수 호출에 맞는 방식으로 관리되기 때문에 할당과 해제가 단순하다. 힙은 동적 수명과 다양한 크기의 메모리를 관리해야 하므로 상대적으로 복잡하다.


Virtual Memory와 Page Fault

가상 메모리를 사용한다고 해서 모든 데이터가 항상 RAM에 올라가 있어야 하는 것은 아니다.

운영체제는 필요한 페이지를 물리 메모리에 올리고, 당장 필요하지 않은 페이지는 저장장치 쪽으로 내보낼 수 있다.

프로그램이 아직 메모리에 올라오지 않은 페이지에 접근하면 Page Fault가 발생한다.

프로그램이 가상 주소 접근 -> 해당 페이지가 RAM에 없음 -> Page Fault -> 운영체제가 페이지를 준비 -> 다시 실행

페이지 폴트 자체가 무조건 오류라는 의미는 아니다.

정상적인 가상 메모리 시스템에서도 발생할 수 있다.

다만 저장장치까지 접근해야 하는 상황은 RAM 접근보다 훨씬 느리기 때문에 페이지 폴트가 과도하게 발생하면 성능 문제가 된다.


동기화

멀티스레드 환경에서는 여러 스레드가 같은 데이터를 동시에 접근할 수 있다.

예를 들어 다음과 같은 코드가 있다고 하자.

counter++;

겉보기에는 한 줄이지만 실제로는 값을 읽고, 증가시키고, 다시 저장하는 과정이 필요하다.

두 스레드가 동시에 실행하면 원하는 결과와 다른 값이 나올 수 있다.

이런 문제를 Race Condition이라고 한다.

공유 자원에 대한 접근을 제어하기 위해 여러 동기화 방법을 사용한다.

  • Mutex
  • Semaphore
  • Spinlock
  • Atomic Operation
  • Condition Variable

어떤 방법을 사용할지는 상황에 따라 다르다.

예를 들어 짧은 시간 동안 하나의 공유 데이터에 대한 원자적인 연산만 필요하다면 atomic이 적합할 수 있고, 여러 작업이 하나의 임계 영역을 공유한다면 mutex가 더 자연스러울 수 있다.


Deadlock

동기화가 복잡해지면 Deadlock도 발생할 수 있다.

대표적인 상황은 두 스레드가 서로가 가진 락을 기다리는 경우다.

Thread A Lock A 획득 -> Lock B 대기

Thread B Lock B 획득 -> Lock A 대기

A는 B가 락을 풀기를 기다리고, B는 A가 락을 풀기를 기다린다.

둘 다 계속 기다리기 때문에 프로그램이 진행되지 않는다.

일반적으로 데드락은 다음 조건들이 동시에 성립할 때 발생할 수 있다.

  • Mutual Exclusion
  • Hold and Wait
  • No Preemption
  • Circular Wait

실무에서는 락 획득 순서를 일정하게 유지하거나 락의 범위를 줄이는 등의 방법으로 데드락 가능성을 낮춘다.


프로세스 간 통신

프로세스는 서로 독립적인 주소 공간을 사용한다.

따라서 다른 프로세스의 메모리를 일반적인 방식으로 직접 읽고 쓸 수 없다.

프로세스 사이에서 데이터를 주고받으려면 IPC(Inter-Process Communication)가 필요하다.

대표적인 방법은 다음과 같다.

  • Pipe
  • Shared Memory
  • Message Queue
  • Socket
  • Signal

게임에서도 이런 구조를 만날 수 있다.

게임 클라이언트와 별도의 런처 프로세스가 통신하거나, 게임 프로세스와 별도의 음성 채팅 프로그램이 네트워크를 통해 통신하는 경우 등이 있다.


파일 시스템

프로그램이 파일을 사용할 때 실제 저장장치의 물리적인 블록을 직접 관리하지 않는다.

운영체제는 파일 시스템을 통해 데이터를 파일과 디렉터리라는 구조로 추상화한다.

파일 시스템은 파일의 위치, 크기, 권한, 메타데이터 등을 관리하고 실제 저장장치에 데이터를 배치하는 작업을 담당한다.

개발자는 일반적으로 다음과 같은 수준에서 파일을 다룬다.

File.Open("save.dat");
File.Read(...);
File.Write(...);
File.Close();

실제 디스크의 블록 관리 같은 부분은 운영체제와 파일 시스템이 처리한다.


I/O와 비동기 처리

CPU가 모든 장치를 CPU 속도로 기다릴 필요는 없다.

디스크나 네트워크 같은 장치는 CPU보다 훨씬 느릴 수 있다. 따라서 I/O 작업을 요청한 뒤 결과가 올 때까지 CPU를 계속 점유하고 있으면 자원이 낭비된다.

운영체제는 I/O 요청과 완료를 관리하고, 프로그램은 동기 또는 비동기 방식으로 이를 처리할 수 있다.

게임에서 대용량 에셋을 로딩한다고 생각해보면 이해하기 쉽다.

메인 스레드 -> 에셋 로딩 요청 -> 백그라운드에서 I/O -> 데이터 준비 완료 -> 게임 로직에서 사용

이런 구조를 사용하면 로딩 때문에 메인 스레드가 오랫동안 멈추는 문제를 줄일 수 있다.

다만 비동기 I/O가 모든 작업을 자동으로 빠르게 만드는 것은 아니다. 결국 저장장치의 성능, CPU 처리량, 메모리 사용량, 작업 분배 방식까지 같이 봐야 한다.


운영체제 개념을 하나의 흐름으로 보기

지금까지의 개념을 따로 외우기보다 하나의 프로그램이 실행되는 과정으로 연결해보면 구조가 조금 명확해진다.

멀티스레드로 실행된다면 여기에 동기화가 추가된다.

여러 Thread -> 공유 데이터 접근 -> Atomic / Mutex / Semaphore -> Race Condition 방지

여러 프로세스가 통신한다면 IPC가 추가된다.

결국 운영체제에서 배우는 개념들은 서로 따로 떨어져 있는 것이 아니다.

CPU를 어떻게 나눠 쓰는가 -> 프로세스와 스레드

메모리를 어떻게 나눠 쓰는가 -> 가상 메모리와 페이지

공유 데이터를 어떻게 안전하게 사용하는가 -> 동기화

프로그램이 커널과 어떻게 통신하는가 -> 시스템 콜

프로세스끼리 어떻게 통신하는가 -> IPC

저장장치를 어떻게 추상화하는가 -> 파일 시스템

이렇게 연결해서 보면 운영체제의 구조가 훨씬 덜 복잡해진다.


개발하면서 결국 만나게 되는 부분

운영체제는 애플리케이션 개발자가 항상 직접 다루는 영역은 아니다.

그렇다고 몰라도 되는 영역도 아니다.

프로그램의 성능 문제를 분석하다 보면 CPU 스케줄링, 캐시, 메모리 할당, 페이지 폴트, 컨텍스트 스위칭, 락 경합 같은 개념이 등장한다.

게임 개발에서는 특히 그렇다.

프레임이 끊긴다고 해서 무조건 그래픽 연산이 문제인 것은 아니다. CPU 작업이 몰렸을 수도 있고, 메모리 할당이 과도할 수도 있고, 다른 스레드와의 동기화 때문에 기다리고 있을 수도 있다. 에셋 로딩 때문에 I/O가 발생하면서 메인 스레드가 영향을 받을 수도 있다.

운영체제를 공부하는 이유도 결국 이런 문제를 볼 수 있는 범위를 넓히기 위해서라고 생각한다.

코드를 작성하는 것만 보면 하나의 함수와 클래스만 보인다.

운영체제까지 내려가면 그 코드가 CPU에서 어떻게 실행되고, 메모리를 어떻게 사용하고, 다른 작업과 어떻게 경쟁하는지까지 볼 수 있다.

그 정도의 관점이 있으면 성능 문제나 동시성 문제를 단순히 “느리다”, “가끔 터진다” 수준에서 끝내지 않고 원인을 추적하기 쉬워진다.