이미지 로딩 중...

Rust로 만드는 나만의 OS 프로세스 상태 관리 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS 프로세스 상태 관리

운영체제의 핵심인 프로세스 상태 관리를 Rust로 직접 구현해봅니다. Ready, Running, Blocked 등 다양한 프로세스 상태와 상태 전이를 안전하게 관리하는 방법을 배우고, 실제 OS 레벨에서 프로세스 스케줄링의 기초를 다집니다.


목차

  1. 프로세스 상태 정의 - 생명주기의 시작
  2. 상태 전이 구현 - 안전한 변화 관리
  3. Ready 큐 구현 - 실행 대기 프로세스 관리
  4. 블록 관리 - I/O 대기 프로세스 추적
  5. 프로세스 컨텍스트 저장 - CPU 상태 보존
  6. 타이머 기반 선점 - 공정한 CPU 시간 배분
  7. 프로세스 생성 - 새로운 생명의 탄생
  8. 프로세스 종료 - 자원 정리와 회수
  9. 우선순위 스케줄링 - 중요한 작업 먼저
  10. 디버그 정보 출력 - 시스템 상태 가시화

1. 프로세스 상태 정의 - 생명주기의 시작

시작하며

여러분이 운영체제를 만들다가 "프로세스가 지금 뭘 하고 있지?"라는 질문에 답하지 못한 적 있나요? 프로세스가 CPU를 사용 중인지, 대기 중인지, 아니면 I/O를 기다리는지 추적하지 못하면 스케줄러는 제대로 동작할 수 없습니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로세스의 상태를 명확히 정의하지 않으면 데드락, 기아 상태, 비효율적인 CPU 활용 등 심각한 문제가 생깁니다.

특히 멀티태스킹 환경에서는 각 프로세스의 현재 상태를 정확히 파악하는 것이 필수입니다. 바로 이럴 때 필요한 것이 프로세스 상태 정의입니다.

Rust의 enum을 활용하면 타입 안전성을 보장하면서도 명확하게 프로세스의 생명주기를 표현할 수 있습니다.

개요

간단히 말해서, 프로세스 상태는 프로세스가 현재 어떤 단계에 있는지를 나타내는 플래그입니다. Ready(실행 준비), Running(실행 중), Blocked(대기), Terminated(종료됨) 같은 상태들이 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스케줄러가 다음에 실행할 프로세스를 선택하거나, I/O 작업이 완료된 프로세스를 다시 실행 가능 상태로 만들 때 반드시 필요합니다. 예를 들어, 디스크 읽기를 요청한 프로세스는 Blocked 상태로 전환하고, 읽기가 완료되면 Ready 상태로 되돌려야 합니다.

전통적인 방법과의 비교를 하자면, C로 작성된 운영체제에서는 정수나 비트 플래그로 상태를 표현했습니다. 이제는 Rust의 enum을 사용하여 컴파일 타임에 잘못된 상태 전이를 방지할 수 있습니다.

이 개념의 핵심 특징은 타입 안전성, 명확한 상태 구분, 패턴 매칭을 통한 처리입니다. 이러한 특징들이 버그를 줄이고 유지보수를 쉽게 만듭니다.

코드 예제

// 프로세스의 가능한 모든 상태를 정의
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessState {
    Ready,        // 실행 준비 완료
    Running,      // 현재 CPU에서 실행 중
    Blocked,      // I/O 또는 이벤트 대기 중
    Terminated,   // 실행 완료
}

// 프로세스 제어 블록(PCB)의 핵심 구조
pub struct Process {
    pub pid: u32,
    pub state: ProcessState,
    pub priority: u8,
}

설명

이것이 하는 일: 프로세스가 가질 수 있는 모든 상태를 명확한 타입으로 정의하여 컴파일러가 잘못된 상태 사용을 방지하도록 합니다. 첫 번째로, ProcessState enum은 Ready, Running, Blocked, Terminated 네 가지 상태를 정의합니다.

Debug trait은 디버깅 출력을 가능하게 하고, Clone과 Copy는 상태를 복사할 수 있게 하며, PartialEq와 Eq는 상태 비교를 가능하게 합니다. 이렇게 derive 매크로를 사용하면 보일러플레이트 코드를 줄일 수 있습니다.

그 다음으로, Process 구조체가 실제 프로세스를 표현합니다. pid는 프로세스 식별자, state는 현재 상태, priority는 스케줄링 우선순위를 나타냅니다.

이 구조체는 운영체제의 프로세스 테이블에 저장되어 모든 프로세스를 추적하는 데 사용됩니다. 상태 값이 enum으로 정의되어 있기 때문에 match 표현식을 사용할 때 모든 경우를 처리했는지 컴파일러가 검사합니다.

만약 새로운 상태를 추가하면 모든 match 문에서 컴파일 에러가 발생하므로 누락을 방지할 수 있습니다. 여러분이 이 코드를 사용하면 정수나 문자열로 상태를 표현할 때 발생하는 타입 오류를 완전히 제거할 수 있습니다.

또한 IDE의 자동완성과 컴파일러의 검사를 통해 개발 속도와 안정성을 동시에 확보할 수 있습니다.

실전 팁

💡 PartialEq를 derive하면 상태 비교(state == ProcessState::Ready)가 간단해지고, 코드 가독성이 크게 향상됩니다.

💡 Copy trait을 구현하면 상태를 이동시키지 않고 복사하므로 소유권 문제를 피할 수 있습니다. 특히 스케줄러에서 유용합니다.

💡 디버깅 시 {:?} 포맷터로 상태를 출력하려면 Debug trait이 필수입니다. derive(Debug)를 항상 추가하세요.

💡 상태에 추가 정보가 필요하면 enum variant에 데이터를 포함시킬 수 있습니다. 예: Blocked(WaitReason)

💡 exhaustive matching을 활용하여 모든 상태를 처리했는지 확인하세요. 컴파일러가 누락된 케이스를 알려줍니다.


2. 상태 전이 구현 - 안전한 변화 관리

시작하며

여러분이 프로세스를 Running에서 Ready로 바꾸려는데, 실수로 Terminated에서 Running으로 전환하는 버그를 만든 적 있나요? 잘못된 상태 전이는 운영체제를 크래시시키거나 예측 불가능한 동작을 일으킵니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 상태 전이 규칙을 코드로 명시하지 않으면 논리적으로 불가능한 전이가 실행될 수 있습니다.

예를 들어, 종료된 프로세스를 다시 실행하거나, 실행 중이 아닌 프로세스를 블록시키는 등의 오류가 발생합니다. 바로 이럴 때 필요한 것이 상태 전이 함수입니다.

Result 타입을 활용하여 유효하지 않은 전이를 에러로 처리하면 안전성을 크게 높일 수 있습니다.

개요

간단히 말해서, 상태 전이는 프로세스가 한 상태에서 다른 상태로 변경되는 것을 의미합니다. 이때 모든 전이가 허용되는 것이 아니라 특정 규칙을 따라야 합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 운영체제의 일관성을 유지하기 위해서입니다. Running 상태의 프로세스만 Blocked로 전환할 수 있고, Ready 상태의 프로세스만 Running으로 전환할 수 있습니다.

이러한 규칙을 코드로 강제하면 논리 오류를 방지할 수 있습니다. 전통적인 방법과의 비교를 하자면, 기존에는 상태를 직접 변경했다면(process.state = NEW_STATE), 이제는 전이 함수를 통해 검증 후 변경합니다.

이렇게 하면 캡슐화와 안전성이 보장됩니다. 이 개념의 핵심 특징은 Result를 사용한 에러 처리, 명시적인 전이 규칙 정의, 불변성 유지입니다.

이러한 특징들이 디버깅을 쉽게 만들고 예상치 못한 버그를 줄입니다.

코드 예제

