Jungle

[TIL] 타이머 인터럽트 정의 및 분석

손가든 2023. 11. 26. 20:20

타이머 인터럽트가 무엇인가? 타이머 인터럽트가 운영체제에서 운영하는데 왜 필요한가? 에 대한 무수한 질문들을 타이머 인터럽트를 구현하기 전에 이해해야 잘 구현할 수 있을 것 같다.

 

따라서 이 부분에 대해서 공부해 보았다.

 

타이머 인터럽트란 무엇인가?

 

수 많은 개발자분들의 포스팅과 챗 지피티에게 질문을 하면서 이해하려고 노력했고, 어떤 느낌인지 개괄적으로 파악이 되기 시작했지만

 

확실히 이해하는데는 포스팅만한게 없다.

 

 

타이머 인터럽트란 시간 주기적으로 발생하는 인터럽트로

 

운영체제에서 정의한 일정 시간(ticks)마다 실행되는 작업을 수행하는 데 사용한다.

 

이 타이머 인터럽트마다 실행되는 작업은 다양한 목적이 있을 수 있다.

 

 

타이머 인터럽트를 왜 사용하는가?

 

1. 스케줄링

 

정해진 주기마다 현재 실행 중인 작업을 중단하고 다음 작업으로 전환하여 다중 프로세스나 스레드 간에 CPU를 공평하게 나누기 위해 타이머 인터럽트를 사용할 수 있다. (Round Robin 같이 동시적 전환 Context Switching 기법)

 

2. 시스템 시간 관리

 

운영체제가 시스템 시간을 관리하는 데에도 사용할 수 있다.

 

매 틱마다 시간이 경과하도록 함으로써 정확한 시간을 유지할 수 있게 된다.

 

3. 유휴 상태 검사

 

시스템이 유휴 상태인지를 타이머 인터럽트를 통해 주기적으로 확인해서

 

만약 시스템이 일정 시간 동안 아무 작업도 수행하지 않으면, 에너지를 절약하거나 추가적인 작업을 수행할 수 있다.

 

 

더보기
CPU를 점유한 스레드가 유휴 상태인지 어떻게 파악할까?

 

 

CPU를 점유하고 있는 스레드가 유휴 상태인지 여부를 파악하는 것은 운영체제의 스케줄러가 한다.

 

스케줄러는 스레드가 유휴 상태인지 파악하기 위해 CPU 사용률을 감시하거나, 스레드의 활동을 감시하기도 한다.

 

 

CPU를 점유하고 있는 스레드가 유휴 상태라면 스케줄러는 이 스레드를 sleep시키고

 

다른 스레드로 전환하는 작업을 통해 CPU가 더 효율적으로 사용할 수 있도록 한다.

 

 

4. 실시간 시스템

 

실시간 시스템에서 정확한 시간 제어가 필요할 때, 타이머 인터럽트로 정확한 시간 간격을 유지할 수 있다.

 

 

 

결론적으로는 타이머 인터럽트를 이용해서 CPU가 효율적으로 잘 사용되고 있는지를 감시하여 적절히 대응하기 위한 것을 동시에, 시스템의 시간을 관리하며, 스케줄링을 지원하는 context 스위칭을 위한 것으로 이해했다.


 

 

Pintos에서는 그럼 타이머 인터럽트를 통해 무엇을 하고 있을까?

 

핀토스의 타이머 인터럽트 코드를 보자. 이는 devices/timer.c 에 있다.

 

timer_init()

/* Sets up the 8254 Programmable Interval Timer (PIT) to
   interrupt PIT_FREQ times per second, and registers the
   corresponding interrupt. */
void
timer_init (void) {
	/* 8254 input frequency divided by TIMER_FREQ, rounded to
	   nearest. */
	uint16_t count = (1193180 + TIMER_FREQ / 2) / TIMER_FREQ;

	outb (0x43, 0x34);    /* CW: counter 0, LSB then MSB, mode 2, binary. */
	outb (0x40, count & 0xff);
	outb (0x40, count >> 8);

	intr_register_ext (0x20, timer_interrupt, "8254 Timer");
}

 

