이미지 로딩 중...

Rust로 만드는 나만의 OS 24 태스크 스위칭 - 슬라이드 1/13
A

AI Generated

2025. 11. 13. · 5 Views

Rust로 만드는 나만의 OS 24 태스크 스위칭

OS 개발에서 가장 핵심적인 태스크 스위칭 메커니즘을 Rust로 구현하는 방법을 다룹니다. 컨텍스트 저장부터 스케줄링, 실제 전환까지 완벽하게 마스터해보세요.


목차

  1. 태스크 컨텍스트 구조체 - CPU 상태를 담는 핵심 자료구조
  2. 컨텍스트 스위칭 함수 - 실제로 태스크를 전환하는 저수준 코드
  3. 태스크 제어 블록 - 태스크의 모든 정보를 담는 구조체
  4. 라운드 로빈 스케줄러 - 공평하게 CPU 시간을 배분하는 알고리즘
  5. 타이머 인터럽트 통합 - 주기적으로 스케줄러를 호출하는 메커니즘
  6. 유휴 태스크 - 할 일이 없을 때 실행되는 특별한 태스크
  7. 협력적 양보 메커니즘 - 태스크가 자발적으로 CPU를 반환하는 방법
  8. 태스크 종료 처리 - 리소스를 정리하고 태스크를 제거하는 메커니즘
  9. 멀티코어 스케줄링 - 여러 CPU 코어에서 동시에 태스크 실행하기
  10. 우선순위 기반 스케줄링 - 중요한 태스크를 먼저 실행하는 알고리즘
  11. 실시간 스케줄링 - 데드라인을 보장하는 결정론적 스케줄링
  12. 컨텍스트 스위칭 최적화 - 전환 속도를 극대화하는 기법들

1. 태스크 컨텍스트 구조체 - CPU 상태를 담는 핵심 자료구조

시작하며

여러분이 멀티태스킹 OS를 만들 때 가장 먼저 직면하는 문제가 있습니다. "어떻게 하나의 CPU로 여러 프로그램을 동시에 실행할 수 있을까?" 하는 질문이죠.

답은 바로 아주 빠르게 프로그램들을 전환하면서 실행하는 것입니다. 이런 전환이 가능하려면 현재 실행 중인 프로그램의 모든 상태를 어딘가에 저장해야 합니다.

CPU 레지스터 값, 스택 포인터, 프로그램 카운터 등 모든 것을 말이죠. 이것을 잃어버리면 프로그램은 다시 돌아왔을 때 어디서부터 실행해야 할지 알 수 없게 됩니다.

바로 이럴 때 필요한 것이 태스크 컨텍스트 구조체입니다. 이것은 태스크의 모든 CPU 상태를 담는 일종의 스냅샷입니다.

이를 통해 언제든지 태스크를 멈추고, 다른 태스크를 실행하고, 다시 원래 태스크로 돌아올 수 있습니다.

개요

간단히 말해서, 태스크 컨텍스트는 특정 시점의 CPU 상태를 모두 저장하는 자료구조입니다. 실제 OS 개발에서 이것 없이는 태스크 스위칭이 불가능합니다.

x86_64 아키텍처에서는 범용 레지스터(rax, rbx, rcx 등), 스택 포인터(rsp), 베이스 포인터(rbp), 그리고 명령 포인터(rip) 등을 저장해야 합니다. 또한 callee-saved 레지스터들도 반드시 보존해야 하는데, 이는 함수 호출 규약에서 호출된 함수가 보존해야 할 레지스터들입니다.

기존에 어셈블리로 모든 레지스터를 수동으로 저장했다면, Rust에서는 구조체를 활용해 타입 안전하게 관리할 수 있습니다. repr(C) 어트리뷰트를 사용하면 메모리 레이아웃을 정확히 제어할 수 있어 어셈블리와의 인터페이스도 안전합니다.

태스크 컨텍스트의 핵심 특징은 첫째, 모든 중요한 레지스터 값을 포함한다는 점, 둘째, 메모리 레이아웃이 예측 가능해야 한다는 점, 셋째, 빠르게 저장하고 복원할 수 있어야 한다는 점입니다. 이러한 특징들이 원활한 태스크 전환을 가능하게 만듭니다.

코드 예제

// x86_64 아키텍처의 태스크 컨텍스트 구조체
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct TaskContext {
    // Callee-saved 레지스터들
    pub r15: u64,
    pub r14: u64,
    pub r13: u64,
    pub r12: u64,
    pub rbx: u64,
    pub rbp: u64,
    // 다음 실행할 명령의 주소
    pub rip: u64,
    // 스택 포인터
    pub rsp: u64,
}

impl TaskContext {
    pub const fn new() -> Self {
        Self {
            r15: 0, r14: 0, r13: 0, r12: 0,
            rbx: 0, rbp: 0, rip: 0, rsp: 0,
        }
    }
}

설명

이것이 하는 일: TaskContext 구조체는 태스크가 실행을 중단했을 때의 CPU 상태 전체를 메모리에 보존합니다. 이를 통해 나중에 정확히 그 시점부터 실행을 재개할 수 있습니다.

첫 번째로, repr(C) 어트리뷰트는 구조체의 메모리 레이아웃을 C 언어 방식으로 고정합니다. 이렇게 하는 이유는 어셈블리 코드에서 이 구조체에 접근할 때 각 필드의 정확한 오프셋을 알아야 하기 때문입니다.

Rust의 기본 레이아웃은 최적화를 위해 필드 순서를 바꿀 수 있지만, repr(C)를 사용하면 선언한 순서대로 메모리에 배치됩니다. 그 다음으로, callee-saved 레지스터들(r12-r15, rbx, rbp)만 저장하는 이유를 알아야 합니다.

x86_64 호출 규약에서는 함수를 호출할 때 caller-saved 레지스터는 호출자가 저장하고, callee-saved 레지스터는 피호출자가 저장해야 합니다. 우리는 함수 호출 경계에서 컨텍스트 전환을 수행하므로, callee-saved 레지스터만 보존하면 됩니다.

rip(명령 포인터)는 다음에 실행할 명령어의 주소를 가리킵니다. 태스크를 재개할 때 이 주소로 점프하면 정확히 멈췄던 곳부터 실행이 계속됩니다.

rsp(스택 포인터)는 현재 스택의 최상단을 가리키며, 함수 호출과 로컬 변수에 필수적입니다. 여러분이 이 구조체를 사용하면 태스크의 실행 상태를 완벽하게 캡처하고 복원할 수 있습니다.

메모리 안전성은 Rust가 보장하고, 레이아웃 안전성은 repr(C)가 보장하며, 실행 흐름의 정확성은 올바른 레지스터 선택이 보장합니다.

실전 팁

💡 repr(C)를 빼먹으면 필드 순서가 뒤바뀔 수 있어 어셈블리 코드와 불일치가 발생합니다. 반드시 추가하세요.

💡 Debug와 Clone, Copy 트레잇을 derive하면 디버깅과 컨텍스트 복사가 편리합니다. 특히 디버깅 시 레지스터 값을 출력할 수 있어 유용합니다.

💡 caller-saved 레지스터(rax, rdi, rsi 등)는 저장하지 않아도 됩니다. 함수 호출 규약에 따라 컴파일러가 이미 처리합니다.

💡 새로운 태스크를 만들 때는 rip를 태스크의 진입점 함수 주소로, rsp를 태스크의 스택 끝으로 초기화해야 합니다.

💡 x86_64가 아닌 다른 아키텍처(ARM, RISC-V)에서는 저장해야 할 레지스터가 다릅니다. 각 아키텍처의 ABI 문서를 참고하세요.


2. 컨텍스트 스위칭 함수 - 실제로 태스크를 전환하는 저수준 코드

시작하며

여러분이 태스크 컨텍스트 구조체를 만들었다면, 이제 이것을 실제로 저장하고 복원하는 코드가 필요합니다. "어떻게 현재 실행 중인 코드를 멈추고 다른 코드로 점프할까?" 하는 마법 같은 과정이죠.

이 과정은 매우 정밀해야 합니다. 레지스터 하나라도 잘못 저장하거나 복원하면 전체 시스템이 크래시됩니다.

또한 이 코드는 OS 전체에서 가장 자주 실행되는 핫 패스(hot path)이므로 성능도 중요합니다. 바로 이럴 때 필요한 것이 컨텍스트 스위칭 함수입니다.

Rust의 인라인 어셈블리를 활용해 CPU 레지스터를 직접 조작하고, 한 태스크에서 다른 태스크로 실행 흐름을 전환합니다.

개요

간단히 말해서, 컨텍스트 스위칭 함수는 현재 태스크의 레지스터를 저장하고 다음 태스크의 레지스터를 복원하는 저수준 함수입니다. 실제 OS에서 이 함수는 타이머 인터럽트나 시스템 콜에서 호출됩니다.

예를 들어, 10ms마다 타이머 인터럽트가 발생하면 스케줄러가 다음 실행할 태스크를 선택하고, 이 함수를 호출해 실제 전환을 수행합니다. 이것이 멀티태스킹의 핵심 메커니즘입니다.

기존에 순수 어셈블리로 작성했다면, 이제는 Rust의 asm! 매크로를 사용해 타입 안전성과 가독성을 높일 수 있습니다.

Rust 컴파일러가 인라인 어셈블리를 검증하고 최적화까지 수행합니다. 이 함수의 핵심 특징은 첫째, 원자적(atomic)이어야 한다는 점(중간에 인터럽트되면 안 됨), 둘째, 모든 필수 레지스터를 정확히 저장/복원해야 한다는 점, 셋째, 스택을 올바르게 전환해야 한다는 점입니다.

이러한 특징들이 안전하고 정확한 태스크 전환을 보장합니다.

코드 예제

