프로그래밍 언어/Golang

Go: 고루틴과 채널 (go-routine, chan)

Go 에서 동시성을 제어하는 일은 너무나도 쉽다. 너무 쉬워서 남발하는 경우까지 나올 정도로 쉬운데, Go 의 동시성은 고루틴(go-routine)이라고 하는데, 이는 OS 스레드를 한 번 더 추상화한 코루틴(co-routine)이다. 이러한 동시성은 멀티 프로세서를 사용하여 실행하거나 문맥교환(Context-Switching)을 통해 동시에 실행되는 것처럼 행동한다. 고루틴과 채널에 대한 내용은 상당히 길기때문에 해당 포스팅에서 전부 이야기할 수는 없을 것이라 여기서는 기초적인 것만 이야기하고, 더 자세한 것은 이후의 포스트에서 다룰 예정이다.

go & go-routine

main() 함수도 사실은 main() 고루틴이다. 이러한 고루틴을 실행하는 것은 아주 간단한데, 단순히 그냥 go 키워드만 붙여주면 된다. 고루틴을 설명함에 있어 한 가지 예시를 들어볼텐데, 아래의 코드는 웹페이지에 Get 요청을 하고 내용을 반환해준다. 3번의 요청을 직렬로 처리하기 때문에 특정 웹페이지의 응답이 늦어지거나 하면 프로그램이 늦게 끝날수도 있다.

package main

import (
	"io/ioutil"
	"log"
	"net/http"
)

func GetWebpageContent(url string) string {
	response, err := http.Get(url)
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()
	body, err := ioutil.ReadAll(response.Body)
	if err != nil {
		log.Fatal(err)
	}

	return string(body)
}

func main() {
	GetWebpageContent("https://example.com")
	GetWebpageContent("https://golang.org")
	GetWebpageContent("https://golang.org/doc")
}

그럼 이걸 각자 다른 고루틴에서 실행하게 만들면, 동시성 코드를 만들 수 있는데, go 키워드를 붙여주기만 하면 된다고 했으니 해보도록 하자. 그런데 여기서 살펴보아야 하는 점은, main() 고루틴이 끝나면 다른 고루틴의 실행여부와는 관계 없이 프로그램이 끝난다는 것이다.

package main

