PONYλM2Modula-2

Go.CodeCompared.To/C

An interactive executable cheatsheet comparing Go and C

Go 1.26.5 C17 (GCC)
Hello, World & Output
Hello, World
Both languages start at a function called main and both need their output machinery named explicitly — fmt in Go, stdio.h in C. The difference is that #include pastes the header's text into your file before compilation, while import resolves a package the compiler already knows how to find.
package main import "fmt" func main() { fmt.Println("Hello, World!") }
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
C's printf takes a format string and trusts you: the newline is yours to remember, and the number and types of the arguments are checked only because modern compilers special-case this one function. fmt.Println appends the newline, spaces its arguments, and asks each one how it wants to be printed. Neither is wrong; C is doing less on purpose.
Format Verbs and Their Consequences
Go's %v prints any value by asking it; C's conversion specifiers each name a machine type, and %ld versus %d is a real distinction you must get right.
package main import "fmt" func main() { count := 42 ratio := 1.5 name := "gopher" fmt.Printf("%d %.1f %s\n", count, ratio, name) fmt.Printf("%v %v %v\n", count, ratio, name) }
#include <stdio.h> int main(void) { int count = 42; double ratio = 1.5; const char *name = "gopher"; printf("%d %.1f %s\n", count, ratio, name); /* There is no %v. Every value's type must be named at the call site, and naming it wrong is undefined behavior rather than an error. */ printf("%d %.1f %s\n", count, ratio, name); return 0; }
The absence of %v is the whole difference. C's variadic arguments carry no type information at runtime, so printf believes the format string — pass a long where %d is expected and it reads the wrong number of bytes off the stack. Go's interface{} arguments carry their type with them, which is why %v can exist at all and why go vet can check the rest.
A Slice Is a Struct
A Slice Is Pointer, Length, Capacity
This is the row the rest of the page leans on. A Go slice header is three words — where the data starts, how many elements are live, and how many fit before it must move. The C column writes that struct out and passes it around exactly as Go does.
package main import "fmt" func main() { numbers := make([]int, 3, 8) numbers[0] = 10 numbers[1] = 20 numbers[2] = 30 fmt.Println(len(numbers), cap(numbers)) fmt.Println(numbers[1]) }
#include <stdio.h> #include <stdlib.h> /* This struct IS a Go slice header. */ typedef struct { long *data; size_t length; size_t capacity; } IntSlice; int main(void) { IntSlice numbers; numbers.capacity = 8; numbers.length = 3; numbers.data = malloc(numbers.capacity * sizeof(long)); if (numbers.data == NULL) return 1; numbers.data[0] = 10; numbers.data[1] = 20; numbers.data[2] = 30; printf("%zu %zu\n", numbers.length, numbers.capacity); printf("%ld\n", numbers.data[1]); free(numbers.data); return 0; }
Everything a slice does follows from those three fields. len and cap are field reads. Passing a slice copies twenty-four bytes, not the data — which is why a function can modify the elements but cannot make the caller's slice longer. And the capacity is what makes append usually free: there is already room.
append Is realloc, Then Copy
When a slice runs out of capacity, append allocates a bigger block, copies the old elements across, and returns a header pointing at the new one. The old block becomes garbage. The C column does each of those steps by name.
package main import "fmt" func main() { numbers := make([]int, 0, 2) for value := 1; value <= 4; value++ { numbers = append(numbers, value*10) fmt.Println(len(numbers), cap(numbers)) } fmt.Println(numbers) }
#include <stdio.h> #include <stdlib.h> typedef struct { long *data; size_t length; size_t capacity; } IntSlice; /* append: grow if needed, then place. Returns the new header, exactly like Go. */ static IntSlice append_value(IntSlice slice, long value) { if (slice.length == slice.capacity) { size_t grown = slice.capacity == 0 ? 1 : slice.capacity * 2; long *moved = realloc(slice.data, grown * sizeof(long)); if (moved == NULL) { free(slice.data); exit(1); } slice.data = moved; slice.capacity = grown; } slice.data[slice.length] = value; slice.length += 1; return slice; } int main(void) { IntSlice numbers = { malloc(2 * sizeof(long)), 0, 2 }; if (numbers.data == NULL) return 1; for (long value = 1; value <= 4; value++) { numbers = append_value(numbers, value * 10); printf("%zu %zu\n", numbers.length, numbers.capacity); } printf("["); for (size_t index = 0; index < numbers.length; index++) { printf("%ld", numbers.data[index]); if (index + 1 < numbers.length) printf(" "); } printf("]\n"); free(numbers.data); return 0; }
Writing numbers = append(numbers, …) rather than just append(numbers, …) stops being a wart once you have seen this: the function receives a copy of the header, and growing means pointing at different memory, so the new header has to be returned. The doubling is also visible — capacity goes 2, 4, 4, 8 — which is why appending in a loop is amortized cheap rather than quadratic.
Two Slices, One Array
Slicing does not copy. numbers[1:3] produces a new three-word header pointing into the same data, so writing through one slice is visible through the other. The C column makes the aliasing impossible to miss: the second struct's data is just an offset pointer.
package main import "fmt" func main() { numbers := []int{10, 20, 30, 40} window := numbers[1:3] window[0] = 99 fmt.Println(numbers) fmt.Println(window) }
#include <stdio.h> typedef struct { long *data; size_t length; } IntSlice; int main(void) { long backing[4] = { 10, 20, 30, 40 }; IntSlice numbers = { backing, 4 }; IntSlice window = { backing + 1, 2 }; /* numbers[1:3] — no copy */ window.data[0] = 99; printf("["); for (size_t index = 0; index < numbers.length; index++) { printf("%ld", numbers.data[index]); if (index + 1 < numbers.length) printf(" "); } printf("]\n"); printf("[%ld %ld]\n", window.data[0], window.data[1]); return 0; }
This is the single most common way Go surprises people, and seeing the pointer arithmetic makes it obvious rather than mysterious. It is also why append on a sliced-down slice can overwrite elements the original still refers to — there was spare capacity, so nothing needed to move. copy and the three-index slice numbers[1:3:3] exist to opt out of exactly this sharing.
Strings Carry a Length
A Go String Has No Terminator
A Go string is two words — a pointer and a length — and the bytes it points at are immutable. A C string is an address, and its end is the first zero byte, which nothing records and every operation must rediscover.
package main import "fmt" func main() { message := "Hello, World!" fmt.Println(len(message)) fmt.Println(message[0:5]) // A Go string may legitimately contain a zero byte: withZero := "ab\x00cd" fmt.Println(len(withZero)) }
#include <stdio.h> #include <string.h> int main(void) { const char *message = "Hello, World!"; /* strlen is a LOOP looking for the zero byte. */ printf("%zu\n", strlen(message)); /* A substring needs somewhere to live and a terminator of its own. */ char window[6]; memcpy(window, message, 5); window[5] = '\0'; printf("%s\n", window); /* A C string CANNOT contain a zero byte — that is where it ends. */ const char with_zero[] = "ab\0cd"; printf("%zu\n", strlen(with_zero)); return 0; }
The last three lines are the point: C reports 2 where Go reports 5, because the zero byte is a terminator rather than data. This is exactly the bug that bites when passing Go strings through cgo — C.CString allocates a NUL-terminated copy precisely because a Go string is not one, and you must C.free the result. Substring also went from a free two-word header to a copy plus a buffer you sized by hand.
Concatenation Allocates, Somewhere
Go's + on strings builds a new immutable string and lets the collector deal with the old ones. In C you decide where the result lives, how big it is, and who frees it — three decisions Go makes silently.
package main import "fmt" func main() { first := "Hello, " second := "World!" greeting := first + second fmt.Println(greeting) fmt.Println(len(greeting)) }
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { const char *first = "Hello, "; const char *second = "World!"; size_t total = strlen(first) + strlen(second); char *greeting = malloc(total + 1); /* +1 for the terminator */ if (greeting == NULL) return 1; strcpy(greeting, first); strcat(greeting, second); printf("%s\n", greeting); printf("%zu\n", strlen(greeting)); free(greeting); return 0; }
The + 1 is the classic off-by-one in C, and forgetting it corrupts the heap rather than raising anything. Note too that strcat walks to the end of the destination before it starts copying, so concatenating in a loop is quadratic — the reason Go programmers reach for strings.Builder is the same reason C programmers track the length themselves instead of calling strcat repeatedly.
Arrays Are Values, Slices Are Views
A Go Array Is a Value; A C Array Decays
Assigning a Go array copies every element, and passing one to a function copies it too — the length is part of the type, so [3]int and [4]int are different types. In C an array name turns into a pointer to its first element the moment you pass it anywhere.
package main import "fmt" func modify(values [3]int) { values[0] = 99 } func main() { numbers := [3]int{10, 20, 30} modify(numbers) fmt.Println(numbers) duplicate := numbers duplicate[1] = 77 fmt.Println(numbers) fmt.Println(duplicate) }
#include <stdio.h> #include <string.h> /* This parameter is NOT an array. It is a pointer, and the 3 is decoration. */ static void modify(long values[3]) { values[0] = 99; } int main(void) { long numbers[3] = { 10, 20, 30 }; modify(numbers); /* the caller's array IS modified */ printf("[%ld %ld %ld]\n", numbers[0], numbers[1], numbers[2]); /* Copying takes an explicit memcpy — there is no array assignment. */ long duplicate[3]; memcpy(duplicate, numbers, sizeof(numbers)); duplicate[1] = 77; printf("[%ld %ld %ld]\n", numbers[0], numbers[1], numbers[2]); printf("[%ld %ld %ld]\n", duplicate[0], duplicate[1], duplicate[2]); return 0; }
The two columns print different first lines, and that is the lesson rather than a mistake: Go's modify got a copy and changed nothing, C's got a pointer and changed the caller's data. This decay is also why sizeof on a parameter gives you the size of a pointer rather than the array, and why C functions taking arrays almost always take a length beside them — which is a slice header, assembled by hand at every call site.
Memory: A Collector, Or Not
Returning a Local
Returning the address of a local is the classic C bug and completely ordinary Go. The difference is escape analysis: the Go compiler notices the value outlives the function and allocates it on the heap for you, silently.
package main import "fmt" type Point struct { X int Y int } // Returning a pointer to a local is FINE — it escapes to the heap. func makePoint() *Point { point := Point{X: 3, Y: 4} return &point } func main() { point := makePoint() fmt.Println(point.X, point.Y) }
#include <stdio.h> #include <stdlib.h> typedef struct { long x; long y; } Point; /* Returning &point here would be a dangling pointer: the frame is gone. The heap allocation Go performs invisibly must be written out. */ static Point *make_point(void) { Point *point = malloc(sizeof(Point)); if (point == NULL) return NULL; point->x = 3; point->y = 4; return point; } int main(void) { Point *point = make_point(); if (point == NULL) return 1; printf("%ld %ld\n", point->x, point->y); free(point); /* and someone has to remember this */ return 0; }
Two things Go removed are visible at once. The allocation is automatic, decided by the compiler rather than by you — run go build -gcflags=-m and it will tell you which values escaped. And the free has no counterpart, which is the entire job of the garbage collector. What you give up is knowing when: C frees at a line you can point at, Go frees at a moment nobody schedules.
Zeroed, Or Whatever Was There
Every Go variable starts as its zero value — 0, "", nil, or a struct with all fields zeroed — and this is guaranteed, not conventional. A C local starts as whatever bytes were on the stack.
package main import "fmt" type Counter struct { Total int Label string } func main() { var count int var counter Counter fmt.Println(count) fmt.Printf("%d %q\n", counter.Total, counter.Label) }
#include <stdio.h> typedef struct { long total; const char *label; } Counter; int main(void) { /* An uninitialized local holds garbage; reading it is undefined behavior. The zeroing Go guarantees must be requested explicitly. */ long count = 0; Counter counter = { 0 }; /* zero-initializes EVERY field */ printf("%ld\n", count); printf("%ld \"%s\"\n", counter.total, counter.label == NULL ? "" : counter.label); return 0; }
The = { 0 } is doing real work: it zeroes the whole struct including padding, which is why it is the idiomatic C way to get a Go-style zero value. The deeper consequence is that Go's zero value is designed to be useful — a zero sync.Mutex is an unlocked mutex, a nil slice appends correctly, a zero bytes.Buffer is ready to write. That only works because zeroing is guaranteed.
Structs & Layout
Field Order Changes the Size
Both languages lay struct fields out in declaration order and insert padding so each field lands on its natural alignment. The row prints the sizes to make the padding visible; the two columns agree, which is the point.
package main import ( "fmt" "unsafe" ) type Padded struct { Flag bool Value int64 Small bool } type Packed struct { Value int64 Flag bool Small bool } func main() { fmt.Println(unsafe.Sizeof(Padded{})) fmt.Println(unsafe.Sizeof(Packed{})) }
#include <stdio.h> #include <stdbool.h> #include <stdint.h> typedef struct { bool flag; /* 1 byte, then 7 bytes of padding */ int64_t value; /* 8 */ bool small; /* 1, then 7 more to round the struct out */ } Padded; typedef struct { int64_t value; /* 8 */ bool flag; /* 1 */ bool small; /* 1, then 6 to round out */ } Packed; int main(void) { printf("%zu\n", sizeof(Padded)); printf("%zu\n", sizeof(Packed)); return 0; }
Twenty-four bytes against sixteen, from reordering three fields. Go does not reorder them for you — unlike Rust, it keeps declaration order precisely so that a struct can be handed to C — which means fieldalignment in go vet has something real to report. This identical layout is also what makes cgo possible at all: a Go struct and a C struct with the same fields in the same order are the same bytes.
A Method Is a Function With a First Argument
Go's method syntax puts the receiver before the name; C puts it in the parameter list. A pointer receiver is a pointer parameter, and that is the whole difference between a method that can modify the value and one that cannot.
package main import "fmt" type Counter struct { Total int } func (counter *Counter) Add(amount int) { counter.Total += amount } func (counter Counter) Doubled() int { return counter.Total * 2 } func main() { counter := Counter{} counter.Add(5) counter.Add(16) fmt.Println(counter.Total) fmt.Println(counter.Doubled()) }
#include <stdio.h> typedef struct { long total; } Counter; /* A pointer receiver is a pointer parameter. */ static void counter_add(Counter *counter, long amount) { counter->total += amount; } /* A value receiver is a by-value parameter — this gets a COPY. */ static long counter_doubled(Counter counter) { return counter.total * 2; } int main(void) { Counter counter = { 0 }; counter_add(&counter, 5); counter_add(&counter, 16); printf("%ld\n", counter.total); printf("%ld\n", counter_doubled(counter)); return 0; }
Go writes counter.Add(5) where the receiver is a value and the method needs a pointer, taking the address for you — a convenience with one sharp edge, that it only works when the value is addressable. The C column cannot hide it: &counter is written at the call site, and forgetting it is a type error rather than a silent copy.
Pointers Without Arithmetic
Go Removed Pointer Arithmetic On Purpose
C lets you add to a pointer, and the addition is scaled by the pointed-to type. Go has pointers but no arithmetic on them at all — walking an array means an index, and the compiler keeps the relationship between pointer and object intact.
package main import "fmt" func main() { numbers := []int{10, 20, 30} // There is no numbers[0] + 1 pointer arithmetic in Go. // You index, and the pointer stays a pointer to a whole object. total := 0 for index := 0; index < len(numbers); index++ { total += numbers[index] } fmt.Println(total) pointer := &numbers[1] *pointer = 99 fmt.Println(numbers) }
#include <stdio.h> int main(void) { long numbers[3] = { 10, 20, 30 }; /* Walking with a moving pointer. "+ 1" advances by sizeof(long). */ long total = 0; for (long *cursor = numbers; cursor < numbers + 3; cursor++) { total += *cursor; } printf("%ld\n", total); long *pointer = &numbers[1]; *pointer = 99; printf("[%ld %ld %ld]\n", numbers[0], numbers[1], numbers[2]); return 0; }
The omission is load-bearing rather than puritanical. Because no Go pointer can be halfway through an object or point past the end of one, the collector can always tell what a pointer refers to — and a moving collector could relocate the object and fix the pointer up. C's cursor is an address with no owner, which is precisely why C cannot have a garbage collector that moves anything.
Functions & Multiple Returns
Multiple Returns Are Out-Parameters
A Go function returning two values compiles to one that writes both into the caller's space. In C you make that explicit: the extra results become pointer parameters the caller supplies, and the return value is left free for a status.
package main import "fmt" func divide(numerator, denominator int) (int, int) { return numerator / denominator, numerator % denominator } func main() { quotient, remainder := divide(17, 5) fmt.Println(quotient, remainder) }
#include <stdio.h> /* The second and third results are OUT-PARAMETERS. */ static void divide(long numerator, long denominator, long *quotient, long *remainder) { *quotient = numerator / denominator; *remainder = numerator % denominator; } int main(void) { long quotient = 0; long remainder = 0; divide(17, 5, &quotient, &remainder); printf("%ld %ld\n", quotient, remainder); return 0; }
Out-parameters are why C's standard library reads the way it does — strtol takes a pointer for the end position, scanf takes pointers for everything. They also cannot be ignored by accident the way a Go return value can, since you had to declare the variable. What Go buys is that the results are named in the signature and the compiler will not let you forget one.
A Closure Carries Its Variables
A Go closure captures the variables it mentions and keeps them alive as long as the function value exists. C function pointers capture nothing at all, so the state must be passed alongside — the "context pointer" convention every C callback API uses.
package main import "fmt" func makeCounter() func() int { total := 0 return func() int { total++ return total } } func main() { next := makeCounter() fmt.Println(next()) fmt.Println(next()) fmt.Println(next()) }
#include <stdio.h> #include <stdlib.h> /* A function pointer captures NOTHING, so the state travels beside it. */ typedef struct { long (*call)(void *state); void *state; } Counter; static long next_value(void *state) { long *total = state; *total += 1; return *total; } static Counter make_counter(void) { long *total = malloc(sizeof(long)); *total = 0; /* the captured variable, on the heap */ Counter counter = { next_value, total }; return counter; } int main(void) { Counter next = make_counter(); printf("%ld\n", next.call(next.state)); printf("%ld\n", next.call(next.state)); printf("%ld\n", next.call(next.state)); free(next.state); return 0; }
The struct of function-pointer plus void * is the shape of every C callback interface you have ever registered, and it is what a Go closure is underneath. Note the captured variable had to go on the heap for the same reason as the escaping local earlier — it outlives the function that created it — and that somebody must free it, which is the part Go removes.
Errors Are Values, Not errno
An error Return Versus errno
Go returns the failure alongside the result and the compiler makes the second value hard to ignore. C's convention is a sentinel return plus a global errno that the next call may overwrite — so it must be read immediately.
package main import ( "fmt" "strconv" ) func main() { value, err := strconv.Atoi("123") if err != nil { fmt.Println("error: bad number") return } fmt.Println(value) _, err = strconv.Atoi("nope") if err != nil { fmt.Println("error: bad number") } }
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <limits.h> int main(void) { const char *text = "123"; char *end = NULL; errno = 0; /* MUST be cleared first */ long value = strtol(text, &end, 10); if (errno != 0 || end == text || *end != '\0') { printf("error: bad number\n"); return 1; } printf("%ld\n", value); const char *bad = "nope"; char *bad_end = NULL; errno = 0; long other = strtol(bad, &bad_end, 10); (void) other; /* No errno is set for "not a number" — the ONLY signal is that the end pointer did not move. This is the trap. */ if (bad_end == bad || *bad_end != '\0') { printf("error: bad number\n"); } return 0; }
The two columns disagree about how much ceremony failure costs, and the C side is not exaggerated: strtol genuinely reports "not a number" only by leaving the end pointer where it started, and genuinely requires clearing errno beforehand because it is not cleared on success. Go's err being an ordinary return value is what lets it be wrapped, compared with errors.Is, and carried across goroutines — none of which a thread-local integer can do.
defer Versus goto cleanup
defer Is goto cleanup, Done Right Every Time
Go runs deferred calls when the function returns, in reverse order, on every path out. C's equivalent is the goto cleanup idiom — a single exit block at the bottom, jumped to from each failure point. It works, and it has to be maintained by hand.
package main import "fmt" func process(shouldFail bool) string { defer fmt.Println("closing second") defer fmt.Println("closing first") if shouldFail { return "failed early" } return "finished" } func main() { fmt.Println(process(true)) fmt.Println(process(false)) }
#include <stdio.h> static const char *process(int should_fail) { const char *result = "finished"; if (should_fail) { result = "failed early"; goto cleanup; /* every early exit must remember to jump */ } cleanup: /* Reverse order is yours to arrange, too. */ printf("closing first\n"); printf("closing second\n"); return result; } int main(void) { printf("%s\n", process(1)); printf("%s\n", process(0)); return 0; }
The reverse ordering is not decoration — it means resources are released in the opposite order they were acquired, which is what you want when the second depends on the first. Go guarantees it; the C column achieves it by writing the two lines in the right order and hoping the next person to add a third keeps the pattern. This is also why defer in a loop is a known Go trap: the calls stack up until the function returns, not the iteration.
Maps Versus a Hand-Rolled Table
There Is No map in C
Go has a hash map in the language, with a literal syntax and a two-value lookup that tells you whether the key was present. C has no such thing in its standard library, so a small table is usually an array of pairs and a loop.
package main import "fmt" func main() { ages := map[string]int{ "alice": 30, "bob": 25, } ages["carol"] = 41 if age, present := ages["bob"]; present { fmt.Println("bob", age) } if _, present := ages["dave"]; !present { fmt.Println("dave not found") } fmt.Println(len(ages)) }
#include <stdio.h> #include <string.h> typedef struct { const char *key; long value; } Entry; /* Returns 1 and writes *out when found, 0 when not — Go's comma-ok, by hand. */ static int lookup(const Entry *entries, size_t count, const char *key, long *out) { for (size_t index = 0; index < count; index++) { if (strcmp(entries[index].key, key) == 0) { *out = entries[index].value; return 1; } } return 0; } int main(void) { Entry ages[3] = { { "alice", 30 }, { "bob", 25 }, { "carol", 41 }, }; size_t count = 3; long age = 0; if (lookup(ages, count, "bob", &age)) { printf("bob %ld\n", age); } long ignored = 0; if (!lookup(ages, count, "dave", &ignored)) { printf("dave not found\n"); } printf("%zu\n", count); return 0; }
The C version is a linear scan, which is fine for three entries and wrong for three thousand — and choosing when to graduate to a real hash table is a decision Go made once, for everyone. Notice that the comma-ok idiom survives the translation exactly: a found/not-found return plus an out-parameter is the same shape, and it exists in C for the same reason it exists in Go, because a zero value is indistinguishable from a missing one.
Interfaces Are Two Pointers
An Interface Is Data Plus a Function Table
A Go interface value is two words: a pointer to the concrete data, and a pointer to a table of the methods that type implements. The C column builds both by hand, which is what every polymorphic C API does.
package main import "fmt" type Shape interface { Area() int Name() string } type Square struct{ Side int } type Rect struct{ Width, Height int } func (square Square) Area() int { return square.Side * square.Side } func (square Square) Name() string { return "square" } func (rect Rect) Area() int { return rect.Width * rect.Height } func (rect Rect) Name() string { return "rect" } func main() { shapes := []Shape{Square{Side: 4}, Rect{Width: 3, Height: 5}} for _, shape := range shapes { fmt.Println(shape.Name(), shape.Area()) } }
#include <stdio.h> /* The method table — one per concrete type, shared by all its values. */ typedef struct { long (*area)(const void *data); const char *(*name)(const void *data); } ShapeTable; /* The interface VALUE: data pointer + table pointer. Two words, like Go. */ typedef struct { const void *data; const ShapeTable *table; } Shape; typedef struct { long side; } Square; typedef struct { long width, height; } Rect; static long square_area(const void *data) { const Square *s = data; return s->side * s->side; } static const char *square_name(const void *data) { (void) data; return "square"; } static long rect_area(const void *data) { const Rect *r = data; return r->width * r->height; } static const char *rect_name(const void *data) { (void) data; return "rect"; } static const ShapeTable SQUARE_TABLE = { square_area, square_name }; static const ShapeTable RECT_TABLE = { rect_area, rect_name }; int main(void) { Square square = { 4 }; Rect rect = { 3, 5 }; Shape shapes[2] = { { &square, &SQUARE_TABLE }, { &rect, &RECT_TABLE }, }; for (size_t index = 0; index < 2; index++) { printf("%s %ld\n", shapes[index].table->name(shapes[index].data), shapes[index].table->area(shapes[index].data)); } return 0; }
Two facts about Go interfaces fall straight out of this picture. A nil interface and an interface holding a nil pointer are different values — the first has both words nil, the second has a table — which is the famous typed-nil trap. And the method table is built by the compiler from whatever methods the type happens to have, with no declaration of intent, which is exactly why Go interfaces are satisfied implicitly.
Goroutines Are Threads
A Goroutine Is a Thread You Did Not Size
The Go side must synchronize or its output is nondeterministic — WaitGroup is doing that job. The C side spawns real OS threads with pthread_create and waits with pthread_join. Both print in a fixed order because both wait; without the wait, neither would.
package main import ( "fmt" "sync" ) func main() { var waiter sync.WaitGroup results := make([]int, 3) for index := 0; index < 3; index++ { waiter.Add(1) go func(slot int) { defer waiter.Done() results[slot] = slot * 10 }(index) } waiter.Wait() fmt.Println(results) }
#include <stdio.h> #include <pthread.h> typedef struct { long *results; long slot; } Work; static void *worker(void *argument) { Work *work = argument; work->results[work->slot] = work->slot * 10; return NULL; } int main(void) { pthread_t threads[3]; Work work[3]; long results[3] = { 0, 0, 0 }; for (long index = 0; index < 3; index++) { work[index].results = results; work[index].slot = index; pthread_create(&threads[index], NULL, worker, &work[index]); } for (int index = 0; index < 3; index++) { pthread_join(threads[index], NULL); /* this IS waiter.Wait() */ } printf("[%ld %ld %ld]\n", results[0], results[1], results[2]); return 0; }
The costs differ by orders of magnitude even though the shapes match. A pthread gets a fixed stack — commonly eight megabytes of address space — and is scheduled by the kernel, so ten thousand of them is not a plan. A goroutine starts with a couple of kilobytes that grow on demand and is multiplexed onto a small pool of real threads, which is why "just start a goroutine per request" is ordinary advice and "start a thread per request" is not.
A Channel Is a Mutex and a Condition Variable
An unbuffered channel makes the sender wait until a receiver is ready. That handshake is a mutex protecting a slot plus a condition variable to wake the other side — which is what the C column assembles. Read it as the definition of what <- does.
package main import "fmt" func main() { messages := make(chan int) go func() { for value := 1; value <= 3; value++ { messages <- value * 10 } close(messages) }() for value := range messages { fmt.Println(value) } }
#include <stdio.h> #include <pthread.h> /* An unbuffered channel, by hand. */ typedef struct { pthread_mutex_t lock; pthread_cond_t changed; long value; int full; int closed; } Channel; static void channel_send(Channel *channel, long value) { pthread_mutex_lock(&channel->lock); while (channel->full) pthread_cond_wait(&channel->changed, &channel->lock); channel->value = value; channel->full = 1; pthread_cond_broadcast(&channel->changed); pthread_mutex_unlock(&channel->lock); } /* Returns 0 once the channel is closed and drained — Go's range ending. */ static int channel_receive(Channel *channel, long *out) { pthread_mutex_lock(&channel->lock); while (!channel->full && !channel->closed) { pthread_cond_wait(&channel->changed, &channel->lock); } if (!channel->full && channel->closed) { pthread_mutex_unlock(&channel->lock); return 0; } *out = channel->value; channel->full = 0; pthread_cond_broadcast(&channel->changed); pthread_mutex_unlock(&channel->lock); return 1; } static void *producer(void *argument) { Channel *channel = argument; for (long value = 1; value <= 3; value++) channel_send(channel, value * 10); pthread_mutex_lock(&channel->lock); channel->closed = 1; pthread_cond_broadcast(&channel->changed); pthread_mutex_unlock(&channel->lock); return NULL; } int main(void) { Channel messages = { PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, 0, 0, 0 }; pthread_t thread; pthread_create(&thread, NULL, producer, &messages); long value = 0; while (channel_receive(&messages, &value)) { printf("%ld\n", value); } pthread_join(thread, NULL); return 0; }
Sixty lines against twelve, and every one of them is a place to get it wrong: the while around pthread_cond_wait rather than an if (spurious wakeups are real), broadcasting after every state change, and the closed-and-drained condition that makes the receiver stop. close and range over a channel are that last piece, and getting it wrong by hand deadlocks rather than raising anything.
Gotchas For Go Developers
int Is Not a Fixed Size in C
Go's int is 32 or 64 bits depending on the platform, but int32 and int64 mean exactly what they say. C's int, long and short have only minimum widths, which is why stdint.h exists and why portable C uses it.
package main import ( "fmt" "math" "unsafe" ) func main() { var native int var fixed int32 fmt.Println(unsafe.Sizeof(native)) fmt.Println(unsafe.Sizeof(fixed)) fmt.Println(math.MaxInt32) }
#include <stdio.h> #include <stdint.h> #include <limits.h> int main(void) { /* sizeof(long) is 8 on Linux/macOS 64-bit and 4 on 64-bit Windows. int32_t is 32 bits everywhere, which is why portable code says so. */ long native = 0; int32_t fixed = 0; (void) native; (void) fixed; printf("%zu\n", sizeof(long)); printf("%zu\n", sizeof(int32_t)); printf("%d\n", INT32_MAX); return 0; }
This is the first thing that bites in cgo. C.int, C.long and C.size_t are distinct Go types and none of them is int, so every value crossing the boundary needs an explicit conversion — and the conversion is where a silent truncation lives if the C side is narrower than you assumed. Using the stdint.h names on the C side of a binding removes the ambiguity entirely.
Nothing Checks the Index
Go checks every index against the length and panics with a message naming both numbers. C does not check, and reading past the end is undefined behavior — often it simply returns whatever bytes are next, which is what makes the bug so hard to find.
package main import "fmt" func main() { numbers := []int{10, 20, 30} index := 5 if index >= len(numbers) { fmt.Printf("index %d out of range [0:%d]\n", index, len(numbers)) return } fmt.Println(numbers[index]) }
#include <stdio.h> int main(void) { long numbers[3] = { 10, 20, 30 }; size_t count = 3; size_t index = 5; /* This check is the one Go emits for you. Omit it and numbers[5] reads eight bytes past the array with nothing to object. */ if (index >= count) { printf("index %zu out of range [0:%zu]\n", index, count); return 0; } printf("%ld\n", numbers[index]); return 0; }
The comparison had to be written, and it had to use an unsigned type so a "negative" index wraps to something enormous and still fails the test. Go's panic is not free either — it is a compare and a branch on every index — but the optimizer removes most of them by proving the bound once, which is why for _, value := range numbers is usually faster than indexing in a loop.
C Reads Top to Bottom, Once
Go resolves names across a whole package regardless of order, so a function may call one defined below it. A C compiler reads the file once from the top, so anything used must already have been declared — which is what header files and forward declarations are for.
package main import "fmt" // helper is defined BELOW main and that is fine — // a Go package is resolved as a whole. func main() { fmt.Println(helper(21)) } func helper(value int) int { return value * 2 }
#include <stdio.h> /* The forward declaration. Without it, the compiler reaches the call in main() having never heard of helper, and this does not compile. */ static long helper(long value); int main(void) { printf("%ld\n", helper(21)); return 0; } static long helper(long value) { return value * 2; }
This single-pass model is why a C project has a .h file beside every .c file, why headers need include guards, and why build times grow with the amount of text pasted in. Go's package-wide resolution plus its refusal to allow cyclic imports is what lets it compile as fast as it does — the compiler never reads the same declaration twice.