이미지 로딩 중...

Rust로 만드는 나만의 OS 4 협력적 멀티태스킹 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 3 Views

Rust로 만드는 나만의 OS 4 협력적 멀티태스킹

Rust로 운영체제를 직접 구현하면서 협력적 멀티태스킹의 원리를 배웁니다. 태스크 전환, 컨텍스트 스위칭, 스케줄링 등 OS의 핵심 개념을 실제 코드로 구현하며 시스템 프로그래밍의 깊이를 경험할 수 있습니다.


목차

  1. 태스크 구조체 설계 - 멀티태스킹의 기본 단위
  2. 컨텍스트 스위칭 구현 - 태스크 전환의 핵심
  3. 협력적 양보 메커니즘 - yield 구현
  4. 라운드 로빈 스케줄러 - 공정한 CPU 시간 분배
  5. 태스크 생성과 초기화 - 새로운 실행 흐름 시작
  6. 블로킹과 깨우기 - I/O 대기 처리
  7. 유휴 태스크 - CPU 절전 모드
  8. 상호 배제와 뮤텍스 - 공유 자원 보호
  9. 태스크 종료와 정리 - 자원 회수
  10. 타이머와 시간 기반 스케줄링 - 공정성 보장

1. 태스크 구조체 설계 - 멀티태스킹의 기본 단위

시작하며

여러분이 운영체제를 만들면서 "여러 프로그램을 동시에 실행하려면 어떻게 해야 하지?"라는 질문에 부딪힌 적 있나요? 각 프로그램의 상태를 어떻게 저장하고, 언제 어떤 프로그램으로 전환할지 결정하는 것은 OS 개발의 핵심 과제입니다.

이런 문제는 실제 개발 현장에서도 매우 중요합니다. 태스크의 상태를 잘못 관리하면 프로그램이 엉뚱한 지점에서 재개되거나, 메모리가 꼬이거나, 최악의 경우 시스템 전체가 멈출 수 있습니다.

바로 이럴 때 필요한 것이 태스크 구조체입니다. 각 태스크의 실행 컨텍스트를 안전하게 저장하고 복원할 수 있는 자료구조를 설계함으로써, 멀티태스킹의 기반을 마련할 수 있습니다.

개요

간단히 말해서, 태스크 구조체는 실행 중인 프로그램의 "순간을 담은 스냅샷"입니다. CPU 레지스터, 스택 포인터, 프로그램 카운터 등 프로그램이 실행을 재개하는 데 필요한 모든 정보를 담고 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 운영체제는 제한된 CPU 자원으로 수십, 수백 개의 프로그램을 "동시에" 실행하는 것처럼 보이게 해야 합니다. 예를 들어, 웹 브라우저가 화면을 렌더링하는 동안 백그라운드에서 파일 다운로드를 하고, 동시에 음악 플레이어가 재생되는 경우를 생각해보세요.

전통적인 단일 태스크 시스템에서는 한 번에 하나의 프로그램만 실행할 수 있었다면, 이제는 태스크 구조체를 통해 각 프로그램의 상태를 저장하고 빠르게 전환하면서 멀티태스킹을 구현할 수 있습니다. 이 구조체의 핵심 특징은 첫째, 모든 CPU 레지스터 상태를 보존한다는 점, 둘째, 각 태스크만의 독립적인 스택 공간을 가진다는 점, 셋째, 태스크의 우선순위와 상태 정보를 관리한다는 점입니다.

이러한 특징들이 안정적이고 효율적인 멀티태스킹 시스템을 만드는 기반이 됩니다.

코드 예제

// 태스크의 실행 컨텍스트를 저장하는 구조체
#[repr(C)]
pub struct TaskContext {
    // 범용 레지스터들 - 태스크 전환 시 보존 필요
    rsp: u64,    // 스택 포인터
    r15: u64, r14: u64, r13: u64, r12: u64,
    rbx: u64, rbp: u64,
}

// 태스크를 나타내는 메인 구조체
pub struct Task {
    id: u64,                          // 고유 태스크 ID
    context: TaskContext,              // CPU 컨텍스트
    stack: Vec<u8>,                    // 독립적인 스택 공간
    state: TaskState,                  // 실행/대기/종료 상태
}

설명

이것이 하는 일: 태스크 구조체는 실행 중인 프로그램의 모든 상태 정보를 캡슐화하여, 운영체제가 여러 프로그램 간에 전환할 때 각 프로그램이 중단된 지점에서 정확히 재개될 수 있도록 합니다. 첫 번째로, TaskContext는 CPU의 핵심 레지스터들을 저장합니다.

#[repr(C)]를 사용하여 C언어와 호환되는 메모리 레이아웃을 보장하는데, 이는 나중에 어셈블리 코드에서 이 구조체에 직접 접근할 때 필요합니다. rsp는 스택 포인터로, 태스크가 현재 스택의 어느 위치에 있는지 가리킵니다.

r15부터 rbp까지의 레지스터들은 "callee-saved" 레지스터로, 함수 호출 규약에 따라 보존되어야 하는 레지스터들입니다. 그 다음으로, Task 구조체는 태스크의 전체 정보를 관리합니다.

id 필드는 각 태스크를 고유하게 식별하며, context는 앞서 설명한 CPU 상태를 담고 있습니다. stack은 Vec<u8>로 구현되어 있어 태스크마다 독립적인 힙 할당 스택을 가지게 됩니다.

이는 스택 오버플로우가 다른 태스크에 영향을 주지 않도록 격리시킵니다. 마지막으로, state 필드는 태스크가 현재 실행 중인지, 대기 중인지, 종료되었는지 등의 상태를 추적합니다.

스케줄러는 이 정보를 기반으로 어떤 태스크를 다음에 실행할지 결정합니다. 예를 들어, I/O 작업을 기다리는 태스크는 대기 상태로 표시되어 CPU 시간을 낭비하지 않습니다.

여러분이 이 구조체를 사용하면 각 태스크가 완전히 독립된 실행 환경을 가지면서도, 운영체제가 이들을 효율적으로 관리하고 전환할 수 있는 기반을 마련하게 됩니다. 실무에서는 메모리 보호, 공정한 CPU 시간 분배, 그리고 시스템 안정성 향상이라는 구체적인 이점을 얻을 수 있습니다.

실전 팁

💡 스택 크기를 너무 작게 설정하면 스택 오버플로우가 발생하므로, 최소 4KB 이상으로 할당하고 가드 페이지를 추가하여 오버플로우를 감지하세요.

💡 TaskContext의 필드 순서는 어셈블리 코드의 오프셋 계산에 직접 영향을 주므로, 구조체를 수정한 후에는 반드시 컨텍스트 스위칭 코드도 함께 업데이트하세요.

💡 태스크 ID를 재사용하지 마세요. 단조 증가하는 카운터를 사용하면 디버깅할 때 태스크의 생명주기를 추적하기 훨씬 쉽습니다.

💡 태스크 생성 시 스택을 0으로 초기화하면 디버깅이 쉬워지며, 보안 측면에서도 이전 데이터 누출을 방지할 수 있습니다.

💡 Rust의 타입 시스템을 활용하여 TaskState를 enum으로 정의하면, 컴파일 타임에 불가능한 상태 전환을 방지할 수 있습니다.


2. 컨텍스트 스위칭 구현 - 태스크 전환의 핵심

시작하며

여러분이 멀티태스킹 시스템을 만들면서 "태스크 A에서 태스크 B로 어떻게 안전하게 전환하지?"라는 근본적인 질문에 직면한 적 있나요? CPU의 모든 레지스터를 정확히 저장하고 복원하지 않으면, 프로그램은 엉뚱한 메모리에 접근하거나 잘못된 명령을 실행하게 됩니다.

이런 문제는 운영체제 개발에서 가장 미묘하고 버그가 발생하기 쉬운 부분입니다. 단 하나의 레지스터라도 잘못 처리하면 시스템이 즉시 크래시하거나, 더 나쁘게는 간헐적으로만 문제가 발생하여 디버깅이 매우 어려워집니다.

바로 이럴 때 필요한 것이 컨텍스트 스위칭입니다. 현재 태스크의 CPU 상태를 완벽히 저장하고, 다음 태스크의 상태를 정확히 복원하는 저수준 메커니즘을 통해 안전한 태스크 전환을 구현할 수 있습니다.

개요

간단히 말해서, 컨텍스트 스위칭은 CPU의 현재 실행 상태를 한 태스크에서 다른 태스크로 원자적으로 전환하는 과정입니다. 이는 운영체제 커널의 가장 핵심적이고 성능에 민감한 부분입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 멀티태스킹의 효과는 컨텍스트 스위칭이 얼마나 빠르고 정확한가에 달려있습니다. 예를 들어, 리얼타임 시스템에서 센서 데이터를 읽는 태스크와 제어 알고리즘을 실행하는 태스크 간에 전환이 느리면, 시스템 응답성이 떨어지고 제어 정밀도가 저하됩니다.

기존의 높은 수준 언어로는 CPU 레지스터를 직접 조작할 수 없었다면, 이제는 Rust의 인라인 어셈블리와 unsafe 블록을 통해 필요한 저수준 제어를 하면서도 나머지 코드에서는 안전성을 유지할 수 있습니다. 이 메커니즘의 핵심 특징은 첫째, 원자성을 보장하여 중간에 인터럽트가 발생해도 일관성을 유지한다는 점, 둘째, 최소한의 레지스터만 저장하여 성능을 최적화한다는 점, 셋째, Rust의 소유권 시스템과 통합되어 메모리 안전성을 보장한다는 점입니다.

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

