컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 03

CPU 스케줄링

OSCPU스케줄링

CPU 스케줄링은 Ready Queue에 있는 프로세스 중 어느 것을 CPU에 올릴지 결정하는 OS의 핵심 기능입니다. 좋은 스케줄링은 CPU 이용률을 높이고, 응답 시간·대기 시간·반환 시간을 최소화합니다. 알고리즘에 따라 공정성과 효율 사이의 트레이드오프가 달라집니다.

핵심 개념

스케줄링 성능 지표

CPU 이용률(Utilization), 처리량(Throughput), 반환 시간(Turnaround Time = 완료 - 도착), 대기 시간(Waiting Time = Ready Queue 대기 합계), 응답 시간(Response Time = 첫 응답까지)으로 알고리즘 성능을 비교합니다.

핵심 개념

FCFS (First-Come, First-Served)

도착 순서대로 CPU를 할당합니다. 구현이 가장 단순하지만 긴 프로세스 뒤에 짧은 프로세스가 오래 기다리는 호위 효과(Convoy Effect)가 발생합니다. 비선점형입니다.

핵심 개념

SJF (Shortest Job First)

CPU 버스트 시간이 가장 짧은 프로세스를 먼저 실행합니다. 버스트 길이를 정확히 안다면 평균 대기 시간이 최소인 최적 알고리즘이지만 다음 CPU 버스트 시간을 미리 알 수 없어 실제 구현은 예측값(지수 평균)을 사용합니다.

핵심 개념

Round Robin (RR)

각 프로세스에 타임 퀀텀(Time Quantum, q)만큼 CPU를 할당하고 시간이 지나면 선점합니다. 공정하고 응답 시간이 짧지만 q가 너무 작으면 컨텍스트 스위치 오버헤드가 커집니다. 시분할(Time-sharing) 시스템의 기반이며, 현대 OS의 스케줄러도 RR의 선점·시간 분할 개념을 발전시킨 형태입니다(Linux CFS/EEVDF는 비례 공정 분배, POSIX SCHED_RR은 같은 우선순위의 실시간 스레드끼리 RR).

핵심 개념

선점 vs 비선점

비선점(Non-preemptive)은 프로세스가 CPU를 자발적으로 반납할 때까지 실행합니다. 선점(Preemptive)은 OS가 강제로 CPU를 빼앗습니다. Round Robin·SRTF는 선점형, FCFS·SJF(기본)는 비선점형입니다.

핵심 개념

디스패처 (Dispatcher)

스케줄러가 선택한 프로세스를 실제로 CPU에 올리는 모듈입니다. 컨텍스트 스위치 + 유저 모드 전환 + 선택된 PC 주소로 점프를 수행합니다. 디스패처 지연(Dispatch Latency)은 스케줄링 오버헤드의 핵심입니다.

Convoy Effect (호위 효과) — FCFS에서 CPU 버스트가 긴 프로세스 하나가 Ready Queue를 막아 짧은 프로세스들이 줄줄이 대기하는 현상. 전체 평균 대기 시간이 크게 늘고, I/O 장치도 유휴 상태가 되어 자원 활용이 떨어집니다.
기아(Starvation) vs 에이징(Aging) — 우선순위 스케줄링에서 낮은 우선순위 프로세스가 영원히 실행되지 못하는 현상이 기아입니다. 에이징은 대기 시간에 비례해 우선순위를 높여 기아를 방지합니다.
타임 퀀텀 크기의 영향 — q가 매우 크면 FCFS와 동일해지고, q가 매우 작으면 프로세서 공유(Processor Sharing)처럼 동작합니다. Linux SCHED_RR의 기본 퀀텀은 100ms입니다. 단, Linux의 일반 프로세스 스케줄러(CFS/EEVDF)는 RR처럼 모든 프로세스에 같은 고정 퀀텀을 주지 않고, 가중치와 가상 시간(EEVDF는 여기에 태스크별 slice와 virtual deadline)으로 실행 시간을 나눕니다.
멀티레벨 큐 (MLQ) — 프로세스를 포그라운드/백그라운드 등 여러 큐로 분류하고 큐마다 다른 스케줄링 알고리즘을 적용합니다. 큐 간 스케줄링에는 보통 우선순위나 시분할을 사용합니다.
CFS / EEVDF — CFS(Completely Fair Scheduler)는 2007년부터 Linux의 기본 스케줄러로, 각 프로세스의 가상 실행 시간(vruntime)을 red-black tree로 관리해 vruntime이 가장 작은 프로세스를 선택하고 nice 값으로 CPU 비율을 조절합니다. Linux 6.6(2023년 10월)부터는 SCHED_NORMAL의 기본 스케줄러가 EEVDF(Earliest Eligible Virtual Deadline First)로 교체되어, lag와 virtual deadline을 기준으로 다음 프로세스를 고릅니다.

