Go · · 4 min read

채널(chan): 버퍼, 블로킹, select, 주의점

기본 개념

타입 안전한 파이프

ch := make(chan int) // int 타입 채널
ch <- 42 // 값 전송
value := <-ch // 값 수신

동기화 프리미티브

  • 채널은 데이터 전달과 동시에 동기화 역할
  • 고루틴 간 안전한 통신 보장

채널의 종류

버퍼 크기에 따른 분류

// 언버퍼드 채널 (동기적)
ch1 := make(chan int)

// 버퍼드 채널 (비동기적)
ch2 := make(chan int, 10)

방향성에 따른 분류

// 양방향 채널
var ch chan int

// 송신 전용
var sendOnly chan<- int

// 수신 전용
var recvOnly <-chan int

언버퍼드 채널 특성

동기적 동작

  • 송신자와 수신자가 동시에 준비될 때까지 블로킹
  • 핸드셰이크 방식의 통신
ch := make(chan int)

go func() {
    ch <- 42 // 수신자가 있을 때까지 블로킹
}()

value := <-ch // 송신자가 있을 때까지 블로킹

동기화 역할

done := make(chan bool)

go func() {
    // 작업 수행
    done <- true // 완료 신호
}()

<-done // 작업 완료까지 대기

버퍼드 채널 특성

비동기적 동작

ch := make(chan int, 3)

ch <- 1 // 블로킹 안됨
ch <- 2 // 블로킹 안됨
ch <- 3 // 블로킹 안됨
ch <- 4 // 버퍼 가득참, 블로킹됨

큐(Queue) 역할

  • FIFO (First In, First Out) 순서
  • 생산자-소비자 패턴에 유용

채널 상태와 동작

열린 채널 vs 닫힌 채널

ch := make(chan int)

// 채널 닫기
close(ch)

// 닫힌 채널에서 수신
value, ok := <-ch // ok는 false, value는 zero value

// 닫힌 채널로 송신 (패닉 발생)
// ch <- 1  // 패닉!

nil 채널의 특성

var ch chan int // nil 채널

// nil 채널 연산은 영원히 블로킹
// <-ch   // 영원히 블로킹
// ch <- 1 // 영원히 블로킹

메모리 모델과 동기화

메모리 가시성 보장

  • 채널 송신 이전의 모든 메모리 연산이 수신 이후에 보임
  • 명시적인 동기화 없이도 안전한 데이터 공유
var data int

go func() {
    data = 42 // happens-before
    ch <- true // 송신
}()

<-ch // 수신
fmt.Println(data) // 42가 보장됨

고급 패턴

Select 문과 함께

select {
case msg1 := <-ch1:
    // ch1에서 수신
case msg2 := <-ch2:
    // ch2에서 수신
case <-timeout:
    // 타임아웃
default:
    // 논블로킹 동작
}

범위 기반 반복

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // 반드시 닫아야 함
}()

for value := range ch {
    fmt.Println(value) // 0, 1, 2, 3, 4
}

성능 특성

경량성

  • 채널 자체는 매우 가벼운 자료구조
  • 고루틴 간 컨텍스트 스위칭 비용 최소화

스케줄링 효율성

  • Go 런타임이 채널 대기 상태의 고루틴을 효율적으로 스케줄링
  • OS 스레드 블로킹 없이 고루틴만 블로킹

주의사항

데드락 방지

// 데드락 상황
ch := make(chan int)
ch <- 1 // 수신자 없어서 영원히 블로킹

채널 누수 방지

// 고루틴 누수 가능성
go func() {
    <-ch // 채널이 닫히지 않으면 고루틴이 누수됨
}()

적절한 버퍼 크기

  • 너무 큰 버퍼: 메모리 낭비
  • 너무 작은 버퍼: 불필요한 블로킹

채널은 Go의 “메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라"는 철학을 구현하는 핵심 도구입니다. 올바르게 사용하면 안전하고 효율적인 동시성 프로그래밍이 가능합니다.

블로킹 동작 상세

언버퍼드 채널의 경우

ch := make(chan int)

go func() {
    fmt.Println("수신 시작")
    value := <-ch // 여기서 블로킹됨
    fmt.Println("수신 완료:", value)
}()

time.Sleep(2 * time.Second)
ch <- 42 // 2초 후 값 전송

버퍼드 채널의 경우

ch := make(chan int, 2)
ch <- 1
ch <- 2 // 버퍼에 값이 있음

go func() {
    value1 := <-ch // 즉시 수신 (블로킹 안됨)
    value2 := <-ch // 즉시 수신 (블로킹 안됨)
    value3 := <-ch // 버퍼 비어있음, 블로킹됨
}()

블로킹의 특징

고루틴만 블로킹, OS 스레드는 블로킹 안됨

go func() {
    <-ch // 이 고루틴만 블로킹
}()

go func() {
    // 다른 고루틴은 정상 실행
    fmt.Println("다른 고루틴 실행 중")
}()

Go 스케줄러가 관리

  • 블로킹된 고루틴은 스케줄링에서 제외
  • 채널에 값이 오면 자동으로 깨어남
  • OS 스레드는 다른 실행 가능한 고루틴으로 전환

실제 예제

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    // 고루틴 1: 수신 대기
    go func() {
        fmt.Println("고루틴 1: 수신 대기 중...")
        msg := <-ch // 블로킹됨
        fmt.Println("고루틴 1: 받은 메시지:", msg)
    }()

    // 고루틴 2: 수신 대기
    go func() {
        fmt.Println("고루틴 2: 수신 대기 중...")
        msg := <-ch // 블로킹됨
        fmt.Println("고루틴 2: 받은 메시지:", msg)
    }()

    time.Sleep(1 * time.Second)
    fmt.Println("메인: 첫 번째 메시지 전송")
    ch <- "Hello"

    time.Sleep(1 * time.Second)
    fmt.Println("메인: 두 번째 메시지 전송")
    ch <- "World"

    time.Sleep(1 * time.Second)
}

출력:

고루틴 1: 수신 대기 중...
고루틴 2: 수신 대기 중...
메인: 첫 번째 메시지 전송
고루틴 1: 받은 메시지: Hello
메인: 두 번째 메시지 전송
고루틴 2: 받은 메시지: World

논블로킹 수신 방법

select with default

select {
case value := <-ch:
    fmt.Println("수신:", value)
default:
    fmt.Println("수신할 값 없음")
}

타임아웃과 함께

select {
case value := <-ch:
    fmt.Println("수신:", value)
case <-time.After(1 * time.Second):
    fmt.Println("타임아웃")
}

주의사항

메인 고루틴 종료 시

func main() {
    ch := make(chan int)

    go func() {
        <-ch // 블로킹됨
    }()

    // 메인 고루틴이 종료되면 프로그램 전체 종료
    // 위 고루틴은 영원히 대기하다가 같이 종료됨
}

고루틴 누수 방지

func worker() {
    ch := make(chan int)

    go func() {
        for {
            select {
            case value := <-ch:
                // 작업 수행
            case <-ctx.Done(): // 컨텍스트로 종료 신호
                return
            }
        }
    }()
}

정리하면, 채널에서 값을 수신할 때 해당 고루틴은 블로킹되지만, 이는 Go 런타임 레벨의 블로킹이므로 OS 스레드나 다른 고루틴의 실행에는 영향을 주지 않습니다.