Skip to content
Published on

운영체제 완전 정복: 프로세스/메모리/파일시스템부터 AI 워크로드 최적화까지

Authors

들어가며

운영체제(OS)는 하드웨어와 응용 프로그램 사이의 중재자입니다. AI/ML 엔지니어라면 단순히 Python 코드를 작성하는 것을 넘어, 아래 질문들에 답할 수 있어야 합니다.

  • PyTorch 학습이 느린 이유가 CPU 스케줄링 때문인가, 메모리 대역폭 때문인가?
  • 멀티프로세싱과 멀티스레딩 중 어떤 것이 더 효율적인가?
  • GPU 자원을 여러 팀이 공유할 때 격리는 어떻게 구현하는가?

이 글은 이 질문들에 대한 답을 OS 핵심 개념과 함께 제공합니다.


1. 프로세스와 스레드

프로세스란?

프로세스는 실행 중인 프로그램의 인스턴스입니다. 각 프로세스는 독립된 가상 주소 공간, 파일 디스크립터, 신호 핸들러를 가집니다.

프로세스 상태 다이어그램:

  생성(New) ──→ 준비(Ready) ──→ 실행(Running)
                   ↑               │
                   │    스케줄러    │ I/O 요청
                   └── 대기(Wait) ←┘
                    종료(Terminated)

PCB(Process Control Block) 는 커널이 프로세스마다 유지하는 데이터 구조입니다.

// Linux의 task_struct (단순화)
struct task_struct {
    pid_t pid;              // 프로세스 ID
    int   state;            // 현재 상태
    struct mm_struct *mm;   // 가상 메모리 매핑
    struct files_struct *files; // 열린 파일 테이블
    struct thread_info thread_info; // 레지스터 저장
    long prio;              // 스케줄링 우선순위
};

fork() + exec() 프로세스 생성

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork 실패");
        return 1;
    } else if (pid == 0) {
        // 자식 프로세스
        char *args[] = {"/bin/ls", "-la", NULL};
        execv("/bin/ls", args);
        perror("exec 실패"); // exec 성공 시 여기 도달 안 함
    } else {
        // 부모 프로세스
        int status;
        waitpid(pid, &status, 0);
        printf("자식 종료 코드: %d\n", WEXITSTATUS(status));
    }
    return 0;
}

fork()는 부모의 가상 주소 공간을 Copy-on-Write(CoW) 방식으로 복사합니다. 실제 물리 메모리는 쓰기가 발생할 때만 복사되므로 효율적입니다.

스레드 vs 프로세스

항목프로세스스레드
주소 공간독립공유
생성 비용높음낮음
통신 방법IPC (파이프, 소켓)공유 메모리
장애 격리강함약함
Python GIL영향 없음CPU 바운드 시 병목

컨텍스트 스위칭

CPU가 프로세스 A에서 B로 전환할 때:

  1. A의 레지스터 상태를 PCB에 저장
  2. 가상 메모리 매핑(페이지 테이블 포인터) 교체
  3. TLB 플러시 (캐시 무효화)
  4. B의 레지스터 상태 복원

컨텍스트 스위칭은 수 마이크로초의 비용이 있으며, AI 추론 서버에서 과도한 스레드는 오히려 성능을 낮출 수 있습니다.


2. CPU 스케줄링

주요 알고리즘 비교

FIFO (First-In, First-Out)

  • 단순하지만 짧은 작업이 긴 작업 뒤에 대기하는 Convoy Effect 발생

SJF (Shortest Job First)

  • 평균 대기 시간 최소화, 하지만 실행 시간 예측이 어려움

Round Robin

  • 각 프로세스에 고정 타임 슬라이스(quantum) 부여
  • 타임 슬라이스가 너무 짧으면 컨텍스트 스위칭 오버헤드 증가

Linux CFS (Completely Fair Scheduler)

  • vruntime 기반: 각 프로세스가 CPU를 얼마나 사용했는지 추적
  • Red-Black Tree로 가장 적게 실행된 프로세스를 O(log n)으로 선택