스케줄러는 무엇을 고르는가

프로세스는 CPU를 쓰는 구간(CPU 버스트)과 입출력을 기다리는 구간을 번갈아 가집니다. CPU가 놀지 않게 하려고 운영체제는 여러 프로세스를 메모리에 두고, Ready 큐에서 다음에 CPU를 줄 프로세스를 고릅니다. 이 선택 기준이 스케줄링 알고리즘이고, 기준에 따라 평균 대기 시간, 응답 시간, 공정성이 달라집니다.

숫자로 확인하기

같은 작업, 다른 순서 — P1(24), P2(3), P3(3)이 모두 시각 0에 도착한다고 합시다. FCFS로 P1, P2, P3 순서이면 대기 시간이 0, 24, 27이라 평균 17이고 평균 반환 시간은 (24 + 27 + 30) / 3 = 27입니다. 짧은 것부터 P2, P3, P1 순서(SJF)이면 대기 시간이 0, 3, 6이라 평균 3이고 평균 반환 시간은 (3 + 6 + 30) / 3 = 13입니다.
Round Robin의 계산(q = 4) — P1이 0~4, P2가 4~7(종료), P3가 7~10(종료), 이후 P1이 10~30에 남은 20을 이어서 끝냅니다. 대기 시간은 P1 6, P2 4, P3 7로 평균 약 5.67이고, 평균 반환 시간은 (30 + 7 + 10) / 3 ≈ 15.67, 평균 응답 시간은 (0 + 4 + 7) / 3 ≈ 3.67입니다. 프로세스가 바뀌는 컨텍스트 스위치는 3번입니다.
퀀텀과 전환 비용(가정) — 컨텍스트 스위치가 0.1ms라고 가정하면 q = 10ms일 때 전환 비용 비율은 0.1 / (10 + 0.1) ≈ 1%이고, q = 1ms이면 약 9%로 늘어납니다.
다음 CPU 버스트 예측 — 지수 평균 τ(n+1) = α·t(n) + (1 − α)·τ(n)에서 α = 0.5, 첫 예측 τ = 10, 실제 버스트가 6, 4였다면 다음 예측은 0.5 × 6 + 0.5 × 10 = 8, 그다음은 0.5 × 4 + 0.5 × 8 = 6입니다.

자주 하는 오해

SJF가 최적이니 그대로 쓰면 된다 — 평균 대기 시간이 최소인 것은 버스트 길이를 정확히 알 때의 이야기입니다. 실제로는 예측해야 하고, 짧은 작업이 계속 오면 긴 작업이 기아에 빠질 수 있습니다.
Round Robin이면 모든 지표가 좋아진다 — 응답 시간은 짧아지지만 위 계산처럼 평균 반환 시간은 SJF(13)보다 길어질 수 있습니다. 퀀텀이 너무 크면 FCFS와 비슷해지고 너무 작으면 전환 비용이 커집니다.
선점형이 항상 낫다 — 응답성은 좋아지지만 전환 비용이 들고, 공유 데이터를 만지는 도중에 선점되면 동기화 문제가 생깁니다.
우선순위가 높으면 항상 먼저 끝난다 — 높은 우선순위 작업이 계속 오면 낮은 작업은 기아에 빠질 수 있어 에이징 같은 보완이 필요합니다.

