preempt_count

Linux 커널의 preempt_count는 32비트 정수 하나로 현재 CPU의 실행 컨텍스트(hardirq·softirq·NMI)와 선점(Preemption) 비활성화 깊이를 동시에 인코딩합니다. 이 문서는 비트 필드 구조, 컨텍스트 판별 매크로(Macro), preempt_disable/enable 메커니즘, 선점 모델(NONE/VOLUNTARY/FULL/RT), PREEMPT_DYNAMIC 런타임 전환, PREEMPT_LAZY(6.12+), Per-CPU 저장 구조, might_sleep/in_atomic, DEBUG_PREEMPT 진단까지 코드 흐름 중심으로 통합 정리합니다.

전제 조건: 인터럽트(Interrupt), softirq/hardirq, 프로세스(Process) 스케줄러(Scheduler), 동기화 기법 문서를 먼저 읽으세요. preempt_count는 "현재 CPU가 어떤 문맥에 있는지", "지금 당장 스케줄러에 CPU를 넘겨도 되는지", "인터럽트 하위 반이 막혀 있는지"를 한 번에 담는 값이라서, 선점과 인터럽트와 락의 기초 개념이 같이 잡혀 있어야 전체 흐름이 보입니다.
일상 비유: preempt_count는 공용 정비 구역 입구에 붙은 작업 상태판과 비슷합니다. "정비공이 작업 중인지", "긴급 호출이 들어왔는지", "하위 작업반 출입을 막아 두었는지", "일반 정비가 아니라 비상 정비인지"를 색깔 칸 하나에 같이 적어 둔 셈입니다. 스케줄러는 이 상태판을 보고 "지금 다른 사람을 들여보내도 되는가"를 결정합니다.

핵심 요약

  • preempt_count — 현재 실행 문맥과 선점 금지 깊이를 함께 담는 32비트 상태 값입니다.
  • PREEMPT 필드preempt_disable(), 일반적인 spin_lock() 같은 "선점 금지" 중첩 횟수를 나타냅니다.
  • SOFTIRQ/HARDIRQ/NMI 필드 — 단순히 "락을 잡았다"가 아니라 인터럽트 문맥 자체인지까지 구분해 줍니다.
  • need_resched 결합 — 이 문서에서 설명하는 빠른 경로는 "선점 가능"과 "재스케줄 필요"를 하나의 워드 비교로 확인하도록 설계되어 있습니다.
  • 균형 유지 — 증가시킨 필드는 반드시 같은 경로에서 정확히 감소시켜야 하며, 한 번이라도 어긋나면 선점이 영구히 막히거나 디버그 경고가 발생합니다.

단계별 이해

  1. 평상시 상태
    프로세스 컨텍스트에서 락도 없고 인터럽트 문맥도 아니면 preempt_count의 주요 필드는 0입니다. 이때가 가장 자유로운 상태입니다.
  2. 임계 구역 진입
    preempt_disable() 또는 일반적인 spin_lock()이 PREEMPT 필드를 올려 "잠깐만, 지금은 중간에 끊으면 안 됨"을 표시합니다.
  3. 인터럽트 진입
    IRQ가 들어오면 HARDIRQ 필드가 올라가고, softirq 처리나 NMI 진입이면 각각 자기 필드가 설정됩니다. 이제 현재 코드는 특정 태스크(Task)의 sleep 가능한 문맥이 아닙니다.
  4. 재스케줄 요청 누적
    다른 태스크가 깨어나 우선순위(Priority)가 더 높아져도, 당장 스케줄하지 못하면 "필요함" 표시만 남겨 둡니다. 이것이 선점 지연(deferred preemption)입니다.
  5. 마지막 금지 요인 해제
    irq_exit(), local_bh_enable(), preempt_enable(), spin_unlock() 같은 경로가 마지막 카운터를 0으로 만들면 그때 비로소 스케줄러 진입 여부를 판단합니다.

개요: 선점의 개념

커널 선점(Kernel Preemption)이란 커널 모드에서 실행 중인 태스크를 더 높은 우선순위의 태스크가 강제로 중단시키고 CPU를 빼앗는 메커니즘입니다. 유저 모드 선점은 syscall 반환이나 인터럽트 반환 시점에서 자연스럽게 발생하지만, 커널 모드 선점은 추가적인 안전 장치가 필요합니다.

왜 커널 모드에서 무조건 선점하면 안 될까요? 커널 코드는 공유 자료 구조(예: 런큐(Runqueue), 페이지 테이블(Page Table), 파일시스템(Filesystem) 캐시(Cache))를 조작하는 도중에 중단되면 일관성이 깨질 수 있기 때문입니다. 유저 공간에서는 각 프로세스가 독립적인 주소 공간(Address Space)을 갖지만, 커널은 모든 프로세스가 동일한 커널 주소 공간을 공유합니다. 따라서 커널은 "지금 선점해도 안전한가?"를 매 시점에 판단해야 하며, 이 판단의 핵심이 바로 preempt_count입니다.

커널 선점 판단 흐름 (인터럽트 반환 시) 인터럽트 발생 (IRQ) IRQ 핸들러 실행 (hardirq 컨텍스트) 이전 모드가 유저 모드? Yes 무조건 선점 검사 need_resched → schedule() (유저 모드 선점: 항상 안전) No (커널 모드) preempt_count == 0 ? No (≠0) 선점 보류 중단된 커널 코드로 복귀 (iret) Yes (==0) need_resched 설정됨? Yes preempt_schedule_irq() 커널 선점 실행! No 커널 코드로 복귀 핵심: preempt_count == 0 검사가 커널 선점의 게이트키퍼 — 이 값이 0이 아니면 커널 모드에서 절대로 선점하지 않음

preempt_count는 이 안전 장치의 핵심으로, 다음 세 가지 질문에 동시에 답합니다:

  1. 현재 어떤 컨텍스트인가? — hardirq, softirq, NMI, 또는 프로세스 컨텍스트
  2. 선점이 안전한가? — 선점 비활성화 중첩 깊이, 인터럽트 컨텍스트 여부
  3. 선점이 필요한가? — bit 31의 NEED_RESCHED 플래그(반전 저장)

이 32비트 카운터가 0이면 "프로세스 컨텍스트이며 선점 가능하고 리스케줄이 필요"를 의미합니다. 0이 아닌 값은 현재 CPU에서 선점이 불가함을 나타내며, 각 비트 영역이 그 이유를 인코딩합니다.

/* 커널 선점 결정의 핵심 흐름 */
if (preempt_count() == 0 && need_resched()) {
    /* 선점 가능: preempt_count가 0이고 리스케줄 요청이 있음 */
    __schedule(SM_PREEMPT);
}
/* preempt_count != 0 이면 → 현재 컨텍스트가 안전하지 않으므로 선점 보류 */

preempt_count가 0이 아닌 대표적인 상황을 정리하면 다음과 같습니다:

상황preempt_count 영향선점 차단 이유
spin_lock() 보유 중PREEMPT 필드 +1공유 자료 구조 보호 (선점되면 다른 태스크가 동일 락 대기 → 교착)
local_bh_disable() 구간SOFTIRQ 필드 +1softirq 핸들러(Handler) 실행 억제 (하위 반 처리 방지)
hardirq 핸들러 내부HARDIRQ 필드 +1인터럽트 핸들러는 특정 태스크에 속하지 않음 (스케줄 불가)
NMI 핸들러 내부NMI 비트 =1마스크 불가 인터럽트 — 가장 제한적 컨텍스트
유저 모드 선점 vs 커널 모드 선점: 유저 모드로 복귀할 때(syscall 반환, irq_exit)는 preempt_count 검사 없이 need_resched만 확인하면 됩니다. 유저 모드 코드는 커널 자료 구조를 조작하지 않으므로 항상 안전하게 선점할 수 있습니다. preempt_count가 중요한 것은 오직 커널 모드에서 커널 모드로 돌아가야 할 때뿐입니다.

핵심 불변식과 빠른 판독 규칙

preempt_count를 단순히 "atomic context 여부를 나타내는 카운터" 정도로 이해하면 실전에서 자주 막힙니다. 실제로는 서로 다른 종류의 진입 금지 이유를 한 워드에 압축한 상태 벡터에 가깝습니다. 선점 금지, 하위 반 금지, hardirq 진입, NMI 진입은 서로 다른 사건이므로 각기 독립적으로 중첩되어야 하고, 인터럽트 반환이나 preempt_enable() 같은 빠른 경로에서는 이를 거의 분기 없이 판독해야 합니다.

이 설계가 중요한 이유는 스케줄러와 인터럽트 반환 경로가 커널에서 가장 뜨거운 경로이기 때문입니다. 불리언 플래그 여러 개를 따로 읽는 대신 한 번 로드하고 몇 개의 마스크 연산만 수행하면, "지금은 절대 스케줄하면 안 되는가", "아직 softirq가 남아 있는가", "하드 IRQ 문맥인가", "마지막 금지 요인이 막 해제되었는가"를 즉시 판단할 수 있습니다.

  1. 필드는 서로 독립적입니다. PREEMPT, SOFTIRQ, HARDIRQ, NMI는 덮어쓰는 값이 아니라 동시에 살아 있을 수 있는 중첩 축입니다. 예를 들어 spin_lock()을 잡은 코드가 IRQ에 의해 끊기면 PREEMPT와 HARDIRQ가 동시에 비영이 됩니다.
  2. 더 높은 문맥이 더 강한 제약을 뜻합니다. HARDIRQ나 NMI 비트가 살아 있으면 PREEMPT 필드가 0이더라도 스케줄할 수 없습니다. "선점 금지 카운터가 0인가"만 보면 부족하고, 어떤 종류의 문맥인지까지 같이 봐야 합니다.
  3. 전체 워드가 0이 되는 순간이 가장 중요합니다. 이 문서에서 설명하는 빠른 경로는 "모든 금지 이유가 해제되었고 지금 재스케줄이 필요함"을 전체 워드 0으로 표현합니다. 그래서 마지막 preempt_enable() 또는 인터럽트 반환 직후의 한 번 비교가 결정적입니다.
  4. 불균형은 즉시가 아니라 나중에 터집니다. spin_unlock() 하나를 빼먹어도 당장 패닉이 안 날 수 있습니다. 대신 해당 CPU 또는 해당 태스크의 선점이 영구히 늦춰져 수 초 뒤 완전히 다른 위치에서 지연, 락업, BUG: scheduling while atomic로 드러납니다.
  5. IRQ 비활성화와 preempt_count는 별개 축입니다. local_irq_disable()는 CPU 플래그를 바꾸지만 보통 PREEMPT/SOFTIRQ/HARDIRQ 필드를 직접 증가시키지 않습니다. 따라서 preempt_count만 읽고 안전 여부를 완전히 판단할 수는 없고, preemptible()irqs_disabled()를 함께 보는 이유가 여기에 있습니다.
  6. 이 값은 "현재 CPU의 지금 시점"을 설명합니다. 다른 CPU에서 더 높은 우선순위 태스크가 깨어났더라도, 현재 CPU가 락을 쥐고 있거나 IRQ 문맥이면 재스케줄은 연기됩니다. 즉, 깨어난 사실과 실제 문맥 전환(Context Switch) 시점은 분리되어 있습니다.
preempt_count 판독은 "상태판 한 장 읽기"와 같다 현재 CPU의 preempt_count 한 번 읽기 한 워드에 PREEMPT / SOFTIRQ / HARDIRQ / NMI / resched 상태가 함께 들어 있음 NMI/HARDIRQ 확인 비영이면 즉시 "인터럽트 문맥" SOFTIRQ 확인 비영이면 softirq 실행 중 또는 BH 비활성화 PREEMPT 확인 비영이면 명시적 선점 금지 또는 락 중첩 상태 IRQ 플래그 확인 irqs_disabled()는 별도 축에서 판단 빠른 경로의 실제 질문 순서 1. NMI/IRQ인가? 그렇다면 스케줄 금지 2. SOFTIRQ/BH 금지인가? 그렇다면 하위 반 처리 우선 3. PREEMPT 중첩이 남았는가? 마지막 unlock/enable을 기다림 4. 모두 해제되었는가? 그때만 선점 검사 핵심은 "어떤 이유로 아직 못 멈추는가"를 한 워드에서 즉시 복원하는 것이다

실전에서 특히 자주 보는 API를 기준으로 어떤 축이 변하는지 정리하면 다음과 같습니다. 아래 표는 이해를 돕기 위한 일반 PREEMPT 커널 기준의 대표 경로이며, PREEMPT_RT처럼 락의 의미가 바뀌는 구성에서는 일부 세부 동작이 달라질 수 있습니다.

이벤트 / API바뀌는 축즉시 의미실전에서 헷갈리는 점
preempt_disable()PREEMPT +1현재 태스크를 커널 내부에서 강제로 빼앗기지 않도록 함IRQ는 여전히 들어올 수 있으므로 "완전히 원자적(Atomic)" 상태와 동일하지는 않습니다.
spin_lock()보통 PREEMPT +1공유 자료 구조 보호와 함께 현재 CPU에서의 선점 방지RT 구성에서는 일반 spinlock이 sleep 가능한 락으로 치환될 수 있어 해석이 달라질 수 있습니다.
spin_lock_irqsave()PREEMPT +1, 로컬 IRQ off현재 CPU에서 선점과 하드 IRQ 진입을 함께 막음preempt_count만 보고는 IRQ off 여부를 복원할 수 없고 저장된 flags가 필요합니다.
local_bh_disable()SOFTIRQ +1softirq와 네트워크 하위 반 실행을 지연시킴in_softirq()가 true가 되어 실제 softirq 핸들러와 혼동되기 쉽습니다.
irq_enter()HARDIRQ +1이후 코드는 hardirq 문맥으로 해석되어 sleep이 금지됨중단된 원래 태스크의 PREEMPT 값이 있었다면 HARDIRQ 위에 겹쳐 보입니다.
irq_exit()HARDIRQ -1하드 IRQ 문맥에서 빠져나오며 softirq 또는 선점 검사를 수행리턴 직후 바로 스케줄되지 않을 수 있으며, 아직 PREEMPT 필드가 남아 있으면 계속 지연됩니다.
nmi_enter()NMI 비트 설정가장 강한 제한 문맥으로 진입NMI는 일반 IRQ 마스크와 별개이므로 "IRQ를 껐으니 안전"이라는 직관이 통하지 않습니다.
local_irq_disable()preempt_count 변화 없음CPU 플래그로 하드 IRQ만 잠시 차단in_interrupt()는 false일 수 있습니다. 즉, 인터럽트 문맥과 IRQ-off 구간은 다른 개념입니다.
헷갈리기 쉬운 점: local_irq_disable()는 인터럽트를 끌 뿐 preempt_count에 hardirq 문맥이 들어왔다는 표시를 남기지 않습니다. 반대로 irq_enter()는 실제 인터럽트 핸들러 진입이므로 HARDIRQ 필드가 올라갑니다. "IRQ를 못 받는 상태"와 "이미 IRQ 문맥 안에 있는 상태"는 비슷해 보여도 커널은 분명히 구분합니다.

