Skip to content

커널 I/O 동작의 모든 것: 동기/비동기부터 io_uring까지 ​

중급 개발자를 위한 리눅스 커널 I/O 완벽 가이드


목차 ​

  1. 커널 I/O 기초 개념
  2. 시스템 콜과 커널의 동작
  3. 동기 vs 비동기, 블로킹 vs 논블로킹
  4. 전통적인 I/O 멀티플렉싱: select와 poll
  5. epoll: 리눅스의 효율적인 이벤트 알림
  6. io_uring: 차세대 비동기 I/O
  7. 성능 측정과 벤치마킹
  8. 실전 적용 사례

1. 커널 I/O 기초 개념 ​

1.1 유저스페이스와 커널스페이스 ​

현대 운영체제는 보안과 안정성을 위해 메모리 공간을 두 가지로 분리합니다:

  • 유저스페이스 (User Space): 응용 프로그램이 실행되는 공간
  • 커널스페이스 (Kernel Space): 운영체제 커널이 실행되는 공간

I/O 작업(네트워크 통신, 파일 읽기/쓰기 등)은 반드시 커널을 통해 이루어집니다. 이는 하드웨어 리소스에 대한 직접 접근을 방지하고, 여러 프로세스 간의 충돌을 막기 위함입니다.

1.2 I/O 작업의 흐름 ​

[응용 프로그램] → [시스템 콜] → [커널] → [하드웨어]
                    ↓
              [컨텍스트 스위칭]

I/O 작업이 발생하면:

  1. 응용 프로그램이 시스템 콜 호출
  2. CPU가 유저 모드에서 커널 모드로 전환 (컨텍스트 스위칭)
  3. 커널이 하드웨어 드라이버를 통해 I/O 수행
  4. 결과를 유저스페이스로 복사
  5. CPU가 다시 유저 모드로 전환

1.3 Go에서의 기본 I/O 예제 ​

go
package main

import (
    "fmt"
    "os"
)

func main() {
    // 파일 열기 - open() 시스템 콜 호출
    file, err := os.Open("example.txt")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer file.Close()

    // 파일 읽기 - read() 시스템 콜 호출
    buffer := make([]byte, 1024)
    n, err := file.Read(buffer)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("읽은 바이트: %d\n", n)
    fmt.Printf("내용: %s\n", buffer[:n])
}

이 코드의 커널 레벨 동작:

  • os.Open(): open() 시스템 콜 → 커널이 파일 디스크립터 생성
  • file.Read(): read() 시스템 콜 → 커널이 디스크에서 데이터 읽어 유저스페이스 버퍼로 복사

2. 시스템 콜과 커널의 동작 ​

2.1 시스템 콜이란? ​

시스템 콜은 유저스페이스 프로그램이 커널 서비스를 요청하는 인터페이스입니다. 일반 함수 호출과 달리 특수한 CPU 명령어(x86-64의 syscall)를 사용합니다.

2.2 read() 시스템 콜의 내부 동작 ​

1. 응용 프로그램이 read() 호출
2. CPU가 커널 모드로 전환
3. 커널이 파일 디스크립터 테이블 확인
4. VFS (Virtual File System) 레이어 통과
5. 실제 파일 시스템 드라이버 호출
6. 디스크 컨트롤러에게 I/O 요청
7. DMA를 통해 데이터가 커널 버퍼로 전송
8. 커널 버퍼에서 유저스페이스 버퍼로 복사
9. CPU가 유저 모드로 복귀

2.3 컨텍스트 스위칭의 비용 ​

컨텍스트 스위칭은 비용이 큽니다:

  • CPU 레지스터 저장/복원
  • TLB (Translation Lookaside Buffer) 플러시
  • 캐시 무효화

따라서 시스템 콜 횟수를 줄이는 것이 성능 최적화의 핵심입니다.

2.4 Go에서 시스템 콜 직접 호출 ​

go
package main

import (
    "fmt"
    "syscall"
)

func main() {
    // 직접 시스템 콜 사용하기
    message := []byte("Hello, Kernel!\n")

    // write(1, message, len(message))
    // 1 = stdout 파일 디스크립터
    n, err := syscall.Write(1, message)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("작성된 바이트: %d\n", n)

    // 파일 디스크립터 정보 가져오기
    var stat syscall.Stat_t
    err = syscall.Fstat(1, &stat)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("파일 모드: %o\n", stat.Mode)
}

설명:

  • syscall.Write(): 직접 write() 시스템 콜 호출
  • syscall.Fstat(): 파일 디스크립터의 상태 정보 조회
  • Go의 표준 라이브러리는 이러한 시스템 콜을 감싸는 래퍼입니다

3. 동기 vs 비동기, 블로킹 vs 논블로킹 ​

이 네 가지 개념은 I/O 프로그래밍에서 가장 혼동되는 개념입니다. 명확히 구분해봅시다.

3.1 블로킹 vs 논블로킹 ​