면접에서 이렇게 물어요

면접 질문

FCFS, SJF, Round Robin을 비교해 주세요.

FCFS는 단순하지만 긴 작업 뒤에 짧은 작업이 기다리는 호위 효과가 있습니다. SJF는 평균 대기 시간이 최소이지만 버스트 예측이 필요하고 기아가 생길 수 있습니다. Round Robin은 퀀텀마다 돌아가며 응답 시간이 짧고 공정하지만 전환 비용이 있습니다.

면접 질문

기아는 어떻게 해결하나요?

대기 시간에 비례해 우선순위를 올리는 에이징을 씁니다. 일정 시간이 지나면 낮은 우선순위 작업도 결국 높은 우선순위가 되어 실행됩니다.

면접 질문

선점형과 비선점형의 차이는 무엇인가요?

비선점형은 실행 중인 프로세스가 CPU를 스스로 놓을 때까지 기다리고, 선점형은 타이머 인터럽트나 더 높은 우선순위의 도착 같은 사건으로 운영체제가 CPU를 빼앗을 수 있습니다.

면접 질문

리눅스는 어떤 스케줄링을 쓰나요?

일반 프로세스는 가중치로 CPU 시간을 나누는 공정 스케줄러를 씁니다. 오랫동안 CFS였고 리눅스 6.6부터 EEVDF로 바뀌었습니다. 실시간 정책으로 SCHED_FIFO와 SCHED_RR이 따로 있습니다.

실제 시스템에서는

우선순위 조절 — nice와 renice로 일반 프로세스의 우선순위 값을 바꾸고, chrt로 스케줄링 정책(SCHED_FIFO, SCHED_RR 등)을 지정합니다. 정책의 설명은 sched(7) 매뉴얼에 있습니다.
RR의 시간 조각 — sched(7) 매뉴얼은 SCHED_RR에서 스레드가 시간 조각 하나만큼만 실행할 수 있다고 설명하고, sched_rr_get_interval(2) 매뉴얼은 그 길이를 sched_rr_get_interval()이나 Linux 3.9부터 /proc/sys/kernel/sched_rr_timeslice_ms로 볼 수 있으며 기본값이 100ms라고 설명합니다.
Java의 우선순위 — Javadoc은 Thread.setPriority 값이 소속 스레드 그룹의 최대 우선순위로 제한되고, 가상 스레드는 항상 NORM_PRIORITY라서 값이 무시된다고 설명합니다. 우선순위로 실행 순서를 보장하려 하지 않습니다.

함께 보면 좋은 토픽

프로세스 — Ready 상태와 컨텍스트 스위칭을 봅니다.
스레드 — 스케줄링의 단위가 되는 스레드를 봅니다.
프로세스 동기화 — 선점과 공유 데이터 접근의 관계를 봅니다.
교착 상태 — 자원 대기와 기아의 차이를 봅니다.
그리디 — SJF가 그리디 선택으로 최적이 되는 이유를 비교합니다.
실시간 스케줄링(RM/EDF) — 마감 안에 작업이 끝나도록 하는 스케줄링을 봅니다.

참고 자료

Linux chrt(1) — chrt(1) — Linux manual page
Java Thread — Thread — Java SE 21 API
교재 — Silberschatz, Galvin, Gagne, "Operating System Concepts", 10th Edition, Chapter 5 "CPU Scheduling"

C — Round Robin 스케줄러 시뮬레이션

#include <stdio.h>
#include <string.h>

#define N       4
#define QUANTUM 2