preempt_count 비트 필드 구조

preempt_count는 32비트를 여러 영역으로 나누어 각각 독립적인 의미를 부여합니다. 아래 다이어그램은 x86_64 기준 비트 레이아웃입니다.

preempt_count 32비트 필드 레이아웃 bit 31 RESCHED bits 22-30 Reserved bit 21 LAZY bit 20 NMI bits 16-19 HARDIRQ (4 bits) bits 8-15 SOFTIRQ (8 bits) bits 0-7 PREEMPT (8 bits) TIF_NEED_RESCHED 비트 반전 저장 PREEMPT_LAZY용 (커널 6.12+) NMI 컨텍스트 중첩 불가 하드 IRQ 중첩 횟수 irq_enter/exit 조작 local_bh_disable 횟수 softirq 처리 중 증가 preempt_disable 횟수 spin_lock 등으로 증가 비트 마스크 상수 (include/linux/preempt.h) PREEMPT_MASK 0x000000ff 비트 0-7 : 선점 비활성화 깊이 (최대 255 중첩) SOFTIRQ_MASK 0x0000ff00 비트 8-15 : softirq/BH 비활성화 깊이 HARDIRQ_MASK 0x000f0000 비트 16-19 : hardirq 중첩 카운트 (최대 15) NMI_MASK 0x00100000 비트 20 : NMI 컨텍스트 (0 또는 1) PREEMPT_NEED_RESCHED 0x80000000 비트 31 : 리스케줄 필요 (반전 저장: 0 = 필요)
/* include/linux/preempt.h — 비트 필드 정의 */
#define PREEMPT_BITS      8
#define SOFTIRQ_BITS      8
#define HARDIRQ_BITS      4
#define NMI_BITS          1

#define PREEMPT_SHIFT     0
#define SOFTIRQ_SHIFT     (PREEMPT_SHIFT + PREEMPT_BITS)    /* 8 */
#define HARDIRQ_SHIFT     (SOFTIRQ_SHIFT + SOFTIRQ_BITS)    /* 16 */
#define NMI_SHIFT         (HARDIRQ_SHIFT + HARDIRQ_BITS)    /* 20 */

#define PREEMPT_MASK      ((1UL << PREEMPT_BITS) - 1)       /* 0x000000ff */
#define SOFTIRQ_MASK      ((1UL << SOFTIRQ_BITS) - 1) << SOFTIRQ_SHIFT
#define HARDIRQ_MASK      ((1UL << HARDIRQ_BITS) - 1) << HARDIRQ_SHIFT
#define NMI_MASK          (1UL << NMI_SHIFT)

/* 비트 31: PREEMPT_NEED_RESCHED — 반전 저장 최적화 */
/*
 * preempt_count 테스트와 need_resched 테스트를 단일 비교로 합침:
 *   preempt_count() == 0 이면
 *     → 모든 필드가 0이고 bit 31도 0(=need_resched 설정됨)
 *     → 하나의 "test %eax, %eax" 명령으로 선점 가능 여부 판단
 */
#define PREEMPT_NEED_RESCHED  0x80000000
코드 설명
  • PREEMPT_BITS~NMI_BITS32비트 preempt_count를 4개 필드로 분할합니다. 하위 8비트(비트 0-7)는 선점 비활성화 중첩 횟수, 비트 8-15는 softirq 카운터, 비트 16-19는 hardirq 중첩 깊이, 비트 20은 NMI 플래그입니다. include/linux/preempt.h에 정의되어 있습니다.
  • PREEMPT_SHIFT~NMI_SHIFT각 필드의 시작 비트 위치를 누적 합산으로 계산합니다. SOFTIRQ_SHIFT는 8, HARDIRQ_SHIFT는 16, NMI_SHIFT는 20이 됩니다.
  • PREEMPT_MASK~NMI_MASK각 필드를 추출하기 위한 비트 마스크입니다. PREEMPT_MASK0xFF, NMI_MASK0x100000입니다. preempt_count() & HARDIRQ_MASK처럼 AND 연산으로 특정 필드 값만 읽습니다.
  • PREEMPT_NEED_RESCHED비트 31을 반전 저장 플래그로 사용합니다. 0이면 리스케줄 필요, 1이면 불필요를 의미합니다. 이로써 preempt_count == 0 단일 비교로 "선점 가능 + 리스케줄 필요"를 동시에 판단할 수 있어, x86에서 test %reg, %reg 한 명령으로 최적화됩니다.
bit 31 반전 저장 최적화: preempt_count 전체가 정확히 0인지만 검사하면 "선점 가능 + 리스케줄 필요"를 한 번에 판단할 수 있습니다. bit 31이 0이면 TIF_NEED_RESCHED가 설정된 상태이므로, test %reg, %reg; jz preempt_schedule 단 두 명령으로 최적 경로가 완성됩니다.

실제 preempt_count 값 예시

아래 다이어그램은 다양한 실행 컨텍스트에서 preempt_count의 실제 비트 값과 16진수 표현을 보여줍니다. 각 상황에서 어떤 비트 영역이 활성화되는지 직관적으로 확인할 수 있습니다.

컨텍스트별 preempt_count 실제 값 컨텍스트 RESCHED LAZY NMI HARDIRQ SOFTIRQ PREEMPT 16진수 선점 가능 + resched 0 0 0 0000 00000000 00000000 0x00000000 프로세스 (resched 없음) 1 0 0 0000 00000000 00000000 0x80000000 spin_lock ×1 1 0 0 0000 00000000 00000001 0x80000001 spin_lock ×2 + bh_disable 1 0 0 0000 00000011 00000010 0x80000302 hardirq 핸들러 (1단계) 1 0 0 0001 00000000 00000000 0x80010000 hardirq + spin_lock 1 0 0 0001 00000000 00000001 0x80010001 softirq 핸들러 실행 중 1 0 0 0000 00000001 00000000 0x80000100 NMI 핸들러 1 0 1 0000 00000000 00000000 0x80100000 값 해석 가이드 0x00000000 = 모든 비트 0 → 선점 가능 + 리스케줄 필요 (bit 31=0은 NEED_RESCHED 설정을 의미) 0x80000000 = bit 31만 1 → 선점 가능하나 리스케줄 불필요 (NEED_RESCHED 미설정) 0x80000001 = PREEMPT 필드=1 → preempt_disable() 또는 spin_lock() 1회 호출 상태 0x80000302 = SOFTIRQ=0x03(local_bh_disable ×1 + 추가), PREEMPT=0x02(spin_lock ×2) 0x80010000 = HARDIRQ=0x1 → irq_enter()로 진입한 hardirq 핸들러 1단계 0x80100000 = NMI 비트=1 → NMI 핸들러 내부 (가장 제한적 컨텍스트)

컨텍스트 판별 매크로

커널 코드는 현재 실행 컨텍스트를 확인하기 위해 preempt_count의 비트 필드를 검사하는 매크로를 제공합니다. 올바른 매크로 선택은 동기화 전략과 메모리 할당 플래그 결정에 직접적인 영향을 미칩니다.

/* include/linux/preempt.h — 컨텍스트 판별 매크로 */

/* 하위 카운터 추출 */
#define preempt_count()   (current_thread_info()->preempt_count)
#define hardirq_count()   (preempt_count() & HARDIRQ_MASK)
#define softirq_count()   (preempt_count() & SOFTIRQ_MASK)
#define irq_count()       (preempt_count() & (HARDIRQ_MASK | SOFTIRQ_MASK | NMI_MASK))

/* 컨텍스트 판별 */
#define in_irq()          (hardirq_count())          /* 하드 IRQ 핸들러 내부 */
#define in_hardirq()      (hardirq_count())          /* in_irq()와 동일 */
#define in_softirq()      (softirq_count())          /* softirq 또는 BH disabled */
#define in_interrupt()    (irq_count())              /* hardirq | softirq | NMI */
#define in_nmi()          (preempt_count() & NMI_MASK)  /* NMI 핸들러 내부 */

/* 선점 가능 여부 */
#define preemptible()     (preempt_count() == 0 && !irqs_disabled())

/* in_serving_softirq() — 실제 softirq 핸들러 내부만 true */
#define in_serving_softirq()  (softirq_count() & SOFTIRQ_OFFSET)

/* in_atomic() — 모든 atomic 컨텍스트 (주의: CONFIG_PREEMPT_COUNT 필요) */
#define in_atomic()       (preempt_count() != 0)
코드 설명
  • preempt_count()current_thread_info()->preempt_count에서 현재 CPU의 선점 카운터 원시 값을 읽습니다. 아키텍처별로 Per-CPU 변수 또는 thread_info 구조체를 통해 접근합니다.
  • in_irq() / in_hardirq()HARDIRQ_MASK(비트 16-19)를 검사하여 하드웨어 인터럽트 핸들러 내부인지 판별합니다. 0이 아니면 hardirq 컨텍스트입니다.
  • in_softirq()SOFTIRQ_MASK(비트 8-15)를 검사합니다. 주의할 점은 local_bh_disable() 구간에서도 true를 반환한다는 것입니다. 실제 softirq 핸들러 내부만 구분하려면 in_serving_softirq()를 사용해야 합니다.
  • in_interrupt()HARDIRQ_MASK | SOFTIRQ_MASK | NMI_MASK를 모두 OR한 마스크로 검사합니다. 어떤 인터럽트 컨텍스트에서든 sleep이 불가능한 상황인지 한 번에 판별할 수 있습니다.
  • preemptible()preempt_count() == 0이고 IRQ가 활성화된 상태인지 확인합니다. 두 조건이 모두 충족되어야 커널 선점이 가능합니다.
  • in_atomic()preempt_count가 0이 아니면 true입니다. CONFIG_PREEMPT_COUNT가 활성화되어 있어야 정확한 값을 반환하며, 비활성 시에는 항상 0을 반환하므로 드라이버에서 분기 조건으로 사용하는 것은 권장되지 않습니다.
in_softirq() vs in_serving_softirq()

in_softirq()local_bh_disable() 구간에서도 true를 반환합니다. 실제 softirq 핸들러 내부인지 확인하려면 in_serving_softirq()를 사용해야 합니다. 네트워크 드라이버에서 이 차이를 간과하면 동기화 로직에서 미묘한 버그가 발생합니다.

아래 표는 각 컨텍스트에서 매크로 반환값과 허용되는 동작을 정리합니다:

컨텍스트in_irqin_softirqin_interruptin_nmiin_atomicsleep 가능
프로세스 (선점 가능)00000O
preempt_disable 구간00001X
local_bh_disable 구간01101X
softirq 핸들러01101X
hardirq 핸들러10101X
NMI 핸들러00111X
in_atomic()의 함정

in_atomic()CONFIG_PREEMPT_COUNT가 활성화된 커널에서만 정확합니다. CONFIG_PREEMPT_NONE 커널에서는 preempt_count가 IRQ/NMI 필드만 추적하므로, spin_lock() 구간에서도 in_atomic()이 false를 반환할 수 있습니다. 드라이버에서 in_atomic()을 기반으로 할당 플래그를 선택하는 것은 권장되지 않으며, 설계 시점에서 컨텍스트를 명확히 아는 것이 올바른 접근입니다.

컨텍스트 판별 매크로 결정 트리: "현재 어떤 컨텍스트인가?" preempt_count() 읽기 NMI_MASK & 0x00100000 ? ≠ 0 NMI 컨텍스트 in_nmi() → true == 0 HARDIRQ_MASK & 0x000f0000 ? ≠ 0 Hardirq 컨텍스트 in_irq() → true == 0 SOFTIRQ_MASK & 0x0000ff00 ? ≠ 0 Softirq / BH off in_softirq() → true == 0 PREEMPT_MASK & 0x000000ff ? ≠ 0 선점 비활성화 in_atomic() → true == 0 프로세스 컨텍스트 preemptible() → true (IRQ 활성 시) in_interrupt() → true NMI, Hardirq, Softirq 모두 포함 (= irq_count() ≠ 0) sleep 절대 금지 mutex_lock 금지 schedule() 금지 GFP_ATOMIC 사용 spin_lock 사용 가능 per-cpu 접근 안전 (단, hardirq에서 spin_lock_bh 금지) in_interrupt()가 true이면 sleep 가능 함수(kmalloc GFP_KERNEL, mutex, copy_from_user 등) 호출 금지

매크로 사용 시 주의사항과 실전 패턴

컨텍스트 판별 매크로를 올바르게 사용하려면 몇 가지 미묘한 차이를 이해해야 합니다:

/* 패턴 1: 메모리 할당 플래그 선택 (권장되지 않지만 현실적) */
void *device_alloc_buffer(size_t size)
{
    /* in_interrupt()를 사용 — NMI/hardirq/softirq 모두 커버 */
    if (in_interrupt())
        return kmalloc(size, GFP_ATOMIC);
    return kmalloc(size, GFP_KERNEL);
}

/* 패턴 2: 네트워크 드라이버에서 BH 컨텍스트 구분 */
void netdev_tx_handler(struct sk_buff *skb)
{
    if (in_serving_softirq()) {
        /* 실제 softirq 핸들러에서 호출됨 — NAPI poll 등 */
        process_in_napi_context(skb);
    } else if (in_softirq()) {
        /* local_bh_disable 구간 — 프로세스 컨텍스트에서 BH 비활성화 */
        process_with_bh_disabled(skb);
    } else {
        /* 일반 프로세스 컨텍스트 */
        process_in_process_context(skb);
    }
}

