Language/Golang

Golang (Go언어) 채널과 컨텍스트(Channel & Context)

HeeWorld 2024. 12. 11. 15:56

Tucker의 Go 언어 프로그래밍 책과 유튜브를 통해 학습 중입니다.

Golang 마스코트 Gopher(고퍼)

 

채널(Channel)

- 고루틴끼리 메시지를 전달할 수 있는 메시지 큐(FIFO).

- Thread Safe Queue라고도 하며, 멀티스레드 환경에서 Lcok 없이 사용할 수 있음.

- 메시지 큐에 메시지들은 차례대로 쌓이게 되고 메시지를 읽을 때 맨 처음 온 메시지부터 차례대로 읽게됨.

 

* 채널 인스턴스 생성

- 채널을 사용하기 위해서는 먼저 채널 인스턴스를 만들어야 함.

var message chan string = make(chan string)

 

- 슬라이스, 맵 등과 같이 make() 함수로 만들 수 있고, 채널 타입은 채널(Channel)을 의미하는 Chan과 메시지 타입을 합쳐서 표현

- chan string은 string 타입 메시지를 전달하는 채널의 타입

 

* 채널에 데이터 넣기(Push & Pop)

message <- "This is a message"

 

- 채널에 데이터를 넣는 데 <- 연산을 이용

- <- 연산자 좌변에 채널 인스턴스를 놓고 우변에 넣을 데이터를 놓으면, 우변 데이터를 좌변 채널에 넣음.

- messages 채널 인스턴스는 chan string 타입으로 만들었기 때문에 문자열 데이터를 넣음.

 

* 채널에서 데이터 빼기

var msg string = <- messages

 

- 데이터를 빼올 때도 <- 연산자를 사용

- 화살표가 빼낸 데이터를 담을 변수를 가리킴(채널 앞에 <- 사용하면 빼는 것)

- 만약 채널 인스턴스에 데이터가 없으면, 데이터가 들어올 때까지 대기함.

 

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	ch := make(chan int)	// 채널 생성

	wg.Add(1)
	go square(&wg, ch)	// 고루틴 생성
	ch <- 9		// 채널에 데이터 넣음
	wg.Wait()	// 작업ㄷ이 완료되길 기다림
}

func square(wg *sync.WaitGroup, ch chan int) {
	n := <-ch	// 데이터 빼옴

	time.Sleep(time.Second)		// 1초 대기
	fmt.Printf("Square: %d\n", n*n)
	wg.Done()
}


// 결과

Square: 81

 

- 채널을 먼저 생성하고, square() 함수를 실행하는 고루틴 생성

   = square() 함수는 main() 루틴이 아닌 새로운 고 루틴에서 동시에 실행됨.

- square() 함수는 앞서 생성한 채널 인스턴스를 인수로 받아 먼저 채널에서 빼오려고 시도함.

   = 현재 채널에는 데이터가 없어서 데이터가 들어올 때까지 대기함.

 

- main() 루틴에서 채널에 9를 넣고, 데이터가 들어왔기 때문에 square()함수에서 데이터를 빼서 결괏값을 출력함.

- square() 함수에서 wg.Done()을 통해 작업이 완료될 때 까지 기다렸다 프로그램 종료.

 

 

채널 크기

- 일반적으로 채널을 생성하면 크기가 0인 채널*이 생성됨.

- 크키가 0이라는 것은 채널에 들어온 데이터를 담아둘 곳이 없다는 것.

- 크기는 make() 함수 끝에 숫자를 적으면 기재한 크기 만큼 공간이 생기고, 기재하지 않으면 크기가 0인 채널이 생성됨.

package main

import "fmt"

func main() {
	ch := make(chan int)	// 채널이 0인 채널 생성

	ch <- 9		// main() 함수가 멈춤
	fmt.Println("Never Print")	// 실행되지 않음
}


// 결과

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/tmp/sandbox571263458/prog.go:8 +0x28

 

- 채널에 데이터를 넣었지만, 보관할 곳이 없기 때문에 다른 고루틴에서 데이터를 빼가기를 기다림.

- 어떤 고루틴도 데이터를 빼가지 않기 때문에 모든 고루틴이 영원히 대기하게 됨.

- deadlock 메시지를 출력하고 프로그램이 강제 종료 됨.

 

 

버퍼를 가진 채널

- 내부에 데이터를 보관할 수 있는 메모리 영역을 버퍼(Buffer)라고 함.

- 보관함을 가지고 있는 채널을 버퍼를 가진 채널이라고 말함.

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

 

- 위와 같이 채널을 생성하면 버퍼가 2개인 채널이 만들어져서, 데이터를 2개까지 보관할 수 있음.

