kawasin73のブログ

技術記事とかいろんなことをかくブログです

原因不明のエラーなどない

お前のいう想定外はただの調査不足。どうも、かわしんです。新年度が始まり周りのみんなは働き始めているのを感じますが、僕は4月6日入社なのでもう少しモラトリアムを満喫します。

さて、去年開催された GoCon 2019 Spring で「エラー設計について / Designing Errors」という発表がありました。

docs.google.com

この発表ではエラーを「既知のエラー」「未知のエラー」と分類し、アプリケーションを動かしながら発生した「未知のエラー」に対するハンドリングを追加しながらどんどん「既知のエラー」に変換していくことで、ハンドリングされないエラーを減らしていこうというアプローチを紹介していたものと僕は認識しています。

しかし、具体的にエラーとはどこから発生するのかという説明がなかったため、いつどこから発生するかわからないエラーと戦い続ける必要があるようにも誤解される方がいるかもしれないと思ったのでこの記事を書くことにしました。

結論からいうと、全てのエラーは事前に予測可能であり恐れる必要はありません

前提

ここでは OS の上で動くソフトウェアのプログラミングを対象として扱います。C 言語とか Go とかの高級言語でのプログラミングです。

カーネルの中とか FPGA などのハードウェアプログラミングは対象としませんが本質は同じではないかなと思っています。

エラーの実態

エラーはプログラミング言語や書き手によって表現方法が違いますが、大抵の言語では「返り値」「例外」「シグナル」のどれかまたは複数で表現されます。どれを使うのがいいかということについては色々な考え方があると思うのでこの記事では触れません。

エラーはどこで発生するのか

我々がプログラミングをする上で扱うエラーの発生源は大きく以下の3つに分類できます。

  • 自分で定義して作り出すエラー
  • 関数を呼び出した時に発生するエラー
  • その他の実行時エラー

「自分で定義して作り出すエラー」は内部状態に対して入力値が異常である場合に処理を中断して発生させるエラーです。例えば、入力値のバリデーションエラーであったり、残高がないのにお金を引き出そうとしていた時のエラーなどがわかりやすいと思います。

「関数を呼び出した時に発生するエラー」は呼び出した関数の中で定義されて作り出されたエラーであったり、その関数がさらに別の関数を呼び出して発生したエラーであったりします。これらエラーはそのまま呼び出し元に返されることもありますし、別のエラーに変換されて返されることもあります。

さらに関数が呼び出した関数によるエラーを突き詰めて辿っていくと、全て「関数の作者が定義して作り出すエラー」と「システムコールから発生するエラー」にたどり着きます。システムコールは OS 上で動くソフトウェアとカーネルの境界をつないでいる命令で、 C 言語や Go などの大抵のプログラミング言語では関数の形で定義され、関数呼び出しと同じように扱うことができます。

システムコールがわからない人はコンピュータサイエンスの基本なので調べてみてください。簡単に説明すると、普通のプログラマが書くプログラムは「ユーザランド」という安全な領域で動き基本的にはメモリ演算しかできず、ネットワーク通信やファイル I/O、ディスプレイの出力、ハードウェア機器の操作などは悪いプログラムに乗っ取られるのを防ぐために全て「カーネルランド」という隔離された領域でしか実行できません。しかし、ユーザランドでしか動かないソフトウェアは何もできないので、ユーザランドは「システムコール」を発行してカーネルランドに処理の実行を依頼するという形になっています。

「その他の実行時エラー」は、不正な CPU 命令や不正なメモリアクセスを想定しています。これらが発生した場合は CPU 例外が発生し OS で定義された例外ルーチンが実行されアプリケーションは停止されたりシグナルが飛んできたりします。(詳しくないので間違っていたらすみません。)しかし、これらのエラーは大抵のメモリ安全な高級言語では仕組み上そもそも発生しえないものです。逆に発生するとデバッグは大変です。

また、ゼロ除算も実行時エラーです。言語によってはゼロ除算をした時の挙動は未定義だったりしますが言語によってはゼロ除算をするときにエラーを発生させてくれます。

全てのエラーは定義されている

さて、エラーの発生源を辿っていくと全て自分か誰か(ライブラリの作者など)が定義したエラーかシステムコールから発生するエラーにたどり着きました。

