Reader.curChunk can strand a waiter goroutine: Cond wakeups are sent without rmux held, and Close never broadcasts
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
調査の方向性
Start with the waiter loop and condition broadcasts in b2/reader.go, especially Reader.curChunk, setErr, and Close; the issue includes a regression test in b2/reader_wakeup_test.go. Read the retry block alongside related issue #57, then run the race detector command shown in the issue. Done means closed or failed readers leave no parked waiter goroutines and the existing test suites pass.
索引モデルが issue の本文から書いたものです。
説明
Summary
(*Reader).curChunk starts a goroutine that waits on r.rcond (a sync.Cond) until the next chunk is ready, an error is set, or the context is done. Two things break that wait:
- Lost wakeups. The wait predicate reads
r.errandr.ctx, but those are changed without holdingrmux(theCond's lock), and the fetch workers callr.rcond.Broadcast()without holding it either. ABroadcastthat lands between the waiter's predicate check and its registration inWaitis lost. - No wakeup on
Close.Reader.Closecancels the context but never broadcasts, and idle fetch workers exit on<-r.ctx.Done()without broadcasting. So once a wakeup is lost, nothing else ever wakes the waiter.
The goroutine stays blocked in r.rcond.Wait() for the life of the process. The caller is not stuck — curChunk's outer select returns on <-r.ctx.Done() — which is why this goes unnoticed.
Affected: v0.8.0 (latest release) and current master — the relevant files are identical in both. Lower severity than #56-#58: it is timing-dependent.
Where it is (b2/reader.go)
- The waiter loop:
for r.chunks[r.chrid] == nil && r.getErr() == nil && r.ctx.Err() == nil { r.rcond.Wait() }(reader.go:192-196). setErrsetsr.errand callsr.cancel()underemux, notrmux(reader.go:84-90).- Every
r.rcond.Broadcast()is issued withoutrmuxheld (reader.go:143, 148, 171, 179, 185). Close(reader.go:78-82) cancels and removes the reader; noBroadcast.- Idle workers return on
<-r.ctx.Done()(reader.go:117) without broadcasting.
Reproduction
This is a race, so the test is a stress loop: 20,000 readers whose downloads fail after a small varying delay, then a count of curChunk goroutines still parked. It reproduces reliably only under the race detector, which slows scheduling enough to hit the window.
Regression test (b2/reader_wakeup_test.go)
package b2
import (
"context"
"errors"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
// wakeRoot fails every download after a small, varying delay so the fetch
// worker's setErr+Broadcast races the curChunk waiter's predicate check.
type wakeRoot struct {
*testRoot
calls int32
}
func (r *wakeRoot) createBucket(ctx context.Context, name, btype string, info map[string]string, rules []LifecycleRule, sse *ServerSideEncryption) (b2BucketInterface, error) {
b, err := r.testRoot.createBucket(ctx, name, btype, info, rules, sse)
if err != nil {
return nil, err
}
return &wakeBucket{testBucket: b.(*testBucket), r: r}, nil
}
type wakeBucket struct {
*testBucket
r *wakeRoot
}
func (b *wakeBucket) downloadFileByName(context.Context, string, int64, int64, bool) (b2FileReaderInterface, error) {
n := atomic.AddInt32(&b.r.calls, 1)
for spin := time.Now().Add(time.Duration(n%40) * time.Microsecond); time.Now().Before(spin); {
}
return nil, errors.New("injected download failure")
}
// Racy by nature: reliably red on v0.8.0 only under -race, which widens the
// window between the waiter's predicate check and its registration in Wait.
//
// go test -race -run TestReaderCurChunkWaitersDoNotLeak ./b2/
func TestReaderCurChunkWaitersDoNotLeak(t *testing.T) {
root := &wakeRoot{testRoot: &testRoot{bucketMap: make(map[string]map[string]string), errs: &errCont{}}}
bucket, err := (&Client{backend: &beRoot{b2i: root}}).NewBucket(context.Background(), bucketName, &BucketAttrs{Type: Private})
if err != nil {
t.Fatal(err)
}
const readers = 20000
for i := 0; i < readers; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
r := bucket.Object("obj").NewReader(ctx)
_, _ = r.Read(make([]byte, 10))
r.Close()
cancel()
}
time.Sleep(500 * time.Millisecond)
buf := make([]byte, 1<<24)
if n := strings.Count(string(buf[:runtime.Stack(buf, true)]), "(*Reader).curChunk.func1"); n > 0 {
t.Fatalf("goroutine leak: %d curChunk waiter goroutine(s) still parked after %d closed readers", n, readers)
}
}
On v0.8.0 / master, go test -race -run TestReaderCurChunkWaitersDoNotLeak ./b2/:
--- FAIL: TestReaderCurChunkWaitersDoNotLeak (1.33s)
reader_wakeup_test.go:61: goroutine leak: 2 curChunk waiter goroutine(s) still parked after 20000 closed readers
(2-3 per 20,000 readers in our runs. Without -race it did not reproduce in 100,000 iterations.)
Expected vs. actual
- Expected: once a reader is closed or has failed, no goroutine of its is left waiting.
- Actual: occasionally a
curChunkwaiter stays blocked inWaitforever.
Impact
A slow goroutine leak in programs that create many readers and see download errors or cancellations. No wrong data and no hung caller.
Proposed fix
Broadcast while holding rmux, so a wakeup cannot fall between the waiter's check and its Wait, and broadcast from Close as well. The diff below is written on top of the #57 fix (it also rewrites the Broadcast that #57 adds); on master alone it is the same change minus that one line.
Proposed diff (b2/reader.go, on top of #57)
diff --git b2/reader.go b2/reader.go
index 74a0111..e689677 100644
--- b2/reader.go
+++ b2/reader.go
@@ -81,4 +81,7 @@ type rchunk struct {
func (r *Reader) Close() error {
r.cancel()
+ if r.rcond != nil {
+ r.broadcast()
+ }
r.o.b.c.removeReader(r)
return nil
@@ -102,4 +105,12 @@ func (r *Reader) setErrNoCancel(err error) {
}
+// broadcast wakes curChunk waiters. It takes rmux so the wakeup cannot land
+// between a waiter's predicate check and its registration in Wait.
+func (r *Reader) broadcast() {
+ r.rmux.Lock()
+ r.rcond.Broadcast()
+ r.rmux.Unlock()
+}
+
func (r *Reader) getErr() error {
r.emux.RLock()
@@ -145,10 +156,10 @@ func (r *Reader) thread() {
r.chunks[chunkID] = buf
r.rmux.Unlock()
- r.rcond.Broadcast()
+ r.broadcast()
return
}
if err != nil {
r.setErr(err)
- r.rcond.Broadcast()
+ r.broadcast()
return
}
@@ -173,5 +184,5 @@ func (r *Reader) thread() {
if attempts >= maxShortReadAttempts {
r.setErr(fmt.Errorf("b2 reader %d: got %dB of %dB after %d attempts", chunkID, i, rsize, attempts))
- r.rcond.Broadcast()
+ r.broadcast()
return
}
@@ -179,5 +190,5 @@ func (r *Reader) thread() {
if err := b.wait(r.ctx); err != nil {
r.setErr(err)
- r.rcond.Broadcast()
+ r.broadcast()
return
}
@@ -187,5 +198,5 @@ func (r *Reader) thread() {
if err != nil {
r.setErr(err)
- r.rcond.Broadcast()
+ r.broadcast()
return
}
@@ -193,5 +204,5 @@ func (r *Reader) thread() {
r.chunks[chunkID] = buf
r.rmux.Unlock()
- r.rcond.Broadcast()
+ r.broadcast()
}
}()
rcond is created lazily in initFunc, so Close checks for nil (a reader closed before its first Read).
Validated on a copy of master with the #57 and #59 changes applied: the regression test passes under -race (three runs), go test ./... passes, and go test -race ./b2/ passes with no data-race reports.
Related
- #57 edits the same retry block; apply this change after it.
- 主要言語
- Go
- スター
- 66
- フォーク
- 18
- 平均マージ
- 1日 10時間
- マージ済み PR(30日)
- 132
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
Backblaze/blazer のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
Bucket.Attrs never returns ReplicationConfig: replication configuration is write-only再び着手できるかも このイシューのプルリクエストはマージされずにクローズされました。 オープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
test: skip the fuzz non-vacuity guard when replaying a single seed対応中かも @ffumero2003 が 5 日前に担当しました。 オープンtesting
Backblaze/blazer#330 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
Backblaze/blazer の issue をすべて見る
似ている issue
-
needs-triage typo
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
llm-d-incubation/llm-d-scaling-manager#122 ·
メンテナーはふだん 1 日以内に返信
-
good first issue severity: low tech-debt
難易度 2/5 半日 初心者へのやさしさ 75/100
TMHSDigital/subenum#329 ·
メンテナーはふだん 1 日以内に返信
-
[Bug] Backend summarize min() / max() panics when a field has no numeric values対応中かも @weng5e が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 64/100
grafana/grafana-infinity-datasource#1832 ·
メンテナーはふだん 1 日以内に返信
-
tech-debt
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
GillMacedo/godebugdiff#13 ·