// 컨텍스트 스위칭을 수행하는 함수
#[naked]
pub unsafe extern "C" fn switch_context(
    old: *mut TaskContext,
    new: *const TaskContext
) {
    core::arch::asm!(
        // 현재 태스크의 컨텍스트 저장
        "mov [rdi + 0x00], r15",
        "mov [rdi + 0x08], r14",
        "mov [rdi + 0x10], r13",
        "mov [rdi + 0x18], r12",
        "mov [rdi + 0x20], rbx",
        "mov [rdi + 0x28], rbp",
        // 리턴 주소를 rip로 저장
        "mov rax, [rsp]",
        "mov [rdi + 0x30], rax",
        // 현재 스택 포인터 저장
        "lea rax, [rsp + 8]",
        "mov [rdi + 0x38], rax",

        // 새로운 태스크의 컨텍스트 복원
        "mov r15, [rsi + 0x00]",
        "mov r14, [rsi + 0x08]",
        "mov r13, [rsi + 0x10]",
        "mov r12, [rsi + 0x18]",
        "mov rbx, [rsi + 0x20]",
        "mov rbp, [rsi + 0x28]",
        // 스택 전환
        "mov rsp, [rsi + 0x38]",
        // 새로운 태스크로 점프
        "jmp [rsi + 0x30]",
        options(noreturn)
    );
}

설명

이것이 하는 일: switch_context 함수는 CPU를 현재 태스크에서 완전히 분리하고, 새로운 태스크에 연결합니다. 이 함수가 반환될 때는 이미 다른 태스크의 컨텍스트에서 실행되고 있습니다.

첫 번째로, #[naked] 어트리뷰트는 Rust 컴파일러에게 함수 프롤로그/에필로그를 생성하지 말라고 지시합니다. 일반 함수는 자동으로 스택 프레임을 설정하고 레지스터를 저장하지만, 우리는 직접 모든 레지스터를 제어해야 하므로 이것이 필요합니다.

extern "C"는 C 호출 규약을 따르도록 하여 rdi와 rsi로 인자를 받습니다. 그 다음으로, 현재 컨텍스트 저장 단계를 살펴봅시다.

rdi는 old 포인터를 가리키며, 각 레지스터를 구조체의 해당 오프셋에 저장합니다. 특히 중요한 것은 리턴 주소 처리입니다.

[rsp]에는 이 함수를 호출한 곳의 다음 명령어 주소가 있으므로, 이것을 rip 필드에 저장합니다. 스택 포인터는 리턴 주소를 제외한 값(rsp + 8)을 저장해야 합니다.

세 번째 단계는 새로운 컨텍스트 복원입니다. rsi가 가리키는 new 컨텍스트에서 모든 레지스터 값을 읽어와 CPU 레지스터에 복원합니다.

마지막으로 rsp를 전환하면 스택이 바뀌고, jmp 명령으로 새로운 태스크의 rip 주소로 점프하면 완전히 다른 실행 흐름으로 전환됩니다. options(noreturn)은 이 어셈블리 블록이 절대 반환하지 않음을 컴파일러에게 알립니다.

실제로 jmp로 다른 곳으로 점프하므로 이 함수는 정상적으로 반환되지 않습니다. 새로운 태스크가 나중에 다시 전환되어 돌아올 때, 그 태스크의 관점에서는 switch_context 호출이 "반환"되는 것처럼 보입니다.

여러분이 이 함수를 사용하면 완벽한 태스크 격리와 전환을 구현할 수 있습니다. 각 태스크는 독립적인 스택과 실행 흐름을 가지며, 서로 영향을 주지 않고 동시에 실행되는 것처럼 보입니다.

성능도 뛰어나 수십만 번의 컨텍스트 전환도 원활하게 처리합니다.

실전 팁

💡 인터럽트를 비활성화한 상태에서만 호출하세요. 컨텍스트 전환 중에 인터럽트가 발생하면 시스템이 망가집니다.

💡 오프셋 값(0x00, 0x08 등)은 TaskContext 구조체의 필드 순서와 정확히 일치해야 합니다. 구조체를 수정하면 여기도 반드시 수정하세요.

💡 디버깅 시 QEMU의 -d int,cpu_reset 옵션을 사용하면 레지스터 상태를 추적할 수 있습니다. 컨텍스트 전환 버그를 찾는 데 필수적입니다.

💡 새로운 태스크를 처음 시작할 때는 초기 컨텍스트를 주의깊게 설정해야 합니다. rip는 태스크 함수 주소, rsp는 스택 끝에서 약간 떨어진 위치로 설정하세요.

💡 성능이 중요하다면 캐시 친화적으로 TaskContext를 배치하세요. 자주 전환되는 태스크들의 컨텍스트를 같은 캐시 라인에 두면 속도가 향상됩니다.


3. 태스크 제어 블록 - 태스크의 모든 정보를 담는 구조체

시작하며

여러분이 컨텍스트 전환만으로는 완전한 태스크 관리 시스템을 만들 수 없다는 것을 곧 깨닫게 됩니다. "이 태스크의 우선순위는?", "현재 상태는?", "어떤 리소스를 가지고 있나?" 같은 정보들도 필요하기 때문이죠.

실제 OS에서는 각 태스크가 단순히 CPU 컨텍스트 이상의 많은 메타데이터를 가집니다. 프로세스 ID, 부모-자식 관계, 열린 파일 디스크립터, 메모리 맵, 시그널 핸들러 등 수십 가지 정보를 추적해야 합니다.

이것들을 체계적으로 관리하지 않으면 OS는 금방 혼란에 빠집니다. 바로 이럴 때 필요한 것이 태스크 제어 블록(TCB, Task Control Block)입니다.

이것은 태스크의 모든 상태와 메타데이터를 하나의 구조체에 담아 효율적으로 관리할 수 있게 해줍니다.

개요

간단히 말해서, TCB는 운영체제가 태스크를 관리하기 위해 필요한 모든 정보를 담은 자료구조입니다. 실무에서 TCB는 스케줄러의 핵심 데이터입니다.

스케줄러는 TCB 리스트를 순회하며 실행 가능한 태스크를 찾고, 우선순위를 비교하고, 컨텍스트 전환을 수행합니다. 예를 들어, 라운드 로빈 스케줄러는 ready 상태의 TCB들을 큐에 넣고 순서대로 실행합니다.

각 태스크의 실행 시간도 TCB에 기록해 통계를 낼 수 있습니다. 기존에 C로 OS를 만들 때는 포인터와 링크드 리스트로 TCB를 관리했지만, Rust에서는 Box, Arc, Option 같은 스마트 포인터로 안전하게 관리할 수 있습니다.

메모리 누수나 댕글링 포인터 같은 버그를 컴파일 타임에 방지할 수 있습니다. TCB의 핵심 특징은 첫째, 태스크의 전체 생명주기를 추적한다는 점, 둘째, 스케줄링에 필요한 모든 정보를 포함한다는 점, 셋째, 태스크 간 관계를 표현할 수 있다는 점입니다.

이러한 특징들이 복잡한 멀티태스킹 시스템을 가능하게 만듭니다.

코드 예제

// 태스크의 실행 상태
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskState {
    Ready,      // 실행 가능
    Running,    // 현재 실행 중
    Blocked,    // I/O 등을 기다림
    Terminated, // 종료됨
}

// 태스크 제어 블록
pub struct TaskControlBlock {
    pub id: usize,
    pub state: TaskState,
    pub context: TaskContext,
    pub stack: Vec<u8>,
    pub priority: u8,
    pub cpu_time: u64, // 누적 실행 시간
}

impl TaskControlBlock {
    pub fn new(id: usize, entry: fn(), stack_size: usize) -> Self {
        let mut stack = vec![0u8; stack_size];
        let stack_top = stack.as_mut_ptr() as usize + stack_size;

        let mut context = TaskContext::new();
        context.rip = entry as u64;
        context.rsp = stack_top as u64;

        Self {
            id,
            state: TaskState::Ready,
            context,
            stack,
            priority: 0,
            cpu_time: 0,
        }
    }
}

설명

이것이 하는 일: TaskControlBlock은 태스크의 전체 생명주기 동안 필요한 모든 데이터를 캡슐화합니다. 스케줄러는 이 구조체들의 컬렉션을 관리하며 멀티태스킹을 구현합니다.

첫 번째로, TaskState 열거형은 태스크의 현재 상태를 나타냅니다. Ready는 CPU만 할당되면 즉시 실행 가능한 상태, Running은 현재 CPU에서 실행 중, Blocked는 I/O나 락을 기다리는 상태, Terminated는 실행이 완료된 상태입니다.

스케줄러는 Ready 상태의 태스크들만 선택해 실행합니다. 이렇게 상태를 명확히 구분하면 스케줄링 로직이 단순해집니다.

그 다음으로, TCB의 각 필드를 살펴봅시다. id는 태스크를 고유하게 식별하며, 디버깅이나 로깅에 유용합니다.

context는 앞서 본 TaskContext로 CPU 상태를 저장합니다. stack은 이 태스크 전용 스택 메모리이며, Vec로 관리하면 크기를 동적으로 조정할 수도 있습니다.

priority는 우선순위 기반 스케줄링에 사용되고, cpu_time은 프로파일링과 페어 스케줄링에 활용됩니다. new 메서드는 새로운 태스크를 생성합니다.

먼저 지정된 크기의 스택을 할당하고, 스택 최상단 주소를 계산합니다. 스택은 높은 주소에서 낮은 주소로 자라므로 stack_top이 초기 rsp가 됩니다.

entry 함수 포인터를 rip에 설정하면, 이 태스크가 처음 실행될 때 해당 함수부터 시작합니다. 초기 상태는 Ready로 설정해 스케줄러가 선택할 수 있게 합니다.

실제 사용 시 스케줄러는 TCB들을 Vec이나 VecDeque에 담아 관리합니다. 타이머 인터럽트가 발생하면 현재 실행 중인 태스크의 TCB에 컨텍스트를 저장하고, 다음 태스크의 TCB에서 컨텍스트를 로드해 switch_context를 호출합니다.

여러분이 이 구조체를 사용하면 복잡한 태스크 관리를 체계적으로 수행할 수 있습니다. 디버깅도 쉬워지고, 새로운 스케줄링 알고리즘을 추가하기도 편리합니다.

Rust의 타입 시스템이 많은 버그를 사전에 방지해줍니다.

실전 팁

💡 스택 크기는 태스크의 호출 깊이를 고려해 결정하세요. 커널 태스크는 4KB, 유저 태스크는 8KB 이상을 권장합니다.

