프로세스 관리 (Process Management)

Linux 커널의 프로세스 관리 전반을 task_struct 생명주기 기준으로 다룹니다. fork/clone/execve 경로와 Copy-on-Write, PID 및 네임스페이스, cgroup 제어와 스케줄링 클래스 상호작용, 컨텍스트 스위칭/시그널/종료 처리, CPU affinity와 로드 밸런싱, 실전 트레이싱 포인트까지 심층적으로 정리합니다.

관련 표준: POSIX.1-2017 (프로세스 모델, 시그널), ELF (실행 파일 형식), System V ABI (호출 규약) — 커널 프로세스 관리가 따르는 핵심 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: 커널 아키텍처메모리 관리 (기초) 문서를 먼저 읽으세요. 프로세스 관리는 task_struct와 주소 공간 개념이 핵심이므로, 커널 구조와 메모리 레이아웃을 먼저 이해하면 효과적입니다.
일상 비유: 프로세스 관리는 레스토랑 주방의 주문 관리와 비슷합니다. task_struct는 각 주문서, 스케줄러는 주문을 요리사(CPU)에게 배분하는 관리자, 컨텍스트 스위치는 요리사가 다른 요리로 전환하는 것입니다. fork()는 주문서 복사, exec()는 메뉴를 완전히 다른 것으로 바꾸는 것과 같습니다.

핵심 요약

  • task_struct — 커널이 프로세스/스레드를 관리하는 핵심 자료구조. 상태, PID, 메모리, 파일 정보를 모두 담습니다.
  • fork() — 현재 프로세스를 복제하여 자식 프로세스를 생성합니다. Copy-on-Write로 효율적입니다.
  • exec() — 프로세스의 메모리 이미지를 새 프로그램(ELF)으로 교체합니다.
  • 컨텍스트 스위치 — CPU 레지스터를 저장/복원하여 다른 프로세스로 전환하는 과정입니다.
  • 커널 스레드 — 사용자 공간 없이 커널 내에서만 동작하는 스레드입니다 (kworker, kswapd 등).

단계별 이해

  1. task_struct 파악ps aux로 보이는 모든 프로세스는 커널 내부에서 task_struct 하나로 표현됩니다.

    /proc/<pid>/status에서 프로세스의 상태, 메모리, 스레드 정보를 확인할 수 있습니다.

  2. fork 이해fork()는 부모의 페이지 테이블을 복사하되, 실제 메모리는 Write 시에만 복사합니다(CoW).

    이 덕분에 fork는 매우 빠르고, 자식이 즉시 exec()하면 복사가 거의 발생하지 않습니다.

  3. exec 이해execve()는 ELF 파일을 파싱하여 코드/데이터 세그먼트를 새로 매핑합니다.

    기존 메모리 매핑은 모두 해제되고 새 프로그램의 진입점(_start)부터 실행됩니다.

  4. 프로세스 계층 — 모든 프로세스는 PID 1(init/systemd)을 루트로 하는 트리 구조를 형성합니다.

    pstree 명령어로 프로세스 트리를 시각적으로 확인할 수 있습니다.

task_struct 구조체 (Task Descriptor)

Linux 커널에서 프로세스(또는 스레드)를 표현하는 핵심 자료구조는 struct task_struct입니다. 이 구조체는 include/linux/sched.h에 정의되어 있으며, 커널이 프로세스를 관리하는 데 필요한 모든 정보를 담고 있습니다. 크기가 수 킬로바이트에 달하는 대형 구조체로, 프로세스의 상태, 스케줄링 정보, 메모리 디스크립터, 파일 디스크립터 테이블, 시그널 핸들러, credentials 등을 포함합니다.

핵심 필드 분석 (Key Fields)

다음은 task_struct의 주요 필드들을 기능별로 분류한 것입니다.

필드 타입 설명
state / __state unsigned int 프로세스의 현재 상태 (TASK_RUNNING, TASK_INTERRUPTIBLE 등)
flags unsigned int 프로세스 플래그 (PF_EXITING, PF_KTHREAD 등)
prio / static_prio / normal_prio int 스케줄링 우선순위 (동적/정적/정규 우선순위)
sched_class const struct sched_class * 해당 태스크가 사용하는 스케줄링 클래스
se struct sched_entity CFS 스케줄링 엔티티 (vruntime 등 포함)
mm struct mm_struct * 메모리 디스크립터 (유저 프로세스의 주소 공간)
active_mm struct mm_struct * 실제 사용 중인 mm (커널 스레드는 이전 태스크의 mm을 빌림)
pid / tgid pid_t 프로세스 ID / 스레드 그룹 ID
real_parent / parent struct task_struct * 실제 부모 프로세스 / ptrace 부모
children / sibling struct list_head 자식 프로세스 리스트 / 형제 프로세스 리스트
files struct files_struct * 열린 파일 디스크립터 테이블
fs struct fs_struct * 파일시스템 정보 (root, pwd)
signal struct signal_struct * 시그널 핸들러 정보 (스레드 그룹 공유)
nsproxy struct nsproxy * 네임스페이스 프록시 (PID/mount/net/user NS 등)
thread_info struct thread_info 아키텍처 의존적 스레드 정보 (커널 스택과 연결)
cred const struct cred * 프로세스 자격 증명 (UID, GID, capabilities)

task_struct 할당 (Allocation)

task_struct는 slab allocator를 통해 할당됩니다. 커널 초기화 시 task_struct 전용 kmem_cache가 생성되며, 새로운 프로세스 생성 시 alloc_task_struct_node()를 통해 할당합니다. 커널 스택은 별도로 할당되며, x86_64의 경우 기본 16KB(4페이지)입니다. thread_info 구조체는 최근 커널에서 task_struct 내부에 임베딩되어 있습니다.

/* task_struct 할당 - kernel/fork.c */
static struct kmem_cache *task_struct_cachep;

void __init fork_init(void)
{
    task_struct_cachep = kmem_cache_create("task_struct",
        arch_task_struct_size, align,
        SLAB_PANIC|SLAB_ACCOUNT, NULL);
}

/* 현재 프로세스의 task_struct에 접근 */
struct task_struct *current = get_current();
printk(KERN_INFO "PID: %d, comm: %s\\n",
       current->pid, current->comm);
ℹ️

current 매크로: current는 현재 CPU에서 실행 중인 프로세스의 task_struct 포인터를 반환하는 매크로입니다. x86_64에서는 per-CPU 변수인 current_task를 통해 접근하며, GS 세그먼트 레지스터를 활용합니다.

프로세스 상태 (Process States)

Linux 커널의 프로세스는 다음과 같은 상태를 가질 수 있으며, task_struct.__state 필드에 비트마스크로 저장됩니다.

상태 설명
TASK_RUNNING 0x00000000 실행 중이거나 런큐에서 실행 대기 중. CPU를 할당받을 수 있는 유일한 상태
TASK_INTERRUPTIBLE 0x00000001 슬립 상태. 시그널 또는 대기 조건 충족 시 깨어남
TASK_UNINTERRUPTIBLE 0x00000002 시그널로 깨울 수 없는 슬립 상태. 디스크 I/O 대기 시 주로 사용 (D state)
__TASK_STOPPED 0x00000004 SIGSTOP/SIGTSTP 등에 의해 중지된 상태
__TASK_TRACED 0x00000008 ptrace에 의해 트레이싱 중인 상태
EXIT_DEAD 0x00000010 최종 종료 상태. task_struct가 곧 해제됨
EXIT_ZOMBIE 0x00000020 종료되었으나 부모가 wait()을 호출하지 않은 상태
TASK_KILLABLE TASK_WAKEKILL | TASK_UNINTERRUPTIBLE 치명적 시그널(SIGKILL)에만 반응하는 슬립 상태

상태 전이 다이어그램 (State Transition Diagram)

TASK_RUNNING (실행/대기) TASK_INTERRUPTIBLE TASK_UNINTERRUPTIBLE __TASK_STOPPED __TASK_TRACED EXIT_ZOMBIE EXIT_DEAD schedule() wake_up() I/O wait 완료 SIGSTOP SIGCONT ptrace resume do_exit() wait()
프로세스 상태 전이 다이어그램 - 각 상태 간 전이 조건 표시
⚠️

D state 주의: TASK_UNINTERRUPTIBLE(D state)인 프로세스는 kill -9로도 종료시킬 수 없습니다. 이 상태가 장기간 지속되면 시스템 문제를 의심해야 합니다. 커널 5.14 이후에는 TASK_KILLABLE을 적극 활용하여 이 문제를 완화하고 있습니다.

프로세스 생성 (Process Creation)

Linux에서 새로운 프로세스는 항상 기존 프로세스를 복제하여 생성됩니다. 최초의 프로세스(PID 0, swapper/idle)만 커널 부팅 시 정적으로 생성되고, 이후 PID 1(init/systemd) 등은 모두 fork 계열 시스템 콜을 통해 생성됩니다.

fork() 시스템 콜