/* 패턴 3: RCU 콜백에서 컨텍스트 확인 */
void rcu_callback(struct rcu_head *head)
{
    /* RCU 콜백은 softirq 컨텍스트에서 실행됨 */
    WARN_ON_ONCE(!in_serving_softirq());
    /* ... */
}

preempt_disable/enable 메커니즘

preempt_disable()preempt_enable()preempt_count의 PREEMPT 필드(비트 0-7)를 조작하여 현재 CPU에서 커널 선점을 임시로 금지합니다. 중첩 호출이 가능하며, 카운터가 정확히 0으로 돌아올 때만 선점이 재개됩니다.

/* include/linux/preempt.h — preempt_disable/enable 구현 */
#define preempt_disable() \
    do { \
        preempt_count_inc(); \
        barrier(); \
    } while (0)

#define preempt_enable() \
    do { \
        barrier(); \
        if (unlikely(preempt_count_dec_and_test())) \
            __preempt_schedule(); \
    } while (0)

/* preempt_enable_no_resched() — 선점 검사 생략 변형 */
#define preempt_enable_no_resched() \
    do { \
        barrier(); \
        preempt_count_dec(); \
    } while (0)

/* __preempt_schedule — 커널 선점 진입점 */
asmlinkage void __preempt_schedule(void)
{
    if (likely(!preemptible()))
        return;
    do {
        preempt_disable();
        __schedule(SM_PREEMPT);
        preempt_enable_no_resched();
    } while (need_resched());
}
코드 설명
  • preempt_disable()preempt_count_inc()로 PREEMPT 필드(비트 0-7)를 1 증가시킨 뒤 barrier()를 수행합니다. 배리어가 카운트 증가 에 위치하여, 임계 구역 코드가 반드시 선점 비활성화 이후에 실행되도록 컴파일러 재정렬을 방지합니다. include/linux/preempt.h에 정의되어 있습니다.
  • preempt_enable()먼저 barrier()로 임계 구역 코드 완료를 보장한 뒤, preempt_count_dec_and_test()로 카운트를 감소시킵니다. 결과가 0이면(모든 중첩이 해제되면) __preempt_schedule()을 호출하여 대기 중인 선점 요청을 처리합니다.
  • preempt_enable_no_resched()카운트만 감소시키고 선점 검사를 생략하는 변형입니다. 스케줄러 내부처럼 이미 리스케줄링을 직접 처리하는 코드에서 사용합니다.
  • __preempt_schedule()kernel/sched/core.c에 정의된 커널 선점 진입점입니다. preemptible() 검사 후, 선점 카운터를 다시 올려 재진입을 방지하면서 __schedule(SM_PREEMPT)를 호출합니다. 스케줄 복귀 후 need_resched()가 여전히 설정되어 있으면 반복 수행합니다.

barrier()는 컴파일러 배리어로, preempt_count 변경이 임계 구역 코드와 재정렬되지 않도록 합니다. preempt_disable에서는 카운트 증가 에 배리어를, preempt_enable에서는 배리어 에 카운트 감소를 수행하여 임계 구역 코드가 반드시 보호 범위 안에 위치하도록 보장합니다.

배리어 순서가 왜 중요한지 다음 예시로 설명합니다:

/* barrier()가 없다면? — 컴파일러가 재정렬할 수 있는 위험 */

/* 의도한 순서: */
preempt_count_inc();       /* ① 보호 시작 */
shared_data++;               /* ② 임계 구역 코드 */
preempt_count_dec();       /* ③ 보호 종료 */

/* 컴파일러가 최적화로 재정렬할 수 있는 순서: */
shared_data++;               /* ② 보호되지 않은 상태에서 실행! */
preempt_count_inc();       /* ① */
preempt_count_dec();       /* ③ */
/* → 임계 구역 코드가 보호 범위 바깥으로 이동 — 데이터 경합 발생! */

/* barrier()를 넣으면 컴파일러가 barrier() 너머로 재정렬 불가 */
preempt_count_inc();  /* ① */
barrier();              /* ═══ 컴파일러 펜스 ═══ */
shared_data++;          /* ② 여기서부터는 ① 이후 보장 */
barrier();              /* ═══ 컴파일러 펜스 ═══ */
preempt_count_dec();  /* ③ 여기는 ② 이후 보장 */
preempt_count 중첩 변화 타임라인 시간 preempt _count 0 1 2 +BH spin_lock(&a) spin_lock(&b) bh_disable() bh_enable() spin_unlock(&b) spin_unlock(&a) 선점 검사! 0 1 2 2+BH 2 1 0 선점 불가 구간 (preempt_count ≠ 0)
/* 위 타임라인에 해당하는 코드 */
spin_lock(&lock_a);         /* preempt_count: 0 → 1 (PREEMPT 필드 +1) */
  spin_lock(&lock_b);       /* preempt_count: 1 → 2 (PREEMPT 필드 +1, 중첩) */
    local_bh_disable();     /* preempt_count: 2 → 0x200+2 (SOFTIRQ 필드 증가) */
    /* 여기서 preempt_count = 0x80000202
     *   PREEMPT=2 (spinlock ×2) + SOFTIRQ=0x200 (bh_disable ×1)
     *   in_softirq()=true, in_atomic()=true, in_irq()=false */
    local_bh_enable();      /* preempt_count: SOFTIRQ 필드 감소 → 2 */
  spin_unlock(&lock_b);     /* preempt_count: 2 → 1 (아직 ≠0이므로 선점 검사 안 함) */
spin_unlock(&lock_a);       /* preempt_count: 1 → 0 → preempt_count_dec_and_test() = true */
                             /* → need_resched 확인 → 설정되어 있으면 __preempt_schedule() */

preempt_enable의 변형들

커널은 상황에 따라 preempt_enable의 여러 변형을 제공합니다:

/* 1. preempt_enable() — 표준 버전: 카운트 감소 + 선점 검사 */
preempt_enable();

/* 2. preempt_enable_no_resched() — 카운트만 감소, 선점 검사 안 함 */
/*    용도: 스케줄러 내부에서 이미 reschedule을 다루는 경우 */
preempt_enable_no_resched();

/* 3. preempt_enable_notrace() — ftrace에서 추적하지 않는 버전 */
/*    용도: ftrace 자체 구현 내부에서 무한 재귀 방지 */
preempt_enable_notrace();

/* 4. preempt_count_dec() — 카운트만 감소 (배리어, 선점 검사 모두 없음) */
/*    용도: preempt_enable_no_resched() 내부 구현 */
preempt_count_dec();

/* 5. preempt_disable_notrace() / preempt_enable_notrace() */
/*    용도: ftrace, BPF, perf 등 트레이싱 인프라 내부 */
preempt_disable_notrace();
/* ... 트레이싱 코드 ... */
preempt_enable_notrace();
코드 설명
  • preempt_enable()표준 버전으로, 카운트 감소 후 need_resched 플래그를 검사하여 필요 시 __preempt_schedule()을 호출합니다. 대부분의 드라이버와 서브시스템에서 사용하는 기본 형태입니다.
  • preempt_enable_no_resched()카운트만 감소하고 선점 검사를 하지 않습니다. __schedule() 내부나 __preempt_schedule()처럼 스케줄러가 직접 리스케줄을 관리하는 경로에서만 사용해야 합니다.
  • preempt_enable_notrace()ftrace 추적을 하지 않는 버전입니다. ftrace, BPF, perf 등 트레이싱 인프라 내부에서 preempt_enable()을 호출하면 무한 재귀가 발생하므로, 이 변형으로 추적 없이 카운터를 조작합니다. include/linux/preempt.h에 정의되어 있습니다.
  • preempt_count_dec()가장 저수준 함수로, 배리어도 선점 검사도 없이 카운터만 1 감소시킵니다. preempt_enable_no_resched()의 내부 구현에 사용됩니다.
spin_lock과의 관계: spin_lock()은 내부적으로 preempt_disable()을 호출합니다. UP(단일 프로세서) 커널에서는 실제 스핀 대기 없이 preempt_disable()만 수행되어 임계 구역을 보호합니다. SMP 커널에서도 선점 비활성화는 spinlock의 필수 전제 조건입니다 — 선점이 가능한 상태에서 spinlock을 보유하면, 선점된 태스크가 동일 CPU에서 다시 그 lock을 잡으려 할 때 교착 상태(Deadlock)가 됩니다.
preempt_disable/enable 규칙
  • 반드시 쌍으로 사용: disable/enable 불일치는 선점 카운터 언더플로우/오버플로우로 이어지며, CONFIG_DEBUG_PREEMPT가 이를 감지합니다.
  • enable은 잠들 수 있음: preempt_enable()은 카운터가 0이 되면 schedule()을 호출할 수 있으므로, IRQ disabled 상태에서 호출하면 안 됩니다. IRQ가 꺼진 채 schedule하면 데드락입니다.
  • 중첩 순서 무관: preempt_count는 단순 카운터이므로 disable A→disable B→enable A→enable B도 유효합니다(카운터 2→1→0). 다만 코드 가독성을 위해 LIFO 순서를 권장합니다.

선점 모델 (NONE / VOLUNTARY / FULL / RT)

Linux 커널은 빌드 시 선점 모델을 선택할 수 있으며, 이 선택은 preempt_count의 활용 범위와 선점 검사 빈도에 직접 영향을 미칩니다.

커널 선점 모델 비교 모델 선점 시점 preempt_count 역할 용도 CONFIG_PREEMPT_NONE (서버 최적화) 커널→유저 전환 시만 (syscall/IRQ 반환) IRQ/NMI 필드만 활성 PREEMPT 필드 미사용 처리량(throughput) 극대화 서버, HPC, 배치 처리 CONFIG_PREEMPT_VOLUNTARY (데스크톱 기본값) NONE + cond_resched() 명시적 양보 지점 NONE과 동일 + might_resched() 검사점 처리량-지연시간 균형 데스크톱, 범용 서버 CONFIG_PREEMPT (Full Preemption) preempt_count == 0인 모든 커널 코드 지점 모든 필드 완전 활성 spin_unlock마다 선점 검사 낮은 지연시간(latency) 임베디드, 오디오, 게임 CONFIG_PREEMPT_RT (Real-Time) FULL + spinlock을 rt_mutex로 대체 모든 필드 활성 + 임계구역도 선점 가능 결정론적 지연시간 산업제어, 로봇, 의료기기 지연시간 감소 처리량 감소
/* cond_resched() — PREEMPT_VOLUNTARY의 명시적 양보 지점 */
static inline int _cond_resched(void)
{
    if (should_resched(0)) {
        preempt_schedule_common();
        return 1;
    }
    return 0;
}

/* PREEMPT_NONE에서는 cond_resched가 nop으로 최적화됨 */
/* PREEMPT(FULL)에서도 cond_resched는 nop — 이미 모든 지점에서 선점 가능 */
/* PREEMPT_VOLUNTARY에서만 실제 리스케줄 검사 수행 */
코드 설명
  • _cond_resched()should_resched(0)으로 preempt_count == 0이고 TIF_NEED_RESCHED가 설정되어 있는지 확인합니다. 조건이 만족되면 preempt_schedule_common()을 호출하여 자발적으로 CPU를 양보합니다. kernel/sched/core.c에 정의되어 있습니다.
  • PREEMPT_NONE에서의 동작CONFIG_PREEMPT_NONE에서는 cond_resched()가 NOP으로 컴파일됩니다. 커널이 자발적 양보 지점을 제공하지 않으므로, 태스크는 schedule()을 명시적으로 호출하거나 유저 공간으로 돌아갈 때만 전환됩니다.
  • PREEMPT_VOLUNTARY에서의 동작이 모드에서만 cond_resched()가 실제 리스케줄 검사를 수행합니다. 긴 루프 중간에 cond_resched()를 삽입하면 커널 응답성을 개선할 수 있습니다.
  • PREEMPT_FULL에서의 동작완전 선점 모드에서는 모든 preempt_enable() 지점이 선점 가능하므로, cond_resched()가 NOP으로 최적화됩니다. CONFIG_PREEMPT_DYNAMIC에서는 static_call로 이 전환이 런타임에 이루어집니다.
PREEMPT_RT에서의 spinlock 변환: CONFIG_PREEMPT_RT에서 일반 spinlock_trt_mutex 기반의 sleeping lock으로 변환됩니다. 따라서 spin_lock() 구간에서도 선점이 가능해지며, preempt_count의 PREEMPT 필드가 증가하지 않습니다. 하드 IRQ 컨텍스트에서 반드시 원래의 스핀 동작이 필요한 경우 raw_spinlock_t를 사용해야 합니다.

선점 모델별 동작 타임라인

동일한 커널 코드가 실행될 때 선점 모델에 따라 선점 발생 시점이 어떻게 달라지는지 아래 타임라인으로 비교합니다. 높은 우선순위 태스크(RT)가 깨어나는 시점을 기준으로, 실제 CPU를 획득하기까지의 지연 시간이 핵심 차이입니다.

선점 모델별 RT 태스크 스케줄 지연 비교 시나리오: 낮은 우선순위 태스크가 커널 코드(spin_lock 구간 포함) 실행 중에 높은 우선순위 태스크가 깨어남 RT 태스크 wakeup spin_unlock() cond_resched() syscall 반환 PREEMPT_NONE 낮은 우선순위 태스크 계속 실행 (선점 안 됨) RT 실행 긴 지연 (ms ~ 수십 ms) VOLUNTARY 낮은 우선순위 태스크 실행 RT 태스크 실행 중간 지연 (cond_resched 도달까지) PREEMPT (FULL) spin_lock 구간 RT 태스크 실행 짧은 지연 (spin_unlock까지) PREEMPT_RT rt_mutex RT 태스크 즉시 실행 (spinlock이 sleeping lock이므로 선점 가능) 최소 지연 (거의 즉시) 모델 선택 가이드 NONE — 서버, HPC, 배치: 최대 처리량, 최대 지연. cond_resched도 nop. VOLUNTARY — 범용 데스크톱: 처리량-지연 균형. 긴 경로에 cond_resched() 삽입. FULL — 저지연 데스크톱, 오디오/게임: spin_unlock마다 선점 검사. 약간의 처리량 감소. RT — 산업제어, 의료기기, 로봇: 결정론적 지연. spinlock→rt_mutex, 처리량 10~30% 감소.

