이미지 로딩 중...

Rust로 만드는 나만의 OS 스레드 로컬 스토리지 - 슬라이드 1/9
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS 스레드 로컬 스토리지

운영체제를 직접 만들 때 각 스레드마다 독립적인 데이터를 안전하게 관리하는 방법을 배웁니다. Thread Local Storage(TLS)의 개념부터 실제 구현까지, Rust를 사용해 OS 레벨에서 스레드별 데이터 격리를 어떻게 구현하는지 단계별로 알아봅니다.


목차

  1. Thread Local Storage 개념 - 스레드마다 독립된 데이터 공간
  2. TLS 구조체 활용 - 복잡한 스레드 컨텍스트 관리
  3. TLS 초기화 메커니즘 - 스레드 시작 시 안전한 설정
  4. CPU별 데이터 분리 - Per-CPU 변수 구현
  5. TLS와 컨텍스트 스위칭 - 스레드 전환 시 TLS 업데이트
  6. 시스템 콜에서 TLS 활용 - 사용자 공간과 커널 공간 전환
  7. TLS 초기화 순서 문제 - 부트스트랩과 첫 스레드
  8. 안전한 TLS 접근 패턴 - 타입 안전성과 러스트 보장

1. Thread Local Storage 개념 - 스레드마다 독립된 데이터 공간

시작하며

여러분이 멀티스레드 운영체제를 개발할 때 이런 상황을 겪어본 적 있나요? 여러 스레드가 동시에 실행되면서 전역 변수를 공유하다가, 한 스레드가 데이터를 수정하면 다른 스레드에도 영향을 미쳐 예상치 못한 버그가 발생하는 상황 말입니다.

이런 문제는 실제 OS 개발 현장에서 자주 발생합니다. 스레드 간 데이터 격리가 제대로 이루어지지 않으면 경쟁 조건(race condition)이 발생하고, 디버깅하기 매우 어려운 간헐적 버그로 이어집니다.

특히 errno 같은 에러 코드나 스레드별 버퍼처럼 각 스레드마다 독립적으로 관리되어야 하는 데이터가 섞이면 심각한 문제가 됩니다. 바로 이럴 때 필요한 것이 Thread Local Storage(TLS)입니다.

TLS는 각 스레드가 자신만의 독립적인 데이터 저장 공간을 갖도록 보장하여, 동기화 오버헤드 없이 스레드 안전한 프로그래밍을 가능하게 합니다.

개요

간단히 말해서, Thread Local Storage는 각 스레드가 동일한 변수명으로 접근하지만 실제로는 스레드별로 다른 메모리 공간을 사용하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 운영체제 커널에서는 수많은 스레드가 동시에 실행되면서 각자의 컨텍스트를 유지해야 합니다.

예를 들어, 시스템 콜 처리 중 발생한 에러 코드를 저장할 때, 각 스레드가 자신의 에러 코드를 독립적으로 관리해야 다른 스레드의 에러와 섞이지 않습니다. 기존에는 전역 변수를 사용하면서 뮤텍스(mutex)로 보호하거나, 스레드 ID를 키로 하는 해시맵을 관리했다면, TLS를 사용하면 동기화 없이도 스레드별 데이터를 안전하게 관리할 수 있습니다.

TLS의 핵심 특징은 첫째, 각 스레드가 독립된 메모리 공간을 갖는다는 점, 둘째, 동기화 오버헤드가 없어 성능이 뛰어나다는 점, 셋째, 프로그래머가 간단한 문법으로 사용할 수 있다는 점입니다. 이러한 특징들이 OS 개발에서 스레드 안전성과 성능을 동시에 보장하는 데 매우 중요합니다.

코드 예제

// TLS 변수 선언 - 각 스레드마다 독립된 값을 가짐
#[thread_local]
static mut THREAD_ID: u32 = 0;

#[thread_local]
static mut ERROR_CODE: i32 = 0;

// 스레드 초기화 시 TLS 설정
pub fn init_thread(id: u32) {
    unsafe {
        THREAD_ID = id;
        ERROR_CODE = 0;
    }
}

// TLS에서 현재 스레드 ID 가져오기
pub fn get_current_thread_id() -> u32 {
    unsafe { THREAD_ID }
}

// 에러 코드 설정 및 조회
pub fn set_error(code: i32) {
    unsafe { ERROR_CODE = code; }
}

pub fn get_last_error() -> i32 {
    unsafe { ERROR_CODE }
}

설명

이것이 하는 일: 위 코드는 Rust의 #[thread_local] 속성을 사용해 스레드별로 독립된 데이터 저장 공간을 만드는 방법을 보여줍니다. 각 스레드는 동일한 변수명으로 접근하지만, 실제로는 자신만의 별도 메모리 영역을 사용합니다.

첫 번째로, #[thread_local] 속성이 붙은 static mut 변수들은 각 스레드마다 별도의 인스턴스를 갖게 됩니다. THREAD_ID와 ERROR_CODE는 전역 변수처럼 선언되어 있지만, 런타임에는 각 스레드가 자신만의 복사본을 가지게 됩니다.

이렇게 하면 스레드 A가 THREAD_ID를 1로 설정하고, 스레드 B가 2로 설정해도 서로 영향을 주지 않습니다. 그 다음으로, init_thread 함수가 실행되면서 새로 생성된 스레드의 TLS 변수들을 초기화합니다.

각 스레드는 자신의 ID를 TLS에 저장하고, 에러 코드를 0으로 초기화합니다. 이 과정은 스레드 생성 직후 한 번만 실행되며, 이후 해당 스레드는 언제든 자신의 ID와 에러 상태를 빠르게 조회할 수 있습니다.

마지막으로, get_current_thread_id와 에러 관련 함수들이 TLS 변수에 접근하여 스레드별 정보를 읽고 쓰는 역할을 합니다. 중요한 점은 이 모든 과정에서 락(lock)이나 동기화 메커니즘이 전혀 필요하지 않다는 것입니다.

각 스레드가 자신만의 메모리 공간에 접근하기 때문에 경쟁 조건이 발생할 수 없습니다. 여러분이 이 코드를 사용하면 운영체제 커널에서 스레드별 컨텍스트를 효율적으로 관리할 수 있습니다.

시스템 콜 처리 중 발생한 에러를 스레드마다 독립적으로 저장하고, 스케줄러가 현재 실행 중인 스레드를 빠르게 식별할 수 있으며, 동기화 오버헤드 없이 높은 성능을 유지할 수 있습니다.

실전 팁

💡 TLS 변수는 static mut으로 선언되므로 unsafe 블록이 필요하지만, 각 스레드가 독립된 인스턴스를 가지므로 실제로는 안전합니다. 다만 초기화 시점을 명확히 관리해야 합니다.

💡 TLS는 메모리 오버헤드가 있습니다. 각 스레드마다 별도 메모리가 할당되므로, 큰 데이터 구조를 TLS로 만들면 스레드가 많을 때 메모리 사용량이 급증할 수 있습니다.

💡 성능이 중요한 경우 TLS는 매우 효과적입니다. 캐시나 버퍼처럼 자주 접근하는 데이터를 TLS로 만들면 락 경쟁 없이 빠르게 접근할 수 있습니다.

💡 디버깅 시 주의: 각 스레드가 다른 값을 가지므로, 디버거로 TLS 변수를 확인할 때 어느 스레드의 값을 보고 있는지 명확히 인식해야 합니다.

💡 스레드 종료 시 TLS 정리를 잊지 마세요. 동적 할당된 메모리를 TLS에 저장했다면, 스레드가 종료될 때 명시적으로 해제해야 메모리 누수를 방지할 수 있습니다.


2. TLS 구조체 활용 - 복잡한 스레드 컨텍스트 관리

시작하며

여러분이 OS 커널을 개발하면서 각 스레드의 상태를 관리할 때, 단순히 하나의 ID나 에러 코드만 저장하는 것으로는 부족한 경우가 많습니다. 스택 포인터, 우선순위, CPU 레지스터 상태, 할당된 리소스 등 수십 가지 정보를 스레드마다 추적해야 하는데, 이들을 각각 별도의 TLS 변수로 만들면 관리가 매우 복잡해집니다.

이런 문제는 실제 운영체제 개발에서 매우 흔합니다. 리눅스 커널의 task_struct나 Windows의 ETHREAD처럼, 실제 OS들은 스레드 정보를 하나의 큰 구조체로 관리합니다.

하지만 이 구조체를 어떻게 각 스레드에서 빠르게 접근할 수 있게 할 것인가가 핵심 문제입니다. 바로 이럴 때 필요한 것이 TLS에 구조체 포인터를 저장하는 패턴입니다.