코드 예제

// 어셈블리로 구현된 컨텍스트 스위칭 함수
#[naked]
pub unsafe extern "C" fn switch_context(
    old: *mut TaskContext,
    new: *const TaskContext
) {
    asm!(
        // 현재 태스크의 레지스터를 저장
        "mov [rdi + 0x00], rsp",    // 스택 포인터 저장
        "mov [rdi + 0x08], r15",
        "mov [rdi + 0x10], r14",
        "mov [rdi + 0x18], r13",
        "mov [rdi + 0x20], r12",
        "mov [rdi + 0x28], rbx",
        "mov [rdi + 0x30], rbp",

        // 새 태스크의 레지스터를 복원
        "mov rsp, [rsi + 0x00]",    // 스택 포인터 복원
        "mov r15, [rsi + 0x08]",
        // ... 나머지 레지스터들도 복원
        "ret",                       // 새 태스크로 점프
        options(noreturn)
    );
}

설명

이것이 하는 일: 컨텍스트 스위칭 함수는 현재 실행 중인 태스크의 모든 CPU 레지스터를 메모리에 저장하고, 다음에 실행할 태스크의 레지스터 값들을 CPU로 로드하여, 마치 그 태스크가 계속 실행되고 있었던 것처럼 만듭니다. 첫 번째로, #[naked] 어트리뷰트를 사용하여 함수 프롤로그와 에필로그를 생성하지 않도록 합니다.

일반 함수는 컴파일러가 자동으로 스택 프레임을 설정하는 코드를 추가하는데, 컨텍스트 스위칭에서는 이런 추가 코드가 레지스터 상태를 변경하여 문제를 일으킬 수 있습니다. 따라서 완전히 우리가 제어하는 어셈블리 코드만으로 함수를 구성합니다.

그 다음으로, 첫 번째 인자인 old(rdi 레지스터에 전달됨)는 현재 태스크의 TaskContext를 가리키는 포인터입니다. mov [rdi + 0x00], rsp 같은 명령어들은 현재 레지스터 값들을 이 구조체에 저장합니다.

오프셋 0x00, 0x08 등은 TaskContext 구조체의 각 필드 위치에 대응됩니다. 스택 포인터 rsp를 가장 먼저 저장하는 이유는, 만약 이 함수가 인터럽트되더라도 최소한 스택 위치는 보존되도록 하기 위함입니다.

세 번째로, 모든 현재 상태를 저장한 후에는 두 번째 인자인 new(rsi 레지스터에 전달됨)가 가리키는 새 태스크의 컨텍스트를 로드합니다. 이 과정은 저장의 정확히 반대입니다.

mov rsp, [rsi + 0x00]으로 새 태스크의 스택 포인터를 복원하면, CPU는 이제 새 태스크의 스택을 사용하게 됩니다. 마지막으로, ret 명령어는 현재 스택의 맨 위에 있는 주소로 점프합니다.

새 태스크의 스택에는 이전에 중단되었던 지점의 반환 주소가 저장되어 있으므로, ret를 실행하면 새 태스크가 중단되었던 정확한 위치에서 실행을 재개합니다. options(noreturn)은 컴파일러에게 이 함수가 일반적인 방식으로 반환하지 않는다는 것을 알려줍니다.

여러분이 이 코드를 사용하면 마이크로초 단위의 빠른 태스크 전환을 달성하면서도, 각 태스크가 자신이 중단된 적 없다고 느낄 만큼 완벽한 상태 보존을 할 수 있습니다. 실무에서는 시스템 응답성 향상, 멀티태스킹 오버헤드 최소화, 그리고 예측 가능한 실시간 성능을 얻을 수 있습니다.

실전 팁

💡 어셈블리 오프셋이 Rust 구조체 레이아웃과 정확히 일치하는지 확인하려면, offset_of! 매크로나 컴파일 타임 어설션을 사용하세요.

💡 디버깅할 때는 컨텍스트 스위칭 전후로 레지스터 값을 시리얼 포트로 출력하면, 어떤 레지스터가 잘못 저장/복원되는지 추적하기 쉽습니다.

💡 x86_64의 System V ABI에서는 rdi, rsi, rdx, rcx, r8, r9는 caller-saved이므로 저장할 필요가 없지만, 인터럽트 컨텍스트에서는 모든 레지스터를 저장해야 합니다.

💡 SIMD 레지스터(xmm0-xmm15)를 사용하는 태스크가 있다면, 이들도 저장해야 하며 이는 컨텍스트 크기를 크게 증가시키므로 lazy FPU 저장 기법을 고려하세요.

💡 컨텍스트 스위칭 중에는 절대 페이지 폴트가 발생하면 안 되므로, TaskContext는 항상 물리 메모리에 상주하도록(pinned) 해야 합니다.


3. 협력적 양보 메커니즘 - yield 구현

시작하며

여러분이 멀티태스킹을 구현하면서 "태스크가 언제 CPU를 포기하도록 만들지?"라는 설계 결정을 내려야 했던 적 있나요? 한 태스크가 CPU를 독점하면 다른 태스크들은 아무것도 할 수 없게 되고, 시스템 전체가 응답 없음 상태에 빠질 수 있습니다.

이런 문제는 특히 I/O 대기나 동기화 객체를 기다리는 상황에서 심각합니다. 만약 태스크가 계속 루프를 돌면서 락이 풀리기를 기다린다면, 정작 락을 풀어줄 태스크는 CPU를 얻지 못해 데드락 상태가 됩니다.

바로 이럴 때 필요한 것이 협력적 양보(cooperative yielding)입니다. 태스크가 스스로 "지금은 할 일이 없으니 다른 태스크에게 CPU를 양보하겠다"고 선언하는 메커니즘을 통해, 효율적이고 공정한 멀티태스킹을 구현할 수 있습니다.

개요

간단히 말해서, yield는 현재 태스크가 자발적으로 CPU 제어권을 포기하고 스케줄러에게 다른 태스크를 실행하도록 요청하는 함수입니다. 이는 협력적 멀티태스킹의 핵심 프리미티브입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 태스크가 공정하게 CPU 시간을 받으려면 각자가 적절한 시점에 양보해야 합니다. 예를 들어, 네트워크 패킷을 기다리는 태스크가 있다면, 패킷이 도착할 때까지 계속 CPU를 점유하는 것보다 yield를 호출하여 다른 태스크가 작업을 진행하도록 하는 것이 훨씬 효율적입니다.

선점형(preemptive) 멀티태스킹에서는 타이머 인터럽트가 강제로 태스크를 전환했다면, 협력적 방식에서는 태스크가 자발적으로 전환 시점을 결정합니다. 이는 구현이 단순하고 컨텍스트 스위칭 오버헤드를 줄일 수 있지만, 태스크가 협력하지 않으면 시스템 전체가 멈출 수 있다는 단점이 있습니다.

이 메커니즘의 핵심 특징은 첫째, 태스크가 일관된 상태에서 전환되므로 동기화가 단순해진다는 점, 둘째, 컨텍스트 스위칭이 예측 가능한 시점에만 발생하여 디버깅이 쉽다는 점, 셋째, Rust의 async/await와 자연스럽게 통합될 수 있다는 점입니다. 이러한 특징들이 경량 스레드나 코루틴 시스템을 구현하는 기반이 됩니다.

코드 예제

// 태스크 양보를 위한 전역 스케줄러 참조
static SCHEDULER: Mutex<Option<Scheduler>> = Mutex::new(None);

// 현재 태스크를 양보하고 다음 태스크로 전환
pub fn task_yield() {
    let mut scheduler = SCHEDULER.lock();
    if let Some(ref mut sched) = *scheduler {
        // 현재 태스크를 준비 큐의 맨 뒤로 이동
        if let Some(current) = sched.current_task.take() {
            sched.ready_queue.push_back(current);
        }

        // 다음 태스크를 가져와서 실행
        if let Some(next) = sched.ready_queue.pop_front() {
            let old_context = &mut sched.current_task.as_mut().unwrap().context;
            let new_context = &next.context;
            sched.current_task = Some(next);

            unsafe { switch_context(old_context, new_context); }
        }
    }
}

설명

이것이 하는 일: task_yield 함수는 현재 실행 중인 태스크를 중단하고, 준비 큐에서 대기 중인 다른 태스크를 선택하여 실행을 전환하는 스케줄링 결정을 수행합니다. 첫 번째로, 전역 SCHEDULER를 Mutex로 보호하여 접근합니다.

멀티태스킹 환경에서는 여러 태스크가 동시에 스케줄러를 조작하려 할 수 있으므로, 상호 배제가 필수적입니다. lock()을 호출하면 다른 태스크가 스케줄러를 사용하는 동안 대기하게 되며, 락을 획득하면 스케줄러를 안전하게 수정할 수 있습니다.

그 다음으로, 현재 실행 중인 태스크를 준비 큐의 맨 뒤로 이동시킵니다. take() 메서드는 current_task에서 소유권을 가져오면서 None으로 설정하고, push_back()은 이 태스크를 준비 큐의 끝에 추가합니다.