제어권의 관점에서 구분합니다.

  • 블로킹 (Blocking): I/O 작업이 완료될 때까지 함수가 반환되지 않음. 호출자는 대기.
  • 논블로킹 (Non-blocking): I/O 작업이 즉시 반환. 데이터가 준비되지 않았다면 에러 반환.
go
package main

import (
    "fmt"
    "net"
    "os"
    "syscall"
    "time"
)

// 블로킹 I/O 예제
func blockingRead() {
    conn, err := net.Dial("tcp", "example.com:80")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer conn.Close()

    fmt.Println("데이터를 기다리는 중... (블로킹됨)")
    buffer := make([]byte, 1024)

    // 이 라인에서 데이터가 도착할 때까지 블로킹됨
    n, err := conn.Read(buffer)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("받은 데이터: %d 바이트\n", n)
}

// 논블로킹 I/O 예제
func nonBlockingRead() {
    conn, err := net.Dial("tcp", "example.com:80")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer conn.Close()

    // TCP 연결을 논블로킹 모드로 설정
    if tcpConn, ok := conn.(*net.TCPConn); ok {
        file, _ := tcpConn.File()
        fd := int(file.Fd())
        syscall.SetNonblock(fd, true)
    }

    buffer := make([]byte, 1024)

    for {
        n, err := conn.Read(buffer)
        if err != nil {
            if opErr, ok := err.(*net.OpError); ok {
                // EAGAIN 또는 EWOULDBLOCK: 데이터가 아직 없음
                if opErr.Temporary() {
                    fmt.Println("데이터 없음, 다른 작업 수행 가능")
                    time.Sleep(100 * time.Millisecond)
                    continue
                }
            }
            fmt.Println("Error:", err)
            return
        }

        if n > 0 {
            fmt.Printf("받은 데이터: %d 바이트\n", n)
            break
        }
    }
}

func main() {
    fmt.Println("=== 블로킹 I/O ===")
    blockingRead()

    fmt.Println("\n=== 논블로킹 I/O ===")
    nonBlockingRead()
}

3.2 동기 vs 비동기 ​

결과 통지 방식의 관점에서 구분합니다.

  • 동기 (Synchronous): 작업 완료 여부를 호출자가 직접 확인
  • 비동기 (Asynchronous): 작업 완료 시 커널이 호출자에게 통지 (콜백, 시그널 등)
go
package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

// 동기 방식: 직접 결과를 기다림
func synchronousHTTP() {
    fmt.Println("동기 HTTP 요청 시작")
    start := time.Now()

    resp, err := http.Get("https://example.com")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("동기 완료: %d 바이트, 소요 시간: %v\n", len(body), time.Since(start))
}

// 비동기 방식: 고루틴과 채널을 사용한 비동기 패턴
func asynchronousHTTP() {
    fmt.Println("비동기 HTTP 요청 시작")
    start := time.Now()

    // 결과를 받을 채널
    resultChan := make(chan int)

    // 백그라운드에서 작업 수행
    go func() {
        resp, err := http.Get("https://example.com")
        if err != nil {
            fmt.Println("Error:", err)
            resultChan <- -1
            return
        }
        defer resp.Body.Close()

        body, _ := io.ReadAll(resp.Body)
        resultChan <- len(body)
    }()

    // 비동기 작업이 실행되는 동안 다른 작업 가능
    fmt.Println("비동기 작업이 백그라운드에서 실행 중...")
    fmt.Println("다른 작업을 수행할 수 있습니다!")

    // 결과가 준비될 때까지 대기
    bodyLen := <-resultChan
    fmt.Printf("비동기 완료: %d 바이트, 총 소요 시간: %v\n", bodyLen, time.Since(start))
}