구조체 전체를 TLS로 만드는 대신, 구조체의 포인터만 TLS에 저장하면 메모리 효율성과 접근 속도를 모두 얻을 수 있습니다.

개요

간단히 말해서, 이 패턴은 복잡한 스레드 컨텍스트 정보를 담은 구조체를 힙에 할당하고, 그 포인터만 TLS에 저장하는 방식입니다. 이 방식이 필요한 이유는, 스레드 정보가 크고 복잡할수록 TLS에 직접 저장하면 각 스레드마다 큰 메모리 공간이 낭비되기 때문입니다.

예를 들어, 각 스레드가 4KB 크기의 버퍼를 TLS로 가지고 있다면, 1000개의 스레드가 있을 때 4MB의 메모리가 TLS만으로 소비됩니다. 포인터를 사용하면 필요할 때만 할당하고, 크기도 8바이트(64비트 시스템)로 고정됩니다.

기존에는 스레드 ID를 키로 하는 전역 해시맵에 컨텍스트를 저장하고 매번 검색했다면, TLS 포인터를 사용하면 O(1) 시간에 현재 스레드의 컨텍스트에 직접 접근할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 메모리 효율적이라는 점(포인터만 TLS에 저장), 둘째, 접근 속도가 매우 빠르다는 점(해시 검색 불필요), 셋째, 구조체 크기 변경이 자유롭다는 점입니다.

이러한 특징들이 확장 가능한 OS 설계에 매우 중요합니다.

코드 예제

// 스레드 컨텍스트 구조체 정의
pub struct ThreadContext {
    pub id: u32,
    pub priority: u8,
    pub stack_pointer: usize,
    pub kernel_stack: usize,
    pub state: ThreadState,
    pub cpu_time: u64,
}

#[derive(Clone, Copy)]
pub enum ThreadState {
    Running,
    Ready,
    Blocked,
    Terminated,
}

// TLS에는 포인터만 저장
#[thread_local]
static mut CURRENT_THREAD: *mut ThreadContext = core::ptr::null_mut();

// 현재 스레드 컨텍스트 설정
pub unsafe fn set_current_thread(ctx: *mut ThreadContext) {
    CURRENT_THREAD = ctx;
}

// 현재 스레드 컨텍스트 가져오기
pub fn current_thread() -> &'static mut ThreadContext {
    unsafe {
        assert!(!CURRENT_THREAD.is_null(), "Thread context not initialized");
        &mut *CURRENT_THREAD
    }
}

// 사용 예시
pub fn example_usage() {
    let ctx = current_thread();
    ctx.cpu_time += 1;
    println!("Thread {} priority: {}", ctx.id, ctx.priority);
}

설명

이것이 하는 일: 위 코드는 스레드의 모든 중요한 정보를 담은 ThreadContext 구조체를 정의하고, TLS에는 이 구조체의 포인터만 저장하여 효율적으로 관리하는 방법을 보여줍니다. 첫 번째로, ThreadContext 구조체는 스레드 ID, 우선순위, 스택 포인터, 커널 스택, 실행 상태, CPU 사용 시간 등 스레드의 모든 필수 정보를 담고 있습니다.

이 구조체는 실제로는 힙이나 특별한 메모리 풀에 할당되며, 각 스레드가 생성될 때 하나씩 만들어집니다. 구조체의 크기가 크더라도 실제 인스턴스는 하나만 존재하므로 메모리 효율적입니다.

그 다음으로, CURRENT_THREAD라는 TLS 변수는 *mut ThreadContext 타입, 즉 포인터만 저장합니다. 이 포인터의 크기는 항상 8바이트(64비트 시스템 기준)로 고정되어 있어, 구조체가 아무리 커져도 TLS 오버헤드는 변하지 않습니다.

set_current_thread 함수는 스레드 컨텍스트 스위칭 시점에 호출되어, 현재 실행 중인 스레드의 컨텍스트 포인터를 TLS에 저장합니다. 세 번째로, current_thread 함수는 TLS에서 포인터를 읽어와 실제 ThreadContext 참조로 변환합니다.

assert를 통해 null 포인터 체크를 하여 안전성을 확보하고, unsafe 블록을 통해 포인터를 역참조합니다. 이 함수는 커널 전체에서 "현재 스레드의 정보"가 필요할 때마다 호출되며, 해시맵 검색이나 락 없이 즉시 데이터에 접근할 수 있습니다.

마지막으로, example_usage 함수는 실제 사용 예시를 보여줍니다. current_thread()를 호출하면 현재 스레드의 컨텍스트를 가변 참조로 받아올 수 있고, 필드를 자유롭게 읽고 쓸 수 있습니다.

CPU 시간을 증가시키거나, 우선순위를 확인하거나, 상태를 변경하는 등의 작업이 모두 간단한 구조체 접근으로 이루어집니다. 여러분이 이 패턴을 사용하면 OS 스케줄러, 시스템 콜 핸들러, 인터럽트 핸들러 등 커널의 어디서든 현재 스레드 정보에 빠르게 접근할 수 있습니다.

특히 컨텍스트 스위칭 성능이 중요한 실시간 시스템이나 고성능 서버 OS에서 이 패턴은 필수적입니다.

실전 팁

💡 포인터를 TLS에 저장할 때는 생명주기 관리가 매우 중요합니다. 스레드가 종료될 때 반드시 해당 ThreadContext를 해제해야 하며, 다른 스레드가 이미 해제된 포인터에 접근하지 않도록 주의해야 합니다.

💡 current_thread() 함수는 매우 자주 호출되므로, 프로덕션 코드에서는 assert 대신 panic을 발생시키지 않는 더 가벼운 체크를 사용하는 것을 고려하세요. 또는 릴리스 빌드에서는 체크를 완전히 제거할 수도 있습니다.

💡 멀티코어 시스템에서는 각 CPU 코어가 다른 스레드를 실행하므로, TLS는 자연스럽게 코어별로 분리됩니다. 이는 캐시 지역성(cache locality)을 높여 성능을 크게 향상시킵니다.

💡 디버깅을 위해 ThreadContext에 디버그 정보(생성 시각, 스택 추적 등)를 추가하는 것을 권장합니다. 프로덕션에서는 조건부 컴파일로 제거할 수 있습니다.

💡 컨텍스트 스위칭 시 set_current_thread를 호출하는 것을 잊지 마세요. 이 호출을 빠뜨리면 current_thread()가 잘못된 데이터를 반환하여 매우 찾기 어려운 버그가 발생합니다.


3. TLS 초기화 메커니즘 - 스레드 시작 시 안전한 설정

시작하며

여러분이 새로운 스레드를 생성할 때, TLS 변수들이 자동으로 올바른 값으로 초기화될 것이라고 기대하셨나요? 하지만 운영체제 개발에서는 그렇지 않습니다.

TLS 변수는 선언 시 지정한 초기값(예: 0이나 null)으로 시작하지만, 실제 의미 있는 값은 스레드가 시작된 후 명시적으로 설정해야 합니다. 이 문제는 특히 스레드 풀이나 동적 스레드 생성을 사용하는 시스템에서 심각합니다.

초기화되지 않은 TLS를 사용하면 null 포인터 역참조, 잘못된 스레드 ID 사용, 일관성 없는 상태 등 다양한 버그가 발생합니다. 더 나쁜 것은 이런 버그가 간헐적으로만 나타나 재현하기 매우 어렵다는 점입니다.

바로 이럴 때 필요한 것이 체계적인 TLS 초기화 메커니즘입니다. 스레드 생성 흐름에 초기화 코드를 통합하여, 모든 스레드가 실행되기 전에 TLS가 올바르게 설정되도록 보장해야 합니다.

개요

간단히 말해서, TLS 초기화는 새로운 스레드가 실제 작업을 시작하기 전에 모든 TLS 변수를 올바른 값으로 설정하는 과정입니다. 이 과정이 중요한 이유는, OS 커널에서는 스레드가 생성되는 즉시 스케줄러에 의해 실행될 수 있기 때문입니다.

만약 스레드 함수가 실행되기 시작했는데 TLS가 초기화되지 않았다면, current_thread()를 호출하는 순간 null 포인터 에러가 발생하거나 잘못된 데이터를 읽게 됩니다. 예를 들어, 인터럽트 핸들러가 현재 스레드의 우선순위를 확인하려 할 때 초기화되지 않은 TLS를 읽으면 시스템 전체가 패닉할 수 있습니다.

기존에는 각 스레드 함수의 시작 부분에서 수동으로 초기화 코드를 작성했다면, 이제는 스레드 생성 함수 자체에 초기화 로직을 내장하여 모든 스레드가 자동으로 올바르게 초기화되도록 만들 수 있습니다. TLS 초기화의 핵심 특징은 첫째, 원자적(atomic)이어야 한다는 점(부분적으로 초기화된 상태가 있으면 안 됨), 둘째, 스레드 실행 전에 완료되어야 한다는 점, 셋째, 실패 시 스레드 생성 자체를 중단해야 한다는 점입니다.