타이머 인터럽트는 CPU 하드웨어 상 0x20로 연결하여야 하도록 명세가 되어있기 때문에

 

timer_interrupt를 interrupt 0x20에서 인터럽트가 발생하면 호출하도록 인터럽트로 등록해준다.

 

++ timer_interrupt()

/* Timer interrupt handler. */
static void
timer_interrupt (struct intr_frame *args UNUSED) {
	ticks++;
	// printf("timer intr: %d\n", ticks);
	thread_tick ();
}

 

아무튼 매 틱 시간마다 발생하는 해당 인터럽트는 thread_tick() 함수를 호출한다.

 

 

 

thread_tick()

/* Called by the timer interrupt handler at each timer tick.
   Thus, this function runs in an external interrupt context. */
void
thread_tick (void) {
	struct thread *t = thread_current ();

	/* Update statistics. */
	if (t == idle_thread)
		idle_ticks++;
#ifdef USERPROG
	else if (t->pml4 != NULL)
		user_ticks++;
#endif
	else
		kernel_ticks++;

	/* Enforce preemption. */
	if (++thread_ticks >= TIME_SLICE)
		intr_yield_on_return ();
}

 

쓰레드 틱이 호출되면서 실행하는 로직은

 

현재 쓰레드가 유휴 상태라면 idle_ticks 시간을 ++ 하며 유휴 상태의 시간경과를 기록하고

 

아니라면 kernel_ticks++를 통해 현재 시간이 CPU를 점유한 시간이었다는 것을 기록한다.

 

이후 TIME_SLICE (=4)만큼의 틱이 지나면 intr_yield_on_return() 함수를 통해 인터럽트를 발생시켜 현재 진행중인 쓰레드를 yield하도록 한다.

 

이 TIME_SLICE는 뭐냐하면 Round Robin 같은 시분할 시스템에서 스레드를 계속적으로 전환하는데,

 

이때 한 스레드가 작업하는 시간을 의미한다.

 

이 인터럽트는 결과적으로 thread_yield()를 발생시킨다.

 

 

 

thread_yield()

/* Yields the CPU.  The current thread is not put to sleep and
   may be scheduled again immediately at the scheduler's whim. */
void
thread_yield (void) {
	struct thread *curr = thread_current ();
	enum intr_level old_level;

	ASSERT (!intr_context ());

	old_level = intr_disable ();
	if (curr != idle_thread)
		list_push_back (&ready_list, &curr->elem);
	do_schedule (THREAD_READY);
	intr_set_level (old_level);
}

 

현재 쓰레드가 유휴 쓰레드가 아니라면 ready_list 맨 뒤로 보내고 do_schedule을 호출한다.

 

중요한 점은 쓰레드를 물리적으로 리스트에 넣는 작업은 여기서 진행했다는 것을 기억해야 한다.

 

이 리스트에 담는 것이 실제로 context-switching 했다는 의미는 아니다.

 

현재는 interrupt를 비활성화 시켜 tick 인터럽트가 수행되지 않기 때문에 시간을 마치 멈춘 것처럼 원자단위로 실행하지만

 

이렇게 하나씩 뜯어봄으로써 그 원자 단위 중에도 제일 먼저 수행된 것이 리스트에 넣는 작업이라는 것을 확인하고 있는 것이다.

 

context-switching과 리스트에 넣은 쓰레드의 상태를 변환해주는 것은 다음 함수에서 각각 실행할 것이다.

 

 

 

 

do_schedule()

/* Schedules a new process. At entry, interrupts must be off.
 * This function modify current thread's status to status and then
 * finds another thread to run and switches to it.
 * It's not safe to call printf() in the schedule(). */
static void
do_schedule(int status) {
	ASSERT (intr_get_level () == INTR_OFF);
	ASSERT (thread_current()->status == THREAD_RUNNING);
	while (!list_empty (&destruction_req)) {
		struct thread *victim =
			list_entry (list_pop_front (&destruction_req), struct thread, elem);
		palloc_free_page(victim);
	}
	thread_current ()->status = status;
	schedule ();
}

 

