Open25

改めて見直すGoの特徴

NoboNoboNoboNobo

極力Goならではな特徴をいくつか挙げていく。

依存解決が必要最低限で互換性を考慮しつつ決定的

モジュール単位で依存をダウンロード。コンパイル対象はサブパッケージ単位。
依存の明示方法はコードに埋め込まれ、かつ未参照のインポートはコンパイルエラー。

つまり動作するコードのすべては正確な依存ツリーが明示されていて余計な依存は引き込まれない。
そして持ち前のコンパイルの速さを含め、相当深い依存ツリーでも依存解決にかかる時間は既知の処理系の中でも最速レベル。(唯一勝てるのはプリビルドバイナリが配布されている場合くらい)

また、コンパイルやリンクに必要な処理量そのものが比較的少ないため、開発環境負荷も小さい。
かなり巨大なプロジェクトであってもメモリ8GBで困るようなことが無い。つまり、CI環境の維持にもローコストで済む。

ライブラリの提供側では後方互換性が破壊されるような変更はV1->V2というようにメジャーバージョン番号を更新するという推奨ポリシーがある。この場合、Goの依存解決でV1を参照していた場合、アップデートではV2が存在していてもV1系列の最新版までしかアップデートしない。

この仕掛けのおかげで既存のアプリケーションが依存のアップデートを行ったときに期待しない結果になる可能性は低く抑えられる。

また、以下の記事の内容も依存解決を無駄に増やさないことを後押ししている。

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

NoboNoboNoboNobo

圧倒的なポータビリティ

とにかく特定のOS、特定のCPUに深く依存した機能提供はしない方針。スレッドに関しては言語埋め込みのgoroutineという抽象スレッドに統一されていて、OSのスレッドやフォークなどは意図的に触れられないようになっている。
Goの標準ライブラリはメジャーなプラットフォームで極力同じような挙動になるよう調整されている。
その代わりOSの特徴的な機能にはアクセスしにくい面はあるが、この方針はメジャーなサードパーティライブラリにも継承されており、Goの成果物の多くはコードを改変することなく他のプラットフォームでも動作可能になる。

多くの処理系がOpenSSL依存しているが、標準でGoはPureGoで実装されているのでOpenSSL依存からは独立している。

また、CGOを使うとこのポータビリティが劇的に低下してしまうので、PureGo実装を増やそうという傾向がある。

- WebRTC関連機能をGoで再実装したpion/webrtc
- SQLite3をPureGoに変換したmodernc.org/sqlite
- cairo(ベクターグラフィックスライブラリ)ライクなPureGo実装canvas

幾つかコツはあるが、ラズパイ用のバイナリをx86_64環境でクロスビルドし、それをラズパイに転送して運用ということが他の処理系に比べると非常に簡易に実現できる。

NoboNoboNoboNobo

Goが思ったほど早くないという誤解

「ネイティブコードを吐く処理系よりもJavaやC#のJIT結果のほうが早いという逆転現象」は注目を浴びすぎていると思う。そのような現象はいろんな実装に踏み込んでみればわかるが結局のところ最適化が上手くかかったレアケースのみ。そのレアケースこそが重要な分野でもない限りその他のメジャーケースにおいて底力のようなものは当然ネイティブコードのほうが上。

これのほうがGoの何倍も速い!みたいな記事を見に行くと、対等な挙動じゃないものは論外として再帰呼び出しを利用したコードだったりする。その場合、Goは普通にループで書き直すとそんなに見劣りしない速度になる。

また、シンプルなマイクロベンチマークでGoがやたら遅いというのも実は理由があったりする。Goの最適化やライブラリ実装はマイクロベンチマークでは大して性能を発揮しないことがある。ある種「ベンチマーク最適化」と呼ばれるような最適化をあえて実装していないから。Goは実際の課題を安全にかつ複雑にならない様に解決するという点に重きを置いていて、特に以下のような最適化はあえて実装していない。

  • CPU機種によって性能差が出てしまうような最適化
  • 関数型にありがちな末尾再帰最適化や副作用のない関数のメモ化
  • HTTPハンドリングにおけるヘッダーの遅延評価

