들어가며
소켓 프로그래밍에 대해 공부를 하다 recv 함수의 동작 방식에 대해 궁금증이 생겼습니다. recv 를 하는 아래와 같은 코드를 한번 봅시다.
while ((str_len = recv(client_sock, buffer, BUFFER_SIZE - 1, 0)) > 0) {
buffer[str_len] = '\0';
printf("Received from client: %s\n", buffer);
}
이 코드를 실행하면, recv()
함수는 CPU를 전부 점유하며 무한 루프를 돌지 않습니다. 기본적으로 블로킹(blocking) 상태로 동작하며, CPU를 점유하지 않고 마치 "잠들어" 있는 것처럼 대기합니다. 그러다가 소켓에 데이터가 도착하면, recv()
함수는 "깨어나서" 데이터를 읽어 str_len
을 반환하죠.
"어떻게 이런 동작이 가능할까?" 이 단순한 궁금증에서 출발해, recv()
함수의 내부를 직접 따라가 보기로 했습니다. 이 과정을 통해 저 또한 기존에 어렴풋이 알고 있던 개념들이 실제로는 얼마나 정교하게 동작하는지, 그리고 제가 얼마나 많은 부분을 놓치고 있었는지 깨닫게 되었습니다.
이 글을 통해 여러분도 저와 함께 recv()
의 여정을 따라가며, 운영체제와 네트워크의 핵심 원리를 다시 한번 되새기는 시간을 가져보시길 바랍니다.
환경
- 아키텍쳐 : x86_64
- 리눅스 커널: v5.15
- glibc: 2.35
- 프로토콜: TCP/IP
TL;DR
✅ 잠들기: recv() 호출 시 읽을 데이터가 없으면, 현재 쓰레드는 커널의 대기 큐에 등록되고 schedule() 함수를 통해 CPU 사용권을 반납하고 수면 상태로 들어갑니다. (실제로는 context_switch 발생)
✅ 깨어나기: 네트워크로부터 데이터가 도착하면, NIC인터럽트가 발생합니다. 커널은 이 데이터를 해당 소켓의 수신 버퍼로 전달하고, 해당 소켓의 대기 큐에 등록된 쓰레드를 cpu의 runqueue에 등록합니다.
✉️ recv() 시스템 콜
우리가 사용자 공간(user space)에서 호출하는 recv()
함수는 사실 glibc와 같은 C 표준 라이브러리가 제공하는 래퍼(wrapper) 함수입니다. 이 래퍼 함수는 내부적으로 recvfrom()
이라는 시스템 콜(system call)을 호출하여 커널(kernel space)에게 실제 작업을 요청합니다. 시스템 콜에 대한 자세한 내용은 이전에 작성한 시스템 콜과 표준 라이브러리 관계 파헤치기 글을 참고하시면 좋습니다.
시스템 콜이 호출되면, 커널은 다음과 같은 함수 호출 흐름을 따라 진행됩니다 (TCP 소켓 기준):__sys_recvfrom
-> sock_recvmsg
-> sock_recvmsg_nosec
-> inet_recvmsg
-> tcp_recvmsg
-> tcp_recvmsg_locked
우리가 집중할 부분은 recv()
함수의 두 가지 핵심 동작입니다:
- 어떻게 잠드는가? (프로세스는 어떻게 CPU를 양보하고 대기 상태로 들어가는가?)
- 어떻게 깨어나는가? (데이터가 도착했을 때, 잠자던 프로세스는 어떻게 다시 실행되는가?)
🍸 1. 어떻게 잠들까? - 프로세스의 수면 과정
먼저, recv()
를 호출한 프로세스가 어떻게 스스로 잠드는지 커널 코드를 통해 확인해보겠습니다.
tcp_recvmsg_locked - 데이터 수신 시도
핵심 로직은 tcp_recvmsg_locked
함수에서 시작됩니다.
소스코드 링크
static int tcp_recvmsg_locked(struct sock *sk, struct msghdr *msg, size_t len,
int nonblock, int flags,
struct scm_timestamping_internal *tss,
int *cmsg_flags)
{
int copied = 0;
unsigned long used;
struct sk_buff *skb, *last; // last는 마지막으로 확인한 skb를 추적
do {
u32 offset;
// 소켓의 수신 큐(sk->sk_receive_queue)를 순회하며 패킷(skb)에서 데이터를 사용자 버퍼로 복사
skb_queue_walk(&sk->sk_receive_queue, skb) {
used = skb->len - offset;
err = skb_copy_datagram_msg(skb, offset, msg, used);
}
if(sk_receive_queue에 남은 데이터 없고 복사한 데이터 없으면)
sk_wait_data(sk, &timeo, last);
} while (len > 0);
return copied : err;
}
위 코드는 핵심 로직을 간추린 것입니다.
len
: 사용자 애플리케이션 버퍼의 크기 (정확히는 recv
로 받고자 하는 최대 크기)copied
: 커널 버퍼에서 사용자 버퍼로 실제로 복사된 데이터의 총량sk->sk_receive_queue
: 우리가 흔히 말하는 소켓의 수신 버퍼(receive buffer)입니다. 리눅스 커널에서는 struct sk_buff
(네트워크 패킷을 표현하는 구조체)들의 큐로 관리됩니다.
tcp_recvmsg_locked
함수는 루프를 돌면서 sk->sk_receive_queue
에 데이터가 있는지 확인하고, 있다면 skb_copy_datagram_msg
를 통해 사용자 버퍼(msg
)로 데이터를 복사합니다.
그러다가 sk_receive_queue에 남은 데이터 없고 복사한 데이터 없으면 sk_wait_data(sk, &timeo, last) 를 호출하게 됩니다.
프로세스가 어떻게 잠드는지 궁금하니 sk_wait_data
함수를 따라가 봅시다.
sk_wait_data - 대기 준비
int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function);
int rc;
add_wait_queue(sk_sleep(sk), &wait);
sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
rc = sk_wait_event(sk, timeo, skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
remove_wait_queue(sk_sleep(sk), &wait);
return rc;
}
이 함수는 프로세스를 잠들게 하고 깨우는 데 있어 매우 중요한 역할을 합니다. 깨어나는 부분에서 더 자세히 다룰 예정이지만, 여기서 핵심은 sk_wait_event
함수를 호출한다는 점입니다.
이 과정은 대략 다음과 같은 함수 호출로 이어집니다:
sk_wait_event
-> wait_woken
-> prepare_to_wait_event
-> schedule_timeout
-> schedule
-> __schedule
-> context_switch
결국, 프로세스를 재우는 핵심은 context_switch
함수에 도달하는 것입니다.
context_switch - 문맥 교환의 순간
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);
prepare_lock_switch(rq, next, rf);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
context_switch
함수를 보니, 프로세스가 "잠든다"는 것이 결국 현재 실행 중인 프로세스(prev
)에서 다른 실행 가능한 프로세스(next
)로 CPU 제어권을 넘기는 문맥 교환(context switch) 과정이라는 것을 알 수 있습니다. prev
프로세스는 특정 상태(예: TASK_INTERRUPTIBLE
)로 변경되어 스케줄러의 선택을 당분간 받지 못하게 됩니다. 이것이 우리가 말하는 "잠드는" 상태입니다.
여기서 한 가지 궁금증이 생깁니다. "프로세스가 잠들었다가 다시 깨어나면, 정확히 어느 코드부터 실행을 재개할까?" 즉, 프로그램 카운터(PC) 레지스터는 어디를 가리키게 될까요?
switch_to(prev, next, prev)
매크로가 실제 문맥 교환을 수행하는 부분입니다.
스택 프레임과 복귀 주소
switch_to
매크로는 아키텍처별 어셈블리 루틴인 __switch_to_asm
을 호출합니다.
소스코드 링크 (switch_to)
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)
소스코드 링크 (__switch_to_asm for x86_64)
SYM_FUNC_START(__switch_to_asm)
/*
* Save callee-saved registers
* This must match the order in inactive_task_frame
*/
pushq %rbp
pushq %rbx
// ... (다른 레지스터들 저장) ...
pushq %r15
/* switch stack */
movq %rsp, TASK_threadsp(%rdi) // prev 태스크의 스택 포인터 저장
movq TASK_threadsp(%rsi), %rsp // next 태스크의 스택 포인터 로드
/* restore callee-saved registers */
popq %r15
// ... (다른 레지스터들 복원) ...
popq %rbx
popq %rbp
jmp __switch_to // C 함수인 __switch_to로 점프 (스케줄링 마무리 작업)
SYM_FUNC_END(__switch_to_asm)
.popsection
switch_to(prev, next, prev)
는 매크로 호출이므로, 실제로는 call __switch_to_asm
이 실행됩니다. 그럼 stack에 리턴 address 로 barrier()
명령어 주소가 푸시됩니다.
__switch_to_asm
내에서 movq %rsp, TASK_threadsp(%rdi)
를 통해 현재 스택 탑 주소를, task_struct 에 저장해줍니다.
movq TASK_threadsp(%rsi), %rsp
를 통해 cpu 가 바라보는 스택주소를 변경합니다. 이로써 기존 stack 에는 더이상 변화가 생길 수 없습니다.
나중에 이 태스크가 깨어나서 다시 스케줄링되면, 실행은 barrier()
부터 재개됩니다.
이로써 recv()
함수를 호출한 프로세스가 어떻게 잠들고, 깨어났을 때 어디서부터 실행을 이어가는지 알게 되었습니다. 그렇다면, 이제 가장 흥미로운 부분, "어떻게 깨어나는가?"를 살펴볼 차례입니다.
🏈 2. 어떻게 깨어날까?
recv()
함수가 데이터를 기다리며 잠들었다가, 데이터가 도착하면 정확히 그 프로세스를 깨우는 메커니즘은 정말 신기합니다. 하지만 context_switch
까지의 과정을 추적하면서 어렴풋이 감을 잡으셨을 수도 있습니다. 이 "자고 깨어나는" 메커니즘은 우리가 다른 프로그래밍 언어에서 사용하는 Thread.sleep()
과 그 근본 원리가 크게 다르지 않습니다.
예를 들어, Java에서 Thread.sleep(1000)
을 호출하면 해당 스레드는 1000ms 동안 잠듭니다. 이 스레드는 타이머 만료라는 "이벤트"가 발생하면 깨어납니다. recv()
의 경우, "소켓에 데이터 도착"이라는 이벤트에 의해 깨어나는 것이죠. 둘 다 특정 이벤트가 발생하기를 기다린다는 점에서 동일합니다.
이런 이벤트 기반 깨우기를 구현하려면 무엇이 필요할까요?
- 어떤 소켓에서 이벤트가 발생했는지 알아야 합니다.
- 해당 소켓에서 데이터를 기다리던 어떤 스레드를 깨워야 하는지 알아야 합니다.
- 즉, "소켓"과 "대기 중인 스레드"를 연결(매핑)하는 방법이 필요합니다.
- 그리고 실제로 스레드를 깨울 수 있는 수단이 필요합니다.
리눅스 커널은 이 문제를 "대기 큐(wait queue)"라는 메커니즘을 통해 우아하게 해결합니다. 아까 잠시 넘어갔던 sk_wait_data
함수를 다시 자세히 살펴보겠습니다.
sk_wait_data - 대기 큐에 스레드 등록
int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function); // 1. 'wait'라는 이름의 대기 큐 엔트리 정의
int rc;
add_wait_queue(sk_sleep(sk), &wait); // 2. 소켓의 대기 큐에 'wait' 엔트리 추가
sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
// 3. sk_wait_event: 조건 만족할 때까지 대기 (내부에서 schedule() 호출로 잠듦)
rc = sk_wait_event(sk, timeo, skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
remove_wait_queue(sk_sleep(sk), &wait); // 4. 대기 큐에서 'wait' 엔트리 제거 (깨어난 후)
return rc;
}
1. DEFINE_WAIT_FUNC(wait, woken_wake_function) - 대기 항목 생성
DEFINE_WAIT_FUNC(wait, woken_wake_function);
에 대해 자세히 알아봅시다.
#define DEFINE_WAIT_FUNC(name, function)
struct wait_queue_entry name = {
.private = current, // 깨울 대상: 현재 실행 중인 태스크 (task_struct)
.func = function, // 깨울 때 호출할 함수 (woken_wake_function)
.entry = LIST_HEAD_INIT((name).entry), // 리스트 헤드 초기화
}
이 매크로는 wait_queue_entry
구조체를 wait
라는 이름으로 생성하고 초기화합니다.
struct wait_queue_entry {
unsigned int flags;
void *private; // 대기 중인 태스크(task_struct) 포인터
wait_queue_func_t func; // 태스크를 깨울 때 호출될 콜백 함수
struct list_head entry; // 대기 큐 리스트에 연결하기 위한 포인터
};
private
: 깨워야 할 대상, 즉 현재recv()
를 호출한 스레드입니다.func
: 이 대기 항목을 깨울 때 호출될 콜백 함수입니다.woken_wake_function
이 지정되었네요.
즉, DEFINE_WAIT_FUNC
는 "현재 스레드를 woken_wake_function
이라는 방법으로 깨워주세요"라는 정보를 담은 wait_queue_entry
(이름은 wait
)를 만드는 것입니다.
쓰레드 - 쓰레드 깨울 방식을 등록 해뒀습니다. 이제는 소켓정보만 같이 등록해두면 됩니다.
2. add_wait_queue(sk_sleep(sk), &wait) - 소켓과 스레드 매핑
이제 이 wait
엔트리를 특정 소켓과 연결해야 합니다. add_wait_queue
함수가 그 역할을 합니다.
그전에 sk_sleep
에대해 먼저 알아 봅시다.
소스코드 링크 (sk_sleep)
static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
return &rcu_dereference_raw(sk->sk_wq)->wait;
}
sk_sleep
은 sock 구조체 내의 sk_wq 내의 wait 이라는 멤버를 리턴하는 역할을 합니다.
직접 확인해보면,
소스코드
struct sock {
...
union {
struct socket_wq __rcu *sk_wq;
};
}
struct socket_wq {
wait_queue_head_t wait;
...
}
wait 은 어떤 리스트의 헤드 형태임을 알 수 있습니다.
add_wait_queue(sk_sleep(sk), &wait)sk_sleep(sk)
: 이 소켓내에 대기 큐&wait
: 앞에서 만든 wait_queue_entry
의 주소입니다.
결국 add_wait_queue(sk_sleep(sk), &wait)
는 sk
라는 소켓의 대기 큐에, 현재 스레드를 깨우기 위한 정보(wait
)를 추가하는 것입니다. 이로써 "소켓 - 스레드 - 스레드를 깨우는 방식"이 모두 연결되었습니다!
🏭 2.1 어떻게 깨어날까? - 실제 깨우기 과정
데이터가 실제로 소켓에 도착했을 때, 커널은 어떻게 이벤트를 감지하고 정확히 해당 스레드를 깨울까요? 이 과정은 네트워크 인터페이스 카드(NIC)에서부터 시작됩니다.
- 패킷이 NIC에 도착하면 NIC는 CPU에 인터럽트를 발생시킵니다.
- CPU는 현재 작업을 잠시 멈추고, 해당 인터럽트 핸들러를 실행합니다.
- 인터럽트 핸들러는 (소프트웨어 인터럽트 또는 tasklet/workqueue 등을 통해) 패킷을 처리하여 TCP/IP 스택을 태우고,最终적으로 해당 패킷이 어떤 소켓(
struct sock
)으로 가야 하는지 식별합니다. - 데이터가 소켓의 수신 버퍼(
sk->sk_receive_queue
)에 추가됩니다. - 데이터가 추가되었음을 알리고, 이 소켓에서 대기 중인 프로세스가 있다면 깨워야 합니다. 이때
sk->sk_data_ready
함수 포인터가 호출되는데, TCP의 경우 보통sock_def_readable
함수가 호출됩니다. (네트워크 패킷 수신 과정에 대한 더 자세한 내용은 제가 예전에 작성한 리눅스 커널 네트워크 패킷 수신 여정 #3 글의 끝부분을 참고하시면 좋습니다.)
sock_def_readable - 데이터 도착 알림 및 깨우기 시작
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI | EPOLLRDNORM | EPOLLRDBAND);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
여기서 핵심은 wake_up_interruptible_sync_poll(&wq->wait, ...)
함수 호출입니다.
인자로 받는 &wq->wait
에 주목해 봅시다.
아까 보았던 socket_wq
의 wait
이라는 멤버입니다. 다시 말하자면 소켓에 달린 대기 큐(wait_queue_entry 의 큐)를 인자로 넘기고 있습니다.
이 wake_up_interruptible_sync_poll
함수는 결국 __wake_up_common
함수로 이어집니다.
__wake_up_common - 대기 큐 순회 및 콜백 실행
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key,
wait_queue_entry_t *bookmark)
{
wait_queue_entry_t *curr, *next;
int cnt = 0;
// 소켓의 대기 큐에 연결된 모든 wait_queue_entry를 순회
list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
unsigned flags = curr->flags;
int ret;
// 각 wait_queue_entry에 등록된 func 콜백 함수를 호출!
ret = curr->func(curr, mode, wake_flags, key);
}
return cnt;
}
이 함수는 wq_head
(우리의 경우, 소켓의 대기 큐 헤드)에 연결된 모든 wait_queue_entry
(curr
)를 순회합니다. 그리고 각 curr
에 대해 curr->func(...)
를 호출합니다.
curr->func
는 무엇이었을까요? sk_wait_data
에서 DEFINE_WAIT_FUNC(wait, woken_wake_function)
로 등록했던 바로 그 woken_wake_function
입니다!
woken_wake_function -> default_wake_function
woken_wake_function
은 내부적으로 default_wake_function
을 호출합니다.
소스코드 링크
int default_wake_function(wait_queue_entry_t *curr, unsigned mode, int wake_flags, void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
curr->private
를 인자로 try_to_wake_up
함수를 호출합니다. curr->private
이 무엇이었는지 기억하시나요?
DEFINE_WAIT_FUNC
에서 .private = current
로 설정했던, 바로 recv()
를 호출하고 잠들었던 우리의 스레드입니다!
try_to_wake_up - 스레드를 Run Queue로!
static int try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
// ... (여러 조건 검사 및 준비 과정 생략) ...
cpu = select_task_rq(p, p->wake_cpu, wake_flags | WF_TTWU); // 태스크 p를 실행할 CPU 선택
ttwu_queue(p, cpu, wake_flags); // 선택된 CPU의 실행 큐(runqueue)에 태스크 p를 추가
return success;
}
try_to_wake_up
함수는 인자로 받은 태스크 p
(우리의 스레드)를 깨웁니다. 핵심은 ttwu_queue(p, cpu, wake_flags)
함수인데, 이 함수는 태스크 p
를 특정 CPU의 실행 큐(runqueue)에 추가합니다.
이것으로 잠자던 스레드를 정확히 특정하여 깨우는 전 과정이 마무리됩니다! 스레드가 다시 CPU를 할당받으면, 이전에 context_switch
함수 내의 switch_to
호출 다음 라인(barrier()
)부터 실행을 재개하게 되는 것입니다.
⚽️ 그림 요약 (개념도)
전체적인 흐름이 복잡하게 느껴질 수 있습니다. 이해를 돕기 위해 흐름을 시각화해 보았습니다.
마무리
처음엔 그저 "어떻게 recv 함수는 필요한 순간에만 깨어날 수 있을까?"라는 단순한 궁금증에서 출발했지만, 커널 내부의 흐름을 하나하나 따라가다 보니 운영체제와 네트워크의 근본 원리를 조금 더 깊게 이해할 수 있었습니다.
우리가 평소 쓰는 "블로킹 I/O"라는 추상적인 개념이 실제로는 어떻게 구현되어 있는지, 그리고 프로세스가 잠들었다가 정확히 어느 시점, 어떤 코드부터 다시 실행되는지까지 살펴보면서, 그동안 표면적으로만 이해했던 지식들이 조금 더 입체적으로 다가왔습니다.
만약, 잘못 작성된 부분이 있다면 댓글로 틀린 부분에 대해 말씀해주시면 감사하겠습니다!
Reference
https://github.com/torvalds/linux/blob/v5.15/net/ipv4/tcp.c
https://github.com/torvalds/linux/blob/v5.15/net/core/sock.c
https://github.com/torvalds/linux/blob/v5.15/kernel/sched/core.c
https://github.com/torvalds/linux/blob/v5.15/arch/x86/include/asm/switch_to.h
https://github.com/torvalds/linux/blob/v5.15/arch/x86/entry/entry_64.S
https://github.com/torvalds/linux/blob/v5.15/include/linux/wait.h
https://github.com/torvalds/linux/blob/v5.15/include/net/sock.h
https://github.com/torvalds/linux/blob/v5.15/kernel/sched/wait.c
'CS > Linux' 카테고리의 다른 글
리눅스는 어떻게 TLB Flush를 최적화할까? (0) | 2025.06.11 |
---|---|
[Linux] epoll의 내부 동작 방식 (1) | 2025.06.01 |
[Linux] 네트워크 커널 스택: 리눅스 커널 네트워크 패킷 수신 여정 #3 (0) | 2025.05.22 |
[Linux] Ring Buffer 에서 NAPI: 리눅스 커널 네트워크 패킷 수신 여정 #2 (0) | 2025.05.21 |
[Linux] NIC에서 링 버퍼까지: 리눅스 커널 네트워크 패킷 수신 여정 #1 (0) | 2025.05.20 |