こんにちは。この記事では、Goで記述されたアプリケーションの「クリーン」な終了を整理するトピックについて説明します。
保証の存在は、プロセスが(シグナルまたはシステム障害以外の理由で)完了した時点で、特定の手順が実行され、完了するまで出力が遅延するクリーンな出口と呼ばれます。 さらに、いくつかの典型的な例を挙げ、標準的なアプローチについて説明し、プログラムおよびサービスでこのアプローチを簡単に適用するためのパッケージを示します。
TL; DR: github.com/xlab/closer
1.はじめに
そのため、サーバーまたはユーティリティがツイストされた
Ctrl^C
をキャッチする方法に少なくとも一度は気づいたと思います。もちろん、大いに謝罪し、延期できないことを解決するまで待つように求めます。 よく書かれたプログラムは物事を完了して終了しますが、悪いプログラムはデッドロックに陥り、
SIGKILL
見るだけでgive
SIGKILL
ます。 より正確には、プログラムには
SIGKILL
について学ぶ時間がありません。プロセスについては、 SIGTERMと SIGKILLとUnixシグナル 。
主要な開発言語としてGoに切り替え、さまざまなサービスを作成するために長期間使用した後、文字通りすべてのサービスに信号処理を追加する必要があることが明らかになりました。 主に
Go
マルチスレッドは原始言語であるという事実による。 たとえば、次のスレッドは単一のプロセス内で動作できます。
- データベースクライアントの接続プール 。
- パブ/サブキューのコンシューマー。
- pub / sub queueのパブリッシャー。
- 実際のワーカーのNスレッド。
- メモリ内のキャッシュ。
- ログファイルを開きます。
超自然的なことは何もありません(私が気分を害したならすみません)、実際には、バックグラウンドで作業を行い( go-routines )、 go-channels (typed queues)を介して互いに通信するいくつかのエンティティを表しています。 通常のマイクロサービスアーキテクチャサービス。
また、起動時にはすべてが非常に簡単です。最初にデータベースクライアントプールを起動し、起動しなかった場合はエラーで終了します。 次に、メモリ内のキャッシュを初期化します。 その後、パブリッシャーを実行します。起動しない場合は、エラーで終了します。 次に、ファイル(ログなど)を開きます。 次に、コンシューマーを介してデータを消費し、データベースに書き込み、キャッシュに何かを保存し、結果をパブリッシャーに配置するワーカーなどを起動します。 そうです、同じスレッドからではなく、より多くの処理イベントがログに書き込まれます。 そして最後に、コンシューマーデータストリームを開くことでこれらすべてをアクティブにし、外れなければ終了します。
初期化は1つのスレッドで順番に行われ、1段階でエラーが発生した場合、データストリームを開くまでシステムは常にゼロの位置にあるため、既に完了した初期化ステップをロールバックする必要はありません。 そして、彼らはデータストリームを開き、5分後、私たちはすぐに終了し、すべてを完了する必要がありました。
なんで? また、バッファリングされたチャネルからのすべての結果がデータベースへの書き込みプロセスによって取得されたわけではなく、チャネルから読み取られた結果がネットワーク経由でデータベースに到達する時間がないためです。 また、すべてのオブジェクトがpub / subキューで公開されるとは限りません。 すべての労働者が結果を適切なチャネルに送信できなかった。 ワーカーによるキューの消費もバッファリングできます。つまり、オブジェクトのごく一部がキューのpub /サブサーバーから読み取れるが、ワーカーによってまだ処理されていない可能性があります。 たとえば、メモリ内のキャッシュは、プログラムの終了時にディスクにダンプする必要があり、ログデータを含むすべてのバッファーを適切なファイルにクリアする必要があります。 これらのすべてをここにリストし、いくつかのバックグラウンドタスクを持つプリミティブサービスが、アプリケーションの終了を確実に追跡する方法を持つ運命にあることを示します。 そして、それはコンソールの「Bye bye ...」という美しい通知のためではなく、マルチスレッドコンバインを同期するための重要なメカニズムとしてです。
2.ちょっとした練習
Goには優れたツールがあります-defer 、この式を関数に適用すると、特別なリストに追加されます。 このリストの関数は、現在の関数から戻る前に逆の順序で実行されます。 このようなメカニズムにより、戻り時に解放する必要があるミューテックスやその他のリソースの処理が単純化される場合があります。 遅延効果は、パニック(=例外)が発生した場合でも有効です。つまり、遅延関数で定義されたコードが実行されることが保証され、例外自体をこの方法でキャッチして処理できます。
func Checked() { defer func() { // , if x := recover(); x != nil { // , } }() // - , }
しかし、悪意のあるアンチパターンが1つあり
defer
何らかの理由で、多くの場合、
main
機能で
defer
が使用され始めます。 例:
func main() { defer doCleanup() // fmt.Println("10 seconds to go...") time.Sleep(10 * time.Second) }
通常の復帰やパニックの場合でもコードは問題なく動作しますが、プロセスが完了のシグナルを受信した場合、
defer
は機能しない
defer
を忘れていました(Goドキュメントのsyscall exitが実行されます: 「プログラムはすぐに終了します。遅延関数は実行されません。」 )
この状況を正しく処理するには、必要な種類の信号を「サブスクライブ」して信号を手動でキャッチする必要があります。 一般的な方法(StackOverflowの回答から判断)はsignal.Notifyを使用することで、パターンは次のようになります。
sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) go func() { s := <-sigChan // }()
不必要な実装の詳細を隠すために、 xlab / closeパッケージが開発されました。これについては後で説明します。
3.より近い
そのため、
closer
パッケージはシグナルの追跡を担当し、関数をバインドし、完了時にそれらを逆の順序で自動的に実行します。 このパッケージはスレッドセーフであるため、 close.Closeを複数のスレッドから同時に呼び出すときに、ここで起こりうる競合状態について考える必要がありません 。 APIは現在5つの関数で構成されています: Init 、 Bind 、 Checked 、 HoldおよびCloseです。 Initを使用すると、ユーザーは信号およびその他のオプションのリストを再定義できます。残りの機能の使用については、例とともに検討します。
シグナルの標準リスト:
syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGABRT
通常の例
func main() { closer.Bind(cleanup) go func() { // fmt.Println("10 seconds to go...") time.Sleep(10 * time.Second) // closer.Close() }() // , — closer.Close closer.Hold() } func cleanup() { fmt.Print("Hang on! I'm closing some DBs, wiping some trails..") time.Sleep(3 * time.Second) fmt.Println(" Done.") }
エラー例
close.Checked関数を使用すると、エラーをチェックして例外をキャッチできます。 この場合、戻りコードはゼロ以外になり、
closer
パッケージはまだ出力の処理に関与しています。
func main() { closer.Bind(cleanup) closer.Checked(run, true) } func run() error { fmt.Println("Will throw an error in 10 seconds...") time.Sleep(10 * time.Second) return errors.New("KAWABANGA!") } func cleanup() { fmt.Print("Hang on! I'm closing some DBs, wiping some trails...") time.Sleep(3 * time.Second) fmt.Println(" Done.") }
パニックの例(例外)
func main() { closer.Bind(cleanup) closer.Checked(run, true) } func run() error { fmt.Println("Will panic in 10 seconds...") time.Sleep(10 * time.Second) panic("KAWABANGA!") return nil } func cleanup() { fmt.Print("Hang on! I'm closing some DBs, wiping some trails...") time.Sleep(3 * time.Second) fmt.Println(" Done.") }
完了コードの適合表:
| ------------- | ------------- error = nil | 0 () error != nil | 1 () panic | 1 ()
おわりに
したがって、プロセスの終了の根本的な原因に関係なく、Goアプリケーションは必要な「クリーンな」終了手順を実行します。 Goでは、このようなプロシージャを必要とするすべてのエンティティで、このエンティティのすべての内部プロセスを終了するCloseメソッドを記述するのが慣例です。 つまり、この記事の2番目の部分から上記のサービスを完了するには、作成されたすべてのエンティティに対して
Close()
メソッドを逆の順序で呼び出し
Close()
。
最初に、pub / sub queueコンシューマデータストリームが閉じられ、システムに新しいタスクがなくなり、システムはすべてのワーカーが完了して完了するまで待機します。その後、キャッシュがディスクと同期され、データベースへの書き込みチャネルが閉じられ、パブリッシャーチャネルが閉じられます。ログファイルが閉じられ、最後に、データベース接続とパブリッシャー自体が閉じられます。 言葉では十分に深刻に聞こえますが、実際には、各エンティティのCloseメソッドを正しく記述し、closeを使用するだけで十分です。初期化するときにバインドします。 明確にするための
main
スケッチ:
func main() { defer closer.Close() pool, _ := xxx.NewPool() closer.Bind(pool.Close) pub, _ := yyy.NewPublisher() closer.Bind(function(){ pub.Stop() <-pub.StopChan }) wChan := make(chan string, BUFFER_SIZE) workers, _ := zzz.NewWorkgroup(pool, pub, wChan) closer.Bind(workers.Close) sub, _ := yyy.NewConsumer() closer.Bind(sub.Stop) // ( closer.Hold) sub.Consume(wChan) }
同期の成功をお祈りします!