쿼카러버의 기술 블로그

[golang] 채널 (channel)이란? - 1탄 간단한 소개 본문

[Golang]

[golang] 채널 (channel)이란? - 1탄 간단한 소개

quokkalover 2021. 8. 1. 23:35

채널에 대한 설명을 읽기 전에 이전 포스팅인 고루틴 (go routine)에 대한 간단한 이해를 하길 바란다.

링크 : https://etloveguitar.tistory.com/39

 

[golang] 고루틴(go routine)이란? - 1탄 간단한 소개

공식 도큐먼트에 따르면 Go routine의 정의는 다음과 같다  “A goroutine is a lightweight thread of execution”. 고루틴은 thread보다 더 가볍고, 따라서 thread를 관리하는 것보다 더 자원효율적(less resou..

etloveguitar.tistory.com

 

1. 채널 간략한 설명

2. 예시

1. 채널 간략한 설명

채널은 고루틴간의 통신 채널 이라고 생각하면된다. 다른 의미로 고루틴끼리 메시지를 전달할 수 있는 메시지 큐다. 메시지큐에 메시지들은 차례대로 쌓이게 되고 메시지를 읽을 때는 맨 처음 온 메시지부터 차례대로 읽게 된다.

총 3개의 고루틴 1, 2를 실행했다고 했을 때, 서로간의 정보를 서로 공유할 수 있도록 해주는 것이다. (더 다수도 가능)

채널은 type을 가지게 되는데, int, error 등이 있다.

- int : integer를 받는 type

- error : error를 받는 type

2. 예시

채널 인스턴스 생성

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

var messages chan string = make(chan string)

채널은 위와 같이 슬라이스, 맵 등과 같이 make()함수로 만들 수 있다. 채널 타입은 채널을 의미하는 chan과 메시지 타입을 합쳐서 표현한다. 그래서 chan string은 string 타입 메시지를 전달하는 채널의 타입이다.

채널에서 데이터 넣기

messages <- "This is a message" 

채널에 데이터를 넣을 때는 <-연산자를 이용한다. <-연산자 좌변에 채널 인스턴스를 놓고 우변에 넣을 데이터를 놓으면 우변 데이터를 좌변 채널에 넣게 된다. messages 채널 인스턴스는 앞서 string을 받는 채널인 chan string 타입으로 만들었기 때문에 문자열 데이터를 넣는다.

채널에서 데이터 빼기

var msg string = <- messages

채널에서 데이터를 뺄때도 마찬가지로 <- 연산자를 사용한다. 다른점은 넣을 때는 <- 연산자의 화살표 방향이 채널 인스턴스를 가리킨 반면,
빼올 때는 화살표가 빼낸 데이터를 담을 변수를 가리킨다는 점이다.
데이터를 빼올 때 만약 채널 인스턴스에 데이터가 없으면 데이터가 들어올 때까지 대기한다.

채널 크기

일반적으로 채널을 생성하면 크기가 0인 채널이 만들어진다. 이는 채널에 들어온 데이터를 담아둘 곳이 없다는 얘기다.
채널크기가 0이라는 얘기는 데이터를 넣을 때 보관할 곳이 없기 때문에 데이터를 빼갈 때까지 대기한다.

채널에서 데이터를 가져가지 않아서 프로그램이 멈추는 경우를 살펴보자

package main

import "fmt"

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

    ch <- 9                    // ❷ main() 함수가 여기서 멈춘다
    fmt.Println("Never print") // ❸ 실행되지 않는다
}

위에서 ❸은 실행되지 않고 모든 고루틴이 영원히 대기하기 때문에 따라서 deadlock 메시지를 출력하고 프로그램이 강제 종료된다.

버퍼를 가진 채널

내부에 데이터를 보관할 수 있는 메모리 영역을 버퍼라고 부른다. 따라서 보관함을 가지고 있는 채널을 버퍼를 가진 채널이라고 말한다.
그럼 버퍼를 가진 채널을 어떻게 만들 수 있을까? 단순하게 make() 함수 뒤에 버퍼 크기를 적어주면 된다.

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() // ❸ 작업 완료를 기다린다.
}