이는 라운드 로빈(round-robin) 스케줄링을 구현하는 것으로, 모든 태스크가 순서대로 공정하게 CPU 시간을 받게 됩니다. 만약 현재 태스크가 없다면(시스템 초기화 중이거나 유휴 상태), 이 단계는 건너뜁니다.

세 번째로, 준비 큐에서 다음 실행할 태스크를 꺼냅니다. pop_front()는 큐의 맨 앞에서 태스크를 제거하고 반환하므로, 가장 오래 대기한 태스크가 선택됩니다.

준비 큐가 비어있다면 실행할 태스크가 없는 것이므로, 일반적으로 유휴 태스크나 절전 모드로 전환해야 합니다. 마지막으로, 실제 컨텍스트 스위칭을 수행합니다.

old_context는 현재 태스크(방금 준비 큐에 넣은 것)의 컨텍스트를 가리키고, new_context는 새로 실행할 태스크의 컨텍스트를 가리킵니다. unsafe 블록 안에서 switch_context를 호출하면 CPU 레지스터가 전환되고, 이 함수는 새 태스크의 컨텍스트에서 "반환"됩니다.

즉, 이 함수를 호출한 코드의 관점에서는, 나중에 다시 스케줄될 때까지 시간이 멈춘 것처럼 보입니다. 여러분이 이 함수를 사용하면 태스크가 블로킹 작업을 기다릴 때나, 공정성을 위해 주기적으로 호출하여 다른 태스크에게 실행 기회를 줄 수 있습니다.

실무에서는 스핀락 대신 뮤텍스 구현, I/O 완료 대기, 그리고 장시간 실행되는 계산 작업의 중간에 양보점을 추가하는 등의 용도로 활용됩니다.

실전 팁

💡 yield를 호출하기 전에 현재 태스크가 언제 다시 실행될지 조건을 설정하세요. 무조건 양보하면 준비 큐만 계속 순환하며 진행이 없을 수 있습니다.

💡 Mutex::lock() 안에서 yield를 호출하면 데드락이 발생할 수 있으므로, 스케줄러 락은 가능한 한 짧게 유지하고 컨텍스트 스위칭 전에 해제하세요.

💡 준비 큐가 비었을 때의 처리를 항상 구현하세요. hlt 명령어로 다음 인터럽트까지 CPU를 절전 모드로 전환하면 전력을 절약할 수 있습니다.

💡 디버깅 시에는 각 yield 호출 시점에 태스크 ID와 타임스탬프를 로깅하면, 스케줄링 패턴을 분석하고 기아 상태를 발견하기 쉽습니다.

💡 async/await와 통합할 때는 Future의 poll 메서드에서 Poll::Pending을 반환할 때 yield를 호출하면, 협력적 멀티태스킹과 자연스럽게 연결됩니다.


4. 라운드 로빈 스케줄러 - 공정한 CPU 시간 분배

시작하며

여러분이 여러 태스크를 관리하면서 "어떤 태스크를 다음에 실행해야 하지?"라는 스케줄링 정책 결정에 고민한 적 있나요? 잘못된 스케줄링 알고리즘은 일부 태스크가 CPU를 독점하거나, 중요한 태스크가 오랫동안 실행되지 못하는 기아 상태를 초래할 수 있습니다.

이런 문제는 실시간 시스템이나 서버 환경에서 치명적입니다. 예를 들어, 사용자 입력을 처리하는 태스크가 계속 대기 상태에 있다면, 시스템이 응답하지 않는 것처럼 보여 사용자 경험이 크게 저하됩니다.

바로 이럴 때 필요한 것이 라운드 로빈 스케줄러입니다. 모든 태스크에게 순서대로 동일한 실행 기회를 제공하는 간단하면서도 공정한 스케줄링 알고리즘을 통해, 기아 상태 없이 모든 태스크가 진행될 수 있도록 보장할 수 있습니다.

개요

간단히 말해서, 라운드 로빈 스케줄러는 준비 큐에 있는 모든 태스크를 순환하면서 각각에게 동일한 시간 할당량(time quantum)을 부여하는 스케줄링 알고리즘입니다. 마치 어린이들이 순서대로 그네를 타는 것과 같은 원리입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 공정성과 단순성이 중요한 대부분의 범용 운영체제에 적합합니다. 예를 들어, 웹 서버가 여러 클라이언트 요청을 동시에 처리할 때, 라운드 로빈을 사용하면 어떤 요청도 무한정 대기하지 않고 모두 합리적인 시간 내에 응답을 받을 수 있습니다.

우선순위 기반 스케줄러에서는 낮은 우선순위 태스크가 기아 상태에 빠질 수 있었다면, 라운드 로빈에서는 모든 태스크가 결국 실행 기회를 받게 됩니다. 물론 중요한 태스크를 우선 처리하지 못한다는 단점이 있지만, 구현이 단순하고 예측 가능하다는 장점이 있습니다.

이 알고리즘의 핵심 특징은 첫째, O(1) 시간 복잡도로 다음 태스크를 선택할 수 있어 오버헤드가 낮다는 점, 둘째, 모든 태스크가 최대 대기 시간의 상한선을 예측할 수 있어 응답성이 보장된다는 점, 셋째, FIFO 큐만으로 구현되어 메모리 효율적이라는 점입니다. 이러한 특징들이 안정적인 범용 멀티태스킹 시스템의 기초가 됩니다.

코드 예제

use alloc::collections::VecDeque;

pub struct RoundRobinScheduler {
    ready_queue: VecDeque<Task>,      // 실행 준비된 태스크들
    current_task: Option<Task>,        // 현재 실행 중인 태스크
    quantum: usize,                    // 각 태스크의 시간 할당량 (ms)
}