fork()는 호출한 프로세스의 거의 완전한 복사본을 생성합니다. 커널 내부에서는 kernel_clone() 함수(과거 do_fork())를 통해 처리됩니다. 주요 처리 과정은 다음과 같습니다:

  1. task_struct 할당: dup_task_struct()로 새로운 task_struct와 커널 스택을 할당합니다.
  2. 리소스 복제: copy_files(), copy_fs(), copy_sighand(), copy_signal(), copy_mm(), copy_namespaces() 등을 호출하여 각 리소스를 복제합니다.
  3. 스케줄러 초기화: sched_fork()로 새 태스크의 스케줄링 정보를 초기화합니다.
  4. PID 할당: alloc_pid()로 새 PID를 할당합니다.
  5. 런큐 추가: wake_up_new_task()로 새 프로세스를 런큐에 넣습니다.
/* kernel/fork.c - kernel_clone() 핵심 흐름 (간략화) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
    struct task_struct *p;
    pid_t nr;

    /* 1. task_struct + 커널 스택 복제 */
    p = copy_process(NULL, 0, args);
    if (IS_ERR(p))
        return PTR_ERR(p);

    /* 2. PID 가져오기 */
    nr = pid_vnr(get_task_pid(p, PIDTYPE_PID));

    /* 3. 새 프로세스를 런큐에 넣고 실행 가능하게 만듦 */
    wake_up_new_task(p);

    return nr;
}

vfork() / clone() / clone3()

Linux는 프로세스 생성을 위해 여러 시스템 콜을 제공하며, 내부적으로는 모두 kernel_clone()을 호출합니다.

시스템 콜 특징 주요 용도
fork() 부모 프로세스의 완전한 복제. COW 사용 일반적인 프로세스 생성
vfork() 부모가 자식이 exec/exit 할 때까지 블록됨. 주소 공간 공유 exec 직전 사용 (성능 최적화, 현재는 거의 불필요)
clone() 플래그로 공유 리소스를 세밀하게 제어 스레드 생성 (pthread_create 내부)
clone3() 확장 가능한 구조체 기반 인터페이스. 커널 5.3+ 새로운 기능 (cgroup, PID fd 등) 지원
/* clone() 호출 시 주요 플래그 */
#define CLONE_VM        0x00000100  /* 주소 공간 공유 */
#define CLONE_FS        0x00000200  /* 파일시스템 정보 공유 */
#define CLONE_FILES     0x00000400  /* 파일 디스크립터 테이블 공유 */
#define CLONE_SIGHAND   0x00000800  /* 시그널 핸들러 공유 */
#define CLONE_THREAD    0x00010000  /* 같은 스레드 그룹 */
#define CLONE_NEWNS     0x00020000  /* 새 마운트 네임스페이스 */
#define CLONE_NEWPID    0x20000000  /* 새 PID 네임스페이스 */

/* 스레드 생성 시 사용되는 플래그 조합 (glibc pthread_create) */
const int thread_flags = CLONE_VM | CLONE_FS | CLONE_FILES |
                         CLONE_SIGHAND | CLONE_THREAD |
                         CLONE_SYSVSEM | CLONE_SETTLS |
                         CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;

Copy-on-Write (COW) 메커니즘

Copy-on-Write는 fork()의 효율성을 크게 높이는 핵심 메커니즘입니다. fork() 시점에 부모와 자식은 같은 물리 페이지를 공유하되, 페이지 테이블 엔트리를 읽기 전용으로 표시합니다. 이후 둘 중 하나가 해당 페이지에 쓰기를 시도하면 페이지 폴트(page fault)가 발생하고, 커널이 해당 페이지를 복사한 뒤 쓰기를 허용합니다.

COW의 핵심 처리 흐름:

  1. fork()copy_mm() -> dup_mmap()에서 부모의 페이지 테이블을 복제합니다.
  2. copy_present_pte()에서 쓰기 가능 페이지를 읽기 전용으로 변경하고, 참조 카운트를 증가시킵니다.
  3. 쓰기 시 페이지 폴트 -> do_wp_page()에서 COW 처리를 수행합니다.
  4. 참조 카운트가 1이면 복사 없이 쓰기 권한만 복원하고, 2 이상이면 새 페이지를 할당하여 복사합니다.
💡

COW의 장점: 대부분의 fork() 호출은 직후에 exec()를 수행하여 주소 공간을 완전히 교체합니다. COW 덕분에 이런 fork-exec 패턴에서 불필요한 메모리 복사가 발생하지 않아 성능이 크게 향상됩니다.

Android 프로세스 모델

Android는 fork()의 COW 메커니즘을 최대한 활용하는 Zygote 패턴을 사용한다. Zygote 프로세스가 ART(Android Runtime)와 공통 프레임워크 클래스를 미리 로드한 뒤, 앱 시작 요청 시 fork()로 빠르게 자식 프로세스를 생성한다.

/* Zygote fork 흐름 (커널 관점) */
/* Zygote: ART + 프레임워크 클래스 미리 로드된 상태 */
pid = fork();  /* copy_process() → COW 페이지 테이블 복사 */
if (pid == 0) {
    /* 자식: 앱 프로세스 */
    setuid(app_uid);       /* 앱별 고유 UID (앱 샌드박스) */
    prctl(PR_SET_SECCOMP); /* seccomp 필터 */
    selinux_android_setcontext(...); /* SELinux 컨텍스트 전환 */
    /* exec() 없이 ART에서 바로 앱 코드 실행 → COW 페이지 공유 유지 */
}

일반적인 fork-exec 패턴과 달리 Zygote는 exec()를 호출하지 않으므로, 부모와 공유하는 COW 페이지(ART, 프레임워크 코드)가 장기간 유지되어 메모리 절약 효과가 크다. Android 10+에서는 USAP(Unspecialized App Process) 풀을 미리 fork하여 cold start 시간을 더욱 단축한다. 자세한 내용은 Android 커널 — Zygote를 참고하라.

fork() + exec() 흐름 시각화

fork() + exec() 프로세스 생성 흐름 (Copy-on-Write 동작) 부모 프로세스 (PID=100) Code: 0x400000 (RO) Data: 0x600000 (RW) Heap: 0x800000 fork() 부모 (PID=100) 페이지 테이블: 0x400000 → PFN 123 (RO) 0x600000 → PFN 456 (RO-COW) 0x800000 → PFN 789 (RO-COW) 자식 (PID=101) 페이지 테이블 (복사): 0x400000 → PFN 123 (RO) 0x600000 → PFN 456 (RO-COW) 0x800000 → PFN 789 (RO-COW) Copy-on-Write 상태 • 물리 페이지 공유 (복사 안 함) • 양쪽 PTE가 RO로 마킹 • 쓰기 시도 → page fault • fault 핸들러가 페이지 복사 자식이 data 수정 자식 (COW 발생) 0x400000 → PFN 123 (RO) 공유 0x600000 → PFN 999 (RW) ← 복사됨! 0x800000 → PFN 789 (RO-COW) 공유 exec("/bin/ls") 자식 (PID=101, /bin/ls) 페이지 테이블 완전 교체: 0x400000 → ELF /bin/ls .text 0x600000 → ELF /bin/ls .data 기존 COW 페이지 모두 해제 핵심 포인트 1. fork(): 페이지 테이블만 복사 → 물리 메모리 공유 (빠름) 2. COW: 쓰기 시에만 복사 → 실제 필요할 때만 메모리 사용 3. exec(): 주소 공간 교체 → COW 페이지 즉시 해제 → fork 후 바로 exec하면 COW 복사 거의 없음 최적 패턴: fork() + exec() 메모리 복사 최소화
fork()는 페이지 테이블만 복사하여 물리 메모리를 공유하고 (COW), 쓰기 시에만 페이지를 복사합니다. exec()는 주소 공간을 완전히 교체하므로, fork 직후 exec하면 COW 복사가 거의 발생하지 않습니다.

exec() 계열과 ELF 로딩 (exec and ELF Loading)

exec() 계열 시스템 콜은 현재 프로세스의 주소 공간을 새로운 프로그램으로 대체합니다. 커널 내부에서는 do_execveat_common()을 통해 처리됩니다. Linux에서 사용자 공간 프로그램의 표준 실행 파일 형식은 ELF(Executable and Linkable Format)이며, 커널은 fs/binfmt_elf.c에서 이를 처리합니다.

ℹ️

ELF 상세 문서: ELF 포맷의 전체 구조(헤더, 세그먼트, 섹션, 심볼 테이블, 재배치, 동적 링킹, GOT/PLT, TLS, 코어 덤프, 커널 모듈 ELF, binfmt 핸들러, 심볼 버저닝, .eh_frame/DWARF, 링커 스크립트, 보안 강화)에 대한 심층 분석은 ELF (Executable and Linkable Format) 페이지를 참고하십시오.

스케줄러 (Scheduler)

Linux 커널의 프로세스 스케줄러는 어떤 태스크에 CPU 시간을 할당할지 결정합니다. 스케줄러의 핵심 진입점은 schedule() 함수이며, __schedule()에서 다음 실행할 태스크를 선택합니다.

