동시성
생각해 보자: 동료가 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으로 구성된 boolean 값 - 올바른 응답에는
true
, 잘못된 응답에는 false
을 반환한다.당신은 또한
WebsiteChecker
를 통과해야 한다. 해당 함수는 단일의 URL을 필요로 하고 boolean 값을 반환한다. 이 기능은 모든 웹 사이트를 확인하는 데 사용된다.이것이 그들이 썼던 테스트이다:
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)
}
}
해당 함수는 생산 중이고 수백 개의 웹사이트들을 확인하는 데 사용되고 있다. 하지만 이 작업이 느리다고 당신의 동료의 불만이 쌓이기 시작한다. 그래서 그들은 이 기능의 속도를 높여달라고 요청했다.
변화에 대한 효과를 보기 위해 기준(benchmark)을 사용하여
CheckWebsites
의 속도를 테스트해보겠다.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)
}
}
벤치마크는 100개의 url들을 사용한
CheckWebsites
와 가짜의 구현체를 사용한 WebsiteChecker
를 테스트한다. slowStubWebsiteChecker
는 의도적으로 느리게 만들었다. 해당 코드는 time.Sleep
를 사용하여 20ms를 기다렸다가 true 값을 반환한다.벤치마크를 사용하려면
go test -bench=.
를 실행하자. (혹은 만약 윈도우 PowerShell을 사용한다면 go test -bench="."
이다.)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
가 2249228637 나노 초로 기준(benchmark)이 되었다 - 2와 1/4초이다.이것을 좀 더 빨리 만들어 보자.
드디어 우리는 이제 동시성에 대해 얘기할 수 있다. 이는 '한 번의 진행에 1개보다 더 많은 일을 하는 것'을 뜻한다. 그리고 이 일은 우리가 매일 자연스럽게 하고 있다.
예를 들어, 나는 아침에 차를 한 잔 만들었다. 나는 주전자를 올려놓고 물이 끓는 동안 냉장고에서 우유를 가지고 왔고, 찬장에서 차를 꺼내고 내가 좋아하는 머그잔을 찾았으며, 컵에 티백을 넣고 물이 다 끓었으면 컵에 물을 따랐다.
내가 하지 않았던 것은 주전자를 올려놓고 거기에 서서 주전자의 물이 끓을 때까지 멍하니 바라보다가, 물이 다 끓으면 모든 일을 하는 것이다.
만약 당신이 첫 번째 방법으로 차를 만드는 방법이 왜 더 빠른지 이해한다면,
CheckWebsites
을 어떻게 더 빠르게 만들지 이해를 할 수 있을 것이다. 다음 웹 사이트에 요청을 보내기 전에 웹 사이트가 응답하기를 기다리는 대신에, 우리가 컴퓨터에게 대기하는 시간 동안 다음 요청을 하도록 만들어 보겠다.보통 Go에서는
doSomething()
이라는 함수를 호출했을 때 반환이 될 때까지 기다려야 한다(반환 값이 없다고 하더라도 함수가 끝날 때까지 기다린다). 우리는 이러한 연산을 동기(blocking) - 이것은 우리가 끝날 때까지 기다리도록 만든다라고 말한다. 동기적으로 실행되지 않는 연산은 goroutine이라고 하는 별도의 프로세스에서 실행된다. Go 코드를 상단부터 아래로 읽어 내려가는 동작을 생각하면, 각 함수를 만날 때마다 코드의 '내부'로 들어가 무슨 기능을 하는지 읽게 된다. 별도의 프로세스가 시작되면 원래 읽던 사람과는 다르게 다른 읽는 사람이 함수 내부를 읽어 내려가는 것과 같다.Go에게 새로운 goroutine으로 실행하라고 말하기 위해서는 키워드
go
를 함수 앞에 붙이는 방법: go doSomething()
으로 함수 호출을 go
statement로 바꿀 수 있다.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
}
goroutine을 시작하는 유일한 방법은
go
를 함수 호출 앞에 붙이는 것이기 때문에, goroutine을 시작하기 위해 종종 익명 함수를 사용하기도 한다. 익명 함수는 정규 함수 선언과 동일하게 보이지만 이름이 없다(당연하다). 위에 적힌 코드의 for
반복문 내의 몸체 부분에서 볼 수 있다.익명 함수에는 유용하게 사용할 수 있는 여러 가지 기능들이 있는데, 이 중 2가지는 위에 사용을 했다. 첫 번째로, 선언된 것과 동시에 실행될 수 있다 - 그래서 익명 함수의 끝에
()
이 붙어있는 것이다. 두 번째로는 정의된 곳에서의 lexical scope에 대한 접근을 유지한다는 것이다 - 익명 함수를 선언할 때 사용할 수 있는 모든 변수들을 함수 본문에서도 사용할 수 있다.위에 있는 익명 함수의 몸체 부분은 이전 반복 문의 몸체 부분과 동일하다. 유일한 차이점은 각 반복이 새로운 goroutine으로 시작이 되고, 현재의 프로세스(
WebsiteChecker
함수)와 동시적으로 실행되어 각 결과를 결과 map에 추가한다는 것이다.하지만 우리 가
go 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
당신은 이 결과를 얻지 않았을 수 있다. 잠시 후에 얘기할 내용에서도 에러 메시지를 받을 수 있다. 그 메시지를 받더라도 걱정하지 말고 위의 결과를 얻을 때까지 계속해서 시도 해 보라. 혹은 성공 한 척해 보라. 너에게 달렸다. 동시성에 오신 것을 환영한다: 올바르게 처리하지 않으면 무슨 일이 일어날지 예측하기 힘들다. 걱정하지 말라 - 그래서 우리는 동시성을 예측할 수 있게 테스트를 작성하는 것이다.
CheckWebsites
가 빈 map 값을 반환하는 것을 볼 수 있다. 무엇이 잘못되었을까?for
반복문이 시작되고 난 후 goroutine들 중 하나도 결괏값을 results
map에 추가할 시간이 없었다; WebsiteChecker
함수가 goroutine들에게는 너무 빨라서 비어있는 map이 반환되는 것이다.이 점을 고치기 위해 우리는 goroutine들이 일들을 마칠 때까지 기다렸다가 반환하기만 하면 된다. 2초면 되지 않을까?
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
}
이제 테스트를 실행해서 값을 얻을 수 있다.(혹은 얻지 못할 수 있다 - 다음과 같이):
--- 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
이것은 맞지 않다 - 왜 하나의 결과만 얻었을까? 시간을 늘려 시도를 해봐야 할 것 같다 - 원하는 만큼 해 보자. 작동이 되지 않을 것이다. 해당 문제는 변수
url
이 모든 for
반복 때마다 재사용 된다는 것이다 - urls
에서 매번 새로운 값을 가져간다. 하지만 우리의 각 goroutine들은 각 url
변수에 대한 참조를 가지고 있다 - 그들은 그들만의 독립된 복사본을 갖고 있지 않다. 그래서 그들은 모두 url
이 반복이 끝날 때 갖는 값을 쓰고 있다 - 마지막 url 말이다. 이것이 우리가 결과로 마지막 url만 받은 이유이다.이것을 고치기 위해:
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
}
각 익명 함수에 url 매개 변수인 -
u
- 를 부여한 다음 url
을 인수로 하여 익명 함수를 호출하고, u
의 값을 goroutine을 실행하는 루프의 반복에 대한 url
값으로 고정되도록 한다. u
는 url
의 값을 복사한 것이므로 변경되지 않는다.당신이 운이 좋다면 얻을 것은:
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 ...
이 말이 길고 무섭지만, 숨을 쉬면서 스택 추적(stacktrace)을 읽기만 하면 된다:
fatal error: concurrent map writes
. 가끔, 테스트를 할 때, 2개의 goroutine들이 같은 시간에 결과 map에 쓸 때가 있다. Go에 있는 Map은 한 번에 여러 개를 쓰는 것을 싫어하기 때문에 fetal error
가 난 것이다.이것을 경쟁 상태 라고 하는데, 이는 소프트웨어 출력이 제어할 수 없는 이벤트의 타이밍과 시퀀스에 종속될 때 발생하는 버그이다. 각 goroutine이 결과 map에 쓰는 시간을 정확하게 제어할 수 없기 때문에, 두 개의 goroutine들이 동시에 결과 map을 쓰는 것에 취약하다.
Go에 내장되어 있는 race detector는 경쟁 상태를 알려주는 데 도움을 준다. 이 기능을 사용하려면, 테스트를
race
옵션과 함께 실행하면 된다: 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
==================
세부 내용이 나왔고, 읽기 힘들다 - 하지만
WARNING: DATA RACE
는 꽤 모호하지 않다. 오류 본문을 읽어보면 2가지의 다른 goroutine들이 map에 쓰려고 하는 것을 볼 수 있다.Write at 0x00c420084d20 by goroutine 8:
은 아래와 같은 메모리 블록에 쓰고 있다.
Previous write at 0x00c420084d20 by goroutine 7:
그 윗줄을 보면 몇 번째 줄의 코드에서 일어난 일인지 볼 수 있다:
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:12
그리고 goroutine 7번과 8번이 시작되는 코드 라인은:
/Users/gypsydave5/go/src/github.com/gypsydave5/learn-go-with-tests/concurrency/v3/websiteChecker.go:11
당신이 알아야 하는 것들은 모두 터미널에 출력 되어있다 - 당신이 해야 할 일은 이것을 읽을 만큼 참을 성이 있는 것이다.