// 여러 비동기 작업을 동시에 처리
func multipleAsyncHTTP() {
    urls := []string{
        "https://example.com",
        "https://golang.org",
        "https://github.com",
    }

    start := time.Now()
    resultChan := make(chan string, len(urls))

    // 여러 요청을 동시에 시작
    for _, url := range urls {
        go func(u string) {
            resp, err := http.Get(u)
            if err != nil {
                resultChan <- fmt.Sprintf("%s: 에러", u)
                return
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            resultChan <- fmt.Sprintf("%s: %d 바이트", u, len(body))
        }(url)
    }

    // 모든 결과 수집
    for i := 0; i < len(urls); i++ {
        fmt.Println(<-resultChan)
    }

    fmt.Printf("모든 요청 완료: %v\n", time.Since(start))
}

func main() {
    synchronousHTTP()
    fmt.Println()
    asynchronousHTTP()
    fmt.Println()
    multipleAsyncHTTP()
}

3.3 네 가지 조합 ​

조합설명예시
동기 + 블로킹가장 일반적. 호출 후 결과를 기다림일반적인 read()
동기 + 논블로킹즉시 반환, 주기적으로 상태 확인 (폴링)O_NONBLOCK + 루프
비동기 + 블로킹거의 사용 안 됨-
비동기 + 논블로킹고성능 I/O의 핵심epoll, io_uring

4. 전통적인 I/O 멀티플렉싱: select와 poll ​

4.1 I/O 멀티플렉싱이란? ​

하나의 스레드에서 여러 파일 디스크립터를 동시에 모니터링하는 기술입니다.

4.2 select의 동작 원리 ​

c
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

문제점:

  • O(n) 복잡도: 모든 파일 디스크립터를 순회
  • 파일 디스크립터 개수 제한 (보통 1024개)
  • 매번 커널에 파일 디스크립터 세트를 복사해야 함

4.3 Go에서 select 스타일 패턴 ​

Go의 select는 시스템 콜 select()와는 다르지만, 유사한 멀티플렉싱 패턴입니다.

go
package main

import (
    "fmt"
    "net"
    "time"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()
    buffer := make([]byte, 1024)

    for {
        // SetReadDeadline으로 타임아웃 설정 (select의 timeout과 유사)
        conn.SetReadDeadline(time.Now().Add(5 * time.Second))

        n, err := conn.Read(buffer)
        if err != nil {
            if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
                fmt.Println("타임아웃")
                continue
            }
            fmt.Println("연결 종료:", err)
            return
        }

        fmt.Printf("받은 데이터: %s\n", buffer[:n])
        conn.Write([]byte("에코: " + string(buffer[:n])))
    }
}

// Go의 select를 사용한 여러 채널 모니터링
func channelMultiplexing() {
    chan1 := make(chan string)
    chan2 := make(chan string)
    timeout := time.After(5 * time.Second)

    go func() {
        time.Sleep(1 * time.Second)
        chan1 <- "채널 1 데이터"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        chan2 <- "채널 2 데이터"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg := <-chan1:
            fmt.Println("받음:", msg)
        case msg := <-chan2:
            fmt.Println("받음:", msg)
        case <-timeout:
            fmt.Println("타임아웃!")
            return
        }
    }
}

func main() {
    // TCP 서버 시작
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer listener.Close()

    fmt.Println("서버 시작: :8080")

    // 연결 수락 루프
    go func() {
        for {
            conn, err := listener.Accept()
            if err != nil {
                fmt.Println("Accept error:", err)
                continue
            }

            // 각 연결을 별도의 고루틴에서 처리
            go handleConnection(conn)
        }
    }()

    // 채널 멀티플렉싱 데모
    channelMultiplexing()
}

4.4 poll의 개선점 ​

c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

개선점:

  • 파일 디스크립터 개수 제한 없음
  • 비트마스크 대신 구조체 배열 사용

여전한 문제:

  • 여전히 O(n) 복잡도
  • 매번 전체 배열을 커널에 복사

5. epoll: 리눅스의 효율적인 이벤트 알림 ​

5.1 epoll의 혁신 ​

epoll은 리눅스 2.6에서 도입된 확장 가능한 I/O 이벤트 알림 메커니즘입니다.

핵심 개선:

  • O(1) 복잡도: 이벤트가 발생한 파일 디스크립터만 반환
  • 커널 내부에 상태 유지: 파일 디스크립터를 매번 복사할 필요 없음
  • Edge-triggered와 Level-triggered 모드 지원

5.2 epoll API ​

c
int epoll_create1(int flags);                    // epoll 인스턴스 생성
int epoll_ctl(int epfd, int op, int fd, ...);   // 파일 디스크립터 추가/수정/삭제
int epoll_wait(int epfd, ...);                   // 이벤트 대기

5.3 Level-triggered vs Edge-triggered ​

  • Level-triggered (LT): 데이터가 있으면 계속 통지 (기본값, 더 안전)
  • Edge-triggered (ET): 상태 변화 시에만 통지 (더 효율적, 주의 필요)
go
package main

import (
    "fmt"
    "golang.org/x/sys/unix"
    "net"
    "syscall"
)

