Carpe Diem

備忘録

goroutineでのpanicのハンドリング

概要

goroutineを使った時のpanicのハンドリングについて調べてみたのでまとめ。
Goはgoroutineで簡単に並列処理を書けますが、エラーハンドリングをきちんとしていないと後で困ることになるという話です。

環境

通常

recoverがどうなるか確認します。

同じ関数内でのpanic

func main() {
    fmt.Println("Calling f.")
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()

    panic("panic")
}

The Go Playground

結果

Calling f.
Recovered in f panic
Returned normally from f.

問題なくrecoverできます。

子の関数内でのpanic

func main() {
    fmt.Println("Calling f.")
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from", r)
        }
    }()
    fmt.Println("Calling g.")
    g()
    fmt.Println("Returned normally from g.")  // ここは呼ばれない
}

func g() {
    panic("g panic")
}

The Go Playground

結果

Calling f.
Calling g.
Recovered from g panic
Returned normally from f.

こちらもちゃんとrecoverされます。注意としてg()の後の処理は実行されなくなります

goroutineの場合

では別goroutineを用意して、そこでpanicが起きたらどうなるでしょうか。

同じ関数内でのpanic

func main() {
    fmt.Println("Calling f.")
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()

    go func() {
        panic("panic")
    }()

    time.Sleep(time.Second * 2)
    fmt.Println("f has finished")
}

The Go Playground

結果

Calling f.
panic: panic

goroutine 5 [running]:
main.f.func2()
    /tmp/sandbox158058757/main.go:22 +0x40
created by main.f
    /tmp/sandbox158058757/main.go:21 +0x60

このようにpanicをrecoverできません

PanicAndRecover · golang/go Wiki · GitHubの中で

A panic cannot be recovered by a different goroutine.

と書かれている通りですね。

子の関数内でのpanic

func main() {
    fmt.Println("Calling f.")
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from", r)
        }
    }()
    fmt.Println("Calling g.")

    g()

    time.Sleep(time.Second * 2)
    fmt.Println("f has finished")
}

func g() {
    go func() {
        panic("g panic")
        fmt.Println("g has finished")
    }()
}

The Go Playground

結果

Calling f.
Calling g.
panic: g panic

goroutine 5 [running]:
main.g.func1()
    /tmp/sandbox500739411/main.go:30 +0x40
created by main.g
    /tmp/sandbox500739411/main.go:29 +0x40

こちらもNGです。親子の関係ならもしかしたら、と感じそうですが、別goroutineになった時点でdeferは機能しなくなります

github.com

対応方法

エラーハンドリングをきちんとする

そもそもGoでは実装側がエラーハンドリングをきちんとする前提の言語であるため、基本的にpanicが起きる事はありません。
「エラーのハンドリングが冗長だから〜」という理由で安易に削るのは止めましょう。

goroutineの中にrecoverを埋め込む

↑の対応は当然である一方、sliceの処理であったり、外部ライブラリの使用によってpanicが起きる事は可能性としてあります。
その上でgoroutineを使う場合はその関数を実行する時にrecoverを埋め込む形で対応が可能です。

func main() {
    fmt.Println("Calling f.")
    f()
    fmt.Println("Returned normally from f.")
}

func f() {
    go func() {
        // recoverをgoroutineの内部に埋め込む
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered from", r)
            }
        }()
        fmt.Println("Calling g.")
        // 実行したい関数
        g()
    }()
    time.Sleep(time.Second * 1)
}

func g() {
    panic("g panic")
}

The Go Playground

以下のように汎用的な関数を用意するのも1つです。

func main() {
    recoverer(f)
    time.Sleep(time.Second * 1)
}

func recoverer(f func()) {
    fmt.Println("Calling argument f.")
    go func() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered from", r)
            }
        }()
        f()
    }()
}

func f() {
    panic("f panic")
}

The Go Playground

例えば有名な github.com というフレームワークでは、gin.Recovery()というミドルウェアを用意しており、各リクエストをgoroutineで捌く際にdeferでrecover()を埋め込むことで、そのリクエストでpanicが起きてもmain関数が死なないようにしています。
※もちろん前述の通り、リクエスト内で更にgoroutineを増やしてそこでpanicした場合はハンドリングできません。

まとめ

別goroutineの場合はpanicをrecoverできなくなるため、捕捉できずmain関数が落ちることもありえるので注意しましょう。

ソース