いろいろ備忘録日記

主に .NET とか Go とか Python絡みのメモを公開しています。

Goメモ-61 (ループ内でdeferする場合のTips)

概要

Goでソース書いているとき、たまにループ内でdeferを使いたいときがあります。

何個もファイルを開いていくときとか。

そういうとき、ループ内で defer 書くと、GoLandさんが「ループ内で defer を直接使ってるよ!」って警告を出してくれるんですが、とか言って、処理を一つするたびにエラー分岐で f.Close() って呼ぶのは面倒だなーって思ってました。

Go使ってる人たちは、どうやってるんだろうって調べてみると

stackoverflow.com

mattn.kaoriya.net

というのを発見。なるほど!ループの中で関数スコープをつくってやればいいんですねー。気づかなかったです。感謝。

サンプル

以下は、関数スコープを作る場合と作らない場合の違いを試してみました。

package defer_

import "fmt"

// DeferInLoop は、deferをループ内で利用したい場合のやり方についてのサンプルです。
//
// REFERNCES::
//   - https://mattn.kaoriya.net/software/lang/go/20151212021608.htm
//   - https://stackoverflow.com/questions/45617758/defer-in-the-loop-what-will-be-better
func DeferInLoop() error {
    // --------------------------------------------------------------
    // 上のURLに書かれているように、deferは内部でLIFOキューで管理されている
    // ので、ループ内で defer をそのまま書くと、どんどんキューに溜まってしまう.
    //
    // なので、ループ内で defer する場合は、匿名関数を用意して
    // 関数スコープを作り、その中で defer するようにする。
    //
    // 関数スコープが作られるので、ループが一回分終了するたびにスコープを
    // 抜け、その際に defer が実行される。
    //
    // ループ内でファイルをオープンしていく処理などを書いている場合に特に注意。
    // --------------------------------------------------------------

    // NGパターン
    bad()

    fmt.Println("---------------------------------------")

    // OKパターン
    good()

    return nil
}

func bad() {
    // ループ内で defer をそのまま使っているので
    // LIFOキューにループ回数分の defer が溜まった後
    // 関数を抜けるときに実行される。
    //
    // なので、この場合は
    //   defer: 09
    //   defer: 08
    //   ・
    //   ・
    //   defer: 00
    //
    // という風に、逆順で出力される
    for i := 0; i < 10; i++ {
        //noinspection GoDeferInLoop
        defer fmt.Printf("defer: %02d\n", i)
    }
}

func good() {
    // ループ内で defer をそのまま使わずに関数スコープを作り
    // defer を使っている。そのため、ループ一回毎に defer が
    // 実行される。
    //
    // なので、この場合は
    //   defer: 00
    //   defer: 01
    //   ・
    //   ・
    //   defer: 09
    //
    // という風に出力される
    for i := 0; i < 10; i++ {
        func(i int) {
            defer fmt.Printf("defer: %02d\n", i)
        }(i)
    }
}

実行すると以下のようになります。

$ make run
go run github.com/devlights/try-golang/cmd/trygolang -onetime -example ""
ENTER EXAMPLE NAME: defer_in_loop
[Name] "defer_in_loop"
defer: 09
defer: 08
defer: 07
defer: 06
defer: 05
defer: 04
defer: 03
defer: 02
defer: 01
defer: 00
---------------------------------------
defer: 00
defer: 01
defer: 02
defer: 03
defer: 04
defer: 05
defer: 06
defer: 07
defer: 08
defer: 09

キレイに逆になりますね。

try-golang/defer_in_loop.go at master · devlights/try-golang · GitHub

サンプル2

以下は、ループ内でファイルをいっぱい開いた際の違いについて試してみました。

package defer_

import (
    "fmt"
    "github.com/devlights/try-golang/lib/output"
    "github.com/devlights/try-golang/util/enumerable"
    "github.com/devlights/try-golang/util/mem"
    "io/ioutil"
    "os"
    "runtime"
)