// 상태 전이 에러 정의
#[derive(Debug)]
pub enum StateTransitionError {
    InvalidTransition { from: ProcessState, to: ProcessState },
}

impl Process {
    // 안전한 상태 전이 함수
    pub fn transition_to(&mut self, new_state: ProcessState) -> Result<(), StateTransitionError> {
        // 유효한 전이인지 검사
        let valid = match (self.state, new_state) {
            (ProcessState::Ready, ProcessState::Running) => true,
            (ProcessState::Running, ProcessState::Ready) => true,
            (ProcessState::Running, ProcessState::Blocked) => true,
            (ProcessState::Running, ProcessState::Terminated) => true,
            (ProcessState::Blocked, ProcessState::Ready) => true,
            (current, target) if current == target => true, // 같은 상태는 허용
            _ => false,
        };

        if valid {
            self.state = new_state;
            Ok(())
        } else {
            Err(StateTransitionError::InvalidTransition {
                from: self.state,
                to: new_state,
            })
        }
    }
}

설명

이것이 하는 일: 프로세스 상태 변경을 함수로 캡슐화하고, 유효하지 않은 전이를 컴파일 타임이 아닌 런타임에 검증하여 에러를 반환합니다. 첫 번째로, StateTransitionError enum은 잘못된 전이가 발생했을 때 어떤 상태에서 어떤 상태로 전환하려 했는지 정보를 담습니다.

이렇게 하면 디버깅할 때 정확한 원인을 파악할 수 있습니다. Debug trait을 derive하여 에러 메시지를 쉽게 출력할 수 있습니다.

그 다음으로, transition_to 메서드가 실제 상태 전이를 수행합니다. match 표현식으로 현재 상태(self.state)와 목표 상태(new_state)의 조합을 검사합니다.

예를 들어, Ready에서 Running으로, Running에서 Blocked로 전환은 허용되지만, Terminated에서 Running으로는 불가능합니다. 세 번째로, valid 변수가 true이면 상태를 변경하고 Ok(())를 반환하며, false이면 Err를 반환합니다.

호출하는 쪽에서는 Result를 match하거나 ? 연산자로 처리할 수 있습니다.

이러한 패턴은 Rust의 에러 처리 철학과 완벽히 일치합니다. 여러분이 이 코드를 사용하면 잘못된 상태 전이를 즉시 발견할 수 있습니다.

예를 들어, process.transition_to(ProcessState::Running)이 실패하면 로그를 남기거나 커널 패닉을 발생시켜 문제를 빠르게 해결할 수 있습니다. 또한 코드 리뷰 시 상태 전이 로직을 한눈에 파악할 수 있어 유지보수성이 크게 향상됩니다.

실전 팁

💡 ? 연산자를 활용하여 에러를 상위로 전파하면 코드가 간결해집니다. process.transition_to(state)?처럼 사용하세요.

💡 로깅 시스템과 결합하여 모든 상태 전이를 기록하면 디버깅과 모니터링이 쉬워집니다. 특히 커널 디버깅에서 유용합니다.

💡 테스트에서는 의도적으로 잘못된 전이를 시도하여 에러가 제대로 반환되는지 확인하세요. 이것이 진정한 방어적 프로그래밍입니다.

💡 상태 전이 다이어그램을 그려서 코드와 비교하면 누락된 전이나 잘못된 규칙을 쉽게 찾을 수 있습니다.

💡 성능이 중요하면 match 대신 룩업 테이블을 사용할 수 있지만, 대부분의 경우 match의 오버헤드는 무시할 수 있습니다.


3. Ready 큐 구현 - 실행 대기 프로세스 관리

시작하며

여러분이 스케줄러를 만들다가 "다음에 실행할 프로세스를 어디서 찾지?"라는 고민을 한 적 있나요? 실행 준비가 된 프로세스들을 효율적으로 관리하지 못하면 CPU 활용률이 떨어지고 응답 시간이 길어집니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. Ready 상태의 프로세스들을 추적하지 못하면 스케줄러가 매번 전체 프로세스 테이블을 순회해야 하므로 O(n) 시간이 걸립니다.

이는 프로세스 수가 많아질수록 심각한 성능 저하로 이어집니다. 바로 이럴 때 필요한 것이 Ready 큐입니다.

VecDeque를 사용하면 양 끝에서 O(1) 시간에 삽입/삭제가 가능하여 효율적인 스케줄링이 가능합니다.

개요

간단히 말해서, Ready 큐는 실행 준비가 완료된 프로세스들을 저장하는 자료구조입니다. 스케줄러는 이 큐에서 프로세스를 꺼내 CPU에 할당합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, FIFO(First In First Out) 스케줄링이나 Round Robin 같은 알고리즘을 구현할 때 필수적입니다. 예를 들어, 타임 슬라이스가 끝난 프로세스를 큐의 뒤쪽에 다시 넣고, 앞에서 새 프로세스를 꺼내는 방식으로 공정한 CPU 시간 배분이 가능합니다.

전통적인 방법과의 비교를 하자면, 기존에는 연결 리스트를 직접 구현했다면, 이제는 Rust 표준 라이브러리의 VecDeque를 사용하여 안전하고 효율적인 큐를 즉시 만들 수 있습니다. 이 개념의 핵심 특징은 양방향 큐 지원, O(1) 시간 복잡도, 동적 크기 조정입니다.

이러한 특징들이 스케줄러 성능을 최적화하고 메모리를 효율적으로 사용하게 합니다.

코드 예제

use std::collections::VecDeque;

// Ready 큐를 관리하는 스케줄러
pub struct Scheduler {
    ready_queue: VecDeque<u32>,  // 프로세스 ID 저장
}

impl Scheduler {
    pub fn new() -> Self {
        Scheduler {
            ready_queue: VecDeque::new(),
        }
    }

    // 프로세스를 Ready 큐에 추가
    pub fn enqueue(&mut self, pid: u32) {
        self.ready_queue.push_back(pid);
    }

    // 다음 실행할 프로세스 선택
    pub fn dequeue(&mut self) -> Option<u32> {
        self.ready_queue.pop_front()
    }

    // Ready 큐가 비어있는지 확인
    pub fn is_empty(&self) -> bool {
        self.ready_queue.is_empty()
    }
}

설명

이것이 하는 일: 실행 준비가 된 프로세스들을 큐에 저장하고, 스케줄러가 다음 프로세스를 빠르게 선택할 수 있도록 합니다. 첫 번째로, Scheduler 구조체는 VecDeque<u32>를 사용하여 프로세스 ID를 저장합니다.

VecDeque는 양방향 큐로, 앞(front)과 뒤(back) 모두에서 효율적인 삽입/삭제가 가능합니다. 이는 내부적으로 링 버퍼(ring buffer)를 사용하여 구현됩니다.

그 다음으로, enqueue 메서드는 새로운 프로세스를 큐의 뒤쪽에 추가합니다. 프로세스가 생성되거나, Blocked에서 Ready로 전환되거나, 타임 슬라이스가 끝나면 이 메서드가 호출됩니다.

push_back은 O(1) 시간 복잡도를 가지므로 매우 빠릅니다. 세 번째로, dequeue 메서드는 큐의 앞쪽에서 프로세스를 제거하고 반환합니다.

Option<u32>를 반환하므로 큐가 비어있으면 None이 반환되어 안전하게 처리할 수 있습니다. 스케줄러는 이 메서드를 호출하여 다음 실행할 프로세스를 선택합니다.

마지막으로, is_empty 메서드는 Ready 큐에 프로세스가 있는지 확인합니다. 모든 프로세스가 Blocked 또는 Terminated 상태이면 CPU는 idle 상태로 들어가야 하므로 이 검사가 중요합니다.