스케줄러 클래스별 선점 판단

선점은 단일 메커니즘이 아닙니다. 커널의 각 스케줄러 클래스(Scheduler Class)는 고유한 check_preempt_curr() 콜백을 구현하며, 태스크가 깨어날 때 현재 실행 중인 태스크를 선점할지 여부를 클래스별로 다르게 판단합니다. 이 판단 결과가 resched_curr()set_tsk_need_resched()preempt_count bit 31 설정으로 이어집니다.

스케줄러 클래스별 check_preempt_curr() 판단 흐름 try_to_wake_up() → check_preempt_curr() 깨운 태스크의 클래스? STOP 클래스 항상 선점 (무조건) migration, stopper thread DL (Deadline) 클래스 dl_time_before(wakeup, curr) deadline이 더 이른 쪽이 선점 RT (Real-Time) 클래스 p->prio < curr->prio ? 우선순위(숫자 낮을수록 높음) 비교 CFS (Fair) 클래스 vruntime 비교 wakeup - curr > wakeup_gran? gran: 과도한 선점 방지 히스테리시스 EEVDF: eligible + lag 비교 resched_curr(rq) → set_tsk_need_resched(curr) → preempt_count bit 31 = 1 다음 선점 검사 지점(spin_unlock, IRQ 반환, cond_resched)에서 preempt_count == PREEMPT_NEED_RESCHED만 남으면 → schedule() PREEMPT_LAZY에서는 bit 21만 설정되면 spin_unlock에서 선점하지 않음
/* kernel/sched/core.c — check_preempt_curr() 디스패처 */

void check_preempt_curr(struct rq *rq, struct task_struct *p, int wake_flags)
{
    /* 깨운 태스크의 클래스가 현재보다 높으면 무조건 선점 */
    if (p->sched_class->order > rq->curr->sched_class->order) {
        resched_curr(rq);
        return;
    }

    /* 같은 클래스 내에서의 선점 판단 → 클래스별 콜백 */
    if (p->sched_class == rq->curr->sched_class)
        rq->curr->sched_class->check_preempt_curr(rq, p, wake_flags);
}

/* ─── STOP 클래스: 항상 선점 ─── */
static void check_preempt_curr_stop(struct rq *rq,
        struct task_struct *p, int flags)
{
    /* stop 태스크는 최상위 클래스 → 도달 불가 */
    resched_curr(rq);
}

/* ─── DL 클래스: deadline 비교 ─── */
static void check_preempt_curr_dl(struct rq *rq,
        struct task_struct *p, int flags)
{
    if (dl_time_before(p->dl.deadline, rq->curr->dl.deadline))
        resched_curr(rq);  /* 깨운 태스크의 deadline이 더 이르면 선점 */
}

/* ─── RT 클래스: 정적 우선순위 비교 ─── */
static void check_preempt_curr_rt(struct rq *rq,
        struct task_struct *p, int flags)
{
    if (p->prio < rq->curr->prio) {
        resched_curr(rq);  /* 숫자가 작을수록 높은 우선순위 */
        return;
    }
    /* 같은 우선순위 + RR 정책이면 time quantum 만료 시 전환 */
}

/* ─── CFS 클래스: vruntime 기반 판단 ─── */
static void check_preempt_wakeup(struct rq *rq,
        struct task_struct *p, int wake_flags)
{
    struct sched_entity *se = &rq->curr->se;
    struct sched_entity *pse = &p->se;

    /* EEVDF: eligible 여부 + vruntime/lag 비교 */
    if (entity_eligible(cfs_rq, pse)) {
        if (wakeup_preempt_entity(se, pse) == 1)
            resched_curr(rq);
    }
    /* wakeup_preempt_entity()는 vruntime 차이가 wakeup_granularity보다
     * 큰 경우에만 1을 반환 → 과도한 선점(ping-pong) 방지 */
}
resched_curr()의 전체 경로:

resched_curr(rq)set_tsk_need_resched(curr)를 호출하여 preempt_count의 bit 31(PREEMPT_NEED_RESCHED)을 설정합니다. PREEMPT_LAZY 모델에서는 CFS 태스크에 대해 set_tsk_need_resched_lazy()를 사용하여 bit 21만 설정할 수 있으며, 이 경우 spin_unlock에서는 선점되지 않고 사용자 공간 복귀 시점이나 cond_resched()에서만 선점이 발생합니다.

PREEMPT_DYNAMIC (런타임 선점 모델 전환)

커널 6.x부터 도입된 CONFIG_PREEMPT_DYNAMIC은 단일 커널 바이너리에서 부팅 시 선점 모델을 전환할 수 있게 합니다. 배포판이 하나의 커널로 서버(NONE)부터 데스크톱(FULL)까지 지원할 수 있어, 별도의 커널 빌드 없이 preempt= 부팅 파라미터로 모델을 선택합니다.

/* kernel/sched/core.c — PREEMPT_DYNAMIC 구현 핵심 */

/* 정적 호출(static call)로 런타임 디스패치 비용 제거 */
DEFINE_STATIC_CALL(preempt_schedule, __preempt_schedule_func);
DEFINE_STATIC_CALL(preempt_schedule_notrace, __preempt_schedule_notrace_func);
DEFINE_STATIC_CALL(cond_resched, __cond_resched);
DEFINE_STATIC_CALL(might_resched, __might_resched);

/* 부팅 파라미터에 따라 static call 대상 교체 */
void sched_dynamic_update(int mode)
{
    switch (mode) {
    case preempt_dynamic_none:
        static_call_update(cond_resched, __cond_resched);
        static_call_update(might_resched, __static_call_return0);
        static_call_update(preempt_schedule, NULL);
        static_call_update(preempt_schedule_notrace, NULL);
        break;
    case preempt_dynamic_voluntary:
        static_call_update(cond_resched, __cond_resched);
        static_call_update(might_resched, __might_resched);
        static_call_update(preempt_schedule, NULL);
        static_call_update(preempt_schedule_notrace, NULL);
        break;
    case preempt_dynamic_full:
        static_call_update(cond_resched, __static_call_return0);
        static_call_update(might_resched, __static_call_return0);
        static_call_update(preempt_schedule, __preempt_schedule_func);
        static_call_update(preempt_schedule_notrace, __preempt_schedule_notrace_func);
        break;
    }
}
코드 설명
  • DEFINE_STATIC_CALL4개의 static_call 지점을 선언합니다. static_call은 함수 포인터 대신 호출 명령어 자체를 런타임에 패치하여 간접 호출 오버헤드를 제거합니다. kernel/sched/core.c에 정의되어 있습니다.
  • preempt_dynamic_nonecond_resched만 활성화하고, preempt_schedulepreempt_schedule_notrace를 NULL(NOP)로 설정합니다. 커널 내부에서 선점이 발생하지 않으며, might_resched도 비활성화됩니다. 서버 워크로드에 최적입니다.
  • preempt_dynamic_voluntarycond_reschedmight_resched를 모두 활성화하되, preempt_schedule은 여전히 NULL입니다. might_sleep() 지점에서 자발적 양보가 추가되어 NONE보다 응답성이 좋습니다.
  • preempt_dynamic_fullpreempt_schedulepreempt_schedule_notrace를 실제 함수로 설정하고, cond_reschedmight_resched는 NOP(__static_call_return0)으로 비활성화합니다. 모든 preempt_enable() 지점에서 선점이 가능해지므로 저지연 워크로드에 적합합니다.
# 부팅 파라미터로 선점 모델 선택
# /etc/default/grub 또는 커널 커맨드 라인
preempt=none        # 서버 최적화
preempt=voluntary   # 데스크톱 (기본값)
preempt=full        # 저지연

# 현재 모델 확인
cat /sys/kernel/debug/sched/preempt
# 출력 예: "full (dynamic)"
Static Call 메커니즘: PREEMPT_DYNAMICstatic_call을 사용하여 간접 호출(function pointer) 대신 직접 호출 명령어를 런타임에 패치(Patch)합니다. 따라서 모드 전환 후에도 호출 오버헤드(Overhead)가 없으며, PREEMPT_NONE 모드에서 preempt_schedule() 호출 자체가 NOP로 대체됩니다. x86에서는 call 명령을 nop으로, ARM64에서는 blnop으로 바꿉니다.
PREEMPT_DYNAMIC: static_call 패칭 메커니즘 부팅 시 preempt= 파라미터에 따라 호출 대상이 NOP 또는 실제 함수로 패치됨 preempt=none preempt_schedule() → NOP (5 bytes) cond_resched() → __cond_resched might_resched() → return 0 (NOP) spin_unlock 시: 선점 검사 없음 (call이 NOP이므로) 최대 처리량 선점 경로 오버헤드 = 0 preempt=voluntary preempt_schedule() → NOP (5 bytes) cond_resched() → __cond_resched might_resched() → __might_resched spin_unlock 시: 선점 검사 없음 cond_resched() 지점에서만 처리량-지연 균형 긴 경로에 양보 지점 제공 preempt=full preempt_schedule() → __preempt_schedule cond_resched() → return 0 (NOP) might_resched() → return 0 (NOP) spin_unlock 시: 즉시 선점 검사! preempt_count==0이면 schedule 최소 지연 모든 선점 가능 지점에서 검사
/* x86에서 static_call이 실제로 패치하는 기계어 */

/* preempt=full 모드에서 spin_unlock 경로의 기계어: */
/*   e8 xx xx xx xx    call __preempt_schedule   ← 5바이트 직접 호출 */

/* preempt=none 모드로 전환하면: */
/*   0f 1f 44 00 00    nopl 0x0(%rax,%rax,1)      ← 5바이트 NOP로 패치 */

/* 패치는 text_poke_bp()를 통해 안전하게 수행됨:
 * 1. INT3(0xcc) 삽입 → 다른 CPU가 실행하면 INT3 핸들러에서 대기
 * 2. 나머지 4바이트 패치
 * 3. 첫 바이트를 최종 opcode로 교체
 * 4. IPI로 모든 CPU의 명령어 캐시 동기화
 */
PREEMPT_DYNAMIC의 제약

PREEMPT_DYNAMIC은 NONE↔VOLUNTARY↔FULL 사이만 전환 가능합니다. PREEMPT_RT로의 전환은 지원하지 않습니다 — RT 모드는 spinlock을 rt_mutex로 대체하는 등 코드 구조 자체가 다르므로 바이너리 패칭으로는 불가능합니다. 또한 현재는 부팅 시에만 전환 가능하며, 런타임 동적 전환은 아직 지원하지 않습니다.

local_bh_disable/enable과 preempt_count

local_bh_disable()/local_bh_enable()preempt_count의 SOFTIRQ 필드(비트 8-15)를 조작하여 현재 CPU에서 softirq 실행을 억제합니다. 이 API와 preempt_count의 관계를 이해하는 것은 네트워크 스택(Network Stack) 등에서 올바른 동기화를 구현하는 데 필수적입니다.

/* kernel/softirq.c — local_bh_disable/enable 구현 */
void __local_bh_disable_ip(unsigned long ip, unsigned int cnt)
{
    /* preempt_count에 SOFTIRQ_DISABLE_OFFSET(0x200) 추가 */
    __preempt_count_add(cnt);
    barrier();
}

void __local_bh_enable_ip(unsigned long ip, unsigned int cnt)
{
    __preempt_count_sub(cnt);

    /* BH 활성화 시점에 pending softirq가 있으면 즉시 처리 */
    if (unlikely(!in_interrupt() && local_softirq_pending())) {
        do_softirq();
    }
    preempt_check_resched();
}

SOFTIRQ 필드와 SOFTIRQ_OFFSET의 비밀

local_bh_disable()은 SOFTIRQ 필드에 SOFTIRQ_DISABLE_OFFSET(0x200)을 더합니다. 그런데 softirq 핸들러가 실행될 때는 SOFTIRQ_OFFSET(0x100)만 더합니다. 이 차이가 in_serving_softirq()의 구현 핵심입니다:

/* include/linux/preempt.h */
#define SOFTIRQ_OFFSET          (1 << SOFTIRQ_SHIFT)     /* 0x100 — bit 8 */
#define SOFTIRQ_DISABLE_OFFSET  (2 * SOFTIRQ_OFFSET)    /* 0x200 — bit 9 */

/* local_bh_disable(): SOFTIRQ 필드에 0x200 추가
 *   → bit 9가 설정됨
 *   → in_softirq() = true (SOFTIRQ_MASK & preempt_count ≠ 0)
 *   → in_serving_softirq() = false (bit 8은 0)
 */

/* __do_softirq() 진입 시: SOFTIRQ 필드에 0x100 추가
 *   → bit 8이 설정됨
 *   → in_softirq() = true
 *   → in_serving_softirq() = true (bit 8 ≠ 0)
 *
 * in_serving_softirq() = softirq_count() & SOFTIRQ_OFFSET
 *   = preempt_count & 0x0000ff00 & 0x00000100
 *   → bit 8만 검사!
 */

/* 조합 시나리오: local_bh_disable 구간 안에서 softirq가 실행되면? */
/* → 불가능: local_bh_disable은 softirq 실행을 억제하므로,
 *    local_bh_enable() 시점에서 pending softirq를 처리합니다.
 *    이때 SOFTIRQ 필드 = 0x200 + 0x100 = 0x300이 됩니다. */
SOFTIRQ 필드의 비트별 역할 bit 15 bh_disable ×64 bit 14-10 bh_disable 카운트 bit 9 BH disabled bit 8 serving softirq SOFTIRQ_MASK = 0x0000ff00 (bits 8-15) in_softirq() = SOFTIRQ_MASK 내 어떤 비트든 설정 시나리오 A: local_bh_disable() SOFTIRQ 필드 += 0x200 bit 9 = 1, bit 8 = 0 in_softirq() → true in_serving_softirq() → false (프로세스 컨텍스트에서 BH 억제) 시나리오 B: __do_softirq() SOFTIRQ 필드 += 0x100 bit 9 = 0, bit 8 = 1 in_softirq() → true in_serving_softirq() → true (softirq 핸들러 실행 중) spin_lock_bh() = spin_lock() + local_bh_disable() preempt_count 변화: +1(PREEMPT) + 0x200(SOFTIRQ) = 0x80000201 프로세스 컨텍스트↔softirq 핸들러 간 공유 데이터 보호에 사용 softirq가 동일 CPU에서 실행되면 spin_lock만으로 보호 불가 → BH도 비활성화 필요

