리눅스 스케줄러 자료구조로 보는 CFS/EEVDF: sched_entity와 cfs_rq

리눅스 6.6부터 CFS를 대체한 EEVDF 스케줄러의 vruntime/eligible time/deadline 같은 개념은 이 블로그의 이전 글에서 다뤘다. 이 글은 그 개념이 실제로 어떤 구조체와 함수로 구현되어 있는지, kernel/sched/fair.c(6.18 기준) 소스를 직접 따라가며 확인한다. 그리고 nice 값에 따라 vruntime 누적 속도가 실제로 달라지는지 두 프로세스를 같은 코어에 묶어 CPU 점유율로 검증한다.

핵심 자료구조: sched_entity와 cfs_rq

태스크 하나의 스케줄링 상태는 struct sched_entity에 담긴다.

struct sched_entity {
	struct load_weight		load;
	struct rb_node			run_node;
	u64				deadline;
	u64				min_vruntime;
	u64				min_slice;
	u64				max_slice;

	u64				exec_start;
	u64				sum_exec_runtime;
	u64				prev_sum_exec_runtime;
	u64				vruntime;
	union {
		s64                     vlag;   /* !on_rq일 때 */
		u64                     vprot;  /* cfs_rq->curr == se일 때 */
	};
	u64				slice;
	...
};

vruntime은 누적 가상 실행 시간, deadline은 EEVDF가 태스크를 고를 기준이 되는 가상 데드라인이다. 이 엔티티들을 모아두는 실행 큐가 cfs_rq다.

struct cfs_rq {
	struct load_weight	load;
	unsigned int		nr_queued;

	s64			sum_w_vruntime;
	u64			sum_weight;
	u64			zero_vruntime;

	struct rb_root_cached	tasks_timeline;

	struct sched_entity	*curr;
	struct sched_entity	*next;
	...
};

tasks_timeline이 실제 엔티티들을 담는 레드블랙 트리다. 트리 키는 vruntime(정확히는 deadline 비교)이라, 왼쪽으로 갈수록 우선순위가 높은 태스크가 위치한다.

vruntime 갱신과 다음 태스크 선택

태스크가 CPU를 쓰는 동안 update_curr()가 매 틱마다 vruntime을 누적시킨다.

static void update_curr(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	s64 delta_exec;

	if (unlikely(!curr))
		return;

	delta_exec = update_se(rq_of(cfs_rq), curr);
	if (unlikely(delta_exec <= 0))
		return;

	curr->vruntime += calc_delta_fair(delta_exec, curr);
	resched = update_deadline(cfs_rq, curr);
	...
}

calc_delta_fair()가 실제 경과 시간을 load.weight로 나눠 가상 시간으로 환산한다 — 가중치가 낮은(= nice 값이 큰) 태스크일수록 vruntime이 더 빠르게 늘어나 우선순위가 빨리 떨어진다. 큐에서 다음에 실행할 엔티티는 pick_eevdf()가 고른다.

static struct sched_entity *pick_eevdf(struct cfs_rq *cfs_rq, bool protect)
{
	struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
	struct sched_entity *se = __pick_first_entity(cfs_rq);
	struct sched_entity *curr = cfs_rq->curr;
	struct sched_entity *best = NULL;

	if (cfs_rq->nr_queued == 1)
		return curr && curr->on_rq ? curr : se;

	if (curr && (!curr->on_rq || !entity_eligible(cfs_rq, curr)))
		curr = NULL;

	/* 가장 왼쪽(가장 이른 deadline) 엔티티가 eligible하면 바로 선택 */
	if (se && entity_eligible(cfs_rq, se)) {
		best = se;
		goto found;
	}

	/* eligible하지 않으면 트리를 내려가며 조건을 만족하는 노드를 찾는다 */
	while (node) {
		struct rb_node *left = node->rb_left;
		if (left && vruntime_eligible(cfs_rq, __node_2_se(left)->min_vruntime)) {
			node = left;
			continue;
		}
		se = __node_2_se(node);
		if (entity_eligible(cfs_rq, se)) {
			best = se;
			break;
		}
		node = node->rb_right;
	}
found:
	if (!best || (curr && entity_before(curr, best)))
		best = curr;
	return best;
}

entity_eligible()은 태스크의 vruntime이 큐 평균(zero_vruntime) 대비 아직 “빚”을 지지 않은 상태인지를 판정한다. 즉 EEVDF는 “가장 이른 deadline” 중에서도 “아직 자기 몫을 다 쓰지 않은” 태스크만 고른다.

실전: nice 값과 실제 CPU 점유율

같은 CPU 코어에 nice 0과 nice 15인 CPU 바운드 프로세스를 동시에 묶어 실제 점유율 차이를 측정했다.

#!/bin/bash
end=$((SECONDS+5))
x=0
while [ $SECONDS -lt $end ]; do x=$((x+1)); done
taskset -c 0 nice -n 0 ./busy.sh &
pid0=$!
taskset -c 0 nice -n 15 ./busy.sh &
pid15=$!
sleep 2
ps -o pid,ni,pcpu,time -p $pid0,$pid15
    PID  NI %CPU     TIME
   3746   0 96.5 00:00:01
   3747  15  3.5 00:00:00

같은 코어를 두고 경쟁했는데도 nice 0 프로세스가 96.5%, nice 15 프로세스가 3.5%를 가져갔다. nice 15는 load.weight가 nice 0 대비 훨씬 작아 calc_delta_fair()에서 vruntime이 그만큼 빠르게 증가하고, pick_eevdf()가 매번 더 작은 vruntime(더 이른 deadline)을 가진 nice 0 쪽을 고르기 때문이다.

주의사항

  • 이 글의 소스는 로컬에 클론된 6.18.38 커널 트리 기준이다. zero_vruntime 같은 필드명은 EEVDF 이전 CFS 시절의 min_vruntime에서 이름이 바뀐 것으로, 커널 버전에 따라 필드명이 다를 수 있다.
  • nice 값과 load.weight의 정확한 대응표는 kernel/sched/core.csched_prio_to_weight[]에 있다. nice 0 = 1024이고 nice가 1 증가할 때마다 대략 10%씩 줄어드는 지수 스케일이다.
  • 위 실험은 CFQ 급 부하나 실시간 스케줄링 클래스(SCHED_FIFO/RR)와는 무관하다. SCHED_FIFO/RR 태스크는 아예 다른 스케줄링 클래스(rt_rq)를 쓰므로 vruntime 기반 공정성 로직이 적용되지 않는다.
  • 이번 실험은 코어 1개에 강제로 묶어 경쟁을 인위적으로 만든 것이다. 코어가 남아도는 상황이라면 두 프로세스가 서로 다른 코어에서 각각 100%를 쓰므로 점유율 차이가 드러나지 않는다.

마무리

EEVDF의 eligible time/deadline 개념은 결국 sched_entity의 몇 개 필드와 cfs_rq의 레드블랙 트리, 그리고 update_curr()/pick_eevdf() 두 함수로 구현된다. nice 값 하나가 어떻게 실제 CPU 점유율 차이로 이어지는지도 같은 코어에 묶어 직접 확인했다. 다음에 스케줄링 관련 버그를 만나면 /proc/[pid]/schedchrt로 실제 값을 확인하는 것부터 시작하면 된다.

참고

답글 남기기