시작하며

Go에서 고루틴 간 통신을 위한 핵심 도구는 채널(Channel)이다. 채널은 메세지 큐 역할을 하며, select 구문과 조합하면 복잡한 동시성 제어가 가능하다. 또한 Context 패키지를 통해 고루틴에 작업 취소, 타임아웃, 값 전달 등의 조건을 명세할 수 있다.

Channel

Channel이란 무엇인가?

Channel(채널)이란 고루틴끼리 메세지를 전달할 수 있는 메세지 큐를 의미한다.

Channel 사용법

func square(wg *sync.WaitGroup, ch chan int) {
	// square goroutine은 빈 채널이다. 따라서 이 channel에 데이터가 들어오기까지 
	// 기다린다.
	n := <- ch
 
	time.Sleep(time.Second)
	fmt.Printf("Square: %d\n", n*n)
	// 출력 동작 후 wg -1한다.
	wg.Done()
}
 
func main() {
	var wg sync.WaitGroup
 
	// 채널 생성
	// var testCh chan string = make(chan string)
	//     채널변수 키워드 메세지타입      (  채널 타입  )
	// 여기서 채널타입은 채널_키워드 + 메세지_타입 을 의미한다.
 	ch := make(chan int) // make함수로 만든다.
 
	wg.Add(1)
	// waitgroup에 1개 추가하고, go routine 하나 만든다.
	go square(&wg, ch) 
	// main thread에서 9를 넣으면 기다리고 있던 square가 동작한다.
	ch <- 9
	// wg 끝날때까지 기다렸다가 종료한다.
	wg.Wait()
}

Channel에 버퍼(Buffer) 부여하기

Channel을 기본으로 선언하면 공간 크기가 0이 된다. 버퍼가 다 차는 경우를 대비해 make() 함수에 버퍼 크기를 지정할 수 있다.

var chan string messages = make(chan string, 2)

Channel에서 데이터 대기하기

채널은 제때 여는 것만큼 제때 닫아주는 것도 중요하다. 닫지 않아서 고루틴이 데이터를 기다리며 무한 대기하는 상태를 GoRoutine Leak(릭)이라고 한다.

func square(wg *sync.WaitGroup, ch chan int) {
	// 데이터가 들어오길 기다렸다가, 데이터를 빼내고 for문 실행
	// channel을 전부 소비한 뒤에도 닫히지 않고 계속 열려있으므로 
	// 계속 데이터 대기상태를 유지한다 -> DeadLock 발생한다.
	for n := range ch { // 데이터 다 쓰고, channel close되면 range 빠져나간다.
		fmt.Printf("square : %d\n", n*n)
		time.Sleep(time.Second)
	}
	wg.Done()
}
 
func main() {
	var wg sync.WaitGroup // wg 선언
	ch := make(chan int) // channel 생성
 
	wg.Add(1)
	go square(&wg, ch) // 고루틴 생성
 
	for i := 0; i < 10; i++ {
		ch <- i * 2 // channel 안에 0,2,4,6,8,10,12,14,16,18
	}
	// 채널을 닫는다. 채널에서 데이터를 모두 빼낸 상태이고, 채널이 닫혔다면 for range문을 빠져나간다.
	close(ch)
	wg.Wait()
}

select

채널에 데이터가 들어오지 않는 상황에서 다른 동작을 시키거나, 여러 채널을 동시에 대기하고 싶을 때 select를 사용한다. 단 하나의 채널에서 데이터를 가져오더라도 해당 구문은 실행되고 select 구문은 종료된다.

func square(wg *sync.WaitGroup, ch chan int, quit chan bool) {
	for {
		select { // channel ch와 quit을 기다린다.
		case n := <- ch: // ch에서 읽을 수 있다면 먼저 읽는다.
			fmt.Printf("Square: %d\n", n*n)
			time.Sleep(time.Second)
		case <- quit: // ch에서 더이상 가지고 올 데이터가 없을 때 Done() 을 실행시켜 종료한다.
			wg.Done()
			return
		}
	}
}
 
func main() {
	var wg sync.WaitGroup
	ch := make(chan int)
	quit := make(chan bool)
 
	wg.Add(1)
	go square(&wg, ch, quit)
 
	for i := 0; i < 10; i++ {
		ch <- i * 2
	}
 
	quit <- true
	wg.Wait()
}

select를 이용한 signal

select를 사용하면 처리 순서를 정할 수 있다. 아래 예시에서 tick, terminate, ch 세 채널에 대한 처리 우선순위는 tick > terminate > ch 순이다. tick과 ch의 채널 소비가 번갈아 이루어지다가 10초 후 terminate가 호출된다.