여러분이 이 코드를 사용하면 Round Robin이나 FIFO 스케줄링을 쉽게 구현할 수 있습니다. 또한 우선순위 큐(BinaryHeap)로 변경하면 우선순위 기반 스케줄링도 가능합니다.

VecDeque의 자동 메모리 관리 덕분에 메모리 누수 걱정 없이 안전하게 운영할 수 있습니다.

실전 팁

💡 우선순위 스케줄링이 필요하면 VecDeque 대신 BinaryHeap을 사용하세요. 최대/최소 우선순위를 O(log n)에 찾을 수 있습니다.

💡 멀티코어 시스템에서는 각 CPU 코어마다 별도의 Ready 큐를 유지하면 캐시 지역성이 향상됩니다.

💡 큐가 너무 커지면 메모리 압박이 생기므로, 최대 크기를 설정하고 초과 시 낮은 우선순위 프로세스를 swap out하세요.

💡 디버깅 시 ready_queue.len()을 로깅하면 시스템 부하를 모니터링할 수 있습니다. 큐 길이가 계속 증가하면 CPU 부족 신호입니다.

💡 락프리(lock-free) 큐를 원하면 crossbeam 크레이트의 SegQueue를 고려하세요. 멀티스레드 환경에서 성능이 우수합니다.


4. 블록 관리 - I/O 대기 프로세스 추적

시작하며

여러분이 디스크 읽기를 요청한 프로세스를 계속 스케줄링하다가 "왜 이 프로세스가 아무것도 안 하고 있지?"라고 당황한 적 있나요? I/O 작업을 기다리는 프로세스를 제대로 관리하지 못하면 CPU 사이클을 낭비하고 시스템 효율이 떨어집니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. Blocked 상태의 프로세스를 Ready 큐에 남겨두면 스케줄러가 실행을 시도하지만 실제로는 아무 작업도 할 수 없어 컨텍스트 스위칭 오버헤드만 증가합니다.

또한 I/O가 완료된 후 어떤 프로세스를 깨워야 하는지 추적하지 못하면 응답 시간이 길어집니다. 바로 이럴 때 필요한 것이 블록 관리 시스템입니다.

대기 중인 프로세스를 이벤트별로 분류하여 저장하면 I/O 완료 시 해당 프로세스만 빠르게 찾아 깨울 수 있습니다.

개요

간단히 말해서, 블록 관리는 I/O나 이벤트를 기다리는 프로세스를 추적하고, 조건이 충족되면 다시 Ready 상태로 전환하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU와 I/O를 효율적으로 중첩(overlap)하기 위해서입니다.

한 프로세스가 디스크를 읽는 동안 다른 프로세스가 CPU를 사용하면 전체 처리량이 크게 증가합니다. 예를 들어, 웹 서버에서 파일을 읽는 동안 다른 요청을 처리할 수 있습니다.

전통적인 방법과의 비교를 하자면, 기존에는 busy waiting으로 I/O 완료를 기다렸다면, 이제는 프로세스를 Blocked 상태로 만들고 이벤트 발생 시 깨우는 방식으로 CPU를 효율적으로 활용합니다. 이 개념의 핵심 특징은 이벤트 기반 대기, HashMap을 통한 빠른 조회, 자동 깨우기 메커니즘입니다.

이러한 특징들이 응답성과 처리량을 동시에 향상시킵니다.

코드 예제

use std::collections::HashMap;

// 대기 이벤트 타입
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WaitEvent {
    DiskRead(u32),   // 디스크 읽기 (디바이스 ID)
    DiskWrite(u32),  // 디스크 쓰기
    NetworkRecv,     // 네트워크 수신
    TimerExpiry,     // 타이머 만료
}

// Blocked 프로세스 관리자
pub struct BlockManager {
    // 이벤트별로 대기 중인 프로세스 목록
    wait_queues: HashMap<WaitEvent, Vec<u32>>,
}

impl BlockManager {
    pub fn new() -> Self {
        BlockManager {
            wait_queues: HashMap::new(),
        }
    }

    // 프로세스를 특정 이벤트에 블록
    pub fn block_on(&mut self, pid: u32, event: WaitEvent) {
        self.wait_queues.entry(event)
            .or_insert_with(Vec::new)
            .push(pid);
    }

    // 이벤트 발생 시 대기 프로세스 모두 깨우기
    pub fn wake_all(&mut self, event: WaitEvent) -> Vec<u32> {
        self.wait_queues.remove(&event).unwrap_or_default()
    }
}

설명

이것이 하는 일: 프로세스가 어떤 이벤트를 기다리는지 추적하고, 이벤트 발생 시 해당 프로세스들만 선택적으로 깨워 Ready 상태로 전환합니다. 첫 번째로, WaitEvent enum은 프로세스가 대기할 수 있는 다양한 이벤트를 정의합니다.

DiskRead는 디바이스 ID를 포함하여 여러 디스크를 구분하고, Hash trait을 derive하여 HashMap의 키로 사용할 수 있습니다. 이렇게 하면 동일한 이벤트를 기다리는 프로세스들을 그룹화할 수 있습니다.

그 다음으로, BlockManager는 HashMap<WaitEvent, Vec<u32>>를 사용합니다. 각 이벤트를 키로, 해당 이벤트를 기다리는 프로세스 ID 목록을 값으로 저장합니다.

이 구조 덕분에 특정 이벤트가 발생했을 때 O(1) 시간에 대기 프로세스 목록을 찾을 수 있습니다. 세 번째로, block_on 메서드는 프로세스를 특정 이벤트에 블록시킵니다.

entry API를 사용하여 키가 없으면 빈 Vec을 생성하고(or_insert_with), 있으면 기존 Vec을 가져와 프로세스 ID를 추가합니다. 이 패턴은 Rust에서 매우 일반적이며 효율적입니다.

마지막으로, wake_all 메서드는 이벤트가 발생했을 때 호출됩니다. remove를 사용하여 HashMap에서 대기 큐를 제거하고 반환하므로, 이미 처리된 이벤트에 대한 메모리가 자동으로 해제됩니다.

unwrap_or_default는 키가 없으면 빈 Vec을 반환하여 안전하게 처리합니다. 여러분이 이 코드를 사용하면 I/O 완료 인터럽트 핸들러에서 wake_all을 호출하여 대기 프로세스들을 즉시 Ready 큐로 이동시킬 수 있습니다.

예를 들어, 디스크 컨트롤러가 인터럽트를 발생시키면 wake_all(WaitEvent::DiskRead(0))을 호출하여 해당 디스크를 기다리던 모든 프로세스를 깨웁니다. 이렇게 하면 I/O 응답 시간이 최소화됩니다.

실전 팁

💡 wake_all 대신 wake_one을 구현하면 Thundering Herd 문제를 피할 수 있습니다. 특히 mutex 대기에서 유용합니다.

💡 타임아웃 기능을 추가하려면 타이머와 연동하여 일정 시간 후 강제로 깨우세요. 무한 대기를 방지할 수 있습니다.

💡 통계를 수집하려면 block_on과 wake_all에서 대기 시간을 측정하세요. I/O 성능 분석에 필수적입니다.

💡 이벤트를 너무 세분화하면 HashMap이 커지므로, 적절한 추상화 수준을 유지하세요. 디바이스마다 구분하되 섹터별로는 구분하지 않는 것이 일반적입니다.

💡 멀티코어 환경에서는 RwLock으로 wait_queues를 보호하여 동시 접근을 허용하면 확장성이 향상됩니다.


5. 프로세스 컨텍스트 저장 - CPU 상태 보존

시작하며

여러분이 프로세스를 전환할 때 "레지스터 값이 어디 갔지?"라며 당황한 적 있나요? CPU에서 실행 중이던 프로세스의 상태를 저장하지 않으면 다음에 실행할 때 완전히 다른 값으로 동작하여 데이터 손상이 발생합니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로세스 스위칭 시 프로그램 카운터(PC), 스택 포인터(SP), 범용 레지스터 등을 저장하지 않으면 프로세스는 이전 실행 지점으로 돌아갈 수 없습니다.