impl RoundRobinScheduler {
    pub fn schedule(&mut self) -> Option<&mut Task> {
        // 현재 태스크를 큐의 끝으로 이동
        if let Some(task) = self.current_task.take() {
            self.ready_queue.push_back(task);
        }

        // 큐의 맨 앞에서 다음 태스크 가져오기
        self.current_task = self.ready_queue.pop_front();
        self.current_task.as_mut()
    }

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

설명

이것이 하는 일: RoundRobinScheduler는 준비 큐에 대기 중인 태스크들을 원형으로 순환하면서, 각 태스크에게 정해진 시간 할당량만큼 CPU를 사용할 기회를 제공하는 스케줄러를 구현합니다. 첫 번째로, 구조체는 세 가지 핵심 필드를 가집니다.

ready_queue는 VecDeque로 구현되어 있어 양쪽 끝에서의 삽입/제거가 O(1) 시간에 가능하며, 이는 큐 연산에 최적화되어 있습니다. current_task는 현재 CPU에서 실행 중인 태스크를 Option으로 감싸고 있어, 실행 중인 태스크가 없는 상태를 안전하게 표현할 수 있습니다.

quantum은 각 태스크가 한 번에 실행될 수 있는 최대 시간을 밀리초 단위로 정의합니다. 전형적으로 10-100ms 정도의 값을 사용하며, 너무 작으면 컨텍스트 스위칭 오버헤드가 커지고 너무 크면 응답성이 떨어집니다.

그 다음으로, schedule 메서드는 스케줄링 결정의 핵심입니다. 먼저 현재 실행 중이던 태스크를 take()로 가져와서 준비 큐의 맨 뒤(push_back)에 추가합니다.

이는 이 태스크가 시간 할당량을 다 사용했으므로 다른 태스크들이 먼저 실행될 기회를 가져야 한다는 것을 의미합니다. 만약 태스크가 시간 할당량 내에 자발적으로 yield했다면, 그래도 큐의 뒤로 가므로 모든 태스크가 공정하게 순서를 유지합니다.

세 번째로, 준비 큐의 맨 앞에서 다음 태스크를 pop_front()로 가져옵니다. 이는 가장 오래 대기한 태스크가 다음 실행 기회를 받는다는 것을 의미하며, FIFO(First-In-First-Out) 원칙을 따릅니다.

이 태스크를 current_task에 저장하고 그 참조를 반환하면, 호출자는 이 태스크로 컨텍스트 스위칭을 수행할 수 있습니다. 마지막으로, add_task 메서드는 새로 생성된 태스크를 스케줄러에 추가합니다.

push_back을 사용하므로 새 태스크는 기존 대기 중인 태스크들 뒤에 추가되어, 이미 대기 중인 태스크들을 방해하지 않습니다. 만약 현재 실행 중인 태스크가 없다면(시스템 초기화 중), 다음 schedule 호출에서 이 새 태스크가 즉시 실행될 것입니다.

여러분이 이 스케줄러를 사용하면 모든 태스크가 예측 가능한 시간 내에 실행 기회를 받으며, 구현의 단순성 덕분에 버그 가능성이 낮고 디버깅도 쉽습니다. 실무에서는 CPU 바운드 작업들이 공정하게 진행되고, 최대 응답 지연 시간을 계산할 수 있어(태스크 수 × quantum), 소프트 리얼타임 요구사항을 만족시킬 수 있습니다.

실전 팁

💡 quantum 값은 컨텍스트 스위칭 비용의 최소 100배 이상으로 설정하세요. 예를 들어 스위칭이 100μs라면 quantum은 최소 10ms 이상이어야 합니다.

💡 I/O 바운드 태스크는 quantum을 다 쓰기 전에 자주 블록되므로, CPU 바운드와 I/O 바운드 태스크를 분리하여 다른 큐로 관리하면 성능이 향상됩니다.

💡 태스크 수가 많아지면 한 바퀴 도는 시간(turnaround time)이 길어지므로, 우선순위 큐를 추가하여 중요한 태스크는 더 자주 실행되도록 하이브리드 방식을 고려하세요.

💡 VecDeque의 capacity를 미리 예상되는 최대 태스크 수로 설정하면, 런타임에 재할당이 발생하지 않아 성능이 안정적입니다.

💡 각 태스크가 실제로 실행된 시간을 추적하면, 통계를 수집하여 quantum을 동적으로 조정하거나 불공정한 스케줄링을 감지할 수 있습니다.


5. 태스크 생성과 초기화 - 새로운 실행 흐름 시작

시작하며

여러분이 멀티태스킹 시스템에서 "새로운 태스크를 어떻게 시작하지?"라는 질문에 직면한 적 있나요? 단순히 함수를 호출하는 것과 달리, 태스크는 독립적인 스택과 컨텍스트를 가져야 하며, 첫 실행을 위한 특별한 초기화가 필요합니다.

이런 문제는 특히 까다롭습니다. 태스크가 처음 스케줄될 때는 이전에 저장된 컨텍스트가 없으므로, "가짜" 초기 컨텍스트를 만들어서 마치 이전에 실행되다가 중단된 것처럼 보이게 해야 합니다.

스택도 올바른 방향으로 성장하도록 설정하고, 함수 인자도 적절한 레지스터나 스택 위치에 배치해야 합니다. 바로 이럴 때 필요한 것이 태스크 생성과 초기화 메커니즘입니다.

새 태스크를 위한 메모리를 할당하고, 실행 시작점을 설정하며, 컨텍스트를 초기화하여 스케줄러가 즉시 실행할 수 있는 상태로 만들 수 있습니다.

개요

간단히 말해서, 태스크 생성은 새로운 실행 흐름을 위한 모든 자원을 할당하고, 지정된 함수에서 실행을 시작하도록 컨텍스트를 설정하는 과정입니다. 이는 프로세스나 스레드 생성과 유사하지만 훨씬 경량입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 시스템이 실행되는 동안 동적으로 새로운 작업을 시작할 수 있어야 합니다. 예를 들어, 웹 서버가 새 클라이언트 연결을 받을 때마다 해당 요청을 처리할 태스크를 생성하거나, GUI 애플리케이션이 백그라운드 작업을 시작할 때 새 태스크를 생성합니다.

전통적인 fork/exec 모델에서는 전체 프로세스를 복제하거나 새 프로그램을 로드해야 했다면, 경량 태스크에서는 단순히 함수 포인터와 스택만 있으면 되므로 훨씬 빠르고 메모리 효율적입니다. 이 메커니즘의 핵심 특징은 첫째, 독립적인 스택을 할당하여 태스크 간 격리를 보장한다는 점, 둘째, 초기 컨텍스트를 정교하게 설정하여 첫 스케줄 시 자연스럽게 실행이 시작된다는 점, 셋째, Rust의 타입 시스템을 활용하여 함수 시그니처 안전성을 보장한다는 점입니다.

이러한 특징들이 안전하고 확장 가능한 태스크 관리 시스템의 기반이 됩니다.

코드 예제

use alloc::vec::Vec;

const STACK_SIZE: usize = 4096 * 4;  // 16KB 스택

pub struct Task {
    id: u64,
    context: TaskContext,
    stack: Vec<u8>,
}

impl Task {
    pub fn new(id: u64, entry_point: fn()) -> Self {
        let mut stack = vec![0u8; STACK_SIZE];

        // 스택은 아래로 성장하므로 끝에서 시작
        let stack_top = stack.as_mut_ptr() as usize + STACK_SIZE;

        // 초기 스택 프레임 설정 (반환 주소 등)
        let stack_top = stack_top - 8;
        unsafe {
            *(stack_top as *mut u64) = entry_point as u64;
        }

        // 초기 컨텍스트 생성
        let context = TaskContext {
            rsp: stack_top as u64,  // 스택 포인터
            r15: 0, r14: 0, r13: 0, r12: 0,
            rbx: 0, rbp: 0,
        };

        Self { id, context, stack }
    }
}

설명

이것이 하는 일: Task::new 함수는 고유 ID와 시작 함수를 받아서, 완전히 초기화된 태스크를 생성하고 스케줄러가 즉시 실행할 수 있는 상태로 만듭니다. 첫 번째로, 각 태스크를 위한 독립적인 스택을 할당합니다.

vec![0u8; STACK_SIZE]는 힙에 STACK_SIZE 바이트의 제로 초기화된 메모리를 할당합니다. 16KB는 대부분의 태스크에 충분하지만, 깊은 재귀나 큰 지역 변수가 필요한 경우 더 크게 설정할 수 있습니다.

제로 초기화는 보안상 중요한데, 이전에 사용된 메모리의 데이터가 새 태스크에 노출되는 것을 방지합니다. 그 다음으로, 스택의 "top"을 계산합니다.

x86_64 아키텍처에서 스택은 높은 주소에서 낮은 주소로 성장하므로, stack_top은 할당된 메모리 영역의 끝을 가리켜야 합니다. as_mut_ptr()은 Vec의 시작 주소를 얻고, STACK_SIZE를 더해서 끝 주소를 계산합니다.

이 주소가 태스크가 처음 실행될 때의 스택 포인터가 됩니다. 세 번째로, 초기 스택 프레임을 설정합니다.

컨텍스트 스위칭에서 ret 명령어로 새 태스크로 점프하므로, 스택의 맨 위에 entry_point 주소를 미리 넣어둡니다. stack_top에서 8바이트를 빼서(스택이 아래로 성장) 공간을 만들고, 그 위치에 entry_point를 u64로 캐스팅하여 저장합니다.

이제 ret가 실행되면 CPU는 이 주소로 점프하여 태스크의 main 함수를 실행하기 시작합니다. 마지막으로, TaskContext를 초기화합니다.

rsp는 방금 설정한 스택 포인터를 가리키고, 나머지 레지스터들은 모두 0으로 초기화합니다. 이는 태스크가 깨끗한 상태에서 시작하며, 이전 실행의 잔여 데이터가 없음을 보장합니다.

일부 레지스터(예: rdi, rsi)를 특정 값으로 설정하면 entry_point 함수에 인자를 전달할 수도 있습니다. 여러분이 이 함수를 사용하면 간단한 함수 호출만으로 새로운 독립적인 실행 흐름을 시작할 수 있습니다.

실무에서는 동적 워크로드 처리, 플러그인 시스템 구현, 그리고 비동기 작업의 실행 컨텍스트 생성 등에 활용되며, Rust의 소유권 시스템 덕분에 메모리 누수나 이중 해제 같은 문제를 컴파일 타임에 방지할 수 있습니다.

실전 팁

💡 스택 크기는 너무 작으면 오버플로우, 너무 크면 메모리 낭비이므로, 프로파일링으로 실제 사용량을 측정한 후 적절한 기본값을 설정하세요.

💡 entry_point가 반환되면 어떻게 할지 처리하세요. 일반적으로 task_exit() 같은 함수를 스택에 추가로 푸시하여, entry_point가 반환되면 자동으로 호출되도록 합니다.

💡 스택 가드 페이지를 추가하면 스택 오버플로우를 조기에 감지할 수 있습니다. 스택 아래에 보호된 메모리 페이지를 두면, 오버플로우 시 페이지 폴트가 발생합니다.

💡 태스크에 인자를 전달하려면 rdi, rsi 등의 레지스터를 초기화하거나, 스택에 인자를 푸시하여 System V ABI 호출 규약을 따르도록 하세요.

💡 태스크 ID는 단조 증가하는 전역 카운터로 생성하되, AtomicU64를 사용하여 멀티코어에서도 안전하게 고유성을 보장하세요.


6. 블로킹과 깨우기 - I/O 대기 처리

시작하며

여러분이 멀티태스킹 시스템을 만들면서 "I/O 작업이 완료될 때까지 기다리는 태스크를 어떻게 처리하지?"라는 질문에 부딪힌 적 있나요? 계속 루프를 돌면서 확인하면 CPU를 낭비하고, 그렇다고 아무것도 하지 않으면 I/O 완료를 감지할 방법이 없습니다.

이런 문제는 실제 시스템에서 매우 흔합니다. 네트워크 패킷 도착, 디스크 읽기 완료, 키보드 입력 등 외부 이벤트를 기다리는 태스크들이 대부분입니다.

만약 이들이 모두 준비 큐에서 계속 순환하면서 "아직?"하고 확인만 한다면, 정작 실제 작업을 할 태스크는 CPU를 거의 못 받게 됩니다. 바로 이럴 때 필요한 것이 블로킹과 깨우기 메커니즘입니다.

태스크가 I/O를 기다릴 때는 준비 큐에서 제거하여 CPU를 낭비하지 않고, I/O가 완료되면 다시 준비 큐에 추가하여 실행을 재개할 수 있습니다.

개요

간단히 말해서, 블로킹은 태스크를 준비 큐에서 제거하여 대기 상태로 전환하는 것이고, 깨우기는 대기 중인 태스크를 다시 준비 큐에 추가하여 실행 가능 상태로 만드는 것입니다. 이는 효율적인 I/O 처리의 핵심입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 태스크는 실행 시간의 상당 부분을 I/O 대기에 소비합니다. 예를 들어, 웹 서버의 요청 핸들러는 데이터베이스 쿼리 결과를 기다리는 동안 아무 작업도 할 수 없으므로, 이 시간 동안 다른 요청을 처리하는 것이 훨씬 효율적입니다.

스핀락(spin lock) 방식에서는 계속 CPU를 점유하면서 확인했다면, 블로킹 방식에서는 대기 중에 CPU를 완전히 포기하므로 전력 효율성과 처리량이 크게 향상됩니다. 이 메커니즘의 핵심 특징은 첫째, 대기 중인 태스크가 CPU 시간을 소비하지 않는다는 점, 둘째, 이벤트와 대기자를 연결하는 대기 큐로 효율적으로 관리된다는 점, 셋째, 인터럽트 핸들러와 통합되어 하드웨어 이벤트에 즉각 반응한다는 점입니다.

이러한 특징들이 반응성이 높고 리소스 효율적인 시스템을 만드는 기반이 됩니다.

코드 예제

use alloc::collections::BTreeMap;
use alloc::vec::Vec;

pub struct Scheduler {
    ready_queue: VecDeque<Task>,
    // 이벤트 ID -> 대기 중인 태스크들
    wait_queues: BTreeMap<u64, Vec<Task>>,
    current_task: Option<Task>,
}

impl Scheduler {
    // 현재 태스크를 블로킹하고 대기 큐에 추가
    pub fn block_on(&mut self, event_id: u64) {
        if let Some(task) = self.current_task.take() {
            self.wait_queues
                .entry(event_id)
                .or_insert_with(Vec::new)
                .push(task);
        }
        // 다음 태스크로 스케줄
        self.schedule();
    }

