CGo vs. Purego: Integrating C with Go
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)
}