Home 운영체제의 스케줄링 코드 살펴보기
Post
Cancel

운영체제의 스케줄링 코드 살펴보기

글을 작성하게 된 계기


이벤트 루프의 동작원리를 학습하며 운영체제의 스케줄링 코드에 관심을 가지게 됐고, 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.





1. 코드


운영체제의 스케줄링과 이벤트 처리 코드를 살펴보겠습니다.

  1. 스케줄링
  2. 이벤트 처리





1-1. 스케줄링

운영체제는 schedule 함수를 통해 한 프로세스가 실행할 수 없는 상태일 때, 다른 프로세스를 실행합니다. 즉, 이 함수는 CPU를 양보하고, 다음 실행할 프로세스를 스케줄러가 선택할 수 있도록 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
asmlinkage __visible void __sched schedule(void)
{
	struct task_struct *tsk = current;

#ifdef CONFIG_RT_MUTEXES
	lockdep_assert(!tsk->sched_rt_mutex);
#endif

	if (!task_is_running(tsk))
		sched_submit_work(tsk);
	__schedule_loop(SM_NONE);
	sched_update_worker(tsk);
}
EXPORT_SYMBOL(schedule);





__schedule_loop 함수는 반복문을 순회하며 __schedule 함수를 호출하고 실행 준비가 된 프로세스를 찾습니다. 이는 내부적으로 pick_next_task 함수를 통해 실행 가능한 프로세스를 탐색하고 해당 프로세스를 실행합니다.

