Low Level Go

Posted on 2016-01-14

Let’s take a look at a golang binary size with real life dependencies.

These are two of my own projects, where I knew they had to run on a command line, and across platforms:

But what about when your work is very simple, and requires doing some classic C with direct operating system calls? Does it “pay off” to build that in Go?

Some would write a small C program and be done with it; being small it would probably be a lot of fun since there’s no abstraction that can stop you anywhere.

However, a one-off C program will probably compromise on portability and perhaps some other arguable properties such as code clarity and ease of maintenance. Let’s see what does it take to get as close as possible to that C program, but stay within Go.

Reducing the Go Binary Size

Go binaries tend to grow fast with each included dependency. However, let’s make a very important statement: in real life, Go programs are small enough for that to not matter at all. Moreover, at runtime the resources consumed will be much less than say Java and Ruby and Python, which is perfect.

That being said, what if we still want to get close to a C binary, and we don’t want to be in the MB range, but in KBs?

How low can we go?

The Go binary will pack the garbage collector, the goroutines scheduler and the dependencies you include via import.

Taking a very minimal CLI program, let’s say we have os to cover basic file handling and flag to parse and access command line arguments. We don’t even do any common I/O operations here.

package main
import(
  "os"
  "flag"
)
func main(){
  os.Open("foobar")
  flag.Parse()
}

Binary size: 1.8MB.

Going foward, let’s remove flag and assume we can get to ARGV via os.Args. It’ll be less elegant but, whatever.

package main
import(
  "os"
)
func main(){
  os.Open("foobar")
}

Binary size: 893Kb. That looks quite good. Can we do better?

syscall

Going through Go docs, we bump into syscall, Go’s interface into the low level OS primitives:

The primary use of syscall is inside other packages that provide a more portable interface to the system, such as “os”, “time” and “net”. Use those packages rather than this one if you can.

Let’s swap everything with “raw” syscalls:

package main
import(
  "syscall"
)
func main(){
  syscall.Open("foobar", 0, 666)
}

Binary size: 544Kb. Neat (we’ll stop here — for anything practical, I assure you this is the bare minimum :).

We can shave around 20KB more with strip but let’s forget about that for the moment.

Shake off abstractions

As with C, you can do without abstractions in Go. You could get a lot of mileage with syscall, as do a lot of the standard packages in order to implement the higher level, more streamlined Go API.

Here is a snippet from os.Chdir, reassuring it’s a fancy wrapper around syscall.

func (f *File) Chdir() error {
  if f == nil {
    return ErrInvalid
  }
  if e := syscall.Fchdir(f.fd); e != nil {
    return &PathError{"chdir", f.name, e}
  }
  return nil
}

Summary

For a real life example, You can take a look at cronlock, a small utility I’ve built with the conclusions from this article. Like cron, it had to be small and simple.

Working without abstractions from time to time is nice; it feels like being a kid with a LEGO again and there’s a special place for C in my heart to relay that feeling. Go makes that a tad bit more accessible and portable.

Note: syscall is supposed to be deprecated in favor of a better architecture, but has not yet — and the ideas here should still be valid after the transition. See more here.