Skip to content

8.1 프로세스와 스케줄링 — 코어 8개 위의 스레드 800개

커널은 프로세스도 스레드도 아닌 "태스크"를 스케줄링하고, 태스크 교체 비용의 큰 몫은 레지스터 저장이 아니라 캐시·TLB에 쌓아 둔 작업 상태의 상실이다. 이 문서는 실행의 단위와 교체 비용, 스케줄러의 동작 모델을 세워서 load average와 컨텍스트 스위치 카운터 같은 CPU 지표를 해석하고, 스레드 풀 크기 같은 설계 판단의 근거를 만든다. 기준 OS는 Linux(커널 6.x)이고, POSIX가 정의하는 것과 Linux 구현의 선택을 구분해 서술한다.

학습 목표

  • 프로세스와 스레드를 커널의 태스크 모델로 통일해 설명하고, 공유되는 것과 격리되는 것을 근거로 둘 사이의 선택을 판단한다.
  • 시스템 콜의 모드 전환과 컨텍스트 스위치를 구분하고, 스위치의 직접 비용과 간접 비용(캐시·TLB 워밍업 상실)의 구조를 설명한다.
  • CFS에서 EEVDF로 이어지는 Linux 스케줄러의 모델로 타임슬라이스, nice, run queue의 동작을 설명한다.
  • load average와 voluntary/nonvoluntary 컨텍스트 스위치 카운터를 읽고 CPU 포화, I/O 대기, 과도한 스위칭을 구분해 진단한다.
  • 워크로드 성격(CPU 바운드/I/O 바운드)과 실행 환경(컨테이너 CPU 제한)을 근거로 스레드 풀 크기를 판단한다.

배경: 왜 이것이 존재하는가

CPU 코어는 한 시점에 하나의 명령 흐름만 실행한다. 그런데 서버 한 대에는 수백 개의 스레드가 "동시에" 떠 있고, 코어 8개짜리 머신에서 스레드 800개가 별문제 없이 돈다. 이 간극을 메우는 것이 운영체제의 첫 번째 착시, **CPU의 시분할(time sharing)**이다.

초기 컴퓨터는 배치(batch) 방식이었다. 작업 하나가 끝나야 다음 작업이 시작됐고, 작업이 디스크나 테이프를 기다리는 동안 CPU는 놀았다. I/O 대기 시간에 다른 작업을 실행하는 멀티프로그래밍이 CPU 활용률 문제를 풀었고, 터미널 앞의 사람에게 즉각 반응해야 하는 대화형 시스템이 등장하자 "작업이 양보할 때까지 기다리는 것"으로는 부족해졌다. 짧은 간격으로 강제로 실행권을 교대시키는 선점형(preemptive) 타임 셰어링이 그 답이다.

이 설계는 두 가지 문제를 풀어야 한다. 첫째, 실행 중인 프로그램에서 제어를 어떻게 되찾는가. 프로그램이 자발적으로 양보하기를 기대하는 협력적(cooperative) 방식은 무한 루프 하나가 시스템 전체를 멈추게 한다(고전 Mac OS와 Windows 3.x가 이 방식이었다). 현대 커널은 타이머 인터럽트로 주기적으로 제어를 회수한다. 둘째, 교대할 때 무엇을 보존해야 다음에 이어서 실행할 수 있는가. 이 보존 대상이 컨텍스트(context)이고, 교대 작업이 컨텍스트 스위치다.

그 결과가 프로세스라는 추상화다. 각 프로그램은 CPU 전체와 메모리 전체를 혼자 쓰는 것처럼 보이지만, 실제로는 커널이 CPU를 잘게 나눠 빌려주고(가상 메모리가 메모리 쪽 착시를 담당한다) 경계 지점 — 시스템 콜, 인터럽트, 예외 — 에서만 개입한다. 교과서(OSTEP)는 이를 **제한적 직접 실행(limited direct execution)**이라 부른다. 프로그램 코드는 대부분의 시간 동안 하드웨어에서 직접 실행되고, 커널의 개입 비용은 그 경계에서만 지불된다. 이 문서의 주제는 정확히 그 경계의 비용이다.

한편 협력적 방식이 사라진 것은 아니다. async/await와 이벤트 루프는 커널의 선점형 스케줄링 위에서 사용자 공간의 협력적 스케줄링을 다시 세운 것이다. 왜 그런 이중 구조가 등장했는지는 8.3의 I/O 모델에서 비용 논거와 함께 다룬다.

핵심 개념

커널의 눈에는 태스크만 보인다