EEVDF 스케줄러 (Earliest Eligible Virtual Deadline First)

Linux 6.6부터 기본 일반(normal) 프로세스 스케줄러로 채택된 EEVDF는 각 태스크에 가상 데드라인(virtual deadline)을 부여합니다. 태스크가 요청한 타임 슬라이스(request)와 현재 vruntime을 기반으로 데드라인을 계산하며, "적격(eligible)" 조건을 만족하는 태스크 중 가장 이른 데드라인을 가진 태스크를 선택합니다.

EEVDF 핵심 개념

EEVDF는 vruntime(가상 실행 시간), deadline(가상 데드라인), lag(이상적 실행 시간과 실제 실행 시간의 차이) 세 가지 핵심 메트릭을 사용합니다:

/* kernel/sched/fair.c - EEVDF sched_entity */
struct sched_entity {
    u64 vruntime;           /* 가상 실행 시간 (공정성 기준) */
    u64 deadline;           /* 가상 데드라인 (선택 기준) */
    u64 min_vruntime;       /* 최소 vruntime 추적 */
    s64 vlag;               /* lag: 적격 여부 판단 */
    u64 slice;              /* 요청 타임 슬라이스 */
    struct load_weight load;  /* nice 값 기반 가중치 */
    ...
};

EEVDF 스케줄링 알고리즘

EEVDF는 다음 절차로 다음 실행 태스크를 선택합니다:

  1. 적격 태스크 필터링: lag ≥ 0인 태스크만 고려 (CPU 시간을 "덜 받은" 태스크)
  2. Deadline 기반 선택: 적격 태스크 중 가장 이른 deadline을 가진 태스크 선택
  3. Vruntime 갱신: 실행 후 vruntime += delta_exec * (NICE_0_LOAD / weight)
  4. Lag 갱신: lag = ideal_runtime - vruntime으로 다음 적격 여부 계산
/* EEVDF 스케줄링 (개념적) */
static struct sched_entity *pick_eevdf(struct cfs_rq *cfs_rq)
{
    struct sched_entity *se, *best = NULL;
    u64 min_deadline = U64_MAX;

    /* RB-tree 순회하며 적격 태스크 중 최소 deadline 찾기 */
    for_each_sched_entity(se) {
        if (se->vlag < 0)
            continue;  /* 적격하지 않음 (CPU를 "많이" 받음) */
        if (se->deadline < min_deadline) {
            min_deadline = se->deadline;
            best = se;
        }
    }
    return best;
}

CFS - 역사적 배경

EEVDF 이전에는 CFS (Completely Fair Scheduler)가 Linux 2.6.23(2007년)부터 6.5(2023년)까지 16년간 사용되었습니다. CFS는 vruntime 기반으로 "가장 적게 실행된" 태스크를 Red-Black 트리의 왼쪽 끝에서 선택하는 단순한 방식이었습니다.

📜

CFS 동작: vruntime만 사용하여 최소값을 가진 태스크 선택. sched_latency, sched_min_granularity 등의 휴리스틱으로 지연시간 조정.

se = rb_leftmost(&cfs_rq->tasks_timeline);  /* 최소 vruntime */

CFS vs EEVDF 비교

항목CFS (Linux 2.6.23 ~ 6.5)EEVDF (Linux 6.6+)
선택 기준 최소 vruntime 적격 태스크 중 최소 deadline
지연시간 제어 sched_latency, sched_min_granularity 휴리스틱 deadline 기반 예측 가능
적격 조건 없음 (모든 태스크 고려) lag ≥ 0 (CPU 시간 부족 태스크만)
자료구조 RB-tree (vruntime 순) RB-tree (deadline 순)
복잡도 O(log N) O(log N)
장점 단순한 공정성 보장 latency-sensitive 워크로드 응답성 향상, 이론적 근거 명확 (EDF 기반)
사용 사례 일반 서버/데스크톱 (레거시) 모든 워크로드 (특히 인터랙티브/실시간성 요구)
💡

마이그레이션: CFS에서 EEVDF로 전환 시 커널 ABI는 동일하게 유지됩니다. SCHED_NORMAL, SCHED_BATCH 정책은 그대로 사용되며, nice 값도 동일하게 동작합니다. 대부분의 워크로드에서 성능 개선 또는 동등한 성능을 보이며, 특히 지연시간 민감 워크로드(GUI, 게임, 오디오)에서 체감 향상이 있습니다.

스케줄링 클래스 (Scheduling Classes)

Linux 스케줄러는 모듈화된 설계를 가지고 있으며, 여러 스케줄링 클래스가 우선순위 체인으로 연결되어 있습니다. 높은 우선순위의 클래스가 먼저 검사되며, 해당 클래스에 실행 가능한 태스크가 있으면 그 태스크를 선택합니다.

우선순위 스케줄링 클래스 정책 설명
1 (최고) stop_sched_class - CPU hotplug, migration 등 특수 용도. 가장 높은 우선순위
2 dl_sched_class SCHED_DEADLINE Earliest Deadline First. 주기, 런타임, 데드라인을 명시적으로 지정
3 rt_sched_class SCHED_FIFO, SCHED_RR 실시간 스케줄링. 우선순위 기반 선점형
4 fair_sched_class SCHED_NORMAL, SCHED_BATCH CFS/EEVDF. 일반 프로세스용. nice 값 -20 ~ +19
5 (최저) idle_sched_class SCHED_IDLE 시스템이 완전히 유휴 상태일 때만 실행
스케줄링 클래스 우선순위 체인 (pick_next_task 흐름) schedule() 호출 1. stop_sched_class CPU hotplug, migration 전용 (최고 우선순위) 실행 가능 태스크 있음? 선택! 없으면 ↓ 2. dl_sched_class SCHED_DEADLINE (EDF - Earliest Deadline First) 실행 가능 태스크 있음? 선택! 없으면 ↓ 3. rt_sched_class SCHED_FIFO, SCHED_RR (실시간, 우선순위 1-99) 실행 가능 태스크 있음? 선택! 없으면 ↓ 4. fair_sched_class SCHED_NORMAL, SCHED_BATCH (CFS/EEVDF, 일반 프로세스) 실행 가능 태스크 있음? 선택! 없으면 ↓ 5. idle_sched_class (최종 폴백 - CPU idle) 높은 우선순위 → 낮은 우선순위
스케줄러는 최고 우선순위 클래스부터 순차적으로 검사하며, 실행 가능한 태스크가 있는 첫 번째 클래스에서 태스크를 선택합니다. 이를 통해 실시간 태스크가 일반 태스크보다 항상 우선 실행됩니다.
/* kernel/sched/sched.h - 스케줄링 클래스 체인 */
/* 우선순위: stop > dl > rt > fair > idle */
#define sched_class_highest  (&stop_sched_class)
#define for_each_class(class) \
    for (class = sched_class_highest; \
         class != &idle_sched_class;  \
         class = class->next)

/* 다음 실행 태스크 선택 - __schedule() 내부 */
struct task_struct *pick_next_task(struct rq *rq)
{
    const struct sched_class *class;

    for_each_class(class) {
        struct task_struct *p = class->pick_next_task(rq);
        if (p)
            return p;
    }
    /* idle 태스크 반환 */
    return idle_sched_class.pick_next_task(rq);
}

런큐(runqueue)와 vruntime 계산

각 CPU마다 하나의 struct rq(런큐)가 존재하며, 이 안에 각 스케줄링 클래스별 서브 런큐가 포함됩니다. CFS/EEVDF의 경우 struct cfs_rq가 Red-Black 트리로 실행 가능한 태스크를 관리합니다.

/* 런큐 구조체 핵심 필드 */
struct rq {
    unsigned int      nr_running;     /* 이 CPU의 총 실행 가능 태스크 수 */
    struct cfs_rq     cfs;            /* CFS/EEVDF 런큐 */
    struct rt_rq      rt;             /* 실시간 런큐 */
    struct dl_rq      dl;             /* Deadline 런큐 */
    struct task_struct *curr;          /* 현재 실행 중인 태스크 */
    struct task_struct *idle;          /* idle 태스크 */
    u64               clock;          /* 런큐 클럭 */
};

struct cfs_rq {
    struct load_weight load;          /* 런큐 총 가중치 */
    unsigned int      nr_running;     /* CFS 실행 가능 태스크 수 */
    u64               min_vruntime;   /* 최소 vruntime (새 태스크 기준점) */
    struct rb_root_cached tasks_timeline; /* RB-tree */
};

vruntime 계산 공식:

vruntime += delta_exec * (NICE_0_LOAD / se->load.weight)

NICE_0_LOAD는 nice 0에 대응하는 가중치입니다. nice 값이 낮을수록 weight가 커져서 vruntime 증가량이 줄어들고, 결과적으로 더 많은 CPU 시간을 할당받게 됩니다. nice 1 단계 차이는 약 1.25배의 CPU 시간 비율 차이를 만듭니다.

