Data Races Are a Compile Error Now: GALA in July (0.63 -> 0.72)
Go's race detector samples a schedule and hopes. GALA makes a value that isn't safe to share across goroutines a build failure.
Search for a command to run...
Go's race detector samples a schedule and hopes. GALA makes a value that isn't safe to share across goroutines a build failure.
No comments yet. Be the first to comment.
A ten-part technical blog series exploring GALA — a modern programming language that brings sealed types, pattern matching, monadic error handling, and immutable-by-default semantics to the Go ecosystem. Each post takes a single concept, shows the problem it solves with real code, compares it to idiomatic Go, and is honest about trade-offs. Written for Go developers who want more expressiveness and functional programmers who want Go's runtime.
Go is a language built on simplicity. But simplicity has costs, and one cost Go developers feel acutely is the lack of pattern matching and algebraic data types. If you have ever modeled a closed set
June's 0.56 -> 0.62 releases: bind/also monadic do-notation, applicative validation with Validated, and concurrent binds over Future.
Importing a third-party Go module through Bazel and rules_gala - with concrete types across the seam, verified hands-on.
From rules welded inside the language repo to a standalone, registry-published rules_gala with a real toolchain and Gazelle BUILD generation.
Eleven weeks, ~55 releases, and a new tagline: GALA hits 0.50.0 with cross-package generics, an LSP, and four new stdlib packages.
Go's race detector is very good at what it does. What it does is watch a program run and report races it actually observed.
That last part is the whole problem. go test -race finds the race your test
happened to schedule, on the interleaving it happened to get, on the machine it
happened to run on. It is a sampling instrument pointed at a nondeterministic
process. The races it misses are not exotic ones — they are the ordinary ones
that needed a slightly different timing to show up, which is also a fair
description of the races that reach production.
Seven releases have landed since the last of these posts, 0.63 through 0.72.2. The one that changes how the language feels is this: a value that isn't safe to share across goroutines is now a compile error. Not a warning, not a lint, not a runtime sampler. A build failure, with a caret pointing at the capture.
A data race needs two goroutines touching the same memory with at least one writing. The cleanest way to make that impossible is to guarantee the value is deeply immutable: if nobody can ever mutate it, any number of goroutines can read it concurrently with no synchronisation and no race.
So GALA's rule for crossing a goroutine boundary is exactly one sentence:
Only deeply-immutable values may cross.
The interesting question is not whether that rule is sound — it obviously is — but whether a language can adopt it without making concurrent code miserable to write. In most languages this rule would be intolerable, because most values in most programs are mutable.
GALA can afford it because the language already pushed everything the other way:
val over var, collection_immutable over collection_mutable,
Option/Try/Either over nil and naked error pairs, immutable struct fields
by default. Most values a well-written GALA program shares are already
shareable. The check is therefore silent in the common case. It only speaks up
when you try to send something genuinely mutable.
That's the design bet: the safety check is cheap because the language spent three years making immutability the path of least resistance.
A concurrency boundary is a parameter typed Sendable[F]. concurrent.Future
uses one; so does FutureOn; so can your own library. Here is a boundary and a
closure that captures a var:
package main
func run(body Sendable[func() int]) int = body()
func main() {
var counter = 0
Println(run(() => counter + 1))
}
That program does not compile:
error[GALA-E0037]: closure crossing a concurrency boundary captures reassignable var "counter" — a data race, because the enclosing scope may reassign it while the goroutine runs
--> var_capture.gala:7:23
|
7 | Println(run(() => counter + 1))
| ^^^^^^^ snapshot it into an immutable `val` before the boundary
|
= hint: snapshot it into an immutable `val` before the boundary (e.g. `val counter = counter` outside the closure) so the goroutine captures a stable copy
The closure never ran. No scheduler had to cooperate. There is no flaky-test phase where this reproduces on CI one time in forty.
Four shapes trigger GALA-E0037: capturing a reassignable var; using a val
whole when its type isn't deeply immutable (a collection_mutable value, a
struct with a var field, a Go-interop handle, a bare slice/map/pointer);
reading a field path that passes through a var field; and capturing a bare
func value, whose type says nothing about what it closed over.
That last one matters more than it looks. A func() int parameter is an opaque
promise — you cannot see its captures, so you cannot vouch for them. Typing the
parameter Sendable[func() int] is how the guarantee propagates: the caller
vouches, and the callee may forward it into a further boundary.
The predicate is deep, transitive, and structural. It looks through wrappers, collections, tuples, struct fields, and sealed variants until it hits a primitive or something it must reject.
Shareable: the value primitives; string; every collection_immutable
structure (Array, List, HashMap, HashSet, TreeMap, TreeSet) when
their type arguments are; Option/Try/Either and tuples when theirs are;
immutable structs and sealed types all the way down.
Not shareable: anything from collection_mutable, structs with a var field,
raw Go slices, maps and pointers, and opaque Go-interop references.
Which means this compiles, and every capture in it is checked:
val xs = ArrayOf(10, 20, 30)
val f = Future(xs.Size())
Println(f.Get())
Future(expr) is by-name thunk sugar — it desugars to Future(() => expr), so
expr is the async closure, and its captures get the same treatment as an
explicit lambda.
A whole-value check would be annoying enough to route around. If sharing a
struct required the struct to be immutable in its entirety, every Future would
open with a stanza of snapshot locals.
So the check is field-access-sensitive. It records how the closure uses each capture, and a capture read only through immutable field paths is accepted even when the struct as a whole is not shareable:
struct AppModel(team string, statuses Array[int], var attempts int)
val model = AppModel("qa", ArrayOf(1, 2, 3), 0)
// ACCEPTED — reads only the immutable `team` / `statuses` fields, even though
// AppModel is unshareable as a whole (it has a `var attempts` field).
Future(() => project(model.team, model.statuses))
// REJECTED — reads through the `var` field.
Future(() => model.attempts + 1)
Calling a method on a field you just read works too. The receiver's own type decides, so the common shapes real code writes are accepted:
// ACCEPTED — `team` is a `string`, unconditionally shareable.
Future(() => model.team.Size())
// REJECTED — the receiver field's own type is mutable.
Future(() => model.buf.Size()) // buf: collection_mutable.Array[int]
That last part is newer than the rest of this post. When I first wrote it up,
model.team.Size() was rejected — a method call ended the field-path run and
collapsed the whole capture, so a string that could never race got refused. It
was sound but wrong-feeling, and it landed on a shape you write constantly
(model.name.Size(), model.items.Map(...)). It's fixed in 0.72.1: a trailing
method call on a field path defers to the leaf's shareability. Indexing and
chained calls stay conservative and still mark the capture whole.
Which is roughly what you want from a check this young — the sound-but-annoying direction is the recoverable one, and it got recovered in a point release.
One more scope limit worth stating plainly: this is not a general race
detector. It guards declared Sendable[F] boundaries. Reach around them — hand
a pointer to hand-written Go, share state through a Go library — and you are
back to go test -race, exactly as before. It makes the safe path checkable, it
does not make the unsafe path impossible.
Structured concurrency and cancellation. Future gained real cancellation
with scope semantics: linear combinators inherit their parent's token, so a
derived stage shares its scope, while a fresh root opens a new one. Cancellation
is API-level — there's no body-token escape hatch to misuse.
Stack traces name your .gala lines. A panic now reports GALA source
positions, not positions in generated Go:
panic: runtime error: integer divide by zero
main.divide(...)
foo.gala:4
main.main()
foo.gala:8
This rides Go's own //line mechanism — the transpiler stamps each statement
with its originating GALA line, which the Go compiler and runtime already honor.
No source-map file, no runtime cost. Imported GALA packages map too, so a single
trace can span library and application source with each frame pointing at real
.gala code. It's approximate inside constructs that lower to several Go
statements or to an IIFE (match, if-expressions, bind/also) — right
file, line possibly off by a few.
Diagnostics got a frame. Errors now render Rust/Elm style, with a source excerpt, a caret span, an inline label, and a hint:
error[GALA-E0035]: bare Go builtin "len(...)" is not part of GALA's surface
--> bare_len.gala:4:13
|
4 | val n = len(s)
| ^^^ use `.Size()`
|
= hint: use `.Size()` (logical size — characters for strings) or `.ByteSize()` (raw bytes) instead of `len(...)`
Guaranteed tail-call optimisation. A self-tail-recursive function with an if-expression body compiles to a loop, so deep recursion runs in constant stack space. This:
func factorial(n int, acc int) int = if (n <= 0) acc else factorial(n - 1, n * acc)
becomes exactly this Go:
func factorial(n int, acc int) int {
for {
if n <= 0 {
return acc
} else {
_tmp_1 := n - 1
_tmp_2 := n * acc
n, acc = _tmp_1, _tmp_2
continue
}
}
}
Note the temporaries: both arguments are evaluated against the old parameter
values before either is reassigned, which is what makes factorial(n-1, n*acc)
mean what it says. The scope is deliberately narrow — plain functions (no
receiver), expression bodies, genuine self-tail-calls. Non-tail recursion like
n * factorial(n-1) is left alone rather than silently mis-transformed.
use, and closing the escape hatches. Scoped resource binding arrived, and
bare defer, bare go, and bare Go builtins were removed from GALA's surface —
routed through use, the concurrent package, and go_interop / go_builtins
respectively. Which is the same instinct as E0037: if the language offers a
checked path, the unchecked one shouldn't be the one that's easier to type.
A subprocess package, with a goroutine-safe Process handle — async line
reads, async stdin writes, kill-after-timeout, all returning Futures.
The pitch for GALA has been "the type system Go is missing." Sum types,
exhaustive matching, Option instead of nil — a compiler that rejects the
programs you didn't mean to write.
Concurrency safety is that same pitch aimed at the bug class Go developers actually lose days to. Go's answer to data races is a runtime tool you remember to run. GALA's answer is that the program doesn't build. Not because GALA is cleverer, but because it spent its immutability budget early and can now cash it in for something a mutable-by-default language can't buy at any price.
The check is young, and it guards declared boundaries rather than every
goroutine — reach around it and you are back to go test -race. Within those
boundaries it is still a different category of guarantee than sampling a
schedule and hoping.
gala.fyi has the reference for
concurrency safety and the full
GALA-E0037 writeup, or you can try
the language without installing anything in the
playground.