SeriesPart 5 of 7 // Go 1.27
GoWriting
Aug 21, 2026
11 min read
Runtime

Go 1.27's Runtime and Compiler: A Buffer Removed, Labels Added, Allocation Sped Up

go1.27rc1 removes the last escape hatch back to buffered timer channels, puts pprof labels in crash tracebacks by default, and speeds up small allocations by up to 39%. I benchmarked all three and ranked them by how badly each one can surprise you.

A brass pneumatic tube station with its cushioning buffer unbolted and removed, bare mounting holes left where it used to sit, and a single burnt-orange canister in transit through the glass tube with nothing left to catch it.

A test suite that has run GODEBUG=asynctimerchan=1 in its environment since 2023, for reasons nobody on the team remembers, does not fail to compile against go1.27rc1. It fails to start. Every process, before main runs, every time. That single line in a .env file or a Kubernetes manifest outranks everything else in this post, including a 39% allocation speedup, because a silent behaviour change with no opt-out can break a program that was working fine an hour ago. A 1% regression you can measure and decide to live with. A fatal startup error you inherited from three years of copy-pasted environment variables, you cannot.

This post covers three things that changed in the runtime and compiler for go1.27rc1: the removal of asynctimerchan, goroutine labels appearing in crash tracebacks by default, and a real, size-scoped allocation win from GOEXPERIMENT=sizespecializedmalloc. I ordered them by how much damage each one can do to a program you already shipped, not by how large the number attached to it is.

Written and benchmarked against go1.27rc1, cut from release-branch.go1.27 on 18 June 2026. The release notes carried a draft warning at the time of writing. Final release is 25 August 2026.

Three changes, ranked by what they can do to you

A GODEBUG that gets Removed: can turn a running configuration into a startup crash the moment you upgrade the toolchain, with no code change on your part. A GODEBUG that gets Changed: moves a default, but only for modules that opt in via their go.mod, and it doesn't crash anything either way. A GOEXPERIMENT that's on by default changes performance characteristics without touching behaviour at all. Same release, three different risk profiles. I built and ran probes for all three against real code in 04-allocation in the companion repo, and the order below is deliberate.

asynctimerchan: removed, not deprecated

Since Go 1.23, a timer's channel has reported a capacity of 0 through cap(), even though time.NewTimer still constructs it internally as make(chan Time, 1). The runtime special-cases timer channels in chancap specifically so that a pending send can be undone without a caller ever noticing the buffer was there. GODEBUG=asynctimerchan=1 was the escape hatch back to the old, genuinely buffered behaviour, where cap(t.C) read 1. I confirmed that under go1.26.5 by hand: with the GODEBUG set, capacity really does read 1.

In go1.27rc1 that escape hatch is gone. The godebug table (internal/godebugs/table.go) marks it Removed: 27, with Old: func(s string) bool { return s == "1" || s == "2" }. There is no successor default to fall back to. Setting either old value is fatal, before main ever runs.

The probe program is small on purpose:

func main() {
	t := time.NewTimer(10 * time.Millisecond)
	// As of Go 1.23, runtime.chancap special-cases timer channels to
	// always report a capacity of 0, even though the channel is
	// constructed internally as make(chan Time, 1) (see
	// $GOROOT/src/time/sleep.go and $GOROOT/src/runtime/chan.go's
	// chancap: "timer channels have a buffered implementation but
	// present to users as unbuffered, so that we can undo sends without
	// users noticing"). Setting GODEBUG=asynctimerchan=1 under go1.26.5
	// restores the pre-1.23 behaviour, where cap(t.C) genuinely reads 1.
	// In go1.27rc1 that restore path is gone entirely: the process is
	// fatal before this print statement ever runs.
	fmt.Printf("cap(t.C) = %d\n", cap(t.C))
	<-t.C
	fmt.Println("received tick")
}

Three runs, captured verbatim, no retyping:

$ GOTOOLCHAIN=local go1.27rc1 run main.go
cap(t.C) = 0
received tick
 
