openlang

ack가 unbuffered receive인데도 -race가 DATA RACE를 보고해요. time.After 때문일까요?

race detector가 틀렸거나, 제가 ack가 보장해 주는 걸 잘못 이해하고 있는 걸까요? pushgate라는 작은 서비스인데, 제 모바일 앱의 푸시 알림용 디바이스 토큰에 붙은 HMAC 서명을 검사해요. 호출하는 쪽이 unbuffered 채널로 Request를 보내면 worker가 Result를 채우고, 그다음 ack 채널로 ID를 보내요. 호출하는 쪽은 ack를 받은 뒤에만 Result를 읽어요. 테스트는 일부러 time.Sleep 없이 이렇게 썼어요. time.After를 쓴 select는 테스트가 멈췄을 때 죽게 하려는 용도일 뿐이에요.

그냥 go test는 항상 초록불이에요. 오늘 릴리스 태그 달기 전에 go test -race -count=200 ./...를 돌렸어요. 30~40번에 한 번쯤 WARNING: DATA RACE가 뜨는데, 테스트 goroutine에서 읽기, worker goroutine에서 쓰기로 나와요. worker 쪽은 verify.go를 가리키고, 테스트 쪽은 verify_test.go의 t.Logf 줄을 가리켜요. TestWorkerRejectsBadSig에서 더 자주 나오는 것 같은데 확실하진 않아요.

해 본 것들:

  • ack 채널을 buffered로 바꿈: race가 더 자주 떠서 원래대로 되돌림
  • -cpu 1: race가 사라지는데 이건 믿음이 안 가요
  • 예전 Result는 필드가 두 개였고, 지난주에 감사 로그용으로 타임스탬프를 추가했어요. 이게 왜 문제가 되는지 모르겠어요

worker:

for r := range in {
	want := sign(secret, r.Token)
	ok := hmac.Equal([]byte(want), []byte(r.Sig))

	r.Result.DeviceID = r.ID
	r.Result.Valid = ok
	ack <- r.ID

	// stamp after the ack so the caller is not kept waiting
	r.Result.Checked = time.Now()
	if verbose {
		log.Printf("checked device=%d valid=%v", r.ID, ok)
	}
}

더 자주 실패하는 테스트:

func TestWorkerRejectsBadSig(t *testing.T) {
	res := runOne(t, 9, "apns-abc123", "deadbeef")
	t.Logf("result for bad sig: %+v", *res)
	if res.Valid {
		t.Fatal("bad signature accepted")
	}
	if res.DeviceID != 9 {
		t.Fatalf("device id %d", res.DeviceID)
	}
}

runOne은 두 채널을 모두 평범한 make(chan ...)로 만들고, worker를 시작하고, Request를 보낸 다음, ack와 time.After(2 * time.Second)를 select하고 res를 반환해요. https://go.dev/ref/mem 을 읽어 봤는데도 순서가 어디서 깨지는지 모르겠어요. struct 안의 포인터가 detector한테서 뭔가를 가리고 있는 걸까요?

영어에서 번역원문 보기

이어지는 대화 4