kernel/sched/fair.c — Linux Kernel (GPL-2.0)

schedule() 호출 경로와 선점 (Preemption)

스케줄러의 핵심 진입점인 schedule()은 내부적으로 __schedule()을 호출합니다. 이 함수는 현재 CPU의 런큐에서 다음 실행할 태스크를 선택하고, 필요 시 컨텍스트 스위칭을 수행합니다.

/* kernel/sched/core.c - __schedule() 핵심 흐름 (간략화) */
static void __schedule(unsigned int sched_mode)
{
    struct task_struct *prev, *next;
    struct rq *rq;
    int cpu;

    cpu = smp_processor_id();
    rq = cpu_rq(cpu);
    prev = rq->curr;

    /* 1. 현재 태스크 상태 갱신 */
    if (sched_mode == SM_NONE) {
        /* 자발적 선점이 아니면 이전 태스크 dequeue */
        if (!signal_pending_state(prev->__state, prev))
            deactivate_task(rq, prev, DEQUEUE_SLEEP);
    }

    /* 2. 다음 실행 태스크 선택 */
    next = pick_next_task(rq, prev, &rf);

    /* 3. TIF_NEED_RESCHED 플래그 클리어 */
    clear_tsk_need_resched(prev);

    /* 4. 컨텍스트 스위칭 (prev != next 인 경우) */
    if (likely(prev != next)) {
        rq->nr_switches++;
        rq->curr = next;
        rq = context_switch(rq, prev, next, &rf);
    }
}

TIF_NEED_RESCHED 플래그

커널은 즉시 스케줄링을 수행하지 않고, TIF_NEED_RESCHED 플래그를 설정하여 "재스케줄링이 필요하다"는 신호를 남깁니다. 이 플래그는 다음 시점에 검사됩니다:

플래그가 설정되는 주요 시점:

선점 모델 (Preemption Model)

Linux 커널은 4가지 선점 모델(PREEMPT_NONE, PREEMPT_VOLUNTARY, PREEMPT, PREEMPT_RT)을 지원하며, 커널 코드 실행 중 스케줄링이 허용되는 범위가 달라집니다. preempt_count 메커니즘을 통해 선점 비활성화 상태를 추적하며, 이 값이 0이 아니면 커널 선점이 불가합니다. 각 모델의 상세 비교, 자발적 선점 포인트(cond_resched(), might_sleep() 등), preempt_count 비트 필드 구조, CONFIG_PREEMPT_DYNAMIC(런타임 전환), PREEMPT_LAZY(커널 6.12+), PREEMPT_RT 심화 내용은 프로세스 스케줄러 — 선점 모델을 참고하세요.

sched_class 인터페이스

각 스케줄링 클래스는 struct sched_class 인터페이스를 구현하여 스케줄러 코어와 상호작용합니다. 이 구조체는 함수 포인터 테이블로, 태스크의 큐 삽입/제거, 다음 태스크 선택, 선점 검사 등의 콜백을 정의합니다.

/* kernel/sched/sched.h - struct sched_class 핵심 콜백 */
struct sched_class {

    /* 태스크를 런큐에 삽입 (wakeup, fork 등) */
    void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);

    /* 태스크를 런큐에서 제거 (sleep, exit 등) */
    void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);

    /* 새 태스크가 wakeup될 때 현재 태스크를 선점할지 확인 */
    void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

    /* 다음 실행할 태스크 선택 */
    struct task_struct *(*pick_next_task)(struct rq *rq);

    /* 현재 태스크를 내려놓기 (다른 태스크로 전환 전) */
    void (*put_prev_task)(struct rq *rq, struct task_struct *p);

    /* 다음 태스크로 설정 (pick 후 context_switch 직전) */
    void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);

    /* 타이머 tick마다 호출 — 타임슬라이스 관리 */
    void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);

    /* fork 시 태스크를 배치할 CPU 선택 */
    int (*select_task_rq)(struct task_struct *p, int prev_cpu,
                          int sd_flag, int wake_flags);

    /* 태스크 마이그레이션 콜백 */
    void (*migrate_task_rq)(struct task_struct *p, int new_cpu);

    /* 태스크의 우선순위/정책 변경 시 */
    void (*prio_changed)(struct rq *rq, struct task_struct *p, int oldprio);

    /* 스케줄링 클래스 간 전환 시 */
    void (*switched_to)(struct rq *rq, struct task_struct *p);
};

각 스케줄링 클래스별 주요 구현 특성:

콜백 fair_sched_class rt_sched_class dl_sched_class
enqueue_task RB-tree에 vruntime 기준 삽입 우선순위별 연결 리스트에 삽입 RB-tree에 deadline 기준 삽입
pick_next_task 적격 태스크 중 earliest deadline (EEVDF) 가장 높은 우선순위의 첫 번째 태스크 가장 이른 absolute deadline
task_tick vruntime 갱신, 재스케줄 여부 확인 SCHED_RR인 경우 타임슬라이스 감소 runtime 소진 확인, throttle 처리
select_task_rq wake affine + LLC 내 idle CPU 탐색 가장 낮은 우선순위 CPU 탐색 deadline feasibility 기반 CPU 선택

실시간(RT) 스케줄링 상세

RT 스케줄링 클래스(rt_sched_class)는 SCHED_FIFOSCHED_RR 두 가지 정책을 제공합니다. 모두 고정 우선순위(1~99) 기반이며, 일반 태스크(SCHED_NORMAL)보다 항상 먼저 실행됩니다.

특성 SCHED_FIFO SCHED_RR
타임슬라이스 없음 (무한) 있음 (기본 100ms, sched_rr_timeslice_ms)
같은 우선순위 태스크 간 먼저 실행된 태스크가 자발적으로 양보할 때까지 계속 실행 라운드 로빈 — 타임슬라이스 소진 시 같은 우선순위 큐의 끝으로 이동
선점 더 높은 우선순위의 RT 태스크가 깨어나면 즉시 선점
용도 이벤트 기반 실시간 처리 여러 동등한 RT 태스크 간 공정한 분배
/* kernel/sched/rt.c - RT 런큐 구조 */
struct rt_prio_array {
    DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1);  /* 비트맵: O(1) 최고 우선순위 탐색 */
    struct list_head queue[MAX_RT_PRIO];     /* 우선순위별 연결 리스트 */
};

struct rt_rq {
    struct rt_prio_array active;
    unsigned int         rt_nr_running;
    int                  highest_prio;    /* 캐시된 최고 우선순위 */
    u64                  rt_time;         /* 이 RT 런큐의 누적 실행 시간 */
    u64                  rt_runtime;      /* 허용 runtime (throttling) */
};

RT Throttling

RT 태스크가 CPU를 독점하여 일반 태스크가 기아(starvation) 상태에 빠지는 것을 방지하기 위해 RT throttling 메커니즘이 존재합니다:

기본 설정은 1초 주기 중 950ms까지 RT 태스크가 실행할 수 있고, 나머지 50ms는 일반 태스크에 보장됩니다. RT 태스크가 할당량을 소진하면 해당 CPU의 RT 런큐가 throttle되어, 할당량이 리필될 때까지 일반 태스크가 실행됩니다.

Priority Inversion과 PI (Priority Inheritance)

우선순위 역전(Priority Inversion)은 높은 우선순위 태스크가 낮은 우선순위 태스크가 보유한 lock을 기다리는 동안, 중간 우선순위 태스크가 CPU를 점유하여 결과적으로 높은 우선순위 태스크가 무한히 지연되는 현상입니다.

Linux 커널은 PI(Priority Inheritance)로 이를 해결합니다:

/* RT mutex의 Priority Inheritance 동작 */
/*
 * 태스크 A (우선순위 99): mutex M을 기다림
 * 태스크 B (우선순위  1): mutex M 보유 중
 * 태스크 C (우선순위 50): CPU 사용 중
 *
 * PI 없이: B(1)는 C(50)에게 선점당해 M을 해제 못함 → A(99) 무한 대기
 * PI 적용: B가 M을 보유하는 동안 A의 우선순위(99)를 일시 상속
 *          → B(99)가 C(50)를 선점하여 빠르게 M 해제 → A 실행
 */
struct rt_mutex {
    struct rb_root_cached  waiters;      /* 대기자 RB-tree (우선순위 순) */
    struct task_struct    *owner;        /* lock 보유자 */
};

/* PI chain을 따라 우선순위를 전파 */
int rt_mutex_adjust_prio_chain(struct task_struct *task, ...);
⚠️

PI futex: 유저 공간에서도 PTHREAD_PRIO_INHERIT 속성의 pthread mutex를 사용하면 커널의 futex(FUTEX_LOCK_PI)를 통해 Priority Inheritance가 동작합니다. 실시간 애플리케이션에서는 반드시 PI mutex를 사용해야 합니다.

SCHED_DEADLINE 상세