💡 TCB를 Box나 Arc로 감싸서 힙에 할당하면 스택 오버플로우를 방지할 수 있습니다. 특히 재귀 호출이 많은 태스크에 유용합니다.

💡 cpu_time 필드를 활용해 각 태스크의 CPU 사용률을 추적하고, 프로파일링 도구를 만들 수 있습니다.

💡 부모-자식 관계를 추가하려면 parent_id: Option<usize> 필드를 추가하세요. 프로세스 트리를 구현할 수 있습니다.

💡 태스크 종료 시 스택 메모리를 명시적으로 해제하거나, drop 구현으로 자동 정리하세요. 메모리 누수를 방지할 수 있습니다.


4. 라운드 로빈 스케줄러 - 공평하게 CPU 시간을 배분하는 알고리즘

시작하며

여러분이 여러 태스크를 만들었다면, 이제 "어떤 순서로 실행할까?"라는 문제에 직면합니다. 모든 태스크에게 공평하게 기회를 주면서도, 시스템이 효율적으로 작동하게 하려면 어떻게 해야 할까요?

가장 단순하지만 효과적인 방법은 라운드 로빈(Round-Robin) 방식입니다. 마치 친구들과 돌아가며 게임을 하듯이, 각 태스크에게 일정 시간씩 CPU를 할당하고 순환하는 것이죠.

이것은 구현이 간단하면서도 기아(starvation) 문제가 없어 실제 OS에서도 널리 사용됩니다. 바로 이럴 때 필요한 것이 라운드 로빈 스케줄러입니다.

태스크 큐를 관리하고, 타이머 인터럽트마다 다음 태스크로 전환하며, 모든 태스크가 골고루 실행되도록 보장합니다.

개요

간단히 말해서, 라운드 로빈 스케줄러는 태스크들을 큐에 넣고 순서대로 일정 시간(타임 슬라이스)씩 실행하는 스케줄링 알고리즘입니다. 실무에서 이것은 대화형 시스템에 적합합니다.

예를 들어, 사용자가 여러 프로그램을 동시에 사용할 때 각 프로그램이 빠르게 반응하는 것처럼 느껴지게 합니다. 10ms 타임 슬라이스를 사용하면 사용자는 모든 프로그램이 동시에 실행되는 것처럼 느낍니다.

Linux의 CFS(Completely Fair Scheduler)도 기본적으로 라운드 로빈 개념을 사용합니다. 기존에 우선순위 기반 스케줄러를 사용하면 낮은 우선순위 태스크가 굶을 수 있지만, 라운드 로빈은 모든 태스크가 반드시 실행 기회를 얻습니다.

물론 I/O 바운드 태스크에는 최적이 아니지만, 공평성과 단순성이 중요한 경우에 완벽합니다. 라운드 로빈의 핵심 특징은 첫째, 모든 태스크가 동등하게 취급된다는 점, 둘째, 구현이 매우 간단하다는 점(단순 큐만 있으면 됨), 셋째, 예측 가능한 응답 시간을 제공한다는 점입니다.

이러한 특징들이 공평하고 안정적인 멀티태스킹을 가능하게 합니다.

코드 예제

use alloc::collections::VecDeque;

pub struct RoundRobinScheduler {
    ready_queue: VecDeque<TaskControlBlock>,
    current_task: Option<TaskControlBlock>,
    time_slice: u64, // 밀리초 단위
}

impl RoundRobinScheduler {
    pub fn new(time_slice: u64) -> Self {
        Self {
            ready_queue: VecDeque::new(),
            current_task: None,
            time_slice,
        }
    }

    pub fn add_task(&mut self, task: TaskControlBlock) {
        self.ready_queue.push_back(task);
    }

    // 다음 태스크로 전환
    pub fn schedule(&mut self) -> Option<&mut TaskContext> {
        if let Some(mut current) = self.current_task.take() {
            current.state = TaskState::Ready;
            self.ready_queue.push_back(current);
        }

        self.current_task = self.ready_queue.pop_front();

        if let Some(ref mut task) = self.current_task {
            task.state = TaskState::Running;
            Some(&mut task.context)
        } else {
            None
        }
    }
}

설명

이것이 하는 일: RoundRobinScheduler는 실행 가능한 태스크들을 VecDeque(양방향 큐)로 관리하고, 타이머 인터럽트마다 다음 태스크로 전환합니다. 마치 원형 레일 위를 도는 것처럼 태스크들이 순환합니다.

첫 번째로, 구조체의 필드들을 살펴봅시다. ready_queue는 실행 대기 중인 태스크들을 저장하며, VecDeque를 사용하면 앞에서 꺼내고 뒤에 넣는 작업이 O(1)로 효율적입니다.

current_task는 현재 실행 중인 태스크이며, Option으로 감싸져 있어 태스크가 없을 때를 안전하게 처리합니다. time_slice는 각 태스크가 실행될 시간 할당량입니다.

그 다음으로, add_task 메서드는 새로운 태스크를 큐의 뒤에 추가합니다. 매우 단순하지만, 이것으로 동적으로 태스크를 추가할 수 있습니다.

부팅 시 커널 태스크들을 추가하거나, 사용자가 프로그램을 실행할 때 호출됩니다. schedule 메서드가 핵심입니다.

먼저 현재 태스크가 있다면 take()로 꺼내고 상태를 Ready로 바꾼 후 큐의 뒤에 다시 넣습니다. 그러면 다음 순서가 올 때 다시 실행될 수 있습니다.

그 다음 pop_front()로 큐의 앞에서 다음 태스크를 꺼내고, 이것을 current_task로 만들며 상태를 Running으로 바꿉니다. 마지막으로 새로운 현재 태스크의 컨텍스트 참조를 반환하면, 호출자는 이것으로 switch_context를 호출할 수 있습니다.

Option을 반환하는 이유는 큐가 비어있을 수도 있기 때문입니다. 그럴 때는 None을 반환하고, 호출자는 idle 상태로 들어가 다음 인터럽트를 기다립니다.

이것이 태스크가 없을 때 CPU가 쉬는 메커니즘입니다. 여러분이 이 스케줄러를 사용하면 간단하면서도 공평한 멀티태스킹을 구현할 수 있습니다.

타이머 인터럽트 핸들러에서 schedule()을 호출하고, 반환된 컨텍스트로 전환하면 됩니다. 복잡한 알고리즘 없이도 여러 태스크가 매끄럽게 실행됩니다.

실전 팁

💡 타임 슬라이스는 10-20ms가 적당합니다. 너무 짧으면 컨텍스트 전환 오버헤드가 크고, 너무 길면 응답성이 떨어집니다.

💡 Blocked 상태 태스크는 별도 큐로 관리하세요. ready_queue에는 실제로 실행 가능한 태스크만 넣어야 합니다.

💡 우선순위를 추가하려면 우선순위별로 여러 큐를 만들고, 높은 우선순위 큐를 먼저 확인하는 멀티레벨 큐를 구현하세요.

💡 CPU 친화도(affinity)를 구현하려면 각 코어마다 스케줄러 인스턴스를 두고, 태스크를 특정 코어에 고정할 수 있습니다.

💡 통계를 위해 각 태스크의 실행 횟수와 총 CPU 시간을 추적하세요. 성능 분석과 디버깅에 매우 유용합니다.


5. 타이머 인터럽트 통합 - 주기적으로 스케줄러를 호출하는 메커니즘

시작하며

여러분이 완벽한 스케줄러를 만들었어도, "누가, 언제 스케줄러를 호출할까?"라는 질문이 남습니다. 태스크는 자발적으로 CPU를 양보하지 않으므로, 강제로 개입할 방법이 필요합니다.

이것이 바로 선점형(preemptive) 멀티태스킹의 핵심입니다. 실제 OS에서는 하드웨어 타이머가 주기적으로 인터럽트를 발생시키고, OS가 이 시그널을 받아 스케줄러를 호출합니다.

이것이 없으면 악의적인 태스크가 CPU를 독점해 시스템 전체를 멈출 수 있습니다. 바로 이럴 때 필요한 것이 타이머 인터럽트 통합입니다.

하드웨어 타이머를 설정하고, 인터럽트 핸들러에서 스케줄러와 컨텍스트 스위칭을 호출해 진정한 멀티태스킹을 완성합니다.

개요

간단히 말해서, 타이머 인터럽트 통합은 하드웨어 타이머와 스케줄러를 연결해 주기적으로 태스크 전환이 일어나도록 하는 메커니즘입니다. 실무에서 이것은 OS 커널의 심장 박동과 같습니다.

x86_64에서는 보통 PIT(Programmable Interval Timer), APIC 타이머, HPET 같은 하드웨어를 사용합니다. 예를 들어, APIC 타이머를 100Hz로 설정하면 10ms마다 인터럽트가 발생하고, 이때 스케줄러가 다음 태스크를 선택합니다.

Linux도 기본적으로 이 방식을 사용하며, CONFIG_HZ로 주파수를 조정할 수 있습니다. 기존에 협력형(cooperative) 멀티태스킹을 사용하면 태스크가 명시적으로 yield()를 호출해야 하지만, 타이머 인터럽트를 사용하면 태스크와 무관하게 강제로 전환할 수 있습니다.

이것이 안정성과 공평성을 크게 향상시킵니다. 타이머 인터럽트 통합의 핵심 특징은 첫째, 주기성이 보장된다는 점, 둘째, 태스크가 저항할 수 없다는 점(선점 가능), 셋째, 타이머 해상도로 스케줄링 정밀도를 조절할 수 있다는 점입니다.

이러한 특징들이 현대적인 선점형 멀티태스킹을 가능하게 만듭니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

static mut SCHEDULER: Option<RoundRobinScheduler> = None;

// 타이머 인터럽트 핸들러
pub extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    unsafe {
        // 스케줄러에서 다음 태스크 선택
        if let Some(scheduler) = SCHEDULER.as_mut() {
            if let Some(new_context) = scheduler.schedule() {
                // 현재 컨텍스트를 저장할 위치
                let old_context = get_current_context_ptr();

                // 실제 컨텍스트 전환 수행
                switch_context(old_context, new_context);
            }
        }

        // EOI(End Of Interrupt) 신호 전송
        send_eoi();
    }
}

