[os]tcp server

ttttom1·3일 전

여기서 server는 accept와 recv 할 때 효율적 대기를 하는것인가? 그러면여기서 mutex와 signal은 누가 주지?
->아주 날카롭고 본질적인 질문입니다!

결론부터 말씀드리면, 효율적인 대기(Sleep/Blocked)가 맞습니다. 하지만 애플리케이션 코드처럼 우리가 직접 pthread_mutex_t나 pthread_cond_t를 사용하는 것이 아니라, OS 커널(Kernel) 내부의 대기 큐(Wait Queue)와 하드웨어 인터럽트(Interrupt) 시스템에 의해 동작합니다.


1. 효율적인 대기(Sleep)가 맞는가?

네, 맞습니다.

accept()나 recv()를 호출해서 블로킹 상태에 빠졌을 때, CPU는 무한 루프(while(1))를 돌며 바쁘게 대기(Busy Waiting / Spin Lock)하는 것이 아닙니다.

OS 커널이 해당 스레드의 상태를 TASK_INTERRUPTIBLE (Blocked/Sleep 상태)로 전환하고 CPU 스케줄링 대상에서 완전히 제외합니다. 따라서 CPU 사용률은 0%가 됩니다.


2. Mutex나 Signal 역할은 누가 해줄까?

우리가 사용자 공간(User Space) 코드에서 C언어로 pthread_cond_wait()을 부를 때와 비유해 보면 구조가 완벽히 대응됩니다.

개념애플리케이션 (User Space)소켓 통신 (Kernel & Hardware)
대기 장소Condition Variable (pthread_cond_t)커널 내부 소켓의 Wait Queue (대기 큐)
Wait (재우기)pthread_cond_wait()accept(), recv() 시스템 콜
Signal (깨우기)pthread_cond_signal()랜카드(NIC)의 하드웨어 인터럽트 & 커널 네트워크 스택
동기화 (Lock)pthread_mutex_t커널 내부의 Spinlock / Socket Lock

3. 실제 패킷이 들어올 때의 전체 동작 과정 (Behind the Scenes)

클라이언트가 데이터를 보내서 서버의 recv()가 깨어나는 과정은 아래와 같이 진행됩니다.

  1. recv() 호출 (Sleep 전환)
  • 애플리케이션 스레드가 recv() 시스템 콜을 호출합니다.
  • 소켓 수신 버퍼(Receive Buffer)가 비어 있음을 확인한 커널은, 이 스레드를 해당 소켓의 Wait Queue(대기 큐)에 넣고 스레드 상태를 Sleep으로 바꿉니다.
  1. 패킷 도착 및 하드웨어 Signal (Interrupt)
  • 클라이언트의 패킷이 서버 랜카드(NIC)에 도착합니다.
  • 랜카드는 CPU에게 "데이터 도착함!"이라는 전기적 신호인 하드웨어 인터럽트(Hardware Interrupt)를 보냅니다.
  1. 커널의 Wakeup 처리 (Signal 역할)
  • 인터럽트를 받은 커널의 인터럽트 핸들러(ISR / Softirq)가 실행되어 패킷을 소켓 수신 버퍼에 카피합니다.
  • 커널은 해당 소켓의 Wait Queue를 확인하고, 거기서 대기 중이던 스레드를 발견합니다.
  • 커널이 내부에 가지고 있는 wake_up() 함수(유저 공간의 Signal과 동일한 역할)를 불러 스레드를 Ready (Runnable) 상태로 깨웁니다.
  1. 스레드 복귀
  • OS 스케줄러에 의해 깨어난 스레드가 CPU를 할당받아 recv() 함수 실행을 완료하고, 데이터를 버퍼로 받아와 다음 C 코드를 계속 실행합니다.

요약

  • 효율적 대기인가? ➔ YES. CPU 자원을 전혀 쓰지 않고 커널에 의해 잠듭니다.
  • Signal은 누가 주나? ➔ 랜카드가 보내는 하드웨어 인터럽트 ➔ 이를 받은 OS 커널이 내부 대기 큐(Wait Queue)에 깨우기 신호(wake_up)를 보냅니다.

#client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <sys/socket.h> // 소켓 생성 및 통신 관련 함수 (socket, connect, send, recv 등)
#include <arpa/inet.h>   // IP 주소 변환 및 바이트 순서 변환 함수 (htons, inet_pton 등)

#define PORT 8080
#define BUF_SIZE 1024

int main(void) {
    // 1. TCP 소켓 생성 (IPv4, TCP 통신)
    int sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    if (sock_fd == -1) {
        perror("socket");
        exit(1);
    }

    // 2. 서버 주소 정보 구조체 설정 (IPv4, Port 8080)
    struct sockaddr_in server_addr = { 0 }; // 쓰레기 값 방지를 위한 초기화
    server_addr.sin_family = AF_INET;       // IPv4 주소 체계
    server_addr.sin_port = htons(PORT);     // Port 번호를 네트워크 바이트 순서(Big-Endian)로 변환

    // 문자열 IP 주소("127.0.0.1")를 바이너리 네트워크 주소로 변환하여 server_addr.sin_addr에 저장
    if (inet_pton(
        AF_INET,
        "127.0.0.1",
        &server_addr.sin_addr
    ) != 1){

        perror("inet_pton");
        close(sock_fd);
        exit(1);
    }
    
    // 3. 서버에 TCP 연결 요청 (3-way handshake 수행)
    if (connect(
        sock_fd,
        (struct sockaddr *)&server_addr,
        sizeof(server_addr)
    ) == -1) {

        perror("connect");
        close(sock_fd);
        exit(1);
    }

    printf("connected to server\n");

    const char *message  = "hello server";

    // 4. 서버로 메시지 전송
    if (send(
        sock_fd,
        message,
        strlen(message),
        0
    ) == -1) {
        perror("send");
        close(sock_fd);
        exit(1);
    }

    char buffer[BUF_SIZE] = {0};

    // 5. 서버로부터 응답 수신 (데이터가 올 때까지 대기/Blocking)
    ssize_t n = recv(
        sock_fd,
        buffer,
        sizeof(buffer) - 1, // 문자열 끝 null 문자를 남겨두기 위해 -1
        0
    );

    if (n  == -1) {
        perror("recv");
        close(sock_fd);
        exit(1);
    }
    // 수신한 데이터 끝에 null 문자('\0')를 붙여 안전한 문자열로 완결
    buffer[n] = '\0';

    printf("server response: %s\n", buffer);

    // 6. 소켓 자원 해제 및 연결 종료
    close(sock_fd);

    return 0;
}
#server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8080
#define BUF_SIZE 1024
int main(void) {
    
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    //열려있다면, 무시하고 다시 덮어씀
    int opt = 1;

    if (setsockopt(
        server_fd,
        SOL_SOCKET,
        SO_REUSEADDR,
        &opt,
        sizeof(opt)
    ) == -1) {
        perror("setsockopt");
        close(server_fd);
        exit(1);
    }

    //IPv4 주소 구조체 생성, 설정
    struct sockaddr_in addr = { 0 };

    addr.sin_family = AF_INET;

    addr.sin_port = htons(PORT);

    addr.sin_addr.s_addr = INADDR_ANY;
    //설정한 주소 정보를 서버 소켓에 바인딩
    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(server_fd);
        exit(1);
    }
    //소켓을 연결 수신 대기  상태로 전환
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        close(server_fd);
        exit(1);
    }

    printf("listening of port %d...\n", PORT);

    // 여기서 block
    int client_fd = accept(server_fd, NULL,NULL);

    if (client_fd == -1) {
        perror("accept");
        close(server_fd);
        exit(1);
    }

    printf("client connected\n");
    printf("server_fd = %d\n", server_fd);
    printf("client_fd = %d\n", client_fd);

    char buffer[BUF_SIZE] = { 0 };

    ssize_t n = recv(
        client_fd,
        buffer,
        sizeof(buffer) - 1,
        0
    );

    if (n == -1) {
        perror("recv");
        close(client_fd);
        close(server_fd);
        exit(1);
    }

    buffer[n] = '\0';

    printf("recieved: %s\n", buffer);

    const char *response = "hello client";

    if (send(
        client_fd,
        response,
        strlen(response),
        0
        ) == -1) {
        perror("send");
    }
    close(client_fd);
    close(server_fd);

    return 0;
}

이번 server.c / client.c (B5 TCP Socket 기초) 실습에서 질문하셨던 핵심 개념과 답변들을 한눈에 복습 및 기록하실 수 있도록 요약·정리해 두었습니다.


1. sockaddr_in 구조체 멤버명 sin_family의 의미

  • sin = socket internet (인터넷 소켓)
  • family = 주소 체계 / 계열
  • 의미: struct sockaddr_in 구조체 내부 멤버의 접두어(sin_)이며, sin_family = AF_INET은 IPv4 인터넷 주소 체계를 사용하겠다는 의미입니다.

2. &server_addr.sin_addr의 역할

  • 개념: server_addr 구조체 내부의 IP 주소 필드(sin_addr)에 대한 메모리 주소(Pointer)입니다.
  • 역할: inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr)와 같이, 문자열 IP를 네트워크 바이너리로 변환한 결과를 어느 메모리 위치에 저장할지 넘겨주는 목적으로 사용됩니다.

3. TCP Three-way Handshake의 주체 및 시점

  • 주체: C 언어 코드(accept 등)가 아니라 OS 커널의 TCP/IP 스택이 백그라운드에서 실행합니다.
  • 시점: 서버가 listen()을 호출해 LISTEN 상태가 된 후, 클라이언트가 connect()를 호출할 때 일어납니다.
  • accept()의 실제 역할: Handshake를 직접 하는 것이 아니라, 커널 내부 Accept Queue에 이미 Handshake가 완료되어 들어온 연결을 꺼내와서 애플리케이션용 File Descriptor(client_fd)를 반환합니다.

4. recv() / accept()에서의 스레드 블로킹 (Blocking)

  • 스레드 상태: 연결 요청이나 데이터가 올 때까지 스레드가 OS 커널에 의해 Waiting (Blocked) 상태로 전환됩니다.
  • 특징: 이 대기 상태 동안 해당 스레드는 CPU 자원을 전혀 소모하지 않으며, 커널에 이벤트(패킷 수신 등)가 발생하면 깨어나 동작을 재개합니다.

5. TCP MSS와 Sequence Number 관리

  • 개념: TCP 통신에서 MSS(최대 세그먼트 크기) 협상과 Sequence/ACK Number를 통한 데이터 순서 보장 및 손실 재전송 제어는 TCP의 핵심 동작 방식입니다.
  • 담당 영역: 이 모든 과정 역시 개발자가 직접 코딩하는 것이 아니라 OS 커널이 자동으로 관리하며, C 소켓 API(send, recv)는 그 결과를 애플리케이션으로 가져오는 인터페이스 역할을 합니다.

6. TCP의 Byte Stream 특성과 가상 예시

  • 개념: TCP는 메시지 경계(Boundary)가 없는 '연속된 바이트의 흐름(Byte Stream)'입니다.
  • send() 2번 예시의 의미: 작성하신 클라이언트 코드는 send()를 1번만 부르고 있으나, "만약 클라이언트가 send()를 2번 호출한다면?"이라는 가상의 예시입니다.
  • 결과: 클라이언트가 send()를 2번 나누어 부르더라도 네트워크 상황이나 OS 버퍼 상태에 따라 서버는 recv() 1번으로 통째로 받거나 여러 번 쪼개서 받을 수 있습니다. 따라서 추후 Protocol Framing(구분자 추가, 길이 명시 등) 처리 로직이 필요합니다.

7. recv() 호출 시 sizeof(buffer) - 1을 사용하는 이유

  • 목적: 수신된 데이터 맨 끝에 C언어 문자열 종료 문자인 NULL('\0')을 추가할 1바이트 공간을 확보하기 위함입니다.
  • 이유: recv()는 데이터 뒤에 NULL 문자를 자동으로 붙여주지 않습니다. 만약 버퍼 크기 전체(1024)를 수신받으면 buffer[n] = '\0' 시 메모리 침범(Buffer Overflow)이 발생하므로, 최대 1023바이트만 받도록 제한하여 안전하게 문자열로 완결 짓습니다.

8. recv() 함수의 추상화 수준

  • 개념: recv()는 하드웨어(NIC) 인터럽트, IP/TCP 헤더 검증, Checksum, 패킷 재정렬, 손실 재전송, 커널 ➔ 유저 메모리 복사 등 복잡한 네트워크 스택의 처리를 C 함수 호출 한 줄로 은닉한 고급 추상화 API입니다.
profile
World Class, The Beginning.

0개의 댓글