typedef struct {
    char  id[4];
    int   arrival;   // 도착 시간
    int   burst;     // CPU 버스트 시간
    int   remaining; // 남은 실행 시간
    int   finish;    // 완료 시간
} Process;

int main() {
    Process procs[N] = {
        { "P1", 0, 6, 6, 0 },
        { "P2", 1, 3, 3, 0 },
        { "P3", 2, 4, 4, 0 },
        { "P4", 3, 2, 2, 0 },
    };

    int  queue[64];     // Ready Queue (원형 배열 대신 단순 배열로 시뮬레이션)
    int  head = 0, tail = 0;
    int  enqueued[N];
    memset(enqueued, 0, sizeof(enqueued));

    int  t    = 0;
    int  done = 0;

    while (done < N) {
        // 현재 시각에 도착한 프로세스를 Ready Queue에 삽입
        for (int i = 0; i < N; i++) {
            if (!enqueued[i] && procs[i].arrival <= t && procs[i].remaining > 0) {
                queue[tail++] = i;
                enqueued[i]   = 1;
            }
        }

        if (head == tail) { t++; continue; }  // CPU 유휴

        int  idx  = queue[head++];            // Dequeue
        int  run  = (procs[idx].remaining < QUANTUM)
                   ? procs[idx].remaining : QUANTUM;

        printf("T=%2d ~ T=%2d : %s 실행\n", t, t + run, procs[idx].id);
        t                    += run;
        procs[idx].remaining -= run;

        // 실행 후 도착한 프로세스 먼저 삽입 (선점 직후 도착 처리)
        for (int i = 0; i < N; i++) {
            if (!enqueued[i] && procs[i].arrival <= t && procs[i].remaining > 0) {
                queue[tail++] = i;
                enqueued[i]   = 1;
            }
        }

        if (procs[idx].remaining > 0) {
            queue[tail++] = idx;  // 아직 남아 있으면 Ready Queue 끝에 재삽입
        } else {
            procs[idx].finish = t;
            done++;
        }
    }

    printf("\n%-4s %7s %7s %7s %7s %11s\n",
           "PID", "도착", "버스트", "완료", "대기", "반환시간");
    float sumW = 0, sumT = 0;
    for (int i = 0; i < N; i++) {
        int tat     = procs[i].finish - procs[i].arrival;
        int waiting = tat - procs[i].burst;
        sumW += waiting; sumT += tat;
        printf("%-4s %7d %7d %7d %7d %11d\n",
               procs[i].id, procs[i].arrival, procs[i].burst,
               procs[i].finish, waiting, tat);
    }
    printf("평균 대기: %.1f   평균 반환: %.1f\n", sumW / N, sumT / N);
    return 0;
}

Round Robin은 Ready Queue를 FIFO로 관리합니다. 프로세스가 퀀텀을 다 소진하면 큐 끝으로 이동하고, 아직 남은 프로세스가 있으면 선점됩니다.
선점 직후 새로 도착한 프로세스를 먼저 삽입한 뒤 현재 프로세스를 재삽입하는 순서가 중요합니다. 순서가 바뀌면 기아나 예상치 못한 실행 순서가 발생합니다.
반환 시간(TAT) = 완료 시각 − 도착 시각, 대기 시간 = TAT − CPU 버스트 시간입니다.

Java — SJF (비선점) 스케줄러 시뮬레이션

import java.util.*;

public class SJFScheduler {

    record Process(String id, int arrival, int burst) {}

