Lesson 16 / 16
C Interoperability
The same task is compared in two forms — the language's own code and a C function called through cgo — and both give the same allocation count (1). The package with the native call does not compile with CGO_ENABLED=0, does not cross-compile, and escape analysis makes no statement about the C function's body (0/21 lines). Limiting measurement: memory not freed on the C side shows up in neither the analyzer nor the allocation count — the cost is not in the call itself, but in what the tool can say. The course, and the M09 Go curriculum, close with this lesson through a sixteen-row table.
Published
In this lesson
- Compares two forms of the same task — the language's own code and a C function called through cgo — by allocation count
- Measures a package with a native library call for compilability with CGO_ENABLED=0 and for cross-compilation
- Shows, by counting, that escape analysis produces no statement about the C function's body
- Gathers the course's and the M09 Go curriculum's closing table from fifteen sibling lessons
Contents
The previous lesson measured that an analyzer coming out clean does not mean flawless, only that the searched pattern was absent — its silence was a limit of the rule list, not proof. This lesson carries that limit further: once a Go program steps outside the language’s boundary into another language’s code, the compiler, the analyzer, and the profiler can no longer always reach it. The question is narrow: through which tool, with which number, does crossing that boundary’s cost become visible?
C here is not a product or a library brand — it is a language, its name written the same way
Go’s own is. But the mechanism used throughout this lesson, cgo, carries a dependency the rest of
the course never carried: the source only compiles with a genuine C compiler present and
CGO_ENABLED on. BD19. This lesson carries that dependency as an open assumption; without a
compiler, or with CGO_ENABLED off, the blocks do not compile at all. None of the course’s
fifteen sibling lessons needed this second chain.
cgo: A Call That Crosses the Language’s Boundary
The standard library opens this crossing through a pseudo-package: right before import "C", a
gapless comment block carries genuine C source, which the compiler hands to a C compiler and
binds back to the Go side. The native.go below defines a small C function (upperC) that
upper-cases a string using <string.h> and <ctype.h>, called from two Go wrappers.
// native.go — a native library wrapper calling a function written in C through cgo
package main
/*
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
char* upperC(const char* s) {
size_t n = strlen(s);
char* result = (char*)malloc(n + 1);
for (size_t i = 0; i < n; i++) {
result[i] = toupper((unsigned char)s[i]);
}
result[n] = 0;
return result;
}
*/
import "C"
import "unsafe"
// upperClean passes the string to the C side's upperC function and gets the result back;
// it frees the result buffer allocated on the C side.
func upperClean(s string) string {
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))
result := C.upperC(cs)
defer C.free(unsafe.Pointer(result))
return C.GoString(result)
}
// upperLeaking makes the same call but never frees the C side's result buffer.
func upperLeaking(s string) string {
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))
result := C.upperC(cs)
return C.GoString(result)
}
C.CString copies the given Go string with C’s own allocator (malloc) — a region Go’s
garbage collector never tracks. C.GoString works the other way: it reads bytes on the C side
and builds a new string on Go’s heap. upperClean frees both C buffers (cs, result) with
C.free; upperLeaking never frees the second — measured in a later section.
The rest of this lesson reuses the same two routines the previous lessons shared: setupModule
sets up a temporary module, runTool runs the language’s own command over it.
// tools.go — the helpers this lesson shares: setting up a temporary package, calling the tool, running five times
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
)
// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
fmt.Printf("%-48s %-7s %s\n", info, speaker, evidence)
}
// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
dir, err := os.MkdirTemp("", "nativelibrary")
if err != nil {
return ""
}
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module nativetrial\n\ngo 1.24\n"), 0o644)
for name, content := range files {
os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
}
return dir
}
// runTool runs the language's own command, returning whether the exit succeeded; output is never printed.
func runTool(dir string, extraEnv []string, arg ...string) (string, bool) {
cmd := exec.Command("go", arg...)
cmd.Dir = dir
cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local"), extraEnv...)
output, err := cmd.CombinedOutput()
return string(output), err == nil
}
// fiveRuns runs the same benchmark five times, returning the first value and how
// many distinct values were seen — the course's stability pattern for proving a number.
func fiveRuns(f func(*testing.B)) (int64, int) {
values := map[int64]bool{}
var first int64
for i := 0; i < 5; i++ {
r := testing.Benchmark(f)
values[r.AllocsPerOp()] = true
if i == 0 {
first = r.AllocsPerOp()
}
}
return first, len(values)
}
Same Task, Two Forms: Allocation Count
The pattern from the course’s Benchmarking lesson continues unchanged: two forms of
the same task, each across five runs, compared only by allocation count — one the language’s own
code (strings.ToUpper), the other a native library call through upperClean.
// measurement1.go — same task, two forms: the language's own code and a native library call, compared by allocation count
package main
import (
"fmt"
"strings"
"testing"
)
var sampleString = "screw-nut-washer"
var sink string
//go:noinline
func benchGo(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = strings.ToUpper(sampleString)
}
}
//go:noinline
func benchNative(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = upperClean(sampleString)
}
}
func main() {
fmt.Println("-- same task, two forms: allocation count --")
goAllocs, goDistinct := fiveRuns(benchGo)
nativeAllocs, nativeDistinct := fiveRuns(benchNative)
show("language's own code (strings.ToUpper)", "run", fmt.Sprint(goAllocs, " distinct value across five runs=", goDistinct))
show("native library call (cgo)", "run", fmt.Sprint(nativeAllocs, " distinct value across five runs=", nativeDistinct))
}
-- same task, two forms: allocation count -- language's own code (strings.ToUpper) run 1 distinct value across five runs=1 native library call (cgo) run 1 distinct value across five runs=1
BD20. Both forms reach the same number, one, unchanged across five runs. This could read as
“the native call has no cost,” but upperClean‘s body does two things strings.ToUpper does
not: it copies the string into C’s allocator (CString), allocates and fills a second buffer on
the C side (upperC), and frees both. The single 1 only counts the final copy GoString
writes onto Go’s heap — C’s malloc calls never enter this number, since the measuring tool
tracks only its own heap.
The Tool Knowledge a Native Call Costs
How much of what the course writes to the tool class — compilability, cross-compilation, the
compiler’s decision — survives once a native call is added? The measurement below writes
nativeSource to a temporary module and asks three things: does it compile with
CGO_ENABLED=0, does it cross-compile for a platform with no copy (linux/arm64), and how many
escape diagnostics belong to the Go side versus upperC.
// measurement2.go — the tool knowledge a native call costs: compilability, cross-compilation, escape boundary
package main
import (
"fmt"
"os"
"strings"
)
const nativeSource = "package main\n\n/*\n#include <string.h>\n#include <ctype.h>\n#include <stdlib.h>\n\nchar* upperC(const char* s) {\n\tsize_t n = strlen(s);\n\tchar* result = (char*)malloc(n + 1);\n\tfor (size_t i = 0; i < n; i++) result[i] = toupper((unsigned char)s[i]);\n\tresult[n] = 0;\n\treturn result;\n}\n*/\nimport \"C\"\nimport \"unsafe\"\n\nfunc upperNative(s string) string {\n\tcs := C.CString(s)\n\tdefer C.free(unsafe.Pointer(cs))\n\tresult := C.upperC(cs)\n\tdefer C.free(unsafe.Pointer(result))\n\treturn C.GoString(result)\n}\n\nfunc main() {\n\t_ = upperNative(\"x\")\n}\n"
func main() {
dir := setupModule(map[string]string{"native.go": nativeSource})
fmt.Println("-- the tool knowledge a native call costs --")
_, disabledCompiled := runTool(dir, []string{"CGO_ENABLED=0"}, "build", "-o", os.DevNull, ".")
show("compiled with CGO_ENABLED=0", "tool", fmt.Sprint(disabledCompiled))
_, crossCompiled := runTool(dir, []string{"CGO_ENABLED=1", "GOOS=linux", "GOARCH=arm64"}, "build", "-o", os.DevNull, ".")
show("compiled for the linux/arm64 target", "tool", fmt.Sprint(crossCompiled))
output, _ := runTool(dir, []string{"CGO_ENABLED=1"}, "build", "-gcflags=-m", "-o", os.DevNull, ".")
lines := strings.Split(strings.TrimSpace(output), "\n")
goSide := 0
for _, l := range lines {
if strings.Contains(l, "native.go") {
goSide++
}
}
cSide := strings.Count(output, "upperC")
show("total escape/compile diagnostic lines", "tool", fmt.Sprint(len(lines)))
show("diagnostics tied to a native.go line (Go side)", "tool", fmt.Sprint(goSide))
show("diagnostics tied to upperC's body (C side)", "tool", fmt.Sprint(cSide))
}
-- the tool knowledge a native call costs -- compiled with CGO_ENABLED=0 tool false compiled for the linux/arm64 target tool false total escape/compile diagnostic lines tool 21 diagnostics tied to a native.go line (Go side) tool 11 diagnostics tied to upperC's body (C side) tool 0
BD21. The package does not compile with CGO_ENABLED=0: a file carrying import "C" never
enters the build once cgo is off — another form of the Build Tags lesson’s “a file stays outside
the build,” this time chosen by an environment variable, not a comment line. BD22. The
Cross-Compilation lesson measured that a source with no platform-specific call compiles on all
three targets; here the uncopied linux/arm64 build fails, because what is needed is not Go’s
own cross-compiler but that target’s own C compiler, missing from this environment.
BD23. Eleven of twenty-one escape diagnostics tie back to a line in native.go. None —
zero — carry upperC‘s name: not because the compiler ignores this function, but because it
genuinely cannot see it — escape analysis runs over Go’s own syntax tree, and upperC’s body
is C. The compiler decides up to the boundary (the cs and result pointers) and asks nothing
beyond it.
Limiting Measurement: The Cost Is Not in the Call Itself
The measurements above point the same way: escape analysis does not see past the boundary,
compilability depends on a C compiler, allocation count counts only Go’s own heap. This final
measurement joins all three into a sharper example: the only difference between upperClean and
upperLeaking is whether the C-side result buffer gets freed — a genuine leak in C’s own heap.
// boundary.go — limiting measurement: the cost is not in the call itself, but in what the tool can say
package main
import (
"fmt"
"testing"
)
const leakingSource = "package main\n\n/*\n#include <string.h>\n#include <ctype.h>\n#include <stdlib.h>\n\nchar* upperC(const char* s) {\n\tsize_t n = strlen(s);\n\tchar* result = (char*)malloc(n + 1);\n\tfor (size_t i = 0; i < n; i++) result[i] = toupper((unsigned char)s[i]);\n\tresult[n] = 0;\n\treturn result;\n}\n*/\nimport \"C\"\nimport \"unsafe\"\n\nfunc upperLeaking(s string) string {\n\tcs := C.CString(s)\n\tdefer C.free(unsafe.Pointer(cs))\n\tresult := C.upperC(cs)\n\treturn C.GoString(result)\n}\n\nfunc main() {\n\t_ = upperLeaking(\"x\")\n}\n"
var sink string
var sampleString = "screw-nut-washer"
//go:noinline
func benchClean(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = upperClean(sampleString)
}
}
//go:noinline
func benchLeaking(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = upperLeaking(sampleString)
}
}
func main() {
fmt.Println("-- limiting measurement: does a memory leak show up when run --")
cleanAllocs, cleanDistinct := fiveRuns(benchClean)
leakingAllocs, leakingDistinct := fiveRuns(benchLeaking)
show("allocation count of the freeing version", "run", fmt.Sprint(cleanAllocs, " distinct value across five runs=", cleanDistinct))
show("allocation count of the leaking version", "run", fmt.Sprint(leakingAllocs, " distinct value across five runs=", leakingDistinct))
dir := setupModule(map[string]string{"native.go": leakingSource})
_, clean := runTool(dir, []string{"CGO_ENABLED=1"}, "vet", "./...")
show("analyzer finds the leaking version clean too", "tool", fmt.Sprint(clean))
}
-- limiting measurement: does a memory leak show up when run -- allocation count of the freeing version run 1 distinct value across five runs=1 allocation count of the leaking version run 1 distinct value across five runs=1 analyzer finds the leaking version clean too tool true
BD24. The two numbers are identical — freeing and leaking both give one allocation, a single
value across five runs. Because the allocation count tracks only Go’s heap, the leak growing on
the C side is never visible in it. The analyzer gives the same silence: vet finds the leaking
version clean too, since none of its patterns cover failing to free a C call’s result — nor could
it, the leak sitting inside upperC‘s body, entirely outside Go’s syntax. The course’s measuring
axis reaches its own boundary here: all three answers (source, tool, run) stayed within one
boundary, never reaching the C compiler’s decisions beyond it. The cost is not in the call
itself, but in what the tool can say: neither compiler, analyzer, nor profiler sees past the
boundary.
Course Wrap-Up
This course carried a single question across sixteen lessons: who states a fact about a program — the source, a tool, or a run? The shared definition’s three claims added three layers. First, most of what can be known about a program is not written in the source: only two of eleven facts sit in the text, the rest come from a tool or a run. Second, tool and run knowledge do not carry the same reliability — a tool needs no run and gives the same answer every call, while run knowledge must be separately tested for staying the same. Third, specific to this course: of the the benchmark’s two numbers, only the allocation count is repeatable; duration was never written, printed, or measured.
This final lesson carried the same three classes past the language’s boundary: escape analysis says nothing about the C function’s body, the analyzer does not see a leak in C, the allocation count does not count C’s allocator. The course’s reading of “a tool speaking is not the same as speaking truly” reaches its sharpest form here: past the boundary, both tool and run fall silent, and that silence is not proof of flawlessness — it is proof of a place the tool never looked.
The course closes without overreaching: what was measured was a single native library call, not the whole of cgo’s machinery; the same boundary can show up with a different tool at a different crossing. The M09 Go curriculum closes here too, and its five courses’ five questions grow from the same root: Go Fundamentals asked what stands in for the unwritten — defined, rejected, or left open; Interfaces, Errors and Generics asked where a behavior’s binding to a type is established; Concurrency asked how a goroutine stops; The Standard Library and Network Applications asked what happens to a value’s type information at a boundary; this course asked which source the knowledge comes from. All five converge on one observation: knowing a program takes more than reading its source.
| Lesson | Fact measured | Speaker | Limiting measurement |
|---|---|---|---|
| Test Package Basics | Whether the test passed | run | A passing test proves only what it tested, not correctness |
| Table-Driven Tests | Whether adding a case changed coverage | run | The sixth case left coverage fixed at 100% |
| HTTP Tests | Which status code a request turned into | run | The request address is fixed — the handler is tested, not the network |
| Faking and Interface Separation | The interface separation that makes faking possible | source | Under excessive reduction the real implementation rejected, the fake accepted |
| Benchmarking | The distribution of eleven facts, allocation count | source · tool · run (2/5/4) | Per-operation and total allocation are separate; conflating them misleads |
| Coverage Reporting | The coverage ratio three test sets gave | run | An uninspecting case gave 100% with flawed source too |
| Profiling | The allocation count of two call forms | run | A value allocated on a never-run branch is invisible in the profile |
| Escape Analysis | The decision whether a value is allocated on the stack or the heap | tool | Not the language’s promise — the compiler’s own implementation detail |
| Garbage Collection Behavior | The effect of three techniques on allocation count | run | A readable and an unreadable form reached the same number |
| Tracing | The event count of two setups | run | Collecting a trace changes behavior; numbers are read within that limit |
| Build Tags | Which files enter the build | tool | An outside file is not deleted, it stays unseen until tagged |
| Cross-Compilation | Whether the source compiles for three targets | tool | Compiled does not mean it runs — the assumption depends on the target |
| Linker Options | A sourceless value embedded into the binary | tool | The linker never validates the embedded value, it accepts emptiness too |
| Module Publishing | Whether the version is compatible at the signature level | source · tool | Same signature, different result — the promise is in signatures, not behavior |
| Static Analysis and Security Scanning | Whether the difference was caught by the analyzer | tool | A clean-looking pattern crashed when run — clean is not flawless |
| C Interoperability | Allocation count, compilability, escape visibility of a native call | tool · run | Leaked C memory shows up in neither the analyzer nor the allocation count |
Summary
- Two forms — the language’s own code, a C function called through cgo — reached the same
allocation count (one), but this equality never counted the two
malloccalls on the C side. - A package with a native call did not compile with
CGO_ENABLED=0and did not cross-compile for a target with no C compiler; the lesson carried this dependency as an open assumption. - Eleven of twenty-one escape diagnostics tied to the Go side, zero to the C function’s body — the compiler cannot see past the boundary, it does not ignore it.
- Limiting measurement: memory not freed on the C side shows up in neither the analyzer nor the allocation count; the cost is not in the call itself, it is in what the tool can say.
- The M09 Go curriculum closes with this lesson; the five courses’ five questions — what stands in for the unwritten, where the binding sits, how a goroutine stops, type information at a boundary, who states this fact — converge on one observation: knowing a program takes more than reading its source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.