- 만약 버퍼가 다 찼을 경우 보관함에 빈자리가 생길 때까지 대기함.

 

 

채널에서 데이터 대기

package main

import (
	"fmt"
	"sync"
	"time"
)

func square(wg *sync.WaitGroup, ch chan int) {
	for n := range ch {		// 데이터를 계속 기다림
		fmt.Printf("Square: %d\n", n*n)
		time.Sleep(time.Second)
	}
	wg.Done()	// 실행되지 않음
}

func main() {
	var wg sync.WaitGroup
	ch := make(chan int)

	wg.Add(1)
	go square(&wg, ch)

	for i := 0; i < 10; i++ {
		ch <- i * 2		// 데이터를 넣음
	}
	wg.Wait()		// 작업 완료를 기다림
}


// 결과

Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
fatal error: all goroutines are asleep - deadlock!
...

 

- 채널에 데이터를 10번 넣고, for range 구문을 사용하여 채널에서 데이터를 계속 기다리게 함 = wg.Wait().

- ch 채널 인스턴스로부터 데이터가 들어오길 기다렸다 데이터가 들어오면 데이터를 빼내서 n 변수에 값을 복사하고 for문을 실행.

for n := range ch {
    ...
}

 

- wg.Wait() 메서드로 작업이 완료되기를 기다리고, for range 구문은 채널에 데이터가 들어오기를 계속 기다리고 있고,

   그 다음 구문이 실행되지 않아 모든 고루틴이 멈추게 되어 deadlock이 표시됨.

   = main 고루틴은 wg.Wait()에서, square 고루틴은 for n := range ch 구문에서 멈춰 있음 = 모든 고루틴이 멈춰 있음.

 

* close()로 채널을 닫아주기

- 위 예제의 문제를 해결하기 위해서는 close()로 채널을 닫아주면 됨.

좀비 고루틴: 채널을 닫아주지 않아서 무한대기하는 고루틴을 좀비 고루틴 또는 고루틴 릭(Leak)이라고 함.

   → 좀비 루틴이 많아지면 프로그램 자원을 소모하게 되어 프로그램이 느려지거나 메모리 부족으로 강제 종료될 수 있음.

package main

import (
	"fmt"
	"sync"
	"time"
)

func square(wg *sync.WaitGroup, ch chan int) {
	for n := range ch {		// 채널이 닫히면 종료
		fmt.Printf("Square: %d\n", n*n)
		time.Sleep(time.Second)
	}
	wg.Done()
}

func main() {
	var wg sync.WaitGroup
	ch := make(chan int)

	wg.Add(1)
	go square(&wg, ch)

	for i := 0; i < 10; i++ {
		ch <- i * 2
	}
	close(ch)	// 채널 닫음
	wg.Wait()
}


// 결과

Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324

 

- 데이터를 10번 넣고 채널이 더 이상 필요하지 않기 때문에 close(ch)를 호출해 닫음.

- for range에서 데이터를 모두 처리하고 채널이 닫힌 상태이면 for문을 빠져나와 프로그램이 정상적으로 종료 됨.

   따라서, 결과가 10개가 나옴.

 

 

Select 문

- 여러 채널에서 동시에 데이터를 기다릴 때 사용함.

select {
case n := <-ch1:
     ...	// ch1 채널에서 데이터를 빼낼 수 있을 때 실행
case n2 := <-ch2:
	 ...	// ch2 채널에서 데이터를 빼낼 수 있을 때 실행
case ...
}

 

- 어떤 채널이라도 하나의 채널에서 데이터를 읽어오면 해당 구문을 실행하고 select문이 종료됨.

   = 하나의 case만 처리되면 종료되기 때문에 반복해서 데이터를 처리하고 싶다면 for문과 함께 사용해야 함.

 

package main

import (
	"fmt"
	"sync"
	"time"
)