교과서의 구분은 이렇다. **프로세스(process)**는 자원의 컨테이너다 — 주소 공간, 열린 파일 디스크립터, 자격 증명(credential)을 소유한다. **스레드(thread)**는 실행의 단위다 — 자기만의 스택과 레지스터 상태를 갖고, 같은 프로세스의 다른 스레드와 나머지 자원을 공유한다.

Linux 커널 구현에서는 이 둘이 별개의 개체가 아니다. 커널이 스케줄링하는 단위는 태스크(task) 하나뿐이고, 프로세스와 스레드의 차이는 태스크를 만들 때 무엇을 공유하기로 했는가의 차이다. 태스크 생성 시스템 콜 clone(2)은 공유 여부를 플래그로 받는다.

자원fork() (프로세스 생성)pthread_create() (스레드 생성)
주소 공간복제 (copy-on-write)공유 (CLONE_VM)
파일 디스크립터 테이블복제공유 (CLONE_FILES)
시그널 핸들러복제공유 (CLONE_SIGHAND)
스택, 레지스터, 스케줄링 상태태스크마다 각자태스크마다 각자

pthread_create는 내부적으로 CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | … 플래그를 켠 clone 호출이고, fork는 거의 아무것도 공유하지 않는 clone이다. 그래서 커널 스케줄러 입장에서 스레드 800개와 프로세스 800개는 똑같이 태스크 800개다. "프로세스는 무겁고 스레드는 가볍다"는 통념은 스케줄링 비용의 차이가 아니라 (1) 생성 비용(주소 공간 복제 여부)과 (2) 스위치할 때 주소 공간 전환이 필요한지의 차이로 정확히 번역해야 한다.

커널이 태스크마다 저장하는 것은 "스위치를 가능하게 하는 최소 상태"로 이해하면 충분하다: 레지스터 상태를 보관할 자리, 자기만의 커널 스택, 스케줄링 정보(우선순위, 소비한 실행 시간), 그리고 주소 공간·파일 테이블 같은 공유 자원을 가리키는 포인터들. (커널 소스의 task_struct 필드 나열은 이 문서의 범위 밖이다.)

관찰 도구도 이 모델을 따른다. getpid()는 스레드 그룹의 ID(TGID)를, gettid()는 태스크 자신의 ID를 반환한다. ps -eLf는 태스크(스레드) 단위로 목록을 보여주고, /proc/<pid>/task/ 아래에 태스크별 디렉터리가 있다.

구분해 둘 것: pthreads의 의미론(스레드가 무엇을 공유하는가)은 POSIX가 정의하고, "사용자 스레드 하나 = 커널 태스크 하나"라는 1:1 매핑은 Linux(glibc의 NPTL 구현)의 선택이다. Go의 고루틴이나 Java 21+의 가상 스레드는 반대 선택 — 사용자 공간 런타임이 다수의 경량 실행 흐름을 소수의 커널 태스크(캐리어 스레드) 위에 다중화하는 M:N 모델 — 이다. 이 선택의 함의는 진단할 때 드러난다. 커널 지표(컨텍스트 스위치 수, run queue)는 커널 태스크만 센다. 고루틴 십만 개가 교대해도 커널 스위치 카운터는 조용할 수 있다.

fork와 exec — 복제한 다음 교체한다

UNIX의 프로세스 생성은 "새 프로그램을 실행해 달라"는 하나의 호출이 아니라 두 단계로 분리되어 있다. fork()는 호출한 프로세스의 복제본을 만들고(한 번 호출하면 부모와 자식에서 각각 리턴한다), exec() 계열은 현재 프로세스의 주소 공간을 새 프로그램으로 교체한다.

이 분리가 사는 이점은 두 단계 사이에 자식이 자신을 설정할 수 있다는 것이다. 셸의 리다이렉션(cmd > out.txt)은 fork 후 자식이 표준 출력 파일 디스크립터를 파일로 바꾼 다음 exec하는 것으로 구현된다. exec은 주소 공간을 교체하지만 파일 디스크립터는 기본적으로 유지하기 때문이다(넘기고 싶지 않은 fd에는 O_CLOEXEC를 켠다 — fd가 무엇인지는 8.3에서 다룬다).

"복제"가 비싸 보이지만 실제로는 그렇지 않다. fork는 부모의 메모리를 전부 복사하지 않고 copy-on-write로 지연시킨다 — 이 메커니즘은 8.2에서 다룬다. 다만 페이지 테이블 복사 비용은 남아 있어서, 주소 공간이 큰 프로세스가 작은 유틸리티를 실행하는 경로에서는 fork+exec 대신 posix_spawn(3)이 쓰인다.

