Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

운영체제(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 활용

대용량 데이터셋을 메모리 맵으로 접근

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으로 디스크 데이터를 배열처럼 접근

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. CPU → DMA: "디스크 블록 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 멀티스레딩

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"

퀴즈

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

**정답**: 총 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를 높일 수 있습니다.

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

**설명**:

- 각 태스크는 실제 CPU 실행 시간에 우선순위 가중치를 적용한 `vruntime`을 누적합니다

- 우선순위가 높은 태스크는 같은 실행 시간에도 vruntime이 느리게 증가합니다

- CFS는 항상 vruntime이 가장 작은 태스크 (Red-Black Tree의 leftmost node)를 선택합니다

- 결과적으로 모든 태스크가 우선순위에 비례하여 CPU를 "공평하게" 사용하게 됩니다

- 새로운 태스크의 초기 vruntime은 `min_vruntime`으로 설정하여 오래된 태스크가 불이익 받지 않도록 합니다

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

**설명**:

1. **상호 배제**: 자원은 한 번에 하나의 프로세스만 사용 가능 (프린터, mutex 등)

2. **점유 및 대기**: 이미 자원을 보유한 상태에서 추가 자원을 기다림

3. **비선점**: 프로세스가 보유한 자원을 강제로 빼앗을 수 없음 (자발적 반납만 가능)

4. **순환 대기**: P1이 P2의 자원을, P2가 P3의 자원을, P3가 P1의 자원을 기다리는 순환

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

**정답**: 시스템콜 오버헤드 최소화와 제로 복사(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 오버헤드 없이 처리할 수 있습니다

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

**설명**:

- 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으로 병목 진단

현재 단락 (1/275)

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

작성 글자: 0원문 글자: 9,079작성 단락: 0/275