The Franconian
Coder Studio

CGo vs. Purego: Integrating C with Go

CGo vs. Purego: Integrating C with Go

I love Go for its simplicity and speed, but sometimes you need to go deeper. When that happens, do you choose CGo for raw power or Purego for simplicity? I explored the trade-offs between these two approaches for integrating C code.

I absolutely love working with Go. It’s a perfect blend of simplicity, speed, and efficiency. However, there are moments when you need to dive deeper into the system. Whether it’s to extend an existing library, optimize an application, or implement a completely new feature. In such cases, C is often the first choice, as it provides direct access to system resources and offers excellent performance. The answer for this in Go is: CGo.

The Power and Peril of CGo

CGo is a powerful tool that allows Go programs to integrate and use C code. It builds a bridge between the two languages, enabling developers to leverage the strengths of C within their Go applications. However, CGo also comes with its own set of challenges that need to be considered.

Purego: A Simpler Alternative?

An alternative can be found in Purego. Purego is a library that allows you to call C functions directly from Go without needing a separate C compiler. This can reduce complexity and simplify integration.

Purego’s motivation:

The Ebitengine game engine was ported to use only Go on Windows. This enabled cross-compiling to Windows from any other operating system simply by setting GOOS=windows. The purego project was born to bring that same vision to the other platforms supported by Ebitengine.

A rather brilliant idea.

Choosing the Right Tool for the Job

That said, you should always evaluate your specific use case. As tempting as Purego may be, there are scenarios where CGo is the better choice. In any case, it’s crucial to analyze the concrete application and weigh the pros and cons of both approaches. Relevant benchmarks are essential to assess the performance and efficiency of each solution. Only then can you make an informed decision that meets your project’s requirements.

Beyond CGo and Purego: The Zig Compiler

If the primary goal is to have a simple toolchain for C code, Zig is also worth a look. Zig is not only a modern programming language but also an excellent C compiler. With Zig, you can compile C code without needing to set up a complex toolchain. Cross-compiling for different platforms also works seamlessly.

Conclusion: Test and Verify

I’ve tried out the different ways to integrate C code into Go and measured them with a minimal example. In this specific use case, CGo is the best choice. But that won’t always be the case. So, be sure to test for yourself which approach best suits your requirements.


🧠 AI-generated: Windows FFI Performance Code Examples

This section contains the actual code variants that produced the benchmark results shown in the article.

1. Purego Variant - Slowest

package main

import (
	"fmt"
	"syscall"
	"time"

	"github.com/ebitengine/purego"
)

func main() {
	lib, err := syscall.LoadLibrary("add.dll")
	if err != nil {
		panic(err)
	}
	defer syscall.FreeLibrary(lib)

	var addFunc func(a, b int32) int32
	purego.RegisterLibFunc(&addFunc, uintptr(lib), "add")

	iterations := 1_000_000
	start := time.Now()
	var result int32
	for i := int32(0); i < int32(iterations); i++ {
		result = addFunc(i, i+1)
	}
	duration := time.Since(start)
	fmt.Printf("Purego: %d calls took %v. Result: %d\n", iterations, duration, result)
}

2. Native Windows API Variant

package main

import (
	"fmt"
	"syscall"
	"time"
	"unsafe"
)

func main() {
	modkernel32 := syscall.NewLazyDLL("kernel32.dll")
	procLoadLibrary := modkernel32.NewProc("LoadLibraryA")

	namePtr := unsafe.Pointer(syscall.StringBytePtr("add.dll"))
	handle, _, _ := procLoadLibrary.Call(uintptr(namePtr))

	addAddr, _ := syscall.GetProcAddress(syscall.Handle(handle), "add")

	iterations := 1_000_000
	start := time.Now()
	var result uintptr
	for i := uintptr(0); i < uintptr(iterations); i++ {
		result, _, _ = syscall.SyscallN(addAddr, i, i+1)
	}
	duration := time.Since(start)
	fmt.Printf("Native Windows API: %d calls took %v. Result: %d\n", iterations, duration, int32(result))
}

3. Direct syscall Variant

package main

import (
	"fmt"
	"syscall"
	"time"
)

func main() {
	dll, err := syscall.LoadDLL("add.dll")
	if err != nil {
		panic(err)
	}
	defer dll.Release()

	proc, err := dll.FindProc("add")
	if err != nil {
		panic(err)
	}

	iterations := 1_000_000
	start := time.Now()
	var result uintptr
	for i := uintptr(0); i < uintptr(iterations); i++ {
		result, _, _ = proc.Call(i, i+1)
	}
	duration := time.Since(start)
	fmt.Printf("Direct syscall: %d calls took %v. Result: %d\n", iterations, duration, int32(result))
}

4. Simple syscall Variant

package main

import (
	"fmt"
	"syscall"
	"time"
)

func main() {
	dll := syscall.MustLoadDLL("add.dll")
	proc := dll.MustFindProc("add")

	iterations := 1_000_000
	start := time.Now()
	var result uintptr
	for i := uintptr(0); i < uintptr(iterations); i++ {
		result, _, _ = proc.Call(i, i+1)
	}
	duration := time.Since(start)
	fmt.Printf("Simple syscall: %d calls took %v. Result: %d\n", iterations, duration, int32(result))
}

5. CGo Variant - Fastest

package main

/*
#cgo windows LDFLAGS: -L. -ladd
extern int add(int a, int b);
*/
import "C"
import (
	"fmt"
	"time"
)

func main() {
	iterations := 1_000_000
	start := time.Now()
	var result int32
	for i := int32(0); i < int32(iterations); i++ {
		result = int32(C.add(C.int(i), C.int(i+1)))
	}
	duration := time.Since(start)
	fmt.Printf("CGo: %d calls took %v. Result: %d\n", iterations, duration, result)
}
#golang#c-language#performance-optimization
Read more in Languages & Runtimes!