// 타이머 초기화
pub fn init_timer(frequency: u32) {
    // APIC 타이머 설정 (예시)
    let divisor = 1193180 / frequency;

    unsafe {
        // 타이머 모드 설정
        outb(0x43, 0x36);
        // 분주비 설정
        outb(0x40, (divisor & 0xFF) as u8);
        outb(0x40, ((divisor >> 8) & 0xFF) as u8);
    }
}

설명

이것이 하는 일: 타이머 하드웨어가 설정된 주기마다 CPU에 인터럽트 신호를 보내면, CPU는 현재 실행을 중단하고 timer_interrupt_handler를 호출합니다. 이 핸들러가 스케줄링과 전환을 수행합니다.

첫 번째로, 전역 SCHEDULER 변수를 살펴봅시다. static mut를 사용하면 프로그램 전체에서 하나의 스케줄러를 공유할 수 있습니다.

Option으로 감싸서 초기화 전에 접근하는 것을 방지합니다. unsafe 블록이 필요한 이유는 여러 인터럽트가 동시에 접근할 수 있어 데이터 레이스 가능성이 있기 때문입니다.

실제로는 인터럽트를 비활성화하거나 스핀락을 사용해 보호해야 합니다. 그 다음으로, timer_interrupt_handler의 동작을 이해해봅시다.

extern "x86-interrupt"는 인터럽트 핸들러 호출 규약을 따르도록 하며, InterruptStackFrame은 인터럽트 발생 시 자동으로 저장된 CPU 상태를 나타냅니다. 핸들러 내부에서는 먼저 스케줄러의 schedule() 메서드를 호출해 다음 태스크를 선택합니다.

새로운 컨텍스트를 받으면, 현재 태스크의 컨텍스트 포인터를 가져와 switch_context를 호출합니다. 이것이 실제 전환을 수행하는 순간입니다.

get_current_context_ptr()은 현재 실행 중인 태스크의 TCB에서 컨텍스트 주소를 반환하는 헬퍼 함수입니다. 마지막으로 send_eoi()로 인터럽트 컨트롤러에 "처리 완료" 신호를 보냅니다.

이것을 빼먹으면 다음 타이머 인터럽트가 발생하지 않아 시스템이 멈춥니다. init_timer 함수는 부팅 시 호출되어 타이머 하드웨어를 초기화하고 원하는 주파수를 설정합니다.

여러분이 이 코드를 사용하면 완전히 자동화된 선점형 멀티태스킹을 구현할 수 있습니다. 태스크는 아무것도 신경 쓸 필요 없이, 시스템이 알아서 공평하게 실행 시간을 배분합니다.

이것이 현대 OS의 기본 동작 방식입니다.

실전 팁

💡 인터럽트 핸들러는 가능한 한 빨라야 합니다. 복잡한 작업은 하단(bottom half)으로 미루고, 핸들러는 최소한의 작업만 수행하세요.

💡 스핀락이나 Mutex로 SCHEDULER를 보호해 데이터 레이스를 방지하세요. 인터럽트 비활성화만으로는 멀티코어에서 불충분합니다.

💡 타이머 주파수를 너무 높이면(1000Hz 이상) 컨텍스트 전환 오버헤드로 실제 작업 시간이 줄어듭니다. 프로파일링으로 최적값을 찾으세요.

💡 디버깅 시 타이머를 일시적으로 비활성화하면 편리합니다. QEMU에서 -no-hpet -no-acpi 옵션을 사용할 수 있습니다.

💡 실시간 시스템이라면 타이머 인터럽트 지연(jitter)을 측정하고 최소화하세요. High-resolution timer를 사용하면 정밀도가 향상됩니다.


6. 유휴 태스크 - 할 일이 없을 때 실행되는 특별한 태스크

시작하며

여러분이 스케줄러를 실행하다 보면 곧 "모든 태스크가 Blocked 상태면 어떻게 되지?"라는 질문에 직면합니다. 실행할 태스크가 없는데 CPU는 멈출 수 없으니까요.

실제로 이런 상황은 자주 발생합니다. 모든 유저 프로그램이 I/O를 기다리거나, 시스템이 부팅 직후라 아직 태스크가 준비되지 않았거나, 단순히 할 일이 없을 때 말이죠.

이럴 때 CPU가 무엇을 해야 할지 정의하지 않으면 패닉이 발생하거나 시스템이 멈춥니다. 바로 이럴 때 필요한 것이 유휴(idle) 태스크입니다.

이것은 우선순위가 가장 낮아서 다른 태스크가 없을 때만 실행되며, 보통 CPU를 저전력 상태로 만들거나 단순히 무한 루프를 돕니다.

개요

간단히 말해서, 유휴 태스크는 실행할 다른 태스크가 없을 때 자동으로 선택되는 특별한 태스크입니다. 실무에서 유휴 태스크는 전력 관리의 핵심입니다.

예를 들어, 노트북에서 아무 작업도 하지 않을 때 CPU가 뜨겁지 않은 이유가 바로 유휴 태스크가 hlt 명령을 실행해 CPU를 대기 상태로 만들기 때문입니다. Linux의 swapper 프로세스(PID 0)가 바로 이 역할을 합니다.

일부 시스템에서는 유휴 시간을 활용해 가비지 컬렉션이나 백그라운드 유지보수 작업을 수행하기도 합니다. 기존에 유휴 상태를 명시적으로 처리하지 않으면 스케줄러가 패닉하거나 undefined behavior가 발생할 수 있습니다.

유휴 태스크를 추가하면 시스템이 항상 실행 가능한 태스크를 하나 이상 가지게 되어 안정성이 크게 향상됩니다. 유휴 태스크의 핵심 특징은 첫째, 절대 Blocked 상태가 되지 않는다는 점, 둘째, 가장 낮은 우선순위를 가진다는 점, 셋째, CPU를 절약하는 명령을 실행한다는 점입니다.

이러한 특징들이 시스템의 안정성과 전력 효율을 보장합니다.

코드 예제

// 유휴 태스크 함수
fn idle_task() -> ! {
    loop {
        // CPU를 저전력 상태로 전환
        // hlt 명령은 다음 인터럽트까지 CPU를 멈춤
        unsafe {
            core::arch::asm!("hlt");
        }

        // 인터럽트가 발생하면 여기로 돌아옴
        // 다시 루프를 돌며 hlt 실행
    }
}

// 스케줄러에 유휴 태스크 추가
pub fn init_idle_task(scheduler: &mut RoundRobinScheduler) {
    const IDLE_STACK_SIZE: usize = 4096;

    let idle_tcb = TaskControlBlock::new(
        0,  // 관례적으로 PID 0
        idle_task,
        IDLE_STACK_SIZE
    );

    // 유휴 태스크의 우선순위를 최저로 설정
    // (우선순위 스케줄러를 사용하는 경우)
    // idle_tcb.priority = 255;

    scheduler.add_task(idle_tcb);
}

// 개선된 스케줄러 - 큐가 비어도 안전
impl RoundRobinScheduler {
    pub fn schedule_safe(&mut self) -> &mut TaskContext {
        // 유휴 태스크가 있으면 큐가 절대 비지 않음
        self.schedule().expect("Idle task should always exist")
    }
}

설명

이것이 하는 일: idle_task 함수는 무한 루프를 돌며 hlt 명령을 반복 실행합니다. hlt는 CPU를 정지시켜 다음 인터럽트가 올 때까지 대기하게 만듭니다.

첫 번째로, hlt 명령의 동작을 이해해야 합니다. 이 명령은 CPU의 클럭을 멈춰 전력 소비를 극적으로 줄입니다.

완전히 꺼지는 것은 아니고, 인터럽트를 감지할 수 있는 상태로 대기합니다. 타이머 인터럽트나 I/O 인터럽트가 발생하면 CPU가 깨어나 인터럽트를 처리하고, 핸들러에서 반환되면 다시 hlt 다음 명령부터 실행됩니다.

즉, 루프가 계속 돌며 다시 hlt를 실행하는 구조입니다. 그 다음으로, 함수 시그니처의 -> !를 주목하세요.

이것은 이 함수가 절대 반환하지 않음을 나타내는 never 타입입니다. 실제로 무한 루프이므로 반환할 수 없고, Rust 컴파일러는 이것을 인식해 특별히 처리합니다.

태스크 함수는 모두 이렇게 never 타입이어야 합니다. init_idle_task 함수는 시스템 초기화 시 호출되어 유휴 태스크를 생성하고 스케줄러에 추가합니다.

PID 0을 사용하는 것은 Unix 전통이며, 디버깅 시 이 태스크를 쉽게 식별할 수 있습니다. 스택 크기는 작아도 괜찮습니다.

유휴 태스크는 복잡한 작업을 하지 않으니까요. schedule_safe 메서드는 유휴 태스크가 있다는 전제 하에 unwrap 대신 expect를 사용합니다.

만약 큐가 비었다면 프로그래밍 오류이므로 명확한 메시지와 함께 패닉하는 것이 맞습니다. 실제로 유휴 태스크가 제대로 추가되었다면 이 패닉은 절대 발생하지 않습니다.

여러분이 유휴 태스크를 사용하면 시스템이 훨씬 안정적이고 예측 가능해집니다. 스케줄러 코드가 단순해지고, 전력 효율도 좋아지며, "할 일 없음" 상태를 명시적으로 처리할 수 있습니다.

모든 제대로 된 OS는 유휴 태스크를 가지고 있습니다.

실전 팁

💡 유휴 태스크 내에서 시스템 통계를 업데이트할 수 있습니다. CPU 사용률을 계산하거나, 평균 대기 시간을 추적하는 등의 작업에 활용하세요.

💡 멀티코어 시스템에서는 각 코어마다 독립적인 유휴 태스크가 필요합니다. 코어별로 하나씩 생성하세요.

💡 전력 관리를 더 고도화하려면 hlt 대신 mwait 명령을 사용하거나, ACPI C-상태를 활용해 더 깊은 슬립에 진입할 수 있습니다.

