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

강의 종료

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