Skip to content

runtime: add fast path for non-blocking single-state select#5500

Open
rdon-key wants to merge 1 commit into
tinygo-org:devfrom
rdon-key:fix-rp2-select-send-freeze
Open

runtime: add fast path for non-blocking single-state select#5500
rdon-key wants to merge 1 commit into
tinygo-org:devfrom
rdon-key:fix-rp2-select-send-freeze

Conversation

@rdon-key

@rdon-key rdon-key commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #4974.

This PR adds a fast path in chanSelect for non-blocking single-state selects.

For example:

select {
case ch <- value:
default:
}

This form can lock at most one channel, so it does not need the global chanSelectLock, which is used to avoid deadlocks when multiple channels may be locked in different orders.

This fast path does not use chanSelectLock, and also avoids lockAllStates / unlockAllStates. Instead, when parallelism is enabled, it directly locks and unlocks the target channel.

lockAllStates / unlockAllStates use channel.selectLocked, and that state is protected by chanSelectLock. Therefore, simply skipping chanSelectLock while still using lockAllStates / unlockAllStates is not safe. The fast path avoids both.

Background

#4974 reports a freeze on RP2040 with -scheduler=cores when using channels, goroutines, and timers together.

While investigating this issue, I found that a repeated single-channel select send with a default case can reproduce a freeze on RP2040/RP2350 with -scheduler=cores.

The minimized reproducer repeatedly sends from a timer goroutine using this form:

select {
case ch <- struct{}{}:
    sentCount++
default:
    defaultCount++
}

A receiver goroutine continuously receives from the same channel.

At the time of freeze, the last report showed:

sent == recv
default == 0

So the issue did not appear to be caused by entering the default branch. It looked related to the successful send path through chanSelect and the receiver wakeup path.

Reproducer

I used a03_nonblocking_select_send_large_buffer_receiver.go as a minimized reproducer.

Details
package main

import "time"

const maxRun = 3 * time.Minute

var sentCount uint32
var defaultCount uint32
var recvCount uint32

func main() {
        time.Sleep(1 * time.Second)

        start := time.Now()

        // Keep the large buffer from a02.
        // Add the receiver goroutine back to separate:
        //   receiver/wakeup effect
        // from:
        //   small buffer / default branch effect
        ch := make(chan struct{}, 1024)

        // Receiver goroutine.
        go func() {
                for range ch {
                        recvCount++
                }
        }()

        // Sender goroutine.
        // This keeps the non-blocking send with default case code path.
        go func() {
                var timer *time.Timer
                var timerC <-chan time.Time

                for {
                        if timer == nil {
                                timer = time.NewTimer(300 * time.Millisecond)
                                timerC = timer.C
                        } else {
                                timer.Stop()
                                timer.Reset(300 * time.Millisecond)
                        }

                        <-timerC

                        select {
                        case ch <- struct{}{}:
                                sentCount++
                        default:
                                defaultCount++
                        }
                }
        }()

        // Main goroutine.
        // Keep the same 30ms timer + frequent output pattern as a01/a02.
        var timer *time.Timer
        var timerC <-chan time.Time
        mainCount := uint32(0)

        for {
                if timer == nil {
                        timer = time.NewTimer(30 * time.Millisecond)
                        timerC = timer.C
                } else {
                        timer.Stop()
                        timer.Reset(30 * time.Millisecond)
                }

                <-timerC

                mainCount++
                print(".")

                if mainCount%333 == 0 {
                        print(" REPORT ms=", time.Since(start).Milliseconds(),
                                " main=", mainCount,
                                " sent=", sentCount,
                                " recv=", recvCount,
                                " default=", defaultCount,
                                "\n")
                }

                if time.Since(start) >= maxRun {
                        print(" DONE ms=", time.Since(start).Milliseconds(),
                                " main=", mainCount,
                                " sent=", sentCount,
                                " recv=", recvCount,
                                " default=", defaultCount,
                                "\n")
                        return
                }
        }
}

The important part is this single-state default select send:

select {
case ch <- struct{}{}:
    sentCount++
default:
    defaultCount++
}

This form uses runtime.chanSelect and reproduced the freeze on RP2040/RP2350 with -scheduler=cores.

At the time of freeze, the report showed:

sent == recv
default == 0