💡 유휴 태스크에서 절대 락을 잡거나 I/O를 수행하지 마세요. 이 태스크가 블록되면 시스템이 멈출 수 있습니다.

💡 디버깅 중에는 hlt 대신 단순 nop 루프를 사용하면 디버거 연결이 끊기지 않아 편리합니다.


7. 협력적 양보 메커니즘 - 태스크가 자발적으로 CPU를 반환하는 방법

시작하며

여러분이 선점형 스케줄링을 구현했지만, 때로는 태스크가 스스로 "지금은 할 일이 없으니 다른 태스크에게 양보할게"라고 말할 수 있으면 효율적입니다. 강제로 타임 슬라이스가 끝날 때까지 기다릴 필요가 없으니까요.

실제로 많은 상황에서 태스크는 자신이 언제 블록될지 압니다. 파일 읽기를 요청한 직후, 네트워크 패킷을 기다릴 때, 락을 획득하려다 실패했을 때 등 말이죠.

이런 경우 즉시 CPU를 양보하면 다른 태스크가 더 빨리 실행되어 전체 시스템 처리량이 향상됩니다. 바로 이럴 때 필요한 것이 협력적 양보 메커니즘입니다.

yield 시스템 콜을 제공해 태스크가 자발적으로 스케줄러를 호출하고 다른 태스크로 전환할 수 있게 합니다.

개요

간단히 말해서, 협력적 양보는 태스크가 명시적으로 yield()를 호출해 타임 슬라이스를 포기하고 다른 태스크에게 실행 기회를 주는 메커니즘입니다. 실무에서 이것은 선점형 스케줄링을 보완합니다.

예를 들어, 스핀락을 기다리는 태스크는 매번 yield()를 호출해 락 소유자가 실행될 기회를 늘립니다. 또는 생산자-소비자 패턴에서 버퍼가 가득 차면 생산자가 yield()로 소비자에게 양보합니다.

Linux의 sched_yield() 시스템 콜이 바로 이것입니다. 기존에 순수 협력형 시스템(예: Windows 3.1)에서는 태스크가 yield()를 호출하지 않으면 다른 태스크가 실행되지 않았지만, 선점형 + 협력형 하이브리드 방식은 양쪽의 장점을 모두 취합니다.

태스크가 협력하면 더 효율적이고, 협력하지 않아도 타이머가 강제로 전환합니다. 협력적 양보의 핵심 특징은 첫째, 태스크의 의도를 존중한다는 점, 둘째, 불필요한 대기를 줄인다는 점, 셋째, 구현이 매우 간단하다는 점입니다.

이러한 특징들이 시스템의 응답성과 효율을 동시에 높입니다.

코드 예제

// yield 시스템 콜 구현
pub fn task_yield() {
    unsafe {
        // 인터럽트를 비활성화해 원자성 보장
        disable_interrupts();

        // 전역 스케줄러에 접근
        if let Some(scheduler) = SCHEDULER.as_mut() {
            // 현재 태스크를 큐 뒤로 보냄
            if let Some(new_context) = scheduler.schedule() {
                let old_context = get_current_context_ptr();

                // 컨텍스트 전환 수행
                switch_context(old_context, new_context);

                // 여기로 돌아오는 것은 나중에 다시 스케줄됨
            }
        }

        // 인터럽트 재활성화
        enable_interrupts();
    }
}

// 사용 예시: 스핀락에서 양보
pub fn spin_lock_with_yield(lock: &AtomicBool) {
    loop {
        // 락 획득 시도
        if lock.compare_exchange(
            false,
            true,
            Ordering::Acquire,
            Ordering::Relaxed
        ).is_ok() {
            break;
        }

        // 실패하면 CPU를 양보
        task_yield();
    }
}

설명

이것이 하는 일: task_yield 함수는 현재 실행 중인 태스크를 즉시 중단하고 스케줄러에게 다음 태스크를 선택하게 합니다. 타이머 인터럽트와 동일한 효과를 내지만, 태스크가 원하는 시점에 호출할 수 있습니다.

첫 번째로, 인터럽트 제어를 살펴봅시다. disable_interrupts()는 cli 명령으로 CPU의 인터럽트 플래그를 끄고, enable_interrupts()는 sti로 다시 켭니다.

이렇게 하는 이유는 스케줄러 접근과 컨텍스트 전환 중에 타이머 인터럽트가 발생하면 데이터 불일치가 발생할 수 있기 때문입니다. 크리티컬 섹션을 짧게 유지하면 인터럽트 비활성화의 영향을 최소화할 수 있습니다.

그 다음으로, 내부 동작은 타이머 인터럽트 핸들러와 거의 동일합니다. scheduler.schedule()을 호출해 현재 태스크를 큐 뒤로 보내고 다음 태스크를 선택합니다.

switch_context로 실제 전환을 수행하면, CPU는 새로운 태스크의 컨텍스트로 점프합니다. 나중에 이 태스크가 다시 스케줄되면, switch_context 다음 줄부터 실행이 재개됩니다.

spin_lock_with_yield 예시는 실용적인 활용법을 보여줍니다. 스핀락은 보통 바쁜 대기(busy waiting)를 하는데, 이것은 CPU를 낭비합니다.

대신 락 획득에 실패하면 즉시 yield()를 호출하면, 락을 소유한 태스크가 실행될 기회가 생겨 더 빨리 락이 해제될 수 있습니다. 이것은 특히 단일 코어 시스템에서 매우 효과적입니다.

compare_exchange는 원자적으로 락 상태를 확인하고 변경합니다. false(잠기지 않음)를 true(잠김)로 바꾸는 데 성공하면 락을 획득한 것이고, 실패하면 다른 태스크가 이미 락을 소유 중입니다.

Ordering::Acquire는 메모리 순서를 보장해 락 이후의 메모리 접근이 락 이전으로 재배치되지 않도록 합니다. 여러분이 협력적 양보를 사용하면 시스템이 더 반응적이고 효율적이 됩니다.

불필요한 CPU 사이클 낭비를 줄이고, 크리티컬 패스의 지연을 최소화할 수 있습니다. 선점형 스케줄링의 안전성과 협력형의 효율성을 동시에 얻을 수 있습니다.

실전 팁

💡 yield()를 너무 자주 호출하면 컨텍스트 전환 오버헤드로 성능이 떨어집니다. 프로파일링으로 적절한 빈도를 찾으세요.

💡 우선순위 역전(priority inversion)을 주의하세요. 낮은 우선순위 태스크가 락을 가지고 yield()하면 높은 우선순위 태스크가 대기하게 됩니다.

💡 실시간 시스템에서는 yield()를 사용하지 마세요. 예측 불가능한 지연을 유발해 데드라인을 놓칠 수 있습니다.

💡 유저 공간에서 yield()를 직접 호출할 수 있도록 시스템 콜로 노출하세요. syscall 명령으로 커널 모드로 전환해 구현합니다.

💡 yield()와 sleep()을 혼동하지 마세요. yield()는 즉시 다시 스케줄 가능하지만, sleep()은 일정 시간 동안 Blocked 상태가 됩니다.


8. 태스크 종료 처리 - 리소스를 정리하고 태스크를 제거하는 메커니즘

시작하며

여러분이 태스크를 생성하고 실행하는 것만큼이나 중요한 것이 태스크를 깔끔하게 종료하는 것입니다. "태스크 함수가 반환되면 어떻게 되지?", "할당된 리소스는 누가 해제하지?" 같은 질문들이 생깁니다.

실제로 태스크가 종료될 때는 많은 정리 작업이 필요합니다. 스택 메모리 해제, 열린 파일 닫기, 부모 태스크에 종료 상태 전달, 자식 태스크 처리, 스케줄러에서 제거 등 복잡한 과정이 수반됩니다.

이것을 제대로 처리하지 않으면 메모리 누수와 좀비 프로세스가 발생합니다. 바로 이럴 때 필요한 것이 태스크 종료 처리 메커니즘입니다.

태스크가 함수 끝에 도달했을 때 자동으로 호출되는 exit 루틴을 구현해, 모든 리소스를 안전하게 정리합니다.

개요

간단히 말해서, 태스크 종료 처리는 태스크 함수가 반환되거나 명시적으로 exit()를 호출했을 때 리소스를 정리하고 스케줄러에서 제거하는 프로세스입니다. 실무에서 이것은 시스템 안정성의 핵심입니다.

Linux에서는 do_exit() 커널 함수가 이 역할을 하며, 파일 디스크립터 정리, 메모리 맵 해제, 부모에게 SIGCHLD 시그널 전송 등 수십 가지 작업을 수행합니다. 예를 들어, 프로그램이 정상 종료되면 exit(0)이 호출되고, 이것이 커널의 정리 루틴을 트리거합니다.

기존에 수동으로 모든 정리를 수행하면 실수로 일부를 빠뜨릴 수 있지만, 체계적인 종료 메커니즘을 구현하면 일관되고 완전한 정리가 보장됩니다. Rust의 Drop 트레잇도 활용할 수 있어 RAII 패턴을 적용하기 좋습니다.

태스크 종료 처리의 핵심 특징은 첫째, 자동으로 트리거된다는 점, 둘째, 모든 리소스를 체계적으로 해제한다는 점, 셋째, 다른 태스크에 영향을 주지 않는다는 점입니다. 이러한 특징들이 안정적이고 깨끗한 시스템 운영을 가능하게 합니다.

코드 예제

// 태스크 종료 함수
pub fn task_exit(exit_code: i32) -> ! {
    unsafe {
        disable_interrupts();

        if let Some(scheduler) = SCHEDULER.as_mut() {
            // 현재 태스크를 종료 상태로 변경
            if let Some(current) = &mut scheduler.current_task {
                current.state = TaskState::Terminated;
                current.exit_code = Some(exit_code);

                // 부모 태스크에 알림 (구현 필요)
                // notify_parent(current.id, exit_code);

                // 자식 태스크를 init에 이양 (구현 필요)
                // reparent_children(current.id);
            }

            // 스케줄러에서 다음 태스크 선택
            // 현재 태스크는 더 이상 ready_queue에 없음
            if let Some(new_context) = scheduler.schedule() {
                // 종료하는 태스크의 컨텍스트는 저장 불필요
                // 직접 새로운 태스크로 점프
                switch_context_no_save(new_context);
            }
        }

        // 여기 도달하면 안 됨
        panic!("task_exit: no task to switch to");
    }
}