    // 이벤트 발생 시 대기 중인 태스크들 깨우기
    pub fn wake_up(&mut self, event_id: u64) {
        if let Some(mut tasks) = self.wait_queues.remove(&event_id) {
            // 모든 대기 태스크를 준비 큐로 이동
            self.ready_queue.append(&mut tasks);
        }
    }
}

설명

이것이 하는 일: 블로킹과 깨우기 메커니즘은 I/O나 다른 외부 이벤트를 기다리는 태스크를 효율적으로 관리하여, CPU가 실제로 작업할 수 있는 태스크만 스케줄링하도록 합니다. 첫 번째로, Scheduler 구조체에 wait_queues 필드가 추가되었습니다.

이는 BTreeMap으로 구현되어 있어, 각 이벤트 ID를 키로 하고 해당 이벤트를 기다리는 태스크들의 Vec을 값으로 저장합니다. BTreeMap을 사용하는 이유는 정렬된 순서를 유지하면서도 O(log n) 검색/삽입/삭제가 가능하기 때문입니다.

예를 들어, 이벤트 ID 42번은 네트워크 소켓의 데이터 도착을 의미할 수 있고, 여러 태스크가 이 소켓에서 읽기를 기다릴 수 있습니다. 그 다음으로, block_on 메서드는 현재 실행 중인 태스크를 블로킹합니다.

current_task에서 태스크를 take()로 가져와서, wait_queues의 해당 event_id 엔트리에 추가합니다. entry() API는 키가 없으면 새 Vec을 생성하고(or_insert_with), 있으면 기존 Vec을 반환하여 중복 코드를 줄여줍니다.

태스크를 대기 큐로 옮긴 후에는 즉시 schedule()을 호출하여 다른 태스크로 전환합니다. 이는 현재 태스크가 더 이상 실행될 수 없으므로 CPU를 다른 태스크에게 양보하는 것입니다.

세 번째로, wake_up 메서드는 이벤트가 발생했을 때 호출됩니다. 일반적으로 인터럽트 핸들러나 디바이스 드라이버에서 호출됩니다.

remove(&event_id)는 해당 이벤트의 대기 큐 전체를 가져오면서 BTreeMap에서 제거합니다. 그런 다음 append()를 사용하여 대기 중이던 모든 태스크를 한 번에 ready_queue로 이동시킵니다.

이는 Vec::push를 반복하는 것보다 효율적이며, 메모리 재할당을 최소화합니다. 마지막으로, 여러 태스크가 같은 이벤트를 기다릴 수 있다는 점이 중요합니다.

예를 들어, 파이프나 소켓에서 여러 리더가 동시에 읽기를 시도하면, 모두 대기 큐에 들어가고 데이터가 도착하면 모두 깨어나서 경쟁합니다. 첫 번째 태스크가 데이터를 읽으면, 나머지는 다시 블로킹되어 다음 데이터를 기다립니다.

여러분이 이 메커니즘을 사용하면 I/O 바운드 워크로드에서 CPU 사용률을 극적으로 개선할 수 있습니다. 실무에서는 네트워크 서버가 수천 개의 동시 연결을 처리하거나, GUI 애플리케이션이 사용자 입력을 기다리면서도 반응성을 유지하는 데 필수적입니다.

또한 이는 Rust의 async/await 런타임이 내부적으로 사용하는 메커니즘과 동일한 원리입니다.

실전 팁

💡 wake_up 후에 즉시 schedule()을 호출하지 마세요. 여러 이벤트를 배치로 처리한 후 한 번만 스케줄하면 오버헤드를 줄일 수 있습니다.

💡 타임아웃 기능을 추가하려면 별도의 타이머 대기 큐를 만들고, 타이머 인터럽트마다 만료된 타이머의 태스크를 깨우세요.

💡 spurious wakeup(허위 깨우기)을 처리하세요. 깨어난 후에도 조건을 다시 확인하고, 여전히 대기해야 하면 다시 block_on을 호출하는 루프 패턴을 사용하세요.

💡 대기 큐에서 특정 태스크만 제거하려면(예: 취소 요청), Vec 대신 HashMap<TaskId, Task>를 사용하면 O(1) 제거가 가능합니다.

💡 우선순위가 있는 이벤트를 처리하려면 BinaryHeap을 사용하여 가장 중요한 대기자부터 깨우도록 하면, 레이턴시에 민감한 작업의 응답성이 향상됩니다.


7. 유휴 태스크 - CPU 절전 모드

시작하며

여러분이 멀티태스킹 시스템을 만들면서 "실행할 태스크가 하나도 없으면 CPU가 뭘 해야 하지?"라는 질문에 직면한 적 있나요? 준비 큐가 비어있는 상태에서 계속 스케줄러를 호출하면 무한 루프에 빠지거나, 최악의 경우 시스템이 멈출 수 있습니다.

이런 문제는 배터리로 동작하는 임베디드 시스템이나 노트북에서 특히 중요합니다. 할 일이 없는데도 CPU가 100% 사용률로 돌아가면 전력을 엄청나게 낭비하고, 발열도 증가하며, 배터리 수명이 급격히 감소합니다.

바로 이럴 때 필요한 것이 유휴 태스크(idle task)입니다. 다른 모든 태스크가 블로킹되어 실행할 것이 없을 때 자동으로 실행되며, CPU를 저전력 상태로 전환하여 에너지를 절약하고 다음 인터럽트를 기다릴 수 있습니다.

개요

간단히 말해서, 유휴 태스크는 시스템에서 가장 낮은 우선순위를 가지며, 다른 실행 가능한 태스크가 없을 때만 실행되는 특수한 태스크입니다. 주 목적은 CPU를 절전 모드로 전환하는 것입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 CPU는 할 일이 없을 때 저전력 상태(C-states)로 들어가서 전력 소비를 크게 줄일 수 있습니다. 예를 들어, 사용자가 문서를 읽고 있는 동안 입력이 없으면, 시스템은 대부분의 시간을 유휴 상태로 보내며 배터리를 절약합니다.

바쁜 대기(busy wait) 루프에서는 CPU가 계속 명령어를 실행하면서 전력을 소비했다면, 유휴 태스크에서는 HLT(halt) 명령어로 CPU를 멈추어 다음 인터럽트까지 전력 소비가 거의 없습니다. 이 태스크의 핵심 특징은 첫째, 절대 블로킹되지 않고 항상 실행 가능하여 시스템이 멈추지 않는다는 점, 둘째, CPU 절전 명령어를 사용하여 실질적인 에너지 절약 효과가 있다는 점, 셋째, CPU 사용률과 전력 소비를 모니터링할 수 있는 기준점이 된다는 점입니다.

이러한 특징들이 에너지 효율적이고 안정적인 시스템을 만드는 기반이 됩니다.

코드 예제

// 유휴 태스크의 메인 루프
pub fn idle_task_main() -> ! {
    loop {
        // 인터럽트 활성화와 CPU 정지를 원자적으로 수행
        unsafe {
            asm!(
                "sti",      // 인터럽트 활성화
                "hlt",      // CPU 정지 (인터럽트까지)
                options(nomem, nostack)
            );
        }

        // 인터럽트로 깨어나면 스케줄러 호출
        // 실행 가능한 다른 태스크가 있는지 확인
        task_yield();
    }
}

// 스케줄러 초기화 시 유휴 태스크 생성
pub fn init_scheduler() {
    let idle_task = Task::new(0, idle_task_main);
    // 유휴 태스크는 특별하게 처리
    // 준비 큐가 비었을 때만 실행
    SCHEDULER.lock().set_idle_task(idle_task);
}

설명

이것이 하는 일: 유휴 태스크는 시스템이 완전히 유휴 상태일 때 CPU를 저전력 모드로 전환하고, 다음 인터럽트가 발생할 때까지 대기하여 불필요한 전력 소비를 방지합니다. 첫 번째로, idle_task_main 함수는 절대 반환하지 않는 함수(-> !)로 선언되어 있습니다.

이는 유휴 태스크가 시스템이 종료될 때까지 계속 실행됨을 의미합니다. loop는 무한 반복을 만들며, 이 루프 안에서 CPU를 정지시켰다가 깨어나는 사이클을 반복합니다.

Rust의 타입 시스템은 ! 타입을 통해 이 함수가 반환될 수 없음을 컴파일 타임에 보장하므로, 이후 코드에서 "유휴 태스크가 종료되면 어떻게 하지?"라는 처리를 할 필요가 없습니다.

그 다음으로, 핵심은 sti와 hlt 명령어의 조합입니다. sti(Set Interrupt flag)는 인터럽트를 활성화하여 타이머, 키보드, 네트워크 등의 하드웨어 이벤트를 받을 수 있게 합니다.

hlt(Halt)는 CPU를 저전력 상태로 전환하여 다음 인터럽트까지 대기합니다. 중요한 점은 이 두 명령어가 원자적으로 실행된다는 것입니다.

만약 sti와 hlt 사이에 인터럽트가 발생하면 hlt가 실행되기 전에 처리되므로, 인터럽트를 놓치는 일이 없습니다. options(nomem, nostack)은 이 어셈블리가 메모리나 스택을 수정하지 않는다는 것을 컴파일러에 알려줍니다.

세 번째로, 인터럽트가 발생하면 CPU는 자동으로 hlt 상태에서 깨어나서 인터럽트 핸들러를 실행합니다. 인터럽트 핸들러가 반환되면, 코드는 hlt 다음 명령어로 계속 실행됩니다.

이 시점에서 task_yield()를 호출하여 스케줄러가 다시 실행 가능한 태스크를 찾도록 합니다. 인터럽트는 I/O 완료 등의 이벤트로 인해 블로킹되었던 태스크를 깨웠을 수 있으므로, 이제 실행할 다른 태스크가 있을 가능성이 높습니다.

마지막으로, 스케줄러는 유휴 태스크를 특별하게 취급합니다. 일반 준비 큐에 넣는 대신 별도로 저장하여, schedule() 함수가 준비 큐가 비어있을 때만 유휴 태스크를 선택하도록 합니다.

이렇게 하면 유휴 태스크는 정말로 할 일이 없을 때만 실행되며, 다른 태스크의 실행 기회를 빼앗지 않습니다. 여러분이 이 유휴 태스크를 사용하면 시스템의 평균 전력 소비를 50% 이상 줄일 수 있으며, 동시에 시스템이 절대 멈추지 않는 안정성도 보장할 수 있습니다.

실무에서는 모바일 기기의 배터리 수명 연장, 데이터센터의 냉각 비용 절감, 그리고 CPU 사용률 통계 수집(유휴 시간 비율 = 전체 시간 중 유휴 태스크 실행 시간)에 필수적입니다.

실전 팁

💡 멀티코어 시스템에서는 각 코어마다 독립적인 유휴 태스크를 실행하여, 각 코어가 개별적으로 절전 모드에 들어갈 수 있도록 하세요.

💡 깊은 C-state로 들어가려면 단순 HLT 대신 MWAIT 명령어를 사용하면 더 큰 전력 절약 효과를 얻을 수 있지만, 깨어나는 데 시간이 더 걸립니다.

💡 유휴 태스크에서 시스템 통계(총 실행 시간, 컨텍스트 스위칭 횟수 등)를 수집하면, 성능 모니터링과 디버깅에 유용한 정보를 얻을 수 있습니다.

💡 인터럽트가 비활성화된 상태에서 HLT를 실행하면 시스템이 영구히 멈추므로, 반드시 STI를 먼저 실행했는지 확인하는 어설션을 추가하세요.

💡 tickless 커널을 구현하려면 다음 예정된 타이머까지의 시간을 계산하고, 그 시간만큼 CPU를 절전 모드로 유지하면 불필요한 타이머 인터럽트를 줄일 수 있습니다.


8. 상호 배제와 뮤텍스 - 공유 자원 보호

시작하며

여러분이 멀티태스킹 시스템에서 "여러 태스크가 동시에 같은 데이터를 수정하려고 하면 어떻게 막지?"라는 동시성 문제에 직면한 적 있나요? 두 태스크가 동시에 계좌 잔액을 업데이트하면 한쪽의 변경이 사라지거나, 연결 리스트를 조작하는 중에 다른 태스크가 개입하면 포인터가 꼬여서 시스템이 크래시합니다.

이런 문제는 레이스 컨디션(race condition)이라고 불리며, 디버깅하기 가장 어려운 버그 유형입니다. 타이밍에 따라 간헐적으로만 발생하므로 재현하기 힘들고, 심지어 디버거를 붙이면 타이밍이 바뀌어서 사라지기도 합니다.

바로 이럴 때 필요한 것이 뮤텍스(mutual exclusion)입니다. 공유 자원에 대한 접근을 한 번에 하나의 태스크만 가능하도록 제한하여, 데이터 일관성을 보장하고 레이스 컨디션을 방지할 수 있습니다.

개요

간단히 말해서, 뮤텍스는 "상호 배제 락(lock)"으로, 크리티컬 섹션(critical section)에 진입하기 전에 획득하고 나올 때 해제하는 동기화 프리미티브입니다. 락을 획득한 태스크만 보호되는 자원에 접근할 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 거의 모든 멀티태스킹 시스템에는 공유 자원이 있습니다. 예를 들어, 여러 태스크가 콘솔에 출력하면 메시지가 뒤섞이거나, 메모리 할당자를 동시에 사용하면 힙이 손상되거나, 파일 시스템 메타데이터를 동시에 업데이트하면 데이터가 손실될 수 있습니다.

스핀락은 계속 CPU를 소비하면서 락이 풀리기를 기다렸다면, 협력적 뮤텍스는 락을 획득할 수 없으면 태스크를 블로킹하여 다른 태스크에게 CPU를 양보합니다. 락이 해제되면 블로킹된 태스크가 깨어나서 다시 시도합니다.

이 프리미티브의 핵심 특징은 첫째, 데드락을 방지하기 위한 메커니즘(타임아웃, 순서 있는 락 획득 등)을 제공한다는 점, 둘째, Rust의 RAII 패턴과 결합되어 락 해제를 자동화할 수 있다는 점, 셋째, 소유권 개념과 통합되어 락 없이는 데이터에 접근할 수 없도록 강제한다는 점입니다. 이러한 특징들이 안전하고 정확한 동시성 프로그래밍의 기반이 됩니다.

코드 예제

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

pub struct Mutex<T> {
    locked: AtomicBool,          // 락 상태
    wait_queue: Vec<u64>,        // 대기 중인 태스크 ID들
    data: UnsafeCell<T>,         // 보호되는 데이터
}

impl<T> Mutex<T> {
    pub fn lock(&self) -> MutexGuard<T> {
        loop {
            // 락 획득 시도 (원자적으로)
            if self.locked
                .compare_exchange(false, true,
                                Ordering::Acquire,
                                Ordering::Relaxed)
                .is_ok()
            {
                // 락 획득 성공!
                return MutexGuard { mutex: self };
            }

            // 락을 얻지 못하면 블로킹
            block_on_mutex(self);
        }
    }
}

// RAII 가드 - 스코프 벗어나면 자동 해제
pub struct MutexGuard<'a, T> {
    mutex: &'a Mutex<T>,
}

