Home 이벤트루프의 동작원리는 무엇일까?
Post
Cancel

이벤트루프의 동작원리는 무엇일까?

글을 작성하게 된 계기


이벤트 루프의 동작원리를 학습하며 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.





1. 이벤트 루프에 대한 의문점


이벤트 루프(Event Loop)는 프로그램이 대기 상태에 있다가 외부 또는 내부 이벤트를 감지하면, 해당 이벤트에 맞는 처리 함수를 호출해 처리합니다. 즉, 평소에는 프로그램이 대기중이다가 이벤트가 발생했을 때 이를 감지해 처리하는 것이죠.

In computer science, the event loop is a programming construct or design pattern that waits for and dispatches events or messages in a program. The event loop works by making a request to some internal or external “event provider”, then calls the relevant event handler.





하지만 이벤트 루프의 코드를 보면 반복문이 무한히 실행 되는데, 어떻게 평소에는 대기 상태 로 있다가 이벤트가 왔을 때만 이를 감지 및 처리 하는지에 대한 궁금증이 생겼습니다. 이는 운영체제의 CPU 스케줄링(CPU Scheduling) 과 관련이 있는데, Nginx를 예로 들어 이벤트 루프의 동작 원리를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
    
    ......

    // Nginx의 이벤트 루프 - 무한히 실행되는 반복문
    for ( ;; ) {
        
        ......
        
    }
}







2. 동작 원리


Nginx는 운영체제 입장에서는 하나의 사용자 프로세스(User Process) 로 CPU 입장에서는 스케줄링의 대상 이 됩니다. 운영체제는 CPU를 효율적으로 관리하기 위해 여러 프로세스 간에 CPU를 할당하며, Nginx 프로세스 또한 이러한 스케줄링 메커니즘의 일부로서 동작합니다.

image





전체 프로세스는 다음과 같은데요, 이를 하나씩 살펴보겠습니다 .

  1. Nginx가 CPU를 할당받으면 이벤트 루프를 실행합니다.
  2. 이 과정에서 epoll_wait 함수를 호출해 파일 디스크립터의 이벤트를 감시하며, 이벤트가 없다면 대기 상태로 전환합니다.
  3. 이벤트가 발생하면 커널이 해당 쓰레드를 TASK_RUNNING 상태로 변경하고, wake_up_process 함수를 호출해 실행 큐에 추가합니다. 스케줄러가 해당 쓰레드에 CPU를 할당하면 epoll_wait의 결과가 반환되고, 이벤트 루프가 실행됩니다.





2-1. 이벤트 루프 실행

Nginx 프로세스가 CPU를 할당받으면 이벤트 루프를 실행해 이벤트를 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static ngx_int_t
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)
{
    int                events;
    uint32_t           revents;
    ngx_int_t          instance, i;
    ngx_uint_t         level;
    ngx_err_t          err;
    ngx_event_t       *rev, *wev;
    ngx_queue_t       *queue;
    ngx_connection_t  *c;

    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "epoll timer: %M", timer);

    // epoll_wait 함수 호출
    events = epoll_wait(ep, event_list, (int) nevents, timer);

    ......

}





2-2. epoll_wait 함수 호출

이 과정에서 epoll_wait 함수를 호출하는데, 이는 시스템 콜(System Call) 로 커널에 등록된 파일 디스크립터(File Descriptor)들에 대한 이벤트를 확인하고, 필요한 경우 프로세스 또는 쓰레드를 대기 상태로 전환 합니다.

The epoll_wait() system call waits for events on the epoll(7) instance referred to by the file descriptor epfd. The buffer pointed to by events is used to return information from the ready list about file descriptors in the interest list that have some events available.





즉, Nginx는 하나의 사용자 프로세스(User Process) 인데, 사용자 프로세스가 시스템 콜을 호출할 경우, 사용자 모드(User Mode)에서 커널 모드(Kernel Mode)로 전환되어 시스템 콜이 실행되는 것이죠.

image





epoll_wait 함수가 호출되면, 커널은 epoll에 등록된 파일 디스크립터들을 검사하여, 처리할 이벤트가 있는지 확인합니다. 만약 이벤트가 있다면 즉시 이벤트를 처리하고 반환합니다.