// epoll을 직접 사용하는 TCP 에코 서버
func epollEchoServer() {
    // 리스닝 소켓 생성
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer listener.Close()

    // 리스닝 소켓의 파일 디스크립터 가져오기
    listenerFile, _ := listener.(*net.TCPListener).File()
    listenerFd := int(listenerFile.Fd())

    // epoll 인스턴스 생성
    epollFd, err := unix.EpollCreate1(0)
    if err != nil {
        fmt.Println("epoll_create1 error:", err)
        return
    }
    defer unix.Close(epollFd)

    // 리스닝 소켓을 epoll에 등록
    event := unix.EpollEvent{
        Events: unix.EPOLLIN, // 읽기 가능 이벤트
        Fd:     int32(listenerFd),
    }
    if err := unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, listenerFd, &event); err != nil {
        fmt.Println("epoll_ctl error:", err)
        return
    }

    fmt.Println("epoll 서버 시작: :8080")

    // 이벤트를 받을 배열
    events := make([]unix.EpollEvent, 128)
    connections := make(map[int32]net.Conn)

    for {
        // 이벤트 대기 (블로킹)
        n, err := unix.EpollWait(epollFd, events, -1)
        if err != nil {
            fmt.Println("epoll_wait error:", err)
            continue
        }

        // 발생한 이벤트 처리
        for i := 0; i < n; i++ {
            ev := events[i]

            if ev.Fd == int32(listenerFd) {
                // 새로운 연결 수락
                conn, err := listener.Accept()
                if err != nil {
                    fmt.Println("Accept error:", err)
                    continue
                }

                // 클라이언트 소켓을 논블로킹으로 설정
                tcpConn := conn.(*net.TCPConn)
                connFile, _ := tcpConn.File()
                connFd := int(connFile.Fd())
                unix.SetNonblock(connFd, true)

                // 클라이언트 소켓을 epoll에 등록
                connEvent := unix.EpollEvent{
                    Events: unix.EPOLLIN | unix.EPOLLET, // Edge-triggered 모드
                    Fd:     int32(connFd),
                }
                unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, connFd, &connEvent)
                connections[int32(connFd)] = conn

                fmt.Printf("새 연결: fd=%d\n", connFd)

            } else {
                // 클라이언트로부터 데이터 수신
                conn := connections[ev.Fd]
                buffer := make([]byte, 1024)

                n, err := conn.Read(buffer)
                if err != nil || n == 0 {
                    // 연결 종료
                    fmt.Printf("연결 종료: fd=%d\n", ev.Fd)
                    unix.EpollCtl(epollFd, unix.EPOLL_CTL_DEL, int(ev.Fd), nil)
                    conn.Close()
                    delete(connections, ev.Fd)
                    continue
                }

                fmt.Printf("받은 데이터 (fd=%d): %s", ev.Fd, buffer[:n])

                // 에코
                conn.Write(buffer[:n])
            }
        }
    }
}

func main() {
    epollEchoServer()
}

5.4 epoll의 성능 특징 ​

장점:

  • 연결 수가 증가해도 성능 일정 (O(1))
  • C10K 문제 해결 (10,000개 동시 연결)
  • CPU 사용률 낮음

단점:

  • 리눅스 전용 (FreeBSD는 kqueue, Windows는 IOCP)
  • 여전히 시스템 콜 필요 (epoll_wait)
  • 파일 I/O에는 효과 제한적 (디스크는 항상 "준비됨" 상태)

6. io_uring: 차세대 비동기 I/O ​

6.1 io_uring의 등장 배경 ​

전통적인 I/O 방식의 한계:

  • 시스템 콜 오버헤드 (컨텍스트 스위칭)
  • 데이터 복사 오버헤드 (커널 ↔ 유저스페이스)
  • 비동기 I/O (AIO)의 제한적인 기능

io_uring (2019년, 리눅스 5.1)은 이러한 문제를 해결하는 새로운 비동기 I/O 인터페이스입니다.

6.2 io_uring의 핵심 아이디어 ​

공유 링 버퍼 (Shared Ring Buffers):

  • SQ (Submission Queue): 유저스페이스 → 커널로 I/O 요청 제출
  • CQ (Completion Queue): 커널 → 유저스페이스로 완료 이벤트 전달
┌─────────────────┐         ┌──────────────────┐
│  User Space     │         │   Kernel Space   │
│                 │         │                  │
│  ┌───────────┐  │  mmap   │  ┌───────────┐   │
│  │    SQ     │◄─┼─────────┼─►│    SQ     │   │
│  └───────────┘  │         │  └───────────┘   │
│                 │         │        ↓          │
│  ┌───────────┐  │         │  ┌───────────┐   │
│  │    CQ     │◄─┼─────────┼─►│    CQ     │   │
│  └───────────┘  │         │  └───────────┘   │
└─────────────────┘         └──────────────────┘

장점:

  • 시스템 콜 최소화: 링 버퍼를 통한 배치 처리
  • 제로 복사: 공유 메모리 사용
  • 진정한 비동기: 모든 I/O 작업 지원 (파일, 네트워크, 타이머 등)

6.3 Go에서 io_uring 사용하기 ​

Go에서는 github.com/iceber/iouring-go 라이브러리를 사용할 수 있습니다.

go
package main

import (
    "fmt"
    "os"
    "unsafe"

    iouring "github.com/iceber/iouring-go"
)

// io_uring을 사용한 파일 읽기
func iouringFileRead() {
    // io_uring 인스턴스 생성
    ring, err := iouring.New(256) // 큐 크기 256
    if err != nil {
        fmt.Println("io_uring 초기화 실패:", err)
        return
    }
    defer ring.Close()

    // 파일 열기
    file, err := os.Open("test.txt")
    if err != nil {
        fmt.Println("파일 열기 실패:", err)
        return
    }
    defer file.Close()

    // 읽기 버퍼 준비
    buffer := make([]byte, 4096)

    // Read 요청을 SQ에 제출
    request := iouring.Read(
        int(file.Fd()),
        uintptr(unsafe.Pointer(&buffer[0])),
        uint32(len(buffer)),
        0, // offset
    )

    prepRequest := ring.Prep(request)
    prepRequest.SetUserData(1) // 요청 식별자

    // 요청 제출
    submitted, err := ring.Submit(1)
    if err != nil {
        fmt.Println("제출 실패:", err)
        return
    }
    fmt.Printf("제출된 요청 수: %d\n", submitted)

    // 완료 이벤트 대기
    cqe, err := ring.WaitCQE()
    if err != nil {
        fmt.Println("대기 실패:", err)
        return
    }

    // 결과 확인
    result := cqe.Result()
    if result < 0 {
        fmt.Printf("I/O 에러: %d\n", result)
        return
    }

    fmt.Printf("읽은 바이트: %d\n", result)
    fmt.Printf("내용: %s\n", buffer[:result])

    // CQE 소비
    ring.SeenCQE(cqe)
}

// io_uring을 사용한 여러 파일 동시 읽기
func iouringBatchRead() {
    ring, err := iouring.New(256)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer ring.Close()

    files := []string{"file1.txt", "file2.txt", "file3.txt"}
    buffers := make([][]byte, len(files))
    fds := make([]*os.File, len(files))

    // 모든 파일 열기
    for i, filename := range files {
        file, err := os.Open(filename)
        if err != nil {
            fmt.Printf("파일 열기 실패 %s: %v\n", filename, err)
            continue
        }
        fds[i] = file
        defer file.Close()

        buffers[i] = make([]byte, 4096)
    }

    // 모든 읽기 요청을 배치로 제출
    for i, fd := range fds {
        if fd == nil {
            continue
        }

        request := iouring.Read(
            int(fd.Fd()),
            uintptr(unsafe.Pointer(&buffers[i][0])),
            uint32(len(buffers[i])),
            0,
        )

        prepRequest := ring.Prep(request)
        prepRequest.SetUserData(uint64(i)) // 파일 인덱스를 식별자로 사용
    }

    // 한 번의 시스템 콜로 모든 요청 제출
    submitted, err := ring.Submit(len(files))
    if err != nil {
        fmt.Println("제출 실패:", err)
        return
    }
    fmt.Printf("배치 제출: %d개 요청\n", submitted)

    // 모든 완료 이벤트 수집
    for i := 0; i < submitted; i++ {
        cqe, err := ring.WaitCQE()
        if err != nil {
            fmt.Println("대기 실패:", err)
            continue
        }

        fileIndex := cqe.UserData()
        result := cqe.Result()

        if result >= 0 {
            fmt.Printf("파일 %s: %d 바이트 읽음\n", files[fileIndex], result)
        } else {
            fmt.Printf("파일 %s: 에러 %d\n", files[fileIndex], result)
        }

        ring.SeenCQE(cqe)
    }
}

func main() {
    fmt.Println("=== io_uring 단일 파일 읽기 ===")
    iouringFileRead()

    fmt.Println("\n=== io_uring 배치 읽기 ===")
    iouringBatchRead()
}

6.4 io_uring의 고급 기능 ​

1. Polling 모드: 커널 스레드가 지속적으로 완료를 체크하여 레이턴시 최소화

2. 체인 요청 (Linked Requests): 여러 작업을 순차적으로 연결 (한 작업 완료 후 다음 작업 자동 시작)

3. Fixed Files & Buffers: 파일 디스크립터와 버퍼를 미리 등록하여 오버헤드 제거

go
// io_uring의 체인 요청 개념 예시
func iouringChainedRequests() {
    ring, _ := iouring.New(256)
    defer ring.Close()

    // 1. 파일 열기
    // 2. 파일 읽기  <- 1번에 체인
    // 3. 파일 쓰기  <- 2번에 체인
    // 4. 파일 닫기  <- 3번에 체인

    // 모든 작업이 하나의 배치로 제출되고
    // 순차적으로 자동 실행됨
    // (실제 코드는 라이브러리 API에 따라 다름)

    fmt.Println("체인 요청: 열기 → 읽기 → 쓰기 → 닫기")
}

6.5 epoll vs io_uring 비교 ​

특징epollio_uring
시스템 콜 횟수많음 (각 이벤트마다)적음 (배치 처리)
지원 I/O주로 네트워크/파이프모든 I/O (파일, 네트워크 등)
복사 오버헤드있음없음 (공유 메모리)
비동기 지원제한적완전한 비동기
성능우수매우 우수
복잡도중간높음

7. 성능 측정과 벤치마킹 ​

7.1 벤치마크 설정 ​

go
package main

import (
    "fmt"
    "net"
    "testing"
    "time"
)

