unsafe.Pointer and //go:linkname: The Escape Hatches That Break Abstraction
unsafe.Pointer lets you reinterpret memory, //go:linkname lets you call unexported symbols in other packages, and the Go linker now enforces a hard allowlist of who may pull which linkname.
TL;DR:
unsafe.Pointeris the type system with the safety catch removed.//go:linknameis the other escape hatch: it lets one package call another package’s unexported function by its link-time symbol name. The linker now keeps an explicit allowlist of who may pull which linkname.
Go’s type system is one of its best features and also, occasionally, an
obstacle. Sometimes you need to reinterpret a []byte as a []int32. Sometimes
you need to call a function the standard library clearly meant to keep to
itself. Go provides two doors for both situations, and both of them are
unlocked with a comment that says unsafe or linkname.
Understanding how these work matters even if you never use them, because they explain a lot about how the compiler and linker are put together, and because the guard rails around them have changed a lot in the last few releases.
unsafe.Pointer: the type system, optional
The unsafe package documentation opens with the deal:
// src/unsafe/unsafe.go:21
// Pointer represents a pointer to an arbitrary type. There are four special operations
// available for type Pointer that are not available for other types:
// - A pointer value of any type can be converted to a Pointer.
// - A Pointer can be converted to a pointer value of any type.
// - A uintptr can be converted to a Pointer.
// - A Pointer can be converted to a uintptr.
//
// Pointer therefore allows a program to defeat the type system and read and write
// arbitrary memory. It should be used with extreme care.
//
// The following patterns involving Pointer are valid.
// Code not using these patterns is likely to be invalid today
// or to become invalid in the future.
// Even the valid patterns below come with important caveats.
//
// Running "go vet" can help find uses of Pointer that do not conform to these patterns,
// but silence from "go vet" is not a guarantee that the code is valid.Then it enumerates the legal patterns. There are five of them, and the distinctions between them are not academic — they are the difference between code that works and code that breaks the moment the garbage collector moves something.
Pattern 1: reinterpret a pointer.
// src/unsafe/unsafe.go:39
// (1) Conversion of a *T1 to Pointer to *T2.
//
// Provided that T2 is no larger than T1 and that the two share an equivalent
// memory layout, this conversion allows reinterpreting data of one type as
// data of another type. An example is the implementation of
// math.Float64bits:
//
// func Float64bits(f float64) uint64 {
// return *(*uint64)(unsafe.Pointer(&f))
// }Pattern 2: convert to uintptr and stop. A uintptr is an integer, not
a reference. The collector will not update it and will not keep the object
alive.
Pattern 3: convert to uintptr, add an offset, convert back — and the
conversion must appear in the same expression:
// src/unsafe/unsafe.go:68
// (3) Conversion of a Pointer to a uintptr and back, with arithmetic.
//
// If p points into an allocated object, it can be advanced through the object
// by conversion to uintptr, addition of an offset, and conversion back to Pointer.
//
// p = unsafe.Pointer(uintptr(p) + offset)If you stash the uintptr in a variable in between, the collector is free to
move the object and you get a dangling pointer. This is the single most
common way people get unsafe wrong, and it is why go vet has a
unsafeptr check for it.
Pattern 4: pass a pointer as a uintptr to a syscall, in the argument
list, because the compiler knows to keep the object alive across the call.
Pattern 5: convert the result of reflect.Value.Pointer() back
immediately, in the same expression.
Pattern 6: use reflect.SliceHeader / reflect.StringHeader, which are
only valid when pointing at an actual slice or string.
The modern intrinsics
The unsafe package has grown a set of functions that are much safer than
the raw uintptr dance, and these are what you should reach for first:
// src/unsafe/unsafe.go:229
// The function Slice returns a slice whose underlying array starts at ptr
// and whose length and capacity are len.
// Slice(ptr, len) is equivalent to
//
// (*[len]ArbitraryType)(unsafe.Pointer(ptr))[:]
//
// except that, as a special case, if ptr is nil and len is zero,
// Slice returns nil.
//
// The len argument must be of integer type or an untyped constant.
// A constant len argument must be non-negative and representable by a value of type int;
// if it is an untyped constant it is given type int.
// At run time, if len is negative, or if ptr is nil and len is not zero,
// a run-time panic occurs.
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
// SliceData returns a pointer to the underlying array of the argument
// slice.
// - If cap(slice) > 0, SliceData returns &slice[:1][0].
// - If slice == nil, SliceData returns nil.
// - Otherwise, SliceData returns a non-nil pointer to an
// unspecified memory address.
func SliceData(slice []ArbitraryType) *ArbitraryTypeunsafe.Slice is the modern replacement for the (*[n]T)(ptr)[:] idiom. It
takes a *T rather than an unsafe.Pointer, which means the compiler knows
the element type and can size the resulting slice correctly. unsafe.SliceData
is the inverse, and unsafe.StringData / unsafe.String do the same job for
strings.
None of these are ordinary Go functions. They have no bodies. The compiler gives them dedicated opcodes:
// src/cmd/compile/internal/typecheck/typecheck.go:456
case ir.OUNSAFESLICE:
n := n.(*ir.BinaryExpr)
return tcUnsafeSlice(curfunc, n)
case ir.OUNSAFESLICEDATA:
n := n.(*ir.UnaryExpr)
return tcUnsafeData(curfunc, n)
case ir.OUNSAFESTRING:
n := n.(*ir.BinaryExpr)
return tcUnsafeString(curfunc, n)
case ir.OUNSAFESTRINGDATA:
n := n.(*ir.UnaryExpr)
return tcUnsafeData(curfunc, n)The type checker enforces the preconditions:
// src/cmd/compile/internal/typecheck/func.go:767
// tcUnsafeSlice typechecks an OUNSAFESLICE node.
func tcUnsafeSlice(curfunc *ir.Func, n *ir.BinaryExpr) *ir.BinaryExpr {
n.X = Expr(curfunc, n.X)
n.Y = Expr(curfunc, n.Y)
if n.X.Type() == nil || n.Y.Type() == nil {
n.SetType(nil)
return n
}
t := n.X.Type()
if !t.IsPtr() {
base.Errorf("first argument to unsafe.Slice must be pointer; have %L", t)
} else if t.Elem().NotInHeap() {
// TODO(mdempsky): This can be relaxed, but should only affect the
// Go runtime itself. End users should only see not-in-heap
// types due to incomplete C structs in cgo, and those types don't
// have a meaningful size anyway.
base.Errorf("unsafe.Slice of incomplete (or unallocatable) type not allowed")
}And then the compiler inlines the runtime check rather than calling it, to avoid the call overhead:
// src/cmd/compile/internal/walk/builtin.go:783
func (w *walkState) walkUnsafeSlice(n *ir.BinaryExpr, init *ir.Nodes) ir.Node {
ptr := w.safeExpr(n.X, init)
len := w.safeExpr(n.Y, init)
sliceType := n.Type()
lenType := types.Types[types.TINT64]
unsafePtr := typecheck.Conv(w.curfunc, ptr, types.Types[types.TUNSAFEPTR])
// If checkptr enabled, call runtime.unsafeslicecheckptr to check ptr and len.
// for simplicity, unsafeslicecheckptr always uses int64.
// Type checking guarantees that TIDEAL len/cap are positive and fit in an int.
// The case of len or cap overflow when converting TUINT or TUINTPTR to TINT
// will be handled by the negative range checks in unsafeslice during runtime.
if ir.ShouldCheckPtr(w.curfunc, 1) {
fnname := "unsafeslicecheckptr"
fn := typecheck.LookupRuntime(fnname)
init.Append(w.mkcall1(fn, nil, init, reflectdata.UnsafeSliceElemRType(w.curfunc, base.Pos, n), unsafePtr, typecheck.Conv(w.curfunc, len, lenType)))
} else {
// Otherwise, open code unsafe.Slice to prevent runtime call overhead.
// Keep this code in sync with runtime.unsafeslice{,64}The comment “Keep this code in sync with runtime.unsafeslice{,64}” is a loose end the maintainers knowingly accept: the fast path is open-coded in the compiler, and the slow path lives in the runtime, and they have to agree.
// src/runtime/unsafe.go:53
// Keep this code in sync with cmd/compile/internal/walk/builtin.go:walkUnsafeSlice
func unsafeslice(et *_type, ptr unsafe.Pointer, len int) {
if len < 0 {
panicunsafeslicelen1(sys.GetCallerPC())
}
if et.Size_ == 0 {
if ptr == nil && len > 0 {
panicunsafeslicenilptr1(sys.GetCallerPC())
}
}
mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
if overflow || mem > -uintptr(ptr) {
if ptr == nil {
panicunsafeslicenilptr1(sys.GetCallerPC())
}
panicunsafeslicelen1(sys.GetCallerPC())
}
}The mem > -uintptr(ptr) test is the interesting one: it checks that the
slice would not extend past the end of the address space, which catches the
classic “I multiplied a length by a size and it wrapped around” bug.
checkptr: the runtime’s unsafe sanity checker
Go has a mode that checks the unsafe rules at runtime. It is called
checkptr, and it is enabled by -d=checkptr, by -race, and by
-msan/-asan:
// src/cmd/compile/internal/base/flag.go:355
if Flag.Race || Flag.MSan || Flag.ASan {
// -race, -msan and -asan imply -d=checkptr for now.
if Debug.Checkptr == -1 { // if not set explicitly
Debug.Checkptr = 1
}
}It catches two classes of bug. The first is a conversion that produces a misaligned or out-of-bounds pointer:
// src/runtime/checkptr.go:9
func checkptrAlignment(p unsafe.Pointer, elem *_type, n uintptr) {
// nil pointer is always suitably aligned (#47430).
if p == nil {
return
}
// Check that (*[n]elem)(p) is appropriately aligned.
// Note that we allow unaligned pointers if the types they point to contain
// no pointers themselves. See issue 37298.
// TODO(mdempsky): What about fieldAlign?
if elem.Pointers() && uintptr(p)&(uintptr(elem.Align_)-1) != 0 {
throw("checkptr: misaligned pointer conversion")
}
// Check that (*[n]elem)(p) doesn't straddle multiple heap objects.
// TODO(mdempsky): Fix #46938 so we don't need to worry about overflow here.
if checkptrStraddles(p, n*elem.Size_) {
throw("checkptr: converted pointer straddles multiple allocations")
}
}The second is pointer arithmetic that wanders off into a different allocation:
// src/runtime/checkptr.go:50
func checkptrArithmetic(p unsafe.Pointer, originals []unsafe.Pointer) {
if 0 < uintptr(p) && uintptr(p) < minLegalPointer {
throw("checkptr: pointer arithmetic computed bad pointer value")
}
// Check that if the computed pointer p points into a heap
// object, then one of the original pointers must have pointed
// into the same object.
base := checkptrBase(p)
if base == 0 {
return
}
for _, original := range originals {
if base == checkptrBase(original) {
return
}
}
throw("checkptr: pointer arithmetic result points to invalid allocation")
}The compiler injects the calls. For unsafe.Slice and unsafe.String, the
runtime versions do a stricter check that the result does not straddle two
heap objects:
// src/runtime/unsafe.go:83
func unsafeslicecheckptr(et *_type, ptr unsafe.Pointer, len64 int64) {
unsafeslice64(et, ptr, len64)
// Check that underlying array doesn't straddle multiple heap objects.
// unsafeslice64 has already checked for overflow.
if checkptrStraddles(ptr, uintptr(len64)*et.Size_) {
throw("checkptr: unsafe.Slice result straddles multiple allocations")
}
}And there is an escape hatch for the runtime itself, which cannot afford the checks:
// src/runtime/mcleanup.go:75
//go:nocheckptr
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup {
// This is marked nocheckptr because checkptr doesn't understand the
// pointer manipulation done when looking at closure pointers.
// Similar code in mbitmap.go works because the functions are
// go:nosplit, which implies go:nocheckptr (CL 202158).//go:nocheckptr on a function tells the compiler not to instrument it. It is
also implied by //go:nosplit.
//go:linkname: calling things you were not invited to call
//go:linkname tells the compiler to give a symbol a different link-time
name, or to look up a symbol under a different name. The syntax is:
// src/cmd/compile/internal/noder/noder.go:281
case strings.HasPrefix(text, "go:linkname "), strings.HasPrefix(text, "go:linknamestd "):
f := strings.Fields(text)
if !(2 <= len(f) && len(f) <= 3) {
p.error(syntax.Error{Pos: pos, Msg: fmt.Sprintf("usage: //%s localname [linkname]", f[0])})
break
}
// The second argument is optional. If omitted, we use
// the default object symbol name for this and
// linkname only serves to mark this symbol as
// something that may be referenced via the object
// symbol name from another package.
var target string
if len(f) == 3 {
target = f[2]
} else if base.Ctxt.Pkgpath != "" {
// Use the default object symbol name if the
// user didn't provide one.
target = objabi.PathToPrefix(base.Ctxt.Pkgpath) + "." + f[1]
} else {
panic("missing pkgpath")
}
p.linknames = append(p.linknames, linkname{pos, f[0] == "go:linknamestd", f[1], target})There are two directions. A push is //go:linkname localname other.name
on a declaration in the defining package, which publishes a symbol under a
different name. A pull is a bodyless declaration with a //go:linkname
in the using package, which imports the symbol by that name.
The runtime uses pushes extensively, to expose internal machinery to packages that need it:
// src/runtime/linkname.go:9
// used in internal/godebug and syscall
//go:linkname write
// used by cgo
//go:linkname _cgo_panic_internal
//go:linkname cgoAlwaysFalse
//go:linkname cgoUse
//go:linkname cgoKeepAlive
//go:linkname cgoCheckPointer
//go:linkname cgoCheckResult
//go:linkname cgoNoCallback
//go:linkname gobytes
//go:linkname gostringn
// used in plugin
//go:linkname doInit
// used in math/bits
//go:linkname overflowError
//go:linkname divideErrorA one-argument //go:linkname foo is a push that keeps the default symbol
name; it exists purely to say “this symbol may be referenced by name from
another package”.
The hall of shame
Some of these pushes exist only because third-party code broke in and the Go team decided to accommodate rather than break. The runtime marks those symbols explicitly, and the comment format is consistent enough to grep for:
// src/runtime/map.go:30
// makemap_small implements Go map creation for make(map[k]v) and
// make(map[k]v, hint) when hint is known to be at most abi.MapGroupSlots
// at compile time and the map needs to be allocated on the heap.
//
// makemap_small should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
// - github.com/bytedance/sonic
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname makemap_small
func makemap_small() *maps.Map {
return maps.NewEmptyMap()
}The instruction is explicit: do not remove or change the type signature. The signature is now an ABI contract with half a dozen high-profile libraries, whether those libraries like it or not.
The pattern extends well beyond maps. The runtime maintains a whole file of compatibility shims for the Go 1.24 map iterator rewrite:
// src/runtime/linkname_shim.go:60
type linknameIter struct {
// Fields from hiter.
key unsafe.Pointer
elem unsafe.Pointer
typ *abi.MapType
// The real iterator.
it *maps.Iter
}with a comment explaining that the shim’s layout has to match the old
hiter struct exactly, or the collector will not track the pointers in it
correctly:
Our structure must maintain compatibility with the old structure. This means:
- Our structure must be the same size or smaller than hiter. Otherwise we may write outside the caller’s hiter allocation.
- Our structure must have the same pointer layout as hiter, so that the GC tracks pointers properly.
The full list of shims in that file includes mapiterinit, mapiternext,
reflect_mapiterinit, reflect_mapiternext, reflect_mapiterkey and
reflect_mapiterelem — six symbols that exist only so that
github.com/bytedance/sonic, github.com/segmentio/encoding,
github.com/ugorji/go/codec and others keep working.
The linker enforces who may pull what
Go 1.23 tightened this substantially. The linker now checks every linkname reference against an explicit allowlist:
// src/cmd/link/internal/loader/loader.go:2438
// A list of blocked linknames. Some linknames are allowed only
// in specific packages. This maps symbol names to allowed packages.
// If a name is not in this map, it is allowed iff the definition
// has a linkname (push).
// If a name is in this map, it is allowed only in listed packages,
// even if it has a linknamed definition.
var blockedLinknames = map[string][]string{
// fips info
"go:fipsinfo": {"crypto/internal/fips140/check"},
// New internal linknames in Go 1.24
// Pushed from runtime
"crypto/internal/fips140.fatal": {"crypto/internal/fips140"},
"crypto/internal/fips140.getIndicator": {"crypto/internal/fips140"},
"crypto/internal/fips140.setIndicator": {"crypto/internal/fips140"},
"crypto/internal/sysrand.fatal": {"crypto/internal/sysrand"},
"crypto/rand.fatal": {"crypto/rand"},
"internal/runtime/maps.errNilAssign": {"internal/runtime/maps"},
"internal/runtime/maps.fatal": {"internal/runtime/maps"},
"internal/runtime/maps.newarray": {"internal/runtime/maps"},
"internal/runtime/maps.newobject": {"internal/runtime/maps"},
"internal/runtime/maps.rand": {"internal/runtime/maps"},
"internal/runtime/maps.typedmemclr": {"internal/runtime/maps"},
"internal/runtime/maps.typedmemmove": {"internal/runtime/maps"},and at the bottom, the entry that is allowed from nowhere at all:
// src/cmd/link/internal/loader/loader.go:2527
"runtime.addmoduledata": {}, // assembly symbol, disallow all packages
}The check itself:
// src/cmd/link/internal/loader/loader.go:2530
// check if a linkname reference to symbol s from refpkg is allowed
func (l *Loader) checkLinkname(refpkg *oReader, name string, s Sym) {
if l.flags&FlagCheckLinkname == 0 {
return
}
pkg := refpkg.unit.Lib.Pkg
error := func() {
log.Fatalf("%s: invalid reference to %s", pkg, name)
}
pkgs, ok := blockedLinknames[name]
if ok {
for _, p := range pkgs {
if pkg == p {
return // pkg is allowed
}
// crypto/internal/fips140/vX.Y.Z/... is the frozen version of
// crypto/internal/fips140/... and is similarly allowed.
if strings.HasPrefix(pkg, "crypto/internal/fips140/v") {
parts := strings.Split(pkg, "/")
parts = append(parts[:3], parts[4:]...)
pkg := strings.Join(parts, "/")
if pkg == p {
return
}
}
}
error()
}The default rule, stated in the comment above the map, is the important one: if a name is not in this map, it is allowed iff the definition has a linkname (push). So the ordinary case still works — a package that pushes a symbol can be pulled by anyone. The map is the exception list, for symbols the runtime wants to keep to itself.
And there is a second tier, for symbols pushed with //go:linknamestd,
which are restricted to the standard library:
// src/cmd/link/internal/loader/loader.go:2599
if osym.IsLinknameStd() {
// It is pushed with linknamestd. Allow only pulls from the
// standard library.
if refpkg.Std() {
return
}
}
if osym.IsLinkname() {
// Allow if the def has a linkname (push).
return
}
error()
}//go:linknamestd is the newer, stricter directive. A symbol pushed with it
can only be pulled from within the standard library. That is how the runtime
can expose internal machinery to internal/... packages without opening it
to the entire ecosystem.
The whole check is behind -checklinkname, which is on by default for
standard-library builds:
// src/cmd/link/internal/ld/lib.go:531
if *flagCheckLinkname {
flags |= loader.FlagCheckLinkname
}Dead code elimination and method pruning
Now, the part that connects //go:linkname to binary size. The linker
performs a reachability analysis from the entry point and deletes everything
that is unreachable. But it cannot simply delete every method of every type,
because reflection can find methods at runtime by name.
The linker’s dead code pass describes the problem and the three ways a method can be invoked:
// src/cmd/link/internal/ld/deadcode.go:402
// deadcode marks all reachable symbols.
//
// The basis of the dead code elimination is a flood fill of symbols,
// following their relocations, beginning at *flagEntrySymbol.
//
// This flood fill is wrapped in logic for pruning unused methods.
// All methods are mentioned by relocations on their receiver's *rtype.
// These relocations are specially defined as R_METHODOFF by the compiler
// so we can detect and manipulated them here.
//
// There are three ways a method of a reachable type can be invoked:
//
// 1. direct call
// 2. through a reachable interface type
// 3. reflect.Value.Method (or MethodByName), or reflect.Type.Method
// (or MethodByName)
//
// The first case is handled by the flood fill, a directly called method
// is marked as reachable.
//
// The second case is handled by decomposing all reachable interface
// types into method signatures. Each encountered method is compared
// against the interface method signatures, if it matches it is marked
// as reachable. This is extremely conservative, but easy and correct.
//
// The third case is handled by looking for functions that compiler flagged
// as REFLECTMETHOD. REFLECTMETHOD on a function F means that F does a method
// lookup with reflection, but the compiler was not able to statically determine
// the method name.
//
// All functions that call reflect.Value.Method or reflect.Type.Method are REFLECTMETHODs.
// Functions that call reflect.Value.MethodByName or reflect.Type.MethodByName with
// a non-constant argument are REFLECTMETHODs, too. If we find a REFLECTMETHOD,
// we give up on static analysis, and mark all exported methods of all reachable
// types as reachable.
//
// If the argument to MethodByName is a compile-time constant, the compiler
// emits a relocation with the method name. Matching methods are kept in all
// reachable types.
//
// Any unreached text symbols are removed from ctxt.Textp.The “REFLECTMETHOD” flag is set by the compiler, in two places. One is when
you call MethodByName with a non-constant argument:
// src/cmd/compile/internal/walk/expr.go:1146
} else {
w.curfunc.LSym.Set(obj.AttrReflectMethod, true)
}The other is when a function value of reflect.Value.Method is taken:
// src/cmd/compile/internal/staticdata/data.go:291
// A function value of reflect.Value.Method or MethodByName may be
// called with a method name the compiler cannot see; see usemethod.
if s.Pkg.Path == "reflect" && (s.Name == "Value.Method" || s.Name == "Value.MethodByName") {
sf.Set(obj.AttrReflectMethod, true)
}The linker reads that flag:
// src/cmd/link/internal/loader/loader.go:1252
// Returns whether the i-th symbol has ReflectMethod attribute set.
func (l *Loader) IsReflectMethod(i Sym) bool {
return l.SymAttr(i)&goobj.SymFlagReflectMethod != 0
}and uses it to decide whether to give up on pruning:
// src/cmd/link/internal/ld/deadcode.go:460
rem := d.markableMethods[:0]
for _, m := range d.markableMethods {
if (d.reflectSeen && (m.isExported() || d.dynlink)) || d.ifaceMethod[m.m] || d.genericIfaceMethod[m.m.name] {
d.markMethod(m)
} else {
rem = append(rem, m)
}
}Read that condition carefully. If any function in the program does a
non-constant MethodByName, then every exported method of every reachable
type is kept. One reflect-by-name call anywhere in your dependency tree
can cost you a substantial fraction of your binary size. That is a real and
frequently surprising effect.
There is a mitigation. If the argument to MethodByName is a compile-time
constant, the compiler emits a relocation naming the method, and the linker
can keep only the matching methods. That is the R_USENAMEDMETHOD
relocation visible in the same compiler file.
Trade-offs
What the escape hatches buy you:
- Zero-copy reinterpretation.
unsafe.Sliceandunsafe.Stringlet you build a view over memory you already have without copying it. - Access to runtime internals. The standard library’s own packages use
//go:linknameto share machinery that is not part of the public API. - Interoperability. Talking to C, to the kernel, or to a memory-mapped
device requires
unsafe.Pointersomewhere. - A working
reflect.reflectitself is a large pile ofunsafe.
What they cost:
- The compiler stops helping. No bounds checks, no nil checks, no escape analysis guarantees you can rely on.
- The collector stops helping. A
uintptrin a variable is not a reference. The object can move, or be collected, while you hold it. - You break the compatibility promise. The
unsafepackage’s own docs say so: “Packages that import unsafe may be non-portable and are not protected by the Go 1 compatibility guidelines.” - You break the linker. A linkname pull from a package that is not
allowed is a hard
log.Fatalfat link time, not a compile error. - You opt out of method pruning. One
MethodByNamewith a non-constant argument keeps every exported method of every reachable type.
Caveats
unsafe code is not “mostly safe”. The rules in the package doc are
precise, and the moment you deviate from them the failure is silent
corruption rather than a panic.
checkptr is not on by default. -race turns it on, but a normal
production build does not check anything. If your code depends on unsafe
being correct, test it with -race or -d=checkptr=1.
//go:nocheckptr silently disables the checks for that function. It is
used sparingly in the runtime and it is exactly the sort of thing you should
not reach for.
Linkname pulls are checked at link time, not compile time. The compiler
will happily compile //go:linkname foo runtime.someInternalThing and you
will find out when the linker says invalid reference to runtime.someInternalThing.
The allowlist changes. The blockedLinknames map is updated every
release, with sections marked “New internal linknames in Go 1.24”,
“Go 1.25”, “Go 1.26”. A linkname that works today can stop working.
//go:linknamestd is the one to prefer if you are writing a package
that legitimately needs a runtime internal. It restricts the pull to the
standard library, so you are not opening your package’s internals to the
world.
Do not linkname into the runtime from a third-party package. It works until it does not. The Go team’s position is visible in the runtime’s own comments: “Do not remove or change the type signature. See go.dev/issue/67401.” That is a promise made under duress.
Wrapping up
The one thing to remember: unsafe.Pointer is where the type system
ends and //go:linkname is where the package boundary ends. Both are
deliberate, documented holes, and both are policed by machinery that has got
much stricter over the last few releases.
The part people get wrong: unsafe.Pointer converted to uintptr and
stored in a variable is no longer a pointer. The garbage collector does not
update it and does not keep the object alive. The conversion has to happen
inside a single expression.
What to go do about it: turn on -race in your test suite. It implies
-d=checkptr, it catches misaligned and straddling unsafe conversions,
and it costs you nothing in production. If you have a MethodByName with a
non-constant argument anywhere in your dependency tree, replace it with a
constant one — you may be keeping a lot of dead methods alive.
Further reading
src/unsafe/unsafe.gosrc/runtime/unsafe.gosrc/runtime/checkptr.gosrc/runtime/map.gosrc/runtime/linkname.gosrc/runtime/linkname_shim.gosrc/runtime/linkname_unix.gosrc/cmd/compile/internal/noder/noder.gosrc/cmd/compile/internal/typecheck/func.gosrc/cmd/compile/internal/walk/builtin.gosrc/cmd/compile/internal/walk/expr.gosrc/cmd/compile/internal/staticdata/data.gosrc/cmd/compile/internal/base/flag.gosrc/cmd/link/internal/loader/loader.gosrc/cmd/link/internal/ld/deadcode.go