func square(wg *sync.WaitGroup, ch chan int) {
	tick := time.Tick(time.Second)	// 1초 간격 시그널을 주는 채널 반환
	terminate := time.After(10*time.Second)	// 10초 이후 시그널
 
	for {
		select {
			case <- tick:
				fmt.Println("Tick")
			case <- terminate:
				fmt.Println("Terminated!")
				wg.Done()
				return
			case n := <- ch:
				fmt.Printf("Square: %d\n", n * n)
				time.Sleep(time.Second)
		}
	}
}

channel을 이용한 파이프라인 GoRoutine

채널을 이용하면 마치 컨테이너 벨트 시스템처럼 역할을 나누어 분배할 수 있다. 처음 루틴은 linear하게 동작하지만 이후 단계들은 parallel하게 진행되므로 전체 처리 속도가 빠르다. 뮤텍스도 필요 없으며 고루틴 하나를 사용한 경우보다 빠르게 작업을 처리할 수 있다.

package main
 
import (
	"fmt"
	"sync"
	"time"
)
 
type Car struct {
	Body string
	Tire string
	Color string
}
 
var wg sync.WaitGroup
var startTime = time.Now()
 
func main() {
	tireCh := make(chan *Car)
	paintCh := make(chan *Car)
 
	fmt.Printf("start Factory\n")
 
	wg.Add(3)
	go MakeBody(tireCh)
	go InstallTire(tireCh, paintCh)
	go PaintCar(paintCh)
 
	wg.Wait()
	fmt.Println("close the factory")
}
 
// step 1. 차체 제작
func MakeBody(tireCh chan *Car) {
	tick := time.Tick(time.Second)
	after := time.After(10 * time.Second)
	for {
		select {
		case <- tick: // 1초간격으로 차체 생산
			car := &Car{}
			car.Body = "Sports Car"
			tireCh <- car
		case <- after:
			close(tireCh)
			wg.Done()
			return
		}
	}
}
 
// step 2. 타이어 설치
func InstallTire(tireCh, paintCh chan *Car) {
	for car := range tireCh { // tireCh 종료될때까지 대기하면서 동작
		time.Sleep(time.Second)
		car.Tire = "Winter tire"
		paintCh <- car
	}
	wg.Done()
	close(paintCh) // 끝날때 paintCh 종료
}
 
// step 3. 도색
func PaintCar(paintCh chan *Car) {
	for car := range paintCh { // paintCh 종료될때까지 동작
		time.Sleep(time.Second)
		car.Color = "Red"
		duration := time.Now().Sub(startTime)
		fmt.Printf("%.2f Complete Car: %s %s %s\n", duration.Seconds(),
			 car.Body, car.Tire, car.Color)
	}
	wg.Done()
}

Context

Context란 무엇인가?

Context는 작업을 지시할 때 작업 가능 시간, 취소 요건 등 작업 조건을 명세하는 context 패키지의 기능이다.

Context 작업 취소

작업을 지시한 지시자가 원할 때 작업 취소를 알릴 수 있다.

func PrintEverySecond(ctx context.Context) {
	tick := time.Tick(time.Second)
	for {
		select {
		case <- ctx.Done():
			wg.Done()
			return
		case <- tick:
			fmt.Println("Tick")
		}
	}
}
func main() {
	wg.Add(1)
	// 컨텍스트 생성
	// 상위 컨텍스트가 없다면 가장 기본적인 context인 background를 넣어준다.
	ctx, cancel := context.WithCancel(context.Background())
	go PrintEverySecond(ctx)
	time.Sleep(5*time.Second)
	// 컨텍스트에서 반환받은 cancel함수 실행
	cancel()
 
	wg.Wait()
}

Context 작업 시간 설정

일정 시간 동안만 작업할 수 있는 컨텍스트이다.

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)

Context 특정 값 설정

특정 값을 추가하는 컨텍스트이다.

ctx := context.WithValue(context.Background(), "number", 9)

정리하며

채널은 고루틴 간 통신의 핵심 수단으로, Mutex 없이도 안전한 동시성 프로그래밍을 가능하게 한다. select 구문으로 여러 채널을 동시에 대기하고, 파이프라인 패턴으로 역할을 나누어 병렬 처리 효율을 높일 수 있다. Context는 고루틴에 취소 신호, 타임아웃, 값을 전달하는 표준화된 방법을 제공한다.

참고문헌