impl<T> Drop for MutexGuard<'_, T> {
    fn drop(&mut self) {
        // 락 해제하고 대기자 깨우기
        self.mutex.locked.store(false, Ordering::Release);
        wake_up_mutex_waiters(self.mutex);
    }
}

설명

이것이 하는 일: 뮤텍스는 여러 태스크가 동시에 접근하면 안 되는 공유 자원을 보호하여, 한 번에 하나의 태스크만 크리티컬 섹션을 실행하도록 보장합니다. 첫 번째로, Mutex 구조체는 세 가지 핵심 요소를 가집니다.

locked는 AtomicBool로, 현재 락이 획득되었는지 원자적으로 추적합니다. 원자적 연산은 CPU 레벨에서 분할 불가능하게 실행되므로, 멀티코어에서도 레이스 컨디션 없이 안전합니다.

wait_queue는 락을 기다리는 태스크 ID들을 저장하여, 락이 해제될 때 누구를 깨울지 알 수 있습니다. data는 UnsafeCell로 감싸져 있어, 내부 가변성(interior mutability)을 허용하면서도 컴파일러에게 이것이 특별히 관리된다고 알립니다.

그 다음으로, lock 메서드는 뮤텍스를 획득하려고 시도합니다. compare_exchange는 "만약 현재 값이 false면 true로 바꾸고 성공 반환"이라는 원자적 연산입니다.

Ordering::Acquire는 이 락 획득 이후의 모든 메모리 접근이 재정렬되지 않도록 보장하여, 다른 CPU 코어가 쓴 데이터가 보이도록 합니다. 락 획득에 성공하면 MutexGuard를 반환하는데, 이 가드가 보호된 데이터에 대한 접근을 제공합니다.

실패하면 block_on_mutex를 호출하여 현재 태스크를 대기 큐에 넣고 다른 태스크로 전환합니다. 세 번째로, MutexGuard는 RAII(Resource Acquisition Is Initialization) 패턴을 구현합니다.

