[Go] 고루틴(goroutine)과 채널(channel)

2023. 2. 20. 19:00[ DevOps ]/[ Golang ]

고루틴

- 고루틴은 동시성(Concurrency)을 지원한다. 즉, 코어에서 특정 작업을 수행 중에 멈추고 다른 작업을 수행할 수 있다.

- 또한 여러 코어에서 동시에 여러 작업들을 수행하는 병렬성(Parallelism)도 지원한다.

- Concurrency 기반으로 실행될지, Parallelism 기반으로 실행될지는 Go 및 OS 내부적으로 처리되기 때문에 개발자가 직접 관여하지 않아도 된다.

- 고루틴이란 Go에서 동시에 실행되는 작업들을 의미한다. 다른 언어에서의 쓰레드와 비슷한 개념이지만 다른 언어의 쓰레드보다 메모리를 더 적게 사용하여 성능상 이점이 있다고 한다.

- 고루틴은 사용하기에 쉽다. 함수 또는 메소드 호출 시 앞에 go 키워드만 붙여주면 된다.

- 모든 Go 프로세스의 main 함수는 고루틴을 사용하여 실행된다. 

 

 

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

func responseSize(url string) {
	fmt.Println("Getting", url)
	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)
	}

	fmt.Println(len(body)) // byte 단위 크기
}

func main() {
	go responseSize("https://kubernetes.io/ko/")
	go responseSize("https://go.dev/")
	go responseSize("https://www.google.co.kr/")
	go responseSize("https://finance.yahoo.com/")

	fmt.Println("end of main")
}

 

- 위 코드는 url로 입력받은 링크에 http로 접속하여 응답 메시지의 길이를 출력하는 예제 코드이다.

- 위와 같이 코드를 작성하고 실행하면 출력문이 모두 찍히기도 전에 프로그램이 끝나버리는데, 이는 main 고루틴이 종료되어 다른 고루틴이 아직 실행중이더라도 그 즉시 실행을 중단하기 때문이다.

- 따라서 main 고루틴이 다른 고루틴이 완료되기를 기다리거나, 정석대로라면 채널(channel)을 사용하는 방식이 있다. (채널은 아래에서 다룬다)

 

 

func main() {
	go responseSize("https://kubernetes.io/ko/")
	go responseSize("https://go.dev/")
	go responseSize("https://www.google.co.kr/")
	go responseSize("https://finance.yahoo.com/")

	time.Sleep(time.Second * 2) // 2초간 중지
	fmt.Println("end of main")
}

 

- 위와 같이 time 패키지의 Sleep을 사용하여 2초간 중지하면 웬만해서 모든 responseSize 메소드의 출력문이 출력될 것이다.

 

 

 


 

 

채널(channel)

https://yoongrammer.tistory.com/9

 

- 위 예제 코드에서 각 고루틴의 실행 순서를 보장할 수 없다. 고루틴 간 실행 순서가 중요하다면 채널을 사용하여 고루틴들을 동기화할 수 있다.

고루틴으로 실행할 함수 또는 메소드는 반환 값을 가질 수 없다. (컴파일 에러) 고루틴으로 특정 함수 또는 메소드를 실행한다는 것은 새로운 쓰레드로 실행하는 것이기 때문에 기존 고루틴은 이후 로직을 그대로 실행한다.(반환하려는 값이 사용하려는 시점보다 먼저 준비될 것을 보장할 수 없음) 즉, 반환 값을 가진 함수, 메소드를 고루틴으로 실행하는 것을 Go 컴파일러가 애초에 시도 자체를 차단하는 것이다.

- 고루틴으로 실행한 함수, 메소드가 반환값을 가지는 대신에 고루틴 간 채널을 기반으로 통신을 통해 데이터를 전달할 수 있다. 

 

func main() {
	var ch1 chan int
	ch1 = make(chan int)

	ch2 := make(chan float64)

	fmt.Println(ch1) // 0xc000086060
	fmt.Println(ch2) // 0xc0000860c0

	go setCh1(ch1)
	go setCh2(ch2)

	fmt.Println(<-ch1) // 345

	receivedVal2 := <-ch2
	fmt.Println(receivedVal2) // 1.2
}

 

- 채널은 특정 타입의 값만 주고받을 수 있다. (슬라이스, 맵, 구조체 타입 등도 가능)

- chan 키워드와 채널이 주고받을 값의 타입을 명시한다.

- 실제 채널을 생성하기 위해선 맵과 슬라이스 생성 시 사용했던 make 함수를 사용한다.

- make 함수에는 채널에서 사용할 타입만 전달하면 된다.

- 채널에 대해 값을 read 할 때와 write 할 때 모두 "<-" 연산자를 사용하지만 채널의 위치가 왼쪽이냐 오른쪽이냐 따라 연산이 달라진다.

- 채널은 반드시 고루틴을 사용할 때에만 사용된다. 즉, main 메소드에서만 채널을 사용할 수 없다. (반드시 서로 다른 2개 이상의 고루틴이 존재해야 함. 반드시 2개일 필요는 없음)

 

 

 

 