$ GOTOOLCHAIN=local GODEBUG=asynctimerchan=1 go1.27rc1 run main.go
fatal error: removed GODEBUG "asynctimerchan" set to old value "1" in environment (https://go.dev/doc/godebug#go-127)
 
goroutine 1 [running, locked to thread]:
runtime.fatal({0x1499bc692000, 0x68})
	runtime/panic.go:1267 +0x58
runtime.main()
	runtime/proc.go:224 +0x214
runtime.goexit({})
	runtime/asm_arm64.s:1039 +0x4
exit status 2
 
$ GOTOOLCHAIN=local GODEBUG=asynctimerchan=2 go1.27rc1 run main.go
fatal error: removed GODEBUG "asynctimerchan" set to old value "2" in environment (https://go.dev/doc/godebug#go-127)
 
goroutine 1 [running, locked to thread]:
runtime.fatal({0x2f8479392000, 0x68})
	runtime/panic.go:1267 +0x58
runtime.main()
	runtime/proc.go:224 +0x214
runtime.goexit({})
	runtime/asm_arm64.s:1039 +0x4
exit status 2

Two things worth being precise about. First, "unbuffered" is the wrong word to use for what go1.27rc1 changes, because timer channels have behaved as unbuffered from a caller's perspective since Go 1.23; that part is two years old, not new. What go1.27rc1 removes is the last way to opt back into the pre-1.23 buffered behaviour. Second, this is not a warning you can silence and move past. No third value and no unset value triggers it, only the two documented old ones ("1" and "2"), but if either is present anywhere in the process environment, the process does not start. If your deployment inherited that setting from an old base image, a Helm chart default, or a CI variable nobody has looked at since Go 1.22, the toolchain upgrade is the moment you find out, in production, at process start.

Labels in the crash dump: tracebacklabels

runtime/pprof goroutine labels have existed for a while as a way to tag a goroutine with structured metadata for profiling. In go1.27rc1, those labels start showing up in the header line of a crash traceback too. The godebug table marks this one Changed: 27, Old: "0", Opaque: true: it's a default flip, not a removal, and its trigger is subtler than the toolchain alone.

The probe sets labels directly and panics:

func main() {
	ctx := pprof.WithLabels(context.Background(), pprof.Labels(
		"request_id", "abc-123",
		"handler", "checkout",
	))
	pprof.SetGoroutineLabels(ctx)
	panic("simulated failure inside a labelled goroutine")
}
$ GOTOOLCHAIN=local go1.27rc1 run main.go
panic: simulated failure inside a labelled goroutine
 
goroutine 1 [running] {handler: checkout, request_id: "abc-123"}:
main.main()
	/path/to/04-allocation/godebug/tracebacklabels/main.go:44 +0x9c
exit status 2
 
$ GOTOOLCHAIN=local GODEBUG=tracebacklabels=0 go1.27rc1 run main.go
panic: simulated failure inside a labelled goroutine
 
goroutine 1 [running]:
main.main()
	/path/to/04-allocation/godebug/tracebacklabels/main.go:44 +0x9c
exit status 2

The default shown in the first run only holds because this program sits inside a module whose go.mod declares go 1.27. Changed: godebugs gate their new default on the module's go directive, the same mechanism tlsmlkem uses. A go 1.26 module built with the exact same go1.27rc1 toolchain does not get labels by default; you'd need GODEBUG=tracebacklabels=1 set explicitly to see them. I verified this by hand with a throwaway go 1.26 module against the same binary. So the accurate way to describe this feature is "on by default in a go 1.27 module," not "on by default in go1.27rc1" unqualified.

There's a sharper catch, and it isn't in the release notes or the godebug table entry. The idiom most pprof documentation shows isn't SetGoroutineLabels directly, it's pprof.Do:

func Do(ctx context.Context, labels LabelSet, f func(context.Context)) {
	defer SetGoroutineLabels(ctx)
	ctx = WithLabels(ctx, labels)
	SetGoroutineLabels(ctx)
	f(ctx)
}

I wrote a second probe that sets the same labels through pprof.Do instead, against an unlabelled parent context, and panics inside the callback:

pprof.Do(context.Background(), pprof.Labels(
	"request_id", "abc-123",
	"handler", "checkout",
), func(context.Context) {
	panic("simulated failure inside pprof.Do, labels already restored by defer")
})
$ GOTOOLCHAIN=local go1.27rc1 run main.go
panic: simulated failure inside pprof.Do, labels already restored by defer
 
goroutine 1 [running]:
main.main.func1({0x100ef9fe0?, 0x7e4c8199e1e0?})
	/path/to/04-allocation/godebug/tracebacklabels/viapprofdo/main.go:51 +0x2c
runtime/pprof.Do({0x100ef9fa8?, 0x100f2c260?}, {{0x7e4c81990080?, 0x100dc3cac?, 0x7e4c81940601?}}, 0x100efa380)
	/Users/ajitem/sdk/go1.27rc1/src/runtime/pprof/runtime.go:57 +0x78
main.main()
	/path/to/04-allocation/godebug/tracebacklabels/viapprofdo/main.go:47 +0x8c
exit status 2

No labels, under the exact same go 1.27 module default that showed them a moment ago for SetGoroutineLabels. The reason is in Do's own body: defer SetGoroutineLabels(ctx) captures ctx as it stood when the defer statement ran, which is the unlabelled parent, before WithLabels reassigns the local variable. Go runs a goroutine's pending deferred calls during an unrecovered panic before the runtime prints the crash traceback, so by the time the goroutine 1 [running] header line is generated, Do's own defer has already restored the goroutine to its unlabelled state. If you reach for pprof.Do, the pattern nearly every example uses, and expect its labels to show up in a crash dump, they won't, in any configuration, including the one that makes SetGoroutineLabels work. Getting labels into a panic traceback means calling SetGoroutineLabels directly, with nothing deferred in the frame that crashes, not pprof.Do.

The allocator's fast path, and where it actually helps

go1.27rc1 ships SizeSpecializedMalloc: true in its default GOEXPERIMENT baseline (internal/buildcfg/exp.go), no flag required. I benchmarked two allocation shapes across a size sweep from 8 to 256 bytes, three ways: go1.26.5, go1.27rc1 as shipped, and go1.27rc1 with GOEXPERIMENT=nosizespecializedmalloc turning the feature back off. n=10, reduced with benchstat, full results in results/04-allocation-benchstat.txt in the companion repo.

The two shapes: BenchmarkAllocSweep allocates a single []byte per iteration, the shape a connection handler makes once per inbound wire frame. BenchmarkDecodeMessageSweep allocates a small struct plus its payload slice, closer to what an RPC or pub/sub decode loop does.

The gain is real and it's concentrated under roughly 100 bytes. At the peak, DecodeMessageSweep/24B improves by 39.07% (p=0.000) and AllocSweep/24B by 35.10% (p=0.000). Both shapes show their largest gains in the 16 to 80 byte range: DecodeMessageSweep/16B is -37.06%, DecodeMessageSweep/32B is -37.67%, AllocSweep/16B is -29.35%, AllocSweep/32B is -33.14%.

Above 100 bytes it fades, and it fades at different rates for the two shapes. AllocSweep drops to -1.43% at 96B and is statistically indistinguishable from baseline from 128B onward (p=0.956 at 128B, p=0.698 at 256B). DecodeMessageSweep shrinks more gradually and stays significant further out: -18.91% at 96B, -5.68% at 128B, and still -4.26% at 256B (p=0.019, real but small). Neither shape shows anything close to the 16-80 byte gains once you're past 100 bytes.

The nossm column is what turns this from a correlation into a causal claim. At every size where rc1 shows a large gain, nossm sits within noise of go1.26.5, not of rc1: AllocSweep/24B nossm is unchanged from baseline (p=0.631), DecodeMessageSweep/24B nossm is unchanged (p=0.481), DecodeMessageSweep/32B nossm is unchanged (p=0.811). Across the whole sweep, benchstat's geomean for rc1 versus go1.26.5 is -20.97%; the geomean for nossm versus the same baseline is +0.01%, indistinguishable from zero. Turn SizeSpecializedMalloc off and the improvement goes with it, on the same toolchain, same source, same day. That's the control: the gain comes from this one experiment, not from something else that shifted between the two Go versions.

B/op doesn't move at all. Every size class reports identical bytes-per-operation across all three variants (benchstat marks every row "all samples are equal," p=1.000). AllocSweep/8B reports 16 B/op rather than 8: that's Go's allocator rounding an 8-byte request up to the 16-byte size class, which predates go1.27rc1 by years and holds identically on all three toolchains here, not a go1.27rc1 change. allocs/op doesn't move either: exactly 1 for AllocSweep, exactly 2 for DecodeMessageSweep, at every size, on every variant. SizeSpecializedMalloc changes what the allocator does internally to service a request of a given size, which size class fast path it takes, not how many times your code calls into the allocator or how many bytes it reports using.

What the smaller binary does and doesn't tell you

Building the same realistic program (a bufio/text/tabwriter/time/strconv/sort/fmt log aggregator, not hello-world) three ways produces three different, byte-identical-on-rebuild binary sizes:

sizeprog-126.5          2633874 bytes
sizeprog-127rc1         2586866 bytes
sizeprog-127rc1-nossm   2535506 bytes

go1.27rc1 vs go1.26.5: -47008 bytes, -1.78%. nossm vs rc1: -51360 bytes, -1.99%. nossm vs go1.26.5: -98368 bytes, -3.74%. These deltas were rebuilt twice each and came out byte-identical both times, so they're exact figures, not samples. But they're link-time artefacts of the runtime and compiler differing between versions, and of SizeSpecializedMalloc adding extra allocator code paths that nossm strips back out. They say nothing about allocation throughput. The build with the smallest binary (nossm) is also the one with none of the allocation speedup above; a smaller binary here means less allocator code linked in, not a faster program.

Code examples: the benchmark harness

The two allocation shapes are deliberately minimal, so the sweep measures the allocator and nothing else:

// newFrame allocates a fresh buffer of size bytes.
//
// This is the shape of allocation a connection handler makes once per
// inbound wire frame, for example:
//
//	frameLen := readLengthPrefix(conn)
//	buf := newFrame(frameLen)
//	if _, err := io.ReadFull(conn, buf); err != nil {
//	    return err
//	}
//
// It is intentionally the simplest possible allocation: one make call,
// nothing else on the heap. That makes it the cleanest probe for the
// allocator's small-object fast path, which is what
// GOEXPERIMENT=nosizespecializedmalloc turns off in go1.27rc1.
func newFrame(size int) []byte {
	return make([]byte, size)
}
// decodeMessage allocates a *message with a payload of the given size.
//
// Unlike newFrame, this allocates twice per call: the message struct
// itself (which escapes because it is returned by pointer) and its
// payload slice. That is deliberate. A frame buffer alone is the cleanest
// probe for the allocator's size-class fast path; a decoded message is
// closer to what real handler code actually allocates, so the sweep
// carries both shapes rather than only the synthetic one.
func decodeMessage(seq uint64, kind messageKind, payloadSize int) *message {
	return &message{
		seq:     seq,
		kind:    kind,
		payload: make([]byte, payloadSize),
	}
}

And the benchmarks themselves, with a package-level sink so the compiler can't prove either allocation dead and optimise it away:

var (
	sinkFrame   []byte
	sinkMessage *message
)
 
func BenchmarkAllocSweep(b *testing.B) {
	for _, size := range sweepSizes {
		b.Run(sizeName(size), func(b *testing.B) {
			b.ReportAllocs()
			for i := 0; i < b.N; i++ {
				sinkFrame = newFrame(size)
			}
		})
	}
}
 
func BenchmarkDecodeMessageSweep(b *testing.B) {
	for _, size := range sweepSizes {
		b.Run(sizeName(size), func(b *testing.B) {
			b.ReportAllocs()
			for i := 0; i < b.N; i++ {
				sinkMessage = decodeMessage(uint64(i), kindEvent, size)
			}
		})
	}
}

Reproduce the three-way comparison yourself from 04-allocation/compare in the companion repo:

cd 04-allocation/compare
GOTOOLCHAIN=local go1.26.5 test -run='^$' -bench=. -benchmem ./...
GOTOOLCHAIN=local go1.27rc1 test -run='^$' -bench=. -benchmem ./...
GOTOOLCHAIN=local GOEXPERIMENT=nosizespecializedmalloc go1.27rc1 test -run='^$' -bench=. -benchmem ./...

Lessons learned

Rank runtime changes by blast radius before you rank them by benchmark size. A 39% allocation win is real and worth having, but it can't break a process that was already running; a Removed: GODEBUG with no successor default can, the instant you swap the toolchain, with zero code changes on your side.

Removed: and Changed: are not the same kind of risk. asynctimerchan fails fatally regardless of your go.mod, unconditionally, if the old value is set anywhere in the environment. tracebacklabels only flips its default for modules that declare go 1.27 or later; a go 1.26 module on the same rc1 toolchain keeps the old, quieter behaviour. Read which kind of entry you're looking at in the godebug table before deciding how urgent it is.

Don't trust an idiom just because it's the one the documentation shows. pprof.Do is the standard way to attach goroutine labels, and it is precisely the one construction that erases those labels before an unrecovered panic's traceback ever prints, because of how its own defer interacts with Go's panic-then-defer-then-print ordering. SetGoroutineLabels called directly, with no deferred restore in the crashing frame, is what actually gets labels into a crash dump.

A performance claim needs a control, not just a before-and-after. The nossm build is what separates "SizeSpecializedMalloc made this faster" from "something changed between two Go releases and I'm guessing why." Without it, -35% at 24 bytes is a number with an unclear cause; with it, it's a measured, falsifiable claim.

Scope allocation wins to the size range that actually earned them. This one holds for allocations well under 100 bytes and largely disappears above that threshold, at different rates for the two shapes tested here. A program whose hot allocations sit at 200 bytes will not see this.

Takeaways

  • asynctimerchan is Removed: 27 with no successor default. Setting it to the old value "1" or "2" anywhere in a go1.27rc1 process's environment is fatal before main runs.
  • Timer channels have reported cap() == 0 since Go 1.23. Go 1.27 doesn't make them unbuffered; it removes the last way to opt back into the pre-1.23 buffered behaviour.
  • tracebacklabels is Changed: 27. Its new default, labels shown in a crash traceback, only applies to modules that declare go 1.27 or later in go.mod; a go 1.26 module on the same toolchain needs GODEBUG=tracebacklabels=1 set explicitly.
  • pprof.Do's own deferred restore erases goroutine labels before an unrecovered panic's traceback prints. Use pprof.SetGoroutineLabels directly if you want labels in a crash dump.
  • SizeSpecializedMalloc, on by default in go1.27rc1, cuts allocation time by up to 39.07% (DecodeMessageSweep/24B) and 35.10% (AllocSweep/24B) for allocations in roughly the 16 to 80 byte range, fading to statistical noise for AllocSweep by 128 bytes and to a small but still real 4.26% for DecodeMessageSweep at 256 bytes.
  • B/op and allocs/op are identical across all three toolchain variants at every size tested. The gain is entirely in timing, not in what gets reported as allocated.
  • The smaller go1.27rc1 binary (-1.78% versus go1.26.5) and the smaller still nossm binary (-1.99% further) are link-time artefacts of runtime and allocator code paths, not evidence about allocation speed. Full numbers and reproduction steps are in 04-allocation in the companion repo.

Series contents