이러한 특징들이 시스템 안정성을 보장합니다.

코드 예제

use alloc::boxed::Box;

// 스레드 생성 함수
pub fn create_thread(
    id: u32,
    priority: u8,
    entry_point: fn(),
) -> Result<(), &'static str> {
    // 1. ThreadContext 할당
    let context = Box::new(ThreadContext {
        id,
        priority,
        stack_pointer: 0,
        kernel_stack: allocate_kernel_stack()?,
        state: ThreadState::Ready,
        cpu_time: 0,
    });

    // 2. Box를 raw pointer로 변환 (소유권 이전)
    let context_ptr = Box::into_raw(context);

    // 3. 스레드 진입 래퍼 함수 생성
    let wrapper = move || {
        unsafe {
            // 4. 스레드 시작 즉시 TLS 초기화
            set_current_thread(context_ptr);

            // 5. 초기화 검증
            assert!(!CURRENT_THREAD.is_null());

            // 6. 실제 스레드 함수 실행
            entry_point();

            // 7. 정리 (스레드 종료 시)
            cleanup_thread(context_ptr);
        }
    };

    // 8. 시스템 스레드 생성
    spawn_kernel_thread(wrapper)?;

    Ok(())
}

// 스레드 정리 함수
unsafe fn cleanup_thread(ctx: *mut ThreadContext) {
    if !ctx.is_null() {
        let context = Box::from_raw(ctx);
        free_kernel_stack(context.kernel_stack);
        drop(context);
    }
}

설명

이것이 하는 일: 위 코드는 새로운 스레드를 생성할 때 TLS를 안전하게 초기화하는 전체 흐름을 보여줍니다. 스레드 컨텍스트 할당부터 TLS 설정, 실제 실행, 정리까지 모든 단계를 체계적으로 처리합니다.

첫 번째로, create_thread 함수는 스레드 ID, 우선순위, 진입점 함수를 받아 새로운 스레드를 생성합니다. 가장 먼저 ThreadContext를 Box로 힙에 할당합니다.

Box를 사용하는 이유는 메모리 관리가 명확하고, 나중에 raw pointer로 변환하기 쉽기 때문입니다. 커널 스택도 이 시점에 할당되며, 실패 시 ?

연산자로 에러를 즉시 반환합니다. 두 번째로, Box::into_raw를 사용해 Box를 raw pointer로 변환합니다.

이렇게 하면 Box의 소유권 관리를 벗어나 수동으로 생명주기를 제어할 수 있습니다. 이 포인터는 wrapper 클로저에 move로 캡처되어, 새로운 스레드가 시작될 때까지 유효하게 유지됩니다.

세 번째로, wrapper 클로저는 실제 스레드가 실행될 때 가장 먼저 실행되는 코드입니다. 이 클로저의 첫 번째 작업은 set_current_thread를 호출하여 TLS를 초기화하는 것입니다.

이 시점 이후로는 해당 스레드에서 current_thread()를 안전하게 호출할 수 있습니다. assert로 null이 아님을 검증한 후, 사용자가 제공한 entry_point 함수를 실행합니다.

네 번째로, entry_point가 정상적으로 반환되면 cleanup_thread가 호출됩니다. 이 함수는 Box::from_raw로 포인터를 다시 Box로 변환하여, Rust의 소유권 시스템이 자동으로 메모리를 해제하도록 합니다.

커널 스택도 명시적으로 해제하여 리소스 누수를 방지합니다. 여러분이 이 패턴을 사용하면 모든 스레드가 일관되게 초기화되고, TLS 관련 버그를 원천적으로 방지할 수 있습니다.

또한 스레드 생성 실패 시 부분적으로 생성된 리소스가 남지 않도록 보장하며, 스레드 종료 시 자동으로 정리되어 메모리 누수가 발생하지 않습니다.

실전 팁

💡 wrapper 클로저는 스레드 실행의 첫 진입점이므로, 여기에 추가적인 초기화 코드(로깅, 성능 측정, 보안 컨텍스트 설정 등)를 넣을 수 있습니다.

💡 panic이 발생할 경우를 대비해 panic handler에서도 cleanup_thread를 호출하도록 설정하세요. 그렇지 않으면 스레드가 비정상 종료될 때 리소스 누수가 발생합니다.

💡 멀티코어 시스템에서는 스레드가 생성된 코어와 실행되는 코어가 다를 수 있습니다. TLS 초기화는 실행 시점(wrapper 내부)에 해야 하며, 생성 시점에 하면 안 됩니다.

💡 성능을 위해 ThreadContext 할당을 미리 할당된 풀에서 가져오도록 최적화할 수 있습니다. 매번 힙 할당하는 것보다 훨씬 빠릅니다.

💡 디버그 빌드에서는 entry_point 호출 전후로 타임스탬프를 기록하여, 각 스레드가 얼마나 오래 실행되었는지 추적할 수 있습니다. 이는 성능 분석에 매우 유용합니다.


4. CPU별 데이터 분리 - Per-CPU 변수 구현

시작하며

여러분이 멀티코어 시스템에서 운영체제를 개발할 때, 각 CPU 코어마다 독립적인 데이터를 관리해야 하는 경우가 많습니다. 예를 들어, 각 코어의 실행 큐(run queue), 인터럽트 카운터, 타이머, 현재 실행 중인 스레드 등은 코어별로 완전히 분리되어야 합니다.

이런 요구사항은 고성능 OS 개발에서 필수적입니다. 만약 모든 코어가 하나의 전역 실행 큐를 공유한다면, 락 경쟁으로 인해 스케줄링 성능이 급격히 저하됩니다.

실제로 리눅스, FreeBSD 등 현대 OS들은 모두 per-CPU 데이터 구조를 광범위하게 사용하여 확장성을 확보합니다. 코어 수가 증가해도 성능이 선형적으로 증가하려면 각 코어가 독립적으로 작동해야 합니다.

바로 이럴 때 필요한 것이 Per-CPU 변수 패턴입니다. TLS가 스레드별 데이터를 제공한다면, Per-CPU 변수는 CPU 코어별 데이터를 제공합니다.

흥미로운 점은 Rust에서 TLS를 사용해 Per-CPU 변수를 구현할 수 있다는 것입니다.

개요

간단히 말해서, Per-CPU 변수는 각 CPU 코어가 독립적인 데이터 인스턴스를 가지며, 다른 코어의 데이터에 접근하지 않도록 보장하는 메커니즘입니다. 왜 이 패턴이 중요한지 실무 관점에서 설명하자면, 현대 멀티코어 시스템에서는 락 경쟁이 성능의 가장 큰 병목이 됩니다.

예를 들어, 8코어 시스템에서 모든 코어가 하나의 뮤텍스로 보호된 카운터를 증가시킨다면, 실제로는 거의 순차 실행처럼 느려집니다. Per-CPU 변수를 사용하면 각 코어가 자신만의 카운터를 가지므로 락이 전혀 필요 없습니다.

기존에는 배열에 CPU ID를 인덱스로 사용해 데이터를 저장했다면(예: counters[cpu_id]), Per-CPU 변수를 사용하면 현재 CPU의 데이터에 직접 접근할 수 있어 인덱싱 오버헤드도 제거됩니다. Per-CPU 변수의 핵심 특징은 첫째, 완벽한 락-프리 접근이 가능하다는 점, 둘째, 캐시 지역성이 극대화된다는 점(각 코어가 자신의 캐시라인만 사용), 셋째, 확장성이 뛰어나다는 점입니다.

이러한 특징들이 고성능, 대규모 멀티코어 시스템에서 필수적입니다.

코드 예제

// Per-CPU 데이터 구조
pub struct PerCpuData {
    pub interrupt_count: u64,
    pub schedule_count: u64,
    pub idle_time: u64,
    pub current_runqueue_size: usize,
}

// TLS를 사용한 Per-CPU 변수
#[thread_local]
static mut CPU_DATA: PerCpuData = PerCpuData {
    interrupt_count: 0,
    schedule_count: 0,
    idle_time: 0,
    current_runqueue_size: 0,
};

// 현재 CPU 데이터에 접근
pub fn current_cpu_data() -> &'static mut PerCpuData {
    unsafe { &mut CPU_DATA }
}

// 인터럽트 핸들러에서 사용
pub fn handle_interrupt() {
    let cpu_data = current_cpu_data();
    cpu_data.interrupt_count += 1;

    // 락 없이 안전하게 카운터 증가
    // 각 CPU가 자신의 데이터만 수정
}