채널에 데이터를 10번 넣는데
for range구문을 사용하면 채널에서 데이터를 계속 기다릴 수 있다.
따라서 ch 채널 인스턴스로부터 데이터가 들어오길 기다렸다가 데이터가 들어모면 데이터를 빼네서 n변수에 값을 복사하고 본문으 ㄹ실행한다.
wg.Wait() 메서드로 작업이 완료되길 기다린다. 하지만 for range 구문은 채널에 데이터가 들어오기를 계속 기다리기 때문에 절대 ❹가 실행되지 않는다. 따라서 deadlock이 표시된다.

그럼 이 문제는 어떻게 해결할 수 있을까?
채널을 다 사용하면 close(ch)를 호출해 채널을 닫고 채널이 닫혔음을 알려줘야 한다. 채널에서 데이터를 모두 빼낸 상태이고 채널이 닫혔으면 for range구문울 빠져나가게 된다.

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

select문

채널에서 데이터가 들어오기를 대기하는 상황에서 데이터가 들어오지 않으면 다른 작업을 하거나, 여러 채널을 동시에 대기하고 싶을 때 어떻게 해야할까?
바로 select문을 사용하면 된다.

select {
case n := <- ch1:
    ...
case n2 := <- ch2:
    ...
case ...
}

select문은 위와 같이 여러 채널을 동시에 기다릴 수 있다. 만약 어떤 채널이라도 하나의 채널에서 데이터를 읽어오면 해당 구문을 실행하고 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() 루틴을 만들 때 알려준다.
ch 채널을 먼저 시도하기 때문에 ch채널에서 데이터를 읽을 수 있으면 계속 읽는다. 따라서 10개의 제곱이 모두 출력되고 quit 채널에서 데이터를 읽어온 다음 square() 함수가 종료된다.

일정 간격으로 실행

메시지가 있으면 메시지를 빼와서 실행하고 그렇지 않다면 1초 간격으로 다른 일을 수행해야 한다고 가정해보자.
이런 경우 어떻게 만들 수 있을까?
time 패키지의 Tick() 함수로 원하는 시간 간격으로 신호를 보내주는 채널을 만들 수 있다.

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

❶ time.Tick()은 일정 시간 간격 주기로 신호를 보내주는 채널을 생성해서 반환하는 함수다. 이 함수가 반환한 채널에서 데이터를 읽어오면 일정 시간 간격으로 현재 시각을 나타내는 Time 객체를 반환한다.
❷time.After()는 현재시간 이후로 일정 시간 경과 후에 신호를 보내주는 채널을 생성해서 반환하는 함수다. 이 함수가 반환한 채널에서 데이터를 읽으면 일정 시간 경과 후에 현재 시각을 나타내는 Time 객체를 반환한다.
❸ select 문을 이용해서 tick, terminate, ch 순으로 채널에서 데이터 읽기를 시도한다. tick에서 메시지를 읽어오면 Tick을 출력하고 terminate에서 읽어오면 함수를 종료한다. tick과 terminate에서 신호를 못읽으면 ch에서 신호를 읽어오게 된다. tick은 1초 간격으로 신호를 보내고 10초 이후에는 terminate 신호가 오므로 함수가 종료된다.

채널을 이용해서 역할을 나누는 방법 (채널로 생산자 소비패턴 구현하기

자동차 공장에서 자동차를 차체 생산 -> 바퀴 설치 -> 도색 -> 완성단계를 거쳐 생산한다고 가정해보자. 각 공정에 1초가 걸린다고 보면 자동차 한대를 만드는데 3초가 걸릴 것이다. 그런데 3명이 공정 하나씩 처리하면 첫 차 생산에만 3초가 걸리고 그 뒤론 1초마다 하나씩 생산할 수 있다.

작업자 간 자동차 전달은 컨베이어 벨트를 통해서 이뤄진다. 자동차는 데이터, 컨베이어 벨트는 채널로 볼 수 있다. 채널을 사용해서 이 과정을 구현해보자.

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 루틴 생성
    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()
}