특히 멀티태스킹 환경에서는 이것이 치명적입니다. 바로 이럴 때 필요한 것이 프로세스 컨텍스트입니다.

CPU의 모든 레지스터 값을 구조체에 저장하면 나중에 정확히 같은 상태로 복원할 수 있습니다.

개요

간단히 말해서, 프로세스 컨텍스트는 CPU에서 실행 중인 프로세스의 모든 상태 정보를 담는 스냅샷입니다. 레지스터, 플래그, 프로그램 카운터 등이 포함됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 컨텍스트 스위칭의 핵심이기 때문입니다. 스케줄러가 프로세스 A를 중단하고 B를 실행할 때, A의 컨텍스트를 저장하고 B의 컨텍스트를 복원해야 합니다.

예를 들어, A가 반복문 중간에 있었다면 다음 실행 시 정확히 그 지점에서 재개되어야 합니다. 전통적인 방법과의 비교를 하자면, 어셈블리로 직접 레지스터를 저장/복원했다면, 이제는 Rust 구조체로 명확히 정의하고 인라인 어셈블리로 안전하게 처리합니다.

이 개념의 핵심 특징은 완전한 상태 보존, 빠른 저장/복원, 아키텍처 독립적 인터페이스입니다. 이러한 특징들이 멀티태스킹의 투명성과 정확성을 보장합니다.

코드 예제

// x86_64 아키텍처의 프로세스 컨텍스트
#[repr(C)]
#[derive(Debug, Clone)]
pub struct Context {
    pub rsp: u64,    // 스택 포인터
    pub rbp: u64,    // 베이스 포인터
    pub rip: u64,    // 명령어 포인터 (프로그램 카운터)
    pub rflags: u64, // 플래그 레지스터
    pub rax: u64,    // 범용 레지스터
    pub rbx: u64,
    pub rcx: u64,
    pub rdx: u64,
    pub rsi: u64,
    pub rdi: u64,
    pub r8: u64,
    pub r9: u64,
    pub r10: u64,
    pub r11: u64,
    pub r12: u64,
    pub r13: u64,
    pub r14: u64,
    pub r15: u64,
}

impl Context {
    // 새 컨텍스트 생성 (초기 프로세스용)
    pub fn new(entry_point: u64, stack_top: u64) -> Self {
        Context {
            rip: entry_point,  // 프로세스 시작 주소
            rsp: stack_top,    // 스택 최상단
            rbp: stack_top,
            rflags: 0x202,     // 인터럽트 활성화
            rax: 0, rbx: 0, rcx: 0, rdx: 0,
            rsi: 0, rdi: 0,
            r8: 0, r9: 0, r10: 0, r11: 0,
            r12: 0, r13: 0, r14: 0, r15: 0,
        }
    }
}

설명

이것이 하는 일: 프로세스가 중단될 때 CPU의 모든 레지스터를 메모리에 저장하고, 재개될 때 다시 로드하여 정확히 이전 상태에서 실행을 계속합니다. 첫 번째로, Context 구조체는 x86_64 아키텍처의 주요 레지스터를 모두 포함합니다.

repr(C) 속성은 C 언어와 동일한 메모리 레이아웃을 보장하여 어셈블리 코드와 상호작용할 때 필수적입니다. rip(명령어 포인터)는 다음 실행할 명령어 주소를, rsp(스택 포인터)는 현재 스택 위치를 나타냅니다.

그 다음으로, 범용 레지스터(rax~r15)는 프로세스가 계산 중이던 임시 값들을 담고 있습니다. 이들을 저장하지 않으면 다음 실행 시 완전히 다른 값으로 시작하여 결과가 잘못됩니다.

rflags는 CPU의 조건 플래그(zero, carry 등)를 포함하여 분기문의 정확한 동작을 보장합니다. 세 번째로, new 메서드는 새로운 프로세스를 위한 초기 컨텍스트를 생성합니다.

entry_point는 프로세스가 처음 시작할 함수 주소이고, stack_top은 할당된 스택의 최상단 주소입니다. rflags를 0x202로 설정하면 인터럽트 플래그(IF)가 활성화되어 타이머 인터럽트를 받을 수 있습니다.

실제 컨텍스트 스위칭은 인라인 어셈블리로 구현됩니다. 현재 프로세스의 레지스터를 Context 구조체에 저장(save)하고, 다음 프로세스의 Context에서 레지스터를 복원(restore)합니다.

이 과정은 수십 나노초 내에 완료되어야 하므로 최적화가 중요합니다. 여러분이 이 코드를 사용하면 프로세스 스위칭을 안전하게 구현할 수 있습니다.

예를 들어, 타이머 인터럽트 핸들러에서 현재 프로세스의 컨텍스트를 저장하고, 스케줄러가 선택한 다음 프로세스의 컨텍스트를 복원하여 실행을 재개합니다. Rust의 타입 시스템 덕분에 잘못된 크기나 정렬로 인한 버그를 컴파일 타임에 잡을 수 있습니다.

실전 팁

💡 FPU/SSE 레지스터도 저장해야 부동소수점 연산 중인 프로세스가 올바르게 동작합니다. fxsave/fxrstor 명령어를 사용하세요.

💡 컨텍스트 구조체를 캐시 라인(64바이트) 경계에 정렬하면 메모리 접근 성능이 향상됩니다. #[repr(align(64))] 사용하세요.

💡 디버깅 시 컨텍스트를 출력하면 프로세스 상태를 한눈에 파악할 수 있습니다. Debug trait이 매우 유용합니다.

💡 lazy context switch 기법을 사용하면 FPU 레지스터는 필요할 때만 저장하여 오버헤드를 줄일 수 있습니다.

