Skip to content

runtime: reuse timer nodes under the threads scheduler - #6092

Draft
0pcom wants to merge 2 commits into
tinygo-org:devfrom
0magnet:timer-node-reuse-pr
Draft

0pcom wants to merge 2 commits into
tinygo-org:devfrom
0magnet:timer-node-reuse-pr

Conversation

@0pcom

@0pcom 0pcom commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Under the threads scheduler, resetting a timer that had fired or been stopped allocated a new timerNode every time. This keeps up to 256 finished or stopped nodes on a free list under the timer queue lock and reuses them in NewTimer and Reset. Other schedulers keep plain allocation.

On linux/amd64 a Stop and Reset loop goes from one allocation per iteration to none. The new TestTimerResetAfterStopNoAlloc in src/runtime fails on dev and passes here. testdata/timers.go matches its expected output with -scheduler=threads and -scheduler=tasks, on arm64 under qemu, and on js/wasm under node, and TestBuild/Host/timers.go passes.

This touches the same code as #6035, so whichever lands second needs a small rebase.

Fixes #5957

Under the threads scheduler, resetting a timer that had fired, or one
that had been stopped, allocated a new timerNode because the old one
had left the queue. Code that rearms one timer in a loop, such as a
deadline on every network wait, allocated every time.

Keep up to 256 finished or stopped nodes on a free list under the
timer queue lock, and take from it in NewTimer and Reset.
timerCallback keeps the timer pointer before reAddTimer, since the
node may be reused as soon as that returns.

Fixes tinygo-org#5957
@dgryski

dgryski commented Oct 29, 2026

Copy link
Copy Markdown
Member

I think an equivalent change should be made to the other schedulers, not just threads. We should also include aging logic similar to #6093 (although using an actual sync.Pool here is overkill) so the extra timer nodes don't hang around for ever. If the aging logic complicates things too much, we could have it cleared every GC() similar to how sync.Pool used to work on Big Go. And maybe for other systems, have a smaller cap than the current 256.

Other than that, I like this change. Seems like a small code size increase for fewer allocations.

The free list only covered the threads scheduler. Move it to shared
code so the tasks, asyncify and cores schedulers reuse nodes too. Each
scheduler supplies lockTimers, which takes the lock that already guards
its timer queue.

Drop the list after each collection, as sync.Pool did before Go 1.13,
so a burst of timers does not keep its nodes forever. This reads the
collection count that tinygo-org#6093 adds for sync.Pool, with the same files.
Two collection aging would need a second list, and a dropped node is
cheap to allocate again.

Keep up to 256 nodes on hosted targets and 16 on baremetal ones.
@0pcom

0pcom commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Node reuse now lives in shared code for every scheduler, and the list is dropped after each collection using the GC count from #6093, with a cap of 16 on baremetal and 256 elsewhere. The no-alloc test and a new drop-after-GC test pass under threads and tasks on linux/amd64, arm64 (qemu) and js/wasm, and testdata/timers.go matches on those plus a cortex-m-qemu and riscv-qemu (cores) build check. A cortex-m-qemu program using timers grows by about 170 bytes at -Oz, and the binary-size table is unchanged.

@dgryski dgryski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

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.

runtime: resetting or stopping a timer allocates a new node under the threads scheduler

2 participants