- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/concurrency
- 개요
Go의 병렬 프로그래밍인 concurrency(go 문법)에 대해 알아보자. goroutine과 channel을 사용해본다.
- CheckWebsites 예제
아래와 같이 URL 들의 응답 상태를 확인하는 CheckWebsites 함수가 있다고 하자.
package concurrency
type WebsiteChecker func(string) bool
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
results := make(map[string]bool)
for _, url := range urls {
results[url] = wc(url)
}
return results
}
위의 함수는 url에 대한 map 정보를 반환하는데, true 이면 요청에 대한 응답이 잘 온상태이고, false 이면 응답이 오지 않은 상태이다. CheckWebsites 함수의 첫번째 인자로 WebsiteChecker를 넘겨야 하는데, 단일 url을 받아 boolean을 반환하는 함수이며 url의 응답상태를 확인하는데 사용된다.
DI에서 살펴보았듯이 이렇게하면 test 함수에서 실제 http 요청을 만들어야 하는 수고를 덜 수 있다. 아래와 같이 test 함수를 작성했다고 가정하자.
package concurrency
import (
"reflect"
"testing"
)
func mockWebsiteChecker(url string) bool {
if url == "waat://furhurterwe.geds" {
return false
}
return true
}
func TestCheckWebsites(t *testing.T) {
websites := []string{
"http://google.com",
"http://blog.gypsydave5.com",
"waat://furhurterwe.geds",
}
want := map[string]bool{
"http://google.com": true,
"http://blog.gypsydave5.com": true,
"waat://furhurterwe.geds": false,
}
got := CheckWebsites(mockWebsiteChecker, websites)
if !reflect.DeepEqual(want, got) {
t.Fatalf("Wanted %v, got %v", want, got)
}
}
코드를 만들어 배포했는데 API 사용자가 속도가 너무 느리다고 피드백을 주었다.
CheckWebsites의 속도를 확인하기 위해 Benchmark 기능을 활용해보자. Benchmark를 활용하면 코드를 변경했을 때 얼마나 효과가 있는지를 알 수 있다.
package concurrency
import (
"testing"
"time"
)
func slowStubWebsiteChecker(_ string) bool {
time.Sleep(20 * time.Millisecond)
return true
}
func BenchmarkCheckWebsites(b *testing.B) {
urls := make([]string, 100)
for i := 0; i < len(urls); i++ {
urls[i] = "a url"
}
for i := 0; i < b.N; i++ {
CheckWebsites(slowStubWebsiteChecker, urls)
}
}
Benchmark는 100개에 url을 호출하는 CheckWebsites를 테스트하고 있다. CheckWebsites의 1번째 인자는 응답에 20ms가 걸린다고 가정하고 가짜 구현체 함수를 넘겨주었다. go test -bench=. 명령어를 수행하면 Benchmark 테스트를 수행할 수 있다.
pkg: github.com/gypsydave5/learn-go-with-tests/concurrency/v0
BenchmarkCheckWebsites-4 1 2249228637 ns/op
PASS
ok github.com/gypsydave5/learn-go-with-tests/concurrency/v0 2.268s
CheckWebsites를 Benchmark 하는데 2249228637 나노초가 걸렸다. 대략 2초 하고 조금 더 걸린수준이다. 이제 최적화를 수행해보자.
- Concurrency
드디어 병렬 프로그래밍을 다룬다. 사실 일상생활에서도 병렬 프로그래밍의 개념을 수행하고 있다. 아침에 차를 마시기 위해 주전자에 물을 넣고 가스렌지에 올린다. 선반에서 티백과 컵을 꺼내고 티백을 컵에 넣는다. 주전자의 물이 끓으면 컵에 끓는 물을 따른다. 이 과정에서 티백과 컵을 꺼내는 행동은 꼭 물이 끓어 야만 할 수 있는 행동은 아니다.
위의 실생활 예제를 이해하고 병렬 프로그래밍에 대한 개념을 알고 있다면 CheckWebsites를 최적화하기 위해서 어떻게 해야하는지 감을 잡았을것이다. 첫번째 요청에 대한 응답이 오기전에 두번째 요청을 보내는 식으로 동작시키면, 100개의 url에 대한 응답을 더 빨리 받을 수 있다.
일반적으로 Go에서 함수를 호출할 때 응답이 오기까지 기다려야 하는데 이를 blocking이라고 한다. Go에서는 block되지 않은 동작을 goroutine이라고 불리는 별도의 프로세스에서 돌릴 수 있다.
Go에서 새로운 goroutine을 돌리려면 go doSomething()과 같이 함수 호출앞에 go를 붙인다.
package concurrency
type WebsiteChecker func(string) bool
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
results := make(map[string]bool)
for _, url := range urls {
go func() {
results[url] = wc(url)
}()
}
return results
}
함수 호출문 앞에 go만 붙이면 goroutine을 사용할 수 있기 때문에 익명 함수를 이용해서 goroutine을 시작하기도 한다.
익명 함수는 몇 가지 특징이 있는데 위에서는 2가지 특징이 나타난다. 우선 선언과 동시에 실행을 할 수 있다. 익명 함수 선언뒤에 ()를 붙임으로써 선언한 익명함수를 곧바로 호출하였다. 또 익명 함수 외부의 변수를 내부에서도 사용할 수 있다.(Javascript closure 개념 참조)
위의 코드에서 익명 함수의 본문은 goroutine을 새로 시작한다는 점을 제외하면 이전과 동일하다. 이제 test를 실행해보자.
--- FAIL: TestCheckWebsites (0.00s)
CheckWebsites_test.go:31: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/concurrency/v1 0.010s
분명히 하라는 대로 했는데 안되는게 이상할것이다. 위의 결과와 똑같은 결과가 아닐 수도 있다. 기대한대로 결과가 나오지 않는 이유는 우리가 병렬 프로그래밍을 하면서 간과한 부분이 있기 때문이다.
for문 안에서 goroutine을 시작하라는 지시는 했지만, wc에서 응답을 받아서 results map에 할당할 시간을 고려하지 않고 곧바로 results를 반환해버렸기 때문이다. 그래서 비어있는 map이 반환된것이다.
for문 안의 goroutine이 모두 끝날때까지 기다리는 동작을 고려해야 한다. 대략 2초 정도면 충분할 것 같다. sleep을 주고 기다려보자.
package concurrency
import "time"
type WebsiteChecker func(string) bool
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
results := make(map[string]bool)
for _, url := range urls {
go func() {
results[url] = wc(url)
}()
}
time.Sleep(2 * time.Second)
return results
}
그리고 test를 수행하면 아래와 같은 결과가 나온다.
--- FAIL: TestCheckWebsites (0.00s)
CheckWebsites_test.go:31: Wanted map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], got map[waat://furhurterwe.geds:false]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/concurrency/v1 0.010s
이상하다. 분명히 충분한 시간을 주었는데 왜 map에 1개의 요소밖에 없을까? 이런 결과의 원인은 url 변수 때문이다. goroutine 안에서 url은 for loop 마다의 복사본 변수가 아니라 url에 대한 참조이다. 그래서 urls의 마지막 url만 results map에 할당된다.
우리가 의도한대로 동작하게 하려면 코드를 아래와 같이 수정해야 한다.
package concurrency
import (
"time"
)
type WebsiteChecker func(string) bool
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
results := make(map[string]bool)
for _, url := range urls {
go func(u string) {
results[u] = wc(u)
}(url)
}
time.Sleep(2 * time.Second)
return results
}
goroutine 사용시 익명의 함수를 선언하면서 동시에 호출하기 위해 ()를 뒤에 붙였었다. 여기에 for loop의 url 변수를 넘겨주고, 익명 함수 선언도 해당 url을 파라미터로 받을 수 있도록 u string으로 정의해준다.
이제 제대로 동작할까? 그럴수도 있고 아닐 수도 있다.
PASS
ok github.com/gypsydave5/learn-go-with-tests/concurrency/v1 2.012s
...........
fatal error: concurrent map writes
goroutine 8 [running]:
runtime.throw(0x12c5895, 0x15)
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/panic.go:605 +0x95 fp=0xc420037700 sp=0xc4200376e0 pc=0x102d395
runtime.mapassign_faststr(0x1271d80, 0xc42007acf0, 0x12c6634, 0x17, 0x0)
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:783 +0x4f5 fp=0xc420037780 sp=0xc420037700 pc=0x100eb65
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1(0xc42007acf0, 0x12d3938, 0x12c6634, 0x17)
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x71 fp=0xc4200377c0 sp=0xc420037780 pc=0x12308f1
runtime.goexit()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/asm_amd64.s:2337 +0x1 fp=0xc4200377c8 sp=0xc4200377c0 pc=0x105cf01
created by github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xa1
... many more scary lines of text ...
운이 좋으면 ...위쪽의 PASS 처럼 test를 통과하고 운이 좋지 않으면 에러가 발생한다. 일단 에러메시지가 0x 메모리 주소가 나오면서 굉장히 읽기가 싫게 생겼지만 첫줄(concurrent map writes)만 잘 읽어보면 감을 잡을 수 있다.
Go에서는 goroutine으로 병렬 수행시 map에 동시에 쓰는것을 허용하지 않고 fatal error를 발생시킨다. 굉장히 기준이 깐깐해 보이지만 이런 race condition은 case가 적은 test를 통과하더라도 실제 상품으로 배포되면 디버깅 하기가 상당히 난해하기 때문에 이렇게 미리탐지하는것이 오히려 더 낫다.
그럼 이런 race condition을 test하려면 test를 여러번 돌려서 운좋게 탐지를 해야 할까? 그렇게 밖에 탐지를 못한다고 하면 이렇게 fatal error를 굳이 발생시킬 필요가 있을까? Go는 이런 race condition을 탐지하기 위해 race flag를 지원한다. test 수행시 go test -race 명령어와 같이 실행하면 된다.
==================
WARNING: DATA RACE
Write at 0x00c420084d20 by goroutine 8:
runtime.mapassign_faststr()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:774 +0x0
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x82
Previous write at 0x00c420084d20 by goroutine 7:
runtime.mapassign_faststr()
/usr/local/Cellar/go/1.9.3/libexec/src/runtime/hashmap_fast.go:774 +0x0
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker.func1()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12 +0x82
Goroutine 8 (running) created at:
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xc4
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.TestWebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker_test.go:27 +0xad
testing.tRunner()
/usr/local/Cellar/go/1.9.3/libexec/src/testing/testing.go:746 +0x16c
Goroutine 7 (finished) created at:
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.WebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11 +0xc4
github.com/gypsydave5/learn-go-with-tests/concurrency/v3.TestWebsiteChecker()
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker_test.go:27 +0xad
testing.tRunner()
/usr/local/Cellar/go/1.9.3/libexec/src/testing/testing.go:746 +0x16c
==================
읽기 싫게 생겼지만 첫줄에 보면 DATA RACE라는 아주 중요한 메시지가 있다. 그리고 아래 2부분을 유심히 보면 Write at 0x00c420084d20 by goroutine 8: 와 Previous write at 0x00c420084d20 by goroutine 7: 부분을 보면 같은 메모리 주소에 대한 쓰기 접근을 했다.
- Channels
이런 data race 현상을 해결하기 위해서는 channel을 사용해서 goroutine을 조정해야 한다. Go에서의 Channel은 송신과 수신이 가능한 데이터 구조이다. 이런 송신과 수신 동작을 이용하면 서로 다른 프로세스간의 통신이 가능해진다.
우리가 학습한 예제에서는 부모 프로세스와 WebsiteChecker 함수를 실행하는 각 goroutine간의 통신을 해야한다.
package concurrency
type WebsiteChecker func(string) bool
type result struct {
string
bool
}
func CheckWebsites(wc WebsiteChecker, urls []string) map[string]bool {
results := make(map[string]bool)
resultChannel := make(chan result)
for _, url := range urls {
go func(u string) {
resultChannel <- result{u, wc(u)}
}(url)
}
for i := 0; i < len(urls); i++ {
r := <-resultChannel
results[r.string] = r.bool
}
return results
}
make 내장함수를 이용하여 results 와 비슷하게 resultChannel 를 선언하였다. chan result는 result형의 channel임을 나타낸다. result struct는 url을 나타내는 string과 해당 url의 응답여부 결과인 bool형을 멤버로 갖는다. 여기서는 익명으로 선언해보았는데 이름을 짓기가 애매한 경우 이렇게 데이터형만 나타내도 된다.
urls를 순회하면서 map에 직접쓰지 않고 wc를 호출한 결과를 담은 구조체를 <-(send statement) 문법을 사용하여 resultChannel로 보내고 있다. 채널로 보낼때는 <- 왼쪽에 받을 채널을, <- 오른쪽에는 보낼 채널 형 값을 써주면 된다.
그 다음 for loop에서는 채널로 부터 받는 연산을 하고 있다. 이때 연산자의 화살표 방향은 <- 로 동일하다. 대신 채널을 오른쪽에 위치시키고 채널로 부터 받아 담을 변수는 왼쪽에 써주면 된다.
result들을 channel로 보내면 results map에 쓰는 시점을 제어할 수 있다. wc의 호출과 result channel로 보내는 각각의 전송은 각각 자신의 프로세스에서 병렬로 발생하지만, results의 각 결과는 <- 표현식으로 result channel로 부터 값을 꺼내올 때 한번에 하나만 처리된다.
최종적으로 map에 동시에 쓰는것과 같이 병렬로 일어나서는 안되는 부분을 선형적으로 동작하도록 보장하면서 속도를 빠르게 하고자 하는 부분의 코드를 병렬화하였다. 그리고 channel을 사용하면서 여러 프로세스간의 통신을 수행하였다. 이제 benchmark를 수행해보면 상당히 빨라진것을 느낄 수 있을것이다.
pkg: github.com/gypsydave5/learn-go-with-tests/concurrency/v2
BenchmarkCheckWebsites-8 100 23406615 ns/op
PASS
ok github.com/gypsydave5/learn-go-with-tests/concurrency/v2 2.377s
'Language > Go' 카테고리의 다른 글
Go - reflection (0) | 2022.01.15 |
---|---|
Go - select (0) | 2022.01.12 |
Go - mocking (0) | 2022.01.02 |
Go - dependency injection (0) | 2021.12.31 |
Go - maps (0) | 2021.12.31 |
댓글