Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

goroutine内のpanic handling

こんにちは。 京都開発本部の櫻(@ysakura_)です。

今回のテーマは、goroutine内のpanic handlingです。

panic handlingを行う事でアプリケーションの予期せぬ終了を防止できます。 今回扱うgoroutine内のpanic handlingを把握する事で、予期せずAPIサーバーが終了する事や、レスポンスを返さない事を防止できます。

panicとは

panicのおさらいです。 panicは、コールされるとプログラムが終了するビルトイン関数の事です。

panicの使い方

panic("hoge")といった様に、ビルトイン関数であるpanicを呼ぶと使えます。 recoverというビルトイン関数を呼ぶ事で、panicによるプログラムの終了を止める事も出来ます。

panicの挙動

Goの仕様によると、以下の様に書かれています。

While executing a function F, an explicit call to panic or a run-time panic terminates the execution of F. Any functions deferred by F are then executed as usual. Next, any deferred functions run by F's caller are run, and so on up to any deferred by the top-level function in the executing goroutine. At that point, the program is terminated and the error condition is reported, including the value of the argument to panic. This termination sequence is called panicking.

以下が意訳です。 ある関数Fでpanicを呼んだとして、panicは以下の挙動をします。 1. Fの実行を終了する 2. Fのdefer文が通常通り実行される 3. Fの呼び出し元のdefer文が実行される 4. 実行中のgoroutineにおけるトップレベル関数のdefer文まで3が繰り返される。 5. プログラムが終了し、エラーの状態が出力される。

ちなみに、Goのmain関数はmain goroutineに該当する為、mainも上記の挙動をします。 in the executing goroutineと書かれている通り、panicはgoroutineを跨いだ際にはハンドリングが出来ません。今回はこのgoroutine内でのpanic handlingについて掘り下げます。

panicの目的

Effective Goによると、以下の様に書かれています。

But what if the error is unrecoverable? Sometimes the program simply cannot continue. For this purpose, there is a built-in function panic that in effect creates a run-time error that will stop the program

つまり、プログラムが続行できない等の回復出来ないエラーに対して、panicが使われます。 具体例として、Nil Pointerの実体参照やSliceのindex out of rangeが該当します。 自分は、起動時の設定読み取りやDB接続が確立できなかった際など、アプリケーションの起動処理が正常に行われなかった際にpanicを利用しています。

panic handlingの方法

panic handlingの方法を2例紹介します。

シンプルな例

個人的に一番よくみる例です。 playgroundでの実行例

package main

import (
    "log"
)

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered. %v", r)
        }
    }()
    hoge()
}

func hoge() {
    panic("hoge")
}

defer文内でrecover()を呼び、panicをhandlingします。 recover()の戻り値は、panic関数に入れたinterface{}です。

スタックトレースを出す

スタックトレースを出す例です。 playgroundでの実行例

package main

import (
    "log"
    "runtime/debug"
)

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered. %v\nStack:\n%s", r, debug.Stack())
        }
    }()
    hoge()
}

func hoge() {
    panic("hoge")
}

runtime/debugdebug.Stack()を使って、スタックトレースが出力できます。

goroutine内でのpanic

次は、goroutine内でのpanicについて解説します。 mainとは別のgoroutine内でpanicを起こす関数を考えてみます。 前述の通り、main goroutine内でrecoverをしていても、recoverする事は出来ません。 ここではその挙動を確認します。 playgroundでの実行例

package main

import (
    "log"
    "sync"
)

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered. %v", r)
        }
    }()
    
    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        hoge()
    }()

    wg.Wait()
}

func hoge() {
    panic("hoge")
}

main goroutine内でpanic handlingを行った上で、別のgoroutine内でpanicを呼んでいます。 goroutineの実行完了を sync.WaitGroupで待つようにしています。 実際に実行してみると、"Recovered"が表示されておらず、recoverされていないことが分かります。

goroutine内でのpanic handling

goroutineを跨いだpanic handlingが出来ない事を確認しました。 ここでは、goroutine内でのpanic handlingの方法を解説します。

一番シンプルな方法

シンプルな例です。 playgroundでの実行例

   go func() {
        defer wg.Done()
        defer func() {
            if r := recover(); r != nil {
                log.Printf("Recovered. %v", r)
            }
        }()
        hoge()
    }()

goroutineの中でrecoverします。 goroutineの数が増えてきた場合には、対応箇所が多くなり大変な可能性があります。

goroutineを独自packageで管理する

goroutineの呼び出しを自前のpacakge経由にし、その中でrecoverをする様にします。 goroutineが自前のpackageにラップされるので、あまり推奨はしません。 適切に管理できる場合は利便性が上がると思いますが、思わぬ副作用を与える危険性があります。 例えば、recovery.Goではinterface{}として引数を渡すので、interfaceの変換が必要になり複雑になります。

package recovery

import (
    "log"
)

func Go(i interface{}, f func(arg interface{})) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("Recovered. %v", r)
            }
        }()
        f(i)
    }()
}

応用編: APIサーバーでのpanic handling

応用編として、APIサーバーでのpanic handlingについて紹介します。 REST APIやgRPCなどのAPIサーバーでは、リクエスト毎にgoroutineが呼び出される事が多いです。 この際、各Handlerでpanic handlingを行うのは大変です。 以下ではpanic時の挙動とhandlingの具体例を見ていきます。

panic時の挙動

net/http

serve関数内でrecover()がコールされています。(実装) しかしレスポンスが返らない為、信頼性を高くするにはpanic handlingが必要です。 labstack/echoの様なHTTP APIのライブラリも、内部的にはnet/httpを使っている事が多いので同様の挙動をします。

grpc/grpc-go

goでgRPCを使う際に利用するライブラリです。 grpc-goではhandlerがpanicするとプロセスが終了します。

panic handling

net/httpを使う場合は、Middlewareパターンを用いてrecoverをすることが多い様に思います。 3rd Partyのライブラリではrecover用のmiddlewareが用意されている事が多いので、その関数を利用しましょう。 以下では、よく使われる2つのライブラリでの例を紹介します。

labstack/echo

HTTP APIで有名なライブラリです。

e := echo.New()
e.Use(middleware.Recover())

middleware packageのRecover()を呼ぶ事でrecoverが可能です。(ドキュメント) panicが起きると、panicのスタックトレースがログに出力されます。 クライアントにはInternal Server ErrorのHTTP Statusが返ります。

grpc/grpc-go

goでgRPCを使う際に利用するライブラリです。

import (
    "google.golang.org/grpc"
    grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
)
func main(){
    myServer := grpc.NewServer(
        grpc.UnaryInterceptor(grpc_recovery.UnaryServerInterceptor()),
    )
}

grpc-goでは、Interceptorを呼び出す事でpanicのハンドリングが出来ます。 panicが起きると、panicのメッセージがクライアントに送信されます。

まとめ

goroutineを跨いだ際のpanicはrecoverする事が出来ません。 なので、goroutineを利用する場合は、panic handlingを意識する必要があります。 是非、適切にpanicをhandlingして、goroutineを最大限活用してください。

 

マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。

【サイトのご案内】 ■マネーフォワード採用サイトWantedly京都開発拠点

【プロダクトのご紹介】 ■お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』