버퍼 있는 채널에 대해 알아보기
앞서 배운 대로 채널은 기본적으로 버퍼링되지 않습니다. 즉, 수신 작업이 있는 경우에만 전송 작업을 받습니다. 그렇게 하지 않으면 프로그램이 영원히 대기 상태로 차단됩니다.
Goroutine 사이에 이 유형의 동기화가 필요한 경우가 있습니다. 그러나 단순히 동시성만 구현해야 하므로 Goroutine가 서로 통신하는 방법을 제한할 필요가 없는 경우가 있을 수 있습니다.
버퍼 있는 채널은 큐 처럼 작동하기 때문에 프로그램을 차단하지 않고 데이터를 주고 받습니다. 채널을 만들 때 다음과 같이 이 큐의 크기를 제한할 수 있습니다.
ch := make(chan string, 10)
채널에 항목을 보낼 때마다 이 요소가 큐에 추가됩니다. 그러면 수신 작업에 의해 큐에서 요소가 제거됩니다. 채널이 가득 차면 모든 전송 작업은 데이터를 보유할 공간이 생길 때까지 대기합니다. 반대로 채널이 비어 있을 때 읽기 작업이 있으면 읽을 내용이 있을 때까지 차단됩니다.
다음은 버퍼 있는 채널을 쉽게 이해할 수 있는 예제입니다.
package main
import (
"fmt"
)
func send(ch chan string, message string) {
ch <- message
}
func main() {
size := 4
ch := make(chan string, size)
send(ch, "one")
send(ch, "two")
send(ch, "three")
send(ch, "four")
fmt.Println("All data sent to the channel ...")
for i := 0; i < size; i++ {
fmt.Println(<-ch)
}
fmt.Println("Done!")
}
프로그램을 실행하면 다음과 같은 출력이 표시됩니다.
All data sent to the channel ...
one
two
three
four
Done!
여기에서 특별히 다른 작업을 수행하지 않았다고 말할지도 모릅니다. 맞는 말입니다. 그렇지만 다음과 같이 size
변수를 더 작은 숫자(더 큰 숫자로 해도 됨)로 변경하면 어떻게 되는지 살펴보겠습니다.
size := 2
프로그램을 다시 실행하면 다음과 같은 오류가 발생합니다.
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.send(...)
/Users/developer/go/src/concurrency/main.go:8
main.main()
/Users/developer/go/src/concurrency/main.go:16 +0xf3
exit status 2
그 이유는 send
함수에 대한 호출이 순차적이기 때문입니다. Goroutine을 새로 만들지 않습니다. 따라서 큐에 어떤 항목도 없습니다.
채널은 Goroutine과 밀접하게 연결되어 있습니다. 채널에서 데이터를 수신하는 다른 Goroutine이 없으면 전체 프로그램이 영구적인 차단 상태로 들어갈 수 있습니다. 앞서 살펴본 것처럼 됩니다.
이제 흥미로운 결과물을 만들어보겠습니다. 마지막 2개의 호출에 대해 goroutine을 만들고(처음 2개의 호출은 버퍼에 올바르게 들어갔습니다) for 루프를 4회 실행합니다. 코드는 다음과 같습니다.
func main() {
size := 2
ch := make(chan string, size)
send(ch, "one")
send(ch, "two")
go send(ch, "three")
go send(ch, "four")
fmt.Println("All data sent to the channel ...")
for i := 0; i < 4; i++ {
fmt.Println(<-ch)
}
fmt.Println("Done!")
}
프로그램을 실행하면 예상대로 작동합니다. 채널을 사용할 때는 항상 Goroutine을 사용하는 것이 좋습니다.
필요한 개수보다 많은 요소를 사용하여 버퍼링된 채널을 만드는 경우를 테스트해 보겠습니다. 앞에서 API를 확인하고 크기가 10인 버퍼링된 채널을 만드는 데 사용한 예제를 사용해 보겠습니다.
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
start := time.Now()
apis := []string{
"https://management.azure.com",
"https://dev.azure.com",
"https://api.github.com",
"https://outlook.office.com/",
"https://api.somewhereintheinternet.com/",
"https://graph.microsoft.com",
}
ch := make(chan string, 10)
for _, api := range apis {
go checkAPI(api, ch)
}
for i := 0; i < len(apis); i++ {
fmt.Print(<-ch)
}
elapsed := time.Since(start)
fmt.Printf("Done! It took %v seconds!\n", elapsed.Seconds())
}
func checkAPI(api string, ch chan string) {
_, err := http.Get(api)
if err != nil {
ch <- fmt.Sprintf("ERROR: %s is down!\n", api)
return
}
ch <- fmt.Sprintf("SUCCESS: %s is up and running!\n", api)
}
프로그램을 실행할 때 이전과 같은 출력을 얻게 됩니다. 채널의 크기를 더 작거나 큰 값으로 변경하여 살펴볼 수 있습니다. 그래도 프로그램은 작동합니다.
버퍼 없는 채널과 버퍼 있는 채널 비교
서로 다른 형식을 사용하면 어떻게 될지 궁금할 것입니다. 이는 Goroutine 간 통신 흐름 방식에 따라 다릅니다. 버퍼 없는 채널은 동기적으로 통신합니다. 이렇게 하면 사용자가 데이터를 보낼 때마다 누군가가 채널에서 읽을 때까지 프로그램이 차단되도록 보장됩니다.
반대로 버퍼 있는 채널은 송신 및 수신 작업을 분리합니다. 프로그램을 차단하지는 않지만 교착 상태를 일으킬 수 있으므로 주의해야 합니다(앞에서 살펴본 것처럼). 버퍼 없는 채널을 사용할 경우 동시에 실행할 수 있는 Goroutine 수를 제어할 수 있습니다. 예를 들어 API를 호출할 수 있으므로 초당 수행하는 호출 수를 제어해야 합니다. 그렇게 하지 않으면 차단될 수 있습니다.
채널 방향
Go의 채널에는 또 다른 흥미로운 특징이 있습니다. 바로 채널을 함수의 매개 변수로 사용할 때 채널을 데이터 전송용 또는 수신용으로 지정할 수 있다는 것입니다. 프로그램이 커지면 함수의 수가 너무 많을 수 있어 각 채널을 정확히 사용할 수 있도록 그 용도를 기록하는 것이 좋습니다. 아니면 라이브러리를 작성할 때 데이터 일관성 유지하기 위해 채널을 읽기 전용으로 표시해야 합니다.
채널의 방향을 정의하려면 데이터를 읽거나 받을 때와 비슷한 방식으로 수행합니다. 그러나 함수 매개 변수에서 채널을 선언할 때 이 작업을 수행합니다. 채널의 형식을 함수의 매개 변수로 정의하는 구문은 다음과 같습니다.
chan<- int // it's a channel to only send data
<-chan int // it's a channel to only receive data
수신 전용 채널을 통해 데이터를 전송하면 프로그램을 컴파일할 때 오류가 발생합니다.
다음 프로그램을 두 함수의 예로 사용해보겠습니다. 하나는 데이터를 읽는 함수이고 다른 하나는 데이터를 전송하는 함수입니다.
package main
import "fmt"
func send(ch chan<- string, message string) {
fmt.Printf("Sending: %#v\n", message)
ch <- message
}
func read(ch <-chan string) {
fmt.Printf("Receiving: %#v\n", <-ch)
}
func main() {
ch := make(chan string, 1)
send(ch, "Hello World!")
read(ch)
}
프로그램을 실행하면 다음과 같은 출력이 표시됩니다.
Sending: "Hello World!"
Receiving: "Hello World!"
프로그램이 모든 함수에서 각 채널의 용도를 명확하게 밝힙니다. 데이터 수진 전용 채널에서 채널을 사용하여 데이터를 전송하려고 할 경우 컴파일 오류가 발생합니다. 예를 들어 다음과 같은 작업을 수행해봅니다.
func read(ch <-chan string) {
fmt.Printf("Receiving: %#v\n", <-ch)
ch <- "Bye!"
}
프로그램을 실행하면 다음과 같은 오류가 표시됩니다.
# command-line-arguments
./main.go:12:5: invalid operation: ch <- "Bye!" (send to receive-only type <-chan string)
채널을 오용하는 것보다 컴파일 오류가 있는 것이 좋습니다.
멀티플렉싱
마지막으로, select
키워드를 사용하여 동시에 둘 이상의 채널과 상호 작용하는 방법을 살펴보겠습니다. 여러 채널로 작업할 때 이벤트가 발생할 때까지 기다려야 하는 경우가 있습니다. 예를 들어 프로그램에서 처리 중인 데이터에 변칙이 있을 때 작업을 취소하는 논리를 포함시킬 수 있을 것입니다.
select
문은 switch
문처럼 작동하지만 채널에 대해 작동합니다. 처리할 이벤트를 받을 때까지 프로그램 실행을 차단합니다. 둘 이상의 이벤트를 받으면 하나를 임의로 선택합니다.
select
문의 필수적인 측면은 이벤트를 처리한 후에 실행을 완료한다는 것입니다. 더 많은 이벤트가 발생할 때까지 기다리려면 루프를 사용해야 할 수 있습니다.
다음 프로그램을 사용하여 select
을 실제로 확인해 보겠습니다.
package main
import (
"fmt"
"time"
)
func process(ch chan string) {
time.Sleep(3 * time.Second)
ch <- "Done processing!"
}
func replicate(ch chan string) {
time.Sleep(1 * time.Second)
ch <- "Done replicating!"
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go process(ch1)
go replicate(ch2)
for i := 0; i < 2; i++ {
select {
case process := <-ch1:
fmt.Println(process)
case replicate := <-ch2:
fmt.Println(replicate)
}
}
}
프로그램을 실행하면 다음과 같은 출력이 표시됩니다.
Done replicating!
Done processing!
replicate
함수가 먼저 완료된 것을 볼 수 있으며, 따라서 터미널에 이 함수의 출력이 먼저 표시됩니다. 이벤트를 받자마자 select
문이 종료되기 때문에 기본 함수에 루프가 있지만 process
함수가 완료될 때까지 기다립니다.