自分か誰か(ライブラリの作者など)が定義したエラーは自明に定義されていますが、システムコールから発生するエラーも全て定義されています。

例えば外部とのネットワーク通信で使うソケットの読み取り命令 recv を見てみましょう。recv man page とググれば以下の Linux のマニュアルが出てきます。または、ターミナルで man 2 recv と実行すると実行した OS のマニュアルが読めるはずです。

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/recv.2.html

ここにはエラーの章があり、全ての発生しうるエラー(EAGAIN EWOULDBLOCK EBADF ECONNREFUSED EFAULT EINTR EINVAL ENOMEM ENOTCONN ENOTSOCK)とそのエラーがどのような時に発生するのかが定義されています。逆に言えばここに定義されていないエラーは絶対に発生しません。

ここまでで、原因不明のエラーなど存在せず全てのエラーが定義されているということがわかったと思います。

エラーハンドリング

さて、(理想的には)全ての関数から発生するエラーは全て定義されているのでそれに対するエラーハンドリングをしていけばいいということになります。

ここでエラーは大きく2つに分類されます。「ハンドリングする(回復可能な)エラー」か「ハンドリングしない(回復不能な)エラー」です。全て定義されているのでそこに想定外のエラーなどありません。

その関数のレイヤーで回復可能な場合はエラーハンドリングをして処理を続行し、そのレイヤーで回復不能な場合はそのエラーを呼び出し元に返し上のレイヤーでエラーハンドリングされることを期待します。

発生したエラーを全て上のレイヤーに戻していればプロセスは異常終了してしまいます。要件の厳しくないアプリケーションサーバなどであれば異常終了させて再起動して後でツギハギの対応をすればいいかもしれませんが、データベースサーバなど一度起動したら数年単位で落ちることが許されないソフトウェアであれば実装時に1つ1つのエラーを精査して回復可能なエラーを拾っていきます。想定外のエラーはただの調査不足です。

しかし現実

しかし、現実はそんなに簡単ではありません。ライブラリから発生するエラーがドキュメントなどで明確に定義されていない場合があります。その場合は冒頭で紹介したような「未知のエラー」を「既知のエラー」に変換していくアプローチが有効だと思います。

一方でもう1つ解決策があります。それは「ライブラリを使わない」ことです。ライブラリを追加すれば追加するほど自分が理解していないコードが増え、把握できていないエラーが発生する可能性が高まります。関数ごとのエラーが定義されていないライブラリは使わずに自分でシステムコールの上にエラー定義されたライブラリを築き上げていくのも有効な解決策だと思います。大変ですが。

言いたかったこと

  • 関数を作るときは発生し得るエラーを定義しろ
  • 全てのエラーを精査して、必要なハンドリングをしろ
  • エラーは全て定義されていて予測可能である。恐れる必要はない

番外編:境界をずらす

エラーの発生源は境界ごとに定義されていることで想定外のエラーのないソフトウェアを作ることができます。アプリケーションとライブラリとの境界(ライブラリ関数呼び出し)、ユーザランドカーネルランドの境界(システムコール)を今回の記事では紹介しました。システムコールについては完全にエラーが定義されドキュメント化されています。

さらに、システムコールの実装の中身をカーネルの中に辿っていくと同様に「カーネルの中で定義されたエラー」と「ハードウェアから発生するエラー」にたどり着きます。ハードウェアから発生するエラーはソフトウェアとハードウェアの境界で定義されたエラーです。

このように境界ごとに明確にエラーを定義することで想定外のないソフトウェアが実現可能になります。

番外編:ハードウェア障害

この記事では暗黙の前提として、ハードウェアが正しく動くことを前提としていました。しかし、ハードウェアも壊れることがあります。メモリが破壊されたり、ストレージが破壊されたり。これらの障害は OS のレイヤーで検知されてエラーとして発生することもありますが、アプリケーションが管理している領域のメモリの値が変わってしまったり、1 + 1 を 3 と CPU が計算してしまったような場合には対応が難しいです。エラーハンドリングの処理自体が正しく動くかわかりません。僕は、普通のソフトウェアを作っている場合はこのような状況はどうしようもないので異常終了するべきだと思いますが、世の中にはこのようなコンピュータが正しく動かないことも想定したソフトウェアの世界があるのかもしれません。(ないのかもしれません)