// 스케줄러에서 사용
pub fn schedule() {
    let cpu_data = current_cpu_data();
    cpu_data.schedule_count += 1;

    // 현재 CPU의 실행 큐 크기 확인
    if cpu_data.current_runqueue_size == 0 {
        enter_idle_state();
    }
}

// 전체 CPU 통계 수집 (모든 코어의 데이터 합산)
pub fn collect_global_stats() -> (u64, u64) {
    // 이 함수는 특별한 경우에만 호출
    // 각 CPU의 데이터를 순회하며 합산
    let mut total_interrupts = 0;
    let mut total_schedules = 0;

    for_each_cpu(|cpu_id| {
        let data = get_cpu_data(cpu_id);
        total_interrupts += data.interrupt_count;
        total_schedules += data.schedule_count;
    });

    (total_interrupts, total_schedules)
}

설명

이것이 하는 일: 위 코드는 TLS를 활용하여 CPU 코어별로 독립적인 데이터를 관리하는 Per-CPU 변수 패턴을 구현합니다. 각 코어는 자신만의 통계와 상태 정보를 락 없이 관리할 수 있습니다.

첫 번째로, PerCpuData 구조체는 각 CPU가 추적해야 하는 정보를 담고 있습니다. 인터럽트 발생 횟수, 스케줄링 호출 횟수, 유휴 시간, 실행 큐 크기 등은 모두 코어별로 다른 값을 가집니다.

이 구조체는 #[thread_local] 속성으로 선언되어, 각 CPU에서 실행되는 스레드마다 독립된 인스턴스를 갖게 됩니다. 운영체제에서는 일반적으로 각 CPU가 하나의 스케줄러 스레드를 실행하므로, TLS가 곧 Per-CPU가 됩니다.

두 번째로, current_cpu_data 함수는 현재 CPU의 데이터에 즉시 접근하는 방법을 제공합니다. 이 함수는 CPU ID를 조회할 필요도 없고, 배열 인덱싱도 필요 없으며, 당연히 락도 필요 없습니다.

단순히 TLS 변수의 주소를 반환하는 것만으로 현재 CPU의 데이터에 접근할 수 있어, 오버헤드가 거의 제로에 가깝습니다. 세 번째로, handle_interrupt와 schedule 함수는 실제 사용 예시를 보여줍니다.

인터럽트 핸들러는 매우 자주 호출되는 코드이므로, 여기서 락을 사용하면 성능이 크게 저하됩니다. Per-CPU 변수를 사용하면 각 코어가 자신의 카운터만 증가시키므로, 락 경쟁 없이 동시에 실행됩니다.

스케줄러도 마찬가지로 각 코어의 실행 큐 상태를 독립적으로 관리하여, 코어 간 간섭 없이 스케줄링 결정을 내릴 수 있습니다. 네 번째로, collect_global_stats 함수는 특수한 경우를 다룹니다.

때로는 시스템 전체의 통계를 보고 싶을 때가 있는데, 이 경우에는 모든 CPU의 Per-CPU 데이터를 순회하며 합산해야 합니다. 이 작업은 비용이 크므로 자주 호출하지 않아야 하며, 주로 디버깅이나 모니터링 목적으로만 사용됩니다.

일반적인 작업 경로(fast path)에서는 절대 호출되지 않아야 합니다. 여러분이 이 패턴을 사용하면 멀티코어 시스템에서 선형적인 성능 확장을 달성할 수 있습니다.

2코어에서 8코어로 늘어나면 실제로 처리량도 4배로 증가하는 것을 볼 수 있습니다. 또한 캐시 일관성 트래픽이 최소화되어, 메모리 대역폭도 효율적으로 사용됩니다.

실전 팁

💡 Per-CPU 변수에 접근하는 동안 선점(preemption)이 발생하면 문제가 될 수 있습니다. 스레드가 다른 CPU로 마이그레이션되면 잘못된 데이터를 읽을 수 있으므로, 중요한 섹션에서는 선점을 비활성화해야 합니다.

💡 캐시 라인 경합(false sharing)을 피하기 위해 PerCpuData의 크기를 64바이트(일반적인 캐시 라인 크기)의 배수로 맞추세요. #[repr(align(64))] 속성을 사용할 수 있습니다.

💡 Per-CPU 변수는 읽기 전용인 경우 더욱 효과적입니다. 쓰기가 빈번하면 캐시 무효화가 발생할 수 있으므로, 읽기가 많고 쓰기가 적은 데이터에 사용하세요.

💡 통계 수집 시에는 정확도와 성능의 트레이드오프를 고려하세요. 정확한 전역 합계가 필요 없다면, 샘플링이나 근사값으로도 충분할 수 있습니다.

💡 NUMA(Non-Uniform Memory Access) 시스템에서는 Per-CPU 데이터를 해당 CPU가 속한 NUMA 노드의 메모리에 할당하면 접근 속도가 더 빨라집니다.


5. TLS와 컨텍스트 스위칭 - 스레드 전환 시 TLS 업데이트

시작하며

여러분이 OS 스케줄러를 구현하면서, 한 스레드에서 다른 스레드로 실행을 전환하는 컨텍스트 스위칭 코드를 작성할 때, TLS 업데이트를 잊어버린 적이 있나요? 컨텍스트 스위칭은 CPU 레지스터와 스택 포인터를 저장하고 복원하는 것만으로는 충분하지 않습니다.

TLS도 함께 업데이트되어야 합니다. 이 문제는 매우 미묘하고 찾기 어려운 버그를 만듭니다.

스레드 A에서 B로 전환했는데 TLS 업데이트를 빠뜨리면, 스레드 B의 코드가 실행되면서 current_thread()를 호출할 때 여전히 스레드 A의 컨텍스트를 읽게 됩니다. 이는 잘못된 우선순위, 잘못된 권한 체크, 데이터 오염 등 심각한 보안 및 안정성 문제로 이어집니다.

바로 이럴 때 필요한 것이 컨텍스트 스위칭 루틴에 TLS 업데이트를 통합하는 것입니다. 레지스터 복원과 동일한 중요도로, TLS 포인터 전환이 원자적으로 이루어져야 합니다.

개요

간단히 말해서, 컨텍스트 스위칭 시 TLS 업데이트는 이전 스레드의 TLS를 저장하고 새로운 스레드의 TLS를 로드하는 과정입니다. 왜 이 과정이 중요한지 실무 관점에서 설명하자면, 운영체제의 모든 코드는 "현재 스레드"라는 개념에 의존합니다.

시스템 콜 핸들러는 호출한 스레드의 권한을 확인하고, 페이지 폴트 핸들러는 스레드의 주소 공간을 참조하며, 스케줄러는 스레드의 우선순위를 보고 결정을 내립니다. 만약 컨텍스트 스위칭 후 TLS가 잘못되어 있다면, 이 모든 것이 엉망이 됩니다.

기존에는 컨텍스트 스위칭과 TLS 업데이트를 별도의 단계로 처리하여 사이에 틈이 생길 수 있었다면, 이제는 두 작업을 하나의 원자적 연산으로 묶어 일관성을 보장합니다. 이 패턴의 핵심 특징은 첫째, 원자성(레지스터와 TLS가 함께 전환되어야 함), 둘째, 빠른 실행(컨텍스트 스위칭은 매우 자주 발생하므로 오버헤드 최소화), 셋째, 인터럽트 안전성(스위칭 도중 인터럽트가 발생해도 일관성 유지)입니다.

이러한 특징들이 OS의 정확성과 성능을 좌우합니다.

코드 예제

// 컨텍스트 스위칭 함수 (간소화된 버전)
pub unsafe fn context_switch(
    from: *mut ThreadContext,
    to: *mut ThreadContext,
) {
    // 1. 인터럽트 비활성화 (원자성 보장)
    let flags = disable_interrupts();

    // 2. 현재 스레드(from)의 상태 저장
    if !from.is_null() {
        // CPU 레지스터를 from의 컨텍스트에 저장
        save_registers(&mut (*from));
        (*from).state = ThreadState::Ready;
    }

    // 3. 새 스레드(to)의 TLS 업데이트 (중요!)
    set_current_thread(to);

    // 4. 새 스레드의 상태 복원
    (*to).state = ThreadState::Running;
    load_registers(&(*to));

    // 5. 페이지 테이블 전환 (프로세스가 다르면)
    if (*from).process_id != (*to).process_id {
        switch_page_table((*to).page_table);
    }

    // 6. 인터럽트 재활성화
    restore_interrupts(flags);

    // 7. 새 스레드로 점프 (실제로는 어셈블리)
    // 여기서 함수가 반환되지 않고 to 스레드가 실행됨
}