// vruntime 계산 (개념적 의사코드)
void update_vruntime(struct task_struct *task, u64 delta_exec) {
    // 우선순위가 높을수록 vruntime이 느리게 증가
    u64 weight = prio_to_weight[task->nice + 20];
    task->vruntime += delta_exec * NICE_0_WEIGHT / weight;
}

우선순위 역전(Priority Inversion) 문제

고우선순위 H ────────────────────→ 대기  (lock 필요)
중우선순위 M ──────────────────→ H보다 먼저 실행!
저우선순위 L ──→ lock 보유 중 ──→ preempt 당함

L이 lock을 보유한 채로 M에 의해 선점되면, H가 M보다 늦게 실행됩니다. 해결책은 Priority Inheritance: L이 lock을 보유하는 동안 H의 우선순위를 임시 상속합니다.


3. 메모리 관리

가상 메모리와 페이징

모든 프로세스는 자신이 물리 메모리 전체를 독점한다고 착각합니다. 커널은 페이지 테이블로 가상 주소를 물리 주소로 변환합니다.

가상 주소 (48비트 on x86-64):
┌──────────┬──────────┬──────────┬──────────┬──────────┬────────────┐
PGD(9)PUD(9)PMD(9)PTE(9)offset(12)│           │
└──────────┴──────────┴──────────┴──────────┴──────────┴────────────┘

TLB (Translation Lookaside Buffer)

페이지 테이블 조회는 메모리 접근이 필요하므로 느립니다. TLB는 최근 변환 결과를 캐싱합니다.

TLB miss 처리 과정:

  1. CPU가 가상 주소로 TLB 조회 → miss
  2. CPU가 CR3 레지스터의 페이지 테이블 기준 주소 참조
  3. 4단계 페이지 테이블 워크 (메모리 4번 접근)
  4. PTE에서 물리 주소 추출 → TLB에 저장
  5. 접근 재시도

페이지 교체 알고리즘

LRU (Least Recently Used): 가장 오래 사용하지 않은 페이지를 교체합니다. Linux는 clock 알고리즘(LRU 근사)을 사용합니다.

Clock 알고리즘:
  각 페이지마다 reference bit(R) 유지
  포인터가 순환하며 R=0인 페이지를 교체 대상으로 선택
  R=1이면 R=0으로 초기화 후 다음으로 이동

Python에서 mmap 활용

import mmap
import os

# 대용량 데이터셋을 메모리 맵으로 접근
with open("large_dataset.bin", "r+b") as f:
    # 파일 전체를 가상 주소 공간에 매핑
    mm = mmap.mmap(f.fileno(), 0)

    # 슬라이싱으로 원하는 영역만 접근 (실제 I/O는 필요할 때만)
    header = mm[:128]
    record = mm[128:256]

    mm.close()

# AI 학습: numpy memmap으로 디스크 데이터를 배열처럼 접근
import numpy as np
data = np.memmap("features.npy", dtype="float32", mode="r", shape=(1_000_000, 512))
batch = data[0:1024]  # 디스크에서 필요한 배치만 로드

4. 동기화

Mutex와 Condition Variable

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define BUFFER_SIZE 10

int buffer[BUFFER_SIZE];
int count = 0;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  not_full  = PTHREAD_COND_INITIALIZER;
pthread_cond_t  not_empty = PTHREAD_COND_INITIALIZER;