    public static void main(String[] args) {
        List<Process> procs = List.of(
            new Process("P1", 0, 6),
            new Process("P2", 1, 3),
            new Process("P3", 2, 4),
            new Process("P4", 3, 2)
        );

        // PriorityQueue: 도착한 프로세스 중 burst 최소 → 우선순위 큐
        PriorityQueue<Process> ready = new PriorityQueue<>(
            Comparator.comparingInt(Process::burst)
                      .thenComparingInt(Process::arrival)
        );

        List<Process> remaining = new ArrayList<>(procs);
        Map<String, Integer> finishMap = new LinkedHashMap<>();
        int t = 0;

        while (!remaining.isEmpty() || !ready.isEmpty()) {
            // 현재 시각에 도착한 프로세스를 Ready Queue에 투입
            Iterator<Process> it = remaining.iterator();
            while (it.hasNext()) {
                Process p = it.next();
                if (p.arrival() <= t) { ready.add(p); it.remove(); }
            }

            if (ready.isEmpty()) {
                // CPU 유휴: 다음 도착 시각으로 점프
                t = remaining.stream()
                    .mapToInt(Process::arrival).min().orElse(t + 1);
                continue;
            }

            // 가장 짧은 버스트 프로세스를 선택 (비선점: 완료까지 실행)
            Process sel = ready.poll();
            System.out.printf("T=%2d ~ T=%2d : %s 실행%n",
                t, t + sel.burst(), sel.id());
            t += sel.burst();
            finishMap.put(sel.id(), t);

            // 실행하는 동안 도착한 프로세스도 큐에 추가
            remaining.removeIf(p -> {
                if (p.arrival() <= t) { ready.add(p); return true; }
                return false;
            });
        }

        printStats(procs, finishMap);
    }

    static void printStats(List<Process> procs, Map<String, Integer> finishMap) {
        System.out.printf("%n%-4s %6s %6s %6s %6s%n",
            "PID", "Arr", "Burst", "Wait", "TAT");
        double sumW = 0, sumT = 0;
        for (Process p : procs) {
            int finish = finishMap.get(p.id());
            int tat    = finish - p.arrival();
            int wait   = tat    - p.burst();
            sumW += wait; sumT += tat;
            System.out.printf("%-4s %6d %6d %6d %6d%n",
                p.id(), p.arrival(), p.burst(), wait, tat);
        }
        System.out.printf("평균 대기: %.1f   평균 반환: %.1f%n",
            sumW / procs.size(), sumT / procs.size());
    }
}

비선점 SJF는 CPU가 비면 그 시점까지 도착한 프로세스 중 버스트가 가장 짧은 것을 선택합니다. PriorityQueue를 사용하면 O(log n)으로 최솟값을 꺼낼 수 있습니다.
실행 도중 새로 도착한 프로세스는 비선점이므로 즉시 CPU를 빼앗지 않고 다음 기회를 기다립니다.
실제 OS에서는 미래의 CPU 버스트 시간을 알 수 없으므로 지수 평균(Exponential Average)으로 예측합니다: τ(n+1) = α·t(n) + (1−α)·τ(n), 보통 α=0.5를 사용합니다.

Q1

FCFS(First-Come First-Served) 스케줄링의 특징은?

이름 그대로 '먼저 온 순서'입니다.
FCFS는 프로세스가 도착한 순서대로 CPU를 할당합니다. 단순하지만 짧은 작업이 긴 작업 뒤에서 오래 기다리는 convoy effect가 생길 수 있습니다.

Q2

SJF(Shortest Job First)가 평균 대기시간 측면에서 유리한 이유는?

짧은 일을 먼저 끝내면 뒤에 기다리는 작업들의 대기시간 합이 줄어듭니다.
SJF는 실행시간이 짧은 작업부터 처리해 이론적으로 평균 대기시간을 최소화하지만, 실행시간을 미리 알아야 한다는 제약이 있습니다.

Q3

Round Robin(RR) 스케줄링에서 타임 퀀텀(time quantum)이 너무 작으면?

너무 자주 스레드를 바꾸면 전환 비용이 누적됩니다.
타임 퀀텀이 너무 작으면 컨텍스트 스위칭이 너무 자주 일어나 오버헤드가 커지고, 반대로 너무 크면 FCFS와 비슷해집니다.