💡 각 아키텍처마다 다른 Context 구조체를 정의하고, 조건부 컴파일(#[cfg(target_arch)])로 선택하세요.


6. 타이머 기반 선점 - 공정한 CPU 시간 배분

시작하며

여러분이 프로세스 하나가 CPU를 독점하여 다른 프로세스들이 아무것도 못 하는 상황을 겪은 적 있나요? 협력적 멀티태스킹(cooperative)만으로는 악의적이거나 버그가 있는 프로세스가 시스템 전체를 멈출 수 있습니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로세스가 자발적으로 CPU를 양보하지 않으면 다른 프로세스들이 기아 상태(starvation)에 빠집니다.

특히 무한 루프를 도는 프로세스가 있으면 시스템 전체가 응답하지 않습니다. 바로 이럴 때 필요한 것이 타이머 기반 선점(preemption)입니다.

하드웨어 타이머가 주기적으로 인터럽트를 발생시켜 강제로 프로세스를 전환하면 공정한 CPU 시간 배분이 가능합니다.

개요

간단히 말해서, 타이머 선점은 일정 시간마다 현재 프로세스를 중단하고 다른 프로세스에게 CPU를 넘기는 메커니즘입니다. 이것이 진정한 멀티태스킹의 핵심입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 응답성과 공정성을 보장하기 위해서입니다. 타임 슬라이스(time slice)를 10ms로 설정하면 모든 프로세스가 최소한 초당 100번은 실행 기회를 얻습니다.

예를 들어, 대화형 프로그램이 사용자 입력에 빠르게 반응하려면 자주 실행되어야 하는데, 선점형 스케줄링이 이를 보장합니다. 전통적인 방법과의 비교를 하자면, 협력적 멀티태스킹에서는 프로세스가 명시적으로 yield()를 호출했다면, 이제는 타이머 인터럽트가 강제로 전환하므로 프로세스의 협조가 필요 없습니다.

이 개념의 핵심 특징은 하드웨어 타이머 사용, 주기적인 강제 전환, 설정 가능한 타임 슬라이스입니다. 이러한 특징들이 현대 운영체제의 기반을 이룹니다.

코드 예제

// 타이머 인터럽트 핸들러
pub fn timer_interrupt_handler(context: &mut Context) {
    static mut TICKS: u64 = 0;
    const TIME_SLICE: u64 = 10; // 10 틱마다 스케줄링

    unsafe {
        TICKS += 1;

        // 타임 슬라이스 만료 확인
        if TICKS % TIME_SLICE == 0 {
            // 현재 프로세스 컨텍스트 저장
            save_current_context(context);

            // 현재 프로세스를 Ready로 전환
            if let Some(current) = get_current_process() {
                current.transition_to(ProcessState::Ready).ok();
                SCHEDULER.enqueue(current.pid);
            }

            // 다음 프로세스 선택
            if let Some(next_pid) = SCHEDULER.dequeue() {
                let next = get_process(next_pid).unwrap();
                next.transition_to(ProcessState::Running).ok();
                restore_context(&next.context);
            }
        }
    }
}

설명

이것이 하는 일: 하드웨어 타이머가 일정 간격으로 인터럽트를 발생시키면, 핸들러가 현재 프로세스를 중단하고 다음 프로세스로 전환합니다. 첫 번째로, TICKS 정적 변수는 타이머 인터럽트가 발생한 횟수를 추적합니다.

TIME_SLICE 상수는 프로세스가 연속으로 실행할 수 있는 최대 틱 수를 정의합니다. 10 틱마다 스케줄링하면 타이머 주파수가 1000Hz일 때 10ms마다 전환이 발생합니다.

이 값을 조정하여 컨텍스트 스위칭 오버헤드와 응답성 사이의 균형을 맞출 수 있습니다. 그 다음으로, 타임 슬라이스가 만료되면(TICKS % TIME_SLICE == 0) 스케줄링이 시작됩니다.

먼저 save_current_context를 호출하여 현재 실행 중인 프로세스의 CPU 레지스터를 Context 구조체에 저장합니다. 이렇게 하면 나중에 정확히 같은 지점에서 재개할 수 있습니다.

세 번째로, 현재 프로세스를 Running에서 Ready 상태로 전환하고 Ready 큐의 뒤쪽에 추가합니다. 이것이 Round Robin 스케줄링의 핵심입니다.

모든 프로세스가 순환하며 공정하게 CPU 시간을 받습니다. transition_to를 호출하여 상태 전이 규칙을 따르는지 검증합니다.

마지막으로, SCHEDULER.dequeue()로 Ready 큐에서 다음 프로세스를 선택합니다. 선택된 프로세스를 Running 상태로 전환하고, restore_context로 저장된 레지스터 값을 CPU에 로드합니다.

이 시점부터 새 프로세스가 실행을 시작하거나 재개합니다. 여러분이 이 코드를 사용하면 어떤 프로세스도 CPU를 독점할 수 없습니다.

무한 루프를 도는 프로세스가 있어도 10ms마다 다른 프로세스에게 기회가 돌아가므로 시스템이 멈추지 않습니다. 또한 대화형 프로그램의 응답 시간이 개선되어 사용자 경험이 향상됩니다.

타임 슬라이스를 조정하면 배치 작업과 대화형 작업 사이의 우선순위를 조절할 수 있습니다.

실전 팁

💡 타임 슬라이스를 너무 짧게 설정하면 컨텍스트 스위칭 오버헤드가 커지고, 너무 길면 응답성이 떨어집니다. 5-20ms가 일반적입니다.

💡 I/O 집약적 프로세스는 타임 슬라이스를 다 쓰지 않고 Blocked 상태로 전환되므로, 별도의 우선순위를 부여하면 좋습니다.

💡 멀티코어 시스템에서는 각 코어마다 독립적인 타이머 인터럽트를 설정하여 병렬로 스케줄링하세요.

💡 인터럽트 핸들러는 최대한 빠르게 실행되어야 하므로, 복잡한 로직은 별도의 bottom-half 핸들러로 분리하세요.

💡 통계를 수집하여 각 프로세스의 CPU 사용 시간을 추적하면 성능 분석과 빌링에 활용할 수 있습니다.


7. 프로세스 생성 - 새로운 생명의 탄생

시작하며

여러분이 새 프로그램을 실행하려는데 "프로세스를 어떻게 만들지?"라는 질문에 막힌 적 있나요? 단순히 구조체를 생성하는 것만으로는 부족하고, 메모리 할당, 스택 설정, 초기 컨텍스트 구성 등 많은 단계가 필요합니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로세스 생성 시 스택을 할당하지 않으면 첫 함수 호출에서 크래시가 발생하고, 초기 컨텍스트를 잘못 설정하면 엉뚱한 주소로 점프하여 보호 위반이 일어납니다.

또한 프로세스 ID를 고유하게 관리하지 않으면 충돌이 발생합니다. 바로 이럴 때 필요한 것이 체계적인 프로세스 생성 함수입니다.

모든 초기화 단계를 하나의 함수로 캡슐화하면 실수를 줄이고 일관성을 유지할 수 있습니다.

개요

간단히 말해서, 프로세스 생성은 실행 가능한 프로그램을 메모리에 로드하고, 필요한 자원을 할당하며, 초기 상태를 설정하여 스케줄러에 등록하는 전체 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 사용자가 프로그램을 실행하거나 시스템 서비스를 시작할 때 필수적입니다.

예를 들어, 셸에서 명령어를 입력하면 새 프로세스가 생성되어 해당 프로그램을 실행합니다. 또한 fork 시스템 콜을 구현하려면 부모 프로세스를 복제하는 생성 로직이 필요합니다.

전통적인 방법과의 비교를 하자면, 어셈블리로 수동으로 스택을 설정하고 레지스터를 초기화했다면, 이제는 Rust 함수로 안전하게 추상화하여 메모리 안전성을 보장합니다. 이 개념의 핵심 특징은 자동 자원 할당, 고유 PID 생성, 초기 상태 설정입니다.

이러한 특징들이 프로세스 관리의 복잡성을 숨기고 간단한 인터페이스를 제공합니다.

코드 예제

use alloc::vec::Vec;

// 전역 프로세스 ID 카운터
static mut NEXT_PID: u32 = 1;

// 프로세스 테이블
static mut PROCESS_TABLE: Option<Vec<Process>> = None;

// 새 프로세스 생성
pub fn create_process(entry_point: u64, priority: u8) -> Result<u32, &'static str> {
    unsafe {
        // 고유 PID 생성
        let pid = NEXT_PID;
        NEXT_PID += 1;

        // 스택 할당 (4KB)
        let stack = allocate_stack(4096)?;
        let stack_top = stack.as_ptr() as u64 + 4096;

        // 초기 컨텍스트 생성
        let context = Context::new(entry_point, stack_top);

        // 프로세스 구조체 생성
        let process = Process {
            pid,
            state: ProcessState::Ready,
            priority,
            context,
            stack,
        };

        // 프로세스 테이블에 추가
        if let Some(ref mut table) = PROCESS_TABLE {
            table.push(process);
        }

        // Ready 큐에 추가
        SCHEDULER.enqueue(pid);

        Ok(pid)
    }
}

설명

이것이 하는 일: 새 프로세스를 실행하는 데 필요한 모든 자원을 할당하고 초기화하여 스케줄러가 즉시 실행할 수 있도록 준비합니다. 첫 번째로, NEXT_PID 정적 변수를 증가시켜 고유한 프로세스 ID를 생성합니다.

이 값은 시스템 전체에서 절대 중복되지 않으므로 프로세스를 식별하는 데 안전하게 사용할 수 있습니다. 실제 운영체제에서는 PID를 재사용하기도 하지만, 간단한 구현에서는 계속 증가시키는 방식이 충분합니다.