자세한 사용 패턴과 spin_lock_bh() 조합은 softirq/hardirq 문서의 local_bh_disable/enable 섹션을 참조하세요.

might_sleep / in_atomic

might_sleep()는 디버그 빌드(CONFIG_DEBUG_ATOMIC_SLEEP)에서 현재 컨텍스트가 sleep 불가능한 atomic 컨텍스트인지 검사하고, 위반 시 경고를 출력합니다. 많은 커널 API(kmalloc(GFP_KERNEL), mutex_lock, copy_from_user 등) 내부에 이미 삽입되어 있습니다.

/* include/linux/kernel.h — might_sleep 매크로 */
#ifdef CONFIG_DEBUG_ATOMIC_SLEEP
#define might_sleep() \
    do { __might_sleep(__FILE__, __LINE__); } while (0)
#else
#define might_sleep() do { } while (0)
#endif

/* kernel/sched/core.c — __might_sleep 구현 */
void __might_sleep(const char *file, int line)
{
    if (preempt_count() || irqs_disabled()) {
        printk(KERN_ERR
            "BUG: sleeping function called from invalid context "
            "at %s:%d\n", file, line);
        printk(KERN_ERR
            "in_atomic(): %d, irqs_disabled(): %d, "
            "non_block: 0, pid: %d, name: %s\n",
            in_atomic(), irqs_disabled(),
            current->pid, current->comm);
        dump_stack();
    }
}
/* sleep 가능 여부 동적 판단 패턴 (권장하지 않음) */
void *my_alloc(size_t size)
{
    if (in_atomic() || irqs_disabled())
        return kmalloc(size, GFP_ATOMIC);
    return kmalloc(size, GFP_KERNEL);
}

/* 커스텀 sleep 가능 함수에 might_sleep() 추가 (권장) */
void my_blocking_function(void)
{
    might_sleep();  /* atomic 컨텍스트에서 호출 시 경고 */
    mutex_lock(&some_mutex);
    /* ... */
    mutex_unlock(&some_mutex);
}
in_atomic() 기반 분기의 문제점

in_atomic()으로 할당 플래그를 동적으로 결정하는 패턴(GFP_ATOMIC vs GFP_KERNEL)은 커널 커뮤니티에서 안티패턴으로 간주됩니다. CONFIG_PREEMPT_NONE 커널에서 in_atomic()spin_lock 구간을 감지하지 못하기 때문입니다. 올바른 접근은 함수 설계 시점에서 호출 컨텍스트를 명확히 정의하고, gfp_t 플래그를 매개변수로 전달받는 것입니다.

"이 함수에서 sleep할 수 있는가?" — might_sleep 판단 흐름 might_sleep() 호출 irqs_disabled() ? Yes BUG: sleep 불가! IRQ disabled → deadlock No in_interrupt() ? Yes BUG: sleep 불가! IRQ/softirq 컨텍스트 No preempt_count() & PREEMPT_MASK ≠ 0 ? Yes BUG: sleep 불가! preempt_disable 구간 No sleep 가능! 프로세스 컨텍스트 + IRQ 활성 + 선점 가능 CONFIG_PREEMPT_NONE에서: PREEMPT_MASK 검사 불완전! spin_lock 구간을 감지 못함 → might_sleep이 false negative CONFIG_DEBUG_ATOMIC_SLEEP 필수 sleep 가능 API (might_sleep 포함) kmalloc(GFP_KERNEL), mutex_lock, wait_event, copy_from_user, msleep, schedule, down atomic 컨텍스트 전용 API kmalloc(GFP_ATOMIC), spin_lock, kfree, tasklet_schedule, mod_timer, printk

might_sleep의 실전 활용: 디버그 경고 분석

CONFIG_DEBUG_ATOMIC_SLEEP이 활성화된 커널에서 atomic 컨텍스트 위반이 발생하면 다음과 같은 경고가 출력됩니다:

BUG: sleeping function called from invalid context at mm/page_alloc.c:5234
in_atomic(): 1, irqs_disabled(): 0, non_block: 0, pid: 1234, name: my_driver
preempt_count: 1 (depth: 1)
CPU: 3 PID: 1234 Comm: my_driver Tainted: G        W          6.8.0 #1
Call Trace:
 <TASK>
 dump_stack_lvl+0x48/0x70
 __might_resched+0x166/0x1e0
 __alloc_pages+0x188/0x340          <— 이 함수가 might_sleep() 호출
 kmalloc_large+0x2d/0x90
 __kmalloc+0xb8/0x120               <— kmalloc(GFP_KERNEL) 호출 지점
 my_driver_work+0x56/0x100 [my_mod] <— 문제의 드라이버 함수
 process_one_work+0x1d6/0x3e0       <— workqueue에서 호출됨

Preemption disabled at:
 [<ffffffff81234567>] _raw_spin_lock+0x1c/0x30     <— 여기서 preempt_disable됨!
 </TASK>

위 경고는 spin_lock()으로 선점이 비활성화된 상태에서 kmalloc(GFP_KERNEL)을 호출했음을 알려줍니다. 해결 방법: GFP_ATOMIC으로 변경하거나, 할당을 lock 바깥으로 이동시킵니다.

Per-CPU 저장과 접근

preempt_count는 태스크별이 아닌 Per-CPU 변수로 저장됩니다(아키텍처에 따라 thread_info 내부 또는 Per-CPU 영역). 이 설계는 현재 CPU의 실행 컨텍스트를 빠르게 읽을 수 있게 하며, 캐시 라인(Cache Line) 경합(Contention) 없이 접근이 가능합니다.

/* x86: Per-CPU 변수로 직접 접근 (커널 5.x+) */
DECLARE_PER_CPU(int, __preempt_count);

static __always_inline int preempt_count(void)
{
    return raw_cpu_read_4(__preempt_count);
}

static __always_inline void preempt_count_set(int pc)
{
    raw_cpu_write_4(__preempt_count, pc);
}

/* x86에서는 GS 세그먼트를 통한 단일 명령어 접근 */
/*   mov %gs:__preempt_count, %eax  */
/* 추가 락이나 원자적 연산 불필요 — 현재 CPU만 접근 */

/* ARM64: thread_info 내부 저장 */
struct thread_info {
    unsigned long   flags;
    int             preempt_count;  /* 0 → 선점 가능, <0 → 버그 */
    /* ... */
};