なので、これらの最適化を期待した実装では最適化の有無で大きめの差が生まれてしまう。しかし、これらの最適化なしでも同等の実装はそんなに難しくないのでそちらで書いてというのがGoの実装方針ではある。

特にヘッダーの遅延評価というのはベンチマーク特化な最適化で、シンプルなベンチマークではヘッダーを参照しないシンプルなAPIで行われることが多い。つまりヘッダーは読み飛ばして一切パースしないのと同じ状態になる。ここをGoのライブラリの多くは毎度すべてのヘッダーをパースするのだからベンチマークの結果は当然低くなる。が、実務上のAPIはヘッダーをパースしなければ安全なAPIを提供できない。なのでこういったベンチマーク結果を期待しても実用上のAPIではそんなに大きく差が生まれないなんてこともある。

NoboNoboNoboNobo

やはり「JSに負ける」などという誤解コメントがあった。それはPythonよりもPyPyのほうが早いという話と同じで処理速度の代わりになにか別のリソースを無駄に消費してしまっている。例えばコード量なりメモリなど。ネイティブと非ネイティブ実装の性能逆転現象は実用実装の中では結局ほんの一部に過ぎない。例えばDockerやKubernetesがnodejsで再実装されたりするかというとたぶん不可能(やったとしても運用上や性能上残念ポイントがいくつか発生して誰も使わない)。

ここではマイクロベンチマークをあてにするなということを言っていて、結局のところ実用的な実装を運用した時の性能を比べればわかるがGoは遅くはない。C/C++やRustの得意分野においてもちゃんと同等の実装をすれば9割程度の性能を出せる。C/C++やRustなどに比べ遅いのは確かだが、「最悪1割程度」のことで「数倍遅い」というのは誤解があるということ。

じつは「9割程度」というのはかなり謙虚な数字だ。後述のWebFramework最速の99.8%を出すようなものも存在するので、細部まで注意深くPure-Goで書けばC/C++やRustにそん色ない実装が書けるという事なのだ。

NoboNoboNoboNobo

13年変わらないCSPイディオム

goroutine、select、chanによるCSPイディオムサポートは言語埋め込みでその役割はGoの1.0(13年前)から一貫して変化がない。そしてGoの場合、マルチスレッド実装のほとんどがgoroutineだけで構成されており、goroutineの仕組みはGoのバージョンが上がるたびにどんどんチューニングが進んでいて、少しづつ過去に書かれたコードも含めて効率が良くなっている。

あらゆるブロッキング処理や非ブロッキング処理もgoroutineに載せられる。他のスレッドシステムだとどうしても何らかの制約があったりする。

  • OSスレッドはメモリを大量に消費するのであまりたくさんは作れない
  • 協調型スレッドだとブロッキング処理を混ぜてはいけない

goroutineは軽量かつプリエンプティブなOSスレッドのように良いとこどりの性質がある。なのでGoではわざわざOSスレッドを使うようなコードは不必要で、湯水のごとくたくさんのgoroutineを扱える。

そして非同期処理の一部を同期させたいとき、selectとchanの組み合わせでいろんな同期ニーズに対応することができる。

またこの変わらないCSPイディオムはこの13年で多くの蓄積資産が生まれた。近年のコードをオープンにする波に乗っているGoのコードはネット上にサンプルの宝庫がある。このことはChatGPTやGitHub-Copilotなどのコード支援精度にも大きく寄与している。

CSPイディオムを学べば比較的容易にマルチコアスケール性能を引き出すことができる。手元のPCやサーバーはこれからもコア数は増えていく見込みであり、マルチコアスケール性能の重要性はどんどん増していくと予想される。

シングルコア性能を限界まで引き出すことよりも、マルチコアスケール性能に直結する「並列度」を1%でも上げることのほうがトータルのシステム性能を上げやすいという認識。「並列度」の高いシステムはCPUへの投資でさらに性能を引き上げやすい。しかもCSPイディオムはこの「並列度」の引き上げを狙ったイディオムである。

並列度に関する解説はこちらを参照。
https://zenn.dev/nobonobo/articles/e43cdca80650e4

NoboNoboNoboNobo

GCが弱点のように言われたのは過去のものになりつつある