이 가드를 통해서만 보호된 데이터에 접근할 수 있으며(Deref/DerefMut 트레잇), 가드가 스코프를 벗어나면 자동으로 Drop 트레잇이 호출되어 락이 해제됩니다. 이는 개발자가 unlock을 명시적으로 호출하는 것을 잊어도 Rust 컴파일러가 자동으로 처리해주므로, 락을 해제하지 않아 데드락이 발생하는 버그를 원천 차단합니다.

마지막으로, Drop::drop 구현에서 실제 락 해제가 일어납니다. store(false, Ordering::Release)는 락을 해제하면서, 이 락 안에서 수행된 모든 메모리 쓰기가 다른 코어에 보이도록 보장합니다(Acquire-Release 시맨틱).

그 후 wake_up_mutex_waiters를 호출하여 대기 큐에 있는 태스크들을 깨웁니다. 일반적으로 한 번에 하나만 깨우는 것이 효율적이지만, 여러 개를 깨워서 경쟁하게 할 수도 있습니다(thundering herd 문제 고려).

여러분이 이 뮤텍스를 사용하면 멀티태스킹 환경에서도 데이터 일관성을 보장하면서, Rust의 타입 시스템 덕분에 락 없이는 데이터에 접근조차 할 수 없게 됩니다. 실무에서는 전역 자료구조 보호, 디바이스 레지스터 접근 직렬화, 그리고 동시성 버그 방지에 필수적이며, std::sync::Mutex와 유사한 API로 이식성도 높습니다.

실전 팁

💡 데드락을 방지하려면 항상 같은 순서로 여러 뮤텍스를 획득하세요. 락 순서를 문서화하고, 가능하면 컴파일 타임에 강제하는 타입 시스템을 만드세요.

💡 크리티컬 섹션은 최대한 짧게 유지하세요. 락을 잡은 채로 I/O나 긴 계산을 하면 다른 태스크들이 모두 대기하게 되어 처리량이 급격히 떨어집니다.

💡 읽기 전용 접근이 많다면 RwLock(reader-writer lock)을 사용하여 여러 리더가 동시에 접근하도록 허용하면 성능이 크게 향상됩니다.

💡 인터럽트 핸들러에서는 블로킹 뮤텍스를 사용하면 안 됩니다. 대신 스핀락을 사용하거나, 인터럽트를 일시적으로 비활성화하는 패턴을 사용하세요.

💡 lock poisoning을 구현하면, 락을 잡은 채로 패닉이 발생했을 때 뮤텍스를 오염된 것으로 표시하여 데이터 손상을 조기에 감지할 수 있습니다.


9. 태스크 종료와 정리 - 자원 회수

시작하며

여러분이 멀티태스킹 시스템에서 "태스크가 끝나면 어떻게 깔끔하게 정리하지?"라는 자원 관리 문제에 직면한 적 있나요? 태스크가 종료되어도 스택 메모리나 파일 핸들을 해제하지 않으면, 시스템을 오래 실행할수록 메모리 누수가 쌓여서 결국 자원이 고갈됩니다.

이런 문제는 장시간 실행되는 서버나 임베디드 시스템에서 치명적입니다. 예를 들어, 웹 서버가 각 요청마다 태스크를 생성하는데 종료된 태스크의 메모리를 회수하지 않으면, 몇 시간 후에는 메모리 부족으로 새 요청을 처리할 수 없게 됩니다.

바로 이럴 때 필요한 것이 태스크 종료와 정리 메커니즘입니다. 태스크가 완료되면 자동으로 모든 자원을 해제하고, 스케줄러에서 제거하여, 깨끗하고 누수 없는 시스템을 유지할 수 있습니다.

개요

간단히 말해서, 태스크 종료는 실행이 끝난 태스크의 모든 자원(스택, 힙 할당, 파일 디스크립터 등)을 시스템으로 반환하는 과정입니다. 이는 메모리 안전성과 자원 효율성의 핵심입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 동적으로 태스크를 생성하고 종료하는 시스템에서는 자원 회수가 필수적입니다. 예를 들어, 게임 엔진에서 각 적 캐릭터가 태스크로 구현되어 있다면, 적이 죽을 때마다 태스크를 정리해야 메모리가 다른 적이나 효과에 재사용될 수 있습니다.

수동 메모리 관리에서는 개발자가 free나 delete를 명시적으로 호출해야 했다면, Rust의 소유권 시스템과 통합하면 태스크 구조체가 드롭될 때 자동으로 모든 자원이 해제됩니다. 이 메커니즘의 핵심 특징은 첫째, 태스크가 자신을 종료할 수 없으므로(자신의 스택에서 실행 중이므로) 좀비 상태를 거친다는 점, 둘째, Rust의 Drop 트레잇을 활용하여 자원 해제를 자동화한다는 점, 셋째, 부모 태스크나 조이너(joiner)에게 종료 상태를 전달할 수 있다는 점입니다.

이러한 특징들이 안전하고 효율적인 태스크 생명주기 관리의 기반이 됩니다.

코드 예제

pub enum TaskState {
    Ready,           // 실행 준비됨
    Running,         // 현재 실행 중
    Blocked,         // I/O 대기 중
    Zombie,          // 종료됨, 정리 대기 중
}

impl Task {
    // 태스크 메인 함수가 반환되면 호출
    pub fn exit(exit_code: i32) -> ! {
        let mut scheduler = SCHEDULER.lock();

        // 현재 태스크를 좀비 상태로 전환
        if let Some(ref mut task) = scheduler.current_task {
            task.state = TaskState::Zombie;
            task.exit_code = Some(exit_code);

            // 대기 중인 부모 태스크 깨우기
            if let Some(parent_id) = task.parent_id {
                scheduler.wake_up(parent_id);
            }
        }

        // 다른 태스크로 전환 (돌아오지 않음)
        scheduler.schedule();

        // 절대 여기 도달하지 않음
        unreachable!();
    }
}

// 정리 태스크 - 주기적으로 좀비 수거
fn reaper_task() -> ! {
    loop {
        let mut scheduler = SCHEDULER.lock();

        // 좀비 태스크들 찾아서 정리
        scheduler.tasks.retain(|task| {
            if task.state == TaskState::Zombie {
                // Drop 트레잇이 자동으로 자원 해제
                false  // 리스트에서 제거
            } else {
                true   // 유지
            }
        });

        drop(scheduler);
        task_sleep(1000);  // 1초 대기
    }
}

설명

이것이 하는 일: 태스크 종료 메커니즘은 실행이 완료된 태스크의 모든 자원을 안전하게 회수하면서, 종료 상태를 부모 태스크에게 전달하고, 시스템의 메모리 누수를 방지합니다. 첫 번째로, TaskState enum은 태스크의 생명주기를 추적합니다.

Zombie 상태가 중요한데, 이는 Unix 프로세스 모델에서 가져온 개념으로 태스크가 실행은 끝났지만 아직 정리되지 않은 상태를 나타냅니다. 태스크는 자기 자신의 스택을 해제할 수 없으므로(현재 그 스택에서 코드를 실행 중이므로), 좀비 상태로 전환하여 다른 누군가(리퍼 태스크)가 정리하도록 합니다.

exit_code는 태스크의 반환 값이나 에러 상태를 저장하여, 부모 태스크가 자식의 성공/실패를 확인할 수 있게 합니다. 그 다음으로, exit 함수는 태스크의 마지막 행동입니다.

이 함수는 절대 반환하지 않으므로(-> !) 타입 시스템에 명시되어 있습니다. 먼저 스케줄러 락을 획득하고 현재 태스크를 좀비 상태로 표시합니다.

만약 부모 태스크가 wait()나 join() 같은 함수로 이 태스크의 종료를 기다리고 있다면, parent_id를 사용하여 부모를 깨웁니다. 그 후 schedule()을 호출하여 다른 태스크로 전환하는데, 이 schedule은 절대 현재 태스크로 돌아오지 않습니다(좀비 태스크는 스케줄되지 않으므로).

unreachable!() 매크로는 코드가 여기 도달하면 패닉을 발생시킵니다. 세 번째로, 리퍼(reaper) 태스크는 좀비 태스크들을 정리하는 백그라운드 작업입니다.

주기적으로(예: 1초마다) 깨어나서 태스크 리스트를 순회하며 좀비 상태인 것들을 찾습니다. retain 메서드는 클로저가 false를 반환하는 항목을 리스트에서 제거하는데, 이때 해당 Task 구조체가 드롭됩니다.

Rust의 Drop 트레잇 덕분에 Task가 드롭되면 자동으로 stack Vec이 해제되고, 다른 모든 필드들도 재귀적으로 정리됩니다. 명시적인 free 호출 없이 모든 메모리가 안전하게 회수됩니다.

마지막으로, 이 모델은 부모-자식 관계를 지원합니다. 부모 태스크가 자식을 생성할 때 parent_id를 설정하면, 자식이 종료될 때 부모에게 알릴 수 있습니다.

이를 통해 join 패턴을 구현할 수 있는데, 부모는 자식이 종료될 때까지 블로킹되고 자식의 exit_code를 받아올 수 있습니다. 만약 부모가 먼저 종료되면, 자식들은 "고아"가 되어 init 태스크(PID 1)에게 입양됩니다.