// 스케줄러에서 호출하는 래퍼
pub fn yield_cpu() {
    let current = current_thread() as *mut ThreadContext;
    let next = scheduler::pick_next_thread();

    unsafe {
        context_switch(current, next);
    }

    // 이 시점에는 다른 스레드들이 실행되다가
    // 나중에 스케줄러가 이 스레드를 다시 선택하면
    // 여기서부터 실행이 재개됨
}

// 타이머 인터럽트 핸들러
pub extern "C" fn timer_interrupt_handler() {
    // 현재 스레드의 시간 할당량 확인
    let current = current_thread();
    current.cpu_time += TIMER_TICK;

    if current.cpu_time >= TIME_SLICE {
        // 시간 할당량 소진, 강제 스위칭
        current.cpu_time = 0;
        yield_cpu();
    }
}

설명

이것이 하는 일: 위 코드는 한 스레드에서 다른 스레드로 실행을 전환하는 컨텍스트 스위칭 과정에서 TLS를 올바르게 업데이트하는 방법을 보여줍니다. 이는 OS 스케줄러의 핵심 기능입니다.

첫 번째로, context_switch 함수는 from 스레드에서 to 스레드로의 전환을 담당합니다. 가장 먼저 하는 일은 인터럽트를 비활성화하는 것입니다.

이는 매우 중요한데, 만약 컨텍스트 스위칭 도중에 타이머 인터럽트가 발생하여 또 다른 스위칭이 시작되면 데이터 구조가 불일치 상태가 될 수 있기 때문입니다. disable_interrupts는 이전 플래그를 반환하여, 나중에 원래 상태로 복원할 수 있게 합니다.

두 번째로, 현재 실행 중이던 스레드(from)의 CPU 레지스터를 저장합니다. save_registers는 일반적으로 어셈블리로 구현되며, 범용 레지스터, 스택 포인터, 프로그램 카운터 등을 ThreadContext 구조체에 복사합니다.

이렇게 저장된 정보 덕분에 나중에 이 스레드가 다시 스케줄링되면 정확히 중단된 지점부터 실행을 재개할 수 있습니다. 스레드 상태도 Running에서 Ready로 변경됩니다.

세 번째로, 가장 중요한 부분인 TLS 업데이트가 발생합니다. set_current_thread(to)를 호출하여 TLS의 CURRENT_THREAD 포인터를 새로운 스레드로 변경합니다.

이 시점 이후로, current_thread()를 호출하면 to 스레드의 컨텍스트가 반환됩니다. 이 업데이트는 레지스터 복원 전에 이루어져야 하는데, 그 이유는 load_registers 과정에서 발생할 수 있는 페이지 폴트나 예외 처리에서 이미 올바른 스레드 컨텍스트를 참조해야 하기 때문입니다.

네 번째로, 새 스레드의 레지스터를 복원하고 상태를 Running으로 변경합니다. 프로세스가 다르면 페이지 테이블도 전환하여, 새 스레드가 올바른 가상 주소 공간을 사용하도록 합니다.

마지막으로 인터럽트를 재활성화하고, 실제로는 어셈블리 코드를 통해 새 스레드의 실행 위치로 점프합니다. 다섯 번째로, yield_cpu와 timer_interrupt_handler는 실제 사용 시나리오를 보여줍니다.

yield_cpu는 스레드가 자발적으로 CPU를 양보할 때 호출되며, timer_interrupt_handler는 강제 선점을 구현합니다. 두 경우 모두 context_switch를 통해 TLS가 올바르게 업데이트됩니다.

여러분이 이 패턴을 사용하면 모든 컨텍스트 스위칭에서 TLS가 자동으로 올바르게 업데이트되어, 스레드 컨텍스트 관련 버그를 원천적으로 방지할 수 있습니다. 또한 인터럽트 비활성화로 원자성을 보장하여, 경쟁 조건이나 불일치 상태가 발생하지 않습니다.

실전 팁

💡 컨텍스트 스위칭은 OS에서 가장 자주 실행되는 코드 중 하나이므로, 극도로 최적화되어야 합니다. save_registers와 load_registers는 반드시 어셈블리로 작성하여 불필요한 메모리 접근을 제거하세요.

💡 인터럽트 비활성화 시간을 최소화하는 것이 중요합니다. 인터럽트가 꺼져 있는 동안 타이머나 I/O 이벤트를 놓칠 수 있으므로, 필수적인 작업만 수행하고 즉시 재활성화하세요.

💡 디버깅을 위해 from과 to가 null이 아닌지, 유효한 주소인지 assert로 체크하는 것이 좋습니다. 다만 프로덕션에서는 성능을 위해 제거할 수 있습니다.

💡 TLS 업데이트 시점이 매우 중요합니다. 너무 일찍 하면 from 스레드 저장 중에 문제가 생기고, 너무 늦으면 to 스레드 복원 중에 문제가 생깁니다. 정확히 두 작업 사이에 위치해야 합니다.

💡 성능 측정을 위해 context_switch 전후에 타임스탬프를 기록하세요. 컨텍스트 스위칭 시간이 지나치게 길다면(예: 1마이크로초 이상) 최적화가 필요합니다.


6. 시스템 콜에서 TLS 활용 - 사용자 공간과 커널 공간 전환

시작하며

여러분이 시스템 콜을 구현할 때, 어떻게 현재 호출한 프로세스나 스레드의 정보를 빠르게 가져올 수 있을까요? 사용자 공간 프로그램이 read()나 write()를 호출하면, 커널은 누가 호출했는지 알아야 권한을 체크하고, 올바른 파일 디스크립터 테이블을 참조할 수 있습니다.

이런 요구사항은 모든 시스템 콜에서 공통적입니다. 전통적인 방법으로는 시스템 콜 인자로 프로세스 ID를 전달받거나, 전역 테이블에서 검색하는 방식이 있지만, 이는 오버헤드가 크고 보안 취약점이 될 수 있습니다(사용자가 다른 프로세스 ID를 속일 수 있음).

리눅스를 포함한 현대 OS들은 모두 커널 내부에서 "현재 프로세스" 포인터를 직접 관리합니다. 바로 이럴 때 TLS가 빛을 발합니다.

시스템 콜 핸들러는 TLS에서 current_thread()를 호출하여 즉시 호출자의 컨텍스트를 얻을 수 있고, 이를 통해 권한 검사, 리소스 할당, 에러 처리 등을 안전하고 효율적으로 수행할 수 있습니다.

개요

간단히 말해서, 시스템 콜에서 TLS를 활용하면 호출한 스레드의 컨텍스트를 즉시 얻어 권한 검사와 리소스 접근을 안전하게 수행할 수 있습니다. 이것이 필요한 이유는, 시스템 콜은 사용자 공간과 커널 공간의 경계이며 보안의 최전선이기 때문입니다.

악의적인 사용자 프로그램이 다른 프로세스의 파일을 읽거나, 권한 없는 메모리에 접근하려 할 때, 커널은 호출자의 신원과 권한을 정확히 파악해야 합니다. 예를 들어, write(fd, buf, len) 시스템 콜에서 커널은 fd가 현재 프로세스가 소유한 유효한 파일 디스크립터인지 확인해야 하는데, 이를 위해 현재 프로세스의 파일 디스크립터 테이블에 접근해야 합니다.

기존에는 시스템 콜 진입 시 스택이나 레지스터에 저장된 정보를 사용했다면, TLS를 사용하면 어떤 시스템 콜이든 일관된 방식으로 현재 스레드 정보에 접근할 수 있습니다. TLS 기반 시스템 콜의 핵심 특징은 첫째, 빠른 컨텍스트 접근(TLS 읽기만으로 충분), 둘째, 보안성(사용자가 조작할 수 없음), 셋째, 코드 단순성(모든 시스템 콜이 동일한 패턴 사용)입니다.

이러한 특징들이 안전하고 유지보수 가능한 OS 구현에 필수적입니다.

코드 예제

// 시스템 콜 핸들러 예시: read
pub fn sys_read(fd: usize, buf: *mut u8, len: usize) -> isize {
    // 1. 현재 스레드 컨텍스트 가져오기 (TLS 사용)
    let current = current_thread();

    // 2. 권한 검사: 버퍼가 사용자 공간에 있는지 확인
    if !is_user_space(buf, len) {
        set_error(EFAULT);
        return -1;
    }

    // 3. 파일 디스크립터 유효성 검사
    let file = match current.get_file_descriptor(fd) {
        Some(f) => f,
        None => {
            set_error(EBADF); // Bad file descriptor
            return -1;
        }
    };

    // 4. 읽기 권한 확인
    if !file.has_read_permission() {
        set_error(EACCES); // Permission denied
        return -1;
    }

    // 5. 실제 읽기 수행
    match file.read(buf, len) {
        Ok(bytes_read) => bytes_read as isize,
        Err(e) => {
            set_error(e);
            -1
        }
    }
}