시스템 콜 — 모드 전환은 컨텍스트 스위치가 아니다

프로세스가 커널의 서비스(파일 읽기, 소켓 생성)를 받으려면 유저 모드에서 커널 모드로 넘어가야 한다. CPU의 특권 수준을 바꾸는 전용 명령(x86-64의 syscall)이 커널의 진입점으로 점프하고, 커널은 요청을 처리한 뒤 유저 모드로 복귀한다. 이것이 **모드 전환(mode switch)**이다.

모드 전환은 컨텍스트 스위치가 아니다. 이 구분이 자주 뭉개지므로 정확히 해 두자.

  • 모드 전환: 같은 태스크가 계속 실행된다. 주소 공간은 그대로이고, 스케줄러는 개입하지 않는다. 저장하는 것은 커널 진입에 필요한 최소 레지스터뿐이다.
  • 컨텍스트 스위치: 실행되는 태스크 자체가 바뀐다. 스케줄러가 개입하고, 프로세스가 다르면 주소 공간도 바뀐다.

물론 시스템 콜이 스위치의 계기가 되는 경우는 많다. read()가 데이터를 기다리며 블로킹되면 커널은 그 태스크를 재우고 다른 태스크로 스위치한다. 하지만 캐시에 있는 데이터를 읽는 read()는 모드 전환 두 번(진입·복귀)으로 끝나고 스위치는 일어나지 않는다.

모드 전환 왕복의 직접 비용은 현대 x86-64에서 수십~수백 ns 자릿수다(정확한 값은 CPU와 완화 패치 상태에 따라 다르며, 직접 측정하는 절차는 이 챕터의 실습 과제 exercises/ch-8/ Part A에 있다). 2018년 Meltdown 취약점 완화(KPTI)가 커널 진입마다 페이지 테이블을 갈아 끼우게 만들면서 이 비용이 유의미하게 커진 적이 있다는 사실은, 이 수치가 "상수"가 아니라 재측정 대상임을 보여준다.

자주 호출되는 일부 함수는 아예 커널에 들어가지 않는다. clock_gettime(), gettimeofday()는 커널이 유저 공간에 매핑해 준 코드·데이터(vDSO)로 처리되어 모드 전환이 없다. strace -c로 어떤 호출이 실제 시스템 콜인지 확인할 수 있다 — vDSO로 처리된 호출은 strace에 나타나지 않는다. 단, strace는 ptrace 기반이라 대상 프로그램을 수십 배 느리게 만들 수 있으므로 관찰 도구이지 측정 도구가 아니다.

컨텍스트 스위치 — 무엇을 저장하고, 무엇을 잃는가

컨텍스트 스위치의 직접 비용은 커널이 하는 일의 목록이다: 현재 태스크의 레지스터(필요 시 FPU/SIMD 상태 포함)를 저장하고, 스케줄러가 다음 태스크를 고르고, 다음 태스크의 상태를 복원한다. 서로 다른 프로세스 간이면 주소 공간 전환(x86-64에서 페이지 테이블 베이스 레지스터 CR3 교체)이 추가된다 — 같은 프로세스의 스레드끼리는 이 단계가 생략되므로 스레드 간 스위치가 프로세스 간 스위치보다 싸다. 직접 비용의 자릿수는 대략 1μs 안팎이다(환경 의존, 실습에서 측정한다).

더 큰 몫은 간접 비용이다. 스위치 직전까지 실행되던 태스크는 자기 데이터와 코드를 CPU 캐시에, 주소 변환 결과를 TLB에 쌓아 두고 있었다. 새 태스크가 들어오면 이 축적이 자신에게는 무의미하므로, 실행 초반은 캐시 미스와 TLB 미스의 연쇄가 된다. 즉 스위치의 진짜 청구서는 스위치 시점이 아니라 스위치 이후의 재워밍업 구간에서 날아온다. working set이 큰 워크로드일수록 이 간접 비용이 직접 비용을 압도하며, 수십~수백 μs 상당의 효과가 될 수 있다. 캐시·TLB가 왜 이런 배율을 만드는지는 7.2 메모리 계층과 캐시 일관성에서 하드웨어 쪽 절반을 다루고, 이 챕터의 실습 Part A-3에서 working set 크기를 바꿔 가며 직접 관찰한다.