func square(wg *sync.WaitGroup, ch chan int, quit chan bool) {
	for {
		select {	// ch와 quit 양쪽 모두를 기다림
		case n := <-ch:
			fmt.Printf("Square: %d\n", n*n)
			time.Sleep(time.Second)
		case <-quit:
			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()
}

 

- quit 종료 채널을 만들어서 square() 루틴을 만들 때 알려줌.

- 이제 select 문에서 ch와 quit 채널을 모두 기다리고, ch 채널을 먼저 시도하기 때문에 ch 채널에서 데이터를 읽을 수 있으면 계속 읽음.

→ 따라서, 10개의 제곱이 모두 출력되고, quit 채널에서 데이터를 읽어온 다음 square() 함수가 종료됨.

 

 

일정 간격으로 실행

- time 패키지의 Tick() 함수로 원하는 시간 간격으로 신호를 보내는 채널을 반환

- After()는 일정 시간 대기 후 한 번만 신호를 주는 채널을 반환

package main

import (
	"fmt"
	"sync"
	"time"
)

func square(wg *sync.WaitGroup, ch chan int) {
	tick := time.Tick(time.Second)	// 1초 간격 시그널
	terminate := time.After(10 * time.Second)	// 10초 이후 시그널

	for {
		select {	// tick, terminate, ch 순서로 처리
		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)
		}

	}
}

func main() {
	var wg sync.WaitGroup
	ch := make(chan int)

	wg.Add(1)
	go square(&wg, ch)

	for i := 0; i < 10; i++ {
		ch <- i * 2
	}
	wg.Wait()
}


// 결과

Square: 0
Tick
Square: 4
Tick
Square: 16
Tick
Square: 36
Tick
Square: 64
Tick
Square: 100
Tick
Square: 144
Tick
Square: 196
Square: 256
Square: 324
Tick
Terminated!

// 결과는 실행할 때마다 조금씩 달라질 수 있음.

 

- time.Tick()은 일정 시간 간격 주기로 신호를 보내주는 채널을 생성해서 반환하는 함수

   함수가 반환한 채널에서 데이터를 읽어오면 일정 시간 간격으로 현재 시각을 나타내는 Time 객체를 반환함.

 

- time.After()는 현재 시간 이후로 일정 시간 경과 후에 신호를 보내주는 채널을 생성해 반환하는 함수

   함수가 반환한 채널에서 데이터를 읽으면 일정 시간 경과 후에 현재 시각을 나타내는 Time 객체를 반환함.

 

- Select문을 이용해 tick, terminate, ch 순서로 채널에서 데이터 읽기를 시도함.

- tick에서 메시지를 읽어오면 Tick을 출력하고, terminate에서 읽어오면 함수를 종료함.

- tick과 terminate에서 신호를 못 읽으면 ch에서 읽어오게 되고, tick은 1초 간격으로 신호를 보내고,

   10초 이후에는 terminate 신호가 와서 함수가 종료됨.

 

 

채널로 생산자(Producer) / 소비자(Consumer) 패턴 구현

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")
}

func MakeBody(tireCh chan *Car) {	// 차체 생산
	tick := time.Tick(time.Second)
	after := time.After(10 * time.Second)
	for {
		select {
		case <-tick:
        	// Make a body
			car := &Car{}
			car.Body = "Sports car"
			tireCh <- car
		case <-after:	// 10초 뒤 종료
			close(tireCh)
			wg.Done()
			return
		}
	}
}

func InstallTire(tireCh, paintCh chan *Car) {	// 바퀴 설치
	for car := range tireCh {
    	// Make a body
		time.Sleep(time.Second)
		car.Tire = "Winter tire"
		paintCh <- car
	}
	wg.Done()
	close(paintCh)
}