// 블로킹 I/O 벤치마크
func BenchmarkBlockingIO(b *testing.B) {
    listener, _ := net.Listen("tcp", ":0")
    defer listener.Close()

    // 에코 서버
    go func() {
        for {
            conn, err := listener.Accept()
            if err != nil {
                return
            }
            go func(c net.Conn) {
                defer c.Close()
                buf := make([]byte, 1024)
                for {
                    n, err := c.Read(buf)
                    if err != nil {
                        return
                    }
                    c.Write(buf[:n])
                }
            }(conn)
        }
    }()

    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        conn, _ := net.Dial("tcp", listener.Addr().String())
        conn.Write([]byte("test"))
        buf := make([]byte, 4)
        conn.Read(buf)
        conn.Close()
    }
}

// 논블로킹 I/O 벤치마크
func BenchmarkNonBlockingIO(b *testing.B) {
    // 유사한 설정이지만 논블로킹 소켓 사용
    // (간략화를 위해 세부 구현 생략)
    b.Skip("논블로킹 구현 필요")
}

// 실제 성능 측정 예제
func measureLatency() {
    iterations := 1000

    // 블로킹 I/O 레이턴시
    start := time.Now()
    for i := 0; i < iterations; i++ {
        conn, _ := net.Dial("tcp", "localhost:8080")
        conn.Write([]byte("ping"))
        buf := make([]byte, 4)
        conn.Read(buf)
        conn.Close()
    }
    blockingDuration := time.Since(start)

    fmt.Printf("블로킹 I/O: %v (평균: %v/op)\n",
        blockingDuration, blockingDuration/time.Duration(iterations))
}

func measureThroughput() {
    dataSize := 1024 * 1024 * 100 // 100MB
    buffer := make([]byte, 8192)

    start := time.Now()

    conn, _ := net.Dial("tcp", "localhost:8080")
    defer conn.Close()

    sent := 0
    for sent < dataSize {
        n, _ := conn.Write(buffer)
        sent += n
    }

    duration := time.Since(start)
    throughput := float64(dataSize) / duration.Seconds() / 1024 / 1024

    fmt.Printf("처리량: %.2f MB/s\n", throughput)
}

func main() {
    fmt.Println("=== 레이턴시 측정 ===")
    measureLatency()

    fmt.Println("\n=== 처리량 측정 ===")
    measureThroughput()
}

7.2 실제 벤치마크 결과 예시 ​

시나리오: 10,000개 동시 연결, 각각 1KB 데이터 교환

방식처리량 (req/s)평균 레이턴시CPU 사용률메모리
블로킹 (스레드 풀)5,000200ms80%2GB
epoll50,00020ms30%200MB
io_uring80,00012ms25%150MB

(실제 결과는 하드웨어와 워크로드에 따라 다름)

7.3 프로파일링 도구 ​

go
package main

import (
    "fmt"
    "os"
    "runtime"
    "runtime/pprof"
    "time"
)

func cpuProfile() {
    // CPU 프로파일링 시작
    f, _ := os.Create("cpu.prof")
    defer f.Close()

    pprof.StartCPUProfile(f)
    defer pprof.StopCPUProfile()

    // 프로파일링할 코드 실행
    doIOOperations()
}

func memoryProfile() {
    doIOOperations()

    // 메모리 프로파일 저장
    f, _ := os.Create("mem.prof")
    defer f.Close()

    runtime.GC()
    pprof.WriteHeapProfile(f)
}

func doIOOperations() {
    // I/O 작업 시뮬레이션
    for i := 0; i < 10000; i++ {
        data := make([]byte, 1024)
        _ = data
        time.Sleep(time.Microsecond)
    }
}

func main() {
    fmt.Println("프로파일링 시작...")

    cpuProfile()
    memoryProfile()

    fmt.Println("프로파일 파일 생성 완료:")
    fmt.Println("  cpu.prof - go tool pprof cpu.prof")
    fmt.Println("  mem.prof - go tool pprof mem.prof")
}

프로파일 분석:

bash
# CPU 프로파일 분석
go tool pprof cpu.prof
(pprof) top    # 가장 많은 CPU를 사용하는 함수
(pprof) list functionName  # 특정 함수의 상세 분석

# 메모리 프로파일 분석
go tool pprof mem.prof
(pprof) top
(pprof) list functionName

8. 실전 적용 사례 ​

8.1 고성능 웹 서버 ​

go
package main

import (
    "fmt"
    "golang.org/x/sys/unix"
    "net"
    "syscall"
)

type HTTPServer struct {
    epollFd int
    listener net.Listener
    connections map[int]net.Conn
}

func NewHTTPServer(addr string) (*HTTPServer, error) {
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        return nil, err
    }

    epollFd, err := unix.EpollCreate1(0)
    if err != nil {
        return nil, err
    }

    server := &HTTPServer{
        epollFd: epollFd,
        listener: listener,
        connections: make(map[int]net.Conn),
    }

    // 리스닝 소켓을 epoll에 등록
    listenerFile, _ := listener.(*net.TCPListener).File()
    listenerFd := int(listenerFile.Fd())

    event := unix.EpollEvent{
        Events: unix.EPOLLIN,
        Fd: int32(listenerFd),
    }
    unix.EpollCtl(epollFd, unix.EPOLL_CTL_ADD, listenerFd, &event)

    return server, nil
}

