들어가며
운영체제(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(¬_full, &mutex); // 버퍼 가득 찼으면 대기
buffer[count++] = i;
printf("생산: %d (count=%d)\n", i, count);
pthread_cond_signal(¬_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(¬_empty, &mutex); // 버퍼 비면 대기
int val = buffer[--count];
printf("소비: %d (count=%d)\n", val, count);
pthread_cond_signal(¬_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 코드를 작성하는 것을 넘어, 아래 질문들에 답할 수 있어야 합니다...