// 태스크 래퍼 - 실제 태스크 함수를 감싸 자동 종료
extern "C" fn task_wrapper(task_fn: fn() -> i32) -> ! {
    let exit_code = task_fn();
    task_exit(exit_code);
}

// TCB 확장 - 종료 코드 저장
pub struct TaskControlBlock {
    // ... 기존 필드들 ...
    pub exit_code: Option<i32>,
    pub parent_id: Option<usize>,
}

설명

이것이 하는 일: task_exit 함수는 태스크의 생명주기를 종료하고, 모든 정리 작업을 수행한 후, 절대 돌아오지 않도록 다른 태스크로 전환합니다. 첫 번째로, 종료 프로세스의 첫 단계는 현재 태스크의 상태를 Terminated로 변경하는 것입니다.

이렇게 하면 스케줄러가 이 태스크를 다시 선택하지 않습니다. exit_code를 저장해두면 부모 태스크가 waitpid() 같은 시스템 콜로 자식의 종료 상태를 확인할 수 있습니다.

0은 일반적으로 성공, 0이 아닌 값은 에러를 나타냅니다. 그 다음으로, 부모-자식 관계 처리가 중요합니다.

notify_parent는 부모 태스크를 깨워서(Blocked -> Ready) 자식이 종료되었음을 알립니다. Unix에서는 SIGCHLD 시그널로 이것을 구현합니다.

reparent_children은 이 태스크의 모든 자식을 init 프로세스(PID 1)에게 입양시킵니다. 그렇지 않으면 부모가 죽으면 자식이 고아가 되어 정리되지 않습니다.

scheduler.schedule()을 호출하면 현재 태스크는 이미 Terminated 상태이므로 ready_queue에 다시 들어가지 않습니다. 다음 태스크가 선택되면, switch_context_no_save로 컨텍스트 저장 없이 바로 전환합니다.

어차피 이 태스크는 다시 실행되지 않을 테니 컨텍스트를 저장할 필요가 없습니다. 이것이 일반 컨텍스트 전환과 다른 점입니다.

task_wrapper 함수는 새로운 태스크를 생성할 때 사용됩니다. 실제 태스크 함수를 직접 rip에 설정하는 대신, task_wrapper를 설정하고 매개변수로 실제 함수를 전달합니다.

이렇게 하면 태스크 함수가 반환될 때 자동으로 task_exit이 호출되어, 개발자가 명시적으로 exit()를 호출하지 않아도 정리가 이루어집니다. 여러분이 이 메커니즘을 사용하면 메모리 누수와 리소스 누출을 방지할 수 있습니다.

태스크가 어떻게 종료되든(정상 반환, panic, 명시적 exit 등) 항상 일관된 정리가 보장됩니다. Rust의 Drop과 결합하면 더욱 강력한 RAII를 구현할 수 있습니다.

실전 팁

💡 좀비 프로세스를 방지하려면 부모가 waitpid()로 자식의 종료를 회수하거나, SIGCHLD 핸들러를 설정해 자동 정리하세요.

💡 TCB 자체의 메모리는 즉시 해제하지 말고, 부모가 종료 코드를 읽을 때까지 유지해야 합니다. 읽은 후에 해제하세요.

💡 파일 디스크립터, 소켓, 락 등 모든 리소스를 Vec에 담아 관리하면 종료 시 일괄 정리가 쉽습니다.

💡 panic이 발생해도 task_exit이 호출되도록 panic 핸들러를 커스터마이징하세요. unwinding 대신 abort 방식을 사용하는 것도 고려하세요.

💡 프로파일러로 메모리 누수를 추적하세요. Valgrind나 Rust의 Miri를 활용하면 미묘한 버그를 찾을 수 있습니다.


9. 멀티코어 스케줄링 - 여러 CPU 코어에서 동시에 태스크 실행하기

시작하며

여러분이 단일 코어에서 완벽하게 작동하는 스케줄러를 만들었다면, 이제 "멀티코어 CPU를 어떻게 활용할까?"라는 다음 단계로 넘어갈 시간입니다. 현대 프로세서는 4개, 8개, 심지어 수십 개의 코어를 가지고 있으니까요.

멀티코어 스케줄링은 단순히 단일 코어 코드를 복사하는 것보다 훨씬 복잡합니다. 각 코어마다 독립적인 스케줄러 큐를 가질지, 전역 큐를 공유할지, 태스크를 어떻게 코어 간에 분산시킬지, 로드 밸런싱을 어떻게 할지 등 많은 결정을 내려야 합니다.

바로 이럴 때 필요한 것이 멀티코어 스케줄링 전략입니다. 각 코어가 독립적으로 태스크를 실행하면서도, 필요에 따라 부하를 균등하게 분산해 전체 시스템의 처리량을 최대화합니다.

개요

간단히 말해서, 멀티코어 스케줄링은 여러 CPU 코어에 태스크를 효율적으로 분배하고, 각 코어가 독립적으로 스케줄링하며, 부하가 불균형할 때 재분배하는 시스템입니다. 실무에서 이것은 현대 OS의 핵심 성능 요소입니다.

Linux는 per-CPU 런큐(runqueue)를 사용해 각 코어가 독립적으로 스케줄링하고, 주기적으로 로드 밸런서가 태스크를 재배치합니다. 예를 들어, 코어 0이 10개 태스크를 가지고 있고 코어 1이 2개만 가지고 있다면, 로드 밸런서가 일부 태스크를 코어 1로 이동시켜 균형을 맞춥니다.

이렇게 하면 모든 코어가 골고루 활용되어 전체 처리량이 증가합니다. 기존에 전역 락을 가진 단일 스케줄러를 사용하면 코어가 많아질수록 락 경합이 심해져 확장성이 떨어집니다.

per-CPU 구조를 사용하면 대부분의 경우 락 없이 스케줄링할 수 있어 멀티코어의 이점을 최대한 살릴 수 있습니다. 멀티코어 스케줄링의 핵심 특징은 첫째, 각 코어의 독립성을 최대한 보장한다는 점, 둘째, 캐시 친화성을 고려한다는 점(태스크가 같은 코어에서 계속 실행되도록), 셋째, 동적으로 부하를 조정한다는 점입니다.

이러한 특징들이 멀티코어 시스템의 성능을 극대화합니다.

코드 예제

use core::sync::atomic::{AtomicUsize, Ordering};

// CPU 코어별 스케줄러
pub struct PerCpuScheduler {
    cpu_id: usize,
    ready_queue: VecDeque<TaskControlBlock>,
    current_task: Option<TaskControlBlock>,
    load: AtomicUsize, // 현재 태스크 수
}

// 전역 스케줄러 배열
static mut CPU_SCHEDULERS: [Option<PerCpuScheduler>; MAX_CPUS] =
    [None, None, None, None]; // 4코어 예시

impl PerCpuScheduler {
    pub fn schedule_local(&mut self) -> Option<&mut TaskContext> {
        // 현재 코어의 큐에서만 선택
        if let Some(mut current) = self.current_task.take() {
            current.state = TaskState::Ready;
            self.ready_queue.push_back(current);
        }

        self.current_task = self.ready_queue.pop_front();

        if let Some(ref mut task) = self.current_task {
            task.state = TaskState::Running;
            Some(&mut task.context)
        } else {
            None
        }
    }

    // 다른 코어에서 태스크 훔치기 (work stealing)
    pub fn steal_task(&mut self) -> Option<TaskControlBlock> {
        self.ready_queue.pop_back()
    }
}

// 로드 밸런싱 함수
pub fn balance_load() {
    unsafe {
        // 가장 바쁜 코어와 가장 한가한 코어 찾기
        let (max_cpu, max_load) = find_max_load();
        let (min_cpu, min_load) = find_min_load();

        // 차이가 임계값 이상이면 태스크 이동
        if max_load > min_load + 2 {
            if let Some(ref mut max_sched) = CPU_SCHEDULERS[max_cpu] {
                if let Some(task) = max_sched.steal_task() {
                    if let Some(ref mut min_sched) = CPU_SCHEDULERS[min_cpu] {
                        min_sched.ready_queue.push_back(task);
                    }
                }
            }
        }
    }
}

설명

이것이 하는 일: PerCpuScheduler는 각 CPU 코어마다 하나씩 존재하며, 독립적으로 태스크를 스케줄링합니다. 코어 간 간섭을 최소화하면서도 필요 시 협력할 수 있습니다.

첫 번째로, per-CPU 구조의 장점을 이해해야 합니다. 각 코어가 자신의 큐를 가지면 대부분의 스케줄링 결정을 로컬에서 할 수 있어 락이 필요 없습니다.

cpu_id 필드로 이 스케줄러가 어느 코어에 속하는지 식별하고, load는 AtomicUsize로 현재 태스크 수를 추적해 다른 코어가 락 없이 읽을 수 있게 합니다. Ordering::Relaxed를 사용하면 성능도 좋습니다.

그 다음으로, schedule_local 메서드는 기본적으로 단일 코어 스케줄러와 동일하지만, 현재 코어의 큐만 고려합니다. 이것이 핵심입니다.

다른 코어의 큐는 전혀 건드리지 않으므로, 각 코어가 동시에 스케줄링을 수행해도 충돌이 없습니다. 캐시 친화성도 자연스럽게 유지됩니다.

태스크가 같은 코어에서 계속 실행되므로, L1/L2 캐시에 데이터가 남아있어 성능이 향상됩니다. steal_task 메서드는 work stealing을 구현합니다.

한 코어의 큐가 비었을 때, 다른 코어의 큐에서 태스크를 가져옵니다. pop_back()을 사용하는 이유는 pop_front()와 충돌을 최소화하기 위해서입니다.

소유자 코어는 앞에서 꺼내고, 도둑 코어는 뒤에서 꺼내면 동시 접근 시 충돌 확률이 줄어듭니다. 물론 이것도 락이나 원자 연산으로 보호해야 합니다.