image





만약 처리할 이벤트가 없다면, 커널은 해당 쓰레드를 TASK_INTERRUPTIBLE 상태로 변경하고 실행 큐에서 제외한 후, 대기 큐(Waiting Queue)에 넣습니다.

image





2-3. 이벤트 발생 및 처리

이벤트가 발생하면 커널이 해당 쓰레드를 TASK_RUNNING 상태로 변경한 후, 커널의 wake_up_process 함수를 호출해 실행 큐에 추가합니다. 이후, 스케줄러가 해당 쓰레드에 CPU를 할당하면 epoll_wait의 결과가 반환되고, 이벤트 루프가 실행됩니다.

image





이벤트가 없어서 CPU를 할당받지 못한 경우, Nginx 프로세스는 대기 큐에 들어가게 되며, 이때 커널은 다른 프로세스에게 CPU를 할당합니다. 이 경우, 이벤트 루프는 동작하지 않으며, Nginx 프로세스는 대기 상태로 전환됩니다.

image





이후, 이벤트가 발생하면 커널의 wake_up_process 함수를 통해 쓰레드를 다시 TASK_RUNNING 상태로 변경하고, 실행 큐에 추가됩니다. 이 과정을 반복하면서 이벤트 루프가 실행되는 것이죠.

image





이를 정리하면 다음과 같습니다.

  1. Nginx는 하나의 프로세스로, 내부에 이벤트 루프가 존재 합니다.
  2. 이벤트 루프는 이벤트가 발생하기 전까지 epoll_wait 함수를 호출하여 대기 상태로 있다가, 이벤트가 발생하면 이를 감지하고 처리합니다.
  3. 운영체제의 CPU 스케줄링 메커니즘에 따라 CPU를 할당받은 경우에만 이벤트 루프가 실행 됩니다.
  4. 이벤트가 없으면 epoll_wait 함수를 호출한 쓰레드는 TASK_INTERRUPTIBLE 상태로 전환되고, 이벤트가 발생하면 wake_up_process 함수를 통해 실행 큐에 추가됩니다.
  5. 이를 통해 요청이 없을 때는 CPU를 사용하지 않으며, 요청이 발생할 때만 CPU를 효율적으로 활용하여 동작 합니다.







3. 커널 코드 살펴보기


epoll_wait 함수의 호출 순서와 동작 과정은 다음과 같습니다.

1
2
// 유저 프로세스가 epoll_wait 함수를 호출하면, 리눅스 커널이 sys_epoll_wait 함수를 실행.
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
1
2
3
4
5
6
7
8
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
		int, maxevents, int, timeout)
{
	struct timespec64 to;

	return do_epoll_wait(epfd, events, maxevents,
			     ep_timeout_to_timespec(&to, timeout));
}