그 다음으로, allocate_stack으로 4KB 크기의 스택을 할당합니다. 스택은 함수 호출 시 지역 변수와 반환 주소를 저장하는 데 필요합니다.

stack_top은 스택의 최상단 주소로, x86_64에서는 스택이 아래로 자라므로(높은 주소에서 낮은 주소로) 가장 높은 주소를 가리킵니다. 할당 실패 시 ?

연산자로 에러를 반환합니다. 세 번째로, Context::new로 초기 컨텍스트를 생성합니다.

entry_point는 프로세스가 처음 실행할 함수 주소이고, stack_top은 초기 스택 포인터입니다. 이렇게 하면 프로세스가 처음 스케줄될 때 해당 함수부터 실행을 시작합니다.

모든 레지스터는 0으로 초기화되고 rflags는 인터럽트를 활성화합니다. 네 번째로, Process 구조체를 생성하고 PROCESS_TABLE에 추가합니다.

상태는 Ready로 설정하여 즉시 실행 가능함을 나타냅니다. priority는 스케줄링 우선순위로, 나중에 우선순위 기반 스케줄러를 구현할 때 사용됩니다.

마지막으로, SCHEDULER.enqueue(pid)로 새 프로세스를 Ready 큐에 추가합니다. 이제 스케줄러가 다음에 이 프로세스를 선택하면 entry_point 함수가 실행됩니다.

여러분이 이 코드를 사용하면 한 줄로 새 프로세스를 생성할 수 있습니다: create_process(main as u64, 5). 모든 복잡한 초기화가 자동으로 처리되므로 실수할 여지가 없습니다.

Result 타입을 반환하므로 메모리 부족 등의 에러를 안전하게 처리할 수 있습니다. 또한 스택을 Vec으로 관리하여 프로세스가 종료될 때 자동으로 해제되므로 메모리 누수를 방지할 수 있습니다.

실전 팁

💡 스택 크기를 프로세스 종류에 따라 조정하세요. 커널 스레드는 작은 스택(4KB), 사용자 프로세스는 큰 스택(1MB)이 일반적입니다.

💡 PID 재사용을 구현하려면 비트맵이나 free list를 사용하여 종료된 프로세스의 PID를 추적하세요. 이렇게 하면 PID 고갈을 방지할 수 있습니다.

💡 부모-자식 관계를 추적하려면 Process 구조체에 parent_pid 필드를 추가하세요. wait 시스템 콜 구현에 필수적입니다.

💡 프로세스 생성 시 보안 속성(UID, GID, 권한)도 초기화하면 권한 관리가 가능합니다. 실제 운영체제에서는 필수입니다.

💡 동시성이 중요하면 PROCESS_TABLE을 Mutex로 보호하여 여러 CPU 코어에서 동시에 프로세스를 생성할 수 있도록 하세요.


8. 프로세스 종료 - 자원 정리와 회수

시작하며

여러분이 프로세스를 종료했는데 메모리가 계속 줄어드는 현상을 겪은 적 있나요? 프로세스가 사용하던 자원을 제대로 해제하지 않으면 메모리 누수가 발생하여 시스템이 점점 느려지고 결국 멈춥니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로세스 종료 시 스택, 힙, 파일 디스크립터, 네트워크 소켓 등 모든 자원을 해제해야 합니다.

또한 부모 프로세스에게 종료를 알려야 하고, 자식 프로세스가 있으면 재배치(reparenting)해야 합니다. 바로 이럴 때 필요한 것이 체계적인 프로세스 종료 함수입니다.

모든 정리 단계를 순서대로 수행하면 자원 누수를 방지하고 시스템 안정성을 유지할 수 있습니다.

개요

간단히 말해서, 프로세스 종료는 실행을 중단하고 사용하던 모든 자원을 운영체제에 반환하는 과정입니다. 상태를 Terminated로 변경하고 정리 작업을 수행합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 장시간 실행되는 서버에서 메모리 누수를 방지하기 위해서입니다. 프로세스가 매번 종료될 때마다 자원을 확실히 해제해야 시스템이 안정적으로 동작합니다.

예를 들어, 웹 서버에서 요청을 처리하는 워커 프로세스가 종료될 때 소켓을 닫지 않으면 파일 디스크립터가 고갈됩니다. 전통적인 방법과의 비교를 하자면, C에서는 free를 수동으로 호출했다면, Rust에서는 Drop trait과 RAII 패턴을 활용하여 자동으로 정리하므로 실수를 줄일 수 있습니다.

이 개념의 핵심 특징은 자동 자원 해제, 부모 프로세스 통지, 좀비 프로세스 방지입니다. 이러한 특징들이 시스템의 건강성을 유지합니다.

코드 예제

// 프로세스 종료 함수
pub fn terminate_process(pid: u32, exit_code: i32) -> Result<(), &'static str> {
    unsafe {
        // 프로세스 찾기
        let process = get_process_mut(pid).ok_or("Process not found")?;

        // 이미 종료되었는지 확인
        if process.state == ProcessState::Terminated {
            return Err("Process already terminated");
        }

        // 상태를 Terminated로 전환
        process.transition_to(ProcessState::Terminated)?;
        process.exit_code = exit_code;

        // 스택 해제 (Drop trait이 자동 처리)
        // process.stack은 Vec이므로 자동으로 drop됨

        // 열린 파일 디스크립터 닫기
        for fd in &process.open_files {
            close_file(*fd);
        }
        process.open_files.clear();

        // 부모 프로세스에게 SIGCHLD 시그널 전송
        if let Some(parent_pid) = process.parent_pid {
            send_signal(parent_pid, Signal::SIGCHLD);
        }

        // 자식 프로세스를 init (PID 1)으로 재배치
        for child_pid in &process.children {
            reparent_to_init(*child_pid);
        }

        // Ready 큐에서 제거 (이미 Running 상태였다면)
        SCHEDULER.remove(pid);

        // 통계 업데이트
        update_process_stats(pid, exit_code);

        Ok(())
    }
}

설명

이것이 하는 일: 프로세스를 안전하게 종료하고, 사용하던 메모리, 파일, 네트워크 연결 등을 모두 해제하며, 관련된 다른 프로세스들에게 알립니다. 첫 번째로, get_process_mut로 종료할 프로세스를 찾습니다.

이미 Terminated 상태이면 중복 종료를 방지하기 위해 에러를 반환합니다. 그런 다음 transition_to로 상태를 Terminated로 변경하고 exit_code를 저장합니다.

이 코드는 프로그램의 성공/실패를 나타내며, 부모 프로세스가 wait 시스템 콜로 확인할 수 있습니다. 그 다음으로, 자원 해제가 시작됩니다.

process.stack은 Vec<u8>이므로 Rust의 Drop trait이 자동으로 메모리를 해제합니다. 이것이 RAII(Resource Acquisition Is Initialization) 패턴의 핵심입니다.

명시적으로 free를 호출할 필요가 없어 실수를 방지합니다. 세 번째로, 열린 파일 디스크립터를 모두 닫습니다.

open_files 벡터를 순회하며 각 파일을 close_file로 닫고, clear()로 벡터를 비웁니다. 파일을 닫지 않으면 운영체제의 파일 디스크립터 한도에 도달하여 새 파일을 열 수 없게 됩니다.

네 번째로, 부모 프로세스에게 SIGCHLD 시그널을 전송합니다. 이렇게 하면 부모가 자식의 종료를 감지하고 wait를 호출하여 좀비 프로세스를 제거할 수 있습니다.

Unix의 전통적인 프로세스 관리 방식을 따릅니다. 다섯 번째로, 자식 프로세스들을 init 프로세스(PID 1)로 재배치합니다.