// DeferInLoopManyFiles は、deferをループ内で利用したい場合のやり方についてのサンプルです。
// ループ内で大量のファイルを開いて defer で close しようとしている場合の対処について。
//
// REFERNCES::
//   - https://mattn.kaoriya.net/software/lang/go/20151212021608.htm
//   - https://stackoverflow.com/questions/45617758/defer-in-the-loop-what-will-be-better
func DeferInLoopManyFiles() error {
    // --------------------------------------------------------------
    // 基本的な動作については、 defer_in_loop.go にて記載している。
    // ここでは、ファイルハンドルをループ内でキャプチャさせたまま
    // defer で大量に登録した場合、メモリがどうなるかを検証する.
    // --------------------------------------------------------------
    dir, err := initDirectory()
    if err != nil {
        return err
    }

    //noinspection GoUnhandledErrorResult
    defer os.RemoveAll(dir)
    output.Stdoutl("dir", dir)

    // ダメなパターン
    loopRange := enumerable.NewRange(1, 3000)
    memory := mem.NewMem(mem.Alloc(true), mem.TotalAlloc(false), mem.NumGC(true))
    err = badDefer(dir, loopRange, memory)
    if err != nil {
        return err
    }

    runtime.GC()

    // 良いパターン
    _, _ = loopRange.Reset()
    err = goodDefer(dir, loopRange, memory)
    if err != nil {
        return err
    }

    memory.Print("main end")

    return nil
}

func initDirectory() (string, error) {
    dir, err := ioutil.TempDir("", "try-golang")
    if err != nil {
        return "", err
    }

    return dir, nil
}

func badDefer(dir string, r enumerable.Range, memory mem.Mem) error {
    // 現在のメモリ量を出力しておく
    memory.Print("badDefer - init")

    // 大量にファイル作って defer に登録.
    // (関数スコープを作らない版)
    for r.Next() {
        file, err := ioutil.TempFile(dir, fmt.Sprintf("try-golang-tmp-%02d", r.Current()))
        if err != nil {
            return err
        }

        // とりあえず適当にデータ入れておく
        err = ioutil.WriteFile(file.Name(), []byte("helloworld"), 0644)
        if err != nil {
            return err
        }

        // defer 登録
        //   GoLand 使っている場合はループ内でのdeferの利用を検知してくれる
        //   サンプルなので抑止しておく
        //noinspection GoUnhandledErrorResult,GoDeferInLoop
        defer file.Close()
    }

    output.Stdoutl("[file count]", r.Current()+1)

    // 現在のメモリ量を出力しておく
    memory.Print("badDefer - before gc")
    runtime.GC()
    memory.Print("badDefer - after gc")

    return nil
}

func goodDefer(dir string, r enumerable.Range, memory mem.Mem) error {
    // 現在のメモリ量を出力しておく
    memory.Print("goodDefer - init")

    // 大量にファイル作って defer に登録.
    // (関数スコープを作る版)
    for r.Next() {
        err := func() error {
            file, err := ioutil.TempFile(dir, fmt.Sprintf("try-golang-tmp-%02d", r.Current()))
            if err != nil {
                return err
            }

            // とりあえず適当にデータ入れておく
            err = ioutil.WriteFile(file.Name(), []byte("helloworld"), 0644)
            if err != nil {
                return err
            }

            // defer 登録
            //   GoLand 使っている場合はループ内でのdeferの利用を検知してくれる
            //   サンプルなので抑止しておく
            //noinspection GoUnhandledErrorResult,GoDeferInLoop
            defer file.Close()

            return nil
        }()

        if err != nil {
            return err
        }
    }

    output.Stdoutl("[file count]", r.Current()+1)

    // 現在のメモリ量を出力しておく
    memory.Print("goodDefer - before gc")
    runtime.GC()
    memory.Print("goodDefer - after gc")

    return nil
}

try-golang/defer_in_loop_manyfiles.go at master · devlights/try-golang · GitHub

実行すると以下のようになります。

$ make run
go run github.com/devlights/try-golang/cmd/trygolang -onetime -example ""
ENTER EXAMPLE NAME: defer_in_loop_manyfiles
[Name] "defer_in_loop_manyfiles"
dir                  /tmp/try-golang012063143
badDefer - init      ----------------------------
Alloc                332 KiB
NumGC                0
[file count]         3000
badDefer - before gc ----------------------------
Alloc                2043 KiB
NumGC                0
badDefer - after gc  ----------------------------
Alloc                1017 KiB
NumGC                1
goodDefer - init     ----------------------------
Alloc                293 KiB
NumGC                2
[file count]         3000
goodDefer - before gc ----------------------------
Alloc                1770 KiB
NumGC                2
goodDefer - after gc ----------------------------
Alloc                293 KiB
NumGC                3
main end             ----------------------------
Alloc                293 KiB
NumGC                3

違いは after gc って出力しているところに表れていますね。ループ内で直接 defer 書いている方は gc しても減りきっていません。つまり、参照が保持されているということです。ループ内で関数スコープを作って defer している方はきっちり減っています。


過去の記事については、以下のページからご参照下さい。

  • いろいろ備忘録日記まとめ

devlights.github.io

サンプルコードは、以下の場所で公開しています。

  • いろいろ備忘録日記サンプルソース置き場

github.com

github.com

github.com