스위치는 두 부류로 나뉘고, 이 구분이 진단의 축이 된다.

  • 자발적(voluntary) 스위치: 태스크가 스스로 실행을 계속할 수 없어서 발생한다 — I/O 대기, 락 경합(경합에 진 스레드는 잠들고, 이것이 스위치를 유발한다), sleep. I/O 바운드 워크로드의 시그니처다.
  • 비자발적(nonvoluntary) 스위치: 태스크는 계속 실행할 수 있는데 스케줄러가 빼앗는다 — 타임슬라이스 소진, 더 급한 태스크의 선점. runnable 태스크가 코어 수보다 많다는 뜻이며, CPU 경합의 시그니처다.

두 카운터는 /proc/<pid>/statusvoluntary_ctxt_switches, nonvoluntary_ctxt_switches에 태스크별로 누적되고, pidstat -w가 초당 비율(cswch/s, nvcswch/s)로 보여준다.

스케줄러 — 공정성을 지키면서 지연을 낮추기

runnable 태스크가 코어보다 많을 때 누구를 얼마나 실행할지 정하는 것이 스케줄러다. 설계 목표부터 상충한다. 처리량을 높이려면 스위치를 줄이고 한 번에 길게 실행해야 하고, 응답 지연을 낮추려면 자주 교대해서 방금 깨어난 태스크를 빨리 실행해야 한다. 타임슬라이스 길이가 이 상충의 다이얼이다.

일반 태스크(SCHED_OTHER 정책)에 대한 Linux의 답은 가중치 기반 공정 분배다. 우선순위는 nice 값(-20~19)으로 조정하고, nice 한 단계는 스케줄링 가중치 약 1.25배에 해당한다 — nice가 낮을수록(우선순위가 높을수록) 같은 시간 동안 더 많은 CPU 몫을 받는다.

2007년부터 쓰인 CFS(Completely Fair Scheduler)는 이 모델을 가상 런타임(vruntime)으로 구현했다. 각 태스크의 실행 시간을 가중치로 보정해 누적하고, 항상 vruntime이 가장 작은 — 자기 몫을 가장 덜 받은 — 태스크를 다음에 실행한다. 고정 타임슬라이스는 없고, 목표 지연 구간(targeted latency)을 runnable 태스크 수로 나눈 몫이 사실상의 슬라이스가 된다. runnable이 많아질수록 슬라이스는 짧아지고(하한 있음), 태스크당 차례는 늦게 돌아온다.

CFS의 약점은 지연 요구를 표현할 방법이 없다는 것이었다. CPU 몫은 같게 받아도 "조금씩 자주" 실행돼야 하는 태스크(오디오, 대화형)와 "몰아서 가끔"이면 충분한 태스크(배치)를 구분할 수 없어, 깨어난 태스크를 언제 선점시킬지에 대한 휴리스틱이 누적됐다. 커널 6.6부터 CFS를 대체한 EEVDF(Earliest Eligible Virtual Deadline First)는 이를 모델로 흡수했다. 각 태스크에 대해 (1) 지금까지 공정한 몫보다 덜 받았는지(eligible)를 판정하고, (2) 요청한 슬라이스 길이로부터 가상 데드라인을 계산해, eligible한 태스크 중 데드라인이 가장 이른 것을 실행한다. 짧은 슬라이스를 요청한 태스크는 데드라인이 일찍 오므로 받는 양은 같아도 차례가 빨리 돌아온다 — 공정성(양)과 지연(시점)을 분리한 것이다. 설계 방향만 기억하면 된다: 스케줄러는 여전히 가중 공정 분배를 하고, EEVDF는 그 틀 안에서 지연 민감성을 1급 개념으로 만들었다.

구조 쪽에서 기억할 것 두 가지. run queue는 코어마다 하나씩이고(전역 큐의 락 경합을 피하기 위해), 주기적인 로드 밸런싱이 코어 간 태스크를 옮긴다. 밸런서는 태스크를 함부로 옮기지 않는다 — 옮기는 순간 그 코어 캐시에 쌓인 working set을 잃기 때문이다(간접 비용과 같은 원리).

실시간 정책 SCHED_FIFO/SCHED_RR/SCHED_DEADLINE은 이름과 용도만 알아 두면 된다: 일반 태스크 전체보다 우선하며, 오디오 파이프라인이나 제어 루프처럼 마감이 있는 워크로드용이다. 일반 서버 진단에는 등장하지 않는다. 스케줄링 정책이라는 인터페이스와 실시간 정책의 의미론은 POSIX가 정의하지만, SCHED_OTHER어떻게 동작하는지는 전적으로 구현(Linux의 CFS/EEVDF)의 몫이다 — 이 문단의 세부는 전부 Linux 이야기다.