SCHED_DEADLINE은 Linux 3.14에서 도입된 스케줄링 정책으로, 태스크에 명시적인 runtime, deadline, period 세 가지 파라미터를 지정합니다. CBS(Constant Bandwidth Server) 알고리즘을 기반으로 하며, 모든 스케줄링 클래스 중 가장 높은 우선순위(stop 제외)를 갖습니다.

SCHED_DEADLINE 타이밍: runtime / deadline / period t runtime deadline period Q D runtime deadline Q = runtime (실행 예산) D = deadline (완료 기한) P = period (반복 주기)

각 파라미터의 관계와 의미:

Admission Control

DEADLINE 태스크 추가 시 커널은 admission control을 수행합니다. 시스템 전체의 총 bandwidth가 이용 가능한 CPU 수를 초과하면 sched_setattr()-EBUSY로 실패합니다:

/* Admission control 조건 */
/* 각 DEADLINE 태스크의 bandwidth = runtime / period */
/* 총 bandwidth 합계 <= 전체 CPU 수 (또는 cpuset 내 CPU 수) */
/*   Σ (runtime_i / period_i) <= M (M = 사용 가능 CPU 수) */

/* 사용자 공간에서 SCHED_DEADLINE 설정 */
struct sched_attr attr = {
    .size           = sizeof(attr),
    .sched_policy   = SCHED_DEADLINE,
    .sched_runtime  = 10 * 1000 * 1000,   /* 10ms runtime */
    .sched_deadline = 30 * 1000 * 1000,   /* 30ms deadline */
    .sched_period   = 100 * 1000 * 1000,  /* 100ms period */
};
/* bandwidth = 10ms / 100ms = 0.1 → CPU의 10% 사용 */
sched_setattr(pid, &attr, 0);

Deadline Miss 처리

DEADLINE 태스크가 runtime을 소진하면 throttle되어 다음 period까지 실행이 중단됩니다. CBS 알고리즘에 의해 absolute deadline이 자동으로 재계산되므로, 일시적인 burst를 수용하면서도 다른 태스크의 보장을 침해하지 않습니다.

CPU Affinity와 격리

CPU affinity는 특정 태스크가 실행될 수 있는 CPU 집합을 제한하는 메커니즘입니다. 각 태스크는 cpus_allowed 비트마스크를 갖고 있으며, 스케줄러는 이 마스크에 포함된 CPU에서만 해당 태스크를 실행합니다.

/* 커널 내부: CPU affinity 설정 */
struct task_struct {
    cpumask_t  cpus_mask;        /* 허용된 CPU 비트마스크 */
    const cpumask_t *cpus_ptr;  /* 실제 사용 마스크 (cpus_mask 가리킴) */
    int        nr_cpus_allowed;  /* 허용 CPU 개수 (최적화) */
    ...
};

/* 시스템 콜을 통한 affinity 설정 */
long sched_setaffinity(pid_t pid, const struct cpumask *new_mask)
{
    struct task_struct *p = find_process_by_pid(pid);

    /* cpuset cgroup 제약과 교집합 */
    cpumask_and(&allowed, new_mask, task_cpu_possible_mask(p));

    /* 빈 집합이면 실패 */
    if (cpumask_empty(&allowed))
        return -EINVAL;

    /* 마스크 설정 및 필요 시 마이그레이션 */
    __set_cpus_allowed_ptr(p, &allowed, 0);
}

CPU 격리 방법

방법 범위 설명
sched_setaffinity() 개별 태스크 특정 태스크를 지정 CPU에 고정. 유저 공간에서 taskset 명령으로도 사용 가능
isolcpus= 부트 파라미터 지정 CPU를 일반 스케줄링 도메인에서 제외. 명시적 affinity 설정 태스크만 실행
cpuset cgroup 프로세스 그룹 cgroup 내 태스크가 사용할 수 있는 CPU/메모리 노드를 제한
nohz_full= 부트 파라미터 지정 CPU에서 타이머 tick을 제거 (adaptive-ticks). 단일 태스크 실행 시 tick 인터럽트 없음
irqaffinity= 부트 파라미터 IRQ 처리에 사용할 CPU 지정. 격리 CPU에서 IRQ를 제거
# 유저 공간에서 CPU affinity / 격리 활용 예

# 1. taskset: 프로세스를 CPU 2,3에 고정
taskset -c 2,3 ./my_realtime_app

# 2. isolcpus: 부트 커맨드라인에서 CPU 2,3을 격리
#    GRUB: isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3

# 3. cpuset cgroup v2로 격리
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
mkdir /sys/fs/cgroup/rt_group
echo "2-3" > /sys/fs/cgroup/rt_group/cpuset.cpus
echo "0" > /sys/fs/cgroup/rt_group/cpuset.mems
echo $$ > /sys/fs/cgroup/rt_group/cgroup.procs
💡

실시간 격리 조합: 실시간 워크로드를 위한 이상적인 CPU 격리는 isolcpus + nohz_full + rcu_nocbs의 조합입니다. 이렇게 하면 지정 CPU에서 스케줄러 로드 밸런싱, 타이머 tick, RCU 콜백이 모두 제거되어 최소 지연 시간을 달성할 수 있습니다.

로드 밸런싱과 스케줄링 도메인

SMP 시스템에서 각 CPU는 독립적인 런큐를 갖고 있어, CPU 간 부하 불균형이 발생할 수 있습니다. 커널의 로드 밸런서는 주기적으로 또는 CPU가 유휴 상태가 될 때 태스크를 바쁜 CPU에서 한가한 CPU로 마이그레이션합니다.

스케줄링 도메인 계층

로드 밸런싱은 스케줄링 도메인(sched_domain) 계층에서 수행됩니다. 하드웨어 토폴로지를 반영하며, 하위 도메인에서 먼저 밸런싱을 시도하고 필요 시 상위 도메인으로 확장합니다.

스케줄링 도메인 계층 (2-socket NUMA) SD_NUMA (Node 0 + Node 1) SD_DIE — Node 0 SD_MC (LLC 0) CPU0 CPU1 CPU2 CPU3 SD_MC (LLC 1) CPU4 CPU5 CPU6 CPU7 SD_DIE — Node 1 SD_MC (LLC 2) CPU8 CPU9 C10 C11 SD_MC (LLC 3) C12 C13 C14 C15
도메인 레벨 플래그 범위 밸런싱 주기
SD_MC SD_SHARE_PKG_RESOURCES 같은 LLC(Last-Level Cache) 공유 코어 짧음 (~4ms)
SD_DIE SD_SHARE_PKG_RESOURCES 같은 소켓(다이) 내 모든 코어 중간 (~16ms)
SD_NUMA SD_NUMA NUMA 노드 간 길음 (~64ms)

로드 밸런싱 동작

로드 밸런싱은 다음 세 가지 경로로 트리거됩니다:

/* kernel/sched/fair.c - 로드 밸런싱 핵심 흐름 (간략화) */
static int load_balance(int this_cpu, struct rq *this_rq,
                        struct sched_domain *sd,
                        enum cpu_idle_type idle)
{
    struct sched_group *busiest;
    struct rq *busiest_rq;

    /* 1. 도메인 내에서 가장 바쁜 그룹 찾기 */
    busiest = find_busiest_group(sd, this_cpu, idle);
    if (!busiest)
        return 0;  /* 밸런싱 불필요 */

    /* 2. 그 그룹에서 가장 바쁜 런큐 찾기 */
    busiest_rq = find_busiest_queue(sd, busiest);

    /* 3. 태스크 마이그레이션 (pull) */
    detach_tasks(&env);  /* busiest에서 태스크 분리 */
    attach_tasks(&env);  /* this_rq에 태스크 부착 */
}

Wake Affine

태스크가 깨어날 때, 스케줄러는 wake affine 휴리스틱을 사용하여 깨우는 태스크(waker)와 같은 LLC 내의 유휴 CPU를 찾으려 합니다. 이는 캐시 공유의 이점을 활용하고 NUMA 간 마이그레이션의 비용을 줄입니다.

NUMA Balancing

NUMA 시스템에서 커널은 Automatic NUMA Placement를 수행합니다. 태스크의 메모리 접근 패턴을 관찰하여, 자주 접근하는 메모리가 있는 NUMA 노드의 CPU로 태스크를 마이그레이션합니다:

그룹 스케줄링과 Bandwidth Control

그룹 스케줄링은 여러 태스크를 그룹으로 묶어 그룹 단위로 CPU 자원을 공정하게 분배하는 메커니즘입니다. cgroup의 CPU controller와 연동되며, 컨테이너 환경에서 CPU 자원 격리의 핵심 기술입니다.

CONFIG_FAIR_GROUP_SCHED

이 옵션이 활성화되면, CFS는 태스크뿐만 아니라 그룹 스케줄링 엔티티(group sched_entity)도 관리합니다. 각 cgroup은 자체 cfs_rq를 갖고, 상위 cgroup의 CFS 런큐에서 하나의 엔티티로 참여합니다.