// ThreadContext에 메서드 추가
impl ThreadContext {
    pub fn get_file_descriptor(&self, fd: usize) -> Option<&File> {
        // 프로세스의 파일 디스크립터 테이블 참조
        self.process.fd_table.get(fd)
    }
}

// 시스템 콜 진입점 (어셈블리에서 호출)
#[no_mangle]
pub extern "C" fn syscall_handler(
    syscall_num: usize,
    arg1: usize,
    arg2: usize,
    arg3: usize,
) -> isize {
    // 시스템 콜 번호에 따라 분기
    match syscall_num {
        SYS_READ => sys_read(arg1, arg2 as *mut u8, arg3),
        SYS_WRITE => sys_write(arg1, arg2 as *const u8, arg3),
        SYS_OPEN => sys_open(arg1 as *const u8, arg2),
        SYS_CLOSE => sys_close(arg1),
        _ => {
            set_error(ENOSYS); // Function not implemented
            -1
        }
    }
    // 모든 시스템 콜이 current_thread()를 통해
    // 호출자 정보에 접근할 수 있음
}

설명

이것이 하는 일: 위 코드는 시스템 콜 구현에서 TLS를 활용하여 호출자의 컨텍스트를 안전하고 효율적으로 가져오는 방법을 보여줍니다. read 시스템 콜을 예시로 권한 검사와 리소스 접근 전 과정을 다룹니다.

첫 번째로, sys_read 함수는 파일 디스크립터, 버퍼 포인터, 길이를 인자로 받습니다. 가장 먼저 하는 일은 current_thread()를 호출하여 TLS에서 현재 스레드의 컨텍스트를 가져오는 것입니다.

이 한 줄의 호출로, 함수는 누가 이 시스템 콜을 호출했는지, 어떤 권한을 가지고 있는지, 어떤 리소스를 소유하고 있는지에 대한 모든 정보에 접근할 수 있게 됩니다. 중요한 점은 이 정보가 사용자 공간에서 전달된 것이 아니라 커널이 내부적으로 관리하는 것이므로, 위조나 조작이 불가능하다는 것입니다.

두 번째로, 버퍼 포인터의 유효성을 검사합니다. is_user_space 함수는 buf부터 buf+len까지의 메모리 범위가 현재 프로세스의 사용자 공간에 속하는지 확인합니다.

만약 커널 공간을 가리키거나 다른 프로세스의 메모리를 가리킨다면 EFAULT 에러를 반환합니다. 이 검사는 보안에 매우 중요한데, 그렇지 않으면 악의적인 프로그램이 커널 메모리를 덮어쓸 수 있기 때문입니다.

세 번째로, 파일 디스크립터의 유효성을 검사합니다. current.get_file_descriptor(fd)는 현재 스레드가 속한 프로세스의 파일 디스크립터 테이블에서 fd를 검색합니다.

각 프로세스는 자신만의 파일 디스크립터 테이블을 가지므로, fd=3이 프로세스 A에서는 stdout이지만 프로세스 B에서는 socket일 수 있습니다. TLS를 통해 현재 프로세스의 테이블에 접근하기 때문에, 항상 올바른 파일 객체를 얻습니다.

네 번째로, 파일에 대한 읽기 권한을 확인합니다. 파일이 읽기 전용이 아니거나 쓰기 전용으로 열렸다면 EACCES 에러를 반환합니다.

모든 검사를 통과한 후에야 실제 file.read()가 호출되어 데이터를 읽고, 읽은 바이트 수를 반환합니다. 다섯 번째로, syscall_handler는 어셈블리 시스템 콜 진입 코드에서 호출되는 C 호환 함수입니다.

시스템 콜 번호와 인자들을 받아 적절한 핸들러 함수로 분기합니다. 모든 핸들러 함수들은 current_thread()를 통해 일관된 방식으로 호출자 정보에 접근할 수 있습니다.

여러분이 이 패턴을 사용하면 시스템 콜 구현이 매우 간결하고 안전해집니다. 매번 호출자 정보를 인자로 전달하거나, 전역 테이블을 검색할 필요 없이, 단순히 current_thread()만 호출하면 됩니다.

또한 보안 버그(예: 다른 프로세스 ID 전달)를 원천적으로 차단할 수 있습니다.

실전 팁

💡 시스템 콜 진입 시 TLS가 올바르게 설정되어 있는지 디버그 빌드에서 assert로 확인하세요. 만약 null이면 심각한 커널 버그입니다.

💡 성능이 중요한 시스템 콜(예: getpid)은 단순히 current_thread().id를 반환하는 것만으로 구현할 수 있어 매우 빠릅니다. 전역 검색이나 락이 전혀 필요 없습니다.

💡 에러 코드는 TLS에 저장하는 것이 좋습니다(이전 예시의 set_error/get_last_error). 이렇게 하면 시스템 콜이 실패 시 -1을 반환하고, 사용자 공간에서 errno 전역 변수로 상세 에러를 확인할 수 있습니다.

💡 시스템 콜에서 블로킹 I/O를 수행할 때, 스레드가 대기 상태로 들어가기 전에 컨텍스트를 저장하고 다른 스레드로 스위칭합니다. 이 경우에도 TLS가 올바르게 업데이트되어야 합니다.

💡 감사(auditing)나 로깅을 위해 시스템 콜 시작 시 current_thread()의 정보를 기록하면, 누가 어떤 작업을 했는지 추적할 수 있어 보안 분석에 유용합니다.


7. TLS 초기화 순서 문제 - 부트스트랩과 첫 스레드

시작하며

여러분이 OS 커널을 부팅할 때, 가장 먼저 실행되는 코드는 어떤 스레드의 컨텍스트에서 실행될까요? 이것은 닭이 먼저냐 달걀이 먼저냐 같은 문제입니다.

스레드를 생성하려면 스레드 생성 함수가 실행되어야 하는데, 그 함수 자체도 어떤 스레드의 컨텍스트에서 실행되어야 합니다. 이 문제는 OS 부트스트랩에서 매우 실질적인 문제입니다.

커널이 메모리에 로드되고 첫 명령어가 실행될 때, 아직 스레드 개념도, TLS도 초기화되지 않았습니다. 만약 이 상태에서 current_thread()를 호출하면 null 포인터가 반환되어 패닉이 발생합니다.

실제로 리눅스, Windows 등 모든 OS는 이 문제를 해결하기 위한 특별한 부트스트랩 코드를 가지고 있습니다. 바로 이럴 때 필요한 것이 부트스트랩 스레드(또는 idle 스레드) 개념입니다.

시스템이 부팅되면 가장 먼저 "스레드 0"을 수동으로 만들고, 이것의 컨텍스트를 TLS에 설정합니다. 이후 모든 스레드는 정상적인 스레드 생성 메커니즘으로 만들어집니다.

개요

간단히 말해서, 부트스트랩 스레드는 커널 부팅 시 수동으로 생성되는 첫 번째 스레드로, TLS를 포함한 모든 스레드 인프라를 초기화하는 역할을 합니다. 이것이 필요한 이유는, 모든 커널 코드는 유효한 스레드 컨텍스트가 있다고 가정하기 때문입니다.

메모리 할당, 동기화 프리미티브, 시스템 콜 등 거의 모든 기능이 current_thread()를 호출할 수 있으므로, 커널 초기화 코드조차도 유효한 TLS를 가져야 합니다. 예를 들어, 부팅 중 디바이스 드라이버를 초기화하는 과정에서 인터럽트가 발생하면, 인터럽트 핸들러가 current_thread()를 호출할 수 있습니다.

기존에는 TLS를 초기화하기 전까지 current_thread()를 호출하지 않도록 모든 코드를 조심스럽게 작성했다면, 부트스트랩 스레드를 사용하면 커널 시작부터 끝까지 일관된 스레드 모델을 유지할 수 있습니다. 부트스트랩 프로세스의 핵심 특징은 첫째, 매우 일찍 실행되어야 한다는 점(다른 초기화 전에), 둘째, 정적으로 할당된 메모리를 사용한다는 점(힙 할당 불가), 셋째, 나중에 정상 스레드로 전환될 수 있다는 점입니다.

이러한 특징들이 깔끔한 OS 아키텍처를 가능하게 합니다.

코드 예제

// 부트스트랩 스레드를 위한 정적 메모리
static mut BOOT_THREAD_CONTEXT: ThreadContext = ThreadContext {
    id: 0,
    priority: 0,
    stack_pointer: 0,
    kernel_stack: 0,
    state: ThreadState::Running,
    cpu_time: 0,
};