balance_load 함수는 주기적으로(예: 1초마다) 호출되어 코어 간 부하를 균등화합니다. 먼저 모든 코어의 load를 확인해 가장 바쁜 코어와 가장 한가한 코어를 찾습니다.

차이가 임계값(예: 2개) 이상이면 태스크 하나를 이동시킵니다. 너무 자주 이동시키면 캐시 친화성이 손상되므로, 보수적으로 접근하는 것이 중요합니다.

여러분이 이 구조를 사용하면 멀티코어의 병렬성을 최대한 활용할 수 있습니다. 4코어 시스템에서는 이론적으로 4배의 처리량을 얻을 수 있고, 실제로도 락 경합이 없어 높은 확장성을 보입니다.

현대 서버와 데스크톱 OS는 모두 이와 유사한 구조를 사용합니다.

실전 팁

💡 CPU 친화도를 유저 공간에 노출하세요. sched_setaffinity() 같은 시스템 콜로 태스크를 특정 코어에 고정할 수 있게 하면 유용합니다.

💡 NUMA(Non-Uniform Memory Access) 시스템에서는 메모리 노드 친화성도 고려하세요. 태스크를 메모리가 가까운 코어에 배치하면 성능이 크게 향상됩니다.

💡 인터럽트 부하도 코어 간 분산하세요. 한 코어만 모든 인터럽트를 처리하면 병목이 됩니다. IRQ affinity 설정으로 제어할 수 있습니다.

💡 로드 밸런싱 빈도를 동적으로 조절하세요. 시스템이 한가하면 덜 자주, 바쁘면 더 자주 실행하면 오버헤드를 줄일 수 있습니다.

💡 per-CPU 변수를 적극 활용하세요. Rust에서는 thread_local!이나 CPU-local storage를 사용해 각 코어마다 독립적인 데이터를 가질 수 있습니다.


10. 우선순위 기반 스케줄링 - 중요한 태스크를 먼저 실행하는 알고리즘

시작하며

여러분이 라운드 로빈으로 모든 태스크를 공평하게 실행하다 보면, "어떤 태스크는 다른 태스크보다 더 중요한데..."라는 생각이 듭니다. 실시간 오디오 처리, UI 응답, 백그라운드 배치 작업이 모두 똑같이 취급되는 것이 비효율적이죠.

실제로 많은 시스템에서 태스크들은 본질적으로 다른 중요도를 가집니다. 사용자 입력에 즉시 반응해야 하는 대화형 프로그램은 높은 우선순위를, 시간이 걸려도 괜찮은 로그 압축 같은 작업은 낮은 우선순위를 가져야 합니다.

이렇게 차등을 두면 사용자 경험이 크게 향상됩니다. 바로 이럴 때 필요한 것이 우선순위 기반 스케줄링입니다.

각 태스크에 우선순위를 부여하고, 높은 우선순위 태스크를 먼저 실행하며, 동적으로 우선순위를 조정해 기아를 방지합니다.

개요

간단히 말해서, 우선순위 기반 스케줄링은 태스크마다 우선순위 값을 부여하고, 실행 가능한 태스크 중 가장 높은 우선순위를 가진 것을 선택하는 알고리즘입니다. 실무에서 이것은 응답성이 중요한 시스템에 필수적입니다.

Windows는 0-31의 우선순위 레벨을 사용하고, Linux는 nice 값(-20에서 +19)과 실시간 우선순위를 결합합니다. 예를 들어, 음악 플레이어는 높은 우선순위를 가져 끊김 없이 재생되고, 파일 인덱싱은 낮은 우선순위를 가져 다른 작업에 방해되지 않습니다.

기존에 라운드 로빈만 사용하면 모든 태스크가 똑같이 대우받지만, 우선순위를 도입하면 중요한 작업이 즉시 응답할 수 있습니다. 물론 낮은 우선순위 태스크가 굶지 않도록 에이징(aging) 같은 메커니즘을 추가해야 합니다.

우선순위 스케줄링의 핵심 특징은 첫째, 중요한 태스크의 지연 시간을 최소화한다는 점, 둘째, 우선순위 큐로 효율적으로 구현할 수 있다는 점, 셋째, 동적 우선순위 조정이 가능하다는 점입니다. 이러한 특징들이 반응적이고 효율적인 시스템을 만듭니다.

코드 예제

use alloc::collections::BinaryHeap;
use core::cmp::Ordering;

// 우선순위 비교를 위한 래퍼
#[derive(Eq)]
struct PriorityTask {
    task: TaskControlBlock,
    effective_priority: u8, // 높을수록 우선순위 높음
}

impl PartialEq for PriorityTask {
    fn eq(&self, other: &Self) -> bool {
        self.effective_priority == other.effective_priority
    }
}

impl Ord for PriorityTask {
    fn cmp(&self, other: &Self) -> Ordering {
        // BinaryHeap은 max-heap이므로 그대로 사용
        self.effective_priority.cmp(&other.effective_priority)
    }
}

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

pub struct PriorityScheduler {
    ready_heap: BinaryHeap<PriorityTask>,
    current_task: Option<PriorityTask>,
    tick_count: u64,
}

impl PriorityScheduler {
    pub fn schedule(&mut self) -> Option<&mut TaskContext> {
        // 현재 태스크 우선순위 감소 (에이징)
        if let Some(mut current) = self.current_task.take() {
            current.effective_priority = current.effective_priority.saturating_sub(1);
            self.ready_heap.push(current);
        }

        // 대기 중인 태스크들의 우선순위 증가 (기아 방지)
        if self.tick_count % 100 == 0 {
            self.age_waiting_tasks();
        }
        self.tick_count += 1;

        // 가장 높은 우선순위 태스크 선택
        self.current_task = self.ready_heap.pop();

        if let Some(ref mut ptask) = self.current_task {
            ptask.task.state = TaskState::Running;
            Some(&mut ptask.task.context)
        } else {
            None
        }
    }

    fn age_waiting_tasks(&mut self) {
        // 모든 대기 태스크의 우선순위를 약간 증가
        let tasks: Vec<_> = self.ready_heap.drain().collect();
        for mut ptask in tasks {
            ptask.effective_priority = ptask.effective_priority.saturating_add(1);
            self.ready_heap.push(ptask);
        }
    }
}

설명

이것이 하는 일: PriorityScheduler는 우선순위 큐(BinaryHeap)를 사용해 O(log N) 시간에 가장 중요한 태스크를 선택하고, 동적으로 우선순위를 조정해 모든 태스크가 결국 실행되도록 보장합니다. 첫 번째로, PriorityTask 래퍼를 이해해야 합니다.

TaskControlBlock에 직접 Ord를 구현하는 대신, 래퍼를 만들어 effective_priority 필드만으로 비교합니다. 이것은 base priority와 분리된 동적 우선순위로, 에이징과 부스팅에 사용됩니다.

Ord 트레잇을 구현하면 BinaryHeap에서 자동으로 정렬되어, pop()은 항상 가장 높은 우선순위 태스크를 반환합니다. 그 다음으로, schedule 메서드의 로직을 살펴봅시다.

먼저 현재 실행 중이던 태스크의 우선순위를 1 감소시킵니다. 이것은 한 태스크가 계속 최고 우선순위를 유지하며 CPU를 독점하는 것을 방지합니다.

saturating_sub는 언더플로우를 방지해 0 이하로 내려가지 않게 합니다. 그 다음 이 태스크를 다시 힙에 넣으면, 다른 태스크와 공정하게 경쟁하게 됩니다.

100 틱마다 age_waiting_tasks를 호출하는 것이 기아 방지 메커니즘입니다. 오랫동안 대기 중인 낮은 우선순위 태스크들의 effective_priority를 증가시켜, 결국 실행될 기회를 얻도록 합니다.

모든 태스크를 drain으로 꺼내고, 우선순위를 증가시킨 후 다시 넣습니다. 이것은 비용이 들지만, 100 틱에 한 번만 수행되므로 오버헤드가 크지 않습니다.

BinaryHeap의 pop()은 최대값을 O(log N)에 추출합니다. 큐에 1000개 태스크가 있어도 10번의 비교만으로 가장 높은 우선순위를 찾을 수 있어 매우 효율적입니다.

VecDeque의 O(N) 순회보다 월등히 빠릅니다. 태스크 수가 많은 서버 환경에서 특히 유리합니다.

여러분이 이 스케줄러를 사용하면 시스템의 응답성을 크게 향상시킬 수 있습니다. 사용자 인터페이스는 항상 빠르게 반응하고, 백그라운드 작업은 방해하지 않으며, 모든 태스크가 결국 실행될 기회를 얻습니다.

Windows와 Unix 계열 OS가 모두 이와 유사한 방식을 사용합니다.

실전 팁

💡 우선순위 레벨을 너무 많이 만들지 마세요. 5-10개 정도가 관리하기 적당하며, 더 많으면 의미 있는 차이를 만들기 어렵습니다.

💡 I/O 바운드 태스크는 자동으로 우선순위를 높이세요. I/O가 완료되면 즉시 실행해야 응답성이 좋아집니다.

💡 실시간 태스크는 별도의 우선순위 범위를 사용하세요. 일반 태스크가 절대 간섭할 수 없도록 격리합니다.

💡 우선순위 역전(priority inversion)을 주의하세요. 낮은 우선순위 태스크가 락을 가지고 있으면, 높은 우선순위 태스크가 대기하게 됩니다. Priority inheritance로 해결할 수 있습니다.

💡 사용자가 nice 값으로 우선순위를 조정할 수 있게 하세요. 유저 공간에서 시스템 콜로 자신의 우선순위를 낮출 수 있으면 협력적인 멀티태스킹이 가능합니다.


11. 실시간 스케줄링 - 데드라인을 보장하는 결정론적 스케줄링

시작하며

여러분이 항공기 제어 시스템이나 의료 기기 같은 실시간 시스템을 만든다면, "이 태스크가 반드시 10ms 이내에 실행되어야 한다"는 엄격한 요구사항에 직면합니다. 일반 스케줄러로는 이런 보장을 할 수 없습니다.

실시간 시스템에서는 평균 성능보다 최악의 경우 지연(worst-case latency)이 더 중요합니다. 대부분의 경우 빠르더라도, 가끔 데드라인을 놓치면 치명적인 결과가 발생할 수 있습니다.

