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 진단까지 코드 흐름 중심으로 통합 정리합니다.
preempt_count는 "현재 CPU가 어떤 문맥에 있는지", "지금 당장 스케줄러에 CPU를 넘겨도 되는지", "인터럽트 하위 반이 막혀 있는지"를 한 번에 담는 값이라서, 선점과 인터럽트와 락의 기초 개념이 같이 잡혀 있어야 전체 흐름이 보입니다.
preempt_count는 공용 정비 구역 입구에 붙은 작업 상태판과 비슷합니다. "정비공이 작업 중인지", "긴급 호출이 들어왔는지", "하위 작업반 출입을 막아 두었는지", "일반 정비가 아니라 비상 정비인지"를 색깔 칸 하나에 같이 적어 둔 셈입니다. 스케줄러는 이 상태판을 보고 "지금 다른 사람을 들여보내도 되는가"를 결정합니다.
핵심 요약
- preempt_count — 현재 실행 문맥과 선점 금지 깊이를 함께 담는 32비트 상태 값입니다.
- PREEMPT 필드 —
preempt_disable(), 일반적인spin_lock()같은 "선점 금지" 중첩 횟수를 나타냅니다. - SOFTIRQ/HARDIRQ/NMI 필드 — 단순히 "락을 잡았다"가 아니라 인터럽트 문맥 자체인지까지 구분해 줍니다.
- need_resched 결합 — 이 문서에서 설명하는 빠른 경로는 "선점 가능"과 "재스케줄 필요"를 하나의 워드 비교로 확인하도록 설계되어 있습니다.
- 균형 유지 — 증가시킨 필드는 반드시 같은 경로에서 정확히 감소시켜야 하며, 한 번이라도 어긋나면 선점이 영구히 막히거나 디버그 경고가 발생합니다.
단계별 이해
- 평상시 상태
프로세스 컨텍스트에서 락도 없고 인터럽트 문맥도 아니면preempt_count의 주요 필드는 0입니다. 이때가 가장 자유로운 상태입니다. - 임계 구역 진입
preempt_disable()또는 일반적인spin_lock()이 PREEMPT 필드를 올려 "잠깐만, 지금은 중간에 끊으면 안 됨"을 표시합니다. - 인터럽트 진입
IRQ가 들어오면 HARDIRQ 필드가 올라가고, softirq 처리나 NMI 진입이면 각각 자기 필드가 설정됩니다. 이제 현재 코드는 특정 태스크(Task)의 sleep 가능한 문맥이 아닙니다. - 재스케줄 요청 누적
다른 태스크가 깨어나 우선순위(Priority)가 더 높아져도, 당장 스케줄하지 못하면 "필요함" 표시만 남겨 둡니다. 이것이 선점 지연(deferred preemption)입니다. - 마지막 금지 요인 해제
irq_exit(),local_bh_enable(),preempt_enable(),spin_unlock()같은 경로가 마지막 카운터를 0으로 만들면 그때 비로소 스케줄러 진입 여부를 판단합니다.
개요: 선점의 개념
커널 선점(Kernel Preemption)이란 커널 모드에서 실행 중인 태스크를 더 높은 우선순위의 태스크가 강제로 중단시키고 CPU를 빼앗는 메커니즘입니다. 유저 모드 선점은 syscall 반환이나 인터럽트 반환 시점에서 자연스럽게 발생하지만, 커널 모드 선점은 추가적인 안전 장치가 필요합니다.
왜 커널 모드에서 무조건 선점하면 안 될까요? 커널 코드는 공유 자료 구조(예: 런큐(Runqueue), 페이지 테이블(Page Table), 파일시스템(Filesystem) 캐시(Cache))를 조작하는 도중에 중단되면 일관성이 깨질 수 있기 때문입니다. 유저 공간에서는 각 프로세스가 독립적인 주소 공간(Address Space)을 갖지만, 커널은 모든 프로세스가 동일한 커널 주소 공간을 공유합니다. 따라서 커널은 "지금 선점해도 안전한가?"를 매 시점에 판단해야 하며, 이 판단의 핵심이 바로 preempt_count입니다.
preempt_count는 이 안전 장치의 핵심으로, 다음 세 가지 질문에 동시에 답합니다:
- 현재 어떤 컨텍스트인가? — hardirq, softirq, NMI, 또는 프로세스 컨텍스트
- 선점이 안전한가? — 선점 비활성화 중첩 깊이, 인터럽트 컨텍스트 여부
- 선점이 필요한가? — 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 필드 +1 | softirq 핸들러(Handler) 실행 억제 (하위 반 처리 방지) |
| hardirq 핸들러 내부 | HARDIRQ 필드 +1 | 인터럽트 핸들러는 특정 태스크에 속하지 않음 (스케줄 불가) |
| NMI 핸들러 내부 | NMI 비트 =1 | 마스크 불가 인터럽트 — 가장 제한적 컨텍스트 |
syscall 반환, irq_exit)는 preempt_count 검사 없이 need_resched만 확인하면 됩니다. 유저 모드 코드는 커널 자료 구조를 조작하지 않으므로 항상 안전하게 선점할 수 있습니다. preempt_count가 중요한 것은 오직 커널 모드에서 커널 모드로 돌아가야 할 때뿐입니다.
핵심 불변식과 빠른 판독 규칙
preempt_count를 단순히 "atomic context 여부를 나타내는 카운터" 정도로 이해하면 실전에서 자주 막힙니다. 실제로는 서로 다른 종류의 진입 금지 이유를 한 워드에 압축한 상태 벡터에 가깝습니다. 선점 금지, 하위 반 금지, hardirq 진입, NMI 진입은 서로 다른 사건이므로 각기 독립적으로 중첩되어야 하고, 인터럽트 반환이나 preempt_enable() 같은 빠른 경로에서는 이를 거의 분기 없이 판독해야 합니다.
이 설계가 중요한 이유는 스케줄러와 인터럽트 반환 경로가 커널에서 가장 뜨거운 경로이기 때문입니다. 불리언 플래그 여러 개를 따로 읽는 대신 한 번 로드하고 몇 개의 마스크 연산만 수행하면, "지금은 절대 스케줄하면 안 되는가", "아직 softirq가 남아 있는가", "하드 IRQ 문맥인가", "마지막 금지 요인이 막 해제되었는가"를 즉시 판단할 수 있습니다.
- 필드는 서로 독립적입니다. PREEMPT, SOFTIRQ, HARDIRQ, NMI는 덮어쓰는 값이 아니라 동시에 살아 있을 수 있는 중첩 축입니다. 예를 들어
spin_lock()을 잡은 코드가 IRQ에 의해 끊기면 PREEMPT와 HARDIRQ가 동시에 비영이 됩니다. - 더 높은 문맥이 더 강한 제약을 뜻합니다. HARDIRQ나 NMI 비트가 살아 있으면 PREEMPT 필드가 0이더라도 스케줄할 수 없습니다. "선점 금지 카운터가 0인가"만 보면 부족하고, 어떤 종류의 문맥인지까지 같이 봐야 합니다.
- 전체 워드가 0이 되는 순간이 가장 중요합니다. 이 문서에서 설명하는 빠른 경로는 "모든 금지 이유가 해제되었고 지금 재스케줄이 필요함"을 전체 워드 0으로 표현합니다. 그래서 마지막
preempt_enable()또는 인터럽트 반환 직후의 한 번 비교가 결정적입니다. - 불균형은 즉시가 아니라 나중에 터집니다.
spin_unlock()하나를 빼먹어도 당장 패닉이 안 날 수 있습니다. 대신 해당 CPU 또는 해당 태스크의 선점이 영구히 늦춰져 수 초 뒤 완전히 다른 위치에서 지연, 락업,BUG: scheduling while atomic로 드러납니다. - IRQ 비활성화와 preempt_count는 별개 축입니다.
local_irq_disable()는 CPU 플래그를 바꾸지만 보통 PREEMPT/SOFTIRQ/HARDIRQ 필드를 직접 증가시키지 않습니다. 따라서preempt_count만 읽고 안전 여부를 완전히 판단할 수는 없고,preemptible()가irqs_disabled()를 함께 보는 이유가 여기에 있습니다. - 이 값은 "현재 CPU의 지금 시점"을 설명합니다. 다른 CPU에서 더 높은 우선순위 태스크가 깨어났더라도, 현재 CPU가 락을 쥐고 있거나 IRQ 문맥이면 재스케줄은 연기됩니다. 즉, 깨어난 사실과 실제 문맥 전환(Context Switch) 시점은 분리되어 있습니다.
실전에서 특히 자주 보는 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 +1 | softirq와 네트워크 하위 반 실행을 지연시킴 | 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 기준 비트 레이아웃입니다.
/* 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_MASK는0xFF,NMI_MASK는0x100000입니다.preempt_count() & HARDIRQ_MASK처럼 AND 연산으로 특정 필드 값만 읽습니다. - PREEMPT_NEED_RESCHED비트 31을 반전 저장 플래그로 사용합니다. 0이면 리스케줄 필요, 1이면 불필요를 의미합니다. 이로써
preempt_count == 0단일 비교로 "선점 가능 + 리스케줄 필요"를 동시에 판단할 수 있어, x86에서test %reg, %reg한 명령으로 최적화됩니다.
preempt_count 전체가 정확히 0인지만 검사하면 "선점 가능 + 리스케줄 필요"를 한 번에 판단할 수 있습니다. bit 31이 0이면 TIF_NEED_RESCHED가 설정된 상태이므로, test %reg, %reg; jz preempt_schedule 단 두 명령으로 최적 경로가 완성됩니다.
실제 preempt_count 값 예시
아래 다이어그램은 다양한 실행 컨텍스트에서 preempt_count의 실제 비트 값과 16진수 표현을 보여줍니다. 각 상황에서 어떤 비트 영역이 활성화되는지 직관적으로 확인할 수 있습니다.
컨텍스트 판별 매크로
커널 코드는 현재 실행 컨텍스트를 확인하기 위해 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()는 local_bh_disable() 구간에서도 true를 반환합니다. 실제 softirq 핸들러 내부인지 확인하려면 in_serving_softirq()를 사용해야 합니다. 네트워크 드라이버에서 이 차이를 간과하면 동기화 로직에서 미묘한 버그가 발생합니다.
아래 표는 각 컨텍스트에서 매크로 반환값과 허용되는 동작을 정리합니다:
| 컨텍스트 | in_irq | in_softirq | in_interrupt | in_nmi | in_atomic | sleep 가능 |
|---|---|---|---|---|---|---|
| 프로세스 (선점 가능) | 0 | 0 | 0 | 0 | 0 | O |
| preempt_disable 구간 | 0 | 0 | 0 | 0 | 1 | X |
| local_bh_disable 구간 | 0 | 1 | 1 | 0 | 1 | X |
| softirq 핸들러 | 0 | 1 | 1 | 0 | 1 | X |
| hardirq 핸들러 | 1 | 0 | 1 | 0 | 1 | X |
| NMI 핸들러 | 0 | 0 | 1 | 1 | 1 | X |
in_atomic()은 CONFIG_PREEMPT_COUNT가 활성화된 커널에서만 정확합니다. CONFIG_PREEMPT_NONE 커널에서는 preempt_count가 IRQ/NMI 필드만 추적하므로, spin_lock() 구간에서도 in_atomic()이 false를 반환할 수 있습니다. 드라이버에서 in_atomic()을 기반으로 할당 플래그를 선택하는 것은 권장되지 않으며, 설계 시점에서 컨텍스트를 명확히 아는 것이 올바른 접근입니다.
매크로 사용 시 주의사항과 실전 패턴
컨텍스트 판별 매크로를 올바르게 사용하려면 몇 가지 미묘한 차이를 이해해야 합니다:
/* 패턴 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(); /* ③ 여기는 ② 이후 보장 */
/* 위 타임라인에 해당하는 코드 */
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()은 내부적으로 preempt_disable()을 호출합니다. UP(단일 프로세서) 커널에서는 실제 스핀 대기 없이 preempt_disable()만 수행되어 임계 구역을 보호합니다. SMP 커널에서도 선점 비활성화는 spinlock의 필수 전제 조건입니다 — 선점이 가능한 상태에서 spinlock을 보유하면, 선점된 태스크가 동일 CPU에서 다시 그 lock을 잡으려 할 때 교착 상태(Deadlock)가 됩니다.
- 반드시 쌍으로 사용: 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의 활용 범위와 선점 검사 빈도에 직접 영향을 미칩니다.
/* 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로 이 전환이 런타임에 이루어집니다.
CONFIG_PREEMPT_RT에서 일반 spinlock_t는 rt_mutex 기반의 sleeping lock으로 변환됩니다. 따라서 spin_lock() 구간에서도 선점이 가능해지며, preempt_count의 PREEMPT 필드가 증가하지 않습니다. 하드 IRQ 컨텍스트에서 반드시 원래의 스핀 동작이 필요한 경우 raw_spinlock_t를 사용해야 합니다.
선점 모델별 동작 타임라인
동일한 커널 코드가 실행될 때 선점 모델에 따라 선점 발생 시점이 어떻게 달라지는지 아래 타임라인으로 비교합니다. 높은 우선순위 태스크(RT)가 깨어나는 시점을 기준으로, 실제 CPU를 획득하기까지의 지연 시간이 핵심 차이입니다.
스케줄러 클래스별 선점 판단
선점은 단일 메커니즘이 아닙니다. 커널의 각 스케줄러 클래스(Scheduler Class)는 고유한 check_preempt_curr() 콜백을 구현하며, 태스크가 깨어날 때 현재 실행 중인 태스크를 선점할지 여부를 클래스별로 다르게 판단합니다. 이 판단 결과가 resched_curr() → set_tsk_need_resched() → preempt_count bit 31 설정으로 이어집니다.
/* 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(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_none
cond_resched만 활성화하고,preempt_schedule과preempt_schedule_notrace를 NULL(NOP)로 설정합니다. 커널 내부에서 선점이 발생하지 않으며,might_resched도 비활성화됩니다. 서버 워크로드에 최적입니다. - preempt_dynamic_voluntary
cond_resched와might_resched를 모두 활성화하되,preempt_schedule은 여전히 NULL입니다.might_sleep()지점에서 자발적 양보가 추가되어 NONE보다 응답성이 좋습니다. - preempt_dynamic_full
preempt_schedule과preempt_schedule_notrace를 실제 함수로 설정하고,cond_resched와might_resched는 NOP(__static_call_return0)으로 비활성화합니다. 모든preempt_enable()지점에서 선점이 가능해지므로 저지연 워크로드에 적합합니다.
# 부팅 파라미터로 선점 모델 선택
# /etc/default/grub 또는 커널 커맨드 라인
preempt=none # 서버 최적화
preempt=voluntary # 데스크톱 (기본값)
preempt=full # 저지연
# 현재 모델 확인
cat /sys/kernel/debug/sched/preempt
# 출력 예: "full (dynamic)"
PREEMPT_DYNAMIC은 static_call을 사용하여 간접 호출(function pointer) 대신 직접 호출 명령어를 런타임에 패치(Patch)합니다. 따라서 모드 전환 후에도 호출 오버헤드(Overhead)가 없으며, PREEMPT_NONE 모드에서 preempt_schedule() 호출 자체가 NOP로 대체됩니다. x86에서는 call 명령을 nop으로, ARM64에서는 bl을 nop으로 바꿉니다.
/* 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은 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이 됩니다. */
자세한 사용 패턴과 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()으로 할당 플래그를 동적으로 결정하는 패턴(GFP_ATOMIC vs GFP_KERNEL)은 커널 커뮤니티에서 안티패턴으로 간주됩니다. CONFIG_PREEMPT_NONE 커널에서 in_atomic()이 spin_lock 구간을 감지하지 못하기 때문입니다. 올바른 접근은 함수 설계 시점에서 호출 컨텍스트를 명확히 정의하고, gfp_t 플래그를 매개변수로 전달받는 것입니다.
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) */
preempt_count는 인터럽트 핸들러(irq_enter/irq_exit)에서도 조작되므로, 락 없이 접근해야 합니다. Per-CPU 변수는 현재 CPU만 쓰기 때문에 원자적 연산(Atomic Operation)이 불필요하고, x86에서는 GS 세그먼트를 통한 단일 명령어로 접근할 수 있어 오버헤드가 거의 없습니다.
RISC-V에서의 preempt_count 접근
RISC-V 아키텍처는 tp(thread pointer, x4) 레지스터를 통해 현재 태스크의 task_struct에 접근합니다. ARM64가 sp_el0를 사용하는 것과 유사한 접근 방식으로, thread_info를 task_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)로 접근하고, ARM64와 RISC-V는 task_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;
/* ... */
}
PREEMPT_DISABLED(값 1)로 초기화됩니다. idle 태스크는 schedule()을 직접 호출하므로 선점될 필요가 없으며, preempt_count가 1인 상태로 유지됩니다. CPU가 idle 상태일 때 인터럽트로 깨어나면, IRQ 반환 경로에서 need_resched를 확인하여 새 태스크로 전환합니다.
PREEMPT_LAZY (커널 6.12+)
커널 6.12에서 도입된 PREEMPT_LAZY는 preempt_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 선점과 동일하게 유지하면서, 일반 태스크의 불필요한 컨텍스트 스위치를 줄여 처리량을 개선합니다.
spin_unlock()마다 preempt_schedule()이 호출되어 불필요한 컨텍스트 스위치가 발생할 수 있습니다. PREEMPT_LAZY는 일반 태스크에 대해 선점을 다음 tick까지 지연시켜 처리량을 개선하면서, RT 태스크에 대해서는 즉시 선점을 보장합니다. 이로써 NONE과 FULL의 장점을 통합하는 것이 장기적 목표입니다.
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 마이그레이션만 차단하는 경량 메커니즘입니다.
/* 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 연산 사용 권장 */
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 트레이서는 선점이 비활성화된 가장 긴 구간을 기록합니다. tracing_max_latency 값이 예상보다 크다면 해당 스택 트레이스를 분석하여 병목(Bottleneck) spinlock이나 과도한 preempt_disable 구간을 식별할 수 있습니다. RT 시스템에서는 이 값이 수십 마이크로초를 넘지 않아야 합니다.
실전 디버깅 예제: 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_count에HARDIRQ_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이며, 거의 모든 커널 기능이 사용 불가합니다.
IPI 기반 원격 선점 요청
스케줄러가 태스크를 깨울 때, 해당 태스크가 다른 CPU에서 실행되어야 한다면 단순히 need_resched 비트를 설정하는 것만으로는 부족합니다. 원격 CPU는 자신의 preempt_count를 직접 검사하지 않으면 선점 요청을 인지할 수 없기 때문입니다. 이때 IPI(Inter-Processor Interrupt)를 통해 원격 CPU에 "재스케줄하라"는 인터럽트를 보냅니다.
/* 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 모델에서는 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() 검사가 모두 들어 있습니다.
- 시작: 태스크 A가 시스템 콜(System Call) 내부에서 실행 중이고, 아직 재스케줄 필요는 없다고 가정합니다. 이 문서의 표기에서는 전체 값이 대략
0x80000000인 상태입니다. - 락 획득: 태스크 A가
spin_lock()을 잡으면 PREEMPT 필드가 올라가0x80000001이 됩니다. 이제 더 높은 우선순위 태스크가 준비되어도 즉시 CPU를 넘길 수 없습니다. - 하드 IRQ 진입: 장치 인터럽트가 들어오면
irq_enter()가 HARDIRQ 필드를 올려0x80010001처럼 보입니다. 이 순간 현재 CPU는 "락을 가진 채 하드 IRQ 문맥 위에 올라탄 상태"입니다. - 더 높은 우선순위 태스크 깨움: IRQ 핸들러가 태스크 B를 깨워 즉시 실행시키고 싶어도, 현재는 hardirq 문맥이므로 스케줄할 수 없습니다. 대신 재스케줄 필요 표시만 남기고 값은
0x00010001쪽으로 바뀝니다. - IRQ 종료:
irq_exit()가 HARDIRQ 필드를 내리면0x00000001이 됩니다. 여전히 PREEMPT 값이 1이므로, "재스케줄 필요"는 알지만 아직 원래 락 보호 구역이 끝나지 않았습니다. - 원래 커널 코드 복귀: 인터럽트 전의 시스템 콜 코드가 이어서 실행됩니다. 이때 태스크 A는 이미 CPU를 넘겨야 한다는 사실을 알고 있지만, lock을 쥔 동안은 끝까지 정리해야 합니다.
- 마지막 unlock:
spin_unlock()이 PREEMPT 필드를 0으로 내리면 전체 워드가0x00000000이 되고, 바로 이 지점에서 빠른 경로 선점 검사가 true가 됩니다. - 문맥 전환: 스케줄러가 태스크 B로 전환합니다. 즉, 인터럽트가 들어온 시점이 아니라 마지막 금지 요인이 사라진 시점이 실제 문맥 전환 시점입니다.
| 단계 | 대표 값 | 무슨 일이 벌어졌는가 | 그 자리에서 스케줄 가능한가 |
|---|---|---|---|
| 시작 | 0x80000000 | 프로세스 컨텍스트, 재스케줄 불필요 | 필요하지 않음 |
| 락 획득 | 0x80000001 | PREEMPT 필드가 올라가 임계 구역 보호 시작 | 불가 |
| IRQ 진입 | 0x80010001 | HARDIRQ까지 겹쳐져 인터럽트 문맥 위에 올라감 | 불가 |
| 태스크 B 깨움 | 0x00010001 | 재스케줄 요청은 생겼지만 hardirq와 lock 때문에 지연 | 불가 |
| IRQ 종료 | 0x00000001 | HARDIRQ는 사라졌지만 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() 또는 대응 경로로 진입할 수 있음 */
}
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_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와 커널 선점 메커니즘은 20년 이상에 걸쳐 점진적으로 발전해왔습니다. 초기의 단순한 선점 카운터에서 시작하여, 런타임 전환, Per-CPU 최적화, 실시간 지원, 지연 선점까지 — 각 버전의 핵심 변경과 그 동기를 정리합니다.
각 버전의 핵심 변경 상세
2.6.x (2003): Robert Love의 패치로 커널 선점이 최초 도입되었습니다. CONFIG_PREEMPT 옵션을 켜면 spin_unlock() 후 preempt_count가 0이 될 때 선점 검사가 수행됩니다. 이전에는 커널 코드가 명시적으로 schedule()을 호출하거나 사용자 공간으로 복귀할 때만 스케줄링이 가능했습니다.
4.x (2015~): x86에서 preempt_count를 thread_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() 호출을 제거할 수 있어, 코드 유지보수성이 크게 개선됩니다.
참고 자료
커널 공식 문서
- Scheduler — kernel.org — 커널 공식 스케줄러 문서 인덱스로, 선점 모델과 스케줄링 정책 전반을 다룹니다
- Proper Locking Under a Preemptible Kernel — kernel.org — 선점 가능 커널에서의 올바른 잠금 기법과 preempt_disable/enable 사용 가이드입니다
- RCU Requirements — kernel.org — RCU와 선점 비활성화의 관계, rcu_read_lock이 preempt_count에 미치는 영향을 설명합니다
- Lock types and their rules — kernel.org — PREEMPT_RT 환경에서 spinlock이 sleeping lock으로 변환되는 규칙과 선점 카운터 동작을 정리합니다
LWN.net 기사
- A new kernel preemption model (LWN, 2020) — PREEMPT_DYNAMIC 도입 배경과 런타임 선점 모델 전환 메커니즘을 상세히 다룹니다
- Schedulers, preemption, and the EEVDF (LWN, 2023) — EEVDF 스케줄러와 선점 판단 기준의 변화를 설명합니다
- PREEMPT_LAZY and the future of preemption (LWN, 2024) — PREEMPT_LAZY 모델의 설계 철학과 기존 선점 모델 통합 전망을 분석합니다
- Preemption and kernel design (LWN, 2013) — 커널 선점의 역사적 발전과 VOLUNTARY/FULL 선점 모델의 트레이드오프를 정리합니다
- An end to cond_resched()? (LWN, 2024) — cond_resched() 제거 논의와 PREEMPT_LAZY 기반 통합 선점 모델의 가능성을 다룹니다
- Preempt_count and PREEMPT_DYNAMIC (LWN, 2020) — preempt_count의 비트 필드 구조와 static_call 기반 동적 선점 전환의 구현 세부 사항을 설명합니다
- A realtime preemption overview (LWN, 2005) — Ingo Molnar의 PREEMPT_RT 패치셋이 preempt_count와 잠금 체계에 미치는 영향을 다룬 초기 문서입니다
커널 소스 코드
- include/linux/preempt.h — preempt_count 매크로, preempt_disable/enable, in_atomic, in_interrupt 등 핵심 인라인 함수가 정의되어 있습니다
- kernel/sched/core.c — schedule(), preempt_schedule(), preempt_schedule_irq() 등 선점 스케줄링 진입점 구현입니다
- include/asm-generic/preempt.h — 아키텍처 공통 preempt_count 접근 함수(preempt_count_set, preempt_count_add 등)가 정의되어 있습니다
- arch/x86/include/asm/preempt.h — x86 아키텍처의 Per-CPU preempt_count 접근 최적화(raw_cpu_read_4 등) 구현입니다
- include/linux/sched.h — task_struct 내 thread_info와 preempt_count 필드의 위치 및 관련 플래그 정의입니다
- kernel/softirq.c — local_bh_disable/enable 구현과 softirq 컨텍스트에서의 preempt_count 조작 코드입니다