/* 그룹 스케줄링 구조 (간략화) */
struct task_group {
    struct sched_entity  **se;       /* per-CPU 스케줄링 엔티티 */
    struct cfs_rq        **cfs_rq;   /* per-CPU 그룹 CFS 런큐 */
    unsigned long        shares;     /* 그룹 가중치 (cpu.weight) */
    struct cfs_bandwidth cfs_bandwidth; /* bandwidth 제한 */
};

/*
 * 계층 구조 예:
 *   root cfs_rq
 *     ├─ group A (se)  → group A의 cfs_rq
 *     │    ├─ task 1 (se)
 *     │    └─ task 2 (se)
 *     └─ group B (se)  → group B의 cfs_rq
 *          ├─ task 3 (se)
 *          └─ task 4 (se)
 *
 *   group A와 B의 shares가 동일하면
 *   각 그룹이 CPU 50%를 받음 (내부 태스크 수 무관)
 */

cgroup v2 CPU Controller

파일 형식 설명
cpu.weight 1~10000 (기본: 100) CFS 가중치. 같은 레벨의 cgroup 간 CPU 비율 결정
cpu.weight.nice -20~19 nice 값 형식으로 가중치 설정
cpu.max $QUOTA $PERIOD bandwidth 제한. "max 100000"이면 무제한. "50000 100000"이면 50%
cpu.stat 읽기 전용 usage_usec, user_usec, system_usec, nr_periods, nr_throttled 등
# cgroup v2 CPU controller 설정 예

# 1. cpu controller 활성화
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control

# 2. 그룹 생성
mkdir /sys/fs/cgroup/app_group

# 3. 가중치 설정 (기본 100 대비 2배의 CPU 비율)
echo 200 > /sys/fs/cgroup/app_group/cpu.weight

# 4. bandwidth 제한: 100ms 주기 중 최대 50ms (50% CPU)
echo "50000 100000" > /sys/fs/cgroup/app_group/cpu.max

# 5. 프로세스 할당
echo $PID > /sys/fs/cgroup/app_group/cgroup.procs

# 6. throttle 상태 확인
cat /sys/fs/cgroup/app_group/cpu.stat
# nr_throttled 59   ← throttle 발생 횟수
# throttled_usec 2340000  ← 총 throttle 시간

CFS Bandwidth Throttling

cpu.max에 quota와 period를 설정하면 CFS bandwidth throttling이 동작합니다. 각 period 동안 그룹 내 태스크가 quota만큼의 CPU 시간을 소진하면, 해당 그룹의 cfs_rq가 throttle되어 다음 period까지 실행이 중단됩니다.

⚠️

컨테이너 CPU 제한 주의: Kubernetes의 resources.limits.cpu는 내부적으로 cpu.max를 설정합니다. 기본 period는 100ms로, CPU 집중 워크로드에서 빈번한 throttle이 발생할 수 있습니다. 멀티스레드 애플리케이션은 throttle quota를 스레드 수에 맞게 충분히 설정해야 합니다.

스케줄러 튜닝과 디버깅

Linux 커널은 /proc/sys/kernel/sched_* 파라미터를 통해 스케줄러 동작을 런타임에 튜닝할 수 있습니다. 또한 다양한 디버깅 도구를 제공합니다.

주요 튜닝 파라미터

파라미터 기본값 설명
sched_min_granularity_ns 750000 (0.75ms) 태스크의 최소 실행 시간 보장. 너무 작으면 컨텍스트 스위칭 오버헤드 증가
sched_latency_ns 6000000 (6ms) CFS의 목표 스케줄링 주기. 이 시간 내에 모든 실행 가능 태스크가 한 번씩 실행
sched_wakeup_granularity_ns 1000000 (1ms) wakeup preemption 임계값. 깨어난 태스크의 vruntime이 이 값보다 작아야 선점
sched_migration_cost_ns 500000 (0.5ms) 마이그레이션 비용 추정. 이보다 최근에 실행된 태스크는 마이그레이션하지 않음
sched_nr_migrate 32 한 번의 로드 밸런싱에서 이동할 최대 태스크 수
sched_rt_runtime_us 950000 RT throttling: 주기 내 RT 최대 실행 시간 (-1이면 무제한)
sched_rt_period_us 1000000 RT throttling 주기
ℹ️

EEVDF와 튜닝: Linux 6.6+에서 EEVDF가 기본 스케줄러가 되면서, sched_latency_nssched_min_granularity_ns의 역할이 줄어들었습니다. EEVDF는 deadline 기반으로 동작하므로 이러한 휴리스틱 파라미터에 덜 의존합니다.

디버깅 인터페이스

# 1. /proc/sched_debug — 전역 스케줄러 상태 덤프
cat /proc/sched_debug
# 각 CPU의 런큐 상태, 실행 중/대기 중 태스크 목록, 도메인 정보 출력

# 2. /proc/[pid]/sched — 개별 태스크 스케줄러 통계
cat /proc/self/sched
# se.vruntime, se.sum_exec_runtime, nr_switches,
# nr_voluntary_switches, nr_involuntary_switches 등

# 3. schedstat — CPU별 스케줄러 통계
cat /proc/schedstat
# CPU별 컨텍스트 스위칭 횟수, 런큐 대기 시간 등

perf sched 도구

perf sched는 스케줄러 이벤트를 기록하고 분석하는 전용 도구입니다:

# 스케줄러 이벤트 기록
perf sched record -- sleep 5

# 스케줄링 지연 시간 분석
perf sched latency

# CPU별 스케줄링 맵 (시각적 타임라인)
perf sched map

# 스케줄링 타임차트 (SVG 출력)
perf sched timechart

perf sched latency 출력 예시:

TaskRuntime (ms)SwitchesAvg delay (ms)
my_app:12341500.31234210.045

perf sched map: CPU 시간축을 문자로 압축해 보여주는 뷰이며, 태스크 ID 문자(예: A, B)가 CPU별로 언제 실행되었는지 빠르게 확인할 때 사용합니다.

ftrace 스케줄러 트레이스포인트

ftrace의 스케줄러 관련 tracepoint를 활용하면 실시간으로 스케줄링 이벤트를 추적할 수 있습니다:

# sched_switch: 컨텍스트 스위칭 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_wakeup/enable

# 트레이스 읽기
cat /sys/kernel/debug/tracing/trace
#           <idle>-0  [002]  123.456: sched_switch: prev=swapper/2:0 [120]
#                                       ==> next=my_app:1234 [120]
#         my_app-1234 [002]  123.458: sched_wakeup: comm=kworker/3:0
#                                       pid=56 prio=120 target_cpu=003

# 주요 스케줄러 tracepoint 목록:
#   sched_switch        — 태스크 전환 시점
#   sched_wakeup        — 태스크 깨어남
#   sched_migrate_task  — CPU 간 마이그레이션
#   sched_process_fork  — fork 이벤트
#   sched_stat_runtime  — 실행 시간 통계
#   sched_stat_wait     — 런큐 대기 시간 통계
💡

실전 디버깅 팁: 스케줄링 지연 문제를 분석할 때는 perf sched latency로 전체 그림을 파악한 후, 문제 태스크의 /proc/[pid]/sched에서 nr_involuntary_switches(비자발적 선점 횟수)와 se.statistics.wait_sum(총 런큐 대기 시간)을 확인하세요. RT 태스크의 경우 cpu.statnr_throttled도 반드시 점검해야 합니다.

컨텍스트 스위칭 (Context Switch)

컨텍스트 스위칭은 현재 실행 중인 태스크를 다른 태스크로 전환하는 과정입니다. context_switch() 함수에서 수행되며, 크게 두 단계로 나뉩니다.

  1. 주소 공간 전환: switch_mm_irqs_off()에서 페이지 테이블(CR3 레지스터)을 새 태스크의 것으로 교체합니다. 커널 스레드 간 전환이면 이 단계를 건너뜁니다.
  2. 레지스터/스택 전환: switch_to()에서 커널 스택 포인터, 프로그램 카운터, 범용 레지스터 등을 전환합니다. 이 작업은 아키텍처 의존적인 어셈블리 코드로 구현됩니다.

컨텍스트 스위칭 과정 다이어그램

Context Switch 흐름 Task A (실행 중) schedule() 호출 레지스터 저장 (A 스택) pick_next_task() context_switch() (prev=A, next=B) switch_mm_irqs_off() switch_to(A, B) 레지스터 복원 (B 스택) Task B (대기 중) Task B 선택 CR3 교체 스택/IP 전환
컨텍스트 스위칭 과정 - schedule()에서 switch_to()까지의 흐름
/* kernel/sched/core.c - context_switch() 핵심 (간략화) */
static void context_switch(struct rq *rq,
    struct task_struct *prev, struct task_struct *next)
{
    /* 1. 주소 공간 전환 */
    if (!next->mm) {
        /* 커널 스레드: 이전 태스크의 mm을 빌림 (lazy TLB) */
        next->active_mm = prev->active_mm;
    } else {
        /* 유저 프로세스: 페이지 테이블 전환 */
        switch_mm_irqs_off(prev->active_mm, next->mm, next);
    }