load average — CPU 사용률이 아니라 수요의 총량

uptime이 보여주는 세 숫자(1분, 5분, 15분)는 CPU 사용률이 아니다. Linux의 load average는 다음 태스크 수의 지수 감쇠 이동평균이다.

실행 중이거나 실행 대기 중(runnable)인 태스크 + uninterruptible sleep(D 상태) 태스크

두 번째 항이 함정의 근원이다. 다른 UNIX 계열은 runnable만 세지만, Linux는 1993년부터 디스크 I/O 등을 기다리며 중단 불가 상태로 잠든 태스크를 포함시켰다(경위는 Brendan Gregg의 조사가 표준 참고 자료다). 그래서 Linux의 load average는 "CPU 수요"가 아니라 **"CPU + 일부 I/O를 합친 시스템 수요"**다.

해석 규칙은 세 가지다.

  1. 코어 수에 상대적으로 읽는다. load 8.0은 8코어 머신에서는 포화 경계이고, 32코어 머신에서는 여유다. 절대값 1.0에 의미를 부여하는 습관은 단일 코어 시대의 유산이다.
  2. 세 창의 관계로 추세를 읽는다. 1분 > 15분이면 부하 증가 중, 반대면 해소 중이다.
  3. CPU 사용률과 함께 읽어야 원인이 갈린다. load는 높은데 CPU가 놀고 있다면(예: load 30, idle 90%) 태스크들이 D 상태 — 디스크, NFS 응답, 커널 내부 대기 — 에 몰려 있다는 뜻이다. CPU를 늘려도 해결되지 않는다.

CPU 수요와 I/O 대기가 섞인 지표라는 태생적 한계 때문에, 원인 분리가 필요하면 더 직접적인 지표를 본다. vmstatr 열(runnable 태스크 수 — 코어 수와 직접 비교 가능)과 b 열(uninterruptible 태스크 수), 그리고 커널 4.20+의 PSI(Pressure Stall Information, /proc/pressure/{cpu,memory,io}) — "자원이 없어서 태스크가 기다린 시간의 비율"을 자원별로 분리해 주는, load average의 현대적 대체재다.

관찰 — oversubscription이 지표에 남기는 흔적

CPU 바운드 태스크가 코어 수를 초과할 때 비자발적 스위치가 나타나는 것을 관찰한다. 다음 예제는 Linux 전용이다(taskset, /proc, pidstat). macOS에서는 Linux 컨테이너나 VM에서 실행한다(구성은 exercises/ch-8/ 참고).

c
// spin.c — N개 스레드가 각자 같은 양의 계산을 수행한다
// 빌드: gcc -O2 -pthread spin.c -o spin
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define WORK (1u << 28)

static void *spin(void *arg) {
    volatile unsigned x = 0;
    for (unsigned i = 0; i < WORK; i++) x += i;
    return NULL;
}

int main(int argc, char **argv) {
    if (argc < 2) { fprintf(stderr, "usage: %s <threads>\n", argv[0]); return 1; }
    int n = atoi(argv[1]);
    pthread_t *t = malloc(sizeof(pthread_t) * n);
    for (int i = 0; i < n; i++) pthread_create(&t[i], NULL, spin, NULL);
    for (int i = 0; i < n; i++) pthread_join(t[i], NULL);
    return 0;
}
sh
# 코어 4개에 고정하고, 스레드 수를 코어 수와 그 4배로 실행한다
taskset -c 0-3 ./spin 4 &
pidstat -w -t -p $! 1        # 실행되는 동안 태스크별 스위치 비율 관찰
wait

taskset -c 0-3 ./spin 16 &
pidstat -w -t -p $! 1
wait

예상되는 관찰: 스레드 4개(= 코어 수)일 때 각 스레드의 nvcswch/s(비자발적 스위치)는 0에 가깝다 — 각자 코어를 독점하므로 빼앗길 일이 없다. 스레드 16개일 때는 각 스레드가 타임슬라이스마다 선점되므로 nvcswch/s가 초당 수십~수백 회 규모로 뛴다. 반면 전체 작업량(총 계산)이 같으므로 총 소요 시간은 두 경우 큰 차이가 없다 — 스위치의 직접 비용은 이 규모에서는 총량 대비 작기 때문이다. 달라지는 것은 스레드 하나가 자기 몫을 끝내는 데 걸리는 시간, 즉 개별 작업의 지연이다(4배의 태스크가 코어를 나누므로 대략 4배). 정밀한 비용 분리는 실습 Part A가 담당한다.