채널 기반의 고루틴 동기화

- 채널에 대한 연산을 blocking I/O 방식으로 하여 고루틴 간 동기화를 구현할 수 있다.

- 채널에 대해 write 하는 연산은 해당 채널에 대해 다른 고루틴이 read 하기 전까지 block 된다. 이 반대도 마찬가지이다. 채널에 대한 read 연산은 다른 고루틴이 해당 채널에 write 해주기 전까지 block 된다.

- 채널에 대한 read/write 연산 시점을 통해 자신의 고루틴을 동기화(synchronization)할 수 있다. 즉, 자신의 실행 타이밍을 조절할 수 있다.

 

func abc(channel chan string) {
	channel <- "a"
	channel <- "b"
	channel <- "c"
}

func def(channel chan string) {
	channel <- "d"
	channel <- "e"
	channel <- "f"
}

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go abc(ch1)
	go def(ch2)

	fmt.Println(<-ch1)	// a
	fmt.Println(<-ch2)	// d
	fmt.Println(<-ch1)	// b
	fmt.Println(<-ch2)	// e
	fmt.Println(<-ch1)	// c
	fmt.Println(<-ch2)	// f
}

 

- main 고루틴은 값을 읽기만하고 abc, def 고루틴은 값을 쓰기만 한다. 즉, abc, def 고루틴은 main 고루틴에서 채널에 대해 read를 해주기 전까지 block 된다.

- 위 메커니즘을 이용해 abc, def 고루틴은 자신의 실행 흐름을 제어할 수 있다. 

 

 

cf. 위 코드에서 고루틴을 사용하지 않고 main 고루틴이 채널에 대해 직접 write를 한다면?

- main 고루틴이 write를 하고 이를 받아줄 때까지 main 고루틴은 block된다. 이때 받아줄 고루틴이 없기 때문에 main 고루틴은 무한정 blcok상태에 머무르게 된다.

 

 

 

채널에 구조체 타입 사용하기

[본 포스팅 첫 번째 예시 코드 수정]

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

type Page struct {
	URL  string
	Size int
}

func responseSize(url string, resultChannel chan Page) {
	fmt.Println("Getting", url)
	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)
	}

	resultChannel <- Page{URL: url, Size: len(body)}
}

func main() {
	urls := []string{
		"https://kubernetes.io/ko/",
		"https://go.dev/",
		"https://www.google.co.kr/",
		"https://finance.yahoo.com/",
	}

	resultChannel := make(chan Page)
	for _, url := range urls {
		go responseSize(url, resultChannel)
	}

	for i := 0; i < len(urls); i++ {
		page := <-resultChannel
		fmt.Printf("url: %s, size: %d\n", page.URL, page.Size)
	}

	fmt.Println("end of main")
}

 

[출력결과]

Getting https://finance.yahoo.com/
Getting https://kubernetes.io/ko/
Getting https://go.dev/
Getting https://www.google.co.kr/
url: https://www.google.co.kr/, size: 16562
url: https://go.dev/, size: 55223
url: https://kubernetes.io/ko/, size: 23877
url: https://finance.yahoo.com/, size: 1592561
end of main

 

- 위와 같이 하나의 채널에 2개 이상의 고루틴이 연산을 할 수 있으며 채널에 구조체 타입 역시 사용할 수 있다.

- 결국 main 고루틴에서 채널에 대해 read 하는 순서대로 responseSize 고루틴이 block이 해제될 것이다.

- 위 코드 역시 코드 실행 순서(출력 순서)를 보장하진 않는다. 채널에 대해 write를 먼저 한 고루틴의 결과가 먼저 출력될 것이다.

 

 

 

Channel Advance

1. 양방향 채널과 단방향 채널

// 수신용 채널
c1 := make(<-chan Type)

// 송신용 채널
c2 := make(chan<- Type)

// 양방향 채널
c3 := make(chan string)

 

 

2. channel I/O의 blocking

Go에서의 채널을 I/O 타입으로 분류했을 때 Java와 유사하게 'Unbuffered Channel', 'Buffered Channel' 2가지 타입이 제공된다. 기본적으로 채널은 Unbuffered이다. Unbufferd 채널은 버퍼링 되지 않기 때문에 수신자가 데이터를 받을 때까지 송신자는 block이 걸리기 때문에 동기화를 보장한다. 

 

Buffered 채널은 수신자가 받을 준비가 되어 있지 않아도 지정된 버퍼만큼 데이터를 보내고 계속 다른 일을 수행할 수 있기 때문에 non-blocking I/O 기반으로 동작할 수 있다.

 

c1 := make(chan Type, N)  // 버퍼 크기 지정

 

* 주의

- 버퍼가 가득차 있을 경우, 수신자가 read를 하기 전까지 전송자(write I/O)는 block된다.

- 반대로, 버퍼가 비어있을 경우, 전송자가 write하기 전까지 수신자(read I/O는 block된다.