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. Related to #4974.

Status: this PR is an optimization, not the fix. It started as a fix for the freeze in #4974, but further investigation showed the root cause is the waiter wake-up ordering in the common channel path — see #5513. A blocking multi-state select, which cannot take the fast path added here, still froze 3/3 on dev.

The #4974 reproducer did not freeze in testing with this PR because its select operation uses the fast path added here. However, this bypasses the underlying bug rather than fixing it. Therefore, this PR is related to #4974 but does not close it.

The fast path is still worth having on its own: the Go compiler applies the same optimization, lowering a select with a single channel operation and a default case to selectnbsend / selectnbrecv instead of going through the general select path.

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

(Historical context — see the status note above for the current understanding.)

#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

@rdon-key

Copy link
Copy Markdown
Contributor Author

I found that the underlying cause is the waiter wake-up ordering in the common channel path. I submitted #5513 to fix it.

A blocking multi-state select reproducer, which cannot use the fast path in this PR, froze in 3/3 runs on dev. With #5513 applied, it completed all five three-minute runs. This indicates that the underlying freeze is not limited to non-blocking single-state selects.

However, the fast path introduced by this PR is still a useful optimization. The Go compiler applies the same optimization to a select with one channel operation and a default case, lowering it to selectnbsend or selectnbrecv instead of using the general select path.

I am therefore keeping this PR open but changing its purpose from a correctness fix to an optimization.

@b0ch3nski b0ch3nski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert on this manner, but this change looks reasonable. I admire your dedication @rdon-key to chase the freeze with cores scheduler 🥇

BTW. You might edit the OP to mention there is another part needed for the real fix.

@rdon-key

Copy link
Copy Markdown
Contributor Author

@b0ch3nski

Thank you for the kind words.

The cores scheduler is great, and I’m sure we can make it solid.

I updated the OP to clarify that this PR is now an optimization, and that the more general underlying issue is addressed by #5513.

@aykevl

aykevl commented Jul 13, 2026

Copy link
Copy Markdown
Member

Good idea.

I wonder if this optimization could perhaps be done in the compiler, calling trySend and tryRecv directly? It might need some additional helper functions, but the compiler can easily detect a non-blocking send/receive and optimize it to direct channel operations.

@rdon-key

Copy link
Copy Markdown
Contributor Author

@aykevl
I see. I agree with this approach. It would also avoid constructing a single-element chanSelectState slice on the stack.

However, #5513 changes the wake-up handling around trySend and tryRecv, and I think this compiler-side optimization should be based on that change.

Could we review #5513 first?

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

3 participants