관리 메뉴

HAMA 블로그

[이더리움에서 배우는 Go언어] chan chan 이란? 본문

Go

[이더리움에서 배우는 Go언어] chan chan 이란?

[하마] 이승현 (wowlsh93@gmail.com) 2018. 5. 24. 11:42


chan chan 이란?

블록체인을 비롯해서 서버쪽 개발 언어로 go 언어가 엄청나게 부상하고 있는데요. 이 글에서는 go 언어의 가장 큰 특징 중 하나인 고루틴/채널에 대하여 간단히 설명하며, 좀 더 고급기술인 채널 위에 채널을 얹는 문법을 이더리움 코드를 통해서 살펴봅니다.

golang은 C,C++ 보다 실전적으로 고성능이라고 생각하는데, 그 근거가 바로 고루틴과 채널입니다. 물론 C++도 쓰레드가 있긴 하지만 코딩 할 때, 아~ 이 부분은 좀 시간 걸리겠는데 하더라도 쓰레드로 빼긴 부담스럽게 느껴지는게 사실이며, 많은 C++ 개발자들이 그러한 습관에 코딩을 하고 있습니다. 하지만 GO언어는 언어자체에서 경량쓰레드를 지원하므로, 너무나도 자연스럽게 쓰레드화하고, 채널을 통해서 그것의 결과에 해당하는 값을 받아 볼 수 있게 하고 있습니다.CPU를 너무나 적극적으로 사용하고 있는 이 과정이 너무 쉽기 때문에 golang 으로 짜여진 오픈소스를 보면 고루틴,채널이 범벅되어 있는 것을 많이 볼 수 있습니다. (아무리 쉽다고 해도 고루틴,채널 범벅이면 시리얼한것보다는 복잡해 보이는것은 사실입니다) 혹시 고루틴과 채널에 대해서 처음 듣는 것이라면 제가 예전에 번역한 고 언어에서의 동시성 모델(CSP) 를 먼저 읽고 이 포스트를 보시면 좋을 거 같습니다.

(혹시 고성능이 무조건 최고다라고 생각하시는 분은 없었으면 합니다. 0.0000001초가 걸리던 0.00001초가 걸리던, 일반적인 현실에서는 0.01초만 걸려도 아무 상관없는 솔루션이 대부분이기 때문입니다. 성능보다는 다른 지표로 언어를 선택해야하는 경우가 더 많습니다.그래서 파이썬이 잘나가는거겠지요.^^) 

고루틴

경량쓰레드인 고 루틴을 만드는것은 너무나 간단하다. 로직을 짜다가 이것은 조금 시간이 걸리겠다 싶으면 그냥 고루틴으로 만들 수 있다. 


func main()
{
go doSomthing() // GO 루틴 시작

}

func doSomthing()
{
.... 무엇인가 한다 ...
}


이렇게 일반 함수 앞에 go 라고 붙히기만 하면 경량 쓰레드로 동작한다.

채널

저렇게 고루틴을 만들고 나면, 고루틴에서 어떤 작업을 했을 때, 그 결과를 받고 싶을 수가 있다. 보통 다른 언어로는 동시성을 보장하는 큐를 만들어서 매개변수로 보내서 처리 할 텐데, (혹은 이벤트 동기화) 고 언어에서는 더욱 간편한 것이 있다. 이걸 채널이라고 부르는데 이거 덕분에 고언어에서 쓰레드를 사용하는 것은 더욱 쉬워 지며, 안정적이게 된다. Mutex 를 사용하지 않고 액터패턴처럼 구현되기 때문인데 아래에 예를 살펴보자.


func main()
{
ch := make(chan int) // 통신용 채널 생성

go sendingGoRoutine(ch) // GO 루틴 시작
go receivingGoRoutine(ch) // GO 루틴 시작
..
}
// 송신 코루틴
func sendingGoRoutine(ch chan int)
{
... 어떤 작업을 함 ...

ch <- 45 // 결과 값을 보냄.
}
// 수신 코루틴
func receivingGoRoutine(ch chan int)
{
// 송신 채널로부터 값을 올때까지 기다리다가 오면, v 에 입력
v := <- ch
fmt.Println("Received value ", v)
}