// 커널 진입점 - 가장 먼저 실행되는 함수
#[no_mangle]
pub extern "C" fn kernel_main() -> ! {
    // 1. 기본적인 하드웨어 초기화
    init_serial();
    println!("Kernel booting...");

    // 2. 부트스트랩 스레드 설정 (TLS 초기화)
    unsafe {
        let boot_ctx = &mut BOOT_THREAD_CONTEXT as *mut ThreadContext;
        set_current_thread(boot_ctx);
    }

    println!("Bootstrap thread initialized");

    // 3. 이제 current_thread()를 안전하게 사용 가능
    let current = current_thread();
    current.id = 0;

    // 4. 메모리 관리 초기화
    init_memory_management();

    // 5. 스케줄러 초기화
    init_scheduler();

    // 6. 첫 번째 사용자 스레드 생성
    create_thread(1, 10, init_process).expect("Failed to create init");

    // 7. 부트스트랩 스레드는 idle 스레드로 전환
    become_idle_thread();

    // 8. idle 루프 - 할 일이 없을 때 실행
    loop {
        let current = current_thread();
        current.idle_time += 1;

        // CPU 전력 절약
        halt_cpu();

        // 인터럽트 발생 시 스케줄러가 다른 스레드로 전환
    }
}

// idle 스레드로 전환
fn become_idle_thread() {
    let current = current_thread();
    current.priority = 0; // 최저 우선순위
    current.state = ThreadState::Ready;

    scheduler::register_idle_thread(current);

    println!("Boot thread became idle thread");
}

// CPU별 부트스트랩 (멀티코어 시스템)
pub fn boot_secondary_cpu(cpu_id: u32) {
    // 각 CPU마다 자신의 부트스트랩 스레드 필요
    let boot_ctx = Box::new(ThreadContext {
        id: cpu_id * 1000, // CPU별 ID 할당
        priority: 0,
        stack_pointer: 0,
        kernel_stack: allocate_kernel_stack().unwrap(),
        state: ThreadState::Running,
        cpu_time: 0,
    });

    let ctx_ptr = Box::into_raw(boot_ctx);

    unsafe {
        set_current_thread(ctx_ptr);
    }

    println!("CPU {} bootstrap complete", cpu_id);

    // 이 CPU의 idle 루프로 진입
    become_idle_thread();
}

설명

이것이 하는 일: 위 코드는 OS 커널이 부팅될 때 TLS를 포함한 스레드 시스템을 부트스트랩하는 과정을 보여줍니다. "닭과 달걀" 문제를 정적 할당으로 해결합니다.

첫 번째로, BOOT_THREAD_CONTEXT는 정적으로 할당된 ThreadContext입니다. static으로 선언되어 있어 컴파일 타임에 데이터 섹션에 배치되며, 커널이 로드되는 순간부터 유효한 메모리에 존재합니다.

이것이 중요한 이유는, 이 시점에는 아직 힙 할당이 초기화되지 않았으므로 Box::new를 사용할 수 없기 때문입니다. 정적 할당을 사용하면 동적 메모리 없이도 첫 스레드를 만들 수 있습니다.

두 번째로, kernel_main 함수는 부트로더가 커널을 로드한 후 제어를 넘기는 첫 진입점입니다. 가장 먼저 하는 일은 시리얼 포트 같은 최소한의 디버깅 출력을 초기화하는 것이고, 그 다음이 부트스트랩 스레드 설정입니다.

set_current_thread를 호출하는 순간, 이 커널 실행 흐름은 스레드 ID 0을 가진 "스레드"가 됩니다. 이제부터 모든 커널 함수가 current_thread()를 안전하게 호출할 수 있습니다.

세 번째로, 부트스트랩 스레드가 설정된 후 나머지 커널 서브시스템들을 초기화합니다. init_memory_management는 힙 할당을 활성화하고, init_scheduler는 스케줄링 자료구조를 만듭니다.

create_thread로 첫 번째 사용자 스레드(전통적으로 "init" 프로세스)를 생성하면, 이제 시스템은 정상적인 멀티태스킹 OS가 됩니다. 네 번째로, become_idle_thread는 부트스트랩 스레드를 idle 스레드로 변환합니다.

Idle 스레드는 실행할 다른 스레드가 없을 때 실행되는 특별한 스레드로, 가장 낮은 우선순위를 가지고 있습니다. 무한 루프에서 halt_cpu를 호출하여 CPU를 저전력 상태로 만들고, 인터럽트(타이머, I/O 등)가 발생하면 깨어나서 스케줄러가 실행할 스레드를 찾습니다.

다섯 번째로, boot_secondary_cpu는 멀티코어 시스템에서 추가 CPU 코어를 부팅할 때 사용됩니다. 각 CPU는 자신만의 TLS를 가지므로, 각 코어마다 별도의 부트스트랩 스레드가 필요합니다.

주 CPU가 부트스트랩을 완료한 후, 각 부 CPU를 깨우면서 이 함수를 실행시킵니다. 이 경우에는 힙 할당이 이미 활성화되어 있으므로 Box를 사용할 수 있습니다.

여러분이 이 패턴을 사용하면 커널 코드 전체에서 "현재 스레드가 있다"는 불변식(invariant)을 유지할 수 있습니다. 예외 처리나 조건부 코드 없이, 어디서든 current_thread()를 호출할 수 있어 코드가 훨씬 단순해집니다.

실전 팁

💡 부트스트랩 스레드의 스택 포인터는 부트로더가 설정한 초기 스택을 사용합니다. 나중에 적절한 커널 스택을 할당하여 교체할 수 있습니다.

💡 디버깅을 위해 부트스트랩 과정의 각 단계마다 로그를 남기세요. "Bootstrap thread set", "Memory initialized", "Scheduler ready" 등의 메시지는 부팅 실패 시 어디서 멈췄는지 파악하는 데 필수적입니다.

💡 멀티코어 시스템에서는 주 CPU(BSP)가 먼저 부트스트랩을 완료한 후, 부 CPU들(AP)을 순차적으로 깨워야 합니다. 동시에 깨우면 공유 자원 초기화에서 경쟁 조건이 발생할 수 있습니다.

💡 Idle 스레드는 절대 블로킹되어서는 안 됩니다. 만약 모든 스레드가 블로킹되고 idle 스레드도 블로킹되면 시스템 전체가 멈춥니다. Idle은 항상 실행 가능 상태여야 합니다.

💡 성능 측정을 위해 idle 스레드의 실행 시간을 추적하면 시스템 전체 부하를 계산할 수 있습니다. (CPU 사용률 = 1 - idle_time / total_time)


8. 안전한 TLS 접근 패턴 - 타입 안전성과 러스트 보장

시작하며

여러분이 TLS를 사용하면서 unsafe 블록이 너무 많아 불편하다고 느낀 적이 있나요? 지금까지 본 예제들은 모두 unsafe를 사용했는데, 이는 TLS 변수가 static mut이기 때문입니다.

Rust의 철학은 안전한 추상화를 제공하는 것인데, TLS를 더 안전하게 사용할 방법은 없을까요? 이 문제는 Rust 커널 개발에서 매우 중요합니다.

Unsafe 코드는 컴파일러의 안전성 보장을 우회하므로, 메모리 안전성 버그나 데이터 경쟁이 발생할 수 있습니다. 특히 OS 커널처럼 복잡한 시스템에서는 unsafe 블록이 많을수록 버그 가능성이 높아집니다.

실제로 리눅스 커널의 C 코드에서 발생하는 보안 취약점의 상당수가 메모리 안전성 문제입니다. 바로 이럴 때 필요한 것이 Cell, RefCell, UnsafeCell 같은 Rust의 내부 가변성(interior mutability) 타입을 활용한 안전한 TLS 래퍼입니다.

이를 통해 unsafe를 최소한의 영역에 격리하고, 대부분의 코드는 안전한 Rust로 작성할 수 있습니다.

개요

간단히 말해서, 안전한 TLS 패턴은 unsafe 코드를 캡슐화하여 외부에는 안전한 API를 제공하는 방식으로, Rust의 타입 시스템을 활용해 잘못된 사용을 컴파일 타임에 방지합니다. 왜 이것이 중요한지 실무 관점에서 설명하자면, OS 개발에서도 Rust를 선택한 이유는 메모리 안전성 때문입니다.

만약 모든 TLS 접근에 unsafe가 필요하다면 Rust의 이점을 잃게 됩니다. 예를 들어, 두 개의 함수가 동시에 current_thread()를 호출해 가변 참조를 얻으면, Rust의 빌림 규칙(borrowing rules)을 위반하지만 컴파일러가 이를 감지하지 못합니다.

