After the Go Maps Deep Dive (no_swiss) — Part 1, the natural next step is to explore Go’s source code itself and see how these functionalities are actually implemented under the hood.
But before we can dive into files like
map.go
ormap_swiss.go
, we need to equip ourselves with some background knowledge. The Go runtime makes heavy use of compiler directives, unsafe operations, bit tricks, and even bits of assembly. Without understanding these essentials, the source code can feel cryptic.That’s why I’m starting this new series: Golang Source Code Essentials. This series will serve as a foundation, teaching the tools and concepts you’ll repeatedly encounter when reading Go’s internals. Once we cover these, we’ll return to maps (and beyond!) with the confidence to truly understand what’s happening in the runtime.
We’ll cover:
- ⚡Compiler directives (
//go:linkname
,//go:nosplit
,build tags
, etc.)- đź”’Unsafe and internal packages (
unsafe
,runtime/internal/sys
,runtime/internal/atomic
)- 🔢Bit operations and low-level optimizations commonly used in the runtime
- đź§©Assembly touchpoints (
memmove
,memclrNoHeapPointers
, etc.)- 📏Stack growth and garbage collector interactions (why
nosplit
matters)- đź§Practical tips on navigating, modifying, and rebuilding the Go source
By the end of this series, you’ll be comfortable opening Go’s runtime files and actually understanding what’s happening.
For part 0, we start with Compiler Directives and Build tags
Line Directives (//line
)📝:
Mainly used by code generators and tools to improve error reporting, it tells the compiler to treat the following code as if it comes from a different line number and/or file.
example:
//line main.go:100
var x = "test" + 123
Here, regardless of the actual file or line, the compiler output will look like::
main.go:100: invalid operation: "test" + 123 (mismatched types untyped string and untyped int)
Function Directives⚙️:
1. //go:noescape:
It tells the compiler that the function does not allow any of its pointer arguments to escape to the heap. This can help the compiler optimize stack allocation and calling conventions. It is mainly used in low-level or runtime code, and has no effect on the function's behavior—just on how the compiler treats pointer arguments.
It must be followed by a function declaration without a body, and the .s
assembly file must exist in the same package; otherwise, the code won’t compile.
example:
//go:noescape
func test1(a unsafe.Pointer) unsafe.Pointer
This is valid if there is the .s
file (Go assembly source file, we will talk about these in the next parts) and the actual implementation of the test1
is inside that .s
file with the exact same name, for example:
//go:build amd64
#include "textflag.h"
// TEXT ·test1(SB), NOSPLIT, $0-16
// Implements: func test1(a unsafe.Pointer) unsafe.Pointer
TEXT ·test1(SB), NOSPLIT, $0-16
MOVQ 0(FP), AX // load a
MOVQ AX, 8(FP) // store return value
RET
in the first line you can see that //go:build amd64
, this is a build constraint. It tells the Go build system to compile that file only when targeting the amd64
architecture, If the file name already ends with _amd64.s
, the build tag is optional
if we don't have any .s
files in the same package and run the above code we will get the error: missing function body
.
As I mentioned, functions marked with //go:noescape
must not have a body.
we put a .s
file and run the following code:
//line main.go:100
//var x = "test" + 123
//go:noescape
func test1(a unsafe.Pointer) unsafe.Pointer
//go:noescape
func test2(a unsafe.Pointer) unsafe.Pointer {
return test1(a)
}
The output will be:
main.go:106: can only use //go:noescape with external func implementations
( Have you noticed the main.go:106
!!!?, Very cool, isn't it?!🤩 )
2. //go:noinlineđźš«:
It must be followed by a function declaration, telling the compiler to never inline the immediately following function, regardless of size or heuristics. It is used mainly for writing stable benchmarks (preventing the optimizer from removing or folding calls) or debugging the compiler
//go:noinline
func add(a, b int) int {
return a + b
}
3. //go:nosplitđź§µ:
It must be followed by a function declaration, disables stack growth checks for the immediately following function. The compiler omits the stack-splitting prologue, so the function must never need more stack than is already available ( no call to morestack
). Used in tiny, leaf, low-level runtime functions that must run even when the goroutine stack is almost exhausted (e.g. during stack growth
, signal handling
).
Note
: We will discuss this further in later parts.
Constraints: keep it very small, do not call into code that might allocate, panic, block, or grow the stack. Misuse can cause unrecoverable crashes.
//go:nosplit
func load8(p unsafe.Pointer) uint8 {
// Must stay tiny: just one load.
return *(*uint8)(p)
}
Crash example:
//go:nosplit
func add(n int) int {
if n == 0 {
return 0
}
return 1 + add(n-1)
}
I called it with n = 1000
and the output was:
main.add: nosplit stack over 792 byte limit
main.add<1>
grows 24 bytes, calls main.add<1>
infinite cycle
Linkname Directives(//go:linkname
)đź”—:
It tells the compiler that a local identifier should be bound to (share the symbol of) some other (possibly unexported) identifier in another package.
It bypasses visibility, is inherently unsafe and version‑fragile.
It requires importing the unsafe
package, even if unused (e.g. import _ "unsafe")
, because the Go team specifically tell us that this is unsafe
The two functions that are linked together don’t need to have the same signature, leading to panics if misused
example:
// inside the main.go file:
//go:linkname testLink
func testLink(a, b, c string) string
func main() {
log.Println(testLink("a", "b", "c"))
}
// inside the test.go file in another package named 'another':
//go:linkname test main.testLink
func test(a, b string) string {
return a + b
}
The output will be "ab". As you can see, the test
function in the another
package is private (not exported), and the signatures also don’t match.
- Linkname: Dangerous Freedom⚠️
The linkname directive created a problem: we can easily write functions or variables that link to Go’s internals, and this made many dependencies on the Golang internals, this has created a serious problem: many programs now depend on Go’s internal details, something they were never meant to rely on, because changing the internals may break those Go programs as well, The Go team is trying to lock down (or at least limit) the usage of linkname in user code to avoid more dependencies on the Golang internals, and currently Go team has a hall of shame
that includes some most known programs that are already dependent on these internals for example:
// Call from Go to C.
//
// This must be nosplit because it's used for syscalls on some
// platforms. Syscalls may have untyped arguments on the stack, so
// it's not safe to grow or scan the stack.
//
// cgocall should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
// - github.com/ebitengine/purego
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname cgocall
//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
}
As you can see, there is documentation for this function inside cgocall.go file inside the runtime package and includes a Notable members of the hall of shame
list.
I strongly recommend reading the Russ Cox issue in Golang GitHub page: cmd/link: lock down future uses of linkname #67401
Build Tags (//go:build
, // +build
)🏗️:
Build tags determine whether a file should be included in the compilation of a package. This is incredibly useful for writing platform-specific code or creating different build versions of your application.
The modern and preferred syntax is the //go:build
directive. It must be placed at the top of the file, preceded only by blank lines or other comments, and must be followed by a blank line.
-
//go:build
(Go 1.17+) This means compile this file only when building for Linux on amd64:
//go:build linux && amd64
This means compile this file only on 64-bit Linux
or 64-bit Windows
systems.
//go:build (linux && amd64) || (windows && amd64)
Custom Tags🏷️: You can define your own tags to create different builds, like a "development" build with extra logging.
//go:build dev
package mypackage
import "log"
func DebugLog(message string) {
log.Printf("[DEV] %s", message)
}
//go:build !dev
package mypackage
// This is a no-op in release builds.
func DebugLog(message string) {}
To compile with the dev tag, you use the -tags
flag:
# This will include debug_logger.go and its DebugLog implementation.
go build -tags="dev"
# This will include release_logger.go, where DebugLog does nothing.
go build
-
// +build
(Legacy) This is the older syntax. While still supported,//go:build
is preferred. You can use both in the same file for backward compatibility. A file is included if the boolean formula is satisfied.
// +build linux,darwin
// ... this file will be included on Linux OR Darwin systems.
🚀What’s Next?
In this part, we explored compiler directives and build tags, the “hidden switches” that control how Go source files are compiled and how runtime functions behave. These are everywhere in the Go runtime and now you’ll recognize them when diving into files like runtime/asm_amd64.s
or map.go
.
👉 In Part 1, we’ll dive into the Unsafe & Internal Packages that the runtime depends on. You’ll see how unsafe.Pointer
, runtime/internal/sys
, and runtime/internal/atomic
unlock the low-level power behind Go.
Stay tuned — it’s about to get even more interesting! ⚡
Top comments (4)
I’m so excited for this series, nobody else seems to dig into these deep parts of Go like compiler directives and build tags. Please keep going,I’m really enjoying it and can’t wait for future parts!
Thanks for your comment ❤️
Thank you so much! I truly enjoyed it — it was amazing beyond words. You deserve the very best. Sending lots of love and deep appreciation for your talent in Go!
Thanks for your kind comment ❤️