    /* 2. 레지스터 + 스택 전환 (아키텍처 의존) */
    switch_to(prev, next, prev);

    /* 이 시점부터 next 태스크의 컨텍스트에서 실행 */
    finish_task_switch(prev);
}
ℹ️

Lazy TLB: 커널 스레드는 자체 유저 주소 공간이 없으므로(mm == NULL), 이전 태스크의 mm을 빌려 사용합니다. 이를 통해 불필요한 TLB flush를 방지하여 성능을 향상시킵니다.

스레드 (Threads)

Linux에서 스레드는 프로세스와 본질적으로 같은 객체입니다. 둘 다 task_struct로 표현되며, 스레드는 같은 스레드 그룹에 속한 task_struct들이 주소 공간, 파일 디스크립터, 시그널 핸들러 등을 공유하는 것일 뿐입니다.

커널 스레드 (Kernel Threads)

커널 스레드는 유저 공간 주소 공간 없이(mm == NULL) 커널 공간에서만 실행되는 태스크입니다. kthreadd(PID 2)가 모든 커널 스레드의 부모이며, kthread_create()/kthread_run() API로 생성합니다. 대표적으로 ksoftirqd, kworker, migration, kswapd 등이 있습니다.

💡

커널 스레드의 생성/종료 API, per-CPU 스레드, kthread_worker, smpboot 프레임워크, 주요 커널 스레드 종합 분석은 Kernel Threads (커널 스레드) 전용 페이지를 참고하세요.

PID / TID 관계 (PID and TID Relationship)

Linux에서 PID와 TID의 관계는 다음과 같습니다:

필드 메인 스레드 추가 스레드 1 추가 스레드 2
pid (= TID) 1000 1001 1002
tgid (= PID) 1000 1000 1000
getpid() 1000 1000 1000
gettid() 1000 1001 1002
💡

PID 네임스페이스: PID는 네임스페이스에 따라 다른 값을 가질 수 있습니다. 컨테이너 안에서 PID 1로 보이는 프로세스가 호스트에서는 전혀 다른 PID를 가집니다. task_struct 내부의 struct pid는 각 네임스페이스 수준별 PID 번호를 보관합니다.

프로세스 종료 (Process Termination)

프로세스 종료는 do_exit() 함수에서 처리됩니다. 종료 과정은 다음과 같습니다:

  1. PF_EXITING 플래그 설정: 이중 종료 방지를 위해 플래그를 설정합니다.
  2. 리소스 해제: 타이머, 시그널 핸들러, 파일 디스크립터, 메모리 디스크립터, 세마포어 등을 해제합니다.
  3. exit_code 설정: 종료 코드를 task_struct.exit_code에 저장합니다.
  4. exit_notify(): 부모에게 SIGCHLD 시그널을 보내고, 자식 프로세스를 입양(reparenting) 처리합니다.
  5. ZOMBIE 상태 전환: EXIT_ZOMBIE 상태로 전환하고 schedule()을 호출합니다. 이 태스크는 다시 스케줄되지 않습니다.
/* kernel/exit.c - do_exit() 핵심 흐름 (간략화) */
void __noreturn do_exit(long code)
{
    struct task_struct *tsk = current;

    tsk->flags |= PF_EXITING;

    exit_signals(tsk);       /* 시그널 처리 정리 */
    exit_mm();               /* 주소 공간 해제 */
    exit_sem(tsk);           /* SysV 세마포어 정리 */
    exit_files(tsk);         /* 파일 디스크립터 닫기 */
    exit_fs(tsk);            /* 파일시스템 참조 해제 */
    exit_task_namespaces(tsk);

    tsk->exit_code = code;
    exit_notify(tsk, group_dead);

    tsk->__state = TASK_DEAD;
    __schedule(SM_NONE);     /* 다시는 돌아오지 않음 */
    BUG();                   /* 도달 불가 */
}

wait()과 좀비 수거 (Zombie Reaping)

부모 프로세스가 wait()/waitpid()/waitid()를 호출하면 커널은 do_wait()을 통해 좀비 상태의 자식을 찾아 종료 상태를 수집하고, release_task()task_struct를 최종 해제합니다.

부모가 wait()을 호출하지 않으면 자식은 좀비 상태로 남아 task_struct 메모리를 계속 점유합니다. 부모가 먼저 종료되면 고아(orphan) 프로세스는 init(PID 1)에게 입양되며, init이 주기적으로 wait()을 호출하여 좀비를 수거합니다.

⚠️

좀비 프로세스 누수: 자식 프로세스를 생성하는 데몬에서 wait()를 적절히 호출하지 않으면 좀비 프로세스가 누적되어 PID 고갈 문제가 발생할 수 있습니다. SIGCHLD 핸들러에서 waitpid(-1, &status, WNOHANG)를 루프로 호출하거나, signal(SIGCHLD, SIG_IGN)으로 자동 수거를 활성화하세요.

코드 예제 (Code Examples)

커널 모듈에서 커널 스레드 생성

ℹ️

커널 스레드 코드 예제(기본 kthread, per-CPU 스레드, kthread_worker, freezable 패턴 등)는 Kernel Threads — 코드 예제로 이동했습니다.

프로세스 리스트 순회 (Traversing the Task List)

#include <linux/sched/signal.h>

/* 모든 프로세스 순회 */
struct task_struct *task;

for_each_process(task) {
    pr_info("[%5d] %s  state=%u  prio=%d\\n",
            task->pid, task->comm,
            task->__state, task->prio);
}

/* 특정 프로세스의 모든 스레드 순회 */
struct task_struct *thread;
struct task_struct *leader = current->group_leader;

for_each_thread(leader, thread) {
    pr_info("  thread: pid=%d comm=%s\\n",
            thread->pid, thread->comm);
}

/* PID로 task_struct 찾기 */
struct task_struct *found;
rcu_read_lock();
found = find_task_by_vpid(1234);
if (found)
    pr_info("found: %s\\n", found->comm);
rcu_read_unlock();

스케줄링 파라미터 확인 및 변경

/* 유저 공간에서 스케줄링 정책/우선순위 변경 */
#include <sched.h>

/* SCHED_FIFO로 변경, 우선순위 50 */
struct sched_param param;
param.sched_priority = 50;
sched_setscheduler(0, SCHED_FIFO, &param);

/* SCHED_DEADLINE 설정 (clone3 또는 sched_setattr 필요) */
struct sched_attr attr = {
    .size           = sizeof(attr),
    .sched_policy   = SCHED_DEADLINE,
    .sched_runtime  = 10000000,   /* 10ms */
    .sched_deadline = 30000000,   /* 30ms */
    .sched_period   = 30000000,   /* 30ms */
};
sched_setattr(0, &attr, 0);
⚠️

RT 프로세스 주의: SCHED_FIFOSCHED_RR 정책의 프로세스가 무한 루프에 빠지면 해당 CPU의 다른 일반 프로세스는 실행되지 못합니다. 커널 설정 CONFIG_RT_GROUP_SCHED/proc/sys/kernel/sched_rt_runtime_us(기본 950000, 즉 1초당 최대 950ms)로 RT 프로세스의 CPU 독점을 제한할 수 있습니다.

프로세스 관리 관련 주요 버그 사례

리눅스 커널의 프로세스 관리 서브시스템에서 발견된 주요 보안 취약점과 버그 사례를 분석합니다. 이러한 사례를 이해하면 커널 코드 작성 시 유사한 실수를 방지할 수 있습니다.

Dirty Pipe (CVE-2022-0847) — pipe 버퍼 플래그 오류

splice() 시스템 콜로 pipe에 데이터를 넣을 때, pipe 버퍼의 PIPE_BUF_FLAG_CAN_MERGE 플래그가 올바르게 초기화되지 않는 버그입니다. 이로 인해 pipe 버퍼가 페이지 캐시(page cache) 페이지를 참조하면서도 쓰기 가능한 상태가 되어, 읽기 전용 파일이나 SUID 바이너리를 덮어쓸 수 있는 로컬 권한 상승 취약점이 발생했습니다.

⚠️

심각도: CRITICAL — Linux 5.8부터 5.16.10까지 영향을 받으며, 비특권 사용자가 읽기 전용 파일을 수정하여 root 권한을 획득할 수 있습니다. 발견자: Max Kellermann. CVSS 점수 7.8.

취약점의 핵심은 copy_page_to_iter_pipe() 함수에서 새로 할당된 pipe 버퍼의 flags 필드를 초기화하지 않은 것입니다. 이전에 PIPE_BUF_FLAG_CAN_MERGE가 설정된 버퍼가 재사용되면, splice()로 매핑된 페이지 캐시 페이지에 일반 write()로 데이터를 병합(merge)할 수 있게 됩니다.