GoのGCはどんどんレイテンシ重視型にチューニングが進んでいる。もちろんトレードオフでスループットの低下が懸念されるが、そこはプールやアリーナを使ってカバーしようという方向に。

スループットが重要な実装ではプールやアリーナでメモリ管理を考慮した実装を書いて、それ以外はGCにお任せして楽ができるというのがGoの良さの一つ。

NoboNoboNoboNobo

GCあるからリアルタイム系で使えないコメントについて

Goでもプールやアリーナを使うことでスループットを保ちつつクリティカルな処理中にだけGCを実質動かさないというアプローチをとる事は可能。セットアップや後始末など処理時間の差が大して重要じゃないところがGCで楽ができるというのは有利な点なはず。頑張って書けばGC不要ということは「あらゆるコードで頑張って書かなきゃいけない」というデメリットになる。

また、リアルタイム系は処理性能要求が上がりがちだけど、そうなると次で説明するあまりがちな「マルチコア性能」を使いたくなってくるはず。リアルタイム代表格のC/C++でそこを使い切るのは「特定OSでの熟練スキル」が要求される。Rustなら良いのかもしれないがZigというRustよりもさらに無駄の少ない非GC処理系も生まれている。Goはシングルコア的には不利かもしれないが、GoでCSP活用した実装ならマシンに投資してよりコア数を確保した時に性能がスケールすることが期待できる。

というわけで、資産の問題を除けばなにがなんでもC/C++でということもなくなってきている。

  • Zigで頑張る
  • Rustで頑張る
  • Goでクリティカルセクションだけ頑張る

という選択もありなのではと思った。

NoboNoboNoboNobo

GoでマニュアルGC

debug.SetGCPercent(-1)という処理を入れるとGCは停止する。
さらにruntime.GC()を手動で呼ぶことでGCを任意のタイミングで動かすことができるし
debug.SetGCPercent(100)で再開することもできる。

この手法を活用すればクリティカルセクションにおけるGCを回避することができる。

NoboNoboNoboNobo

マルチコアパラダイムへのシフトにマッチ

トランジスタ数の増加は年々増え続けているのに対し、クロック周波数は2003年以降は横ばい、シングルコア性能の伸びは鈍化。トランジスタ数の増加分とシングルコア性能の間に隙間が現れた。この隙間を埋める性能を発揮するにはマルチコア性能の活用つまりマルチコアパラダイムへのシフトが急務となった。

しかし、古参の言語処理系でマルチコア性能を引き出すには特定プラットフォームにおいてパワーユーザーが書くようなライブラリかフレームワークのようなものが必要だった。もしくは新参のマルチコアパラダイムサポートのある言語処理系を使うこと。Erlangを筆頭にHaskell(GHC)などがマルチコアパラダイムをサポート。

この隙間を埋めるべく設計されたのがマルチコアパラダイムをサポートするGo言語なのである。そしてこの隙間は今後どんどん拡大する事が予想される。

GoでCSPを活用して作られた実装はマルチコアフレンドリーで、しかもメジャーなOS環境のいずれにおいてもその性能を発揮できる。パワーユーザーを必要とせずにマルチコア性能を引き出せる。

ちなみにGoとほぼ同時期に生まれたRust言語はこの隙間を埋めることは主眼になく、早々に非同期サポートを捨ててシングルスレッド性能の限界を引き出すことに主眼を置いていた。後発でマルチコア性能を引き出せるような機能がライブラリで作られたという経緯がある。

ErlangやGoなどの生まれ持ってマルチコアパラダイムサポートがあるものと後付けのマルチコアパラダイムサポートでは双方の開発を体験すれば実感できるがかなり質の異なる開発体験になる。前者は世の中に公開されているライブラリがすべて問題なく取り込んで利用できるが、後者の場合、後付け実装が複数存在し、それらへの依存のあるライブラリらは同じプロセス内では混在が許されない。つまりA派とB派などに分かれライブラリ資産の分断がある。