func (s *HTTPServer) handleHTTPRequest(conn net.Conn, request []byte) {
    // 간단한 HTTP 응답
    response := []byte(
        "HTTP/1.1 200 OK\r\n" +
        "Content-Type: text/plain\r\n" +
        "Content-Length: 13\r\n" +
        "Connection: keep-alive\r\n" +
        "\r\n" +
        "Hello, World!")

    conn.Write(response)
}

func (s *HTTPServer) Run() {
    events := make([]unix.EpollEvent, 128)
    listenerFile, _ := s.listener.(*net.TCPListener).File()
    listenerFd := int32(listenerFile.Fd())

    fmt.Println("HTTP 서버 시작...")

    for {
        n, err := unix.EpollWait(s.epollFd, events, -1)
        if err != nil {
            fmt.Println("epoll_wait error:", err)
            continue
        }

        for i := 0; i < n; i++ {
            if events[i].Fd == listenerFd {
                // 새 연결
                conn, _ := s.listener.Accept()
                tcpConn := conn.(*net.TCPConn)
                connFile, _ := tcpConn.File()
                connFd := int(connFile.Fd())

                unix.SetNonblock(connFd, true)

                event := unix.EpollEvent{
                    Events: unix.EPOLLIN | unix.EPOLLET,
                    Fd: int32(connFd),
                }
                unix.EpollCtl(s.epollFd, unix.EPOLL_CTL_ADD, connFd, &event)
                s.connections[connFd] = conn

            } else {
                // 데이터 수신
                conn := s.connections[int(events[i].Fd)]
                buffer := make([]byte, 4096)

                n, err := conn.Read(buffer)
                if err != nil || n == 0 {
                    unix.EpollCtl(s.epollFd, unix.EPOLL_CTL_DEL, int(events[i].Fd), nil)
                    conn.Close()
                    delete(s.connections, int(events[i].Fd))
                    continue
                }

                s.handleHTTPRequest(conn, buffer[:n])
            }
        }
    }
}

func main() {
    server, err := NewHTTPServer(":8080")
    if err != nil {
        fmt.Println("서버 생성 실패:", err)
        return
    }

    server.Run()
}

8.2 채팅 서버 (Broadcast 패턴) ​

go
package main

import (
    "bufio"
    "fmt"
    "net"
    "sync"
)

type ChatServer struct {
    clients map[net.Conn]bool
    broadcast chan []byte
    register chan net.Conn
    unregister chan net.Conn
    mutex sync.RWMutex
}

func NewChatServer() *ChatServer {
    return &ChatServer{
        clients: make(map[net.Conn]bool),
        broadcast: make(chan []byte, 100),
        register: make(chan net.Conn),
        unregister: make(chan net.Conn),
    }
}

func (s *ChatServer) Run() {
    for {
        select {
        case conn := <-s.register:
            s.mutex.Lock()
            s.clients[conn] = true
            s.mutex.Unlock()
            fmt.Printf("새 클라이언트 연결: %s (총 %d명)\n",
                conn.RemoteAddr(), len(s.clients))

        case conn := <-s.unregister:
            s.mutex.Lock()
            if _, ok := s.clients[conn]; ok {
                delete(s.clients, conn)
                conn.Close()
            }
            s.mutex.Unlock()
            fmt.Printf("클라이언트 연결 해제 (총 %d명)\n", len(s.clients))

        case message := <-s.broadcast:
            s.mutex.RLock()
            for conn := range s.clients {
                go func(c net.Conn, msg []byte) {
                    _, err := c.Write(msg)
                    if err != nil {
                        s.unregister <- c
                    }
                }(conn, message)
            }
            s.mutex.RUnlock()
        }
    }
}

func (s *ChatServer) handleClient(conn net.Conn) {
    defer func() {
        s.unregister <- conn
    }()

    reader := bufio.NewReader(conn)

    for {
        message, err := reader.ReadBytes('\n')
        if err != nil {
            return
        }

        fmt.Printf("메시지 수신: %s", message)
        s.broadcast <- message
    }
}

func (s *ChatServer) Listen(addr string) error {
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    defer listener.Close()

    fmt.Printf("채팅 서버 시작: %s\n", addr)

    for {
        conn, err := listener.Accept()
        if err != nil {
            fmt.Println("Accept error:", err)
            continue
        }

        s.register <- conn
        go s.handleClient(conn)
    }
}

func main() {
    server := NewChatServer()

    go server.Run()

    if err := server.Listen(":9000"); err != nil {
        fmt.Println("서버 시작 실패:", err)
    }
}

8.3 파일 다운로드 서버 (Zero-copy) ​

go
package main

import (
    "fmt"
    "io"
    "net"
    "os"
)