/* 취약한 코드 (fs/pipe.c) — 플래그 미초기화 */
static size_t copy_page_to_iter_pipe(struct page *page,
                                     size_t offset, size_t bytes,
                                     struct iov_iter *i)
{
    struct pipe_inode_info *pipe = i->pipe;
    struct pipe_buffer *buf;
    unsigned int p_tail = pipe->tail;
    ...
    buf = &pipe->bufs[p_tail & (pipe->ring_size - 1)];
    buf->page = page;
    buf->offset = offset;
    buf->len = bytes;
    /* BUG: buf->flags 초기화 누락!
     * 이전 사용에서 PIPE_BUF_FLAG_CAN_MERGE가 남아 있으면
     * 페이지 캐시 페이지에 쓰기가 가능해짐 */
    ...
}
/* 수정된 코드 — 플래그를 명시적으로 초기화 */
buf->flags = 0;  /* PIPE_BUF_FLAG_CAN_MERGE 제거 */
ℹ️

공격 시나리오: 공격자는 (1) pipe를 생성하고 버퍼를 모두 채운 뒤 비워서 PIPE_BUF_FLAG_CAN_MERGE를 설정하고, (2) splice()로 대상 파일의 페이지 캐시를 pipe에 매핑한 후, (3) write()로 원하는 데이터를 덮어씁니다. 이 과정은 /etc/passwd나 SUID 바이너리에 적용하여 root 쉘을 획득할 수 있습니다.

ELF 로더 취약점 — Stack Clash (CVE-2017-1000364)

리눅스 커널의 ELF 로더가 사용자 공간 스택을 설정할 때, guard page가 충분하지 않아 스택이 다른 메모리 영역(heap, mmap 등)과 충돌하는 취약점입니다. 재귀 호출이나 큰 로컬 변수 할당을 통해 guard page를 한 번에 건너뛸 수 있었습니다.

⚠️

심각도: HIGH — 스택과 heap/mmap 영역의 경계 보호가 단일 guard page(4KB)에만 의존했기 때문에, 4KB 이상의 스택 프레임을 할당하는 함수 호출로 guard page를 건너뛰어 인접 메모리 영역을 덮어쓸 수 있었습니다. 이를 통해 로컬 권한 상승이 가능했습니다.

/* 취약점 원리: 스택 가드 페이지 우회 */

/* 높은 주소 */
/* ┌─────────────────────┐ */
/* │   User Stack        │ ← 아래로 성장 */
/* ├─────────────────────┤ */
/* │ Guard Page (4KB)    │ ← 단일 페이지, 건너뛸 수 있음! */
/* ├─────────────────────┤ */
/* │   mmap / heap       │ ← 위로 성장 */
/* └─────────────────────┘ */
/* 낮은 주소 */

/* 공격 코드 예시: 큰 로컬 변수로 guard page 건너뛰기 */
void exploit(void) {
    char buf[1024 * 1024];  /* 1MB — guard page(4KB)를 한 번에 건너뜀 */
    buf[0] = 'A';            /* heap/mmap 영역에 쓰기 발생 */
}
/* 수정: stack_guard_gap 확대 (mm/mmap.c) */
unsigned long stack_guard_gap = 256UL << PAGE_SHIFT;  /* 256 페이지 = 1MB */

static int __init cmdline_parse_stack_guard_gap(char *p)
{
    unsigned long val;
    if (!p)
        return -EINVAL;
    if (kstrtoul(p, 10, &val))
        return -EINVAL;
    stack_guard_gap = val << PAGE_SHIFT;
    return 0;
}
__setup("stack_guard_gap=", cmdline_parse_stack_guard_gap);
ℹ️

RLIMIT_STACK과의 상호작용: RLIMIT_STACK은 프로세스 스택의 최대 크기를 제한하지만, guard page 크기와는 독립적으로 동작합니다. 수정 후에는 stack_guard_gap(기본 1MB)이 스택 확장 시 인접 VMA와의 최소 거리를 보장합니다. 커널 부트 파라미터 stack_guard_gap=N으로 페이지 단위 조정이 가능합니다.

ASLR 우회 기법과 대응

ASLR(Address Space Layout Randomization)은 프로세스의 메모리 레이아웃을 무작위화하여 공격을 어렵게 만드는 핵심 보안 메커니즘입니다. 그러나 다양한 정보 누출(information leak) 경로를 통해 ASLR이 무력화될 수 있으며, 커널은 이에 대한 대응책을 지속적으로 강화해 왔습니다.

⚠️

정보 누출 경로: /proc/self/maps 파일은 프로세스의 전체 메모리 맵을 노출하며, 이를 통해 ASLR이 완전히 무력화됩니다. 또한 커널 로그(dmesg)에 출력되는 커널 포인터 주소도 KASLR 우회에 사용될 수 있습니다.

/* printk의 %p → %pK 변환 (commit 57e734423ad) */
/* 변경 전: 커널 주소가 그대로 노출 */
printk(KERN_INFO "object at %p\\n", obj);
/* 출력: "object at ffff8880123abc00" ← 실제 주소 노출! */

/* 변경 후: 해시된 주소 출력 */
printk(KERN_INFO "object at %p\\n", obj);
/* 출력: "object at 00000000deadbeef" ← 해시값 (비특권 사용자) */

/* 제한된 포인터 출력 (%pK): kptr_restrict에 따라 동작 */
printk(KERN_INFO "symbol at %pK\\n", sym);
/* kptr_restrict=0: 실제 주소 출력 (기본값) */
/* kptr_restrict=1: CAP_SYSLOG 없으면 0 출력 */
/* kptr_restrict=2: 항상 0 출력 */
/* ASLR 강화를 위한 sysctl 설정 */

/* dmesg 접근 제한 */
# sysctl -w kernel.dmesg_restrict=1

/* 커널 포인터 출력 제한 */
# sysctl -w kernel.kptr_restrict=1

/* /proc/sys/kernel/randomize_va_space 설정값 */
/* 0: ASLR 비활성화 */
/* 1: mmap, 스택, VDSO 무작위화 */
/* 2: 1 + brk 무작위화 (기본값, 완전 ASLR) */
ℹ️

Stack canary와 ASLR의 상보적 보호: Stack canary는 버퍼 오버플로우를 탐지하고, ASLR은 공격 대상 주소의 예측을 방지합니다. 두 메커니즘은 독립적으로 동작하지만, 함께 사용할 때 보호 효과가 극대화됩니다. Stack canary가 우회되더라도 ASLR이 유효하면 ROP 공격이 어렵고, ASLR이 우회되더라도 canary가 스택 기반 공격을 차단합니다. GCC의 -fstack-protector-strong 옵션으로 canary를 활성화할 수 있습니다.

fork() bomb과 PID 고갈

fork()를 무한 반복 호출하여 시스템의 PID를 고갈시키는 fork bomb은 가장 단순하면서도 효과적인 서비스 거부(DoS) 공격입니다. 리눅스 커널은 여러 계층에서 이를 방어하는 메커니즘을 제공합니다.

/* 고전적인 fork bomb */
/* bash: :(){ :|:& };: */

#include <unistd.h>

int main(void)
{
    while (1)
        fork();  /* 프로세스 수가 기하급수적으로 증가 */
    return 0;
}
/* 방어 1: RLIMIT_NPROC — 사용자별 프로세스 수 제한 */
#include <sys/resource.h>

struct rlimit rl;
rl.rlim_cur = 1024;   /* 소프트 한계: 1024개 */
rl.rlim_max = 4096;   /* 하드 한계: 4096개 */
setrlimit(RLIMIT_NPROC, &rl);

/* /etc/security/limits.conf 설정 */
/* *    hard    nproc    4096  */
/* *    soft    nproc    1024  */
/* 방어 2: cgroup pids 컨트롤러 — 그룹 단위 PID 제한 */
/* cgroup v2에서 pids.max 설정 */

# cgroup 생성 및 PID 제한 설정
# mkdir /sys/fs/cgroup/mygroup
# echo 100 > /sys/fs/cgroup/mygroup/pids.max
# echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs

/* 커널 코드: cgroup pids 제한 확인 (kernel/cgroup/pids.c) */
static int pids_can_fork(struct task_struct *task,
                         int *retval)
{
    struct pids_cgroup *pids;
    int64_t limit;

    pids = css_to_pids(task_css(task, pids_cgrp_id));
    limit = atomic64_read(&pids->limit);

    if (limit != PIDS_MAX &&
        atomic64_read(&pids->counter) >= limit) {
        *retval = -EAGAIN;
        return 1;  /* fork 거부 */
    }
    atomic64_inc(&pids->counter);
    return 0;
}
ℹ️

PID namespace 격리: PID namespace는 컨테이너 환경에서 fork bomb의 영향 범위를 제한하는 핵심 메커니즘입니다. 각 PID namespace는 독립적인 PID 번호 공간을 가지며, namespace 내부의 프로세스 폭주가 호스트의 전체 PID 공간을 고갈시키지 않습니다. cgroup pids.max와 결합하면 namespace당 프로세스 수를 엄격하게 제한할 수 있어, 컨테이너 오케스트레이션 환경(Kubernetes 등)에서는 반드시 설정해야 하는 보안 항목입니다.

프로세스 관리와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.