また、上記の隙間を埋めるもう一つの技術がコンテナである。コンテナ群の実体はマルチプロセスだが、マルチプロセスによってOSの助けを得てマルチコア性能を発揮しようという仕掛け。このコンテナ周辺技術のおかげで一部の分野ではシングルスレッドプログラミングでもそこそこ十分な性能を引き出すことが可能になった。そしてこのマルチコア性能が要求されるコンテナ周辺技術では、DockerやKubernetesを筆頭に急速発展させたのがGoによる実装だった。

自動化や利便性を提供するアプリ寄りではまだまだシングルコアでいいや感があるけれど、大量のデータ処理が必要なアプリやインフラ寄りの分野ではもはやマルチコアパラダイムは必須になっている。マルチコアパラダイムへのシフトはゆったり進行している。

NoboNoboNoboNobo

言語仕様への変更が極端に少ない

  • 1.0~1.20(約13年)に至るまでに言語仕様への変更はごく軽微なもの1~2点と、
  • 大きな機能追加としては1.18リリース時のジェネリクスくらい

これは他の言語処理系に比べると異常に少ない。

また、ジェネリクスが無かったころ、型横断するためにLLのような動的型の実装がいくつか生まれたりはしたが、Goに期待されるものに対しミスマッチだったので積極的に使われることはなかった。

以上のような状況のおかげか、Goの初期のコードは現在最適に書き直したとしてほとんどのコードに変化はない。「LLのような動的型の実装はジェネリクスで書き直す」というレアケースのみ。なのでWebで古いコードを見つけた時、今風に書き直すというような作業が発生しにくい。

また、言語仕様の変化が少ないことは静的解析ツールの維持コストも少ないという事。Goは他の処理系に比べ、高品質な静的解析ツールが充実しているし、積極的に利用することで開発効率も上昇する。GoではWebフレームワークとしての機能は控えめなのに、静的解析に関しては充実したフレームワークがあり、静的解析ツールを自作するためのコストも少ない。

NoboNoboNoboNobo

開発ツールが省リソース

でかいC/C++プロジェクトをラズパイなどでビルドする時、千個ファイルをビルドするのに半日かかったり、リンク時にメモリが枯渇したりといったことはありがち。Goの場合、ラズパイ上でかなり巨大なプロジェクトでもビルドは可能だし思ったよりも時間はかからない。よほどでかいと思ったものでも20分以上待たされたことはない感じ(もちろん、CGO経由でC/C++資産に依存している場合はその限りじゃない)。

ここ数年使っていた開発マシンも8GBメモリで十分で困ることはなかった。もちろんブラウザのタブを開きまくったりVMを動かしたりすると足りないが。

とにかくコンパイル時の待ち時間は少ない。SSD上で開発しているとコンパイル時間が一瞬過ぎてスクリプトを実行しているような感覚に襲われる。そしてそれがGoではデフォルトコンパイルがリリースビルド並みの最適化が実施済みなのも他の処理系とは異なるところ。

この特性はCIに組み込んだりコンテナ上でビルドする場合などに「省メモリかつ短時間(=ローコスト)」で処理できるのも強み。

NoboNoboNoboNobo

Rustとの比較も書けとコメントいただいたのでざっくり挙げてみる

  • 上記の内容の多くはRustには無い特徴(実はRustをイメージして書いた)
  • Rustもマルチコアスケール性能はあるがGoのそれとは質が異なる
  • 逆に上に書いた「Goの苦手な領域」にはRustが活躍できる
  • GoとRustはコマンドラインツールを作りやすい以外は実は得意分野が被らない
  • RustはC/C++資産の活用や置き換えが得意でGoはそうではない
  • どちらかというとGoはJavaっぽい用途に向いている

という印象を持っている。なのでGoとRustを比較しても仕方がない面はある。

Rustの成果物は素晴らしいという話はよく聞く。確かに成果物だけに注目するとGoより圧倒的にRustのほうがよく見えてしまう。が、実際に開発してみるとC/C++などに比べると圧倒的に開発体験は良いが、Goと比べるといろいろツラミが見えてしまう。やはり、Rustは打倒C/C++が主眼なんだと思う。