실무 관점

스레드 풀 크기의 논리

스레드 풀 크기 판단의 출발점은 워크로드의 성격이다.

  • CPU 바운드: runnable 스레드가 코어 수를 넘는 순간부터는 처리량이 늘지 않고 교대만 잦아진다. 코어 수(±α)가 기준선이다. 늘려서 얻는 것이 없고, 지연과 스위치 비용만 얻는다.
  • I/O 바운드: 스레드가 대부분의 시간을 블로킹 대기로 보내므로, 코어를 채우려면 코어 수보다 많은 스레드가 필요하다. 스레드가 전체 시간 중 대기에 W, 계산에 C를 쓴다면 코어당 대략 (1 + W/C)개가 있어야 코어가 논다 — 이 비율은 워크로드마다 다르고 시간에 따라 변하므로, 계산으로 시작하되 측정(코어 활용률과 지연)으로 보정한다.

이 논리의 연장선에 아키텍처 선택이 있다. W/C가 매우 크면(연결은 많은데 연결당 계산이 적으면) 필요한 스레드 수가 폭증하고, 그 지점에서 "스레드로 대기를 표현하는 것" 자체를 포기하는 이벤트 루프 모델이 등장한다 — 8.3의 주제다.

포화의 신호 — 처리량이 아니라 지연을 봐라

runnable이 코어 수를 넘어선 뒤에도 처리량 그래프는 한동안 평평하다. 무너지는 것은 지연이다. 대기열이 생기면 각 작업의 완료 시간에 "큐에서 기다린 시간"이 더해지고, 이는 p99 같은 꼬리 지연에 먼저 나타난다. 포화 진단의 신호들:

  • vmstatr이 코어 수를 지속적으로 초과
  • pidstat -w에서 nvcswch/s 비중 증가 (CPU 경합) — cswch/s(자발적)가 지배적이면 CPU가 아니라 I/O·락 쪽을 본다
  • /proc/pressure/cpu의 some 비율 상승 — "runnable인데 CPU를 못 받아 기다린 시간"의 직접 측정치

컨테이너에서 "코어 수"는 거짓말을 한다

cgroup으로 CPU quota가 걸린 컨테이너 안에서도 nproc이나 런타임의 "사용 가능 프로세서 수"는 호스트의 코어 수를 반환할 수 있다. 32코어 호스트에서 2코어 quota로 도는 컨테이너가 "코어 32개 기준"으로 스레드 풀을 잡으면, 실제로는 16배 oversubscription이 된다 — 스로틀링으로 주기적인 지연 스파이크가 나타난다. 사용하는 런타임이 cgroup 제한을 인지하는지(버전에 따라 다르다)를 확인하는 것이 컨테이너 배포 체크리스트에 들어가야 한다. cgroup 자체의 동작은 챕터 12(가상화와 클라우드)에서 다룬다.

CPU 지표 치트시트

지표위치세는 것읽는 법
load average/proc/loadavg, uptimerunnable + uninterruptible 태스크 수의 이동평균코어 수 대비, 세 창의 추세, CPU 사용률과 교차
r / bvmstat 1runnable 수 / uninterruptible 수r > 코어 수 지속 = CPU 포화, b 지속 = I/O 쪽
csvmstat 1시스템 전체 초당 컨텍스트 스위치절대값보다 평소 대비 급변
cswch/s, nvcswch/spidstat -w [-t]태스크별 자발/비자발 스위치 비율워크로드 성격 판별 (I/O 대기·락 vs CPU 경합)
voluntary/nonvoluntary_ctxt_switches/proc/<pid>/status태스크별 누적 카운터두 시점의 차이로 구간 측정
PSI/proc/pressure/cpu자원 부족으로 지연된 시간 비율자원별 원인 분리, load average의 대체

통념: "컨텍스트 스위치 비용은 마이크로초라 무시해도 된다"

직접 비용만 보면 맞는 말처럼 들린다. 초당 스위치 1만 회 × 1μs ≈ CPU의 1%다. 함정은 두 가지다. 첫째, 간접 비용(캐시·TLB 재워밍업)은 이 계산에 없고, working set이 큰 워크로드에서는 직접 비용의 수십 배가 될 수 있다. 둘째, 비용은 평균이 아니라 어느 경로에서 지불되는가가 문제다. 락 경합으로 인한 자발적 스위치는 정확히 가장 붐비는 임계 구역 경로에서 발생한다. 스위치 수를 줄이는 일반적인 지렛대는 스레드 수 축소(oversubscription 해소), 락 경합 완화, 블로킹 I/O의 묶음 처리다.