This suggests that the channel values were not being lost. Instead, progress appeared to stop around the successful chanSelect send path and receiver wakeup.

Control case

I also used a06_two_state_select_send_full_case.go as a multi-state select control.

Details
package main

import "time"

const maxRun = 3 * time.Minute

var sentCount uint32
var recvCount uint32
var otherCount uint32
var defaultCount uint32

func main() {
        time.Sleep(1 * time.Second)

        start := time.Now()

        ch := make(chan struct{}, 1024)
        otherCh := make(chan struct{}, 1)
        otherCh <- struct{}{}

        go func() {
                for range ch {
                        recvCount++
                }
        }()

        go func() {
                var timer *time.Timer
                var timerC <-chan time.Time

                for {
                        if timer == nil {
                                timer = time.NewTimer(300 * time.Millisecond)
                                timerC = timer.C
                        } else {
                                timer.Stop()
                                timer.Reset(300 * time.Millisecond)
                        }

                        <-timerC

                        select {
                        case ch <- struct{}{}:
                                sentCount++
                        case otherCh <- struct{}{}:
                                otherCount++
                        default:
                                defaultCount++
                        }
                }
        }()

        var timer *time.Timer
        var timerC <-chan time.Time
        mainCount := uint32(0)

        for {
                if timer == nil {
                        timer = time.NewTimer(30 * time.Millisecond)
                        timerC = timer.C
                } else {
                        timer.Stop()
                        timer.Reset(30 * time.Millisecond)
                }

                <-timerC

                mainCount++
                print(".")

                if mainCount%333 == 0 {
                        print(" REPORT ms=", time.Since(start).Milliseconds(),
                                " main=", mainCount,
                                " sent=", sentCount,
                                " recv=", recvCount,
                                " other=", otherCount,
                                " default=", defaultCount,
                                "\n")
                }

                if time.Since(start) >= maxRun {
                        print(" DONE ms=", time.Since(start).Milliseconds(),
                                " main=", mainCount,
                                " sent=", sentCount,
                                " recv=", recvCount,
                                " other=", otherCount,
                                " default=", defaultCount,
                                "\n")
                        return
                }
        }
}


The important part is:

select {
case ch <- struct{}{}:
    sentCount++
case otherCh <- struct{}{}:
    otherCount++
default:
    defaultCount++
}

otherCh is pre-filled, so the second send case should normally not be selected.

This test is intended to check that the existing multi-state select path is not broken. With this PR, multi-state selects still use the existing chanSelectLock path.

Investigation

I first tried skipping only chanSelectLock for single-state selects. However, while still using lockAllStates / unlockAllStates, the #4974 reproducer hit this panic:

panic: sync: unlock of unlocked Mutex

This happened because lockAllStates / unlockAllStates use channel.selectLocked, and that state is protected by chanSelectLock.

Therefore, this PR adds a dedicated fast path for non-blocking single-state selects. This path avoids both chanSelectLock and lockAllStates / unlockAllStates, while still directly locking the channel when parallelism is enabled.

Validation

RP2040 / -scheduler=cores

RP2040 / -scheduler=tasks

RP2350 / -scheduler=cores

  • a03 single-state default select reproducer: 3/3 completed
  • a06 multi-state select control: 3/3 completed

RP2350 / -scheduler=tasks

  • a03 single-state default select reproducer: 1/1 completed
  • a06 multi-state select control: 1/1 completed

Before this change

  • The a03 reproducer repeatedly froze on RP2040.
  • The a03 reproducer also reproduced the freeze on RP2350.

Notes

This change only adds a fast path for non-blocking single-state selects.

Multi-state selects continue to use the existing chanSelectLock path, so the existing protection against deadlocks from locking multiple channels in different orders is preserved.

The fast path still directly locks and unlocks the target channel when parallelism is enabled, so channel-level protection is preserved.

@rdon-key

rdon-key commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Additional stability test: a #4975-style workload ran successfully with this PR applied.

Setup: pico, -scheduler=cores, SSD1306 OLED over I2C at 400kHz, SDA=GP12, SCL=GP13, address 0x3c, 128x32 display.

After adjusting the reproducer for the current ssd1306.NewI2C API (*ssd1306.Device), it completed two 30-minute runs without freezin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Freezing with channels, goroutines and timers

1 participant