안전한 래퍼를 사용하면 이런 문제를 컴파일 타임에 잡을 수 있습니다. 기존에는 모든 TLS 접근마다 unsafe 블록을 작성하고 주석으로 안전성을 설명했다면, 이제는 한 번만 안전한 래퍼를 작성하면 나머지 코드는 모두 안전한 Rust가 됩니다.

안전한 TLS의 핵심 특징은 첫째, unsafe가 작고 명확한 영역에 격리된다는 점, 둘째, Rust의 타입 시스템이 잘못된 사용을 방지한다는 점, 셋째, 코드 리뷰와 감사가 쉬워진다는 점입니다. 이러한 특징들이 신뢰할 수 있는 OS 개발의 기반이 됩니다.

코드 예제

use core::cell::UnsafeCell;

// 안전한 TLS 래퍼
pub struct ThreadLocal<T> {
    inner: UnsafeCell<T>,
}

// ThreadLocal은 스레드 간 공유되지 않으므로 Sync 구현
unsafe impl<T> Sync for ThreadLocal<T> {}

impl<T> ThreadLocal<T> {
    pub const fn new(value: T) -> Self {
        ThreadLocal {
            inner: UnsafeCell::new(value),
        }
    }

    // 불변 참조로 읽기 (안전)
    pub fn with<F, R>(&self, f: F) -> R
    where
        F: FnOnce(&T) -> R,
    {
        unsafe {
            let ptr = self.inner.get();
            f(&*ptr)
        }
    }

    // 가변 참조로 쓰기 (안전)
    pub fn with_mut<F, R>(&self, f: F) -> R
    where
        F: FnOnce(&mut T) -> R,
    {
        unsafe {
            let ptr = self.inner.get();
            f(&mut *ptr)
        }
    }
}

// 사용 예시: 안전한 TLS 변수 선언
#[thread_local]
static THREAD_CONTEXT: ThreadLocal<Option<*mut ThreadContext>> =
    ThreadLocal::new(None);

// 안전한 API 제공
pub fn set_current_thread(ctx: *mut ThreadContext) {
    THREAD_CONTEXT.with_mut(|opt| {
        *opt = Some(ctx);
    });
}

pub fn current_thread() -> &'static mut ThreadContext {
    THREAD_CONTEXT.with(|opt| {
        let ptr = opt.expect("Thread context not initialized");
        unsafe { &mut *ptr }
    })
}

// 안전한 사용 예시
pub fn example_safe_tls() {
    // 스레드 ID 읽기 - unsafe 없음
    let id = THREAD_CONTEXT.with(|opt| {
        match opt {
            Some(ptr) => unsafe { (*ptr).id },
            None => 0,
        }
    });

    // 우선순위 수정 - unsafe 없음
    THREAD_CONTEXT.with_mut(|opt| {
        if let Some(ptr) = opt {
            unsafe {
                (*ptr).priority = 10;
            }
        }
    });

    println!("Thread {} priority updated", id);
}

// 더 나아가: RefCell 기반 안전 TLS (성능 약간 희생)
use core::cell::RefCell;

#[thread_local]
static SAFE_CONTEXT: ThreadLocal<RefCell<Option<Box<ThreadContext>>>> =
    ThreadLocal::new(RefCell::new(None));

pub fn safe_current_thread() -> core::cell::Ref<'static, ThreadContext> {
    SAFE_CONTEXT.with(|refcell| {
        core::cell::Ref::map(refcell.borrow(), |opt| {
            opt.as_ref().expect("Thread context not initialized").as_ref()
        })
    })
}

pub fn safe_current_thread_mut() -> core::cell::RefMut<'static, ThreadContext> {
    SAFE_CONTEXT.with(|refcell| {
        core::cell::RefMut::map(refcell.borrow_mut(), |opt| {
            opt.as_mut().expect("Thread context not initialized").as_mut()
        })
    })
}

설명

이것이 하는 일: 위 코드는 Rust의 타입 시스템을 활용하여 TLS 접근을 안전하게 만드는 래퍼를 구현합니다. Unsafe는 최소한의 영역에만 존재하고, 나머지는 컴파일러가 안전성을 보장합니다.

첫 번째로, ThreadLocal<T> 구조체는 UnsafeCell<T>를 감싸고 있습니다. UnsafeCell은 Rust에서 내부 가변성을 제공하는 유일한 프리미티브로, 불변 참조(&self)를 통해서도 내부 값을 변경할 수 있게 해줍니다.

이것이 필요한 이유는 TLS 변수가 static으로 선언되어 불변이지만, 실제로는 각 스레드가 자신의 값을 변경할 수 있어야 하기 때문입니다. unsafe impl Sync는 ThreadLocal이 스레드 간 공유되어도 안전함을 컴파일러에게 알려줍니다(각 스레드가 다른 인스턴스를 가지므로 실제로는 공유되지 않음).

두 번째로, with와 with_mut 메서드는 클로저 기반 API를 제공합니다. 직접 참조를 반환하는 대신, 클로저를 받아서 그 안에서만 데이터에 접근하도록 합니다.

이렇게 하면 참조의 생명주기가 클로저 내부로 제한되어, 댕글링 참조(dangling reference)나 중복 가변 참조 같은 문제를 방지할 수 있습니다. Unsafe는 이 두 함수 내부에만 존재하고, 호출하는 쪽은 안전한 코드입니다.

세 번째로, set_current_thread와 current_thread는 ThreadLocal 래퍼를 사용하는 공개 API입니다. Set 함수는 with_mut를 통해 안전하게 값을 설정하고, get 함수는 with를 통해 읽어옵니다.

여기서도 unsafe가 사용되지만, 이는 포인터를 역참조하기 위한 것으로, 잘 정의된 작은 영역에만 존재합니다. 중요한 점은 이 함수들을 사용하는 커널 코드에서는 unsafe를 전혀 볼 필요가 없다는 것입니다.

네 번째로, example_safe_tls는 실제 사용 예시를 보여줍니다. THREAD_CONTEXT에 접근할 때 unsafe 블록이 필요 없으며, 클로저 내부에서 안전하게 읽고 쓸 수 있습니다.

만약 누군가 실수로 두 개의 가변 참조를 동시에 얻으려 하면, 런타임에 panic이 발생하여(RefCell의 경우) 조기에 버그를 발견할 수 있습니다. 다섯 번째로, RefCell 기반 버전은 더 나아가 런타임 빌림 검사를 제공합니다.

safe_current_thread는 Ref<ThreadContext>를 반환하는데, 이는 스마트 포인터로 스코프를 벗어날 때 자동으로 빌림을 해제합니다. 만약 이미 가변 빌림이 존재하는 상태에서 불변 빌림을 시도하면 panic이 발생하여, Rust의 빌림 규칙을 런타임에 강제합니다.

약간의 성능 오버헤드(빌림 카운터 체크)가 있지만, 디버그 빌드에서는 매우 유용합니다. 여러분이 이 패턴을 사용하면 대부분의 커널 코드를 안전한 Rust로 작성할 수 있습니다.

코드 리뷰 시에도 unsafe 블록만 집중적으로 검토하면 되고, 나머지는 컴파일러가 안전성을 보장합니다. 이는 대규모 OS 프로젝트에서 유지보수성과 신뢰성을 크게 향상시킵니다.

실전 팁

💡 프로덕션 빌드에서는 포인터 기반 버전을, 디버그 빌드에서는 RefCell 기반 버전을 사용하는 조건부 컴파일을 고려하세요. 개발 중에는 안전성 체크가 유용하지만, 릴리스에서는 성능이 중요합니다.

💡 ThreadLocal의 with/with_mut 클로저는 매우 짧게 유지하세요. 클로저가 길면 내부에서 무엇이 일어나는지 추적하기 어려워집니다. 필요한 데이터만 추출하여 반환하고, 로직은 밖에서 처리하세요.

💡 RefCell의 빌림 검사는 런타임 비용이 있으므로, 핫 패스(hot path)에서는 사용을 피하세요. 스케줄러나 인터럽트 핸들러 같은 성능 critical한 코드에서는 포인터 기반 버전을 사용하세요.

💡 TLS 래퍼에 const fn 생성자를 제공하면(new 함수), static 변수 초기화에 사용할 수 있어 편리합니다. Const fn은 컴파일 타임에 평가되어 런타임 오버헤드가 없습니다.

💡 더 나아가, 타입 상태 패턴(type state pattern)을 사용하면 "초기화되지 않은 TLS 접근"을 컴파일 타임에 방지할 수 있습니다. ThreadLocal<Uninitialized>와 ThreadLocal<Initialized<T>> 같은 타입을 만들어 상태를 타입으로 인코딩하세요.


#Rust#TLS#ThreadLocalStorage#OS개발#멀티스레딩#시스템프로그래밍

댓글 (0)

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