동시성 코드 테스트
이 챕터에서 다루는 것
7-6이 go run -race에서 멈추며 남긴
숙제를 여기서 마친다.
- 플래키 테스트가 왜 생기는가
go test -race,-count=N,-cpu=1,4,16으로 흔들기testing/synctest— 시간에 의존하는 동시성 코드를 결정적으로 테스트하는 표준 답
마지막이 이 챕터의 중심이다. time.Sleep 튜닝은 답이 아니었던 적이 한 번도 없다.
대상 코드
7-9의 수집기를 이 파트에서
자립적으로 다시 썼다. errgroup 대신 wg.Go와 세마포어 채널만 쓴다.
// Fetcher는 URL 하나를 가져와 본문 크기를 돌려준다.
// 쓰는 쪽인 여기에 선언한 최소 인터페이스다.
type Fetcher interface {
Fetch(ctx context.Context, url string) (int, error)
}
// Result는 URL 하나의 수집 결과다. 에러도 값으로 담는다.
type Result struct {
URL string
Bytes int
Err error
}
// Collector의 의존성과 설정은 전부 필드다.
type Collector struct {
fetcher Fetcher
workers int
// PerURL이 0보다 크면 URL 하나마다 그만큼의 데드라인을 건다.
PerURL time.Duration
// Progress는 완료 개수가 25% 구간을 넘을 때마다 호출된다.
// 집계 고루틴 하나에서만 불리므로 이 함수 안에는 잠금이 필요 없다.
Progress func(done, total int)
}
// Run은 urls를 동시에 수집한다. 반환 슬라이스는 입력과 같은 순서다.
// 각 고루틴이 자기 인덱스 칸에만 쓰므로 잠금 없이도 경합이 없다.
func (c *Collector) Run(ctx context.Context, urls []string) ([]Result, error) {
results := make([]Result, len(urls))
if len(urls) == 0 {
return results, nil
}
ticks := make(chan struct{})
reported := make(chan struct{})
go func() {
defer close(reported)
n := 0
for range ticks {
n++
if c.Progress != nil && milestone(n, len(urls)) {
c.Progress(n, len(urls))
}
}
}()
sem := make(chan struct{}, c.workers)
var wg sync.WaitGroup
for i, u := range urls {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
fctx := ctx
if c.PerURL > 0 {
var cancel context.CancelFunc
fctx, cancel = context.WithTimeout(ctx, c.PerURL)
defer cancel()
}
n, err := c.fetcher.Fetch(fctx, u)
results[i] = Result{URL: u, Bytes: n, Err: err}
ticks <- struct{}{}
})
}
wg.Wait()
// 워커가 전부 끝난 뒤에 닫고, 집계 고루틴이 정리될 때까지 기다린다.
// 이 대기를 빼면 Run이 반환한 뒤에도 고루틴이 남는다.
close(ticks)
<-reported
return results, ctx.Err()
}
첫 번째 원칙 — 순수한 부분을 떼어낸다
동시성 테스트를 시작하기 전에 할 일이 있다. 동시성이 필요 없는 부분을 먼저 분리하는 것이다.
milestone과 Summarize가 그렇다. 둘 다 순수 함수라 평범한 테이블 주도
테스트로 끝난다.
// 동시성 코드에서 순수 함수를 떼어내면 그 부분은 평범한 테이블 주도
// 테스트로 끝난다. 흔들릴 여지가 아예 없다.
func TestMilestone(t *testing.T) {
tests := []struct {
n, total int
want bool
}{
{1, 4, true},
{2, 4, true},
{3, 4, true},
{4, 4, true},
{1, 8, false},
{2, 8, true},
{4, 8, true},
{7, 8, false},
{8, 8, true},
{1, 1, true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d/%d", tt.n, tt.total), func(t *testing.T) {
if got := milestone(tt.n, tt.total); got != tt.want {
t.Errorf("milestone(%d, %d) = %v, want %v", tt.n, tt.total, got, tt.want)
}
})
}
}
동시성 코드의 버그는 대부분 로직 버그다. 그 로직을 고루틴 밖으로 꺼내면 가장 싼 방법으로 검증된다. 남는 것 — 스케줄링, 타이밍, 취소 — 만이 진짜 동시성 테스트의 대상이다.
플래키 테스트의 원인
가끔 실패하고 다시 돌리면 통과하는 테스트가 플래키(flaky) 테스트다. 원인은 몇 가지로 분류된다.
| 원인 | 증상 | 해법 |
|---|---|---|
Sleep으로 기다린다 | 부하가 걸린 CI에서만 실패 | synctest.Wait, 채널로 동기화 |
| 데이터 경합 | 값이 가끔 틀림, 재현 불가 | -race |
| 실행 순서 가정 | 고루틴이 특정 순서로 끝난다고 가정 | 순서에 무관한 검증 |
| 공유 상태 | 다른 테스트를 먼저 돌리면 실패 | 테스트마다 새 인스턴스, -shuffle |
| 맵 순회 순서 | 실행마다 다름 | 정렬하거나 순서 무관하게 검증 |
| 실제 시간 단언 | 느린 기계에서 실패 | 시계 주입, synctest |
| 포트·파일 충돌 | 병렬 실행에서만 실패 | :0 포트, t.TempDir() |
| 고루틴 누수 누적 | 오래 돌리면 느려지다 실패 | 8-1의 누수 검사 |
:::danger 플래키 테스트를 재시도로 덮는 것 CI에 "실패하면 3번까지 재시도"를 넣는 순간 그 테스트는 죽는다. 플래키 테스트의 절반은 진짜 버그다. 프로덕션에서 가끔 깨지는 코드가 테스트에서 가끔 깨지고 있는 것뿐이다.
재시도 대신 원인을 분류한다. 위 표의 어느 칸인지만 정하면 해법은 거의 정해져 있다. :::
흔들어서 잡기
플래키 테스트는 정의상 "가끔" 실패하므로, 한 번 돌려서는 못 잡는다. 흔드는 방법이 셋이다.
일부러 틀리게 쓴 테스트로 실험한다. 빌드 태그로 분리해 뒀다.
//go:build flaky
// 이 파일은 일부러 틀린 테스트다. 기본 빌드에서 제외해서
// `go test ./...`가 초록색으로 남게 하고, 관찰하고 싶을 때만 켠다.
//
// go test -tags flaky -count 20 ./07-concurrency/collect
// go test -tags flaky -race ./07-concurrency/collect
package collect
import (
"context"
"testing"
"time"
)
// Sleep으로 "이 정도면 끝났겠지" 하고 기다린다. 두 가지가 동시에 잘못됐다.
//
// 1. lastDone은 집계 고루틴이 쓰고 테스트 고루틴이 읽는다 → 데이터 경합.
// 2. 2ms가 충분하다는 근거가 없다 → 부하가 걸린 CI에서 깨진다.
func TestProgressWithSleep(t *testing.T) {
c := New(slowFetcher{delay: time.Millisecond}, 4)
var lastDone int
c.Progress = func(done, total int) { lastDone = done }
go func() {
// 테스트가 이미 끝난 뒤에 t를 건드릴 수도 있다. 그것도 버그다.
if _, err := c.Run(context.Background(), urls(4)); err != nil {
t.Errorf("Run: %v", err)
}
}()
time.Sleep(2 * time.Millisecond)
if lastDone != 4 {
t.Fatalf("lastDone = %d, want 4", lastDone)
}
}
-count=N — 반복해서 돌린다
go test -tags flaky -run TestProgressWithSleep -count 200 ./07-concurrency/collect
이 기계에서는 200번 중 0번 실패했다. 조용한 개발 머신에서 2ms는 충분히
넉넉하기 때문이다. -count만으로는 이 종류를 못 잡는다는 뜻이다.
-cpu=1,4,16 — 병렬성을 바꾼다
go test -tags flaky -run TestProgressWithSleep -count 200 -cpu 1 ./07-concurrency/collect
역시 0/200 실패였다. -cpu는 스케줄링 형태를 바꿔 순서 가정을 깨는 데
효과가 있지만, 이 테스트의 문제는 그게 아니다.
-race — 근본 원인을 직접 잡는다
go test -tags flaky -race -run TestProgressWithSleep -count 1 ./07-concurrency/collect
==================
WARNING: DATA RACE
Read at 0x00c000012318 by goroutine 7:
example.com/testing-and-quality/07-concurrency/collect.TestProgressWithSleep()
/Users/sgn04088/golang-lecture/examples/08-testing/07-concurrency/collect/flaky_test.go:35 +0x214
testing.tRunner()
/usr/local/go/src/testing/testing.go:2036 +0x164
Previous write at 0x00c000012318 by goroutine 9:
example.com/testing-and-quality/07-concurrency/collect.TestProgressWithSleep.func1()
/Users/sgn04088/golang-lecture/examples/08-testing/07-concurrency/collect/flaky_test.go:24 +0x30
example.com/testing-and-quality/07-concurrency/collect.(*Collector).Run.func1()
/Users/sgn04088/golang-lecture/examples/08-testing/07-concurrency/collect/collect.go:71 +0x120
==================
--- FAIL: TestProgressWithSleep (0.00s)
testing.go:1712: race detected during execution of test
FAIL
-race는 한 번에 잡았다. 400번을 돌려도 못 찾은 것을 한 번에 찾는다.
7-6에서 설명한 대로 -race는 "동시에 일어났는가"가 아니라 happens-before
관계가 있는가를 보기 때문이다.
리포트의 두 지점이 정확하다. flaky_test.go:35가 테스트 고루틴의 읽기,
flaky_test.go:24가 Progress 콜백 안의 쓰기다. 그 아래 collect.go:71이
콜백을 부른 집계 고루틴이다.
:::note 종료 코드가 66이 아니다
7-6에서 go run -race의 종료 코드가
66이라고 했다. 테스트에서는 다르다. testing 프레임워크가 경합을 가로채
race detected during execution of test로 테스트를 실패시키고, 프로세스는
평범한 실패 코드 1로 끝난다.
:::
-race를 켠 채 200번 돌리면 200개의 실행 중 race detected during execution of test가 딱 한 줄 나온다.
go test -tags flaky -race -run TestProgressWithSleep -count 200 ./07-concurrency/collect
경합 리포트는 한 번만 나온다. race detector가 같은 스택의 경합을 중복
보고하지 않기 때문이다. 그리고 나머지 199번은 통과했다 — lastDone != 4
단언은 끝내 실패하지 않았다.
이것이 이 절의 결론이다.
-count와-cpu는 순서 가정을 흔드는 데 쓰고, 경합은-race로 잡는다. 그리고Sleep기반 테스트는 셋 다로도 안 잡힐 수 있다.
Sleep 기반 테스트를 근본적으로 없애는 것이 다음 절이다.
:::tip CI 설정
go test -race -count=1 ./...
-race는 항상 켠다. 5~10배 느려지지만 그만한 값을 한다.
-count=1로 캐시를 끄고, 벤치마크는 -race 없이 별도로 돌린다.
:::
testing/synctest — 가상 시간
synctest.Test(t, f)는 f를 버블(bubble) 안에서 실행한다. 버블 안에서
일어나는 일이 셋이다.
time패키지가 가상 시계를 쓴다. 시작 시각은 2000년 1월 1일 자정 UTC다.- 버블 안의 모든 고루틴이 블록되면 시간이 점프한다. 다음으로 고루틴을 깨울 시각으로 즉시 이동한다.
synctest.Wait()는 다른 모든 고루틴이 블록될 때까지 기다린다.
가장 단순한 확인부터 한다.
// 버블 안의 시계는 2000-01-01 자정 UTC에서 시작한다.
// 1시간을 자도 실제로는 즉시 끝난다.
func TestClock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
time.Sleep(time.Hour)
t.Log("1시간 잤다:", time.Now())
})
}
go test -v -count=1 ./07-concurrency/bubble
=== RUN TestClock
bubble_test.go:16: 1시간 잤다: 2000-01-01 10:00:00 +0900 KST
--- PASS: TestClock (0.00s)
1시간을 잤는데 0.00초에 끝났다. 시각은 2000-01-01 00:00 UTC + 1시간이고, 로컬 타임존(KST)으로 표시되어 10:00이다.
워커 수 제한을 정확히 단언하기
가짜 fetcher는 정해진 시간 뒤에 응답한다.
// slowFetcher는 URL마다 정해진 시간이 지난 뒤에 응답한다.
// ctx가 먼저 끝나면 즉시 ctx.Err()를 돌려준다.
type slowFetcher struct {
delay time.Duration
}
func (f slowFetcher) Fetch(ctx context.Context, url string) (int, error) {
t := time.NewTimer(f.delay)
defer t.Stop()
select {
case <-t.C:
return len(url) * 10, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
이제 이런 단언이 가능해진다.
// 워커가 2개이고 URL 6개가 각각 1초씩 걸리면 총 3초다.
// 가상 시간이므로 이 테스트는 실제로는 즉시 끝난다.
func TestRunRespectsWorkerLimit(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New(slowFetcher{delay: time.Second}, 2)
start := time.Now()
results, err := c.Run(t.Context(), urls(6))
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(results) != 6 {
t.Fatalf("결과 %d개, want 6", len(results))
}
if elapsed != 3*time.Second {
t.Errorf("경과 시간 = %v, want 3s", elapsed)
}
})
}
// 워커를 6개로 늘리면 같은 일이 1초다. "동시에 돌았다"를
// 타이밍 여유값 없이 정확한 등호로 단언할 수 있다.
func TestRunFullyParallel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New(slowFetcher{delay: time.Second}, 6)
start := time.Now()
if _, err := c.Run(t.Context(), urls(6)); err != nil {
t.Fatalf("Run: %v", err)
}
if elapsed := time.Since(start); elapsed != time.Second {
t.Errorf("경과 시간 = %v, want 1s", elapsed)
}
})
}
elapsed != 3*time.Second가 정확한 등호라는 점이 핵심이다.
synctest 없이 이 테스트를 쓰려면 이렇게 됐을 것이다.
// synctest가 없던 시절의 형태. 세 가지가 모두 나쁘다.
if elapsed < 2500*time.Millisecond || elapsed > 4*time.Second {
t.Errorf(...) // 여유값이 임의적이고, 3초가 실제로 걸리고, CI에서 깨진다
}
데드라인 테스트
// URL 하나당 데드라인이 걸리는지 본다. 가짜 응답은 1시간이 걸리지만
// 테스트는 즉시 끝나고, 5초라는 값을 정확히 단언할 수 있다.
func TestRunPerURLTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New(slowFetcher{delay: time.Hour}, 4)
c.PerURL = 5 * time.Second
start := time.Now()
results, err := c.Run(t.Context(), urls(4))
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Run: %v", err)
}
if elapsed != 5*time.Second {
t.Errorf("경과 시간 = %v, want 5s", elapsed)
}
for _, r := range results {
if !errors.Is(r.Err, context.DeadlineExceeded) {
t.Errorf("%s: err = %v, want DeadlineExceeded", r.URL, r.Err)
}
}
if got := Summarize(results); got.Failed != 4 {
t.Errorf("Summarize = %+v, want Failed=4", got)
}
})
}
타임아웃 테스트가 실제 시간을 쓰지 않는다. 이것이 synctest의 가장 큰
실용적 가치다. 그동안 타임아웃 테스트는 "타임아웃을 10ms로 줄여서 테스트하고,
프로덕션에서는 30초를 쓴다"는 타협을 강요했다. 그러면 테스트한 것과 배포한
것이 다른 코드 경로가 된다.
synctest.Wait — "아직 / 이제"를 확인하기
시간을 흘리지 않고 "다른 고루틴들이 할 일을 다 했다"만 알고 싶을 때가 있다.
synctest.Wait()가 그것이다.
// 바깥 컨텍스트를 취소하면 진행 중인 수집이 즉시 멈춘다.
// synctest.Wait()는 버블 안의 다른 고루틴이 전부 블록될 때까지 기다린다 —
// "아직 안 됐다 / 이제 됐다"를 Sleep 없이 확인하는 도구다.
func TestRunCancelStopsEverything(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New(slowFetcher{delay: time.Hour}, 4)
ctx, cancel := context.WithCancel(t.Context())
type outcome struct {
results []Result
err error
}
done := make(chan outcome, 1)
go func() {
rs, err := c.Run(ctx, urls(4))
done <- outcome{rs, err}
}()
// 아직 취소하지 않았으므로 Run은 끝나지 않았어야 한다.
synctest.Wait()
select {
case o := <-done:
t.Fatalf("취소 전에 Run이 끝났다: %+v", o)
default:
}
cancel()
o := <-done
if !errors.Is(o.err, context.Canceled) {
t.Errorf("Run err = %v, want context.Canceled", o.err)
}
if got := Summarize(o.results); got.Failed != 4 {
t.Errorf("Summarize = %+v, want Failed=4", got)
}
})
}
synctest.Wait() 뒤의 select ... default는 **"이 시점에 확실히 아직 안 끝났다"**를
단언한다. Wait가 없으면 이 검사는 무의미하다 — 아직 Run이 시작조차 안 했을
수도 있으니까. Wait가 반환했다는 것은 버블 안의 모든 고루틴이 더 진행할 수
없는 상태라는 뜻이고, 그러므로 "안 끝난 것"이 스케줄링 운이 아니라 사실이다.
전체 실행
go test -v -race -count=1 ./07-concurrency/collect
=== RUN TestRunRespectsWorkerLimit
--- PASS: TestRunRespectsWorkerLimit (0.00s)
=== RUN TestRunFullyParallel
--- PASS: TestRunFullyParallel (0.00s)
=== RUN TestRunPerURLTimeout
--- PASS: TestRunPerURLTimeout (0.00s)
=== RUN TestRunCancelStopsEverything
--- PASS: TestRunCancelStopsEverything (0.00s)
=== RUN TestProgressMilestones
--- PASS: TestProgressMilestones (0.00s)
PASS
3초, 1초, 5초, 1시간 대기가 전부 0.00초다. 그리고 -race와도 문제없이
같이 돈다.
버블 안에서 무엇이 "블록"인가
synctest가 시간을 진행시키는 조건은 모든 고루틴이 지속적으로 블록(durably
blocked) 된 상태다. 무엇이 여기 해당하는지가 정확히 정해져 있다.
지속적 블록으로 인정되는 것
- 버블 안에서 만든 채널에 대한 송신·수신
- 모든 case가 버블 안 채널인
select sync.Cond.Waitsync.WaitGroup.Wait(단,Add가 버블 안에서 불렸을 때)time.Sleep
인정되지 않는 것
sync.Mutex/RWMutex잠금- 네트워크 I/O를 포함한 모든 I/O
- 시스템 콜
:::warning 네트워크를 쓰면 synctest가 무력해진다
실제 소켓에서 읽기를 기다리는 고루틴은 지속적으로 블록된 것이 아니다
(버블 밖 사건으로 깨어날 수 있으므로). 그래서 시간이 진행하지 않고,
synctest.Wait()도 돌아오지 않는다.
해법은 가짜 네트워크다. net.Pipe()가 인메모리 연결을 만들어 준다.
testing/synctest 문서의 HTTP 예제가 정확히 그 방식을 쓴다. httptest.Server는
진짜 루프백 소켓을 쓰므로 버블 안에서 쓸 수 없다.
:::
이 제약은 사실 좋은 설계 압력이다. synctest로 테스트할 수 있는 코드는
I/O가 인터페이스 뒤로 분리된 코드이고, 그건 8-3이
말한 바로 그 구조다.
버블은 고루틴 누수 검사기이기도 하다
버블에서 나갈 때 남아 있는 고루틴이 있으면 패닉이다.
// 버블에서 나갈 때 블록된 고루틴이 남아 있으면 패닉이다.
func TestLeak(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
go func() { <-ch }() // 아무도 보내지 않는다 → 버블 안에 남는다
time.Sleep(time.Second)
})
}
go test -tags bubbledemo -count=1 -run TestLeak -v ./07-concurrency/bubble
=== RUN TestLeak
--- FAIL: TestLeak (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
8-1의 TestMain 누수 검사기가 하던 일을 버블이
공짜로 해 준다. 게다가 훨씬 정확하다 — 어느 테스트에서 샜는지가 바로 나오고,
"잠깐 기다렸다 세기" 같은 휴리스틱이 필요 없다.
버블 전체가 멈추면 다른 메시지가 나온다.
// 버블 안의 모든 고루틴이 블록되고 깨울 타이머도 없으면 데드락이다.
func TestDeadlock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
<-ch
})
}
go test -tags bubbledemo -count=1 -run TestDeadlock -v ./07-concurrency/bubble
=== RUN TestDeadlock
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: all goroutines in bubble are blocked [recovered, repanicked]
7-2의 fatal error: all goroutines are asleep - deadlock!과 같은 성격인데, 버블 단위라 테스트 하나만 죽는다.
런타임 데드락 감지는 프로세스 전체가 멈춰야 발동하므로, 다른 테스트가 돌고
있으면 영영 안 잡힌다. 버블은 그 한계를 없앤다.
synctest가 답이 아닌 경우
- 실제 I/O가 필수인 통합 테스트. 가짜 네트워크로 대체할 수 없다면 버블 밖에서 돈다.
- 성능 측정. 가상 시간은 벤치마크에 쓸 수 없다.
sync.Mutex경합 자체를 검증하고 싶을 때. 잠금은 지속적 블록이 아니다.- 버블 밖 객체를 건드릴 때. 버블 밖에서 만든 채널·타이머·티커를 버블 안에서
조작하면 패닉이다. 패키지 변수
var wg sync.WaitGroup은 버블과 연결될 수 없다는 기술적 제약도 있다(var wg = new(sync.WaitGroup)은 괜찮다).
그런 경우에도 8-3의 시계 주입은 여전히 쓸 수 있다.
흔히 하는 실수
1. time.Sleep으로 고루틴을 기다린다
go doSomething()
time.Sleep(100 * time.Millisecond) // 근거 없는 숫자
check()
세 가지가 동시에 나쁘다. 느린 기계에서 실패하고, 빠른 기계에서 100ms를 낭비하고,
대개 데이터 경합을 동반한다. 채널로 완료를 받거나 synctest.Wait()를 쓴다.
2. 테스트가 띄운 고루틴에서 t.Fatal을 부른다
8-1에서 다룬 그것이다. runtime.Goexit이 그 고루틴만
끝낸다. t.Error를 쓰거나 값을 채널로 보낸다.
3. 고루틴이 끝나기 전에 테스트가 반환한다
t를 쓰던 고루틴이 테스트 종료 후에 t.Log를 부르면 패닉이다.
t.Cleanup에서 완료를 기다리게 한다.
4. 완료 순서를 가정한다
if results[0].URL != "first" { ... } // 워커 수에 따라 순서가 다르다
이 예제의 Run이 인덱스 슬롯을 쓰는 이유가 그것이다 — 순서가 입력과 같도록
설계로 보장한다. 그렇지 않은 결과는 정렬하거나 집합으로 비교한다.
5. 버블 안에서 진짜 서버를 띄운다
httptest.NewServer는 루프백 소켓을 쓴다. 버블 안에서는 net.Pipe로 만든
인메모리 연결을 쓴다.
6. -race 없이 CI를 돌린다
동시성 코드에서 -race 없는 CI는 안전망이 없는 것과 같다.
정리
- 순수 로직을 먼저 떼어내 평범한 테이블 주도 테스트로 덮는다. 남는 것만이 진짜 동시성 테스트다.
- 플래키의 원인은 분류된다:
Sleep대기, 데이터 경합, 순서 가정, 공유 상태, 실제 시간 단언, 자원 충돌, 고루틴 누수. 재시도로 덮지 않는다. -count와-cpu는 순서 가정을 흔든다. 경합은-race가 잡는다. 이 챕터의 예제에서-count 200과-cpu 1은 0/200이었고-race는 한 번에 잡았다.- 테스트에서 경합이 잡히면 종료 코드는 66이 아니라 1이고, 메시지는
race detected during execution of test다. 같은 스택의 경합은 한 번만 보고된다. synctest.Test는 가상 시간 버블을 만든다. 3초·5초·1시간 대기가 0.00초에 끝나고,elapsed != 3*time.Second같은 정확한 등호를 쓸 수 있다.synctest.Wait()는 "아직 / 이제"를Sleep없이 확인한다.- 지속적 블록으로 인정되는 것: 버블 안 채널 연산,
select,Cond.Wait,WaitGroup.Wait,time.Sleep. 뮤텍스와 I/O는 아니다 — 가짜 네트워크 (net.Pipe)를 쓴다. - 버블은 고루틴 누수 검사기다. 남은 고루틴이 있으면
main bubble goroutine has exited but blocked goroutines remain으로 실패한다. - CI는
go test -race -count=1 ./....
연습문제
-
Run에서close(ticks)뒤의<-reported를 지우고synctest테스트를 돌려 보자. 어떤 패닉이 나오는가? 8-1의TestMain누수 검사기와 비교해 어느 쪽이 더 나은 진단을 주는가? -
PerURL을 5초로 두되,slowFetcher의 지연을 URL마다 다르게 만들어 (예: 인덱스가 짝수면 1초, 홀수면 1시간) 일부만 타임아웃되게 해 보자.Summarize의OK와Failed를 정확히 단언할 수 있는가? 전체 경과 시간은 몇 초여야 하는가 — 그것도 등호로 단언해 보자. -
TestProgressMilestones는 워커를 1개로 두어 완료 순서를 고정했다. 워커를 4개로 늘리면 무엇이 깨지는가? 순서에 의존하지 않으면서도 "25% 구간마다 정확히 4번 불렸다"를 검증하려면 어떻게 써야 하는가? (힌트: 호출 횟수와 마지막 값만 본다.)