Blog

Snapshots on the road to Go 1

I rebuilt three small programmes against a recent weekly snapshot today. Two compiled after mechanical fixes. The third exposed an assumption in my own package layout. That ratio feels like progress: the road to Go 1 is becoming less about chasing syntax and more about discovering what code actually depends upon.

I keep a tiny record beside each experiment:

snapshot: release.rNN from the weekly series
compiler: gc
system: linux/amd64
result: build, test, benchmark

rNN here is a placeholder for the precise release printed by the toolchain, not a new version naming proposal. Recording it prevents a December result from quietly becoming a claim about whichever snapshot happens to be current in January.

My surprise was a benchmark that improved after migration even though I had not optimized the programme. The toolchain and runtime are changing alongside the source interfaces. A source diff alone therefore cannot explain every performance change between snapshots. Rebuilding the old source with the old toolchain and the migrated source with the new one changes two variables at once.

For correctness, the weekly process is straightforward. The compiler checks the current language. gofix handles recognized source transformations. Formatting normalizes rewritten code. Tests then check the semantic assumptions neither compiler nor fixer can know. Each layer catches a different class of mistake.

Performance needs a stricter comparison. I preserve the old binary and input, warm up the machine, and run enough iterations to see variation. Linux 3.0, the scheduler, the garbage collector, and compiler output can all affect the number. One fast run is a pleasant event, not a conclusion.

I prefer following named snapshots during this final stretch because each migration stays small and attributable. Waiting for Go 1 might sound safer, but it accumulates every incompatible change into one rather theatrical weekend. The caveat is that preview code remains preview code. I keep important production builds pinned and use copies for migration practice.

The compatibility goal is the larger prize. Once Go 1 arrives, source written to its specification and standard packages should not require this weekly dance merely to remain source-compatible. Implementations can still become faster, schedulers can change, and bugs can be fixed. Stability is a promise about the platform, not the end of engineering.

For now, I will continue writing down the snapshot number. Future me is a notoriously unreliable eyewitness.

gofix is not a test suite

I ran gofix over an older utility and was rewarded with a clean build. Then its first real input produced different output. That was a useful correction to my optimism.

My migration loop is now deliberately boring:

copy or commit the old tree
run gofix
inspect the diff
format and build
run representative inputs

The surprise is how convincing syntactically correct output can be. gofix understands Go source and can perform precise, repeatable rewrites for known language and library changes. It cannot infer every assumption my programme made about the old operation.

That boundary follows from the mechanism. A fix rule recognizes a source pattern and substitutes the new form. It can use syntax and type context; it does not possess the history behind my data or know which edge cases matter to users.

I prefer the tool over hand-editing twenty copies of the same call. Mechanical work should be mechanical. I also prefer one rule or related group at a time, because a small diff tells me which migration changed behaviour.

The caveat is code generation. Fixing generated output while leaving the generator untouched guarantees an encore. Tests and examples may need migration too, and they are often the best record of old semantics.

In my utility the changed behaviour was easy to repair once a failing input exposed it. gofix had done its job. I had briefly promoted it to quality assurance director without discussing the salary.

Watching the Go garbage collector stop

I noticed a small server pausing at intervals under an allocation-heavy test. The average request time looked respectable, but individual requests occasionally took a visible detour. That pattern made the old garbage collector worth examining.

I reduced the trigger to a loop that replaces short-lived buffers:

for i := 0; i < n; i++ {
	b := make([]byte, 32*1024)
	consume(b)
}

The first surprise was that allocation speed and collection latency are separate observations. Allocation can be cheap while creating enough work for periodic collections. An average over the entire run smooths those pauses into a harmless-looking number.

The collector in this runtime is a stop-the-world collector. When a collection begins, Go execution is brought to a safe point and paused while the runtime determines which heap objects are reachable. It starts from roots such as stacks and global data, follows pointers through reachable objects, and eventually reclaims storage that was not found. Application goroutines do not continue doing ordinary work during that collection.

This explains why adding processors does not simply make the pause disappear. It may improve the application between collections, but the collector’s coordination and tracing remain a shared event. It also explains why retaining one pointer to a large structure matters: reachability, not whether I personally remember using the object, determines whether it survives.

I prefer reducing needless allocation only after a profile or latency measurement identifies it. Reusing a buffer at a clear ownership boundary can help. Converting every local variable into a global cache can help the heap grow mysterious hair. Smaller live data and fewer transient objects are good outcomes; cleverness measured only by line count is not.