Rustの良さを伝えてくれる記事ではGoよりも高品質な成果物を出力できることは書いていても、ターンアラウンドタイム(修正してもう一度テストするまでの時間)などの開発体験についてはあまり言及してくれない。

  • 例えばhttp負荷ツールをcargoでインストールしようとすると無関係な大量のサーバー実装依存まで引き込んでコンパイルを始めてしまう
  • ニッチなライブラリを見つけて取り込もうとすると基盤の非同期ライブラリがミスマッチで使えない
  • 作りこんでいるうちにいつの間にか環境依存が発生していて、他の環境で動かそうとすると修正が必要になったりする
  • 特にコンテナむけの実行環境づくりでは依存解決の遅さがかなりの時間ロス

以下にもまとめたけれども、これは裏を返せば環境依存の機能をちゃんと呼びだせる環境がそろっているという事でもある。つまり、特定環境にフォーカスして作りこむことに主眼を置いている印象。Goは特定環境に縛られないようにデザインされている。

https://zenn.dev/nobonobo/articles/5b1872497502d5

結論としては「RustかGoのどっちを学びますか?」と聞かれたら回答は「それぞれ得意分野は異なるので両方やりましょう」で。そして課題に対して有利な方を使うといいと思う。

NoboNoboNoboNobo

Goの人気はもう下火という誤解

そんなことない。本気でそう思っているのならインフラ寄りの業界を知らなさすぎる。一部のインフラ系企業がコードをオープンにしないという側面もあるが・・・。インフラ寄りではGoの採用がかなり増え続けているし、インフラ系でなくともほとんどの開発者はDockerというGoの成果物にお世話になっているはず。Dockerが下火になっているというのと同じ誤解だ。DockerビジネスがどうかはともかくGo製のDocker周辺ツールはまだまだ使われているし当分置き換えられる様子もない。置き換えるのに大きなアドバンテージが無い。サーバー側の場合、K8Sはもちろんロードバランサーやゲートウェイのようなネットワークサーバー実装を筆頭にGo採用例は増えている。

上にも書いたけれど、Web業界はコンテナ応用でマルチコアスケールさせることでマルチコアパラダイムシフトに遅延が発生しているだけ。今後特にサーバーサイドはCPUコア数は3桁に突入してマルチコンテナにおいてもメモリ効率からそれぞれマルチコアパラダイムが一般化していくと予想する。

Goは目新しさはないとよく言われるが、「カジュアルにマルチコアパラダイムが扱えてネイティブコードで動く」という点は抜きんでていてこれに代わるような処理系は「Pony」ぐらいしか思いつかない。コンパイル時間が長いのを我慢するならGHCやRustを使うという手もあるがそうするためにはマンパワーや環境への投資がどうしても大きくなると思われ。

ちなみにGoに比べRustがそこそこ人気なのは日本だけだったりする。中国を筆頭に欧米などでもGoのほうが人気という面ではかなり上。

https://trends.google.co.jp/trends/explore?hl=ja&tz=-540&date=today+5-y&geo=JP&hl=ja&q=%2Fm%2F09gbxjr,%2Fm%2F0dsbpg6,%2Fm%2F0zm_2_r&sni=3

たまにRustのほうが高いのは同名のゲームタイトルに引っ張られているだけ。

NoboNoboNoboNobo

NetflixやABEMA、TVerなどメディアストリーミング系はごりごりGoつかっているし、
AkamaiやCloudFlareなどCDN企業もガンガンGo使ってる。
SAKURAインターネットなどのクラウド事業者や、新興SNSのBlueskyなども。
AWSやGCP、GitHubもメインとまでは言えないけど裏方やツールの類ではかなり使ってるし、マイクロソフトもリポジトリ覗いてきてみればガンガン使ってるのがわかる。

Go関連のニュースフィードを見てると「人気が下火」なんて雰囲気はちっとも感じない。

NoboNoboNoboNobo

あれもできないこれもできない

これ実はGo言語ユーザーからすると「誉め言葉」だったり。そういう風に狙ってデザインされたので。Goではベストではないが持ち前の「厳選された基本イディオム」を使ってベターに書いて解決する。しかしその「厳選された基本イディオム」にgoroutineなどのCSP支援機能があるというのがGoの最大かつユニークな特徴(この「厳選」がある意味狂っていて、こういう「厳選」ができる新興言語を開発できる人は今後なかなか生まれないんじゃないかとすら思える)。

悪口が蔓延して一人前とのコメントを見るが、探せばそういう記事いっぱいあるよ。

NoboNoboNoboNobo

統一されたエラー戦略

  • JavaScriptのようにエラーハンドリング方法にバラつきがない
  • ただし、ある程度のハンドリング慣習を踏襲する必要はある
  • アーリーリターンスタイル、必要なエラーに付随する処理はできるだけ直近で行う
  • 上位に持ってきてからエラー種別分岐して付随処理を行うのは極力避ける
  • 最上位まで持ってきた(付随処理はもうない)エラーをログ出力する
  • 多値返しを基本として、エラーを返す際、最後の要素をエラーとする
  • 一部のエラーは無視する慣習がある(Close()など)
  • エラーによる分岐は明示的で統一的
  • 慣れると目でフローを追うときに読み違えしにくい
  • 「ポインタとエラー」を返す時は「どちらかだけがnil」を期待していいし実装側もこれを踏襲する必要がある
  • つまりエラーチェックはnilチェックを兼ねている(ここがOptionalがGoに採用されない理由の一つ)
  • このハンドリングなら直和オブジェクトでいいじゃんという指摘もあるが、
  • 実は「値とエラー」の直積を返すレアケースがある
  • 同じケースを直和でハンドリングしているものはトリッキーなルールが必要だった
  • そして多値返しはオブジェクト返しよりもCPUフレンドリー
NoboNoboNoboNobo

Rustはバイナリも小さいしマルチコアスケールする?

a. goのbuildとrustの--releaseを比べるとrustのほうがコンパクト
b. Goに似たハイブリッドM:N型スレッドシステムを備えたライブラリが存在し、それを使うと自動的にマルチコアスケールする
c. スレッドセーフでない扱いでスレッドを起こそうとするとコンパイルエラーになるので並列支援が充実

というような言及があるが、実際にGoと比較すると注意すべき点がある

a.について

  • ここ2~3年で出力バイナリサイズはRustは機能の増加に伴い増加傾向にあり、Goは最適化が進歩して減少傾向(じつは現状で比較してもGoとRustで大差なかったり)
  • 「go build -ldflags="-s -w" -trimpath」というデバッグ情報をいろいろ捨てると実はバイナリサイズはそう違わないし、さらにRustはlibcをロードする。Goもおおむねロードするが外すこともできる
  • Rustのバイナリの小ささ(もはや小さいというよりはなぜ大きいのか質問されるくらいになっている?)の要因の一つはlibc依存なのでバイナリサイズのアドバンテージはあんまり気にするところでもない
  • Goもランタイムを単独のDLLにくくりだす方法もありバイナリサイズだけならかなり小さくできる

Hello World レベルを20KBくらいで出力できる。

https://zenn.dev/nobonobo/articles/a8c07284247b64

b.について

  • Rustでは「ブロッキング実装=非同期じゃない長い時間CPUを占有する実装」を混ぜてはいけない
  • ブロッキング実装を混ぜるとレイテンシが悪化するので混入しないように注意が必要
  • 期待するレイテンシを超える時間CPUビジーな処理もブロッキングと言えるので意外と意図せず混入しやすい
  • OSのシステムコールにもパラメータの違いでブロッキングに該当したりしなかったりする
  • ブロッキング処理の混入を完全に回避するのは困難
  • ブロッキング処理はもうラップしてもasync化はできない。async向けに再実装が必要。
  • 標準のHTTPサーバーサンプルはC10K問題を持っているのでGoのサンプル並みにしたければライブラリの利用が必須
  • そのライブラリは主にデファクト(tokio)と標準化(async-std)を目指しているものの2派あり、これらは混ぜられないので事実上世に公開されたライブラリはどちらかに依存していて分断しているといえる
  • WebAPIなどでRust+tokioが早いというのも似た処理を行うGo+fasthttpを比べるとそんなに差が無かったりする
  • また、プラットフォームを変えると逆転したりする(Rustのライブラリが特定環境に良くチューニングされている)

追記:Rustのtokioなどではブロッキング前提にspawnする機能があるっぽい?ただ、ブロッキングかどうかの判断を書き手に求めるものでGoよりも知識が要求されるという点は変わらない。

c.について

  • 雑な扱いの型をスレッドに渡そうとするときにコンパイルエラーになるのは確かに便利
  • 正しく排他処理が必要という型宣言をしたものはlockをしないとアクセスできないし、スコープアウトで自動unlockされるのも便利(unlock忘れが発生しにくい)
  • Goの場合、静的解析ツールで問題を検出することで上記と同じ効果を得られる
  • Goと同じく苦労するところ
    • 複数のスレッドから共通のメモリを参照をするとき排他処理は必須
    • 排他処理を多用すると並列度の低い実装になりがち
    • ちゃんと必要なところでチャネルなどのメッセージングを活用しないと並列度を高く保てない
    • 結局のところ並列度の高い実装を書くのは難しい

やはりGoと違うのはこの苦労に立ち向かうユーザー人口の数だと思うGoでは中級レベルの人がこの問題と向き合っている感じがするし、Rustは上記の困難さ+所有権を解決しなければいけないので難易度が高い。つまり上級レベルの人だけがこの問題と向き合っているように見える。Rustでは並行・並列処理に取り組んでいる解説記事やコード例が圧倒的に少ない。また解説があってもtokioやasync-stdに触れられていないものがほとんど。

まとめ

  • Rustはバイナリが小さいというには理由があるしGoもちゃんと対処すれば大差ないようにできる
  • Rustは並行処理を安全に行うコンパイルタイム検査が優秀だけどGoは静的解析で同じことができる
  • Rustの並行・並列実装は低オーバーヘッドゆえに実行時に問題の検出は一切しない
  • そのためRustでは行儀の悪いスレッドによる悪影響を受けやすい
  • Goは実行時にブロッキングを検出したスレッドはネイティブスレッドに追い出される仕掛けがある
  • シングルスレッド性能に関してはGoよりRustのほうが安定して速度が出るが、Goも同じ処理を注意深く実装することでほぼほぼ同等の速度を出すことはできる
  • マルチコアスケール性能に関してはRustよりGoのほうが安定して速度が出せるが、Rustも同じように並列度を高く保つよう、ブロッキング処理を混入しないよう、注意深く実装することで同等の速度を出すことはできる
  • シングルスレッド性能の重要な分野(OSに近い実装やエミュレータなど)にはRustが向いている
  • マルチコアスケール性能が重要な分野(メニーセッションなゲートウェイなど)にはGoが向いている
NoboNoboNoboNobo

速さだけを追い求めても仕方がない

pure-Go実装のgnetは現状でWebFramework最速実装の99.8%の性能を出す。

https://www.techempower.com/benchmarks/#section=data-r21&test=plaintext

この結果だけを見てもGoは決して遅くないことがわかる。突き詰めると同等のことをやりさえすればほとんどの処理系と同程度の性能を出そうと思えば出せる。

このgnetはWebフレームワークというよりはネットワークサーバーフレームワークだ。これもまたインフラで活躍する。キャッシュサーバーやゲートウェイ関連に向く。

結局適材適所が有効なのは変わらない。これでWebフレームワークのようなことをやろうとすると大変苦労する。早いという理由で言語処理系やフレームワークを選ぶのはやめよう。要件を満たすのに十分な機能があるかどうか。

NoboNoboNoboNobo

Goのコードに個人差を載せにくくする努力

  • プログラミング言語のイディオムに対する理解度の差でコードの差分が生まれてしまう。
  • Goではこの差分を最小化するためにデザインされている
  • Goコアメンバーはユーザーの理解度を引き上げるための読み物を提供する
  • この2面でコードにできるだけ個人差が載らないことを目標に進んでいる
  • 強制力の強いgofmt文化も個人差を減らす活動の一環でこの文化の良さは認知が広まり、瞬く間にほかの言語処理系にも取り入れられた
NoboNoboNoboNobo

うん年もオブジェクトやDDDをうまく設計する方法を勉強したりするよりもGoでは愚直に書こう!っていう考え方。

言語の記述スタイルの拡張や改変はGoでは忌避されている。マクロやオペレーターオーバーロードやメタプログラミングなどもできないし、どのみち継承はできない。
責任の明確化と分離だけ限られたイディオム使って頑張るという言語仕様。

NoboNoboNoboNobo

優先機能の違い

  • Goでは並行処理を優先してジェネリクス は後回し(9年後?)。
  • Rustではジェネリクス を優先して並行処理は後回し(9年後?)。

これらの言語のベクトルが違うのがわかりますね。

NoboNoboNoboNobo

生まれ持って並行処理サポートのある言語処理系

  • Erlang/1986/Actorモデル
  • Occam-V2(もう使われない)/1987/CSPモデル
  • Haskell(GHC)/1992/Actor、CSP両対応
  • Clojure/2007/CSPモデル
  • Go/2009/CSPモデル
  • Elixir(Erlang派生)/2012/Actorモデル
  • Pony/2016/Actorモデル

この中で実用例が豊富なのはErlangとGoあたり。Ponyは後発なだけに並行処理の不備をコンパイル時に検出する仕掛けを豊富に持つ。

Rustも並行処理サポートあるよ!っていう話もありますが、2019年に後付けでようやくasync/awaitが安定リリース。後発だけにM:Nスレッディングで効率も稼いでいる。

参考:「自動並列化」の仕組みを持つ処理系

  • Intel C/C++ Compiler
  • OpenMP
  • Fortran/2008
  • Mojo/2023

これらは並行処理プログラミングパラダイムを含むというよりは膨大な計算処理をマルチコアに分散する実装を支援するというもの。実装者は並行プログラミングを意識せずともマルチコア性能を引き出せるのが強みであり、意識して並行プログラミングをするための支援はあまりない。

NoboNoboNoboNobo

goroutineがメモリを使いすぎる?

https://pkolaczk.github.io/memory-consumption-of-async/

この件、Goがメモリ食いすぎ的な指摘があるわけだけれど、goroutineの初期スタックから逆算してもあってると言えばあってる。
実用を考えた時、そんなに細分化したスレッドを利用することはあまりない。
一般サーバー実装を考えた場合1セッション=1~2goroutineという設計が最もシンプル。
1セッションあたりにソケットを1つを掴むと考えた時、ソケットのリソース数はOS全体で64K以下に制限されるので60Kセッション程度をさばくのが限界だったりするわけです。

まとめると、

  • 指摘されたメモリ消費は正確で正しいがメモリ消費量は一つの目安でしかない
  • 実用的なサーバーを書くとき60Kセッション以上を扱う利点は大してない
  • goroutineは効率よく動くのは100~200K個程度までを想定してチューニングされている(もちろんその際メモリはギガ単位を消費する)
  • これ以上のgoroutineを動かすといかに軽量とはいえタスクスイッチにCPUを浪費しがちになるのでわざわざそんな実装を実際にすることはない
  • 対象の実装は単純すぎて一つのgoroutineに割り当てる内容としては軽すぎる
  • 同様のミスリードなベンチマークはたくさんある(数列をchanでイテレーションして計算するなど)
  • この例のうち時間待ちのために100K個以上のgoroutineを使うというのは単に誤用でしかない
NoboNoboNoboNobo

既存のイディオムが使えない

  • 例外処理機構が無い
  • クラス・継承機能が無い
  • (オペレーター)オーバーロード機能が無い
  • マクロが無い
  • 高階関数が無い
  • イミュータビリティサポートが無い
  • Setコンテナが無い
  • コンテナ操作メソッドが無い
  • パターンマッチが無い
  • Enum型が無い
  • Nullable/not Nullable型修飾やヌル安全が無い
  • if式や三項演算子が無い

しかし、Goはこれらを採用せず、基本イディオムでこれらをカバーしてきた。それはなぜか。
Goの出自は様々な言語処理系が互いに無い機能を取り込みあって同じような言語にどんどん近づいていることに対するアンチテーゼとして生まれたから。できるだけ厳選したイディオムで勝負すると決めて生まれた経緯がある。

なので例にあげた使えないイディオムがGoに採用されることはほぼ無さそう。
(ジェネリクスは最初のバージョンドキュメントの時点で検討するとあって9年後にやっと実装された。)