자율주행차가 브레이크 명령을 1초 늦게 처리하면 사고가 날 수 있으니까요. 바로 이럴 때 필요한 것이 실시간 스케줄링입니다.

Rate-Monotonic Scheduling(RMS), Earliest Deadline First(EDF) 같은 알고리즘으로 태스크의 데드라인을 수학적으로 보장하고, 예측 가능한 동작을 제공합니다.

개요

간단히 말해서, 실시간 스케줄링은 각 태스크의 데드라인과 주기를 고려해, 모든 태스크가 제시간에 완료될 수 있도록 스케줄링하는 알고리즘입니다. 실무에서 이것은 임베디드 시스템과 산업 제어에 필수적입니다.

VxWorks, FreeRTOS 같은 RTOS는 실시간 스케줄링을 핵심 기능으로 제공합니다. 예를 들어, 공장 로봇 제어 시스템에서 센서 읽기는 1ms마다, 모터 제어는 10ms마다, 로깅은 1초마다 실행되어야 한다면, RMS 알고리즘으로 우선순위를 할당할 수 있습니다(주기가 짧을수록 높은 우선순위).

기존에 일반 스케줄러를 사용하면 부하가 높아질 때 예측할 수 없는 지연이 발생하지만, 실시간 스케줄러는 스케줄 가능성 분석으로 시스템이 모든 데드라인을 만족할 수 있는지 미리 검증합니다. 만족할 수 없다면 아예 실행을 거부하는 것이 더 안전합니다.

실시간 스케줄링의 핵심 특징은 첫째, 데드라인 보장이 가능하다는 점, 둘째, 결정론적이고 예측 가능하다는 점, 셋째, 수학적 분석으로 검증 가능하다는 점입니다. 이러한 특징들이 안전하고 신뢰할 수 있는 실시간 시스템을 만듭니다.

코드 예제

use core::cmp::Ordering;

// 실시간 태스크 속성
#[derive(Clone, Copy)]
pub struct RealTimeTask {
    pub id: usize,
    pub period: u64,        // 실행 주기 (밀리초)
    pub execution_time: u64, // 최악의 경우 실행 시간
    pub deadline: u64,      // 상대 데드라인
    pub next_release: u64,  // 다음 실행 시각
    pub context: TaskContext,
}

// EDF (Earliest Deadline First) 스케줄러
pub struct EDFScheduler {
    tasks: Vec<RealTimeTask>,
    current_time: u64,
}

impl EDFScheduler {
    // 스케줄 가능성 검사 (utilization test)
    pub fn is_schedulable(&self) -> bool {
        let total_utilization: f64 = self.tasks.iter()
            .map(|t| t.execution_time as f64 / t.period as f64)
            .sum();

        // EDF는 utilization이 1.0 이하면 스케줄 가능
        total_utilization <= 1.0
    }

    pub fn schedule(&mut self) -> Option<&mut TaskContext> {
        self.current_time += 1;

        // 실행 준비된 태스크 중 데드라인이 가장 빠른 것 선택
        let mut earliest: Option<&mut RealTimeTask> = None;
        let mut earliest_deadline = u64::MAX;

        for task in &mut self.tasks {
            // 실행 시각이 되었는지 확인
            if self.current_time >= task.next_release {
                let absolute_deadline = task.next_release + task.deadline;

                if absolute_deadline < earliest_deadline {
                    earliest_deadline = absolute_deadline;
                    earliest = Some(task);
                }
            }
        }

        if let Some(task) = earliest {
            // 다음 주기 설정
            task.next_release += task.period;
            Some(&mut task.context)
        } else {
            None
        }
    }
}

설명

이것이 하는 일: EDFScheduler는 각 태스크의 절대 데드라인을 계산하고, 그중 가장 빠른 것을 선택해 실행합니다. 이것이 최적 동적 우선순위 알고리즘으로 증명되어 있습니다.

첫 번째로, RealTimeTask 구조체의 필드들을 이해해야 합니다. period는 태스크가 얼마나 자주 실행되어야 하는지, execution_time은 최악의 경우 이 태스크가 얼마나 오래 실행되는지(WCET, Worst-Case Execution Time), deadline은 각 주기마다 언제까지 완료되어야 하는지를 나타냅니다.

next_release는 다음 실행 시각으로, 매 주기마다 period만큼 증가합니다. 그 다음으로, is_schedulable 메서드가 중요합니다.

이것은 Liu & Layland의 utilization bound 이론을 구현합니다. 각 태스크의 CPU 사용률(execution_time / period)을 모두 더해, 1.0 이하면 스케줄 가능합니다.

예를 들어, 태스크 A가 10ms마다 2ms 실행(20% 사용률), 태스크 B가 20ms마다 5ms 실행(25% 사용률)이면 총 45%로 스케줄 가능합니다. 이것을 초과하면 어떤 스케줄러로도 모든 데드라인을 만족시킬 수 없습니다.

schedule 메서드는 매 틱마다 호출됩니다. current_time >= next_release 조건으로 실행 준비된 태스크를 찾고, absolute_deadline(= next_release + deadline)을 계산합니다.

모든 준비된 태스크 중 이 값이 가장 작은 것이 가장 급한 태스크입니다. 선택된 태스크의 next_release를 period만큼 증가시켜 다음 주기를 설정합니다.

EDF가 최적인 이유는 수학적으로 증명되어 있습니다. 만약 어떤 스케줄 가능한 태스크 세트가 있다면, EDF는 반드시 모든 데드라인을 만족시킬 수 있습니다.

정적 우선순위 알고리즘인 RMS보다 utilization bound가 높아(100% vs 69%) 더 많은 태스크를 스케줄할 수 있습니다. 여러분이 이 스케줄러를 사용하면 실시간 보장이 필요한 시스템을 안전하게 구축할 수 있습니다.

시스템 설계 단계에서 is_schedulable로 검증하고, 런타임에는 데드라인을 확실하게 만족시킵니다. 의료 기기, 항공우주, 산업 자동화 분야에서 필수적입니다.

실전 팁

💡 WCET를 정확히 측정하세요. 너무 낮게 잡으면 데드라인을 놓치고, 너무 높게 잡으면 스케줄 가능한 태스크 수가 줄어듭니다.

💡 인터럽트 지연도 고려하세요. 인터럽트 핸들러 실행 시간을 WCET에 포함시키거나, 인터럽트를 별도 태스크로 모델링해야 합니다.

💡 주기적이지 않은 비주기(aperiodic) 태스크는 서버 태스크(server task)로 처리하세요. Polling Server나 Deferrable Server 알고리즘을 사용할 수 있습니다.

💡 오버런 감지를 구현하세요. 태스크가 execution_time을 초과하면 경고하거나 강제 종료해 다른 태스크를 보호합니다.

💡 실시간 시스템에서는 동적 메모리 할당을 피하세요. 힙 프래그멘테이션과 예측 불가능한 지연을 유발할 수 있습니다. 정적 메모리나 메모리 풀을 사용하세요.


12. 컨텍스트 스위칭 최적화 - 전환 속도를 극대화하는 기법들

시작하며

여러분이 완벽하게 작동하는 태스크 스위칭을 구현했다면, 이제 "얼마나 빠르게 만들 수 있을까?"라는 성능 최적화 단계로 넘어갈 시간입니다. 컨텍스트 전환은 OS에서 가장 자주 실행되는 코드 중 하나니까요.

실제로 컨텍스트 전환 속도는 시스템 전체 성능에 직접적인 영향을 줍니다. 100Hz 스케줄러에서 전환에 100μs가 걸린다면 1% 오버헤드지만, 1000Hz에서는 10% 오버헤드가 됩니다.

마이크로초 단위의 최적화가 전체 시스템 처리량을 크게 바꿀 수 있습니다. 바로 이럴 때 필요한 것이 컨텍스트 스위칭 최적화입니다.

불필요한 레지스터 저장 제거, lazy FPU 저장, XSAVE 명령 활용 등으로 전환 비용을 최소화합니다.

개요

간단히 말해서, 컨텍스트 스위칭 최적화는 전환 시 저장/복원하는 상태를 최소화하고, 하드웨어 기능을 활용하며, 캐시 친화적으로 구현해 전환 속도를 극대화하는 기법들입니다. 실무에서 이것은 고성능 시스템의 핵심입니다.

Linux는 lazy FPU switching을 사용해 FPU를 사용하지 않는 태스크 간 전환 시 FPU 상태를 저장하지 않습니다. x86_64의 경우 FPU/SSE 상태가 512바이트 이상이므로, 이것만 건너뛰어도 큰 성능 향상이 있습니다.

또한 XSAVE/XRSTOR 명령으로 실제 사용된 확장 상태만 저장해 더욱 빠릅니다. 기존에 모든 상태를 항상 저장하는 단순한 방식은 안전하지만 느립니다.

최적화된 방식은 필요한 것만 저장하고, 지연 저장(lazy save)과 하드웨어 가속을 활용해 속도를 크게 높입니다. 컨텍스트 스위칭 최적화의 핵심 특징은 첫째, 공통 경로(common path)를 최대한 빠르게 만든다는 점, 둘째, 하드웨어 기능을 적극 활용한다는 점, 셋째, 캐시와 TLB 효율을 고려한다는 점입니다.

이러한 특징들이 마이크로초 단위의 빠른 전환을 가능하게 합니다.

코드 예제

// FPU 상태 플래그
static mut FPU_USED: [bool; MAX_CPUS] = [false; MAX_CPUS];

// 최적화된 컨텍스트 스위칭
#[naked]
pub unsafe extern "C" fn switch_context_optimized(
    old: *mut TaskContext,
    new: *const TaskContext
) {
    core::arch::asm!(
        // 필수 레지스터만 저장 (callee-saved)
        "mov [rdi + 0x00], r15",
        "mov [rdi + 0x08], r14",
        "mov [rdi + 0x10], r13",
        "mov [rdi + 0x18], r12",
        "mov [rdi + 0x20], rbx",

#Rust#TaskSwitching#ContextSwitch#OSdev#Scheduler#시스템프로그래밍

댓글 (0)

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