/* ARM64에서는 sp_el0 레지스터로 current_thread_info() 접근 */
/*   ldr w0, [x28, #TI_PREEMPT]   (x28 = current_thread_info) */
왜 Per-CPU인가? preempt_count는 인터럽트 핸들러(irq_enter/irq_exit)에서도 조작되므로, 락 없이 접근해야 합니다. Per-CPU 변수는 현재 CPU만 쓰기 때문에 원자적 연산(Atomic Operation)이 불필요하고, x86에서는 GS 세그먼트를 통한 단일 명령어로 접근할 수 있어 오버헤드가 거의 없습니다.
아키텍처별 preempt_count 접근 경로 x86_64 GS 세그먼트 레지스터 기준 주소 Per-CPU 데이터 영역 (CPU N) current_task (struct task_struct *) __preempt_count (int) ← 이것! cpu_number, irq_count, ... 생성되는 기계어 (1 명령어!) mov %gs:__preempt_count, %eax GS 세그먼트 오프셋으로 직접 읽기 — 1 cycle 장점 단일 명령어 접근, 포인터 역참조 없음 캐시 미스 가능성 낮음 (항상 같은 CPU 접근) ARM64 (AArch64) SP_EL0 레지스터 → task_struct task_struct (현재 태스크) sp_el0 = current (커널 모드에서 재사용) 오프셋 thread_info (task_struct 내장) preempt_count (int) ← 이것! 생성되는 기계어 (2 명령어) mrs x0, sp_el0 ldr w1, [x0, #TI_PREEMPT] 장점 task_struct에 내장 → 컨텍스트 스위치 시 자동 전환 Per-CPU 변수 테이블 불필요

RISC-V에서의 preempt_count 접근

RISC-V 아키텍처는 tp(thread pointer, x4) 레지스터를 통해 현재 태스크의 task_struct에 접근합니다. ARM64가 sp_el0를 사용하는 것과 유사한 접근 방식으로, thread_infotask_struct에 내장(embed)하여 별도의 Per-CPU 변수 테이블 없이 직접 접근합니다.

/* arch/riscv/include/asm/asm-offsets.h 등에서 정의 */
/* tp 레지스터 = 현재 task_struct 시작 주소 */

/* ─── RISC-V: preempt_count 읽기 (2 명령어) ─── */
/*
 *   lw   a0, TI_PREEMPT(tp)    // tp + 오프셋으로 preempt_count 로드
 *   bnez a0, .Lno_preempt      // 0이 아니면 선점 불가
 *
 * tp 레지스터는 컨텍스트 스위치 시 __switch_to()에서
 * 다음 태스크의 task_struct 주소로 갱신됨:
 *   ld   tp, TASK_THREAD_TP(next)  // (실제론 TASK_THREAD_INFO)
 */

/* arch/riscv/kernel/entry.S — 선점 검사 예시 */
/*
 * resume_kernel:
 *   REG_L s0, TASK_TI_PREEMPT_COUNT(tp)   // preempt_count 로드
 *   bnez  s0, restore_all                  // ≠ 0이면 선점 스킵
 *   REG_L s0, TASK_TI_FLAGS(tp)            // TIF_NEED_RESCHED 검사
 *   andi  s0, s0, _TIF_NEED_RESCHED
 *   beqz  s0, restore_all                  // 미설정이면 스킵
 *   call  preempt_schedule_irq             // 선점 실행
 */

/* arch/riscv/include/asm/thread_info.h */
struct thread_info {
    unsigned long   flags;          /* TIF_NEED_RESCHED 등 */
    int             preempt_count;  /* 선점 카운터 */
    /* ... */
};
/* task_struct 첫 필드로 내장 → tp = &task_struct → tp + offset = preempt_count */
아키텍처별 접근 방식 비교:

x86은 Per-CPU 변수(gs:__preempt_count)로 접근하고, ARM64RISC-Vtask_struct에 내장된 thread_info.preempt_count를 전용 레지스터(sp_el0, tp)로 접근합니다. 결과적으로 모두 1~2개 명령어로 preempt_count를 읽을 수 있어 선점 검사의 오버헤드가 매우 작습니다.

preempt_count 초기화: fork()에서의 설정

새 태스크가 fork()로 생성될 때 preempt_count는 어떻게 초기화될까요? 커널은 자식 태스크가 처음 스케줄될 때 안전하게 선점을 다룰 수 있도록 초기값을 신중하게 설정합니다.

/* kernel/fork.c — copy_thread()에서 preempt_count 초기화 */

/* 새 태스크의 preempt_count 초기값 */
#define FORK_PREEMPT_COUNT  (2 * PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLED)
/* 값: 2 (PREEMPT 필드) → 선점 비활성화 상태로 시작
 *
 * 이유:
 *   1. 자식 태스크가 schedule()에서 처음 선택될 때,
 *      context_switch() → finish_task_switch()를 거침
 *   2. finish_task_switch()에서 preempt_enable() 1회 호출 → count: 2→1
 *   3. ret_from_fork()에서 preempt_enable() 1회 호출 → count: 1→0
 *   4. 이 시점에서 비로소 선점 가능한 상태가 됨
 *
 * 왜 2인가?
 *   context_switch 내부에서 아직 이전 태스크 정리가 끝나지 않은 상태에서
 *   선점되면 안 되므로, 2중으로 보호함
 */

static int copy_thread(struct task_struct *p, ...)
{
    /* ... */
    task_thread_info(p)->preempt_count = FORK_PREEMPT_COUNT;
    /* ... */
}
idle 태스크의 preempt_count: 각 CPU의 idle 태스크(pid 0, swapper)는 PREEMPT_DISABLED(값 1)로 초기화됩니다. idle 태스크는 schedule()을 직접 호출하므로 선점될 필요가 없으며, preempt_count가 1인 상태로 유지됩니다. CPU가 idle 상태일 때 인터럽트로 깨어나면, IRQ 반환 경로에서 need_resched를 확인하여 새 태스크로 전환합니다.

PREEMPT_LAZY (커널 6.12+)

커널 6.12에서 도입된 PREEMPT_LAZYpreempt_count의 비트 21을 활용하여 "지연된 선점(lazy preemption)" 개념을 구현합니다. 기존 FULL 선점 모델의 응답성은 유지하면서, 불필요한 선점을 줄여 처리량(Throughput)을 개선하는 것이 목표입니다.

/* include/linux/preempt.h — PREEMPT_LAZY 비트 정의 */
#define PREEMPT_NEED_RESCHED      0x80000000  /* bit 31: 즉시 선점 */
#define PREEMPT_NEED_RESCHED_LAZY 0x00200000  /* bit 21: 지연 선점 */

/* Lazy preemption 핵심 개념:
 *
 * 일반 태스크 깨우기(ttwu) 시:
 *   1. 먼저 NEED_RESCHED_LAZY만 설정 (비트 21)
 *   2. 커널 코드는 계속 실행 (spin_unlock에서 선점하지 않음)
 *   3. 다음 tick 인터럽트에서 LAZY를 NEED_RESCHED(비트 31)로 승격
 *   4. 이때 비로소 선점 발생
 *
 * 실시간 태스크 깨우기 시:
 *   1. 즉시 NEED_RESCHED(비트 31) 설정
 *   2. 기존 FULL preemption과 동일하게 즉시 선점
 */

/* tick 핸들러에서 lazy → urgent 승격 */
static void tick_check_preempt_lazy(void)
{
    if (test_preempt_need_resched_lazy()) {
        /* LAZY 요청을 URGENT로 승격 */
        set_tsk_need_resched(current);
        set_preempt_need_resched();
        clear_preempt_need_resched_lazy();
    }
}
코드 설명
  • PREEMPT_NEED_RESCHED (bit 31)기존의 즉시 선점 플래그입니다. 이 비트가 클리어(0)되면 "즉시 리스케줄 필요"를 의미하며, preempt_enable()이나 irq_exit()에서 즉각 선점이 발생합니다.
  • PREEMPT_NEED_RESCHED_LAZY (bit 21)커널 6.12에서 추가된 지연 선점 플래그입니다. 일반(CFS) 태스크가 깨어날 때 비트 31 대신 비트 21만 설정하여, spin_unlock() 경로에서의 즉시 선점을 억제합니다. include/linux/preempt.h에 정의되어 있습니다.
  • Lazy → Urgent 승격일반 태스크에 대해 LAZY 비트만 설정된 상태에서는 커널 코드가 계속 실행됩니다. 다음 tick 인터럽트에서 tick_check_preempt_lazy()가 LAZY를 감지하면 비트 31(NEED_RESCHED)을 설정하고 LAZY를 클리어하여, 그때 비로소 선점이 발생합니다.
  • RT 태스크 예외실시간(RT) 태스크가 깨어날 때는 LAZY를 거치지 않고 즉시 비트 31을 설정합니다. 이로써 RT 태스크의 지연 시간은 FULL 선점과 동일하게 유지하면서, 일반 태스크의 불필요한 컨텍스트 스위치를 줄여 처리량을 개선합니다.
PREEMPT_LAZY의 이점: FULL 선점 모델에서는 spin_unlock()마다 preempt_schedule()이 호출되어 불필요한 컨텍스트 스위치가 발생할 수 있습니다. PREEMPT_LAZY는 일반 태스크에 대해 선점을 다음 tick까지 지연시켜 처리량을 개선하면서, RT 태스크에 대해서는 즉시 선점을 보장합니다. 이로써 NONE과 FULL의 장점을 통합하는 것이 장기적 목표입니다.
PREEMPT_LAZY vs FULL: 일반 태스크 깨우기 비교 일반 태스크 wakeup spin_unlock spin_unlock tick 인터럽트 spin_unlock PREEMPT FULL spin_lock 구간 (선점 불가) 새 태스크 실행 (즉시 선점됨) bit31=0 (NEED_RESCHED 설정) preempt_count==0 → schedule()! PREEMPT LAZY 기존 태스크 계속 실행 (LAZY 비트만 설정, 즉시 선점 안 함) spin_lock 구간 선점! bit21=1 (LAZY 설정) spin_unlock: LAZY만 → 선점 안 함 tick: LAZY→bit31 승격! → schedule() 절약: 불필요한 컨텍스트 스위치 2회 회피 LAZY + RT 태스크 spin_lock 구간 RT 태스크 즉시 실행 (FULL과 동일하게 동작!) bit31=0 (NEED_RESCHED 즉시 설정 — LAZY 우회) PREEMPT_LAZY의 핵심 원리 1. 일반 태스크 wakeup → LAZY 비트(bit 21)만 설정 → spin_unlock에서 선점 안 함 → tick에서 승격 2. RT 태스크 wakeup → NEED_RESCHED(bit 31) 즉시 설정 → spin_unlock에서 즉시 선점 (FULL과 동일) 3. 결과: RT 응답성은 FULL과 동일 유지, 일반 태스크 간 불필요한 선점 감소 → 처리량 개선 장기 목표: PREEMPT_LAZY가 NONE/VOLUNTARY/FULL을 대체하여 단일 모델로 통합 (커널 6.13+ 논의 중)

PREEMPT_LAZY 구현 세부사항

/* kernel/sched/core.c — 태스크 깨우기 시 lazy vs urgent 결정 */
static void check_preempt_curr(struct rq *rq, struct task_struct *p)
{
    if (task_is_realtime(p)) {
        /* RT/DL 태스크: 즉시 선점 요청 (bit 31) */
        resched_curr(rq);  /* set_tsk_need_resched + PREEMPT_NEED_RESCHED */
    } else {
        /* 일반(CFS) 태스크: lazy 선점 요청 (bit 21) */
        if (sched_feat(PREEMPT_LAZY))
            resched_curr_lazy(rq);  /* PREEMPT_NEED_RESCHED_LAZY만 설정 */
        else
            resched_curr(rq);       /* fallback: 기존 동작 */
    }
}

/* kernel/sched/core.c — tick에서 lazy 승격 */
void scheduler_tick(void)
{
    /* ... */
    if (test_preempt_need_resched_lazy()) {
        /* 매 tick마다 검사: LAZY 요청이 있으면 URGENT로 승격 */
        resched_curr(rq);  /* bit 21 → bit 31로 승격 */
        clear_preempt_need_resched_lazy();
    }
    /* ... */
}

/* preempt_schedule()에서 bit 31만 검사 — bit 21은 무시 */
/* 따라서 spin_unlock → preempt_schedule → should_resched(0)에서
 * bit 31이 0(=NEED_RESCHED 미설정)이면 선점하지 않음
 * LAZY 비트만 설정된 상태에서는 spin_unlock이 무관통!
 */

migrate_disable/enable과 preempt_count

PREEMPT_RT 커널에서 spin_lock()은 슬리핑 뮤텍스(sleeping mutex)로 변환되므로, 더 이상 preempt_disable()을 내포하지 않습니다. 그러나 Per-CPU 데이터를 보호하려면 태스크가 다른 CPU로 이동하지 않는다는 보장이 여전히 필요합니다. 이 문제를 해결하기 위해 도입된 것이 migrate_disable()/migrate_enable()입니다. 선점은 허용하되 CPU 마이그레이션만 차단하는 경량 메커니즘입니다.

preempt_disable() vs migrate_disable() 동작 비교 동작 preempt_disable() migrate_disable() 선점 허용 여부 차단 (preempt_count++) 허용 (preempt_count 불변) CPU 마이그레이션 차단 (선점 자체가 불가) 차단 (migration_disabled++) sleep 가능 함수 불가 (BUG) 가능 (같은 CPU에서 재개) PREEMPT_RT spin_lock 내부 사용 안 함 자동 적용 (Per-CPU 보호) 카운터 위치 preempt_count (bits 0~7) task_struct→migration_disabled 핵심: migrate_disable()는 preempt_count를 건드리지 않음 → in_atomic() = false → sleep 가능 선점 되더라도 같은 CPU에서 재개 보장 → Per-CPU 데이터 일관성 유지
/* kernel/sched/core.c — migrate_disable() 구현 */

void migrate_disable(void)
{
    struct task_struct *p = current;

    if (p->migration_disabled) {
        p->migration_disabled++;  /* 중첩 카운트 증가 */
        return;
    }

    preempt_disable();             /* 일시적으로 선점 차단 */
    p->migration_disabled = 1;
    p->cpus_ptr = cpumask_of(smp_processor_id());
    /* cpus_ptr를 현재 CPU만으로 제한 → 스케줄러가 이동시키지 않음 */
    preempt_enable();              /* 선점 다시 허용 */
}

void migrate_enable(void)
{
    struct task_struct *p = current;

    if (p->migration_disabled > 1) {
        p->migration_disabled--;  /* 중첩 해제 */
        return;
    }

    preempt_disable();
    p->migration_disabled = 0;
    p->cpus_ptr = &p->cpus_mask;  /* 원래 affinity 복원 */
    preempt_enable();
    /* 복원 후 밀린 마이그레이션 요청이 있으면 처리 */
}

/* ─── get_cpu() / put_cpu()와의 관계 ─── */
/* 기존 패턴 (Non-RT): */
int cpu = get_cpu();          /* preempt_disable() + smp_processor_id() */
per_cpu(my_var, cpu) += 1;
put_cpu();                    /* preempt_enable() */

/* PREEMPT_RT 호환 패턴: */
migrate_disable();
int cpu = smp_processor_id();
per_cpu(my_var, cpu) += 1;   /* 선점 가능하지만 같은 CPU 유지 */
migrate_enable();

/* 주의: 단순 읽기/쓰기는 migrate_disable로 충분하지만,
 * read-modify-write(증감 등)는 선점 지점에서 동시 접근 가능
 * → this_cpu_inc() 같은 원자적 Per-CPU 연산 사용 권장 */
PREEMPT_RT에서의 Per-CPU 접근 주의사항

migrate_disable() 구간에서도 선점이 발생할 수 있으므로, Per-CPU 변수의 read-modify-write 연산은 원자적이지 않습니다. 예를 들어 per_cpu(counter, cpu)++는 읽기와 쓰기 사이에 같은 CPU의 다른 태스크가 끼어들 수 있습니다. 이런 경우 this_cpu_inc(), this_cpu_add() 등 원자적 Per-CPU 연산을 사용하거나, local_lock으로 보호해야 합니다. local_lock은 Non-RT에서는 preempt_disable(), PREEMPT_RT에서는 Per-CPU spinlock(sleeping)으로 변환되어 양쪽 환경에서 모두 안전합니다.

디버깅 (DEBUG_PREEMPT)

CONFIG_DEBUG_PREEMPT를 활성화하면 preempt_count 관련 오류를 런타임에 감지합니다. 선점 카운터 언밸런스, 잘못된 컨텍스트에서의 호출, sleep-in-atomic 등의 버그를 조기에 발견할 수 있습니다.

/* CONFIG_DEBUG_PREEMPT 활성화 시 추가되는 검사 */

/* 1. preempt_count 언더플로우 감지 */
void preempt_count_sub(int val)
{
    if (DEBUG_LOCKS_WARN_ON(val > preempt_count()))
        return;  /* 감소량이 현재값보다 크면 언더플로우 경고 */
    __preempt_count_sub(val);
}

/* 2. 컨텍스트 불일치 감지 */
void preempt_count_add(int val)
{
    DEBUG_LOCKS_WARN_ON((preempt_count() < 0));
    __preempt_count_add(val);
    DEBUG_LOCKS_WARN_ON((preempt_count() < 0));
}

/* 3. might_sleep() — CONFIG_DEBUG_ATOMIC_SLEEP */
/* atomic 컨텍스트에서 sleep 가능 함수 호출 시 경고:
 *
 * BUG: sleeping function called from invalid context at mm/slab.h:XXX
 * in_atomic(): 1, irqs_disabled(): 0, non_block: 0, pid: 1234, name: test
 * Preemption disabled at:
 * [<ffffffff81234567>] my_function+0x12/0x80
 * Call Trace:
 *  dump_stack_lvl+0x...
 *  __might_resched+0x...
 *  kmem_cache_alloc+0x...
 *  my_function+0x...
 */
# ftrace로 선점 이벤트 추적
echo preemptirq:preempt_disable > /sys/kernel/tracing/set_event
echo preemptirq:preempt_enable > /sys/kernel/tracing/set_event
echo 1 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace

# 출력 예:
#           TASK-PID  CPU#  d..1  TIMESTAMP  FUNCTION
#           |    |     |     |         |           |
#   kworker-123   [002] d..1  123.456: preempt_disable caller=_raw_spin_lock+0x1c
#   kworker-123   [002] d..1  123.457: preempt_enable  caller=_raw_spin_unlock+0x2f

# lockdep와 연계: 선점 관련 잠금 순서 위반 검출
echo 1 > /proc/sys/kernel/lock_stat

# preemptoff tracer: 선점 비활성화 최대 지연 측정
echo preemptoff > /sys/kernel/tracing/current_tracer
echo 1 > /sys/kernel/tracing/tracing_on
# ... 워크로드 실행 ...
cat /sys/kernel/tracing/tracing_max_latency
# 출력 예: 1523 (마이크로초 단위 최대 선점 비활성화 구간)

# eBPF로 preempt_disable 구간 히스토그램 수집
bpftrace -e 'kprobe:preempt_count_add { @start[tid] = nsecs; }
  kprobe:preempt_count_sub /@start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
  }'
preemptoff 트레이서 활용: preemptoff 트레이서는 선점이 비활성화된 가장 긴 구간을 기록합니다. tracing_max_latency 값이 예상보다 크다면 해당 스택 트레이스를 분석하여 병목(Bottleneck) spinlock이나 과도한 preempt_disable 구간을 식별할 수 있습니다. RT 시스템에서는 이 값이 수십 마이크로초를 넘지 않아야 합니다.
preempt_count 관련 문제 디버깅 워크플로우 선점 관련 문제 발생! sleep-in-atomic 경고 BUG: sleeping function... 진단 방법 1. CONFIG_DEBUG_ATOMIC_SLEEP 2. 스택 트레이스에서 호출 지점 확인 3. "Preemption disabled at:" 확인 → GFP_ATOMIC 또는 lock 범위 조정 preempt_count 언밸런스 언더플로우/오버플로우 경고 진단 방법 1. CONFIG_DEBUG_PREEMPT 활성화 2. ftrace preempt_disable/enable 추적 3. lock/unlock 쌍 검증 → 누락된 unlock/enable 경로 수정 높은 선점 지연시간 latency spike 발생 진단 방법 1. preemptoff 트레이서 사용 2. tracing_max_latency 확인 3. eBPF 히스토그램 수집 → 병목 lock 식별 및 최적화 디버깅 도구 요약 CONFIG_DEBUG_PREEMPT preempt_count 언더/오버플로우 즉시 감지, 음수 값 경고 CONFIG_DEBUG_ATOMIC_SLEEP atomic 컨텍스트에서 sleep 시도 경고 + 호출 스택 덤프 preemptoff tracer 선점 비활성화 최대 구간 측정 (마이크로초 단위 정밀도) eBPF + bpftrace preempt_disable 구간 히스토그램, 프로덕션 커널에서도 사용 가능 lockdep + lock_stat 잠금 순서 위반 검출, lock 보유 시간 통계 — 선점 관련 교착 식별

실전 디버깅 예제: preempt_count 값 해석

# /proc/<pid>/status에서 preempt_count 확인 (커널 제공 없으므로 ftrace 활용)

# 1. 특정 함수 진입 시 preempt_count 값 출력
echo 'p:myprobe my_driver_func preempt=%ax' > /sys/kernel/tracing/kprobe_events
echo 1 > /sys/kernel/tracing/events/kprobes/myprobe/enable
cat /sys/kernel/tracing/trace_pipe
# 출력: ... preempt=0x80000001 → PREEMPT필드=1, RESCHED 미설정