ch := make(chan int) // 통신용 채널 생성

채널은 위와 같이 만들며 int 타입을 전송할 수 있는 채널을 만든 다는 것이다.
생산자가 ch 에 어떤 값을 쓰면, 소비자는 ch 을 기다리다가 값이 들어오면 처리한다.
위의 예에서 생산자는

func sendingGoRoutine(ch chan int)
{
   ...
  ch <- 45
}
이것이 되며, ch <- 45 , 즉 채널에 45를 송신해 주고 있다.
소비자는
func receivingGoRoutine(ch chan int)
{
   // 채널로부터 값을 받으면 v 에 입력
   v := <- ch
   fmt.Println("Received value ", v)
}
이것이 되며, v := <-ch , 즉 채널로부터 값을 수신 받기를 기다리다가 값이 들어오면 처리한다.


chan chan 이란?

requestChan := make(chan chan string)

자 채널에 대해서 공부해 봤는데. chan chan 이렇게 두개를 연속으로 사용 할 수도 있다.무엇 일까?
채널에 값을 보내는게 아니라, 채널에 채널(문자열을 통신하는)을 보내고 있다. 즉
chan 이 "보내면 받을께" 라면 
chan chan 은 "내가 준비됬다는것을 알려줄께 기다리다, 내가 보낸 채널을 통해 보내" 이다. 양방향 처리를 할 수 있다.
아래 그림을 보자. 고루틴 C (소비자 역할)는 고루틴 D (생산자 역할) 에게 "내가 준비 됬다는 것을 chan chan채널을 통해서 알려주면, 채널을 통해 내가 보낸 채널을 통해 니가 나한테 생산한것을 건네줘" 라고 하는 것을 도식화 한 것이다.

Screenshot

