
시작하며
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는 고루틴에 취소 신호, 타임아웃, 값을 전달하는 표준화된 방법을 제공한다.