Why should I call os.Exit at most once in the main function?

961 views Asked by At

I started a new job and we've been instructed to use Ubers Go coding standards. I'm not sure about one of their guidelines entitled "Exit Once":

If possible, prefer to call os.Exit or log.Fatal at most once in your main(). If there are multiple error scenarios that halt program execution, put that logic under a separate function and return errors from it.

Wouldn't this just mean offloading main() into another function (run())? This seems a little superfluous to me. What benefits does Uber's approach have?

1

There are 1 answers

0
jub0bs On

I'm not familiar with Uber's entire Go coding standards, but that particular piece of advice is sound. One issue with os.Exit is that it puts an end to the programme very brutally, without honouring any deferred function calls pending:

Exit causes the current program to exit with the given status code. Conventionally, code zero indicates success, non-zero an error. The program terminates immediately; deferred functions are not run.

(my emphasis)

However, those deferred function calls may be responsible for important cleanup tasks. Consider Uber's example code snippet:

package main

func main() {
  args := os.Args[1:]
  if len(args) != 1 {
    log.Fatal("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()

  // If we call log.Fatal after this line,
  // f.Close will not be called.

  b, err := ioutil.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  // ...
}

If ioutil.ReadAll returns a non-nil error, log.Fatal is called; and because log.Fatal calls os.Exit under the hood, the deferred call to f.Close will not be run. In this particular case, it's not that serious, but imagine a situation where deferred calls involved some cleanup, like removing files; you'd leave your disk in an unclean state. For more on that topic, see episode #112 of the Go Time podcast, in which these considerations were discussed.

Therefore, it's a good idea to eschew os.Exit, log.Fatal, etc. "deep" in your programme. A run function as described in Uber's Go coding standards allows deferred calls to be run as they should before programme execution ends (potentially with a non-zero status code):

package main

func main() {
  if err := run(); err != nil {
    log.Fatal(err)
  }
}

func run() error {
  args := os.Args[1:]
  if len(args) != 1 {
    return errors.New("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    return err
  }
  defer f.Close()

  b, err := ioutil.ReadAll(f)
  if err != nil {
    return err
  }

  // ...
}

An additional benefit of this approach is that, although the main function itself isn't readily testable, you can design such a run function with testability in mind; see Mat Ryer's blog post on that topic.