1
2
3
4
5
6
7
8
static __always_inline void __schedule_loop(int sched_mode)
{
	do {
		preempt_disable();
		__schedule(sched_mode);
		sched_preempt_enable_no_resched();
	} while (need_resched());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void __sched notrace __schedule(int sched_mode)
{
	struct task_struct *prev, *next;
	bool preempt = sched_mode > SM_NONE;
	unsigned long *switch_count;
	unsigned long prev_state;
	struct rq_flags rf;
	struct rq *rq;
	int cpu;

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

	schedule_debug(prev, preempt);

	if (sched_feat(HRTICK) || sched_feat(HRTICK_DL))
		hrtick_clear(rq);

	local_irq_disable();
	rcu_note_context_switch(preempt);

	......

        // 실행 가능한 프로세스 탐색
	next = pick_next_task(rq, prev, &rf);
	
	......
	
}





스케줄링을 통해 실행 가능한 프로세스를 찾기 전, 현재 실행 중인 프로세스가 실행 큐에서 제거되고 대기 큐로 이동하는데요, 이를 위해 try_to_block_task 함수를 호출합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void __sched notrace __schedule(int sched_mode)
{
	......

	if (sched_mode == SM_IDLE) {
		if (!rq->nr_running && !scx_enabled()) {
			next = prev;
			goto picked;
		}
	} else if (!preempt && prev_state) {
		try_to_block_task(rq, prev, prev_state);
		switch_count = &prev->nvcsw;
	}

	next = pick_next_task(rq, prev, &rf);
	
	......
	
}





try_to_block_task 함수는 프로세스가 실행 큐에서 제거되도록 처리하며, TASK_INTERRUPTIBLE 상태일 경우 시그널이 존재하면 즉시 TASK_RUNNING으 로 변경하고, 그렇지 않으면 block_task 함수를 호출해 대기 상태로 전환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static bool try_to_block_task(struct rq *rq, struct task_struct *p,
			      unsigned long task_state)
{
	int flags = DEQUEUE_NOCLOCK;

	if (signal_pending_state(task_state, p)) {
		WRITE_ONCE(p->__state, TASK_RUNNING);
		return false;
	}

	p->sched_contributes_to_load =
		(task_state & TASK_UNINTERRUPTIBLE) &&
		!(task_state & TASK_NOLOAD) &&
		!(task_state & TASK_FROZEN);

	if (unlikely(is_special_task_state(task_state)))
		flags |= DEQUEUE_SPECIAL;

	block_task(rq, p, flags);
	return true;
}
1
2
3
4
5
6
7
8
9
10
static void block_task(struct rq *rq, struct task_struct *p, int flags)
{
	lockdep_assert_rq_held(rq);

	// 실행 큐에서 제거
	__block_task(rq, p);

	// waiting queue로 들어가는 순간
	WRITE_ONCE(p->__state, TASK_INTERRUPTIBLE);
}





이런 과정을 통해 우리가 알고 있는 CPU 스케줄링이 진행되는 것입니다. 프로세스가 실행 가능한 상태가 되면 다시 레디 큐에 들어가서 CPU와 작업하고, 그렇지 않으면 대기 큐에서 이벤트를 기다리며 블록된 상태로 유지됩니다. 이후, 특정 이벤트가 발생하거나 wake_up 함수가 호출되면 프로세스는 다시 실행 큐로 이동하여 스케줄러에 의해 실행될 수 있습니다. 즉, 이벤트 루프가 특정 이벤트가 발생할 때 깨어나는 원리인 것이죠.

image







1-2. 이벤트 처리

다음은 이벤트 처리 코드 입니다. wait_event 함수는 특정 조건이 참이 될 때까지 현재 프로세스를 대기 상태로 전환 합니다. wait_event 함수를 사용하면, CPU를 불필요하게 점유하지 않고 대기 상태로 들어가며, 이벤트가 발생하면 wake_up 함수를 통해 재 실행할 수 있습니다. 이는 내부적으로 __wait_event 함수를 호출하여 대기 상태로 전환됩니다. 이 과정에서 현재 프로세스는 TASK_UNINTERRUPTIBLE 상태가 되며, CPU를 양보하고 다른 태스크가 실행됩니다.

1
2
3
4
5
6
7
#define wait_event(wq_head, condition)						\
do {										\
	might_sleep();								\
	if (condition)								\
		break;								\
	__wait_event(wq_head, condition);					\
} while (0)





__wait_event 함수는 내부적으로 ___wait_event 함수를 호출하여, 프로세스를 대기열에 추가하고 TASK_UNINTERRUPTIBLE 상태로 변경한 후 schedule 함수를 호출해 CPU를 양보합니다. 이는 현재 프로세스를 외부 인터럽트 없이 대기 상태로 유지하도록 합니다. 즉, 외부에서 wake_up 함수가 호출될 때까지 깨어나지 않습니다. 이는 TASK_INTERRUPTIBLE 상태에서 대기하도록 설정하여, 외부에서 시그널(SIGCONT, SIGIO 등)이 발생하면 즉시 깨어나도록 합니다.

1
2
3
#define __wait_event_freezable(wq_head, condition)				\
	___wait_event(wq_head, condition, (TASK_INTERRUPTIBLE|TASK_FREEZABLE),	\
			0, 0, schedule())





일반적인 wait_event 함수는 TASK_UNINTERRUPTIBLE 상태에서 대기하지만, 경우에 따라 인터럽트가 발생하면 즉시 깨어나야 하는 경우가 있습니다. 이때는 __ wait_event_interruptible 함수를 사용합니다.

1
2
3
#define __wait_event_interruptible(wq_head, condition)				\
	___wait_event(wq_head, condition, TASK_INTERRUPTIBLE, 0, 0,		\
		      schedule())





__wait_event 함수를 내부에서 호출하며, 실제로 프로세스를 대기 상태로 만들고 CPU를 양보합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define ___wait_event(wq_head, condition, state, exclusive, ret, cmd)		\
({										\
	__label__ __out;							\
	struct wait_queue_entry __wq_entry;					\
	long __ret = ret;	/* explicit shadow */				\
										\
	init_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0);	\
	for (;;) {								\
		long __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);\
										\
		if (condition)							\
			break;							\
										\
		if (___wait_is_interruptible(state) && __int) {			\
			__ret = __int;						\
			goto __out;						\
		}								\
										\
		cmd;								\
	}									\
	finish_wait(&wq_head, &__wq_entry);					\
__out:	__ret;									\
})





프로세스가 wait_event 함수를 호출 후 대기하고 있을 때, 다른 프로세스가 wake_up 함수를 호출하면 해당 프로세스가 다시 실행 가능 상태로 변경됩니다. 이는 condition이 참이 되면 즉시 종료되지만, 그렇지 않다면 cmd1을 실행한 후 __wait_event_cmd 함수를 호출해 대기 상태로 들어갑니다.

1
2
3
4
5
6
#define wait_event_cmd(wq_head, condition, cmd1, cmd2)				\
do {										\
	if (condition)								\
		break;								\
	__wait_event_cmd(wq_head, condition, cmd1, cmd2);			\
} while (0)
1
2
3
#define __wait_event_cmd(wq_head, condition, cmd1, cmd2) \
	___wait_event(wq_head, condition, TASK_UNINTERRUPTIBLE, 0, 0, \
		      cmd1; schedule(); cmd2;)





프로세스가 대기 중일 때, wake_up 또는 wake_up_process 함수가 호출되면 프로세스는 실행 큐(runqueue)로 다시 추가됩니다. 이때 내부적으로 try_to_wake_up 함수를 호출하여 프로세스를 TASK_RUNNING 상태로 변경하고 실행할 준비를 합니다.

1
2
3
4
int wake_up_process(struct task_struct *p)
{
	return try_to_wake_up(p, TASK_NORMAL, 0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
int try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
        // 현재 코드 블록에서 preemption이 발생하지 않도록 보호
	guard(preempt)(); 

	int cpu, success = 0;

        // Wake-up 플래그 설정(WF_TTWU: Try To Wake Up)
	wake_flags |= WF_TTWU;

	/*
	 *  현재 실행 중인 태스크(p == current)라면 직접 깨운다.
	 *  현재 실행 중인 태스크는 실행 큐에서 빠진 상태가 아니므로 즉시 실행 가능
	 */
	if (p == current) {
		SCHED_WARN_ON(p->se.sched_delayed); 
		
		// 현재 태스크 상태가 wake-up 대상인지 확인
		if (!ttwu_state_match(p, state, &success))
			goto out;

		trace_sched_waking(p);
		
		// 현재 실행 중이라면 바로 wake-up 처리
		ttwu_do_wakeup(p);
		goto out;
	}

	/*
	 * 스핀락을 걸고, 프로세스를 깨울 수 있는 상태인지 확인한다.
	 * - `pi_lock`을 획득하여 `p->state`를 안전하게 읽음
	 */
	scoped_guard (raw_spinlock_irqsave, &p->pi_lock) {
		
		......

		/*
		 * 실행 큐에 있는지 확인 (`p->on_rq`)
		 * - `p->on_rq == 1` 이면 이미 실행 큐에 있는 것이므로 따로 깨울 필요 없음
		 */
		smp_rmb();
		
		// 이미 실행 큐에 있는 경우 바로 빠져나옴
		if (READ_ONCE(p->on_rq) && ttwu_runnable(p, wake_flags))
			break; 

#ifdef CONFIG_SMP
		smp_acquire__after_ctrl_dep();

                // 태스크 상태를 `TASK_WAKING`으로 변경하여 깨울 준비를 함
		WRITE_ONCE(p->__state, TASK_WAKING);

		/*
		 * 실행 중인 CPU(`on_cpu`)에서 컨텍스트 스위칭이 끝날 때까지 대기
		 * - 현재 CPU에서 실행 중이라면 (`on_cpu == 1`), IPI를 보내서 wake-up 처리
		 */
		if (smp_load_acquire(&p->on_cpu) &&
		    ttwu_queue_wakelist(p, task_cpu(p), wake_flags))
			break;

		smp_cond_load_acquire(&p->on_cpu, !VAL);

		cpu = select_task_rq(p, p->wake_cpu, &wake_flags);
		
		// 현재 태스크가 실행될 CPU가 변경될 필요가 있는 경우
		if (task_cpu(p) != cpu) { 
		    // 태스크가 I/O 대기 중이라면 I/O 완료 처리
			if (p->in_iowait) {
				delayacct_blkio_end(p);
				atomic_dec(&task_rq(p)->nr_iowait);
			}

			wake_flags |= WF_MIGRATED; 
			psi_ttwu_dequeue(p);
			set_task_cpu(p, cpu);
		}
#else
		cpu = task_cpu(p);
#endif /* CONFIG_SMP */

		/*
		 * 태스크를 실행 큐에 추가 (`ttwu_queue()` 호출)
		 * - 최종적으로 실행 큐에 태스크를 다시 넣음
		 */
		ttwu_queue(p, cpu, wake_flags);
	}

out:
	if (success)
		ttwu_stat(p, task_cpu(p), wake_flags);
	return success;
}

코드가 등장하면서 전체 흐름을 파악하기 어려웠을 수 있는데요, 이를 정리하면 다음과 같습니다.

  1. 운영체제는 스케줄링을 하면서 실행 가능한 프로세스를 찾고, CPU를 할당해 작업을 처리합니다.
  2. 이벤트 루프는 이벤트가 발생하기 전까지 대기 상태로 있으며, 이벤트가 발생하면 이를 감지해 처리합니다.







2. 정리


리눅스 운영체제의 스케줄링 코드를 살펴보았습니다. 운영체제마다 스케줄링 방식은 다르지만 핵심 개념은 비슷하므로, 전체 프로세스를 파악하는 정도로 참고하면 좋을 것 같습니다.


This post is licensed under CC BY 4.0 by the author.