부모가 종료되면 고아(orphan) 프로세스가 생기는데, init이 이들을 입양하여 책임집니다. 이렇게 하면 프로세스 트리 구조가 유지됩니다.

여러분이 이 코드를 사용하면 exit 시스템 콜을 안전하게 구현할 수 있습니다. 프로세스가 정상 종료하든 크래시하든 모든 자원이 해제되므로 메모리 누수가 발생하지 않습니다.

또한 부모-자식 관계를 올바르게 관리하여 좀비 프로세스 문제를 방지합니다. 통계를 수집하면 프로세스 실행 시간, 메모리 사용량 등을 분석할 수 있습니다.

실전 팁

💡 좀비 프로세스를 방지하려면 부모가 wait를 호출할 때까지 프로세스 구조체를 유지하고, 호출 후 완전히 제거하세요.

💡 강제 종료(kill -9)를 구현할 때도 terminate_process를 재사용하면 일관된 정리가 보장됩니다. 추가 플래그로 구분하세요.

💡 스레드를 지원하면 모든 스레드를 먼저 종료한 후 프로세스를 정리해야 합니다. 순서가 중요합니다.

💡 메모리 매핑(mmap)을 사용했다면 munmap으로 해제해야 합니다. 자동으로 해제되지 않으므로 명시적 처리가 필요합니다.

💡 디버깅 시 종료된 프로세스의 로그를 남기면 크래시 원인을 분석할 수 있습니다. exit_code와 함께 스택 트레이스를 저장하세요.


9. 우선순위 스케줄링 - 중요한 작업 먼저

시작하며

여러분이 중요한 백그라운드 작업과 대화형 프로그램을 동시에 실행할 때, 둘 다 똑같은 시간을 받아서 UI가 버벅거린 적 있나요? 모든 프로세스를 동등하게 취급하면 중요도를 반영할 수 없어 사용자 경험이 나빠집니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 비디오 재생 같은 시간에 민감한 작업은 높은 우선순위가 필요하고, 로그 분석 같은 배치 작업은 낮은 우선순위로 실행해도 됩니다.

우선순위를 고려하지 않으면 모든 작업이 뒤섞여 효율이 떨어집니다. 바로 이럴 때 필요한 것이 우선순위 스케줄링입니다.

각 프로세스에 우선순위를 부여하고, 높은 우선순위 프로세스를 먼저 실행하면 시스템 응답성이 크게 향상됩니다.

개요

간단히 말해서, 우선순위 스케줄링은 프로세스마다 중요도를 나타내는 숫자를 할당하고, 스케줄러가 우선순위가 높은 프로세스를 먼저 선택하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 서로 다른 종류의 작업을 공정하게 처리하기 위해서입니다.

대화형 프로그램은 빠른 응답이 중요하고, 배치 작업은 처리량이 중요합니다. 우선순위로 구분하면 각 작업의 특성에 맞게 스케줄링할 수 있습니다.

예를 들어, 오디오 재생 프로세스는 최고 우선순위로 설정하여 끊김 없는 재생을 보장합니다. 전통적인 방법과의 비교를 하자면, FIFO 큐만 사용했다면, 이제는 우선순위 큐(BinaryHeap)를 사용하여 자동으로 최고 우선순위 프로세스를 선택합니다.

이 개념의 핵심 특징은 동적 우선순위 조정, 기아 방지 메커니즘, O(log n) 선택 시간입니다. 이러한 특징들이 실시간 시스템과 일반 시스템 모두에서 활용됩니다.

코드 예제

use std::collections::BinaryHeap;
use std::cmp::Ordering;

// 우선순위를 가진 프로세스 래퍼
#[derive(Eq, PartialEq)]
struct PriorityProcess {
    pid: u32,
    priority: u8,  // 높을수록 우선순위 높음 (0-255)
}

// BinaryHeap은 최대 힙이므로 priority가 높은 것이 먼저 나옴
impl Ord for PriorityProcess {
    fn cmp(&self, other: &Self) -> Ordering {
        self.priority.cmp(&other.priority)
    }
}

impl PartialOrd for PriorityProcess {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

// 우선순위 기반 스케줄러
pub struct PriorityScheduler {
    ready_queue: BinaryHeap<PriorityProcess>,
}

impl PriorityScheduler {
    pub fn new() -> Self {
        PriorityScheduler {
            ready_queue: BinaryHeap::new(),
        }
    }

    // 우선순위와 함께 프로세스 추가
    pub fn enqueue(&mut self, pid: u32, priority: u8) {
        self.ready_queue.push(PriorityProcess { pid, priority });
    }

    // 최고 우선순위 프로세스 선택
    pub fn dequeue(&mut self) -> Option<u32> {
        self.ready_queue.pop().map(|p| p.pid)
    }