import (
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

func GetWebpageContent(url string) string {
	// ...
}

func main() {
	go GetWebpageContent("https://example.com")
	go GetWebpageContent("https://golang.org")
	go GetWebpageContent("https://golang.org/doc")

	time.Sleep(time.Second * 5)
}

time.Sleep() 을 사용하면 다른 고루틴이 끝날때까지 대기할 수 있다. 하지만 이렇게하면 고루틴이 5초이내로 끝난다고해도 결국 5초를 기다려야하고, 응답이 5초를 넘어선다면 받지도 못하고 프로그램이 끝나버리게 될 것이다.

sync.WaitGroup

time.Sleep() 대신에 사용할 수 있는것이 바로 sync.WaitGroup 다. 이것을 사용하게 되면 main() 고루틴은 다른 고루틴이 끝날 때까지 대기할 수 있게되고 등록해둔 고루틴들이 종료되면 main() 고루틴도 종료되면서 프로그램이 끝날 것이다.

package main

import (
	"io/ioutil"
	"log"
	"net/http"
	"sync"
)

func GetWebpageContent(url string) string {
	defer wg.Done()
	// ...
}

var wg sync.WaitGroup

func main() {
	wg.Add(3)
	go GetWebpageContent("https://example.com")
	go GetWebpageContent("https://golang.org")
	go GetWebpageContent("https://golang.org/doc")

	wg.Wait()
}

wg.Add() 에 들어가는 값은 실행할 고루틴의 수이며 wg.Done() 을 실행하면 해당 고루틴을 종료한다는 의미이며 wg.Add() 에 넣은 고루틴의 수와 wg.Done() 을 호출한 고루틴의 수는 같아야 한다. 마지막으로 wg.Wait() 를 호춣게 되면 main() 고루틴 이외에 다른 고루틴이 끝날 때까지 기다린다.

채널

고루틴은 언제 끝날지 예측이 안 되기 때문에 값을 반환하고 그 값을 사용하는 방식으로 사용할 수 없다. 그래서 고루틴간의 값을 주고받을 방식이 필요한데 Go 에서는 채널이라는 방식을 택했다. TV채널에서 알 수 있듯이 채널이라는 개념은 단방향 소통방식이다. 방송사가 채널을 통해 방송을 송출하면 불특정 다수의 사람이 받을 수 있는데, Go 의 채널도 마찬가지다. 어떤 고루틴이 채널을 통해 데이터를 보내면 또 다른 고루틴이 데이터를 받아서 사용할 수 있다. 이는 고루틴 간의 소통 방식이라서 main() 고루틴과 타 고루틴간의 통로가 되어준다.

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

func GetWebpageContent(url string) {
	// ...
	ch <- string(body)
}

var ch chan string = make(chan string)

func main() {
	defer close(ch)

	urls := []string{"https://example.com", "https://golang.org", "https://golang.org/doc"}
	for _, url := range urls {
		go GetWebpageContent(url)
	}
	for i := 0; i < len(urls); i++ {
		fmt.Println(<-ch)
	}
}

ch 변수의 chan string 이 바로 채널이다. chan 바로 다음에 채널에 들어갈 데이터의 타입을 주면 된다. 채널은 make() 를 통해 생성해주어야 한다. 채널을 닫으려면 빌트인 함수인 close() 를 사용하면 된다. 채널에 데이터를 넣고 받는 연산은 위의 코드에서 볼 수 있으나 한 가지 의문이 들 수 있다면 sync.WaitGroup 을 사용하지 않았다는 점이다.

 

채널은 데이터를 송신하고 또 다른 고루틴에서 데이터를 수신하지 않으면 해당 고루틴은 블로킹되어 멈추게 된다. 3개의 고루틴 중에 가장 먼저 끝난 고루틴이 채널에 데이터를 넣었으나 아직 수신을 하지 않아 블로킹되어 있으며, 마찬가지로 다음으로 끝난 고루틴도 요청을 다 마쳤다고 하더라도 채널에 이미 데이터가 들어가 있는 상태이기 때문에 다른 고루틴에서 수신이 되는대로 데이터를 채널에 보낼 준비를 하고 있다.

데이터를 지속적으로 수신하기

닫히지 않은 채널에 대해 데이터를 지속적으로 수신하고 싶다면 for ~ range 를 채널에 대해 적용시키면 된다. 이렇게하면 채널이 닫힐때까지 데이터를 계속 수신하려든다. 아래의 코드는 기존의 코드에서 약간 수정되었는데, 기존에 단일로 받던 함수에 대해 배열로 받도록 바꾸었다. 채널 송신에 대해 블로킹을 당하다가 모든 채널이 다 수신되면 채널을 닫는다.

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

func GetWebpageContents(urls []string) {
	for _, url := range urls {
		// ...
		ch <- string(body)
	}
	close(ch)
}

var ch chan string = make(chan string)

func main() {
	urls := []string{"https://example.com", "https://golang.org", "https://golang.org/doc"}
	go GetWebpageContents(urls)

	for b := range ch {
		fmt.Println(b)
	}
}

다수의 채널에서 데이터가 있는 채널 수신하기

다수의 채널을 리스닝하면서 특정 시점에 송신된 데이터가 있는 채널에 대해 수신하여 처리할 수 있는데, 이것이 바로 for + select 구문이다. for 문과 select 문을 섞어서 사용하며, select 문은 다수의 채널에서 데이터가 송신된 채널에 대해 선택하여 수신한다. select 만 단독으로 사용하는 경우는 채널을 한 번만 수신하며 무한 루프와 함께 사용하면 대기하면서 채널을 선택하여 수신할 수 있다. 물론 이러한 경우는 대체로 별로 없다.

package main

import (
	"fmt"
)

func Counter()  {
	ch <- 10
}

var ch chan int = make(chan int)

func main() {
	go Counter()

	select {
	case count := <-ch:
		fmt.Println(count)
	}
}

위의 코드는 ch 채널에 대해 수신을 할 수 있다고 이야기한다. 이번에는 채널을 두 개 써보도록 하자. 우리의 프로그램은 5초 뒤에 동작을 멈추게 될 것이다. Counter() 고루틴은 1초마다 카운트를 올려 채널에 전송한다. 제한은 없으므로 무한루프다. 그러나, Stop() 고루틴으로 인해 5초 뒤 select 에서 quit 채널에 대해 수신하고 프로그램을 종료한다.

package main

import (
	"fmt"
	"os"
	"time"
)

func Counter()  {
	for i := 0;; i++ {
		time.Sleep(time.Second)
		ch <- i
	}
}

func Stop()  {
	time.Sleep(time.Second * 5)
	quit <- true
}

var ch chan int = make(chan int)
var quit chan bool = make(chan bool)

func main() {
	go Counter()
	go Stop()

	for {
		select {
		case count := <-ch:
			// 0, 1, 2, 3
			fmt.Println(count)
		case <-quit:
			os.Exit(1)
		}
	}
}