# 2. 선점 비활성화 구간 길이 히스토그램 (bpftrace)
bpftrace -e '
tracepoint:preemptirq:preempt_disable { @start[tid] = nsecs; }
tracepoint:preemptirq:preempt_enable /@start[tid]/ {
  @disable_us = hist((nsecs - @start[tid]) / 1000);
  delete(@start[tid]);
}'
# 결과 예:
# @disable_us:
# [0]              15234 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
# [1]               8921 |@@@@@@@@@@@@@@@@@@@@@@@                |
# [2, 4)            3456 |@@@@@@@@@                              |
# [4, 8)             892 |@@                                     |
# [8, 16)            234 |                                       |
# [16, 32)            45 |                                       |  ← 이런 구간 주의!
# [32, 64)             3 |                                       |  ← 문제 후보

# 3. preemptoff 트레이서로 최악 구간의 콜스택 확인
echo preemptoff > /sys/kernel/tracing/current_tracer
echo 0 > /sys/kernel/tracing/tracing_max_latency  # 리셋
echo 1 > /sys/kernel/tracing/tracing_on
# ... 워크로드 실행 ...
cat /sys/kernel/tracing/tracing_max_latency
# 출력: 847 (마이크로초)
cat /sys/kernel/tracing/trace
# → 최대 선점 비활성화 구간의 전체 함수 호출 트레이스가 출력됨
# → 어떤 lock이 847μs 동안 선점을 막았는지 식별 가능

# 4. /proc/sys/kernel/preempt_count_threshold (사용 가능 시)
# 선점 비활성화 임계값을 설정하여 초과 시 경고 출력
echo 1000 > /proc/sys/kernel/preempt_count_threshold  # 1000μs

irq_enter/irq_exit와 preempt_count 조작

하드웨어 인터럽트 핸들러가 실행되기 전후로 irq_enter()/irq_exit()preempt_count의 HARDIRQ 필드를 조작합니다. 이 과정은 인터럽트 컨텍스트를 정확히 추적하고, 인터럽트 반환 시 선점 결정에 핵심적인 역할을 합니다.

/* kernel/softirq.c — irq_enter/irq_exit 구현 */

void irq_enter(void)
{
    rcu_irq_enter();           /* RCU에 IRQ 컨텍스트 진입 알림 */
    irqtime_account_irq(current);  /* IRQ 시간 회계 시작 */
    preempt_count_add(HARDIRQ_OFFSET);  /* HARDIRQ 필드 +1 (0x10000) */
    /* 이 시점부터 in_irq() == true
     * in_interrupt() == true
     * preempt_schedule()이 호출되어도 즉시 리턴
     * sleep 가능 함수 호출하면 BUG */
}

void irq_exit(void)
{
    preempt_count_sub(HARDIRQ_OFFSET);  /* HARDIRQ 필드 -1 */

    /* hardirq에서 나온 직후: softirq 처리 필요한지 확인 */
    if (!in_interrupt() && local_softirq_pending()) {
        /* 모든 IRQ/softirq 컨텍스트에서 나왔고 pending softirq 있음 */
        invoke_softirq();
    }

    irqtime_account_irq(current);
    rcu_irq_exit();

    /* 선점 검사:
     * preempt_count가 0이 되었고 need_resched면 → 커널 선점!
     * 이 경로는 CONFIG_PREEMPT(FULL) 이상에서만 활성
     */
}

/* NMI 컨텍스트의 경우 */
void nmi_enter(void)
{
    preempt_count_add(NMI_OFFSET);  /* NMI 비트 설정 (0x100000) */
    /* NMI는 중첩 불가: 이미 NMI 내부이면 CPU 예외로 처리
     * 따라서 NMI_MASK는 1비트만 사용 (0 또는 1) */
}

void nmi_exit(void)
{
    preempt_count_sub(NMI_OFFSET);  /* NMI 비트 해제 */
}
코드 설명
  • irq_enter()preempt_countHARDIRQ_OFFSET(0x10000)을 더하여 HARDIRQ 필드(비트 16-19)를 1 증가시킵니다. 이 시점부터 in_irq()in_interrupt()가 true를 반환하며, sleep 가능 함수 호출이 금지됩니다. kernel/softirq.c에 정의되어 있습니다.
  • irq_exit()HARDIRQ_OFFSET을 빼서 HARDIRQ 필드를 감소시킨 뒤, in_interrupt()가 false이고 pending softirq가 있으면 invoke_softirq()를 호출합니다. 모든 인터럽트 컨텍스트에서 빠져나온 시점에서만 softirq를 처리하는 것이 핵심입니다.
  • irq_exit() 선점 경로CONFIG_PREEMPT(FULL) 이상에서, preempt_count가 0이 되고 TIF_NEED_RESCHED가 설정되어 있으면 irq_exit() 반환 경로에서 커널 선점이 발생합니다. 이것이 인터럽트 반환 시 선점의 핵심 메커니즘입니다.
  • nmi_enter() / nmi_exit()NMI_OFFSET(0x100000)으로 비트 20을 설정/해제합니다. NMI는 중첩이 불가능하므로 1비트만 사용합니다. NMI 내부에서는 in_nmi()가 true이며, 거의 모든 커널 기능이 사용 불가합니다.
IRQ 처리 중 preempt_count 변화 시간 프로세스 컨텍스트 count=0 IRQ! irq_enter() count+=0x10000 IRQ 핸들러 실행 in_irq()=true irq_exit() count-=0x10000 pending softirq? Yes __do_softirq() No preempt_count == 0 ? Yes preempt_schedule! No 중단 지점으로 복귀 irq_exit()에서 HARDIRQ 감소 후: ① pending softirq 있으면 처리, ② preempt_count==0이면 선점 검사 → 인터럽트 반환은 커널 선점의 가장 중요한 체크포인트

IPI 기반 원격 선점 요청

스케줄러가 태스크를 깨울 때, 해당 태스크가 다른 CPU에서 실행되어야 한다면 단순히 need_resched 비트를 설정하는 것만으로는 부족합니다. 원격 CPU는 자신의 preempt_count를 직접 검사하지 않으면 선점 요청을 인지할 수 없기 때문입니다. 이때 IPI(Inter-Processor Interrupt)를 통해 원격 CPU에 "재스케줄하라"는 인터럽트를 보냅니다.

CPU0 wakeup → IPI → CPU1 선점 흐름 CPU 0 CPU 1 try_to_wake_up(task_B) — B는 CPU1에서 실행 적합 check_preempt_curr(cpu1_rq) → resched_curr() resched_curr(): cpu != smp_processor_id() → 원격 CPU → smp_send_reschedule(cpu1) IPI 태스크 C 실행 중 (낮은 우선순위) IPI 수신: scheduler_ipi() set_preempt_need_resched() → bit 31 = 1 IRQ 반환 경로: preempt_count 검사 preempt_count == 0 + NEED_RESCHED → schedule() → 태스크 B로 전환 PREEMPT_LAZY: CFS 태스크에 bit 21만 설정 시 IPI 생략 가능 → IPI 오버헤드 감소
/* kernel/sched/core.c — resched_curr() 로컬 vs 원격 경로 */

void resched_curr(struct rq *rq)
{
    struct task_struct *curr = rq->curr;
    int cpu;

    if (test_tsk_need_resched(curr))
        return;  /* 이미 설정됨 → 중복 IPI 방지 */

    cpu = cpu_of(rq);
    if (cpu == smp_processor_id()) {
        /* ─── 로컬 경로: 비트만 설정하면 됨 ─── */
        set_tsk_need_resched(curr);
        /* preempt_count bit 31 = 1 */
        set_preempt_need_resched();
        return;
    }

    /* ─── 원격 경로: 비트 설정 + IPI 전송 ─── */
    if (set_nr_and_not_polling(curr)) {
        /* curr이 poll 모드가 아니면 IPI로 깨워야 함 */
        smp_send_reschedule(cpu);
        /* arch별 구현: x86은 RESCHEDULE_VECTOR IPI
         * ARM64는 IPI_RESCHEDULE SGI */
    }
    /* poll 모드(idle)면 TIF_NEED_RESCHED만으로 충분
     * → 다음 idle loop 반복에서 감지 */
}

/* ─── IPI 수신 측: scheduler_ipi() ─── */
void scheduler_ipi(void)
{
    /* IPI가 도착하면 이미 IRQ 컨텍스트 진입 상태
     * → irq_exit() 경로에서 need_resched 검사가 자동 수행
     *
     * 핵심: IPI 자체가 인터럽트이므로,
     * irq_exit() → preemptible() 검사 → schedule() 경로를 탐
     *
     * 별도의 work가 있으면 여기서 처리:
     * - 마이그레이션 요청
     * - 로드밸런싱 콜백
     */
    irq_enter();
    /* ... 밀린 콜백 처리 ... */
    irq_exit();
    /* irq_exit() 내부에서 preempt_count HARDIRQ 감소 후
     * need_resched 확인 → schedule() 호출 가능 */
}
PREEMPT_LAZY에서의 IPI 최적화:

PREEMPT_LAZY 모델에서는 CFS 태스크에 대해 NEED_RESCHED_LAZY(bit 21)만 설정하는 경우 IPI를 보내지 않을 수 있습니다. lazy 비트는 사용자 공간 복귀 시점이나 명시적 cond_resched()에서만 검사하므로, 원격 CPU를 긴급하게 인터럽트할 필요가 없습니다. 이 최적화로 CFS 워크로드에서 불필요한 IPI가 크게 줄어 캐시 오염과 전력 소비가 감소합니다. 단, RT/DL 태스크의 선점 요청은 항상 즉시 IPI를 발생시킵니다.

끝까지 따라가는 상태 전이 예제

개별 API 설명만으로는 preempt_count의 진짜 역할이 잘 안 보입니다. 가장 이해하기 쉬운 장면은 커널 코드가 락을 쥔 상태에서 하드 IRQ에 끊기고, 그 IRQ가 더 높은 우선순위 태스크를 깨운 뒤, 원래 코드가 마지막 unlock을 하자마자 선점이 발생하는 경우입니다. 이 시나리오 하나에 PREEMPT, HARDIRQ, need_resched, 인터럽트 반환, 마지막 spin_unlock() 검사가 모두 들어 있습니다.

  1. 시작: 태스크 A가 시스템 콜(System Call) 내부에서 실행 중이고, 아직 재스케줄 필요는 없다고 가정합니다. 이 문서의 표기에서는 전체 값이 대략 0x80000000인 상태입니다.
  2. 락 획득: 태스크 A가 spin_lock()을 잡으면 PREEMPT 필드가 올라가 0x80000001이 됩니다. 이제 더 높은 우선순위 태스크가 준비되어도 즉시 CPU를 넘길 수 없습니다.
  3. 하드 IRQ 진입: 장치 인터럽트가 들어오면 irq_enter()가 HARDIRQ 필드를 올려 0x80010001처럼 보입니다. 이 순간 현재 CPU는 "락을 가진 채 하드 IRQ 문맥 위에 올라탄 상태"입니다.
  4. 더 높은 우선순위 태스크 깨움: IRQ 핸들러가 태스크 B를 깨워 즉시 실행시키고 싶어도, 현재는 hardirq 문맥이므로 스케줄할 수 없습니다. 대신 재스케줄 필요 표시만 남기고 값은 0x00010001 쪽으로 바뀝니다.
  5. IRQ 종료: irq_exit()가 HARDIRQ 필드를 내리면 0x00000001이 됩니다. 여전히 PREEMPT 값이 1이므로, "재스케줄 필요"는 알지만 아직 원래 락 보호 구역이 끝나지 않았습니다.
  6. 원래 커널 코드 복귀: 인터럽트 전의 시스템 콜 코드가 이어서 실행됩니다. 이때 태스크 A는 이미 CPU를 넘겨야 한다는 사실을 알고 있지만, lock을 쥔 동안은 끝까지 정리해야 합니다.
  7. 마지막 unlock: spin_unlock()이 PREEMPT 필드를 0으로 내리면 전체 워드가 0x00000000이 되고, 바로 이 지점에서 빠른 경로 선점 검사가 true가 됩니다.
  8. 문맥 전환: 스케줄러가 태스크 B로 전환합니다. 즉, 인터럽트가 들어온 시점이 아니라 마지막 금지 요인이 사라진 시점이 실제 문맥 전환 시점입니다.
"락 보유 중 IRQ 발생" 시나리오의 전체 상태 전이 시간 프로세스 A 실행 0x80000000 spin_lock() 0x80000001 irq_enter() 0x80010001 태스크 B 깨움 need_resched 설정 0x00010001 irq_exit() 0x00000001 irq_exit() 이후에도 즉시 스케줄되지 않는 이유 HARDIRQ는 내려갔지만 PREEMPT 값 1이 남아 있어 원래 락 보호 구역이 아직 끝나지 않았기 때문 즉, "깨워야 한다"와 "당장 바꿀 수 있다"는 다른 조건입니다 원래 커널 코드 복귀 여전히 lock 보유 spin_unlock() 0x00000001 → 0x00000000 선점 검사 통과 schedule() 진입 실제 문맥 전환은 IRQ가 태스크 B를 깨운 순간이 아니라 마지막 금지 요인(PREEMPT=1)이 사라진 직후에 발생 이 차이를 이해하면 "왜 need_resched가 켜졌는데 당장 안 바뀌지?"라는 질문이 대부분 해결됩니다
단계대표 값무슨 일이 벌어졌는가그 자리에서 스케줄 가능한가
시작0x80000000프로세스 컨텍스트, 재스케줄 불필요필요하지 않음
락 획득0x80000001PREEMPT 필드가 올라가 임계 구역 보호 시작불가
IRQ 진입0x80010001HARDIRQ까지 겹쳐져 인터럽트 문맥 위에 올라감불가
태스크 B 깨움0x00010001재스케줄 요청은 생겼지만 hardirq와 lock 때문에 지연불가
IRQ 종료0x00000001HARDIRQ는 사라졌지만 PREEMPT 중첩이 남아 있음불가
unlock 직후0x00000000마지막 금지 요인이 해제되어 빠른 경로 조건 충족가능
/* "락 보유 중 IRQ가 더 높은 우선순위 태스크를 깨움" 시나리오 의사 코드 */
void sys_write_path(struct rq *rq)
{
    spin_lock(&rq->lock);           /* PREEMPT +1 */
    /* ... 런큐 또는 장치 상태 갱신 ... */

    /* 이 사이에 하드 IRQ가 들어온다고 가정 */
    /* irq_enter(): HARDIRQ +1 */
    /* wake_up_process(task_b): need_resched 설정 */
    /* irq_exit(): HARDIRQ -1, 하지만 PREEMPT는 아직 1 */

    /* 인터럽트에서 돌아왔지만 아직 lock 보유 중이므로 선점 불가 */
    spin_unlock(&rq->lock);         /* PREEMPT 1 → 0 */
    /* 여기서 비로소 preempt_count_dec_and_test()가 true가 되어 */
    /* __preempt_schedule() 또는 대응 경로로 진입할 수 있음 */
}
모델별 차이: PREEMPT_NONE과 VOLUNTARY에서는 마지막 spin_unlock()만으로 바로 커널 선점이 일어나지 않을 수 있고, 명시적 양보(Yield) 지점이나 유저 모드 복귀가 필요합니다. 반대로 FULL 계열에서는 이 지점이 실제 선점 발생 후보가 됩니다. 즉, 위 시나리오의 "상태 누적"은 비슷하지만, 언제 실제로 schedule()에 들어가느냐는 선점 모델에 따라 달라집니다.