MakeBody(), InstallTire(), PaintCar() 고륀을 생성한다.
그리고 main() 루틴은 모든 고루틴이 종료될 때까지 대기한다.
MakeBody()는 1초간격으로 차체를 생성해서 tireCh채널에 데이터를 넣어준다. 그리고 10초 이후에 tireCh채널을 닫아주고 루틴을 종료한다.
InstallTire()루틴은 tierCh채널에서 데이터를 읽어서 바퀴를 설치하고 PaintCh 채널에 넣어준다. 만약 tireCh채널이 닫히면 루틴을 종료하고 paintCh 채널을 닫아준다.
PaintCar() 루틴은 PaintCh채널에서 데이터를 읽어서 도색을 하고 완성된 차를 출력해준다.
paintCh채널이 닫히면 루틴을 종료한다.
차가 완성되면 현재 시각에서 시작 시각을 빼서 경과 시간을 출력해준다.
이와 같이 한족에서 데이터를 생성해서 넣어주면 다른 쪽에서 생성된 데이터를 빼서 사용하는 방식을 생산자 소비패턴이라고 한다. 이번 예제에서는 MakeBody() 루틴이 생산자 Install()루틴은 소비자다. 또 InstallTire()는 PaintCar() 루틴에 대해서는 생산자가 되는 구조다.

또다른 예

예를 들어서 int type의 ch 채널이 있다고 해보자.

채널에 무언갈 보내고 싶을 때는 (1을 ch에 대입)

ch <- 1

채널로 부터 무언가를 받고 싶다면 (변수 var에 채널 값 대입)

var := <- ch

를 실행한다.

아래 코드를 실행해보면 채널이 어떤식으로 작동하는 지 파악할 수 있다.

Playground:https://play.golang.org/p/3zfQMox5mHn

  package main

  import "fmt"

  //prints to stdout and puts an int on channel
  func printHello(ch chan int) {
    fmt.Println("Hello from printHello")
    //send a value on channel
    ch <- 2
  }

  func main() {
    //make a channel. You need to use the make function to create channels.
    //channels can also be buffered where you can specify size. eg: ch := make(chan int, 2)
    //that is out of the scope of this post.
    ch := make(chan int)
    //inline goroutine. Define a function and then call it.
    //write on a channel when done
    go func(){
      fmt.Println("Hello inline")
      //send a value on channel
      ch <- 1
    }()
    //call a function as goroutine
    go printHello(ch)
    fmt.Println("Hello from main")

    //get first value from channel.
    //and assign to a variable to use this value later
    //here that is to print it
    i := <- ch
    fmt.Println("Recieved ",i)
    //get the second value from channel
    //do not assign it to a variable because we dont want to use that
    <- ch
  }

채널을 자칫 잘못사용하면 데드락에 빠질 수 있다.

예를 들어 아래 코드 처럼 printHello에서 ch의 값을 가져오려고 하지만 데이터가 없어서 printHello안에서 Received도 출력되지 않고, 

main함수가 차라리 종료되면 printHello 함수를 끝낼 수 잇을텐데 ch에서 데이터를 가져오려고해서 main함수도 멈추면서 데드락이 발생할 수 있다.

package main

import (
    "fmt"
    "time"
)

func printHello(ch chan int) {

  fmt.Println("Hello from printHello")
  i:= <- ch
  fmt.Println("Recieved ", i)

}

func main() {

  ch := make(chan int)
  go printHello(ch)
  time.Sleep(8 * time.Second)
  fmt.Println("Recieved ")

  <- ch
}

 

 

Deadlock: When you trying to read or write data from the channel but the channel does not have value. So, it blocks the current execution of the goroutine and passes the control to other goroutines, but if there is no other goroutine is available or other goroutines are sleeping due to this situation program will crash. This phenomenon is known as deadlock. As shown in the below example:

Comments