The caveat is that stack and heap placement are compiler decisions, and the compiler is changing. I avoid writing code that depends on a guessed escape decision. I also avoid claims about a future collector when explaining this one. Today’s pauses are real even if the runtime will improve.

For the server, pooling two large scratch buffers around a serialized operation removed most of the allocation burst. I kept the simpler allocating version in a benchmark so the trade remains measurable. The garbage collector was doing exactly what it had been asked to do; I had simply been sending rather enthusiastic invitations.

The Go 1 preview and gofix

The Go 1 plan changed how I looked at a compiler error today. Until now, a broken build after updating was mostly the price of following a young language. With a compatibility target in sight, the churn starts to look like a migration with a destination.

I tried gofix on a copy of a small package before changing anything by hand. The useful workflow is pleasantly conservative:

$ gofix -r rule package.go
$ hg diff package.go

The available rules and invocation depend on the snapshot, so I check the tool’s help rather than preserving that command as scripture. The second command matters more: automated rewriting should produce a diff I can understand.

My surprise was that the mechanical part was the easy part. Renaming an API or adjusting a call shape can often be recognized from syntax and type information. Deciding whether the programme still means the same thing requires a person and tests. A clean compile is evidence, not absolution.

gofix works by parsing Go source rather than treating it as undifferentiated text. That lets a rule identify a particular construct and rewrite it while preserving valid Go structure. It can then format the result. This is much safer than a global search-and-replace that cannot distinguish a package name from a comment or an unrelated identifier.

The Go 1 preview is important because it proposes a stable base for source code and core packages. It does not mean every implementation detail freezes, nor does it make the current preview the final release. There is still work between a compatibility promise and a toolchain one can call 1.0 without crossing one’s fingers behind one’s back.

I prefer making these migrations promptly and in small commits. Old code plus several skipped snapshots turns a sequence of obvious edits into software archaeology. I run gofix, inspect every hunk, format, build, and exercise the behaviour that changed.

The caveat is generated source and unusual build arrangements. A rewrite may need to happen at the generator, not merely in its output. Local APIs with names resembling standard ones also deserve attention.

Tools cannot remove change, but they can make change reviewable. That seems a suitably Go-like ambition: less ceremony, still no magic.

A look at the old Go scheduler

I wrote a small fan-out test and expected twice as many goroutines to mean twice as much parallel work. The result barely moved. That sent me away from the programme and into the runtime scheduler.

The test looked innocent:

for i := 0; i < 4; i++ {
	go crunch(work[i])
}

Goroutines are cheap concurrent activities, but concurrency does not itself promise simultaneous execution on every processor. In the current runtime, scheduling is still comparatively simple, and processor use depends on runtime settings, operating-system threads, blocking calls, and where the scheduler gets a chance to run.

The surprise was how many layers my little loop depended upon. A goroutine begins with a small stack and runtime bookkeeping. Runnable goroutines wait for the scheduler. The scheduler places work onto operating-system threads, and the kernel finally decides when those threads run. Linux 3.0 may schedule the threads beautifully, but it cannot create parallel Go execution the runtime never offered it.

This scheduler predates the more ambitious designs being discussed for later Go. It should not be described using machinery that does not exist yet. There is no reason to draw a modern diagram over an old runtime and then congratulate the diagram for explaining it.

Blocking is the interesting case. If a goroutine enters a system call, the runtime may need another thread so runnable Go code can continue. Channel operations and synchronization also move goroutines between runnable and waiting states. The scheduler therefore coordinates cheap user-level goroutines with more expensive kernel threads rather than mapping one permanently to the other.

I prefer to write synchronization for correctness first and treat parallel speedup as a measured property. A channel can make ownership clear even on one processor. If CPU parallelism matters, I set the runtime’s processor allowance explicitly, run a long enough workload, and compare against a serial version.

The caveat is that scheduler behaviour is an implementation detail in active development. A trick that coaxes today’s scheduler may become useless or harmful after a snapshot. Explicit communication and coarse useful work are safer investments than depending on an accidental scheduling order.

My test eventually did improve, but only after I made each unit of work large enough to outweigh coordination and enabled the processors I intended to use. Four goroutines were not four tiny employees waiting inside the computer. This was disappointing for payroll, but helpful for the benchmark.