1. 고루틴 C 와 고루틴 D은 chan chan 채널(requestChan := make(chan chan string)을 함께 가지고 있다.

Screenshot

2. 고루틴 C (소비자) 는 처리 할 준비가 되었다고, 알려주며 이 채널 (여기선 response 채널)을 통해서 보내라고 고루틴 D에게  말해준다.

Screenshot

3. 고루틴 D (생산자) 는  고루틴 C에게 받은  채널 (여기선 response 채널)을 통해서 고루틴 C에게 문자열을 보낸다.

package main

import "fmt"
import "time"

func main() {

requestChan := make(chan chan string)

go goroutineC(requestChan)
go goroutineD(requestChan)

time.Sleep(time.Second)

}

func goroutineC(requestChan chan chan string) {

responseChan := make(chan string)

requestChan <- responseChan

response := <-responseChan

fmt.Printf("Response: %v\n", response)

}

func goroutineD(requestChan chan chan string) {

responseChan := <-requestChan

responseChan <- "wassup!"
}

소스를 통해 명확히 이해 할 수 있을 것이다. 키포인트는
requestChan <- responseChan  채널에게 값을 전송하는게 아니라, 채널을 전송하는 지점이다. 

이더리움 오픈소스에서 chan chan 은 어떻게 사용 되었나? (소스)

이더리움에서 p2p 시작 부분을 살펴보면, (kademlia 알고리즘에 따라) 다른 노드를 찾기 위한 요청을 하기 전에 자신의 버켓을 다시 채우는(리프레쉬)과정이 있는데, 여기서 리프레쉬를  고루틴(쓰레드)화 해서 처리하고, 리프레쉬가 끝나면 끝났다고 알려주길 바라는 부분이 있다. 이때 chan chan을 통해서 처리하는데, 소스를 통해 확인해 보자.

refreshReq: make(chan chan struct{}),

 refreshReq 라는 chan chan을 만듭니다. 변수 이름이 말해 주듯이 리프레쉬를 요청하는 채널인데, 위의 예제와 마찬가지로 이 채널에 response에 해당하는 채널을 건네주고, 대기 하는 로직 일 수도 있겠다.

func (tab *Table) refresh() <-chan struct{} {
    done := make(chan struct{})
    select {
    case tab.refreshReq <- done: <-- 이 부분
    case <-tab.closed:
        close(done)
    }
    return done
}

response 는 아니고, done 이라는 채널을 만들어서 건네주고 있다. 리프레쉬를 한 다음에 완료되면 done 채널을 통해 보내라는 것 같다. 그럼 누가 받을 까? 리프레쉬를 진짜 실행하는 놈이 받겠지. 그리고 리프레쉬 하는 놈이 done에 어떤 것을 보내면 그것을 처리 하기 위해 위의 return done 을 받은 녀석은 어디선가 대기하고 있을 것이다.


// loop schedules refresh, revalidate runs and coordinates shutdown.
func (tab *Table) loop() {
    ...
    for {
        select {
  
        case req := <-tab.refreshReq: <--- 이 부분
            waiting = append(waiting, req)
            if refreshDone == nil {
                refreshDone = make(chan struct{})
                go tab.doRefresh(refreshDone)
            }
       ...

loop 함수에서 req := <-tab.refreshReq:  이렇게 받고 있다. 받은 것을 waiting 배열에 넣어서 보관하며
진짜 리프레쉬를 하는 go tab.doRefresh(refreshDone)  함수를 호출하고 있다.

    case req := <-tab.refreshReq:
            waiting = append(waiting, req)
            if refreshDone == nil {
                refreshDone = make(chan struct{})
                go tab.doRefresh(refreshDone)
            }
        case <-refreshDone: <--- 이 부분
            for _, ch := range waiting {
                close(ch) <--- 이 부분
            }
            waiting, refreshDone = nil, nil

go tab.doRefresh(refreshDone) 부분을 보면, refreshDone 이라는 채널을 매개변수로 넣어주는데, 예상하듯이 리프레쉬가 완료되면 이 채널로 완료 보고를 하라는 것이고,  case <-refreshDone:  <--- 이 부분 여기서 완료 보고를 기다리고 있다.
그 아래 close(ch)가 있는데, 여기서 진짜 처음 소스에 있는 done := make(chan struct{})이 done에 대한 완료 보고를 하고 있다. 즉 모든 처리가 끝났다고 알려 준다. 그럼 done 을 보고 받는 마지막 코드를 보자.

func (tab *Table) lookup(targetID NodeID, refreshIfEmpty bool) []*Node {
....
    for {
        tab.mutex.Lock()
        // generate initial result set
        result = tab.closest(target, bucketSize)
        tab.mutex.Unlock()
        if len(result.entries) > 0 || !refreshIfEmpty {
            break
        }
        // The result set is empty, all nodes were dropped, refresh.
        // We actually wait for the refresh to complete here. The very
        // first query will hit this case and run the bootstrapping
        // logic.
        <-tab.refresh() <--- 여기 대기
        refreshIfEmpty = false
    } .. look up 처리 ..

위의 코드를 보면 tab.refresh() 를 호출하고 리프레쉬가 완료되길 기다리고 있다. 즉 리프레쉬가 완료 된 후에야 lookup을 처리하겠다는 것이다. 

소스를 보면 아시다시피, 고 언어에서 고루틴/채널은 정말 너무 사소하게 사용 된다. 그러다보니, 채널의 채널이 생기고 그 그 채널을 처리 하기 위해 또 고루틴을 만들고 그것을 위한 채널을 또 만들고...멀티코어 를 적극적으로 사용하는 모습이 기본이며, 그래도 고 언어이기 때문에 그나마 복잡도가 많이 줄어 든거 같긴 하다. :-) 



레퍼런스:
http://tleyden.github.io/blog/2013/11/23/understanding-chan-chans-in-go/
https://github.com/ethereum/go-ethereum/blob/master/p2p/discover/table.go

Comments