이 함수에서는 바로 이전에 ready 리스트에 담은 현재 스레드의 status를 ready 상태로 바꾼다.

 

이게 가능한 이유는 아직 현 스레드가 ready_list에 담았다고 하여도 이 함수에서는 아직 context_switching되지 않았기 때문이다.

 

 

이 함수에서 특이한 점은 바로 다음 호출하는 schedule() 함수에서 작업하는 폐기된 쓰레드를 destruction_req 리스트에 담을 것인데

 

do_schedule에서는destruction_req 리스트에 폐기할 쓰레드가 있다면 이 할당된 페이징 공간을 반환처리 작업을 같이 수행한다.

 

그리고 마지막으로 schedule () 함수를 호출한다.

 

 

 

 

schedule()

static void
schedule (void) {
	struct thread *curr = running_thread ();
	struct thread *next = next_thread_to_run ();

	ASSERT (intr_get_level () == INTR_OFF);
	ASSERT (curr->status != THREAD_RUNNING);
	ASSERT (is_thread (next));
	/* Mark us as running. */
	next->status = THREAD_RUNNING;

	/* Start new time slice. */
	thread_ticks = 0;

#ifdef USERPROG
	/* Activate the new address space. */
	process_activate (next);
#endif

	if (curr != next) {
		/* If the thread we switched from is dying, destroy its struct
		   thread. This must happen late so that thread_exit() doesn't
		   pull out the rug under itself.
		   We just queuing the page free reqeust here because the page is
		   currently used by the stack.
		   The real destruction logic will be called at the beginning of the
		   schedule(). */
		if (curr && curr->status == THREAD_DYING && curr != initial_thread) {
			ASSERT (curr != next);
			list_push_back (&destruction_req, &curr->elem);
		}

		/* Before switching the thread, we first save the information
		 * of current running. */
		thread_launch (next);
	}
}

 

마지막으로 호출한 이 함수는 현재 진행중인 쓰레드의 다음 쓰레드를 running 상태로 상태 전환 시킨다.

 

마찬가지로 이 상태 전환을 한다고 쓰레드가 전환된 것은 아니다.

 

실제로 context_switching이 일어나는 함수는 thread_launch()를 하면서 발생한다.

 

그 점을 유의하여야 한다.

 

 

그리고 thread_ticks를 초기화 하며 다음 쓰레드가 가지는 ticks를 0부터 다시 세게 함으로써 맨 처음 단계인 thread_tick 함수의 TIME_SLICE기간 동안 해당 스레드가 수행할 수 있도록 한다.

 

그리고 #endif 밑에 나오는 if(curr != next)를 조금 유심히 이해해보려면 정의 부에 'next_thread_to_run()'함수를 확인해 볼 필요가 있다.

 

++ next_thread_to_run()

/* Chooses and returns the next thread to be scheduled.  Should
   return a thread from the run queue, unless the run queue is
   empty.  (If the running thread can continue running, then it
   will be in the run queue.)  If the run queue is empty, return
   idle_thread. */
static struct thread *
next_thread_to_run (void) {
	if (list_empty (&ready_list))
		return idle_thread;
	else
		return list_entry (list_pop_front (&ready_list), struct thread, elem);
}

 

리스트가 비어있다면 next 쓰레드는 idle 쓰레드를 반환한다.

 

따라서 이 if(curr != next) 조건 분기리스트가 비어있고 현재 스레드가 idle 스레드라면 현재까지 진행한 모든 것을 하지 않도록 하여 CPU 가 idle 쓰레드로 점유되지 않고 쉴 수 있도록 하려는 의도이다.

 

 

 

아무튼 다시 'schedule()' 함수로 돌아가서 조건문 안을 살펴보면 현재 스레드가 initial_thread가 아니고 죽은 스레드라면 아까 했던 do_schedule을 비웠던 그 휴지통 리스트인 destruction_req 리스트에 담는걸 여기서 진행한다.

 

이후 그리고 thread_launch 함수를 통해 실제 동작을 변환시키는 context_switching을 수행한다.