더 깊이

스레드 스위치가 프로세스 스위치보다 싼 정확한 이유

주소 공간 전환은 페이지 테이블 베이스 레지스터를 갈아 끼우는 것이고, 이는 (주소 공간별 태그가 없다면) TLB 무효화를 의미한다. 같은 프로세스의 스레드 간 스위치는 이 단계가 통째로 없다. 커널 스레드로의 스위치도 마찬가지다 — 커널은 직전 프로세스의 주소 공간을 그대로 빌려 쓴다(lazy TLB). 하드웨어도 이 비용을 줄이는 방향으로 진화했다. x86의 PCID(주소 공간 ID 태그)는 프로세스 전환 시 TLB 전체 플러시를 피하게 해 준다. 거꾸로 KPTI(Meltdown 완화)는 커널·유저 페이지 테이블을 분리해서 모드 전환에까지 주소 공간 전환 비용을 얹었다 — PCID가 있는 CPU에서 그 비용을 상당 부분 회수했다. 요점: 스위치 비용의 지형은 하드웨어 기능과 보안 완화 패치에 따라 움직이는 값이므로, 성능이 걸린 판단이라면 자기 환경에서 측정한다(실습 Part A).

사용자 공간 스케줄링은 커널 지표에서 사라진다

고루틴, 가상 스레드, async 태스크의 교대는 함수 호출 수준의 사용자 공간 작업이라 커널 스위치 카운터에 잡히지 않는다. 이는 성능상 이점인 동시에 진단의 함정이다. 이벤트 루프나 고루틴 런타임이 내부적으로 포화해도(태스크 큐 적체) vmstat·pidstat은 조용할 수 있다. 커널 지표는 "커널이 보는 경합"만 보여주므로, M:N 런타임 위의 워크로드는 런타임 자체의 지표(이벤트 루프 지연, 고루틴 스케줄링 지연)를 함께 봐야 한다. 반대 방향의 함정도 있다: M:N 런타임에서 태스크가 블로킹 시스템 콜을 부르면 캐리어 스레드가 통째로 잠들어, 커널 지표에는 자발적 스위치로, 런타임에는 워커 손실로 나타난다.

정리

  • 커널의 스케줄링 단위는 태스크 하나뿐이다. 프로세스와 스레드는 태스크 생성 시 주소 공간·fd 테이블 등을 공유하는지의 차이(clone 플래그)이고, 스케줄러는 둘을 구분하지 않는다.
  • 모드 전환(시스템 콜)과 컨텍스트 스위치는 별개다. 전자는 같은 태스크의 특권 전환(수십~수백 ns 자릿수), 후자는 태스크 교체(직접 비용 ~μs 자릿수 + 캐시·TLB 재워밍업이라는 더 큰 간접 비용)다.
  • 스위치는 자발적(I/O·락 대기)과 비자발적(선점)으로 나뉘고, 두 카운터의 비율이 워크로드의 성격(I/O 바운드 vs CPU 경합)을 말해 준다.
  • Linux 스케줄러는 nice 가중치 기반 공정 분배다. CFS는 vruntime이 가장 작은 태스크를, EEVDF(커널 6.6+)는 eligible한 태스크 중 가상 데드라인이 가장 이른 태스크를 실행해 공정성과 지연 민감성을 분리한다.
  • Linux의 load average는 runnable + uninterruptible 태스크 수다. 코어 수에 상대적으로, CPU 사용률과 교차해서 읽고, 원인 분리가 필요하면 vmstat r/b와 PSI를 쓴다.
  • CPU 바운드 스레드 풀은 코어 수가 기준선, I/O 바운드는 대기/계산 비율로 보정한다. 컨테이너에서는 quota 기준 코어 수를 쓰는지 확인한다.

확인 문제

1. 8코어 서버에서 load average가 24.0인데 CPU 사용률은 12%다. 무슨 일이 벌어지고 있을 가능성이 높은가? 가설을 확인할 지표와 명령을 순서대로 제시하라.

정답과 해설

