Skip to content

sync: RWMutex can deadlock when readers queue behind a writer #5692

Closed

Description

@yohimik

Concurrent process and pipe creation stopped in my CLI tests. Both use syscall.ForkLock, which is a sync.RWMutex.

A reader holds the lock. A writer waits, then another reader queues. When the first reader unlocks, the writer can remain blocked. A second failure can lose a reader wakeup during lock handover.

On 2 September 2026, the tests in #5630 reproduced this with TinyGo v0.42.0 on Linux/arm64 and Darwin/arm64. TestRWMutexWriterNotStarvedByLateReaders failed after 10 seconds. The lock should let the waiting writer and readers finish.

Regression tests are in #5630. Copy them into src/sync and run tinygo test -run TestRWMutex sync.

Source checked on 18 September 2026. The affected code is unchanged in dev at 93940cb6. #5630 was closed on 17 September with a request for this issue.

Replacement draft PR #5697.

Activity

  1. deadprogram commented on Sep 19, 2026

    @deadprogram
    Member

    What was the original problem in one of your projects that triggered this @yohimik ? Perhaps a single simple reproducer, if you please?

  2. yohimik commented on Sep 20, 2026

    @yohimik
    ContributorAuthor

    @deadprogram The original failure was in CLI tests that created processes and pipes at the same time. The tests stopped making progress. The shared lock was syscall.ForkLock, which uses sync.RWMutex.

    Here is a small standalone version of TestRWMutexWriterNotStarvedByLateReaders. It removes the process and pipe code and keeps the lock sequence that caused the failure:

    package main
    
    import (
    	"fmt"
    	"sync"
    	"time"
    )
    
    func main() {
    	var mu sync.RWMutex
    	writerDone := make(chan struct{})
    	readerDone := make(chan struct{})
    
    	mu.RLock()
    	go func() {
    		mu.Lock()
    		mu.Unlock()
    		close(writerDone)
    	}()
    	time.Sleep(50 * time.Millisecond)
    
    	go func() {
    		mu.RLock()
    		mu.RUnlock()
    		close(readerDone)
    	}()
    	time.Sleep(50 * time.Millisecond)
    	mu.RUnlock()
    
    	select {
    	case <-writerDone:
    	case <-time.After(10 * time.Second):
    		panic("writer did not finish after the first reader unlocked")
    	}
    	select {
    	case <-readerDone:
    	case <-time.After(10 * time.Second):
    		panic("queued reader did not finish")
    	}
    	fmt.Println("ok")
    }

    Save this as main.go and run tinygo run main.go. The expected result is ok. The sleeps give the writer and second reader time to enter their lock calls, so this reproducer depends on scheduling.

    In the affected implementation, the second reader increases the reader count before it waits for the writer. When the first reader unlocks, the count still includes that waiting reader. This prevents the writer wakeup. The writer and second reader then wait for each other.

    The regression test reproduced the failure with TinyGo v0.42.0 on Linux/arm64 and Darwin/arm64 on 2 September, as recorded above. I have not rerun this standalone version. The same test is included in #5697.

  3. deadprogram commented on Sep 22, 2026

    @deadprogram
    Member

    Handled in #5697 labeling to close on next release.

  4. added a commit that references this issue on Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions