Goの良さをまとめてみた

公開:2020/11/11
更新:2020/11/12
6 min読了の目安(約5500字TECH技術記事

よく知られる良さ

  • ネイティブコード出力で実行効率が良い
  • コードの可読性を重視している
  • 開発でよく使うツールがバンドル
  • クロスビルドが簡単にできる
  • コンパイルが遅くない(LLライクにrunできる)
  • 並行処理の抽象化を組み込み言語仕様にもつ
  • メモリ安全である

上記の一部に解説を加えつつあまり言及されない良さを以下にまとめます。

依存解決が最小限で決定的

https://zenn.dev/nobonobo/articles/81f24d31bfebff6cd0e3

ここにも書きましたが、Goの依存解決は常に

  • 最小限のダウンロード
  • 最小の範囲でのみビルドを実行

だけが走ります。これを一度体験すると、従来のパッケージ依存管理が冗長で余計なものをビルドしすぎることに気づくでしょう。これらに相当の時間を奪われているのです。

また、Goモジュール機構によりそのバージョン選択は決定的に安定動作するバージョンに決められます。このことのメリットは数ヶ月後のリビルドで安定してビルドできることで実感できるでしょう。

開発環境負荷が軽い

Go処理系は「コンパイルを遅くしない」ことを優先してデザインされています。
シンプルなAPIサーバーを依存解決ー>ビルドする時、
前述の通り最小限のダウンロードと最小の範囲のビルドを行う挙動とあわせて
とにかく対象が動き始めるまでの時間は他の処理系に比べると非常に短く保たれています。
また、デバッグビルドとリリースビルドの区別がありません。常にリリースビルド相当です。
他の処理系のリリースビルドはCPUとメモリと時間を多く消費するものが多いです。

このリリースビルドの早さが大きく変わるから開発PCにメモリをたくさん積みたくなり、「32GiB積むのが人権」などと言われたりしますが、Goの場合他にVMを起動したりしてないならPCメモリが「8GiB」あれば困ることはほとんどありません(メモリ不足によるコンパイル速度低下はありません)。

開発支援ツール全部入り

Goには古参の処理系が長らく育ててきた有用なツール相当の多くをかなり初期の段階から一通りそろえてあります。

goコマンド内包ツール

  • ドキュメント生成(godoc)
  • コードフォーマッタ(gofmt)
  • コードジェネレート支援(go generate)
  • モジュール依存管理(go mod)
  • テストランナー/ベンチマークツール(go test)
  • プロファイラー(go tool pprof)
  • コード診断(go vet)
  • etc...

追加されたツール

  • より賢いコードフォーマッタ
  • LSP(エディタ用コード解析サーバー/コード補完、リファクタリング)
  • 多様な静的解析ツール

gofmt最高

gofmtはかなり有用な発明だったと思います。
これのおかげで複数のコラボレータ間の意思調整を一切することなくVCSの差分が本当に変更部分だけが可視化されるようになります。このありがたみはチームで一つのプロダクトに関わった時に強烈に実感できます。

また、gofmtには個人の好みをいれる調整の余地は全く設けられていません。これもまた上記のメリットを強化していますし、その最大化を図るためにGo言語の仕様もまたgofmtに向いたシンタックスを採用しています。

Go言語のシンタックスは自由度がほとんどありません。「Aという風にかけるがBという風にかける」とか「この場合この記号を省略できます」といった要素がほぼありません。行の継続をする記号が限られていることもgofmt出力の一貫性を保つ効果があります。

このありがたみは認知され、gofmtという発明は輸出されどんどん他の処理系も採用しようという動きが増えました。「ほげfmt」というツールやあらゆるコードに対し強制力の強いフォーマッタとして「prettier」などもよく使われるようになりました。

しかし、言語仕様をこの用途向きに修正するわけではないのでいずれもgofmtほど気持ちよく幅広い記述が統一されるほどではありませんし、カスタマイズの余地を残したりしてしまっています。
これだとやはりコラボレータ間の意思調整が必要になってしまいますし、プロダクトごとに違う調整が生まれてしまいがちなのです。

Goのgofmtは脳死でコードフォーマットしてVCSに差分を投げればOKなのが素晴らしいのです。

エラーハンドル方法が画一的

Goのエラーハンドリングは面倒とよく叩かれますが、「ハンドリング方法が一貫している」ことに関しては抜きん出ています。

少し前のJavascriptなんかもPromise.catchがエラーハンドル場所になってだれもtry-catchを書かなくなってましたね。try-catchは同一のスタックでのみ有効な方式でした。非同期が関わるとどうしてもスタックを超えたメッセージの仕組みが必要になるのです。モダン処理系のasync/awaitでは裏で相当苦労して別のスタックにあるエラーをawait側に持ってきて例外処理に乗せ直しています。そうしてやっと再びtry-catchで統一的にエラーハンドリングできるようになりつつあるところ。

よくGoのエラーはなぜ直積の多値返しなんだ、直和でいいじゃないかともいわれますが、エラーのみを返すのにerrorだけを返せばいいのはごく自然ですし、レアケースですが直積が必要な場面もあります。似たケースを直和でハンドリングしている事例を確認してみたところトリッキーなルールが必要になっていました。

なんにせよ、異常系は抽象化&統一が難しいのです。その中でGoは「直積の多値返し」というCPUフレンドリーな方法で統一できているというのは素晴らしいことなのです。

標準の並行記述を並列に最適化

Goはマルチコア時代を迎えるソフトウェアを容易に記述できるというところを第一にデザインされています。goキーワードは初めから言語仕様に含まれており、goroutineという抽象軽量スレッドの概念も2009年の発表以降11年を経過しても一切ぶれていません。

たまに誤解されるのですがgoroutineはcoroutineなどの協調型スレッドと同じではありません。ネイティブスレッドのようにプリエンプティブであり、感覚的にはネイティブスレッドのようなものを軽量に大量に扱えるというものです。Goのランタイムにてネイティブスレッドプールがgoroutineランナーとして動いていて、goroutineはこのプール内の暇なネイティブスレッドを渡り歩きます。このルールにより調整なしでまんべんなく負荷をマルチコアにスプレッドします。

ネイティブスレッドと協調型スレッドは役割は似てますが扱い方は全く異なります。それぞれには守らねばならない制約というものがあります。この使い分けはベテランでもなかなかちゃんと守れていないようなコードを書いてしまいます。そこをGoはgoroutineの扱い方だけを覚えれば良いというメリットがあります。また、協調型スレッドはよく車輪の再発明されます。Pythonなどでも指折り数えて何種類もの協調型スレッドシステムが生まれました。そしてこれらの協調型スレッドシステムは同一のプロセスに混ぜてはいけないという制約があります。つまり「ライブラリの分断」があります。

Goのスレッドシステムは基本goroutineしかありません(ネイティブスレッドをユーザーが起こす需要もありません)。この時サードパーティライブラリを組み合わせて使う制約に悩まされないというのもGoの良さです。ほとんどのサードパーティライブラリはgoroutineと併用可能です。goroutine-safeでなかったりブロックするような処理もラッパーレベルでgoroutineに載せることができます。協調型スレッドの場合そんなわけにはいきません、多くの場合そのライブラリは協調型スレッドシステムに依存した処理の記述が必要です。

ARMコアのPCが出始めて今後はより多くのコアを積んだ環境が増え、マルチコアスプレッド性能が必要に応じて求められることが予想されます。この時Goの成果物はなにも調整することなくメニーコア環境に適応します。

静的解析処理を書くのが容易

Goは高度な抽象化機能や型修飾がないが故に静的解析処理を書くのが容易です。高度な型システムを持つ処理系の場合、その仕組みを正確に理解するのに多くの時間が必要になってしまいます。そして高度な型システムは利用するだけなら意識しなくて済むところも静的解析をするためにはかなり詳細な裏の仕組みを追う必要があったりします。

Goに慣れた人がコードの記述で気をつけるべき点を見つけたらそれを検査してあぶり出すツールをすぐに書き始められます。そのためのライブラリやツールがかなり充実しています。

そのため他の処理系にはみられないほどの多様で高品質な静的解析ツールが揃っています。また、Go言語仕様の変更の少なさからこれらのいろんなツールが長期間にわたって安定動作し続けています。

成果物の依存が最小または無い

Goの成果物出力は極力他のライブラリ依存を載せないような出力を行おうとします。
WindowsやmacOSではOSのシステムランタイム依存を外すことはできませんが、Linuxでは"net"と"os/user"パッケージだけがlibc依存を出力します。そしてこれらのPure-Goによる互換実装が準備されており、ビルド時にタグ「netgo,osusergo」を付与することでlibc依存を切り離すことができます。
詳しくは こちら を参照してください。

スタティックリンクバイナリは同じCPUアーキテクチャの同類OSならそのバイナリさえ持っていけば動作するという特性を得られます。これはアプリケーション配布の手間を大きく簡略化してくれます。
この特性は例えば「RaspberryPi上で動くバイナリをPC上でクロスコンパイル」するような場合に便利です。

Goの出力バイナリが他の処理系に比べて大きいと叩かれることはありますがそれはlibc相当の機能を内包しているからなのです。他の処理系はlibc相当の機能がバイナリの外にあるから小さいのです。libcのサイズ分は差し引いて比較して欲しいところです。

性能はGo中級でC++玄人の9割以上

よくベンチマークなどで「Goは最速実装の半分も速度でない」みたいに叩かれる事例が後を立ちませんが、Goはベンチマークで数字の良くなるような最適化をあえて採用してきませんでした。

代表的なところは以下の2つ。

  • 末尾再帰最適化はデバッガビリティのために非採用
  • HTTPヘッダパースは毎回全て行う(セキュアなHTTPハンドリングには必要)

Goでは再帰の深さが極端に処理性能が落ちる要因になります。
深さの数だけスタックを浪費する実装になっていますのでGoに慣れた人は自然と深い再帰処理はループ処理に書き換えます。(フォルダツリーのような有限な深さであることがわかっているような処理は再帰で書いても問題はありません)

これに関するベンチマークで大きく差の出るようなものは例えばフィボナッチ計算を再帰で実装している場合などGoの最適化が期待できないところかつ比較処理系では最適化が効くところを比較しているような事例です。

APIの秒間リクエスト処理数比較などもそうです。HTTPヘッダパース遅延評価機能を持つものと持たないもののベンチマーク等ではヘッダへのアクセス不要な簡易負荷で比較しているような状況になりがちです。特にデータベースアクセスなどを伴わないような場合だとその性能差は大きな差として現れます。

実際、Goのサードパーティライブラリ「fasthttp」はHTTPヘッダパース遅延評価機能を持っており、この場合ベンチマーク結果の数値は最速実装にかなり近づきます。しかし、HTTPヘッダは実用の現場の場合、評価しないわけにはいかないので実用上の性能は標準のnet/httpと大差ないということが実際にあり得ます。

結局のところ比較する処理系双方を書き慣れている人が実用的な実装をベターに書いた場合、そんな大きく差を開けられることはありません。C++もGoもやたらとオーバーヘッドを持つような処理はないネイティブコードを走らせる処理系なので。

僕の感覚ではC++コード性能の9割はGoで出せると思っています。また、C++玄人コードの9割の速度を出すGoコードはGoの中級者でも十分出せます。

あとの1割を追い求めるとGoは辛くなるのは確かなんですが、Goのユーザーはそこを求めてはいません。Goの真骨頂はさっくり9割だせてもっと処理性能が欲しいのなら環境に投資して引き上げることができる「マルチコアスプレッド性能」があることなのです。