void *producer(void *arg) {
    for (int i = 0; i < 50; i++) {
        pthread_mutex_lock(&mutex);
        while (count == BUFFER_SIZE)
            pthread_cond_wait(&not_full, &mutex);  // 버퍼 가득 찼으면 대기

        buffer[count++] = i;
        printf("생산: %d (count=%d)\n", i, count);
        pthread_cond_signal(&not_empty);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

void *consumer(void *arg) {
    for (int i = 0; i < 50; i++) {
        pthread_mutex_lock(&mutex);
        while (count == 0)
            pthread_cond_wait(&not_empty, &mutex);  // 버퍼 비면 대기

        int val = buffer[--count];
        printf("소비: %d (count=%d)\n", val, count);
        pthread_cond_signal(&not_full);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main(void) {
    pthread_t prod, cons;
    pthread_create(&prod, NULL, producer, NULL);
    pthread_create(&cons, NULL, consumer, NULL);
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);
    return 0;
}

교착상태(Deadlock)

Coffman 조건 — 4가지 모두 성립할 때 교착상태 발생:

  1. 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 프로세스만 사용
  2. 점유 및 대기(Hold and Wait): 자원을 보유한 채로 다른 자원을 기다림
  3. 비선점(No Preemption): 보유 자원을 강제로 빼앗을 수 없음
  4. 순환 대기(Circular Wait): P1→P2→P3→P1 형태의 순환 의존

예방 전략:

  • 자원 순서 고정 (순환 대기 제거)
  • 모든 자원을 한 번에 요청 (점유 및 대기 제거)
  • 뱅커 알고리즘으로 안전 상태 유지

5. 파일 시스템

ext4와 inode

# inode 정보 확인
stat /etc/hostname
# File: /etc/hostname
# Size: 12        Blocks: 8   IO Block: 4096  regular file
# Inode: 131073   Links: 1
# Access: 2026-03-17 10:00:00
# Modify: 2026-01-01 00:00:00

# 남은 inode 수 확인 (inode 고갈도 디스크 꽉 찬 것과 같은 효과)
df -i /

inode 구조:

  • 파일 메타데이터 (권한, 소유자, 타임스탬프)
  • 데이터 블록 포인터 (직접/간접/이중 간접)
  • 파일 이름은 inode에 없음 — 디렉토리 엔트리에 있음

Journaling

ext4는 저널링으로 갑작스러운 전원 차단 후 복구를 보장합니다.

쓰기 전: Journal에 변경 내용 먼저 기록 (Write-Ahead Log)
쓰기 후: 실제 블록에 반영
완료 후: Journal 항목 제거 (Commit)

VFS 추상화 계층

사용자 공간:  open() read() write()
커널 VFS 계층: vfs_open() vfs_read()   ← 공통 인터페이스
파일 시스템:  ext4 | btrfs | tmpfs | procfs | nfs
블록 디바이스 계층 → 실제 하드웨어

6. I/O와 인터럽트

DMA와 인터럽트 핸들러

CPU가 직접 데이터를 메모리에 복사하지 않고, DMA 컨트롤러가 수행합니다.

1. CPUDMA: "디스크 블록 X를 메모리 주소 Y에 복사해줘"
2. DMA가 독립적으로 전송 수행 (CPU는 다른 작업)
3. DMA 완료 → 인터럽트 발생
4. CPU: 현재 명령 완료 → 인터럽트 벡터 테이블 참조 → ISR 실행
5. ISR: 완료 처리 후 대기 중인 프로세스 깨움

epoll vs io_uring

epoll (이벤트 기반 I/O 다중화):

// epoll 방식: 여전히 시스템콜이 필요
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
epoll_wait(epfd, events, MAX_EVENTS, -1);  // 시스템콜
read(fd, buf, size);                        // 또 시스템콜

io_uring (Linux 5.1+, 공유 링 버퍼):

// io_uring: 시스템콜 없이 I/O 제출 가능
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, 0);
io_uring_submit(&ring);  // 시스템콜 1번으로 여러 I/O 제출

// 완료 대기 (커널과 공유 메모리로 통신)
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

io_uring이 고성능인 이유:

  • SQ/CQ 링 버퍼를 유저/커널이 공유 → 데이터 복사 없음
  • 여러 I/O를 배치로 제출 → 시스템콜 횟수 감소
  • IORING_SETUP_SQPOLL 모드: 커널 스레드가 폴링 → 시스템콜 0

7. AI/ML 관점에서의 운영체제

NUMA 아키텍처

다중 소켓 서버에서 각 CPU 소켓은 로컬 메모리를 가집니다.

Socket 0              Socket 1
┌─────────┐          ┌─────────┐
CPU 0  │──QPI────│  CPU 1│ 32GB RAM│          │ 32GB RAM└─────────┘          └─────────┘
로컬 접근: ~100ns    원격 접근: ~300ns

AI 학습에서 NUMA 영향:

  • 데이터 전처리 프로세스가 Socket 0, GPU가 Socket 1에 있으면 모든 텐서가 원격 메모리를 거침
  • numactl --cpunodebind=0 --membind=0 python train.py 로 고정
# NUMA 토폴로지 확인
numactl --hardware
# NUMA 통계 모니터링
numastat -p python

Python 멀티프로세싱 vs 멀티스레딩

import time
import threading
import multiprocessing

def cpu_bound_task(n):
    """소수 계산 (CPU 집약적)"""
    count = 0
    for i in range(2, n):
        if all(i % j != 0 for j in range(2, int(i**0.5) + 1)):
            count += 1
    return count

N = 100_000

# 멀티스레딩: GIL 때문에 CPU 바운드에서 성능 향상 없음
start = time.time()
threads = [threading.Thread(target=cpu_bound_task, args=(N,)) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(f"스레딩: {time.time() - start:.2f}초")

# 멀티프로세싱: 별도 프로세스로 GIL 우회
start = time.time()
with multiprocessing.Pool(4) as pool:
    pool.map(cpu_bound_task, [N] * 4)
print(f"프로세싱: {time.time() - start:.2f}초")
# 결과: 프로세싱이 ~4배 빠름

cgroups로 GPU 자원 격리

# cgroups v2 설정으로 팀별 GPU 메모리 제한

# 팀 A 그룹 생성
mkdir /sys/fs/cgroup/team_a

# CPU 제한: 전체의 25%만 사용
echo "25000 100000" > /sys/fs/cgroup/team_a/cpu.max

# 메모리 제한: 16GB
echo $((16 * 1024 * 1024 * 1024)) > /sys/fs/cgroup/team_a/memory.max

# 프로세스를 그룹에 추가
echo $$ > /sys/fs/cgroup/team_a/cgroup.procs

# GPU 격리는 NVIDIA MIG + cgroups 조합
# MIG: 하나의 A100을 여러 인스턴스로 분할
nvidia-smi mig -cgi 3g.40gb -C  # 40GB 인스턴스 생성

# Docker로 GPU 자원 제한
docker run --gpus '"device=0,1"' \
  --cpuset-cpus="0-15" \
  --memory="32g" \
  pytorch/pytorch:latest python train.py

/proc 파일 시스템 탐색

# 프로세스 메모리 매핑 확인
cat /proc/$(pgrep python)/maps

# 프로세스 상태 확인
cat /proc/$(pgrep python)/status | grep -E "VmRSS|VmPeak|Threads"

# CPU 스케줄링 통계
cat /proc/$(pgrep python)/schedstat

# NUMA 메모리 바인딩 확인
cat /proc/$(pgrep python)/numa_maps | head -20

# 시스템 전체 메모리 정보
cat /proc/meminfo | grep -E "MemTotal|MemFree|Cached|HugePages"

퀴즈

운영체제 핵심 개념을 점검해봅시다.

Q1. 가상 메모리에서 TLB miss가 발생했을 때 처리 과정을 순서대로 설명하세요.

정답: 총 5단계로 처리됩니다.

설명:

  1. CPU가 가상 주소로 TLB를 조회하지만 해당 항목이 없음 (TLB miss)
  2. CPU의 MMU가 CR3 레지스터에 저장된 페이지 테이블 기준 주소(PGD)를 참조
  3. PGD → PUD → PMD → PTE 순으로 4단계 페이지 테이블을 순회하며 물리 주소 획득 (메모리 4회 접근)
  4. 획득한 가상-물리 주소 매핑을 TLB에 저장
  5. 원래 메모리 접근을 재시도하여 완료

TLB hit rate가 낮으면 성능이 크게 저하됩니다. Huge Pages (2MB/1GB) 사용으로 TLB 항목 수를 줄여 hit rate를 높일 수 있습니다.

Q2. Linux CFS가 vruntime을 사용하는 방식과 이것이 공정성을 어떻게 보장하는지 설명하세요.

정답: vruntime은 가중치가 적용된 가상 실행 시간입니다.

설명:

  • 각 태스크는 실제 CPU 실행 시간에 우선순위 가중치를 적용한 vruntime을 누적합니다
  • 우선순위가 높은 태스크는 같은 실행 시간에도 vruntime이 느리게 증가합니다
  • CFS는 항상 vruntime이 가장 작은 태스크 (Red-Black Tree의 leftmost node)를 선택합니다
  • 결과적으로 모든 태스크가 우선순위에 비례하여 CPU를 "공평하게" 사용하게 됩니다
  • 새로운 태스크의 초기 vruntime은 min_vruntime으로 설정하여 오래된 태스크가 불이익 받지 않도록 합니다
Q3. 교착상태(Deadlock) 발생의 4가지 필요 조건(Coffman conditions)을 설명하세요.

정답: 상호 배제, 점유 및 대기, 비선점, 순환 대기

설명:

  1. 상호 배제: 자원은 한 번에 하나의 프로세스만 사용 가능 (프린터, mutex 등)
  2. 점유 및 대기: 이미 자원을 보유한 상태에서 추가 자원을 기다림
  3. 비선점: 프로세스가 보유한 자원을 강제로 빼앗을 수 없음 (자발적 반납만 가능)
  4. 순환 대기: P1이 P2의 자원을, P2가 P3의 자원을, P3가 P1의 자원을 기다리는 순환

이 4가지 중 하나라도 성립하지 않으면 교착상태는 발생하지 않습니다. 예방 전략은 이 중 하나를 제거하는 방식으로 설계합니다.

Q4. io_uring이 epoll보다 고성능 I/O에 유리한 이유를 설명하세요.

정답: 시스템콜 오버헤드 최소화와 제로 복사(zero-copy)

설명:

  • 시스템콜 감소: epoll은 이벤트 감지와 실제 read/write 각각 시스템콜이 필요하지만, io_uring은 하나의 io_uring_submit()으로 여러 I/O를 배치 제출
  • 공유 링 버퍼: SQ(Submission Queue)와 CQ(Completion Queue)를 유저/커널 공간이 공유하는 메모리로 구현하여 데이터 복사 불필요
  • SQPOLL 모드: 커널 스레드가 SQ를 폴링하므로 submit 시스템콜도 불필요
  • 고정 버퍼: io_uring_register_buffers()로 버퍼를 사전 등록하면 매 I/O마다 주소 매핑 불필요
  • 결과적으로 초당 수백만 건의 I/O를 CPU 오버헤드 없이 처리할 수 있습니다
Q5. NUMA 아키텍처에서 remote memory access가 AI 학습 성능에 미치는 영향을 설명하세요.

정답: 메모리 대역폭 감소와 레이턴시 증가로 학습 속도 저하

설명:

  • NUMA 로컬 메모리 접근은 약 100ns이지만 원격 접근은 약 300ns로 3배 느림
  • AI 학습 시 대용량 텐서 데이터를 매 iteration마다 GPU로 전송하므로 메모리 대역폭이 핵심
  • DataLoader 워커가 Socket 0에 바인딩되고 GPU가 Socket 1 PCIe에 연결된 경우, 모든 데이터가 QPI 인터커넥트를 거쳐야 함
  • 최적화 방법: numactl --cpunodebind=N --membind=N으로 GPU와 같은 NUMA 노드에 프로세스와 메모리 고정, torch.cuda.set_device()와 함께 NUMA-aware 데이터 로더 사용

마무리

운영체제는 AI 엔지니어에게 블랙박스가 아니어야 합니다. 핵심 요약:

  • 스케줄링: CFS의 vruntime으로 공정한 CPU 배분, 우선순위 역전 주의
  • 메모리: 가상 메모리로 격리, TLB miss 최소화, Huge Pages 활용
  • 동기화: mutex/condvar로 경쟁 조건 제거, 락 순서로 교착상태 예방
  • I/O: io_uring으로 시스템콜 오버헤드 최소화
  • AI 최적화: NUMA-aware 배치, cgroups로 GPU 격리, /proc으로 병목 진단