Linux의 load average는 runnable뿐 아니라 uninterruptible sleep(D 상태) 태스크를 포함한다. CPU가 놀고 있는데 load가 높다면 태스크들이 CPU가 아니라 I/O(디스크, NFS 등)를 기다리며 D 상태에 몰려 있을 가능성이 높다. 확인 절차: (1) vmstat 1에서 r(runnable)과 b(uninterruptible)를 분리해 본다 — 이 시나리오면 b가 크다. (2) ps -eo state,pid,wchan,comm | grep '^D'로 D 상태 태스크와 대기 지점을 특정한다. (3) /proc/pressure/io로 I/O 압력을 확인하고, iostat -x로 어느 디바이스가 포화인지(%util, 대기 큐) 본다. CPU 증설은 답이 아니고, 스토리지 지연·NFS 응답·과도한 동기 쓰기 쪽을 조사해야 한다.

2. 16코어 머신의 CPU 바운드 이미지 처리 서비스가 스레드 풀 256으로 운영되고 있다. 처리량은 스레드 64일 때와 비슷한데 p99 지연이 10배 나쁘다. 이 관찰을 스케줄러 모델로 설명하고, 어떤 지표로 확증할 수 있는지, 어떻게 고칠지 제시하라.

정답과 해설

CPU 바운드 태스크 256개가 코어 16개를 나누면 스케줄러는 공정 분배를 위해 각 스레드에 잘게 쪼갠 슬라이스를 돌아가며 준다. 총 계산량은 코어가 소화하는 만큼으로 고정이므로 처리량은 늘지 않지만, 개별 작업은 자기 계산 시간의 약 16배(256/16) 동안 큐에서 기다리며 완료되므로 지연이 급증한다. 여기에 스위치의 직접·간접 비용(캐시 재워밍업)이 얹힌다. 확증 지표: pidstat -w -t에서 스레드들의 nvcswch/s가 높고(선점 반복), vmstatr이 코어 수를 크게 초과하며, /proc/pressure/cpu의 some이 높다. 수정: 풀을 코어 수 근처(16±α)로 줄이고, 대기열은 스레드가 아니라 명시적 큐(백프레셔 가능한)로 옮긴다. 처리량이 더 필요하면 스레드가 아니라 코어를 늘려야 한다.

3. pidstat -w로 두 서비스를 관찰했다. A는 cswch/s(자발적)가 40,000으로 높고 nvcswch/s는 거의 0이다. B는 반대로 nvcswch/s가 높다. 각 서비스의 워크로드 성격을 추론하고, 각각에서 스위치 수를 줄이려면 어디를 손봐야 하는지 설명하라.

정답과 해설

A의 스위치는 거의 전부 자발적이다 — 태스크가 스스로 실행을 멈춘다는 뜻이므로 I/O 대기나 락 경합이 지배하는 워크로드다(전형적으로 요청당 여러 번의 짧은 블로킹 I/O, 또는 뜨거운 락). 줄이려면 I/O를 묶음 처리(배칭)하거나, 다중화 기반 I/O 모델로 전환하거나(8.3), 락 경합이라면 임계 구역을 쪼개거나 락-프리 구조로 경합을 낮춘다. B의 비자발적 스위치는 runnable 태스크가 코어보다 많아 선점이 반복된다는 뜻 — CPU 경합이다. 스레드 수를 코어 수 근처로 줄이고, CPU 바운드 작업을 별도 풀로 분리하며, 필요하면 코어를 늘린다. 같은 "컨텍스트 스위치 급증"이라도 두 카운터 중 어느 쪽인지에 따라 처방이 정반대라는 점이 핵심이다.

참고 자료

  • Remzi Arpaci-Dusseau, Andrea Arpaci-Dusseau, Operating Systems: Three Easy Pieces — 4~10장(프로세스, 제한적 직접 실행, 스케줄링). 이 문서의 태스크·선점·스케줄링 모델의 뼈대. 무료 공개 교재.
  • man pages: clone(2) — 프로세스/스레드가 clone 플래그의 차이임을 보여주는 1차 자료, sched(7) — 스케줄링 정책과 nice의 공식 서술, proc_pid_status(5) — voluntary/nonvoluntary 카운터의 정의.
  • kernel.org 문서: CFS Scheduler, EEVDF Scheduler — vruntime과 EEVDF 전환의 공식 설명.
  • LWN, An EEVDF CPU scheduler for Linux — CFS의 지연 요구 문제와 EEVDF가 그것을 어떻게 푸는지의 배경(보조 검증용).
  • Brendan Gregg, Linux Load Averages: Solving the Mystery — load average가 uninterruptible을 포함하게 된 1993년 패치의 발굴과 해석 방법.
  • kernel.org 문서: PSI — Pressure Stall Information — CPU·메모리·I/O 압력 지표의 정의.
  • Brendan Gregg, Systems Performance 2nd ed. (2020) — 6장 CPU. 지표 해석과 USE 방법론.