여러분이 이 메커니즘을 사용하면 메모리 누수 없이 태스크를 동적으로 생성하고 종료할 수 있으며, Rust의 소유권 시스템이 자원 해제를 자동화하여 버그 가능성을 크게 줄입니다. 실무에서는 장시간 실행되는 서버의 안정성, 동적 워크로드 처리, 그리고 자원 제약이 있는 임베디드 시스템에서 필수적입니다.

실전 팁

💡 리퍼 태스크의 실행 주기를 상황에 맞게 조정하세요. 태스크 생성이 빈번하면 짧게, 드물면 길게 설정하여 오버헤드를 최적화합니다.

💡 exit 함수를 태스크 엔트리 포인트의 "반환 주소"로 스택에 미리 푸시하면, 태스크 함수가 return할 때 자동으로 exit가 호출됩니다.

💡 좀비 태스크의 최대 개수를 제한하여, 리퍼가 따라잡지 못할 정도로 빠르게 태스크가 종료되는 경우에도 메모리 사용량이 폭발하지 않도록 하세요.

💡 패닉이 발생한 태스크는 특별히 처리하세요. panic handler에서 exit를 호출하되, exit_code를 특수 값(예: -1)으로 설정하여 정상 종료와 구분합니다.

💡 디버깅을 위해 좀비 상태의 태스크 정보(스택 트레이스, 실행 시간 등)를 일정 시간 보존하면, 종료된 태스크의 동작을 사후 분석할 수 있습니다.


10. 타이머와 시간 기반 스케줄링 - 공정성 보장

시작하며

여러분이 협력적 멀티태스킹을 구현하면서 "악의적이거나 버그가 있는 태스크가 yield를 호출하지 않으면 어떻게 하지?"라는 신뢰 문제에 직면한 적 있나요? 한 태스크가 무한 루프에 빠지거나 의도적으로 CPU를 독점하면, 다른 모든 태스크가 굶어 죽게 됩니다.

이런 문제는 협력적 멀티태스킹의 근본적인 한계입니다. 모든 태스크가 협력적이라고 가정하지만, 단 하나의 비협력적인 태스크만 있어도 전체 시스템이 무응답 상태가 됩니다.

특히 서드파티 코드를 실행할 때는 이를 신뢰할 수 없습니다. 바로 이럴 때 필요한 것이 타이머 기반 선점(preemption)입니다.

하드웨어 타이머 인터럽트를 사용하여 강제로 태스크를 전환함으로써, 모든 태스크가 협력하지 않아도 공정하게 CPU 시간을 분배할 수 있습니다.

개요

간단히 말해서, 타이머 기반 스케줄링은 주기적인 타이머 인터럽트를 활용하여, 각 태스크의 실행 시간을 강제로 제한하고 시간 할당량이 소진되면 자동으로 다음 태스크로 전환하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 범용 운영체제는 임의의 프로그램을 실행해야 하므로 모든 코드가 협력적이라고 가정할 수 없습니다.

예를 들어, 사용자가 작성한 프로그램이 버그로 무한 루프에 빠져도, 운영체제는 여전히 다른 프로그램들을 실행하고 사용자가 Ctrl+C로 중단할 수 있어야 합니다. 순수 협력적 방식에서는 태스크가 자발적으로 yield를 호출해야 했다면, 선점형 방식에서는 타이머 인터럽트가 강제로 스케줄러를 호출하여 태스크를 전환합니다.

이는 구현이 복잡하고 오버헤드가 있지만, 공정성과 견고성을 보장합니다. 이 메커니즘의 핵심 특징은 첫째, 타이머 인터럽트가 임의의 시점에 발생하므로 모든 코드 경로에서 재진입(reentrant) 안전해야 한다는 점, 둘째, 각 태스크에 정확히 동일한 시간 할당량을 보장하여 공정성을 달성한다는 점, 셋째, 협력적 방식과 결합하여 하이브리드 스케줄링을 구현할 수 있다는 점입니다.

이러한 특징들이 견고하고 공정한 멀티태스킹 시스템의 기반이 됩니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;

// 전역 타이머 틱 카운터
static TIMER_TICKS: AtomicU64 = AtomicU64::new(0);

// 타이머 인터럽트 핸들러
pub extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    // 틱 증가
    TIMER_TICKS.fetch_add(1, Ordering::Relaxed);

    // 현재 태스크의 실행 시간 카운터 감소
    let mut scheduler = SCHEDULER.try_lock();
    if let Some(ref mut sched) = scheduler {
        if let Some(ref mut task) = sched.current_task {
            task.time_slice -= 1;

            // 시간 할당량 소진 시 선점
            if task.time_slice == 0 {
                task.time_slice = QUANTUM;  // 리셋

                // 컨텍스트 스위칭 수행
                sched.schedule();
            }
        }
    }

    // EOI (End of Interrupt) 신호 전송
    unsafe {
        PICS.lock().notify_end_of_interrupt(TIMER_IRQ);
    }
}

설명

이것이 하는 일: 타이머 인터럽트 핸들러는 하드웨어 타이머가 주기적으로(예: 1ms마다) 발생시키는 인터럽트를 처리하여, 각 태스크의 실행 시간을 추적하고 공정하게 CPU 시간을 분배합니다. 첫 번째로, TIMER_TICKS 전역 카운터는 시스템이 부팅된 이후 경과한 틱 수를 기록합니다.

AtomicU64를 사용하여 멀티코어 환경에서도 안전하게 증가시킬 수 있으며, fetch_add는 현재 값을 1 증가시키는 원자적 연산입니다. 이 카운터는 시스템 시각 계산, 타임아웃 측정, 프로파일링 등 다양한 용도로 활용됩니다.

예를 들어, 틱 주기가 1ms라면 이 값을 1000으로 나누면 초 단위 시간을 얻을 수 있습니다. 그 다음으로, timer_interrupt_handler는 x86-interrupt 호출 규약으로 선언되어 있어, 인터럽트 발생 시 CPU가 자동으로 호출합니다.

InterruptStackFrame은 인터럽트 발생 시점의 CPU 상태(rip, rsp, rflags 등)를 담고 있지만, 이 핸들러에서는 사용하지 않으므로 _를 붙여 무시합니다. try_lock()을 사용하는 이유는, 만약 다른 코드가 이미 스케줄러 락을 잡고 있다면(예: schedule() 실행 중) 데드락을 피하기 위해서입니다.

락을 얻지 못하면 이번 틱은 건너뛰고 다음 인터럽트를 기다립니다. 세 번째로, 각 태스크는 time_slice 필드를 가지고 있어 남은 시간 할당량을 추적합니다.

매 틱마다 1씩 감소하며, 0이 되면 해당 태스크의 quantum이 소진된 것입니다. 이때 time_slice를 QUANTUM(예: 10ms = 10 ticks)으로 리셋하고, schedule()을 호출하여 다음 태스크로 전환합니다.

중요한 점은 이 전환이 태스크의 협력 없이 강제로 일어난다는 것입니다. 태스크는 무한 루프를 돌고 있어도, 타이머는 어김없이 발생하여 CPU를 빼앗습니다.

마지막으로, EOI(End of Interrupt) 신호를 인터럽트 컨트롤러(PIC 또는 APIC)에 전송해야 합니다. 이 신호를 보내지 않으면 컨트롤러는 인터럽트가 아직 처리 중이라고 판단하여 더 이상 같은 우선순위의 인터럽트를 발생시키지 않습니다.

notify_end_of_interrupt는 unsafe 블록에서 호출되는데, 이는 하드웨어 I/O 포트를 직접 조작하기 때문입니다. TIMER_IRQ는 타이머에 할당된 IRQ 번호(일반적으로 0번)입니다.

여러분이 이 타이머 기반 선점을 사용하면 협력적 방식의 효율성을 유지하면서도, 비협력적인 태스크로부터 시스템을 보호할 수 있습니다. 실무에서는 범용 OS의 필수 기능이며, 공정한 CPU 시간 분배, 응답성 보장, 그리고 서드파티 코드 격리에 활용됩니다.

하이브리드 방식으로 yield를 자주 호출하는 협력적 태스크는 빠르게 전환하고, 그렇지 않은 태스크는 타이머로 강제 전환하여 최상의 성능과 공정성을 달성할 수 있습니다.

실전 팁

💡 타이머 주기를 너무 짧게 설정하면(예: 100μs) 인터럽트 오버헤드가 커지므로, 일반적으로 1-10ms가 적절합니다. 리얼타임 시스템은 더 짧게 설정합니다.

💡 인터럽트 핸들러에서는 최소한의 작업만 수행하고, 복잡한 처리는 별도 태스크로 지연시키세요(bottom half 패턴). 인터럽트가 오래 걸리면 다른 인터럽트를 놓칠 수 있습니다.

💡 try_lock() 실패 횟수를 추적하여, 자주 실패한다면 스케줄러 락 경합이 심하다는 신호이므로 락 없는 자료구조나 세밀한 락을 고려하세요.

💡 각 태스크의 누적 CPU 시간을 기록하면, top 명령어처럼 어떤 태스크가 CPU를 많이 사용하는지 프로파일링할 수 있습니다.

💡 APIC의 TSC-deadline 모드를 사용하면 periodic 모드보다 정확하고 유연한 타이머를 구현할 수 있으며, 각 코어마다 독립적인 타이머를 가질 수 있습니다.


#Rust#Multitasking#ContextSwitch#TaskScheduling#OSdev#시스템프로그래밍

댓글 (0)

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