// sendfile 시스템 콜을 사용한 제로 카피 파일 전송
func sendFileZeroCopy(conn net.Conn, filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()

    // 파일 정보 가져오기
    stat, err := file.Stat()
    if err != nil {
        return err
    }

    // TCP 연결의 파일 디스크립터 가져오기
    tcpConn := conn.(*net.TCPConn)
    connFile, err := tcpConn.File()
    if err != nil {
        return err
    }
    defer connFile.Close()

    // sendfile 시스템 콜 사용
    // 커널 내부에서 파일 → 소켓으로 직접 전송 (유저스페이스 복사 없음)
    written, err := io.Copy(connFile, file)
    if err != nil {
        return err
    }

    fmt.Printf("전송 완료: %d/%d 바이트\n", written, stat.Size())
    return nil
}

// 일반적인 파일 전송 (유저스페이스 버퍼 사용)
func sendFileNormal(conn net.Conn, filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()

    buffer := make([]byte, 32*1024) // 32KB 버퍼
    written := int64(0)

    for {
        n, err := file.Read(buffer)
        if n > 0 {
            m, writeErr := conn.Write(buffer[:n])
            written += int64(m)
            if writeErr != nil {
                return writeErr
            }
        }
        if err == io.EOF {
            break
        }
        if err != nil {
            return err
        }
    }

    fmt.Printf("전송 완료: %d 바이트\n", written)
    return nil
}

func fileServer() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer listener.Close()

    fmt.Println("파일 서버 시작: :8080")

    for {
        conn, err := listener.Accept()
        if err != nil {
            fmt.Println("Accept error:", err)
            continue
        }

        go func(c net.Conn) {
            defer c.Close()

            // 클라이언트로부터 파일명 수신
            buffer := make([]byte, 256)
            n, err := c.Read(buffer)
            if err != nil {
                return
            }

            filename := string(buffer[:n])
            fmt.Printf("파일 요청: %s\n", filename)

            // 제로 카피로 파일 전송
            err = sendFileZeroCopy(c, filename)
            if err != nil {
                fmt.Println("전송 에러:", err)
            }
        }(conn)
    }
}

func main() {
    fileServer()
}

8.4 실전 팁과 Best Practices ​

1. 연결 풀링 (Connection Pooling)

go
type ConnectionPool struct {
    connections chan net.Conn
    factory func() (net.Conn, error)
    maxSize int
}

func NewConnectionPool(factory func() (net.Conn, error), maxSize int) *ConnectionPool {
    return &ConnectionPool{
        connections: make(chan net.Conn, maxSize),
        factory: factory,
        maxSize: maxSize,
    }
}

func (p *ConnectionPool) Get() (net.Conn, error) {
    select {
    case conn := <-p.connections:
        return conn, nil
    default:
        return p.factory()
    }
}

func (p *ConnectionPool) Put(conn net.Conn) {
    select {
    case p.connections <- conn:
    default:
        conn.Close()
    }
}

2. 타임아웃 설정

go
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))

3. 백프레셔 (Backpressure) 처리

go
// 버퍼가 가득 차면 새 요청을 거부
select {
case requestChan <- request:
    // 성공
default:
    return errors.New("시스템 과부하")
}

4. Graceful Shutdown

go
func (s *Server) Shutdown(ctx context.Context) error {
    s.listener.Close()

    done := make(chan struct{})
    go func() {
        s.wg.Wait()  // 모든 고루틴 종료 대기
        close(done)
    }()

    select {
    case <-done:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

결론 ​

핵심 요약 ​

  1. 커널 I/O의 기초

    • 모든 I/O는 커널을 통해 수행됨
    • 시스템 콜과 컨텍스트 스위칭이 주요 오버헤드
  2. 동기/비동기와 블로킹/논블로킹

    • 블로킹/논블로킹: 제어권의 관점
    • 동기/비동기: 결과 통지 방식의 관점
  3. I/O 멀티플렉싱

    • select/poll: 전통적이지만 O(n) 복잡도
    • epoll: O(1) 복잡도로 대규모 연결 처리
    • io_uring: 진정한 비동기와 제로 카피
  4. 성능 최적화

    • 시스템 콜 횟수 최소화
    • 배치 처리와 파이프라이닝
    • 제로 카피 기법 활용
  5. 실전 적용

    • 워크로드에 맞는 방식 선택
    • 적절한 타임아웃과 백프레셔 설정
    • 프로파일링을 통한 병목 지점 파악

선택 가이드 ​

시나리오권장 방식
소규모 서비스 (< 1000 연결)블로킹 I/O + 고루틴
중규모 서비스 (< 10000 연결)epoll 또는 Go net
대규모 서비스 (10000+ 연결)epoll + 최적화
초고성능 필요io_uring
파일 I/O 중심io_uring + 제로 카피

추가 학습 자료 ​

  • Linux Kernel 소스 코드: fs/eventpoll.c, io_uring/
  • Go runtime 네트워크 구현: src/runtime/netpoll*.go
  • 논문: "Lord of the io_uring" (2020)
  • 책: "Linux System Programming" by Robert Love

강의 종료

질문이나 피드백이 있으시면 언제든지 문의해주세요!