func PaintCar(paintch chan *Car) {	// 도색
	for car := range paintch {
    	// Make a body
		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()
}


// 결과

Start Factory
3.00 Complete Car: Sports car Winter tire Red
4.00 Complete Car: Sports car Winter tire Red
5.00 Complete Car: Sports car Winter tire Red
6.00 Complete Car: Sports car Winter tire Red
7.00 Complete Car: Sports car Winter tire Red
8.00 Complete Car: Sports car Winter tire Red
9.00 Complete Car: Sports car Winter tire Red
10.00 Complete Car: Sports car Winter tire Red
11.00 Complete Car: Sports car Winter tire Red
Close the factory

 

- MakeBody(), InstallTire(), PaintCar() 고루틴을 생성하고, main() 루틴은 모든 고루틴이 종료될 때까지 대기.

 

- MakeBody()는 1초 간격으로 차제를 생성해 tireCh 채널에 데이터를 넣고, 10초 이후에 tireCh 채널을 닫아주고 루틴 종료.

- InsatllTire() 루틴은 채널에서 데이터를 읽어 바퀴를 설치하고 paintCh 채널에 넣고,

   만약, tireCh 채널이 닫히면 루틴을 종료하고 paintCh 채널을 닫음.

- PaintCar() 루틴은 paintCh 채널에서 데이터를 읽어서 도색을 하고 완성된 차를 출력하며, paintCh 채널이 닫히면 루틴을 종료함.

 

- 차가 완성되면 현재 시각에서 시작 시간을 뺀 경과 시간을 출력

 

→ 채널을 이용해 역할을 나누면, 고루틴 하나를 사용할 때보다 더 빠르게 작업을 완료할 수 있고, 뮤텍스를 사용하지 않아도 됨.

 

✓ 한 쪽에서 데이터를 생성해서 넣어주면 다른 쪽에서 생성된 데이터를 빼서 사용하는 방식을 생산자 소비자 패턴*이라고 함.

* 생산자 소비자 패턴(Producer Consumer Pattern), 생산자와 소비자가 연속적으로 연결된 형태를 파이프라인 패턴이라고 부르기도 함.

 

 

컨텍스트(Context)

- 작업을 지시할 때 작업 가능 시간, 작업 취소 등의 조건을 지시할 수 있는 작업 명세서 역할

- 새로운 고루틴으로 작업을 시작할 때 일정 시간 동안만 작업을 지시하거나 외부에서 작업을 취소할 때 사용

- 작업 설정에 관한 데이터를 전달할 수도 있음

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

func main() {
	wg.Add(1)
	ctx, cancel := context.WithCancel(context.Background())	// 컨텍스트 생성
	go PrintEverySecond(ctx)
	time.Sleep(5 * time.Second)
	cancel()	// 취소

	wg.Wait()
}

func PrintEverySecond(ctx context.Context) {
	tick := time.Tick(time.Second)
	for {
		select {
		case <-ctx.Done():	// 취소 확인
			wg.Done()
			return
		case <-tick:
			fmt.Println("Tick")
		}
	}
}


//결과

tick
tick
tick
tick
tick

 

- context.WithCancel() 함수로 취소 가능한 컨텍스트를 생성

- 상위 컨텍스트를 인수로 넣으면 그 컨텍스트를 감싼 새로운 컨텍스트를 만듦

   = 상위 컨텍스트가 없으면 가장 기본적인 컨텍스트인 context.Background()를 넣어줌.

- context.WithCancel() 함수는 값을 두 개 반환하는데 첫 번째가 컨텍스트 객체이고, 두 번째가 취소 함수로

   두 번째 취소 함수를 사용해서 원할 때 취소할 수 있음.

 

- main() 함수에서 5초 이후 취소 함수를 호출해 작업 취소를 알리고, 컨텍스트의 Done() 채널에 시그널을 보내 작업자가

  작업을 취소할 수 있도록 알림.

 

- PrintEverySecond() 루틴에서 인수로 받은 컨텍스트의 Done() 채널의 시그널이 있는지 검사함.

- 컨텍스트가 완료될 때 Done() 채널에 시그널을 넣기 때문에 여기서 메시지를 수신하면 고루틴을 종료함.

 

 

작업 시간을 설정한 컨텍스트

- 일정한 시간 동안만 작업을 지시할 수 있는 컨텍스트

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

 

- context 패키지의 WithTimeout() 함수를 사용해서 작업 시간을 설정할 수 있음.

- 두 번째 인수로 시간을 설정하면 그 시간이 지난 뒤 컨텍스트의 Done() 채널에 시그널을 보내 작업 종료를 요청함.

- WithTimeout() 함수도 두 번째 반환값으로 cancel 함수를 반환하기에 작업 시간 전에 원하면 언제든지 작업 취소 가능함.

 

 

특정 값을 설정한 컨텍스트

- 작업자에게 작업을 지시할 때 별도 지시사항을 추가 할 수 있음

- 컨텍스트에 특정 키로 값을 읽어올 수 있도록 설정할 수 있음

- context.WithValue() 함수를 이용해 컨텍스트에 값을 설정할 수 있음.

package main

import (
	"context"
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
	wg.Add(1)

	ctx := context.WithValue(context.Background(), "number", 9)	// 컨텍스트에 값을 추가

	go square(ctx)

	wg.Wait()
}

func square(ctx context.Context) {
	if v := ctx.Value("number"); v != nil {		// 컨텍스트에서 값을 읽음
		n := v.(int)
		fmt.Printf("Square:%d", n*n)
	}
	wg.Done()
}


// 결과

Square:81

 

- "number"를 키로 값을 9로 설정한 컨텍스트를 만들고, square() 함수 인수로 넘겨 값을 사용할 수 있도록 함.

- 컨텍스트의 ctx의 Value() 메서드로 값을 읽어오고, Value() 메서드의 반환 타입은 빈 인터페이스라,

   int 타입으로 변환하여 사용

 

 

컨텍스트 랩핑

ctx, cancel := context.WithCancel(context.Background())  // 1
ctx = context.WithValue(ctx, "number", 9)	// 2
ctx = context.WithValue(ctx, "keyword", "Lilly")	//3

 

- 먼저 취소 기능이 있는 컨텍스트를 만들고, 이를 감싸서 값을 설정한 컨텍스트를 만듦.

- 컨텍스트를 여러 번 감싸 여러 값을 설정할 수 있음.