자주 발생하는 버그 패턴

preempt_count 관련 버그는 발견이 어렵고 재현이 까다로운 경우가 많습니다. 아래는 실제 커널 개발에서 자주 만나는 버그 패턴과 해결 방법입니다.

/* 버그 1: 에러 경로에서 unlock 누락 */
int my_function(void)
{
    spin_lock(&my_lock);      /* preempt_count: 0 → 1 */

    if (some_error_condition()) {
        return -EINVAL;        /* BUG! spin_unlock 없이 반환 */
                                  /* → preempt_count 영구적으로 1 유지 */
                                  /* → 이 CPU에서 선점이 영원히 비활성화! */
    }

    spin_unlock(&my_lock);
    return 0;
}

/* 수정: goto 패턴으로 통일된 해제 경로 */
int my_function_fixed(void)
{
    int ret = 0;
    spin_lock(&my_lock);

    if (some_error_condition()) {
        ret = -EINVAL;
        goto out_unlock;       /* 통일된 해제 경로 */
    }

out_unlock:
    spin_unlock(&my_lock);
    return ret;
}

/* 버그 2: IRQ 핸들러에서 sleep 가능 함수 호출 */
irqreturn_t my_irq_handler(int irq, void *dev)
{
    void *buf = kmalloc(4096, GFP_KERNEL);  /* BUG! IRQ 컨텍스트에서 GFP_KERNEL */
    /* in_irq()=true → preempt_count의 HARDIRQ 필드 ≠ 0 */
    /* GFP_KERNEL은 sleep 가능 → 여기서 schedule() 시도하면 BUG */

    /* 수정: GFP_ATOMIC 사용 */
    buf = kmalloc(4096, GFP_ATOMIC);
    /* 또는: 작업을 workqueue로 이연 (deferred work) */
    return IRQ_HANDLED;
}

/* 버그 3: preempt_disable 구간에서 조건부 sleep */
void process_items(void)
{
    preempt_disable();        /* preempt_count: 0 → 1 */

    struct item *item = per_cpu_ptr(my_items, smp_processor_id());
    /* per-cpu 데이터를 안전하게 읽으려고 preempt_disable */

    if (item->needs_processing) {
        mutex_lock(&item->mutex);  /* BUG! mutex는 sleep 가능 */
        /* preempt_count=1 상태에서 schedule() 시도 → 교착 가능 */
    }

    preempt_enable();

    /* 수정: per-cpu 데이터를 로컬 변수에 복사한 후 lock */
    preempt_disable();
    struct item local_copy = *per_cpu_ptr(my_items, smp_processor_id());
    preempt_enable();          /* 선점 활성화 후 */
    if (local_copy.needs_processing)
        mutex_lock(&item->mutex); /* OK: 프로세스 컨텍스트에서 sleep 가능 */
}
RCU와 preempt_count의 관계

rcu_read_lock()CONFIG_PREEMPT 커널에서 preempt_disable()과 동일하게 preempt_count의 PREEMPT 필드를 증가시킵니다. 이는 RCU 읽기 측 임계 구역에서 선점이 발생하면 grace period 감지가 실패할 수 있기 때문입니다. CONFIG_PREEMPT_RCU에서는 별도의 카운터(rcu_read_lock_nesting)를 사용하여 선점을 허용하면서도 grace period를 올바르게 추적합니다. 자세한 내용은 RCU 문서를 참조하세요.

/* 버그 4: preempt_disable 구간에서 copy_from_user 호출 */
void buggy_read_config(void __user *ubuf, size_t len)
{
    preempt_disable();          /* preempt_count: 0 → 1 */

    /* BUG! copy_from_user는 page fault 발생 가능
     * → page fault handler가 do_page_fault() → handle_mm_fault()
     * → 페이지를 디스크에서 읽어올 때 schedule() 호출
     * → in_atomic() == true → "scheduling while atomic" BUG
     *
     * 커널 로그:
     * BUG: scheduling while atomic: myapp/1234/0x00000001
     * Modules linked in: my_driver
     * Preemption disabled at:
     * [] buggy_read_config+0x8/0x40
     */
    copy_from_user(kernel_buf, ubuf, len);  /* page fault → sleep! */

    preempt_enable();
}

/* 수정: Per-CPU 접근과 사용자 복사를 분리 */
void fixed_read_config(void __user *ubuf, size_t len)
{
    /* 1단계: 사용자 공간 → 커널 버퍼 (선점 가능 상태에서) */
    if (copy_from_user(kernel_buf, ubuf, len))
        return;

    /* 2단계: Per-CPU 데이터 접근 (선점 차단) */
    preempt_disable();
    per_cpu(config_cache, smp_processor_id()) = kernel_buf[0];
    preempt_enable();
}

/* 버그 5: 중첩 lock 해제 순서 오류 (AB-BA deadlock) */
void buggy_nested_lock(void)
{
    /* CPU 0: lock_a → lock_b 순서로 획득 */
    spin_lock(&lock_a);          /* preempt_count: 0→1 */
    spin_lock(&lock_b);          /* preempt_count: 1→2 */

    /* BUG! 해제 순서가 획득 순서와 다름 (ABBA 패턴)
     *
     * 다른 코드 경로에서:
     * CPU 1: spin_lock(&lock_b) → spin_lock(&lock_a)
     *
     * → deadlock 발생!
     * preempt_count는 2로 유지되며 두 CPU 모두 영구 대기
     *
     * lockdep 경고:
     * ======================================================
     * WARNING: possible circular locking dependency detected
     * ------------------------------------------------------
     * CPU0: lock_a → lock_b
     * CPU1: lock_b → lock_a
     */
    spin_unlock(&lock_a);        /* preempt_count: 2→1 (잘못된 순서!) */
    spin_unlock(&lock_b);        /* preempt_count: 1→0 */
}

/* 수정: 일관된 lock 순서 유지 (항상 a → b) */
void fixed_nested_lock(void)
{
    spin_lock(&lock_a);          /* preempt_count: 0→1 */
    spin_lock_nested(&lock_b, SINGLE_DEPTH_NESTING);
    /* ... 작업 ... */
    spin_unlock(&lock_b);        /* LIFO 순서: 마지막 획득 먼저 해제 */
    spin_unlock(&lock_a);        /* preempt_count: 1→0 */
}

/* 버그 6: 모듈 초기화에서 irq_disable 후 preempt_enable 호출 */
static int __init buggy_module_init(void)
{
    unsigned long flags;

    local_irq_save(flags);       /* IRQ 비활성화 (preempt_count 변경 없음) */

    /* 여기서 실수로 preempt_enable() 호출 */
    preempt_enable();
    /* BUG! IRQ가 꺼진 상태에서 preempt_enable이 선점을 시도할 수 있음
     *
     * IRQ 비활성 상태에서 schedule()이 호출되면:
     * BUG: scheduling while atomic: insmod/5678/0x00000000
     * irqs_disabled(): 1
     *
     * 더 심각한 경우: preempt_count 언더플로우
     * → DEBUG_PREEMPT에서 감지:
     * WARNING: bad preempt_count value: -1
     */

    local_irq_restore(flags);
    return 0;
}

/* 수정: preempt/irq 쌍을 올바르게 매칭 */
static int __init fixed_module_init(void)
{
    unsigned long flags;

    local_irq_save(flags);
    /* IRQ 비활성 구간 작업 */
    local_irq_restore(flags);    /* IRQ save/restore 쌍 */

    /* 선점 제어가 필요하면 별도 블록으로 */
    preempt_disable();
    /* ... */
    preempt_enable();             /* disable/enable 쌍 */
    return 0;
}
preempt_count 관련 버그 패턴 분류 preempt_count 관련 버그 Sleep-in-Atomic preempt_disable + copy_from_user (버그 4) IRQ 핸들러 + GFP_KERNEL (버그 2) 카운터 언밸런스 에러 경로에서 enable 누락 (버그 1) irq_save + preempt_enable (버그 6) 교착 / 순서 오류 AB-BA 순서 deadlock (버그 5) 조건부 sleep in preempt_off (버그 3) 진단 도구 CONFIG_DEBUG_PREEMPT: 카운터 언더플로우/오버플로우 감지 CONFIG_DEBUG_ATOMIC_SLEEP (might_sleep): sleep-in-atomic 감지 | CONFIG_PROVE_LOCKING (lockdep): 교착 감지

커널 버전별 preempt_count 진화

preempt_count와 커널 선점 메커니즘은 20년 이상에 걸쳐 점진적으로 발전해왔습니다. 초기의 단순한 선점 카운터에서 시작하여, 런타임 전환, Per-CPU 최적화, 실시간 지원, 지연 선점까지 — 각 버전의 핵심 변경과 그 동기를 정리합니다.

preempt_count 진화 타임라인 (2.6 → 6.13+) 2.6.x (2003) CONFIG_PREEMPT 도입 preempt_count 최초 구현 spin_unlock 선점 검사 3.x (2011) PREEMPT_VOLUNTARY 안정화 (데스크톱 기본) cond_resched() 확산 4.x (2015) PREEMPT_COUNT 기본 ON x86: Per-CPU 변수 이전 gs 세그먼트 직접 접근 5.x (2019) PREEMPT_DYNAMIC 도입 migrate_disable() 도입 5.14: static_call 기반 런타임 모델 전환 6.x (2022) PREEMPT_RT 메인라인 머지 시작 (단계적) sleeping spinlock 공식화 6.12+ (2024) PREEMPT_LAZY 도입 bit 21 (lazy resched) 6.13+: 통합 선점 모델 NONE/VOLUNTARY 제거 논의 핵심 흐름: 컴파일 타임 고정 → 런타임 전환 → 하드웨어 비트 활용 → 통합 모델 preempt_count 비트 필드 진화 2.6: PREEMPT(8) + SOFTIRQ(8) + HARDIRQ(4) + NEED_RESCHED(1) 5.x: + NMI(1) 필드 추가, 비트 폭 아키텍처별 조정 6.12: + NEED_RESCHED_LAZY(bit 21) 추가 저장 방식 진화 2.6: thread_info (스택 하단) 내 필드 4.x: x86 Per-CPU 변수 (gs 세그먼트) 5.x+: ARM64/RISC-V는 task_struct 내장 유지 미래 방향 (6.13+ 논의 중) PREEMPT_LAZY가 FULL의 상위 호환 → NONE/VOLUNTARY/FULL을 LAZY 하나로 통합 가능 cond_resched()를 NOP으로 만들고, 모든 선점 검사를 LAZY/IMMEDIATE 이분법으로 단순화 배포판: 단일 커널 + preempt= 부팅 파라미터로 서버~데스크톱~RT 전체 커버

각 버전의 핵심 변경 상세

2.6.x (2003): Robert Love의 패치로 커널 선점이 최초 도입되었습니다. CONFIG_PREEMPT 옵션을 켜면 spin_unlock()preempt_count가 0이 될 때 선점 검사가 수행됩니다. 이전에는 커널 코드가 명시적으로 schedule()을 호출하거나 사용자 공간으로 복귀할 때만 스케줄링이 가능했습니다.

4.x (2015~): x86에서 preempt_countthread_info(스택 기반)에서 Per-CPU 변수로 이전했습니다. gs 세그먼트 레지스터를 통한 직접 접근으로 캐시 효율이 개선되고, 스택 오버플로우 공격 시 preempt_count 조작이 불가능해져 보안도 강화되었습니다.

5.x ~ 5.14 (2019~2021): PREEMPT_DYNAMIC이 도입되어 부팅 시 preempt= 파라미터로 선점 모델을 전환할 수 있게 되었습니다. static_call 메커니즘을 사용하여 런타임 분기 비용을 제거했으며, migrate_disable()이 추가되어 PREEMPT_RT에서의 Per-CPU 데이터 보호 패턴이 확립되었습니다.

6.x (2022~): 20년간 별도 패치셋으로 유지되던 PREEMPT_RT가 메인라인에 단계적으로 머지되기 시작했습니다. spin_lock이 RT 커널에서 sleeping mutex로 변환되는 인프라가 공식화되었습니다.

6.12 (2024): Peter Zijlstra의 PREEMPT_LAZY 패치가 머지되었습니다. preempt_count의 bit 21을 활용하여 "급하지 않은" 선점 요청을 표시하고, spin_unlock이 아닌 자연스러운 선점 지점(사용자 복귀, cond_resched())에서만 처리합니다. 이로써 FULL 모델의 응답성을 유지하면서도 불필요한 컨텍스트 스위치를 대폭 줄였습니다.

6.13+ (논의 중): PREEMPT_LAZY가 충분히 범용적이라면, 기존의 NONE/VOLUNTARY/FULL 구분을 없애고 LAZY 기반의 통합 선점 모델로 단순화하자는 논의가 진행 중입니다. cond_resched()를 NOP으로 만들 수 있다면 커널 코드에 산재한 수천 개의 cond_resched() 호출을 제거할 수 있어, 코드 유지보수성이 크게 개선됩니다.

참고 자료

커널 공식 문서

LWN.net 기사

커널 소스 코드