이는 내부적으로 do_epoll_wait 함수를 호출하며, epoll이 관리하는 파일 디스크립터에서 이벤트를 감지하고 처리할 준비를 합니다. ep_poll 함수가 호출되면서 실제 이벤트 감지 및 대기 상태 전환이 수행되며, 이벤트가 발생한 즉시 커널이 wake_up 함수를 호출해 대기 중인 프로세스를 깨웁니다.

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
static int do_epoll_wait(int epfd, struct epoll_event __user *events,
			 int maxevents, struct timespec64 *to)
{
	// epoll에서 발생한 이벤트를 받을 수 있는 최대 개수가 0보다 작거나 EP_MAX_EVENTS보다 클 경우 에러 리턴
	if (maxevents <= 0 || maxevents > EP_MAX_EVENTS)
		return -EINVAL;

	// 사용자 공간에 전달된 events 버퍼가 maxevents * sizeof(struct epoll_event) 바이트 만큼 쓰기 가능한지 확인
	if (!access_ok(events, maxevents * sizeof(struct epoll_event)))
		return -EFAULT;

	// epfd(파일 디스크립터)에 해당하는 file 구조체를 얻어옴
	CLASS(fd, f)(epfd);
	if (fd_empty(f))
		return -EBADF;

	/*
	 * 전달된 파일 디스크립터가 epoll 파일인지 확인
	 * epoll 파일이 아니라면 에러 리턴
	 */
	if (!is_file_epoll(fd_file(f)))
		return -EINVAL;

	/*
	 * 파일의 private_data 필드에는 epoll의 내부 데이터 구조체(eventpoll)가 저장되어 있다.
	 * 이제 해당 구조체를 사용하여 이벤트를 처리할 수 있음
	 */
	ep = fd_file(f)->private_data;

	// 실제 epoll polling 함수를 호출하여 이벤트들을 가져오고 처리 결과를 반환
	return ep_poll(ep, events, maxevents, to);
}
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
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
		   int maxevents, struct timespec64 *timeout)
{
	......

	/* 현재 감지된 이벤트 개수 확인 */
	eavail = ep_events_available(ep);

	/* 이벤트가 발생할 때까지 루프 실행 */
	while (1) {
		/* 이벤트가 존재하는 경우 */
		if (eavail) {
			/* 감지된 이벤트를 사용자 공간으로 전송 */
			res = ep_send_events(ep, events, maxevents);
			if (res) {
				if (res > 0)
					ep_suspend_napi_irqs(ep); // IRQ 비활성화
				return res; // 이벤트 개수 반환
			}
		}

		......

		/* 현재 프로세스를 대기열에 추가 */
		init_wait(&wait);
		wait.func = ep_autoremove_wake_function;

		/* 이벤트 큐에 대기 프로세스 추가 (Lock 획득) */
		write_lock_irq(&ep->lock);
		__set_current_state(TASK_INTERRUPTIBLE); // 프로세스를 대기 상태로 설정
		eavail = ep_events_available(ep);
		if (!eavail)
			__add_wait_queue_exclusive(&ep->wq, &wait); // 대기열 추가

		write_unlock_irq(&ep->lock); // Lock 해제

		/* 대기 상태로 진입 (CPU 양보) */
		if (!eavail)
			timed_out = !schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS);

		/* 다시 실행될 때, 상태를 TASK_RUNNING으로 복구 */
		__set_current_state(TASK_RUNNING);
		eavail = 1; // 다시 이벤트 체크하도록 설정

		/* 대기열에서 제거 (wake-up 또는 timeout 발생) */
		if (!list_empty_careful(&wait.entry)) {
			write_lock_irq(&ep->lock);
			if (timed_out)
				eavail = list_empty(&wait.entry); // 대기열이 비어있는지 확인
			__remove_wait_queue(&ep->wq, &wait); // 대기열에서 제거
			write_unlock_irq(&ep->lock);
		}
	}
}





wake_up_process() 함수는 대기 상태(TASK_INTERRUPTIBLE)의 쓰레드를 TASK_RUNNING 상태로 변경한 후, 실행 큐(runqueue)에 추가합니다. 이후 스케줄러가 해당 쓰레드에 CPU를 할당하면 epoll_wait가 반환되고, 이벤트 루프가 실행됩니다. 이 과정에서 내부적으로 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;
}





epoll_wait 함수가 호출되면, 커널은 해당 프로세스를 TASK_INTERRUPTIBLE 상태로 변경하고, 실행 큐에서 제거합니다. 이 상태에서는 등록된 파일 디스크립터에서 이벤트가 발생하면 다시 깨울 수 있으며, 시그널을 받으면 즉시 TASK_RUNNING 상태로 전환됩니다. 이후 커널의 schedule 함수가 실행되면서, 이 프로세스는 실행 큐에서 제거하고 CPU를 사용하지 않습니다.

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);
}





4. 정리


이벤트 루프에 대해 정리해보았습니다. 이벤트 루프는 겉으로 볼 땐, 무한한 반복문을 순회하는 것 같지만 프로세스가 CPU를 할당받지 못한 경우, 대기 상태로 전환되며, 이벤트가 발생했을 때만 이벤트 루프가 동작합니다. 내부 동작 원리를 명확하게 이해하고 있으면, 이벤트 루프를 사용하는 언어/프레임워크를 활용할 때, 이를 잘 활용할 수 있겠죠?


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

단조 시계와 실제 시계는 무엇일까?

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