    // 프로세스 우선순위 동적 조정
    pub fn adjust_priority(&mut self, pid: u32, new_priority: u8) {
        // 큐를 재구성하여 우선순위 반영
        let mut temp: Vec<_> = self.ready_queue.drain().collect();
        for process in &mut temp {
            if process.pid == pid {
                process.priority = new_priority;
            }
        }
        self.ready_queue = temp.into_iter().collect();
    }
}

설명

이것이 하는 일: 각 프로세스에 우선순위를 부여하고, 스케줄러가 항상 최고 우선순위 프로세스를 선택하여 중요한 작업이 먼저 처리되도록 합니다. 첫 번째로, PriorityProcess 구조체는 프로세스 ID와 우선순위를 함께 저장합니다.

Ord와 PartialOrd trait을 구현하여 BinaryHeap이 우선순위로 정렬할 수 있게 합니다. priority가 높을수록 먼저 나오도록 cmp에서 직접 비교합니다.

BinaryHeap은 최대 힙이므로 가장 큰 값이 먼저 pop됩니다. 그 다음으로, PriorityScheduler는 BinaryHeap<PriorityProcess>를 사용합니다.

enqueue는 새 프로세스를 힙에 추가하며, 내부적으로 O(log n) 시간에 올바른 위치에 삽입합니다. dequeue는 최대값(최고 우선순위)을 O(log n) 시간에 꺼냅니다.

VecDeque의 O(1)보다 느리지만, 우선순위를 고려하므로 가치가 있습니다. 세 번째로, adjust_priority는 실행 중인 프로세스의 우선순위를 동적으로 변경합니다.

BinaryHeap은 내부 요소 수정을 직접 지원하지 않으므로, drain으로 모든 요소를 꺼내 Vec으로 만들고, 해당 PID의 우선순위를 수정한 후 다시 힙으로 변환합니다. 이 작업은 O(n)이지만, 자주 호출되지 않으면 문제없습니다.

실제 운영체제에서는 우선순위가 고정되지 않고 동적으로 변합니다. CPU를 많이 사용한 프로세스는 우선순위가 낮아지고(aging down), 오래 대기한 프로세스는 높아져(aging up) 기아를 방지합니다.

이를 구현하려면 타이머 인터럽트마다 adjust_priority를 호출하여 공정성을 유지합니다. 여러분이 이 코드를 사용하면 시스템 응답성이 크게 향상됩니다.

예를 들어, 마우스 커서 이동 프로세스를 최고 우선순위(255)로 설정하면 시스템이 아무리 바빠도 커서는 부드럽게 움직입니다. 백그라운드 다운로드는 낮은 우선순위(50)로 실행하여 사용자 작업을 방해하지 않습니다.

우선순위를 잘 설정하면 멀티태스킹 환경에서도 쾌적한 사용 경험을 제공할 수 있습니다.

실전 팁

💡 기아(starvation)를 방지하려면 일정 시간마다 모든 프로세스의 우선순위를 조금씩 올리세요. 낮은 우선순위 프로세스도 언젠가는 실행됩니다.

💡 실시간 시스템에서는 우선순위를 정적으로 고정하고, 일반 시스템에서는 동적으로 조정하는 하이브리드 방식이 효과적입니다.

💡 우선순위 역전(priority inversion)을 방지하려면 우선순위 상속(priority inheritance) 프로토콜을 구현하세요. 뮤텍스와 함께 사용됩니다.

💡 nice 값(-20~19)을 지원하려면 priority를 계산 공식으로 변환하세요. priority = 140 - nice가 Linux의 방식입니다.

💡 멀티큐 스케줄링을 구현하려면 우선순위 범위별로 여러 개의 큐를 유지하고, 각 큐에 다른 타임 슬라이스를 부여하세요.


10. 디버그 정보 출력 - 시스템 상태 가시화

시작하며

여러분이 스케줄러가 제대로 동작하는지 확인하려는데 "지금 어떤 프로세스가 실행 중이지? Ready 큐에는 뭐가 있지?"라는 질문에 답할 방법이 없어서 답답한 적 있나요?

시스템 상태를 볼 수 없으면 버그를 찾기가 거의 불가능합니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.

운영체제 개발은 일반 애플리케이션과 달리 디버거를 사용하기 어렵고, 브레이크포인트를 설정하면 시스템 전체가 멈춥니다. 따라서 실행 중인 상태를 출력하는 기능이 필수적입니다.

바로 이럴 때 필요한 것이 디버그 정보 출력 함수입니다. 모든 프로세스의 상태, Ready 큐 내용, 현재 실행 중인 프로세스 등을 한눈에 볼 수 있으면 문제를 빠르게 파악할 수 있습니다.

개요

간단히 말해서, 디버그 정보 출력은 시스템의 현재 상태를 사람이 읽을 수 있는 형식으로 표시하여 개발자가 동작을 이해하고 버그를 찾을 수 있게 하는 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널 개발은 블랙박스 같아서 내부를 들여다볼 방법이 필요하기 때문입니다.

예를 들어, 프로세스가 Blocked 상태에서 빠져나오지 못하는 버그가 있다면, 디버그 출력으로 어떤 이벤트를 기다리고 있는지 확인할 수 있습니다. 전통적인 방법과의 비교를 하자면, printk나 printf로 산발적으로 출력했다면, 이제는 구조화된 함수로 일관된 형식의 정보를 제공합니다.

이 개념의 핵심 특징은 구조화된 출력, 필터링 가능한 정보, 성능 통계 포함입니다. 이러한 특징들이 디버깅과 성능 분석을 동시에 지원합니다.

코드 예제

// 시스템 상태 출력
pub fn print_system_state() {
    println!("=== Process State Dump ===");

    unsafe {
        // 현재 실행 중인 프로세스
        if let Some(current) = get_current_process() {
            println!("Current Process: PID={}, Priority={}, State={:?}",
                current.pid, current.priority, current.state);
        } else {
            println!("Current Process: None (idle)");
        }

        println!("\nReady Queue:");
        let queue_snapshot = SCHEDULER.snapshot();
        for (idx, pid) in queue_snapshot.iter().enumerate() {
            if let Some(p) = get_process(*pid) {
                println!("  [{}] PID={}, Priority={}, Ticks={}",
                    idx, p.pid, p.priority, p.ticks_used);
            }
        }

        println!("\nBlocked Processes:");
        for (event, pids) in BLOCK_MANAGER.get_all_waits() {
            println!("  Event: {:?}", event);
            for pid in pids {
                if let Some(p) = get_process(*pid) {
                    println!("    PID={}, Wait Time={}ms",
                        p.pid, p.block_time);
                }
            }
        }

        println!("\nAll Processes:");
        if let Some(ref table) = PROCESS_TABLE {
            for process in table {
                println!("  PID={}, State={:?}, Priority={}, CPU Time={}ms",
                    process.pid,
                    process.state,
                    process.priority,
                    process.total_cpu_time);
            }
        }

        println!("\nStatistics:");
        println!("  Total Context Switches: {}", CONTEXT_SWITCHES);
        println!("  Total Processes Created: {}", TOTAL_PROCESSES);
        println!("  Active Processes: {}", count_active_processes());
    }

    println!("=========================");
}

설명

이것이 하는 일: 시스템의 전체 상태를 스냅샷으로 캡처하여 현재 실행 중인 프로세스, 대기 중인 프로세스, 블록된 프로세스 등을 한 번에 보여줍니다. 첫 번째로, 현재 실행 중인 프로세스를 출력합니다.

get_current_process()로 CPU를 점유한 프로세스를 가져와 PID, 우선순위, 상태를 표시합니다. 아무것도 실행 중이지 않으면 "idle"이라고 표시하여 CPU가 쉬고 있음을 나타냅니다.

이 정보는 컨텍스트 스위칭 버그를 찾는 데 매우 유용합니다. 그 다음으로, Ready 큐의 모든 프로세스를 인덱스 순서대로 출력합니다.

snapshot 메서드는 큐를 수정하지 않고 복사본을 반환하여 시스템 동작을 방해하지 않습니다. 각 프로세스의 ticks_used를 표시하여 얼마나 CPU를 사용했는지 추적합니다.

큐가 비어있으면 모든 프로세스가 Blocked 또는 Terminated 상태라는 의미입니다. 세 번째로, Blocked 프로세스들을 대기 중인 이벤트별로 그룹화하여 표시합니다.

get_all_waits는 BlockManager의 내부 HashMap을 순회하여 각 이벤트와 대기 프로세스 목록을 반환합니다. block_time을 표시하여 얼마나 오래 기다렸는지 알 수 있습니다.

특정 프로세스가 너무 오래 블록되어 있으면 데드락이나 I/O 문제를 의심할 수 있습니다. 네 번째로, 전체 프로세스 테이블을 순회하며 모든 프로세스의 요약 정보를 출력합니다.

상태별로 분류하지 않고 한 번에 보여주므로 전체적인 그림을 파악하기 좋습니다. total_cpu_time은 프로세스가 생성된 이후 사용한 총 CPU 시간으로, 어떤 프로세스가 CPU를 많이 사용하는지 분석할 수 있습니다.

마지막으로, 시스템 전체 통계를 출력합니다. CONTEXT_SWITCHES는 컨텍스트 스위칭이 얼마나 자주 발생하는지, TOTAL_PROCESSES는 지금까지 생성된 프로세스 수를 나타냅니다.

count_active_processes()는 Terminated가 아닌 프로세스 수를 계산하여 시스템 부하를 추정합니다. 여러분이 이 코드를 사용하면 디버깅이 훨씬 쉬워집니다.

예를 들어, 타이머 인터럽트마다 또는 특정 키 입력 시 print_system_state를 호출하면 시스템 동작을 실시간으로 모니터링할 수 있습니다. 버그가 발생했을 때 마지막 출력을 보면 어떤 상태에서 문제가 생겼는지 즉시 알 수 있습니다.

또한 성능 분석 시 어떤 프로세스가 CPU를 독점하는지 찾는 데도 유용합니다.

실전 팁

💡 디버그 출력은 시스템 성능에 영향을 주므로, 릴리즈 빌드에서는 조건부 컴파일(#[cfg(debug_assertions)])로 제거하세요.

💡 시리얼 포트나 별도의 디버그 콘솔로 출력하면 메인 화면을 방해하지 않고 정보를 볼 수 있습니다.

💡 JSON 형식으로 출력하면 스크립트로 파싱하여 자동 분석하거나 그래프를 그릴 수 있습니다.

💡 특정 PID만 필터링하여 출력하는 함수를 추가하면 관심 있는 프로세스만 집중해서 볼 수 있습니다.

💡 타임스탬프를 포함하면 이벤트의 순서와 간격을 분석할 수 있어 타이밍 버그를 찾는 데 도움이 됩니다.


#Rust#ProcessState#OSKernel#StateManagement#Scheduling#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.