서로의 근거를 따라
  1. 고유 링크

    찾았어요, detector가 맞았네요)) 문제는 제 주석 "stamp after the ack"였어요. ack는 그 앞에서 일어난 쓰기만 순서를 보장해요. r.Result.Checked = time.Now()는 ack <- r.ID 뒤에 실행되니까 호출하는 쪽의 읽기와는 순서가 전혀 정해져 있지 않아요. t.Logf("%+v", *res)는 Checked를 포함한 구조체 전체를 복사하는데, 그때 worker가 아직 그 필드를 쓰고 있을 수 있어요.

    나머지도 이걸로 설명돼요:

    • 필드가 두 개일 때는 둘 다 ack 전에 쓰였으니 race가 없었어요. 지난주에 추가한 타임스탬프가 ack 뒤의 첫 번째 쓰기예요.
    • -cpu 1에서는 worker가 그 구간에 실행될 기회가 거의 없어서 가려져요.
    • buffered ack는 worker가 send에서 막히지 않으니까 늦은 쓰기에 더 빨리 도달해서 더 악화돼요.
    • TestWorkerRejectsBadSig가 더 자주 실패하는 이유는 구조체 전체를 로그로 찍는 테스트가 이것뿐이라서예요. 다른 테스트는 필드 하나씩만 읽으니까 우연히 Checked를 읽을 때만 걸려요.

    고치는 방법은 stamp를 ack 앞으로 옮기는 거예요:

    r.Result.DeviceID = r.ID
    r.Result.Valid = ok
    r.Result.Checked = time.Now()
    ack <- r.ID

    send는 receive가 완료되기 전에 일어나요(happens-before). https://go.dev/ref/mem 의 "Channel communication" 부분에 나와 있어요. -race -count=200을 다시 돌렸더니 깨끗하게 나왔어요. ack 뒤의 log.Printf는 Result를 건드리지 않으니까 괜찮아요.

    영어에서 번역원문 보기
  2. 고유 링크

    나중에 이 글을 찾아보실 분들을 위해 짧게 덧붙여요. send 위의 주석을 "everything the caller reads is written before this line"으로 바꿨고, stamp는 이제 다른 두 필드 바로 아래에 있어요. 애초에 잘못된 주석 때문에 길을 잘못 들었던 거예요.

    이번에는 race detector 문서도 제대로 읽었어요: https://go.dev/doc/articles/race_detector. detector는 그 실행에서 실제로 일어난 race만 보고한다고 해요. 그러니까 초록불이던 그냥 go test는 아무것도 증명하지 못한 거고, 깨끗한 -count=200도 증거일 뿐 보장은 아니에요. 그래서 이제는 -race를 머릿속에만 두지 않고 릴리스 전 스크립트에 넣어 뒀어요.

    감사용 타임스탬프를 꼭 ack 뒤에 써야 한다면, worker가 두 번째 메시지로 보내거나 호출하는 쪽이 이후의 동기화 지점을 지난 다음에야 Result를 복사하는 방법이 있어요. pushgate에서는 필요 없어서 뺐어요.

    웃긴 건 Android 클라이언트에서 같은 실수를 했다면 사용자 로그에 드물게 나는 크래시로 나타났을 테고, 제 노트북에서는 절대 재현 못 했을 거라는 점이에요))

    영어에서 번역원문 보기
  3. 고유 링크

    저는 반대예요, detector가 틀렸다고 생각하지 않아요. ack 뒤의 쓰기가 race로 보여요. 애초에 왜 Result 포인터를 공유하나요? Result를 채널로 값으로 보내면 안 되나요? 감사 타임스탬프 때문에 좀 어색해지는 건 인정해요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. webhook 하나에서만 HMAC 불일치가 나요 (이름에 악센트가 들어간 것)

    저장해 둔 webhook 전송분을 다시 재생해서, 하위 단계에서 신뢰하기 전에 HMAC-SHA256을 검증하는 작은 Node 체커를 만들었어요 (순수 JS, 빌드 단계 없음). 세 개 중 두 개는 통과해요. billing-p…

    답글 7최근 활동 typescriptjavascript
  2. free 전에 memset하면 기본 플래그에서는 통과하는데 OPT=-O2에서는 실패해요

    WSL에서 vault라는 작은 C 크리덴셜 홀더를 만들고 있어요. vault_release()는 시크릿을 지운 다음 free해야 해요: 테스트인 tests/wipe_check.c는 시크릿 포인터를 저장해 두고 vault_re…

    답글 6최근 활동 c
  3. -O0과 -O2에서 같은 overflow 체크인데 에러 분기가 달라요

    count=65536, rec_size=32768인 악성 pack을 넣어봤어요. -O0에서는 "pack too large"가 나오는데, -O2에서는 "out of memory"가 나와요. 소스는 똑같은데 왜 이런지 도저히 모…

    답글 6최근 활동 c