Skip to content
academia.sh

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 malloc calls on the C side.
  • A package with a native call did not compile with CGO_ENABLED=0 and 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close