施策を "Own it" するエンジニア 〜オーナーエンジニア制度の紹介〜

レシピサービス開発部の新井(@SpicyCoffee66)です。いろんなゲームが好きなのでどれも中途半端にしか上手くならないことに 10 年以上悩んでいます。

この記事では、クックパッドのレシピサービス開発に導入している "オーナーエンジニア" と呼ばれる制度について紹介します。

  • クックパッドでサービス開発をしているエンジニアがどういう働き方をしているのか知りたい
  • ディレクターやデザイナーと上手く協業する方法が知りたい
  • サービス開発エンジニアはやることが多すぎてどうやって仕事をすればいいか悩んでいる

といったような方の参考になると思いますので、興味があれば是非ご一読ください。

制度導入の背景

オーナーエンジニアという役割について述べる前に、まずはレシピサービスの開発を取り巻く環境について軽く解説します。
現在クックパッドのレシピサービスは、エンジニアが 10 名と少し、デザイナーが 5 名程度、ディレクター数名の、計 20 名少しのメンバーで機能開発をしています。 開発手法にはスクラムを活用していますが、組織に合うようなチューニングを進めた結果、現在は企画や施策立案を中心に取り組む Discovery Process と、その実装とリリースに取り組む Delivery Process に分かれて運用しています。 前者はプロダクトバックログ(以下 PBL)にアイテムを追加していく役割*1を、後者はそのアイテムをスプリントバックログ(いわゆるカンバン)に落とし込んで実現していく役割を担っています*2

レシピサービスの開発におけるスクラムの全体像
レシピサービスの開発におけるスクラムの全体像

スクラムの導入自体は、開発組織の持つ課題を解決するために昨年導入されました。それにより、当時抱えていた課題は概ね解消されたと言えますが、新たに以下のような問題が発生するようになりました。

  • PBL のアイテムを数時間程度のタスクに分解しようとしたときに、要件や仕様に生煮えの部分が多く分解することができなかった
  • ディレクターやデザイナーがエンジニアにラフな相談をしたいときのやり方がわからなくなった
    • スクラム導入以前は施策ごとに担当エンジニアがつくようなやり方で開発を進めていたチームが多く、良くも悪くも個々人のコミュニケーションが多かった
  • 企画側から開発の様子が見えづらく、進捗の遅れや意図のズレに気がつくのが遅れてしまった

改めて並べてみると、開発体制の変化によってディレクター・デザイナーとエンジニア間のコミュニケーションに課題が生まれ始めていたことがわかります。この課題を解決するために生まれたのが「オーナーエンジニア」と呼ばれる制度です*3

オーナーエンジニアの役割

概要

オーナーエンジニアは PBL の各アイテムごとに担当エンジニアがつく制度で、主に以下の役割を担っています。

  • 施策の技術仕様を現実的なものに落し込む
  • スプリントプランニングでアイテムが見積もりできる状態に持っていく
  • 施策の評価指標の設計が必ず行われるように提案・助言する

この他にも、アイテムに関しての技術的な相談窓口だったり、アイディアの壁打ち相手などをやることが多いです。
役割としては技術領域の責任者なのですが、言葉の意味としては「担当施策のオーナーシップを持つエンジニア」という解釈の方が正確で、実装を中心にしながらも施策が滞りなく進行することに自体に責任を持っているという認識です。そのため、求められる知識や能力は幅広く、例えば以下のようなものが挙げられます。

  • 施策コンセプトへの理解
  • 施策理解をベースにした、実現方法の策定
  • 開発のリード
  • プロジェクト進行への理解
  • 何かあったら自分がなんとかするという気概*4

要素を並べただけではイメージしづらいと思うので、ここからは自分が実際にオーナーエンジニアを担当したときにおこなったことを紹介していきます。

実例

1. オーナーエンジニアのアサインとキックオフ

バックログリファインメントにおいて、優先順位が上がってきたアイテムにはオーナーエンジニアがアサインされます。今回自分にアサインされたアイテムは「つくれぽを投稿することによって自分がつくりたいレシピに出会いやすくなる体験をつくる」といった、規模が大きく抽象度も高いものでした。叩きのアイデアはあるものの、ここから仕様を取捨選択し磨き込む必要があるフェーズのアイテムだったため、ある程度長期間のプロジェクトになることを見越してキックオフの設定を提案しました。
キックオフでは、施策の目的やコンセプト・手法の概要など現時点で決まっていることを施策オーナーからインプットしてもらい、今後の進め方と次のアクションを決定します。今回は、前述したようにある程度長期間の伴走が必要になるアイテムだと感じたため、一時的なプロジェクトと考えて定例ミーティングも設定してその場は解散となりました。

余談ですが、自分はキックオフミーティングに必要な要素を以下のようなモデルで捉えています。このモデル自体は組織や個人によって変わってくると思いますが、こういうイメージを頭の中に持っておくと、自分で設定する時は抜け漏れが減り、キックオフに招待されたときにも足りない部分をフォローすること等ができて便利です。

キックオフの概念モデル
キックオフの概念モデル

2. 施策コンセプトの理解

キックオフで聞いた概要をもとに、施策の目的やコンセプトを自分の頭に叩き込んでいきます。開発のフェーズになると他のエンジニアメンバーとコミュニケーションを取ることも増えるため、この施策ではどういう価値を提供したいのか、あるいは検証したいのか、部署の目標とはどう繋がっているのか、その結果どういう手法を取るつもりなのかといったことを自分の言葉で話せるようにしておきます。
具体的には施策オーナーやデザイナーへのヒアリングをしたり、仕様やデザイン案を読み込んだり、ユーザーインタビューの録画を見たりしました。この部分の解像度が低いと、後の仕様を詰めていく工程で削るべき部分や残すべき部分の判断がつかなかったり、設計工程において適切な判断ができなかったりするので、しっかりと労力を割きました。

3. 仕様の相談と決定

施策のコンセプトを理解した上で、ディレクターの仕様やデザイナーのデザインに対して改めて確認や提案をしていきます。最初に出てきている案は検証したい価値に対して機能過多になる傾向があります。そのため、基本的には自分の中で MVP になる体験をイメージしつつ、削れそうな仕様を探したりフェーズ分割できそうなポイントを探したりしながら、仕様をコンパクトにできないか提案することが多いです。

こういう開発はなるべく避けたい
こういう開発はなるべく避けたい

加えて、リリース後にどういう分析をしたいのかを確認し、提案を交えながら埋め込んでおく必要がありそうなログをリストアップしておきます。後で「あ〜〜〜!ログ埋まってなかった!!!」というのはやりがちなミスなので、この時点でケアできていることが望ましいです*5

やりがちな失敗
やりがちな失敗

また、施策オーナーは仕様の技術的な難易度を認識していないことが多いため、その辺りもすり合わせながら整理していきます。実装難易度が高くなりそうな箇所については、改めて重要性の確認をしたり、ザックリの工数感と代替案を提示した上で再度意思決定をお願いしたりします。逆に、それほど工数がかからないような詳細の磨き込み等は、こちらから仕様追加を提案することもあります。
他にも、施策オーナーの希望しているスケジュール感を確認し、現状開発チームが出せているベロシティによっては、PBL 上での優先順位を上げてもらうよう PO とコミュニケーションを取ってもらうように提案したりもします。開発チームの現状はエンジニアからの方がよく見えるので、プロジェクト進行面でのフォローも効果的です。

ここで心掛けているのは、あくまで施策のゴールを達成するために仕様を煮詰めていくという意識です。仕様を削れば削るほど実装は楽になりますが、それ自体が目的になってしまっては必要な仕様まで削ってしまうことになりかねません。エンジニア目線だけを持つのではなく、施策のゴール達成という目標を同じ目線で見るプロダクト開発者として考えることが重要です。そのためにも 2 のフェーズで施策に対する解像度を高めておくことが重要になります。

4. 概算見積もり

ある程度固まってきた仕様をもとに概算で工数を見積もっていきます。設計方針を決め、コード調査をし、場合によっては他のエンジニアに相談しながら進めていきます。
施策の目的が検証であればデーターソースは YAML でよいと割り切れる*6が、恒常的な機能を開発するなら DB に table をつくる。施策の確度が低く、リリース後もどんどん改善していく想定であれば後から消しやすいように別の table でデータを持つが、そうでないなら今ある table に column を追加する。今後想定している利用者数の推移に基づいてスケーラビリティをどの程度考慮するか決めるなどなど……。このフェーズでも施策理解の解像度に左右される意思決定が多数存在します。繰り返しになりますが、施策意図のインプットが非常に重要です。

参考までに、自分はデザイナーが Figma で描いてくれたデザインをコピーしてきて画面遷移図をつくり、その上に付箋をペタペタする形で見積もりをつくっていくことが多いです。こうすることで、必要実装の抜け漏れが減りますし、視覚的にどういう機能をつくればいいかがわかりやすくなります。Figma 上で仕様やデザインについての質問が完結するのも便利です。

Figma での見積もり
Figma での見積もり

一人でグッと考えていると行き詰まることも多いので、なるべくラフに他のエンジニアに相談するように心掛けています。実際に開発に入る前の段階からエンジニアメンバーに施策概要が浸透することにもつながるので、一人で抱え込むよりは早めに状況を開示することが大事だと考えています。今のチームは #recipe-sekkei-inquiry という slack チャンネルを運用しており、割と雑な質問や相談が飛び交っているのがいい環境だなと思います*7
最終的にはエンジニアが集まる「概算見積もり会」という会議体に持ち込み、参加者から「ざっくりよさそう」の合意が取れれば、優先順位の高いものから順にスプリントに乗っていくことになります。

5. 開発

ここから先は基本的には一般的なスクラムの進行になります。スプリントプランニングでアイテムの詳細見積もりをし、タスクに書き下してスプリントバックログに貼る。デイリースクラムで進捗を確認しながら、メンバーが各々取り組みたいタスクを選んで受け持っていく。といった流れです。
ただし、少し規模の大きな開発になる場合は、最初に「設計ドキュメントを書く」というチケットを作成し、エンジニアメンバー間で認識をすり合わせるためのドキュメントを執筆します*8。設計ドキュメントのフォーマットは現状規定されていませんが、issue へのリンク、最低限の仕様、デザインのスクリーンショット、レスポンスの形式、実装予定のサービス名や該当ファイルなどが含まれていることが多いです。このチケットは原則としてオーナーエンジニアが取ることが多く、なるべくスプリントの序盤に取り組むことが推奨されています。

設計ドキュメントのイメージ
設計ドキュメントのイメージ

開発が始まってからは、他のメンバーが実装してくれた箇所については、積極的にコードレビューに入るなどして、仕様漏れのキャッチや整合性の担保ができるように心掛けます。

6. 分析・評価

機能をリリースした後は、分析・評価をおこなって next action を決定するまでが施策です。といっても弊チームではエンジニアではないメンバーが平気な顔をして分析 SQL を書いたりするので、3 の工程で正しくログを設計できていればあまりやることはないのですが。最近では、機能リリースの目処が立った時点で「数字を見る会」なる会議体が設定され、そこで関わったメンバーみんなが数字を見ながらやいのやいの言いつつ next action が決まるケースが増えてきました。分析・評価が実施されることや、その透明性を担保するための一つのプラクティスになりそうです。

雑感

本記事では、クックパッドのレシピサービスで導入されているオーナーエンジニアという制度について紹介しました。
改めて並べてみるとなかなかやることが多く「これは本当にエンジニアの仕事なのか?」と感じる業務まで含まれているようにも見えます。しかし「専門職が集まって分業する」というのは、得てして下図のように、イメージと現実の間にギャップがあるものです。我々がユーザーへの価値提供にフォーカスする限りは、現実の中で境界にあるような仕事を誰かが拾って進めていく必要があり、そのためにもできることは多い方がいいでしょう*9

分業のイメージと現実
分業のイメージと現実

もちろん自分だって凄腕デザイナーに CSS の修正をしてもらったり、視野広ディレクターに考慮漏れを拾ってもらったりしたことが数多くあります。僕は、専門性に軸足を置きながらも役割にとらわれないメンバーが多いチームは強いと信じているので、自分もそうありたいと思います。

また、今年自分でやってみて思いましたが、シンプルにオーナーエンジニアの仕事は楽しかったです。僕は「サービス開発エンジニアになりたい!」と言いながら 2017 年にクックパッドに入社し、エンジニアをやったり PjM をやったり部長をやったりしていましたが、どれもエンジニアリングとサービス開発に対して同時に向き合うことは難しい仕事でした。今年それらの経験を経た上でエンジニアに戻り、オーナーエンジニアをやってみたところ

  • プロダクト開発と技術力の両面を同時に求められ、向き合う必要がある
  • それぞれの能力が施策のクオリティに直結する実感がある
  • その上で、エンジニアリングという専門性に軸足を置いているので(キャリア的な意味で)自我を保ちやすい

という実感がありました。サービス開発にはさまざまな要素が存在するため、日々の仕事の中で自分の役割に迷う方も多いのではないかと思います。そんな方の参考になれば幸いです。

この記事の内容について質問などある方は、気軽に Twitter などにご連絡ください。選考応募はもちろんのこと、カジュアル面談も積極募集しているため、チャネルにこだわらずお声がけいただければと思います。

*1:特定の人だけが PBL にアイテムを追加できるというルールではなく、あくまでメインの役割として担っているメンバーです。実際に、たとえば技術的負債解消のためのアイテムが Delivery Process のメンバーから起票されるようなこともあります。

*2:あくまで同じチームではあるため、レトロスペクティブ等は合同で開催しています。

*3:スクラム導入以前に発生していた課題については Cookpad TechConf 2022 で発表された「レシピサービスにおける持続的なプロダクト開発プロセスについて」というセッションで、Discovery Process と Delivery Process やオーナーエンジニアについては Cookpad Lounge #15 でも触れられているので、よろしければ併せてご覧ください。

*4:弊社 Values の一つ "Own it." の精神です。

*5:この辺りの考え方は、自分がリーンスタートアップをもとにプロダクト開発をしている影響もありそうです。過去には MVP に触れている記事も投稿しているので、よろしければ参照ください → https://techlife.cookpad.com/entry/2018/02/10/150709

*6:極端な例として挙げているものの多くの場合はよくない

*7:この辺りの話は Kaigi on Rails 2022 で弊チーム Techlead の akamatsu が話していたので、興味のある方は併せてどうぞ → https://kaigionrails.org/2022/talks/ukstudio/

*8:こちらも前述した akamatsu の発表で触れられています

*9:弊社エンジニアリングマニフェストにある "境界を越える" の精神です。

Rubyインタプリタの品質向上のために個人的にやっていること

技術部の笹田です。Ruby 3.2 無事にリリースされて良かったよかった。

Rubyインタプリタは複雑なプログラムなので、当然のごとくバグが入ってきます。Rubyインタプリタ開発者は、これに対していろんな対策をしています。たとえば、テストを書いて、CI環境でチェックするとか、今となっては当然のことを、当然のごとくやっています(RubyCIchkbuildruby/spec: The Ruby Spec Suite aka ruby/specなどの整備や、実行環境の日々のメンテナンスの成果です)。

これに追加して、個人的にテストをとにかくたくさん繰り返し行うマシン群を用意しています。テストの実行頻度をなるべくあげて、「時々しか発生しない」というバグを炙り出して、Rubyインタプリタの品質向上を目指すためです。本稿ではそんな、ちょっとだけ変わったテスト環境についての話をご紹介します。

このテスト環境を用意するために、いろいろな方にご支援いただいております。本稿では感謝の意をこめて、そのご支援をご紹介させていただきます。

バグを「炙り出す」必要性

定期的にテストを実行する環境

よくある CI/CD の文脈では、リポジトリへのコミット(PR)単位でテストを実行します。もし問題がでたら、そのコミットに問題があることがわかるためです。GitHub Actions などでよく対象にするのはそんなテストです。

つまり、

「バグは修正に混入する → 修正ごとにテストを走らせることで、そのバグを見つけることができる」

という仮説のもとに定期的にテストする環境を用意するわけです。

この問題に対処するため、Ruby インタプリタ開発では、次のようなテスト環境を用意して利用しています。

  1. GitHub Actions による PR 単位、push 単位でのテスト環境
  2. chkbuild による網羅的なテスト環境(rubyci

1 も 2 も、基本的には直前に入った修正に問題がないか、チェックするための仕組みです。

2 は、いろいろな OS などの環境で、毎回 clean build して逐次テストすることで、正確なテスト結果を出します。ただ、時間がかかるため、2時間に1度程度、実行されています。

現在は、計算機の多くは一般社団法人 Ruby Association などからのご支援を受けて AWS 環境に構築しています。また、GitHub actions は GitHub 様から計算機資源の提供をいただいています。

そういえば、Shopify では、彼らの(おそらく膨大な)アプリケーションのテストをRubyの開発版で行っていただいているそうです。助かりますね。

ときどき落ちるテストを発見するテスト環境

Rubyインタプリタくらいの規模のソフトウェアになると、何も変わらないのに、時々落ちる、という現象にあたることがあります。また、修正はあっても、その修正では考えられない理由でテストが落ちる、ということもあります。こういうのを flaky test などということがあります。これには、いくつか理由が考えられます。

  1. テストが悪い
  2. 「時間」や「システムの状況」など外部要因に起因するテスト
  3. すでにバグは混入しているが、運が悪い(良い)ときにしか見つからない

経験上一番多いのは 1 のテストが悪いというものです。たとえば、テストするメソッドの順番に依存していたりすると、何かの拍子に問題が生じることになります。タイミングがシビアなテストを書いていると、ちょっとタイミングがずれて時々失敗する、みたいなこともあります(マシンスペックが変わって失敗する、とかもありますね)。

2 の外部要因に起因するテストも、テストが悪い、と言えなくもないですが、時々あります。たとえば ruby/zlibのテストが何もしていないのに失敗するようになった話 - @znz blog で紹介されている例は、特定の時刻でタイムスタンプが特定のデータを生成してしまい、テストが失敗してしまう、というものでした(テストを修正して解決)。

まぁ、上記は「テストが悪い」の範疇なので、インタプリタ自体の品質には直接関係ありません。ただ、これらを放置するとテスト結果を確認するのが億劫になるので、出来るかぎり早く修正する必要があります。われ窓理論ですね。

で、3の運が悪い(良い)と現れる問題が、インタプリタの品質にとって大切になります。1万回に1度、運が悪いと出現するようなバグでも、1日に利用者が1万人いるソフトウェアだと、1日に1度は踏んでしまうかもしれません。というか、踏んじゃいます。さらに悪いと、脆弱性のもとになってしまうかもしれません。

この手のバグが出やすいのは次のような場面です。

  • 自動メモリ管理(GC)
  • キャッシュを用いているもの
  • 並行・並列実行をしているもの
  • ネットワークなど、外部のシステムを利用しているもの

どれも、非決定的、つまり2度実行しても同じ結果にならないような挙動を持ち込みやすい部分です(そして私が良く扱う分野です)。ほかにも、システムによるメモリアドレスのランダマイズなど、「あれ、さっきと結果が違うぞ?」という状況を作る原因はいろいろあります。

で、いろんな工夫が考えられるのですが、われわれは「とにかく数を実行してみる」という手法を用いています。単純ですね。1万回に1度出るなら、1万回動かせば再現するだろう、という話です。

つまり、

「あまり出現しないバグがすでに混入している → テストの試行回数を増やせば、このようなバグを踏む確率が高くなる(炙り出せる)」

という仮説のもとに、とにかくたくさんテストを実行するテスト環境があるといいなぁと思うわけです。

先ほどご紹介した chkbuild では一日に 12 回程度(これに環境の数だけ掛け算)、GitHub actions ではイベントごと、ということで、「沢山実行する」というにはちょっと足りません。そこで、独自にテスト環境を作って5年くらい運用しています。

もともとは GC 開発時のデバッグで「時々起こる」バグに業を煮やし、1台でずっとテストを走らせていたことから始めました。

始めた当時は、while make up all test-all; do date; done というコマンドで無限にテストを走らせました(失敗したら止まります)。ただ、これだと結果を確認するためにターミナルを見なければならず、また意図しないところで停止ししていると気付くことができません。また、スケールも難しいので、環境一式を作りこんでいった感じです。

たくさんテストを実行するための工夫

テストをたくさん実行するためには、次のような工夫を行いました。

  • マシンを複数台使う(スケールアウト)
  • 性能の良いマシンを使う(スケールアップ)
  • 1マシンで複数のテストを同時に実行してハードウェアリソースを使い切る
  • 1回のビルド・テストの試行時間を短くする

それぞれご紹介します。

利用するマシンの用意

お金があればクラウドでマシンを沢山用意してスケールアウトするのが一番確実(そして、慣れた人には簡単)なのですが、個人で行っている活動なので、用意できる金額に限りがあります。また、この手の計算機リソースを使い切る用途は、安いクラウドサービスにはあわないというものがあります。

自宅のスペースに若干の余裕があったので、現在は実マシンをてきとうにおいて運用しています(子どもたちが大きくなると余裕はなくなるため、この活動もそこで終了しそう)。AWS などの料金表をにらめっこしてみたのですが、やはり実マシンが一番安いですね...(割引プランをいろいろ探せばもっと安いんだろうか)。10万円弱で 8 cores 16 threads のちょっとした良いマシンが買えるのでありがたいです。現在は、4台のマシンで運用しています。

我が家においてあるマシン群

新しいマシンはどれも小さいです。以前はミドルタワーのマシンを並べていたんですが、さすがにむっちゃ邪魔で...。HX90 は先日のブラックフライデーでちょっと安かったので買ってしまいました。

テストの実行時間はCPUの動作周波数にきれいに相関していました。速ければはやいほど良い。

メモリは1つのテストスイートを走らせる程度なら、ビルドや各テストを並列実行しても2GB程度あれば十分なようで、意外にもそんなに必要ありませんでした。

電力計をつけているのですが、見ていると全部で 400Wh のあたりを上下している感じです。東京電力の料金 スタンダードプラン(関東)|電気料金プラン|東京電力エナジーパートナー株式会社 を見ると、301kWh を超えると 30.57円/1kWh のようですので、この数字をもとに計算してみると 30.57円/kWh * 0.4kWh * 24h * 30d = 約 8804 円。まぁ1万円弱くらい。こちらも GitHub sponsors の収益で一部補填させていただいております。

(ちなみに、この電気代には先ほど紹介した rubyci/chkbuild で利用している Mac mini 3台が入っています。Mac mini は一般社団法人日本Rubyの会のご支援で購入したものです)

今は寒いからいいんですが、暑い時期は(エアコンを入れなかったので)ファンがすごい音をたてていました。火事が心配。今のところ、連続稼働でも1年以上は動いています。ただ、5年たったらミドルタワーのマシン2台が壊れました。小さいマシンはもっと寿命短そう。

マシン代は(古いのはおいといて)22万円で3年で減価償却するとして7万円/年くらい。電気代は大雑把に12万円/年。つまり20万円/年くらいでしょうか。場所代とメンテ人件費が要らないのでやっぱり安いですね。落ちたら大変、ってシステムもないので、SLA も要らない。まぁ、家でマシン並べるのは趣味ですよねぇ。

余談ですが、物理マシンを手元においているのはベンチマークをとるため、という側面もあります。クラウド上のマシンだと、インスタンスガチャみたいな話もあるので、なるべく物理マシンを利用したいというところです。例えば https://rubybench.github.io/ のマシンは我が家でホストしているマシンになります(このマシンも日本Rubyの会様にご提供いただきました、ありがとうございます)。新しい機能のベンチマークを真面目にとらないといけないときは、動かしているテストをとめてベンチマークしたりしています(ベンチマークのために複数台必要になることがあるためです)。

ビルド・テストプロセスの並列実行

テストの回数を増やすために、テストスイートを実行するプロセスを1つのマシン上で複数起動する、という方法があります。

テストスイートを実行すると、リソースを消費するときと暇なときがあるので、あるテスト実行プロセスが暇なときに別のテスト実行プロセスを走らせることで全体のパフォーマンス向上を目指すという考え方です。ただ、同時実行テストプロセス数が多すぎるとリソースの取り合いにリソースが消費されてしまうため、全体のパフォーマンスは悪化する危険があります。

単純にテストプロセスを複数実行すると、テスト同士で干渉することがあったので(たとえば、ファイルシステムやネットワークのポート)、いろいろ試行錯誤しながら Docker コンテナで設定をいくつかいじれば大丈夫であると突き止めました。今は 1つのマシン上で 22 の Docker コンテナがそれぞれ同時にテストスイートを実行するようにしています(build-ruby/run_sp2.rb at master ・ ko1/build-ruby )。メモリは 32GB で何とか足りています(ただし、後述する RAM ディスクはあきらめました)。

Dockerコンテナでいろんなテストを同時実行している様子(メモリ消費)

ビルド・テスト時間の短縮

最新版のRubyをビルドしてテストスイートすべてを走り終わるまでの時間を短縮するため、次のような工夫をしています。

  • コンパイル結果などを再利用する
  • RAMディスクを用いる
  • ビルド・テストを並行処理する

rubyci.org に掲載されているテスト実行は、テストの結果を確実にするため、一切のコンパイル結果などの再利用をしません。ただ、今回は数を稼ぐことが目標なので、コンパイル結果を積極的に再利用するようにしています。ただし、再利用を起因とする問題もたまにあるので、2度連続で失敗したときは、一度コンパイル結果などをすべて消し、まっさらな状態からビルドするようにしています。

メモリが比較的余っている環境では、ビルド結果はすべてRAM ディスク(tmpfs)を用いて、ちょっとでもビルドが速くなるようにしています。ただ、これどれくらい効くかは微妙です。性能に関連しそうなデータは、OSが勝手にメモリ上にキャッシュに載せたりするためです。なんとなく速いような気がする、という気持ちの問題みたいな側面が大きいです。

ビルドの並列実行は make -jN とするやつです。10年くらい前は結構これに起因するバグもあったんですが、今ではほぼ問題なく並列ビルドできています。

テストを並列に実行する、というのは、テストスイートを分割し、その結果を並列に実行するというものです。この環境で実行するRubyのテストは大雑把にわけて3グループあるのですが、そのうち1つが以前より並列処理に対応していました。数を稼ぐという目標のために、さらに1つのグループ(btest)を並列実行可能にするように書き換えました。

これらの工夫により、速いマシンを占有して「最新版をビルド→テストの実行」を繰り返し実行している環境では、「最新版をビルド→テストの実行」が2分弱程度で終わることができるようになっています。つまり、常にリポジトリから最新版の Ruby を取得しテストするため、テストが通らなくなるような問題のあるコミットをすると、はやいと2分程度でテストの失敗通知が得られるようになっています(結果は Slack に通知される)。

テストを繰り返している様子

テスト回数

これらの工夫により、1日に2000回程度のビルド・テスト実行ができるようになっています。5日で1万回。

バグを炙り出す工夫

テストを増やす

バグが混入されているにしても、そのバグを絶対踏まないコードしかなければ、そのバグを検出することはできません。そのため、広範なテストが必要になります。すでに Ruby は大きなテストセットをもっているため、それを利用しています。

また、Rubyインタプリタのソースコードには、たくさんのアサーション(プログラムのこの箇所では必ずこうなっているだろう、という状態の表明)が入っています。これも、テストの一種と考えられるでしょう。自分がコーディングする部分では、このようなアサーションを増やすことで、おかしな状態を検出できるようにしています。

これらのアサーションは多くはデバッグビルドでのみチェックが有効になります。そのため、走らせているいくつかの環境でデバッグビルドを用いて実行させています。

テストについて、理想的には、著名なアプリやライブラリを持ってきて、そのテストを最新の開発版 Ruby で動かすと良いと思うのですが、そこまで手が回っていません。

テストパターンを増やす

実行するテストはすべて一緒ではなく、さまざまなパターンでテストを走らせることでバグを炙り出そうとしています。

  • いろいろなパラメータでビルドした Ruby インタプリタでテストを実行。
  • ビルド環境(コンパイラ)のバージョンを変えてテストを実行。
  • テストの順番をランダムにして実行。たとえば、テストの実行順によってメソッドキャッシュの状況が変わるので、そこで発見できるバグがあるかもしれない。
  • テストを繰り返し実行。同じく、同じテストを繰り返し行うことで、メソッドキャッシュの状況が変わる可能性がある。

この辺を一括で記述することができるように、設定に従って Ruby をビルドし、テストを走らせるソフトウェアを書きました(ko1/build-ruby: Build Ruby from source code. )。設定一覧は例えばこんな感じ: https://github.com/ko1/build-ruby/blob/master/docker/ruby/targets.yaml

エラーへの対処

予期しない問題に対処するため、いくつか工夫しています。

  • 全実行ログの記録
  • 無限に停止するこことをふせぐためにタイムアウトを設定可能に
    • タイムアウトがあったら、gdb で関連プロセスのバックトレースをダンプ
  • core を吐くような異常終了時には core をダウンロードできるように
  • 失敗が続いたらデータを全部削除したり、実行間隔をあけたり

しかし、テストが失敗しても結局原因はわからないことも多いです。もう少し工夫したいところです。

結果を確認するためのシステムの整備

結果を集約するサイト ci.rvm.jp を作っています。見る人は限られているので、DB は SQLite3 という雑さ(なので遅い)。本当にヨワヨワサーバなので、リンクにもしていません。

失敗ページを見ると、何がまずいかわかりやすいように stderr への出力だけ実行結果の概要ページで見えるようにするなど、ちょっと工夫しています(が、世のCIサイトは無限にやってそうな話ですね)。

テストが失敗したら Slack での通知(Rubyコミッタの方々が見ているところ宛て)とメールでの通知(これは私にだけ宛て)が飛ぶようになっています。必ず失敗するようなコミットが稀に入ってしまうのですが、そのときは通知がひどいことになります。

余談:その他の考えられる工夫

非決定的な挙動をテストするためにはいろいろな手法が考えられます。

例えば、入出力やスレッドスケジューリングなど、外部のイベントをすべて決定的になるようにOSなどのレベルで整備するものです。つまり、いろんな工夫で同じプログラム(と外部からの入力)については必ず同じ結果を返すようにする、というものです。一度、問題を発見できたら、その問題が必ず再現できる、となればはかどりそうですね。ただ、研究レベルではいろいろ聞いたことがあるんですが、実際どれくらい実用になるんでしょうね。

形式手法を用いて網羅的なテストや、網羅しやすくするデータを自動的に生成する、といった手法も考えられます。こういうのできるとかっこいいですよね。

成果

毎日数千回の試行があると、けっこうバタバタ失敗するため、最初はかなり頑張って修正しました。主にテストの不備が多いので、だいぶ頑張って修正しました。

また、タイミングに起因するバグも修正することができました。手元のメモに残っているパッチだとこんなものがありました。

おわりに

本稿では、品質向上のために個人的に行っている「テストの実行回数を増やしてレアなバグを見つける」ための仕組みについてご紹介しました。

プログラムにはバグはつきものですし、大きく複雑なプログラムのバグを見つけるのは大変です。今回は、そんな試行錯誤の一端をご紹介しました。今回ご紹介したものは、とにかく力業、という感じなので、もう少し科学的アプローチもできればいいなぁ、と思っています。いい方法知っていたら教えてください。

記事中でも言及したとおり、この仕組みは多くのご支援を受けて実現しております(ご紹介できなかった方もいるかもしれないですが、ごめんなさい、感謝してます)。改めて御礼申し上げます。

マシンについては、数年前に某社から不要になったメモリ3桁GBのごついラックマウントマシンを3台いただきまして、それを別の某N社に設置させていただき、これらを含めて運用してきました(マシンの運用はN社のS様にずっと面倒見てもらっていました)。先日、これらのマシンがさすがに古かろうということで撤去されたので、その供養と感謝を込めてこの記事を執筆しました。どうもありがとうございました。

では、良いお年をお迎えください。

... 今年は「Ruby 3.2 の XXX 自慢したい」記事がないのですが、それについてはまた来年ご紹介します。

プロと読み解く Ruby 3.2 NEWS

技術部の笹田(ko1)と遠藤(mame)です。クックパッドで Ruby (MRI: Matz Ruby Implementation、いわゆる ruby コマンド) の開発をしています。お金をもらって Ruby を開発しているのでプロの Ruby コミッタです。

昨日 12/25 に、恒例のクリスマスリリースとして、Ruby 3.2.0 がリリースされました(Ruby 3.2.0 リリース)。今年も Ruby 3.2 の NEWS.md ファイルの解説をします。NEWS ファイルとは何か、は以前の記事を見てください。

本記事は新機能を解説することもさることながら、変更が入った背景や苦労などの裏話も記憶の範囲で書いているところが特徴です。

Ruby 3.2 は、新しい機能をたくさん盛り込み、また非推奨の機能を削除するなどアグレッシブなバージョンです(ただ、往年の Ruby をご存じの方にとっては変更が足りず物足りないかもしれません)。性能改善もたくさん行われています。ぜひ使ってみてください。

ちなみに、Ruby 2.7はあと4ヶ月くらいでEOL(サポート終了)で、なんと Ruby 2.x がもうすぐ終わってしまいます。移行の準備はいかがですか?

Ruby 3.2 の目玉として、次のようなものがあげられています(リリースノートから抜粋)。

  • WASIベースのWebAssemblyサポート
  • 実用段階になったYJIT
  • ReDoSに対するRegexpの改善
  • SyntaxSuggest の導入

本記事では、これらを含めて NEWS ファイルにあるものをだいたい紹介していきます。

■言語の変更

メソッド引数の委譲に *** 無名引数が使えるようになった

  • Anonymous rest and keyword rest arguments can now be passed as arguments, instead of just used in method parameters. [Feature #18351]

      def foo(*)
        bar(*)
      end
      def baz(**)
        quux(**)
      end
    

Rubyでメソッドに受けた引数を別のメソッドに渡す方法として、無名の***が利用できるようになりました。

# ポジショナル引数だけ委譲したい
def foo(*)
  bar(*)
end

# これと同じ
def foo(*args)
  bar(*args)
end

# キーワード引数だけ委譲したい
def baz(**)
  quux(**)
end

# これと同じ
def baz(**kw)
  quux(**kw)
end

似たような話として、次のような機能がありました。

  • Ruby 2.7 で導入された ... 記法(def f(...); g(...); end
    • Ruby 3.0 で引数が加えられるように拡張された(def f(...) = g(:first, ...); end
  • Ruby 3.1 で導入されたブロックを委譲するための記法 def foo(&) = bar(&)

今回の拡張は、これらの「無名引数」シリーズの延長かと思います。今回の拡張で委譲する引数にちょっと足して渡す、みたいなのがやりやすくなったんではないかと思います。

def f(*, **)
  g(:first, *, add_key: 1, **)
end

# これと同じ
def f(*args, **kw)
  g(:first, *args, add_key: 1, **kw)
end

とくに、キーワード引数ですかねぇ。余談ですが、上記例で渡された引数に add_key キーワードが含まれていた場合、kwのほうが優先されるためadd_key: 1g() にわたりません。渡されたキーワード引数が優先される場合はこれで問題ないのですが、絶対に add_key: 1 を渡したい場合は** よりも後ろに書く必要があります。

def f1(*, **)
  p(*, add_key: 1, **)
end

def f2(*, **)
  p(*, **, add_key: 1)
end

f1(add_key:10) #=> {:add_key=>10}
f2(add_key:10) #=> {:add_key=>1}

用途に応じて気を付けてください。

引数に名前を付けておけば、これまでと全く変わりません。無名にすることで名前を考える苦労がちょっと減るのですが、乱用するとわかりづらいコードになるので、容量用法にお気を付けください。

(ko1)

procの引数のコーナーケース挙動が少し変更

  • A proc that accepts a single positional argument and keywords will no longer autosplat. [Bug #18633]

引数を複数受け取るProcに、配列を1つ渡して呼び出すと、配列が勝手に分解されます。

proc {|a, b| a }.call([1, 2]) #=> 1
proc {|a, b| b }.call([1, 2]) #=> 2

しかし引数を1つだけ受け取るProcの場合は、分解されません。

proc {|a| a }.call([1, 2]) #=> [1, 2]
proc {|a| a }.call([1]     #=> [1]

ここで、引数を1つ受け取りつつ、キーワード引数も受け取るProcの挙動はどうあるべきでしょうか。

proc {|a, **k| a }.call([1, 2]) #=> ?

Ruby 3.1では分解される(つまり 1 が返る)という挙動でしたが、Ruby 3.2からは分解されない(つまり [1, 2] が返る)ということになりました。

Procの引数はとてもむずかしいですね。

(mame)

定数の評価順序が「左から右」の原則を守るようになった

  • Constant assignment evaluation order for constants set on explicit objects has been made consistent with single attribute assignment evaluation order. With this code:

      foo::BAR = baz
    

    foo is now called before baz. Similarly, for multiple assignments to constants, left-to-right evaluation order is used. With this code:

      foo1::BAR1, foo2::BAR2 = baz1, baz2
    

    The following evaluation order is now used:

    1. foo1
    2. foo2
    3. baz1
    4. baz2

    [Bug #15928]

定数は、expr::FOO のように記述できるのはご存じでしょうか。expr にはたいていの Ruby 式を記述することができます。

class C; class D; end; end
d = (p C)::D #=> C (p C の出力)
p d          #=> C::D

さらに、この記法は多重代入の左辺に使うことができます。そうすると Ruby 3.1 で修正されたコード順の問題が出てきます(参考: 「多重代入の評価順序が変更された」プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログ)。

NEWS にあるコード例を、実際に動くコードにしてみます。

class FOO; end

def foo1 = (p(:foo1); FOO)
def foo2 = (p(:foo2); FOO)
def baz1 = p(:baz1)
def baz2 = p(:baz2)

foo1::BAR1, foo2::BAR2 = baz1, baz2

これを Ruby 3.1 で実行してみると、

:baz1
:baz2
:foo1
:foo2

となります。つまり、右辺の2式が実行されたあと、左辺の2式が実行されています。そこで、Ruby 3.1 で多重代入の評価順序を修正した Jeremy Evans によって、ただしく「左から右」と評価されるように修正されました。

:foo1
:foo2
:baz1
:baz2

いやぁ、良かったよかった。ただ、これバイトコードだいぶ冗長にして若干遅くなるんですよね...。できれば、皆様にはこういう難しいコード(多重代入)は書かないでいただけると助かります。

ちなみに、すごいどうでもいいんですが、この多重代入が書けるようになったのは Ruby 1.8 以降なんですね。

(ko1)

Findパターンが実用段階

Findパターンがパターンマッチで利用可能になりました。正確に言うとRuby 3.1でも実験的機能として導入されていましたが、今回から正式な機能となります。違いとしては、使っても警告が出ません。

ary = [1, 2, 3, 4, 5]

# Find パターン
if ary in [*, 3, *]
  p "3を含んでいる"
end

if ary in [*, 3, x, *]
  p "3の後にある値は#{ x }"
end

上の例のように、最初にマッチした値の次の値、などを取りたい時に便利かもしれません。

(mame)

ruby2_keywordsが厳格に要求される

  • Methods taking a rest parameter (like *args) and wishing to delegate keyword arguments through foo(*args) must now be marked with ruby2_keywords (if not already the case). In other words, all methods wishing to delegate keyword arguments through *args must now be marked with ruby2_keywords, with no exception. This will make it easier to transition to other ways of delegation once a library can require Ruby 3+. Previously, the ruby2_keywords flag was kept if the receiving method took *args, but this was a bug and an inconsistency. A good technique to find the potentially-missing ruby2_keywords is to run the test suite, for where it fails find the last method which must receive keyword arguments, use puts nil, caller, nil there, and check each method/block on the call chain which must delegate keywords is correctly marked as ruby2_keywords. [Bug #18625] [Bug #16466]

Ruby 3.0で導入されたruby2_keywordsがより厳格に要求されるようになりました。

ruby2_keywordsは、メソッドがキーワード引数を可変長引数の一部として受け取るようにする注釈です。その引数を更に他のメソッドに渡すとき、キーワード引数だったものとして渡されます。Ruby 2のときの委譲を再現するために使われます。

# fooは普通の引数もキーワード引数もまとめて可変長引数として受け取る
ruby2_keywords def foo(*args)
  # 受け取った可変長引数をtargetに委譲する
  target(*args)
end

def target(k:)
  k
end

foo(k: 1) #=> 1

今回の変更は、次のような多段の委譲(foo→bar→target)のときの話です。

ruby2_keywords def foo(*args)
  bar(*args)
end

# Ruby 3.2からはここにもruby2_keywordsを書く必要がある
def bar(*args)
  target(*args)
end

def target(k:)
  k
end

foo(k: 1)

fooruby2_keywordsが指定されていますが、barには指定されていないことに注意してください。 原則で言えばbarも委譲をしているのでruby2_keywordsを指定すべきですが、Ruby 3.1までは指定しなくても意図通りに委譲ができていました。

しかし、Ruby 3.2からはruby2_keywordsの注釈が厳格に求められるようになりました。

よって、ruby2_keywordsを書き足してもいいですが、もしRuby 2との互換性を気にしないのであれば、次のように委譲するのがおすすめです。Ruby 2.7は2023年3月末にEOLになる見込みです。あと3ヶ月。

def bar(*args, **opts)
  target(*args, **opts)
end

(mame)

■組み込みクラスのアップデート

Fiber#storage が導入された

  • Introduce Fiber. and Fiber.= for inheritable fiber storage. Introduce Fiber#storage and Fiber#storage= (experimental) for getting and resetting the current storage. Introduce Fiber.new(storage:) for setting the storage when creating a fiber. [Feature #19078]

    Existing Thread and Fiber local variables can be tricky to use. Thread local variables are shared between all fibers, making it hard to isolate, while Fiber local variables can be hard to share. It is often desirable to define unit of execution ("execution context") such that some state is shared between all fibers and threads created in that context. This is what Fiber storage provides.

    def log(message)
      puts "#{Fiber[:request_id]}: #{message}"
    end

    def handle_requests
      while request = read_request
        Fiber.schedule do
          Fiber[:request_id] = SecureRandom.uuid

          request.messages.each do |message|
            Fiber.schedule do
              log("Handling #{message}") # Log includes inherited request_id.
            end
          end
        end
      end
    end

You should generally consider Fiber storage for any state which you want to be shared implicitly between all fibers and threads created in a given context, e.g. a connection pool, a request id, a logger level, environment variables, configuration, etc.

ちょっと背景が長い話になります。

グローバル変数を使いたいときってありますよね。複数メソッドをまたいで情報を共有したい、でも引数で引き回すのも嫌。グローバル変数は、そんな我がままなあなたのための機能です。

ただ、グローバル変数はプロセス内で共有されてしまうので、複数のスレッドで共有したくないときは嫌ですね。そこで、スレッドローカルストレージ(TLS)が導入されていました。Thread#[] でアクセスできるやつです。Thread.current[:request_id] = 1 としてけば、あるスレッド内で Thread.current[:request_id] と参照すると 1、それ以外のスレッドからは別の値(もしくは未設定の nil)が返るというものです。

その後、Ruby 1.9で、Fiber が導入されました。実は、TLS は Fiber ローカルな挙動にするためによく利用されてきました。例えば、pメソッドなどでの循環参照の検出などです。そこで、Thread#[]はTLSではなく、ファイバーローカルな値を扱う機能(FLS)になりました。

その後、Ruby 2.0で、あるスレッドで作ったFiber間で共有してほしいけど、スレッド間では共有してほしくない、という要望が出てきました。Fiber は Enumerator などでいつの間にか利用される可能性があるので、それらを使っていても同じ変数セットを使い続けてほしい、というものです。そこで、Thread#thread_variable_set/getが TLS として導入されました。

そろそろ混乱してきたのではないかと思うのでまとめておきます。

  • Ruby 1.8まで: Thread#[] は TLS
  • Ruby 1.9から: Thread#[] は FLS
  • Ruby 2.0から: Thread#[] は FLS、Thread#thread_variable_set/get は TLS

まぁ、正直 Thread#thread_variable_set/get が必要になるケースは、コンポーネント間の暗黙的な依存を増やすので、引数やインスタンス変数などで正しくデータを伝搬するべきだと思いますが...。

で、さらに次の要望が出てきました。現状のよくわからん TLS、FLS を一新する提案です。

  • スレッドの親子(親スレッドと子スレッド)で共有する変数セット
  • あるスレッドで作ったファイバで共有する変数セット
  • ファイバの親子(親ファイバと子ファイバ)で共有する変数セット
  • それらの変数セットを柔軟にとっかえひっかえしたい

つまり、スレッド -> スレッド、スレッド -> ファイバ、ファイバ -> ファイバの親子関係(スレッドを作るとファイバが作成されるので、そういう意味ではファイバ -> ファイバの親子関係しかないのですが)で共有される変数セットを用意したい、それをイイ感じに管理したい、というものです。これを、Fiber storage という機能群でまとめられました。利用用途は、a connection pool, a request id, a logger level, environment variables, configuration だそうです。

Fiber storage という名前ですが、Fiber local storage ではなく、Fiber 間で変数セットを共有するための仕組みです。ただし、ファイバを作成した時点で変数セットはコピーされるので、独立しています。

アクセスは Fiber[:key] で行います(つまり、別ファイバの storage にはアクセスできません)。

Fiber[:k1] = :k1
Fiber.new{ # デフォルトでは親の Fiber storage が引き継がれる
  p Fiber[:k1] #=> :k1
  Fiber[:k1] = :updated_k1
}.resume

p Fiber[:k1] #=> :k1 (:updated_k1 ではない)

ファイバ生成時、引き継がない、独自の変数セットを持つ、という選択をすることもできます。

Fiber[:k1] = :k1
Fiber.new(storage: nil){
  p Fiber[:k1] #=> nil
}.resume

Fiber.new(storage: {k1: :my_k1}){
  p Fiber[:k1] #=> :my_k1
}.resume

また、Fiber#storage(=) で変数のセットをまとめて取り出したり設定したりできます(なお、Fiber#storage= は experimental feature です)。

Fiber.current.storage = {k1: :new_k1, k2: :new_k2}
p Fiber[:k1] #=> :new_k1
p Fiber[:k2] #=> :new_k2

このまとめての設定機能が何に使うのかよくわからなかったんですが、スレッドプールからスレッドを取り出して、そこに今から実行するために必要になるストレージをガツンと設定するために必要なんだそうです。なるほどなぁ。

細かい話はもう少しあるんですが、十分細かい話をした気がするので、この辺で。

マニア向け:この機能が議論されていたきは、「ある種のダイナミックスコープ」と説明されていました(気になる人はググってください)。なるほどなぁ、並行実行単位を超えて共有されるダイナミックスコープ(並行実行単の生成を関数呼び出しととらえれば等価です)。確かに、便利そうではあるんだけど。ActiveRecord みたいな DSL で使いたいよなぁ。でも、これはこれで複雑そうなんで、大丈夫かなぁ。ほかの言語で似たような機能の成功例(もしくは失敗例)があったら教えてください。

人類は、必要に駆られてさまざまなスコープを導入してきました。

  • グローバルスコープ(グローバル変数、プロセスローカル変数、Rubyだと定数空間も)
  • ローカルスコープ(ローカル変数、ブロックローカル変数)
  • レキシカルスコープ(ローカル変数、親クロージャ(Rubyだとブロック)の変数)
  • ダイナミックスコープ(動的変数、Rubyにはない、今回のは似ている)
  • インスタンスローカルスコープ(インスタンス変数)
  • クラスローカルスコープ(クラス変数)
  • スレッドローカルスコープ(スレッドローカル変数)
  • ファイバローカルスコープ(ファイバローカル変数)
  • Ractor ローカルスコープ(Racotr ローカル変数)
  • (広げれば、分散システムのディレクトリサービスとかも入りそう)
  • (きっと探せばもっとありそう)

今回、これにファイバの親子関係によるスコープが導入されたわけですね。人類の業は深い。

(ko1)

Fiber::Scheduler#io_select が導入された

  • Introduce Fiber::Scheduler#io_select for non-blocking IO.select. [Feature #19060]

Fiber scheduler で IO.select をフックするためのインターフェースが導入されました。

(ko1)

IO#timeout=sec が導入された

  • Introduce IO#timeout= and IO#timeout which can cause IO::TimeoutError to be raised if a blocking operation exceeds the specified timeout. [Feature #18630]

      STDIN.timeout = 1
      STDIN.read # => Blocking operation timed out! (IO::TimeoutError)
    

1度の I/O 操作(例えば、read とか write とか)に、その I/O が対応していれば、設定した時間を超えたらタイムアウトの例外(IO::TimeoutError)が発生するようになりました。

r, w = IO.pipe
r.timeout = 3
# pipe の片方の w に何も書いていないので、r.read してもずっと sleep する
r.read(1) #=> Blocking operation timed out! (IO::TimeoutError)

(timed out! ってメッセージは、なんか元気いいですね。いいことあったのかな)

この API は便利そうですが、いくつか注意が必要です。

  • 1回の操作ごとにタイムアウトはリセットされるので「複数の I/O 処理全部を 3 秒のタイムアウトで」のようにするには、結構大変です。
  • 大きなバッファを write する場合、write が複数回に別れることがあります。このとき、途中の write でタイムアウトが生じると、書き出している途中で例外が発生するため、どこまで書き出したかわかりません。(read はそういうのないんだっけ? 遠藤さんによるとあるらしいです)というわけで、タイムアウトを検出しても、リトライできるとは思わない方が安全です。たとえば、ウェブのリクエストの場合は素直にエラーを返しましょう。
  • 対応する I/O、および操作にしか効きません。典型的にはブロックデバイス(いわゆるストレージ上のファイル)には効きませんし、close などにも効きません。ほとんどの場合、(人間の尺度では)一瞬で処理が終わるので問題になりませんが、ハードウェアが壊れていて時間がかかったり、NFS上で時間がかかったりしていても、これにタイムアウトは効きません。簡単には、ソケットにしか効かないって思っておくのが良いと思います。

というわけで、ちょっと使うのが難しそうな API なんですが、「どーしょーもない」ことを検出するための最後の手段としてご利用いただくのがいいでしょうか。私はイマイチ、この API の良い用法がわかっていません。

(ko1)

IO.newにpathキーワードが渡せるようになった

  • Introduce IO.new(..., path:) and promote File#path to IO#path. [Feature #19036]

ファイルディスクリプタ番号からIOを作る機能があるのですが、それにファイルパスを指定できるようになりました。

io = IO.for_fd(0, path: "hoge")

p io      #=> #<IO:hoge>
p io.path #=> "hoge"

inspectの結果に影響を与える程度なので、まあ、あまり使うことはないと思います。

いちおう導入の背景を書くと、

  • ptyという拡張ライブラリが無理やりIO#pathを指定していた(実装がC言語なので、強引に書き換えることができてた)
  • JRubyがpure Ruby(+FFI)でptyを模倣するライブラリを作っていたとき、IO#pathを指定する方法がないことに気づいた
  • 困るので導入された

という感じです。

(mame)

Class#attached_object が導入された

  • Class#attached_object, which returns the object for which the receiver is the singleton class. Raises TypeError if the receiver is not a singleton class. [Feature #12084]

特異クラスから、それのインスタンスを得るメソッドが導入されました。

obj = Object.new
klass = obj.singleton_class

p obj                   #=> #<Object:0x00007f7919a60150>
p klass.attached_object #=> #<Object:0x00007f7919a60150>

Rubyの型チェッカであるSorbet、のための型定義のスタブ生成ツールであるtapioca、は、Rubyの黒魔術を活用しまくって型定義を動的に推定するのですが、その際にこれが欲しかったとのこと。

(mame)

Dataクラスが導入された

  • New core class to represent simple immutable value object. The class is similar to Struct and partially shares an implementation, but has more lean and strict API. [Feature #16122]
        Measure = Data.define(:amount, :unit)
        distance = Measure.new(100, 'km')            #=> #<data Measure amount=100, unit="km">
        weight = Measure.new(amount: 50, unit: 'kg') #=> #<data Measure amount=50, unit="kg">
        weight.with(amount: 40)                      #=> #<data Measure amount=40, unit="kg">
        weight.amount                                #=> 50
        weight.amount = 40                           #=> NoMethodError: undefined method `amount='

Dataという新しいクラスが導入されました。一言で言えば、書き換え不可のStructです。

Point = Data.define(:x, :y)
pt = Point.new(1, 2)

p pt.x    #=> 1
p pt.y    #=> 2

Data.defineStruct.newと読み替えれば、だいたい同じです。

違いというと、

  • 書き換えをするメソッドがないこと
  • 配列やハッシュのように扱うメソッド([]とかselectとか)がないこと
  • 初期化時にフィールドの値を省略できないこと

くらいです。

# 書き換えはできない
pt.x = 3  #=> undefined method `x=' for #<data Point x=1, y=2> (NoMethodError)

# []でフィールドを読み出せない
pt[:x]    #=> undefined method `[]' for #<data Point x=1, y=2> (NoMethodError)

# newで引数が足りないとエラー
Point.new(1) #=> missing keyword: :y (ArgumentError)

一部のフィールドだけ置き換えた新しいインスタンスを作りたい場合は、Data#withが使えます。

pt2 = pt.with(x: 3)

p pt2 #=> #<data Point x=3, y=2>

以下余談。

Rubyに新しいクラスを導入する場合、問題になるのが名前です。下手に定数を増やしてしまうと、その名前を使っているgemがあったとき、非互換となってしまう可能性があります。にもかかわらずDataという思い切った名前になったのは、かつてRuby自身がDataというトップレベルの定数を定義していたからです。

これは拡張ライブラリ作者が内部実装に使うために用意されていたクラスだったのですが、なぜか誰も使いませんでした。そして長らく非推奨となっていて、Ruby 3.0くらいでついに削除されました。なので、2年ほど経ってはいますが、今ならまだ他のライブラリとの衝突の可能性が低いのでは?ということで、この名前になりました。

(mame)

Encoding#replicate が非推奨に

  • Encoding#replicate has been deprecated and will be removed in 3.3. [Feature #18949]

エンコーディングをコピーして新しい名前を付ける Encoding#replicate が非推奨になりました。

もともとは、無限にエンコーディングを増やすことを許すと、性能的に問題になるケースがある、ということで無限に増やす可能性があるけど、結局そんなに使うことないじゃん、ってことで、非推奨になりました。

(ko1)

Encoding::UTF_16Encoding::UTF_32 の特別扱いをやめた

  • The dummy Encoding::UTF_16 and Encoding::UTF_32 encodings no longer try to dynamically guess the endian based on a byte order mark. Use Encoding::UTF_16BE/UTF_16LE and Encoding::UTF_32BE/UTF_32LE instead. This change speeds up getting the encoding of a String. [Feature #18949]

Encoding::UTF_16/32 は、バイトエンディアンが決まっていない「仮」のエンコーディングです。そのため、Ruby 3.1ではこれらのエンコーディングが設定された文字列については、なんらかの文字列処理を行うたびに(エンコーディングを取り出すたびに)文字列先頭の Byte order mark (BOM) をチェックして LE か BE に動的に可能なら変更するようにしていました。

で、この変更は、この特別扱いをやめて、Encoding::UTF_16 については何らかの文字列処理(のエンコーディングを取り出す処理)をしても動的に LE/BE にはしないようにしました。つまり、利用できる文字列処理が凄く少なくなりました。処理前に、先にアプリケーションでチェックして LE か BE か設定しておいてください。

この特別扱いは、実はふつーの(今は多くの文字列は UTF-8 でしょうか)文字列処理も分岐を一個増やすので遅くなっちゃう、という問題が指摘されたので、じゃあやめて速くしてやろ、ってことで、やめました。実際、どれくらい速くなったのかな。

(ko1)

Encoding テーブルが 256 個になった

  • Limit maximum encoding set size by 256. If exceeding maximum size, EncodingError will be raised. [Feature #18949]

エンコーディングシリーズの最後です。Encoding#replicate のところで述べた通り、無限にエンコーディングを増やせるようにすると、性能上問題がありました。具体的には、テーブルを拡張する必要があるので、Ractor並列実行でも問題ないように同期するようにしていました。これがあると、一般的な文字列操作のためにエンコーディング情報を取り出す操作が(ちょびっと)遅くなってしまう、という問題がありました。

そこで、テーブル長を256個にする、つまり Ruby インタプリタが扱うエンコーディングの数を 256個を上限とすることで、この同期を排除しました(エンコーディングのロード時には、まだ同期処理が入っています)。

256個に制限されたので、万が一「わしのエンコーディングリストは65,536個あるぞ」みたいな方がいらっしゃいましたらお早めにご連絡ください。例えば、起動時に上限を指定できるようにする、みたいな拡張は可能です。ちなみに、Internet Assigned Numbers Authority (IANA) の Character Sets によると、知られている文字エンコーディング(なのか?)の数は 258 だそうです。全部持ってこられるとちょっとあふれちゃうね。

というわけで、 [Feature #18949] で提案されたエンコーディングに起因する性能向上策は導入されたわけですが、さてどれくらい速くなったんだろうな。

(ko1)

Enumerator.productが導入された

  • Enumerator.product has been added. Enumerator::Product is the implementation. [Feature #18685]

組み合わせを簡単に列挙できるメソッドが導入されました。

Enumerator.product([1, 2], [3, 4]) do |x, y|
  p [x, y]
  #=> [1, 3]
  #   [1, 4]
  #   [2, 3]
  #   [2, 4]
end

探索アルゴリズムなんかで便利そうですね。

Array#productというメソッドもあったのですが、こちらはなぜか配列を返してしまうので、探索に使うには不向きなのでした。

(mame)

Exception#detailed_messageが導入された

  • Exception#detailed_message has been added. The default error printer calls this method on the Exception object instead of #message. [Feature #18564]

エラーメッセージを拡張するためのAPIが導入されました。did_you_meanやerror_highlightやsyntax_suggestなど、エラーメッセージを拡張するgem向けのものなので、アプリ開発者が直接使うことはあまりないかもしれません。

いちおう簡単な利用例を載せておきます。

class MyError < Exception
  def detailed_message(highlight: true, **)
    super + "\n\n補足情報です"
  end
end

raise MyError
#=> test.rb:7:in `<main>': MyError (MyError)
#
#   補足情報です

エラーを扱うフレームワークでは、#messageを呼び出していたところで#detailed_messageを呼び出すように変えたほうがよいかもしれません。

前述の通り、このメソッドはerror_highlightなどエラーメッセージを拡張するgemのために導入しました。というのも、エラーメッセージを下手に上書きすることは非互換になってしまい、いくつかのプロジェクトのテストを失敗させてしまうためです。このあたりの背景はRubyKaigiで話したので、よければそちらを見てください。

https://rubykaigi.org/2022/presentations/mametter.html#day3

(mame)

Hash#shift が空ハッシュに対しては常に nil を返すようになった

  • Hash#shift now always returns nil if the hash is empty, instead of returning the default value or calling the default proc. [Bug #16908]

Hashの要素をArray#shiftのように一個ずつ取り出す Hash#shift というメソッドがあります。

h = {k1: 1, k2: 2}
p(h.shift) #=> [:k, 1]
p h        #=> {:k2=>2}

さて、ここで空 Hash に shift するとどうなるでしょうか。

p({}.shift) #=> nil

(おそらく)期待した通り、nil が返ります。

ここまでは、とくに疑問もないと思うのですが、ではデフォルト値があったらどうしましょう。デフォルト値は、「無いときにはこれを返す」というのがルールでした。

p Hash.new{true}.shift

結論を書くと、こんな感じでした。

  • Ruby ~1.6: nil
  • Ruby 1.8~: true(デフォルト値だけ。key はなし)

キーと値のペアが返るはずなのに、true が返ってくるというのが変です。さらに、デフォルト値をかえすときに、ついでレシーバを設定するようなケースだとどうでしょうか。

h = Hash.new {|h, k| h[k] = true }
5.times{
  p [h.shift, h]
}

# Ruby 3.1 での結果 #=>
# [true, {nil=>true}]
# [[nil, true], {}]
# [true, {nil=>true}]
# [[nil, true], {}]
# [true, {nil=>true}]

なんかよくわからない振動をしています(じっと見ると気持ちがわかります)。

で、これなんか変じゃない? ってことで、「空のときは(デフォルト値は無視して)単にnil 返そう」ということになりました。

余談ですが、私は Hash#shift というメソッドをこの議論で初めて知りました。

(ko1)

Integer#ceildivが導入された

切り上げの整数除算をするメソッドが追加されました。

# 5/3 を切り上げる
5.ceildiv(3) #=> 2

もう (a + b - 1) / b と書かなくてすみますね。 pagenationなんかで便利かもしれません。つまり、1ページにn個のアイテムを持たせる時、全部でm個のアイテムを書くには何ページ必要か、を計算するとか。

(mame)

binding が取れないコンテキストでは binding メソッドは nil を返すようになった

  • Kernel#binding raises RuntimeError if called from a non-Ruby frame (such as a method defined in C). [Bug #18487]

これは気にしなくていいです。気にしたい人はきっと悪い人。

悪い人向けの補足です。これまで、C メソッドなど、Ruby で記述されたコンテキストでない場所で Binding を取ろうと、rb_funcall(:binding) みたいなことをやったら、その呼び出し側の Binding が取れていたんですが(たしか)、Ruby 3.2 から例外があがるようになりました。悪いことはするもんじゃないですね。

冗談はおいといて、まぁほとんど踏むことはないんじゃないかと思います。

(ko1)

Stringをバイト列として扱うメソッドがいくつか増えた

正規表現にマッチした位置を、「何文字めか」ではなく「何バイトめか」で返すメソッドが増えました。

"あいうえお" =~ /い/

# 「い」は3バイトめから6バイトめにある
$~.byteoffset(0) #=> [3, 6]
  • String#byteindex and String#byterindex have been added. [Feature #13110]

こちらはString#indexString#rindexと似ていますが、やはり「何バイトめか」を返すメソッドです。

"あいうえお".byteindex("い") #=> 3

おそらくTextbringerというRuby製エディタで必要だった機能と思われます。

(mame)

これも、文字列をバイト単位で文字列を編集するためのメソッドです。

s = 'hello'
p s.bytesplice(2, 2, 'LL') # s の 2 文字目から 2 文字分を 'LL' に置き換え
#=> "LL"
p s
#=> "heLLo"

これ、なんで self じゃなくて "LL" 返すんだろ。 (聞いてみたら、String#[]= にあわせたんだそうです。でも便利なのかなぁ?)

指定した箇所がコードポイントの途中だったりするとエラーがでるそうです。

(ko1)

Module.used_refinements が導入された

現在実行中の箇所で、どの refinements が using されているか返す Module.used_refinements が追加されました。

module R1
  refine String do
  end
  refine Array do
  end
end

module R2
  refine Array do
  end
end

module R3
  refine Hash do
  end
  refine Regexp do
  end
end

using R1
using R2
p Module.used_refinements #=> [#<refinement:String@R1>, #<refinement:Array@R2>, #<refinement:Array@R1>]

using R3
p Module.used_refinements
#=> [#<refinement:String@R1>, #<refinement:Array@R2>, #<refinement:Array@R1>, #<refinement:Hash@R3>, #<refinement:Regexp@R3>]

返り値は、Ruby 3.1 で導入された Refinement クラスです。

(ko1)

Module#refinements が導入された

そのモジュールが含む Refinement クラス一覧を取る Module#refinements が導入されました。

module R1
  refine String do
  end
  refine Array do
  end
end

p R1.refinements     #=> [#<refinement:String@R1>, #<refinement:Array@R1>]
p String.refinements #=> []

何に使うんでしょうね。

(ko1)

Refinement#refined_class が導入された

ある Refinement がどのクラスを refine しているか返します。上で紹介した Module#refinements を使ってサンプルコードを書いてみます。

module R1
  refine String do
  end
  refine Array do
  end
end

p R1.refinements.map{|r| r.refined_class}
#=> [String, Array]

(ko1)

Module#const_added が追加された

定数が定義されたときに呼ばれる Module#const_added が導入されました。

class C
  def self.const_added name
    p name
    super
  end
end

class C
  C_CONST = :C
  #=> :C_CONST
  class D
    #=> :D_CONST

    D_CONST = :D
    #=> ここでは呼ばれない
  end
end

この手のフックは別の人も使っているかもしれない(利用している複数のモジュールをprependしているかもしれない)ので、super を呼んでおくのが礼儀です。

この機能、何に使うといいですかねえ。あんまり「これやるから入れてほしい」って提案じゃなかった気がするんですよね。

(ko1)

undef されたメソッド一覧を返す Module#undefined_instance_methods が導入された

Module#undef_method(もしくは undef 式)と Module#remove_method の違いをご存じでしょうか。どちらも、メソッドを使えなくしそうなんですが、Module#remove_method は単純で、そのモジュール(クラス)に定義されているあるメソッドを削除します。そのメソッドを呼び出そうとすると、継承関係の親クラスにあるメソッドが呼ばれます。

class C
  def f = :C
end

class D < C
  def f = :D
end

D.remove_method(:f)
p D.new.f #=> :C (C#f が呼ばれている)

Module#undef_method(もしくは undef式)は、「継承関係があろうと、そこでメソッド探索は失敗」というメソッドエントリを作成します。

class C
  def f = :C
end

class D < C
  def f = :D
end

D.undef_method :f
p D.new.f
#=> undefined method `f' for #<D:0x0000027b746213c0> (NoMethodError)
# (C#f は呼ばれない)

というわけで、そんな undef されたメソッド一覧を返すのが Module#undefined_instance_methods です。

class C
  def f = :C
end

class D < C
  def f = :D
end

D.undef_method :f
p D.undefined_instance_methods #=> [:f]

これも、なんで欲しかったんだろう。

(ko1)

Proc のこまごました話

  • Proc#dup returns an instance of subclass. [Bug #17545]

Proc#dup が、もし Proc の サブクラスだった場合、そのサブクラスのインスタンスを返すようになりました。しかし、Proc のサブクラスなんて作ることあるんかな...。

Proc は、生成方法が lambda(->)と proc(Proc.new)で、ちょっと引数の扱いが変わります。具体的には、lambda だとメソッドのように厳密に(引数の数が違うと例外)、proc だとブロックのようにあいまいに(引数の数が違ってもなんとなく動かす)なるのですが、Proc#parameterは、lambdaかprocかで結果が変わっていました。ただ、あるケースで proc で作った Proc であっても lambda のような引数が欲しい、ということがあったそうで、この挙動を指定する lambda キーワードが導入されました。

p proc{|a|}.parameters                  #=> [[:opt, :a]]
p proc{|a|}.parameters(lambda: true)    #=> [[:req, :a]]
p lambda{|a|}.parameters                #=> [[:req, :a]]
p lambda{|a|}.parameters(lambda: false) #=> [[:opt, :a]]

多分、逆(lambda だけど proc 形式で欲しい)はおまけですかね?

(ko1)

FreeBSD で Proc::RLIMIT_NPTS が導入された

  • Process
    • Added RLIMIT_NPTS constant to FreeBSD platform

man によると「このユーザ id が作成することを許可する疑似端末の最大の数」とのことです。

(ko1)

正規表現エンジンにメモ化の最適化が導入された

  • The cache-based optimization is introduced. Many (but not all) Regexp matching is now in linear time, which will prevent regular expression denial of service (ReDoS) vulnerability. [Feature #19104]
  • Regexp.linear_time? is introduced. Feature #19194

正規表現エンジンが大幅に改良されました。最悪計算量が(多くの場合で)入力文字列長に対して線形になります。これにより、いわゆるReDoSと呼ばれる脆弱性の可能性を大きく減らすことができます。

人工的な例ですが、次の正規表現マッチングはRuby 3.1では10秒くらいかかります。これがRuby 3.2では一瞬で返ります。

/^(a|a)+$/ =~ "a" * 28 + "b"

時間がかかっていた理由をざっくりいうと、正規表現エンジンが同じマッチング失敗を何度も繰り返すせいでした。今回の最適化は、一度失敗したマッチングを記憶するようにして、同じ失敗を二度と繰り返さないようにしたという感じです。

これは @makenowjust さんがクックパッドにインターンに来て実装してくれた大成果になります。詳しくは作者本人が記事を書いてくれましたので、ご参照ください、

techlife.cookpad.com

いくつかのコードを用いた実験によると、おおよそ9割の正規表現が最適化できる(つまり線形時間に抑えられる)ことがわかりました。逆に言うと、1割程度の正規表現は最適化できないので、「RubyではもうReDoSの心配は一切しなくてよい」というわけではないことにご注意ください。念のため。

Regexp.linear_time?というメソッドで、正規表現が最適化対象かどうかを判定できます。

# 最適化できる
p Regexp.linear_time?(/^(a|a)+$/)   #=> true

# 後方参照の \1 があるので最適化できない
p Regexp.linear_time?(/^(a|a)+\1$/) #=> false

(mame)

正規表現マッチングにタイムアウトが指定できるようになった

  • Regexp.timeout= has been added. Also, Regexp.new new supports timeout keyword. See [Feature #17837]

正規表現マッチングの時間上限を設定できるAPIが導入されました。もうひとつのReDoS緩和策です。

# 正規表現マッチングを最長1秒で打ち切る
Regexp.timeout = 1

# 前述の最適化ができない正規表現で時間がかかると例外になる
/^(a|a)+\1$/ =~ "a" * 28 + "b"
  #=> regexp match timeout (Regexp::TimeoutError)

最適化できない1割の可能性が心配なら、これも併用すると良いかもしれません。

(mame)

正規表現のフラグを文字列で指定できるようになった

  • Regexp.new now supports passing the regexp flags not only as an Integer, but also as a String. Unknown flags raise ArgumentError. Otherwise, anything other than true, false, nil or Integer will be warned. [Feature #18788]

Regexp.newにおいて、正規表現のフラグを文字列で指定できるようになりました。

Regexp.new("foo", "i")  #=> /foo/i
Regexp.new("foo", "m")  #=> /foo/m
Regexp.new("foo", "im") #=> /foo/im

以前は Regexp.new("foo", Regexp::IGNORECASE | Regexp::MULTILINE) などと書く必要がありました。

なお、シンボルで :im などとは書けません。

(mame)

RubyVM::AbstractSyntaxTree.parseにモードが増えた

  • RubyVM::AbstractSyntaxTree
    • Add error_tolerant option for parse, parse_file and of. [Feature #19013]
    • Add keep_tokens option for parse, parse_file and of. Add #tokens and #all_tokens for RubyVM::AbstractSyntaxTree::Node [Feature #19070]

Rubyのコードをパースするためのメソッドに新たなモードが増えました。

  • syntax errorのあるコードでもなんとなくパースするモード(error_tolerant: trueで有効になる)
  • 各ノードに対応するトークン列を保持するモード(keep_tokens: trueで有効になる)

どちらもRubyのIDEサポートなどを実装することを念頭においた拡張になっています。詳しくは作者のyui-knkさんの記事をご参照ください。

yui-knk.hatenablog.com

(mame)

Setが組み込み(?)になった

  • Set is now available as a built-in class without the need for require "set". [Feature #16989] It is currently autoloaded via the Set constant or a call to Enumerable#to_set.

setライブラリが組み込みになりました。

が、現状では、インタプリタがデフォルトでrequire "set"をするだけという感じです*1。といっても、Railsがrequire "set"をするらしいので、今更これの恩恵を受ける人はほとんどいないかもしれません。

残念ながらいまのところ、「Cで書き直されて高速」みたいな話はありません。{ 1, 2, 3 }みたいな専用の記法が導入されたわけでもありません。今後に期待ですね。

(mame)

Unicode 15.0.0 になった

  • Update Unicode to Version 15.0.0 and Emoji Version 15.0. [Feature #18639] (also applies to Regexp)

だそうです。

(ko1)

String#-@String#dedupという別名がついた

  • String#dedup has been added as an alias to String#-@. [Feature #18595]

文字列の重複を排除(deduplicate)するString#-@ があったんですが、この別名で String#dedup が入りました。

s1 = -'foo'
s2 = 'foo'.dedup

p s1.object_id == s2.object_id #=> true

(ko1)

Structの初期化にキーワードが使えるようになった

  • A Struct class can also be initialized with keyword arguments without keyword_init: true on Struct.new [Feature #16806]

Structの初期化でキーワードを使えるようになりました。

Point = Struct.new(:x, :y)

Point.new(y: 2, x: 1) #=> #<struct Point x=1, y=2>

Struct.new(:x, :y, keyword_init: true) などとすれば同じような挙動は実現できていたのですが、このオプションが不要になります。

少し細かいことを言うと、keyword_initはtrue/false/nilでそれぞれ意味が微妙に違います。

  • keyword_init: truePoint.new(x: 1, y: 2)で初期化できる、Point.new(1, 2)はエラー
  • keyword_init: nilPoint.new(x: 1, y: 2)でもPoint.new(1, 2)でも初期化できる
  • keyword_init: falsePoint.new(1, 2)で初期化できる、Point.new(x: 1, y: 2)は要注意の挙動(次の動作例を参照)
# Ruby 3.1 の挙動:Point.new({x: 1, y: 2}) と同じ扱い
Point.new(x: 1, y: 2) #=> #<struct A x={:y=>2, :x=>1}, y=nil>

今回の変更は、keyword_initのデフォルトがfalseからnilに変わったということになります。

(mame)

Thread.each_caller_location が導入された

自スレッドのバックトレース情報を取る Kernel#caller_locations というメソッドがあります。

def f = g
def g = pp(caller_locations)
f #=> ["t.rb:1:in `f'", "t.rb:3:in `<main>'"]

で、これらのメソッドで全部取るんじゃなくて、必要な場所だけフィルタしたい、最初だけ欲しい、というときに、毎回ブロックに渡すインターフェースがあると便利そうです。で、最終的に着地点として Thread.each_caller_location というメソッドが導入されました。

def f = g
def g = Thread.each_caller_location{|loc| p loc}
f
#=> "t.rb:1:in `f'"
#   "t.rb:3:in `<main>'"

この例では、全部 p で出力していますが、フィルタしたり、途中で break したりしてもいいわけです。

(ko1)

Queue#pop(timeout: sec) とかが導入された

  • Thread::Queue
  • Thread::SizedQueue

Queue#pop とか SizedQueue#push/pop とかは、待ちが生じる操作ですが、そこに timeout: sec キーワード引数でタイムアウトが設定できるようになりました。

p Queue.new.pop(timeout:1) #=> nil

タイムアウトしたら、例外ではなく、nil が返ります。

(ko1)

Timeがパターンマッチ可能になった

  • Time#deconstruct_keys is added, allowing to use Time instances in pattern-matching expressions [Feature #19071]

要するに、こんなのが書けるようになりました。

# 1時2分3秒に実行した場合
Time.now => { hour:, min:, sec: }
p hour #=> 1
p min  #=> 2
p sec  #=> 3

指定できるキーは :year、:month、:day、:yday、:wday、:hour、:min、:sec、:subsec、:dst、:zone だそうです。

  • Added Date#deconstruct_keys and DateTime#deconstruct_keys same as [Feature #19071]

DateやDateTimeも同様らしいです。

(mame)

Time.newTime#inspect の結果の文字列フォーマットで時間を指定できるようになった

  • Time.new now can parse a string like generated by Time#inspect and return a Time instance based on the given argument. [Feature #18033]

Time.newTime.parseみたいなことができるようになりました。

Time.new("2022-12-25 15:00:00")       #=> 2022-12-25 15:00:00 +0900
Time.new("2022-12-25 15:00:00 +0000") #=> 2022-12-25 15:00:00 +0000
Time.new("2022-12-25 15:00:00Z")      #=> 2022-12-25 15:00:00 UTC

なんでもかんでも時刻として無理やり解釈しようとするTime.parseと違って、Time.newはフォーマットに厳格です。たとえば桁数は省略できません。

Time.new("2022-12-25 15:00:0")
  #=> two digits sec is expected after `:': :0 (ArgumentError)

ただし、ISO 8601の時刻表記のような "T" は許容されます。

# iso8601のような "T" は許容される
Time.new("2022-12-25T15:00:00Z")      #=> 2022-12-25 15:00:00 UTC

"T" のかわりに空白を許容する Time.iso8601 に近いかも。強いて特典をあげるなら、Time.new は C 言語で書かれているので速いです。

(mame)

SyntaxError#pathが追加された

SyntaxErrorを引き起こした原因のファイル名を得られるようになりました。

begin
  load "broken.rb"
rescue SyntaxError
  p $!.path #=> "broken.rb"
end

後述するsyntax_suggestのために導入されました。一般的な用途は、あんまりないかも。

(mame)

CメソッドをフックしたときのTracePoint#bindingnilを返すようになった

  • TracePoint#binding now returns nil for c_call/c_return TracePoints. [Bug #18487]

Kernel#binding の話と似ています。これまで、c_call/creturnフックでは、呼び出し元の Ruby コードの Binding が返ってきましたが、これを nil にしています。

TracePoint.new(:c_call){|tp|
  p [tp, tp.binding]
}.enable{
  p 1
}

#=>
# [#<TracePoint:c_call `p' t.rb:4>, nil]
# [#<TracePoint:c_call `to_s' t.rb:4>, nil]
# 1

多分、問題ないと思うんですが、もしはまったらゴメンなさい。

(ko1)

TracePoint#enable(target_thread: Thread.current) がデフォルトになった

  • TracePoint#enable target_thread keyword argument now defaults to the current thread if a block is given and target and target_line keyword arguments are not passed. [Bug #16889]

TracePoint#enable メソッドを用いると、その TracePont が有効になる対象をいろいろ決められるのですが、TracePoint#enable(target_thread:) で有効にするスレッドを指定できます。

今までは、これがないと全スレッドに有効になっていたんですが、enable にブロックがわたされているときは、まぁたいていはそのブロックを実行している自スレッドだけに効かせたいでしょう、ということで、target_thread: Thread.current をデフォルトにするようにしました。nil にすると全スレッドに効きます。また、targettarget_line キーワードなどがついていても、全スレッドに有効になります。

Thread.new{loop{1.inspect}}
TracePoint.new(:c_call){|tp| 
  p tp # 別スレッドには効かない
}.enable{
  sleep 0.5
}
p :term

(ko1)

UnboundMethod がレシーバの情報を持たなくなった

  • UnboundMethod#== returns true if the actual method is same. For example,String.instance_method(:object_id) == Array.instance_method(:object_id)returns true. [Feature #18798]
  • UnboundMethod#inspect does not show the receiver of instance_method. For example String.instance_method(:object_id).inspect returns"#<UnboundMethod: Kernel#object_id()>"(was "#<UnboundMethod: String(Kernel)#object_id()>").

これは多分興味ある人ほとんどいないと思うんだけど...。

これまで、Module#instance_method(method_name) で取り出した UnboundMethodインスタンスは、レシーバの情報をもっていました。

# Ruby 3.1
p m1 = String.instance_method(:object_id)
#=> #<UnboundMethod: String(Kernel)#object_id()>
p m2 = Array.instance_method(:object_id)
#=> => #<UnboundMethod: Array(Kernel)#object_id()>
p m1 == m2
#=> false

つまり、String や Array から取り出したメソッドだということがわかるようにしていたのです。 また、それらは別物とされていました。

ただ、そもそもそんな区別いる? という議論がされ、要らんのでは、ということになりました。

# Ruby 3.2
p m1 = String.instance_method(:object_id)
#=> #<UnboundMethod: Kernel#object_id()>
p m2 = Array.instance_method(:object_id)
#=> #<UnboundMethod: Kernel#object_id()>
p m1 == m2
#=> true

(ko1)

GC.latest_gc_infoneed_major_by を返すようになった

  • Expose need_major_gc via GC.latest_gc_info. GH-6791

GC.latest_gc_info は直前の GC の実行状況を Hash で返すメソッドですが、その状況の中に、:need_major_by が追加されました。これは、「次の GC が major GC と決まっていたら、その決定理由を返す」という情報です。

pp GC.latest_gc_info
#=>
# {:major_by=>:oldgen,
   :need_major_by=>nil,     # nil なので(現時点では)次の GC は major GC を予定していない。
   :gc_by=>:newobj,
   :have_finalizer=>false,
   :immediate_sweep=>false,
   :state=>:none}

次の GC が major GC かどうかは、直前の GC の実行状況によって「逼迫してそうだから次は major GC にしよ」とどこかのタイミングで決まるので、その情報を返すためのものです。

ただ、need_major_by は「次のGC」の話でgc_byは「前回のGC」の話で、区別しづらい良くない名前になってしまっています。気づかないうちに入ってしまっていました。不覚。

(ko1)

ObjectSpace.dump_all が shape を返すようになった

  • ObjectSpace.dump_all dump shapes as well. [GH-6868]

主にヒープの状況の詳細な分析に利用するための ObjectSpace.dump_all の出力が、後述する object shape の情報を含むようになりました。

(ko1)

■stdlibのアップデート

BundlerがPubGrubバージョン解決アルゴリズムを利用するようになった

  • Bundler

BundlerはGemfileやgemspecに書かれた依存性を満たすバージョンを決定する必要があるのですが、これを行うアルゴリズムを早くて賢いものに変えたそうです。

このアルゴリズムはPubGrubと呼ばれていて、解説記事を眺めたところ、“unit propagation”、“logical resolution”、“conflict-driven clause learning”など、SATソルバの実装で聞くテクニックを使っているみたいです。

(mame)

CGI.escapeURIComponentが導入された

  • CGI.escapeURIComponent and CGI.unescapeURIComponent are added. [Feature #18822]

CGI.escapeCGI.unescapeの亜種が導入されました。

CGI.escapeURIComponent("foo/bar baz") #=> "foo%2Fbar%20baz"
CGI.escape("foo/bar baz")             #=> "foo%2Fbar+baz"

違いは空白の扱いだけです。CGI.escapeは空白を+に置換えますが、CGI.escapeURIComponent%20にします。

Rubyらしからぬメソッド名は、JavaScriptのescapeURIComponent関数と同じであることを明示するために選ばれました。

(mame)

evalされたコードのカバレッジを測定できるようになった

  • Coverage.setup now accepts eval: true. By this, eval and related methods are able to generate code coverage. [Feature #19008]
  • Coverage.supported?(mode) enables detection of what coverage modes are supported. [Feature #19026]

コードカバレッジを測定するCoverageライブラリが、requireloadしたファイルだけでなく、evalされたコードのカバレッジも測定できるようになりました。Coverage.start(lines: true, eval: true)などと初期化する必要があります。

どう便利かというと、要するに、ERBのカバレッジがなんとなく取れるようになります。次のPRを見てください。

https://github.com/simplecov-ruby/simplecov/pull/1037

使いかたとしては、simplecov 0.22.0以降で次のように設定することで有効にできます。

SimpleCov.start do
  enable_coverage_for_eval
end

実際にカバレッジ測定しているのはERBが生成したRubyコードで、その行番号をむりやりERBのカバレッジとして表示しているので、やや不自然に見えることもあるかもしれません。本格的にやるには、ERBにsourcemap的なものを吐かせる必要があるのだろうなあ。

(mame)

ERB::Util.html_escapeが速くなった

  • ERB::Util.html_escape is made faster than CGI.escapeHTML.
    • It no longer allocates a String object when no character needs to be escaped.
    • It skips calling #to_s method when an argument is already a String.
    • ERB::Escape.html_escape is added as an alias to ERB::Util.html_escape, which has not been monkey-patched by Rails.
  • ERB::Util.url_encode is made faster using CGI.escapeURIComponent.

ERB::Util.html_escapeが速くなりました。次のコードでRuby 3.1.3では1.3秒、Ruby 3.2.0では0.67秒くらいでした。

s = "foo"
10000000.times { ERB::Util.html_escape(s) }

ただし、このために少しだけ非互換があります。

  • エスケープ対象の文字が1文字もなかったら、引数の文字列をdupせずにそのまま返す(返り値を破壊的に使っている人は注意)
  • 引数が文字列だったときに #to_s を呼ぶのをやめた

あとついでにERB::Util.url_encodeも速くなったそうです。前述のCGI.escapeURIComponentを使ったとのこと。

(mame)

erbコマンドの-Sオプションが消された

  • -S option is removed from erb command.

erbコマンドからセーフレベルを指定するオプションが消えました。セーフレベル自体はRuby 3.0で消されています。コマンドライン引数はdeprecatedとして残されていましたが、今回ついに消えたようです。

(mame)

FileUtils.ln_srが導入された

  • Add FileUtils.ln_sr method and relative: option to FileUtils.ln_s. [Feature #18925]

ターゲットディレクトリからの相対パスでシンボリックリンクを生成するメソッドが増えました。

FileUtils.ln_s("src/file", "dest/symlink")とすると、dest/symlinkdest/src/fileへのシンボリックリンクになりますが、これは意図しない結果かもしれません。destと同じディレクトリにあるsrcの中のfileへシンボリックリンクをはりたかったら、FileUtils.ln_s("../src/file", "dest/symlink")とする必要がありました。つまり、第一引数には第二引数からの相対パスを示す必要がありました。これからはFileUtils.ln_sr("src/file", "dest/symlink")と書くだけですみます。

(mame)

IRBに多数のコマンドが導入された

  • Added many of new commands and improvements. see [ruby-3-2-irb]

たくさんの新しいコマンドが導入されています。詳細は下記の記事をご参照ください。

余談:irb は、これまでは Ruby の式しか実行できませんでした。コマンドに見えるものも、すべて Ruby のメソッドとして実装されてきていました。今回のリリースでは、この制限をとっぱらって、(pryなどのように)独自の構文を導入しました。これによって、pry の$ といった機能が使えるようになっています。

個人的には、Ruby の式に限定していた理由をよく知らないのですが(単純性のため?)、この限定を解除したデメリットがないか、ちょっと興味があります。

(ko1)

Net::Protocolのバッファ実装が改善された

net-httpなどで使われているバッファの実装が効率的になったようです。以前はString#slice!を使って文字列を切り出していたため、長いバッファに対して先頭から一行ずつ読むような場合に遅くなっていたとのこと。改善後は切り出さずに先頭からのオフセットを管理するようにしたらしい。

(mame)

Pathname#lutimeが導入された

Pathnameに最終アクセス時刻と更新時刻を変更するメソッドが追加されました。utimeとの違いは、シンボリックリンクに対してlutimeを行ったとき、参照先のファイルではなくシンボリックリンク自身の時刻を変更するところです。

File.lutimeは以前からあったので、それのPathname版です。

(mame)

Socketに定数が追加された

  • Socket
    • Added the following constants for supported platforms.
      • SO_INCOMING_CPU
      • SO_INCOMING_NAPI_ID
      • SO_RTABLE
      • SO_SETFIB
      • SO_USER_COOKIE
      • TCP_KEEPALIVE
      • TCP_CONNECTION_INFO

Socket にいろんな定数が追加されました。詳しくないから詳細わかりません、ゴメン。

(ko1)

syntax_suggest gemが導入された

  • The feature of syntax_suggest formerly dead_end is integrated in Ruby. [Feature #18159]

endが足りなかったり多すぎたりした場合に、親切なエラーメッセージを出すようにするsyntax_suggest gem(旧名 dead_end gem)が導入されました。

class C
  def foo
  end

  def bar
    # endが欠けている
    if cond?
  end

  def baz
  end
end
$ ruby test.rb
test.rb: --> /tmp/wrong_syntax.rb
Unmatched keyword, missing `end' ?
   1  class C
   5    def bar
>  7      if cond?
   8    end
  12  end
/tmp/wrong_syntax.rb:12: syntax error, unexpected end-of-input (SyntaxError)

ifに対するendが足りていないことを、行単位の要約によって示してくれています。

dead_end gemはRubyKaigi Takeout 2021で作者のRichard Schneemanが発表していたものです。無事入ってよかったですね。

(mame)

WindowsでUNIXSocketをサポートした(かも)

  • Add support for UNIXSocket on Windows. Emulate anonymous sockets. Add support for File.socket? and File::Stat#socket? where possible. [Feature #19135]

最近の Windows では UNIX Domain Socket (ぽいもの)をサポートしているそうです。へー、知らなかった。 というわけで、対応していれば対応する、というパッチが入ったそうです。すごい。

(ko1)

ライブラリのアップデート

  • The following default gems are updated.

    • RubyGems 3.4.1
    • abbrev 0.1.1
    • benchmark 0.2.1
    • bigdecimal 3.1.3
    • bundler 2.4.1
    • cgi 0.3.6
    • csv 3.2.6
    • date 3.3.3
    • delegate 0.3.0
    • did_you_mean 1.6.3
    • digest 3.1.1
    • drb 2.1.1
    • english 0.7.2
    • erb 4.0.2
    • error_highlight 0.5.1
    • etc 1.4.2
    • fcntl 1.0.2
    • fiddle 1.1.1
    • fileutils 1.7.0
    • forwardable 1.3.3
    • getoptlong 0.2.0
    • io-console 0.6.0
    • io-nonblock 0.2.0
    • io-wait 0.3.0
    • ipaddr 1.2.5
    • irb 1.6.2
    • json 2.6.3
    • logger 1.5.3
    • mutex_m 0.1.2
    • net-http 0.3.2
    • net-protocol 0.2.1
    • nkf 0.1.2
    • open-uri 0.3.0
    • open3 0.1.2
    • openssl 3.1.0
    • optparse 0.3.1
    • ostruct 0.5.5
    • pathname 0.2.1
    • pp 0.4.0
    • pstore 0.1.2
    • psych 5.0.1
    • racc 1.6.2
    • rdoc 6.5.0
    • readline-ext 0.1.5
    • reline 0.3.2
    • resolv 0.2.2
    • resolv-replace 0.1.1
    • securerandom 0.2.2
    • set 1.0.3
    • stringio 3.0.4
    • strscan 3.0.5
    • syntax_suggest 1.0.2
    • syslog 0.1.1
    • tempfile 0.1.3
    • time 0.2.1
    • timeout 0.3.1
    • tmpdir 0.1.3
    • tsort 0.1.1
    • un 0.2.1
    • uri 0.12.0
    • weakref 0.1.2
    • win32ole 1.8.9
    • yaml 0.2.1
    • zlib 3.0.0
  • The following bundled gems are updated.

    • minitest 5.16.3
    • power_assert 2.0.3
    • test-unit 3.5.7
    • net-ftp 0.2.0
    • net-imap 0.3.4
    • net-pop 0.1.2
    • net-smtp 0.3.3
    • rbs 2.8.2
    • typeprof 0.21.3
    • debug 1.7.1

いろいろアップデートされました。詳細は各 gem のリリースノートを見てください。

自分が見ているので debug.gem のアップデートだけ紹介すると、新しいデバッグコマンド(untilなど)が足されたり、VSCode/Chrome連携がよくなったり、たくさんのバグフィックスが入っていたりします。

(ko1)

■サポートプラットフォーム

RubyがWebAssembly/WASIに対応しました。かんたんに言えば、Rubyがブラウザ上で動いて楽しいです。

しかしWebAssembly/WASIはブラウザにとどまらず、いろいろな活用が期待されているプラットフォームで、いわゆるエッジコンピューティングやプラグイン機構などでこれから使われていく、かもしれません。今回のRubyの対応は、そういう将来に備えた布石です。

RubyKaigi 2022の初日のキーノートで作者の @kateinoigakukun が発表していたので、記憶に残っている人も多いのではないかと思います。実装など詳しくはそちらの発表資料を御覧ください。

クックパッドはRubyKaigiのブース企画として、このWebAssembly/WASI対応を使ったコードパズルを作りました。興味あれば、次の2つの記事をご覧ください。

techlife.cookpad.com

techlife.cookpad.com

(mame)

■非互換

String#to_cのわずかな変更

  • String#to_c currently treat a sequence of underscores as an end of Complex string. [Bug #19087]

"1__0".to_cと書いたとき、Ruby 3.1では(10+0i)が帰っていましたが、Ruby 3.2からは(1+0i)が返ります。

(mame)

ENV.cloneが例外を投げる

  • Now ENV.clone raises TypeError as well as ENV.dup [Bug #17767]

ENV.cloneが例外を投げるようになりました。

(mame)

削除された定数

The following deprecated constants are removed.

消すぞ消すぞ、と言われていた古い定数が削除されました。

(ko1)

削除されたメソッド

The following deprecated methods are removed.

上記のメソッドが削除されました。ほとんどのメソッドは呼ぶと警告が出ていたものになります。

Dir.exists?は代わりにDir.exist?を使うようにしてください。Rubyのメソッド名は原則として原型を使うので、三単現のsがあるものは削除となりました。

Method#public?などはRuby 3.1で導入されたメソッドだったのですが、キャンセルして削除されました。これには次のような事情があります。

Ruby 3.2の仕様の議論ではメソッドオブジェクトのコーナーケースの整理が一大トピックとなっていて、非常に長時間をかけて細かい議論が行われていました。その中で、「メソッドの可視性というのは、メソッド自体が持つものではなく、それが所属するクラスが持つものである」という哲学的な整理にいたり、「Methodクラスがpublicかどうかを返すのはおかしい」ということで、Ruby 3.1で導入されたばかりだったということもあり、あっさり削除されることなりました。なお、この一大トピックの全体の結論としては、「Ruby 3.0のときの挙動がほぼ正しかった」となったので、内容は割愛します。

(mame)

拡張ライブラリのソースコード非互換性

  • Extension libraries provide PRNG, subclasses of Random, need updates. See [PRNG update] below for more information. [Bug #19100]

疑似乱数生成器を自分で実装するためのインターフェースに修正が入りました。興味ある人は資料を見てください。

(ko1)

エラーメッセージのエスケープをやめた

  • Ruby no longer escapes control characters and backslashes in an error message. [Feature #18367]

Rubyはエラーメッセージを表示する時に制御文字やバックスラッシュをエスケープしていたのですが、これをやめました。error_highlightでバックスラッシュのある行を表示するとき、下線位置がずれることがあったのですが、この変更でずれなくなります。

(mame)

includeが絡んだクラス・モジュールを定義するときの定数解決

  • When defining a class/module directly under the Object class by class/module statement, if there is already a class/module defined by Module#includewith the same name, the statement was handled as "open class" in Ruby 3.1 or before. Since Ruby 3.2, a new class is defined instead. [Feature #18832]
module X
  module Y
  end
end

include X

p Y #=> X::Y

class Y
end
#=>
# Ruby 3.1: class X::Y とオープンクラスをやろうとして、モジュールなのでエラー
# Ruby 3.2: class Y を実行し、新しく ::Y を定義

このプログラムでは、トップレベルで X を include しているので、トップレベルで Y が参照できます(X::Y)。

このとき、class/module文でクラスやモジュールを定義しようとすると、X::Yに対してオープンしようとしていましたが、Ruby 3.2 からはまったく新しい Y が定義されるようになりました。

(ko1)

■stdlibの非互換

libyamlやlibffiのソースコードのバンドルをやめた

  • Psych no longer bundles libyaml sources. And also Fiddle no longer bundles libffi sources. Users need to install the libyaml/libffi library themselves via the package manager like apt, yum, brew, etc. [Feature #18571]

Rubyのパッケージはlibyamlやlibffiのソースコードをバンドルしていて、これらがインストールされていない環境ではバンドル版をビルドして利用していたのですが、このバンドルをやめました。今後は各自でシステムにインストールしてください。

たしかRuby 2.0でPsychが導入されるとき、libyamlが簡単にインストールできない環境のためにバンドルを始めたのですが、libyamlに脆弱性が発見されたときにRubyもリリースを検討しないといけないなど、意外とコストが高かったので、バンドルをやめることは長らく懸案となっていました。

Windowsでlibffiを入れるためにパッチが必要と思われていたのがバンドル中止に踏み切れなかった理由だったのですが、libffi本家側の改善でそのパッチが不要になっており、vcpkgなどでも簡単にインストールできることがわかったので、今回思い切ってバンドル中止となりました。

(mame)

CGI::Cookieがname/path/domainの文字列を検査するようになった

  • Check cookie name/path/domain characters in CGI::Cookie. [CVE-2021-33621]

CGI::Cookieを生成する際、属性値の中身をRFC 6265にもとづいて検査するようになりました。これは脆弱性の可能性を防ぐためです。

バグ発見者の徳丸浩さんが記事を書いてくれましたので、あわせてご参照ください。

blog.tokumaru.org

(mame)

URI.parseが少しだけ変更

  • URI.parse return empty string in host instead of nil. [sec-156615]

次のような文字列をURLとしてパースした時に、ホスト名がnilだったところ、空文字列が返るようになりました。

URI("http:////example.com/").host #=> ""

(mame)

■C APIの更新

更新されたC API

The following APIs are updated.

  • PRNG update rb_random_interface_t in ruby/random.h updated and versioned. Extension libraries which use this interface and built for older versions need to rebuild with adding init_int32 function.

疑似乱数生成器を提供するためのAPIが変わったそうです。

(ko1)

追加されたC API

  • VALUE rb_hash_new_capa(long capa) was added to created hashes with the desired capacity.

事前に指定された分のメモリを確保しておく rb_hash_new_capa() が導入されました。

  • rb_internal_thread_add_event_hook and rb_internal_thread_add_event_hook were added to instrument threads scheduling. The following events are available:
    • RUBY_INTERNAL_THREAD_EVENT_STARTED
    • RUBY_INTERNAL_THREAD_EVENT_READY
    • RUBY_INTERNAL_THREAD_EVENT_RESUMED
    • RUBY_INTERNAL_THREAD_EVENT_SUSPENDED
    • RUBY_INTERNAL_THREAD_EVENT_EXITED

スレッドが停止したり実行可能になったり実際に実行再開したりするときに内部的なイベントを発行して、それをトラップすることでスレッドの挙動を計測することができるようになりました。

実際にこれを使ったツールを作っていただいています(ivoanjo/gvl-tracing: Get a timeline view of Global VM Lock usage in your Ruby app )。

  • rb_debug_inspector_current_depth and rb_debug_inspector_frame_depth are added for debuggers.

デバッガのために、スタックフレームの深さに関する情報を返す API が追加されました。

(ko1)

削除されたC API

The following deprecated APIs are removed.

  • rb_cData variable.
  • "taintedness" and "trustedness" functions. [Feature #16131]

rb_cDataというグローバル変数が削除されました。これはDataクラスの項で少し触れた、過去のData定数に入っていたクラスの残骸です。

また、Ruby 3.0で廃止されたセーフレベルに関するC API群も削除されました。

(mame)

■実装の改善

  • Fixed several race conditions in Kernel#autoload. [Bug #18782]

autoload の thread-safety の状況が改善されました。

(ko1)

  • Cache invalidation for expressions referencing constants is now more fine-grained. RubyVM.stat(:global_constant_state) was removed because it was closely tied to the previous caching scheme where setting any constant invalidates all caches in the system. New keys, :constant_cache_invalidations and :constant_cache_misses, were introduced to help with use cases for :global_constant_state. [Feature #18589]

定数のキャッシュが、いままで大雑把に管理していた(たとえば、ある定数を定義したら、すべての定数キャッシュを全部クリア)のを、より細かく制御することになりました。 それに関連して、RubyVM.stat の返す値が変わりました。

(ko1)

  • Variable Width Allocation is now enabled by default. [Feature #18239].

Ruby 3.1 では off にしてリリースされていた Variable Width Allocation (VWA) がオンになりました。これは、オブジェクトのサイズを可変長にすることで、追加のメモリアロケーション(つまり malloc)を減らして性能向上を目指すものです。 実際どれくらいの高速化になっているのかは知らないんだよなぁ。

shopify.engineering

(ko1)

  • Added a new instance variable caching mechanism, called object shapes, which improves inline cache hits for most objects and allows us to generate very efficient JIT code. Objects whose instance variables are defined in a consistent order will see the most performance benefits. [Feature #18776]

オブジェクトシェイプという仕組みを使ってインスタンス変数アクセスの高速化が図られました。 とくに、JITにおいて、効くんじゃないかと思われるテクニックです。

詳細は、RubyKaigi 2022 の Jemma Issroff さんによる Implementing Object Shapes in CRuby - RubyKaigi 2022、それから RubyKaigi 2021 の Chris Seaton さん(先日早逝の報を聞いて悲しい)によるキーノートThe Future Shape of Ruby Objects by Chris Seaton - RubyKaigi Takeout 2021 をごらんください。

(ko1)

  • Speed up marking instruction sequences by using a bitmap to find "markable" objects. This change results in faster major collections. [Feature #18875]

命令列(バイトコード)からマークするべきオブジェクト一覧を取り出すのに、ちょっと面倒なことをしていたのを、ビットマップを用いることで、ピタッとわかるようにしたという話です。このパッチにより、Major GCの性能が向上したそうです。

性能向上については、Shopify からの貢献が大きいです。リーダーの Ufuk の Twitter のスレッドが参考になりそうなので、(DeepLが)日本語に訳しておきました。

Ufuk's explanation of Ruby 3.2 achievements by Shopify

(ko1)

■JIT

YJIT

  • YJIT is no longer experimental
    • Has been tested on production workloads for over a year and proven to be quite stable.
  • YJIT now supports both x86-64 and arm64/aarch64 CPUs on Linux, MacOS, BSD and other UNIX platforms.
    • This release brings support for Mac M1/M2, AWS Graviton and Raspberry Pi 4.
  • Building YJIT now requires Rust 1.58.0+. [Feature #18481]
    • In order to ensure that CRuby is built with YJIT, please install rustc >= 1.58.0 before running ./configure
    • Please reach out to the YJIT team should you run into any issues.
  • Physical memory for JIT code is lazily allocated. Unlike Ruby 3.1, the RSS of a Ruby process is minimized because virtual memory pages allocated by --yjit-exec-mem-size will not be mapped to physical memory pages until actually utilized by JIT code.
  • Introduce Code GC that frees all code pages when the memory consumption by JIT code reaches --yjit-exec-mem-size.
    • RubyVM::YJIT.runtime_stats returns Code GC metrics in addition to existing inline_code_size and outlined_code_size keys: code_gc_count, live_page_count, freed_page_count, and freed_code_size.
  • Most of the statistics produced by RubyVM::YJIT.runtime_stats are now available in release builds.
    • Simply run ruby with --yjit-stats to compute and dump stats (incurs some run-time overhead).
  • YJIT is now optimized to take advantage of object shapes. [Feature #18776]
  • Take advantage of finer-grained constant invalidation to invalidate less code when defining new constants. [Feature #18589]
  • The default --yjit-exec-mem-size is changed to 64 (MiB).
  • The default --yjit-call-threshold is changed to 30.

YJIT にも色々変更入ってますね。

  • Rust で書き直し
  • x86-64 に加え、ARM にも対応した
  • コードGC に対応した

が大きいトピックでしょうか。細かいことは追えていないので説明できません。ゴメン。

(ko1)

MJIT

  • The MJIT compiler is re-implemented in Ruby as ruby_vm/mjit/compiler.
  • MJIT compiler is executed under a forked Ruby process instead of doing it in a native thread called MJIT worker. [Feature #18968]
    • As a result, Microsoft Visual Studio (MSWIN) is no longer supported.
  • MinGW is no longer supported. [Feature #18824]
  • Rename --mjit-min-calls to --mjit-call-threshold.
  • Change default --mjit-max-cache back from 10000 to 100.

MJITも書き変わっていて、いろいろRubyで書くという野心的な話をしています。 詳しくは国分さんの RubyKaigi 2022 での発表 Towards Ruby 4 JIT - RubyKaigi 2022 をご覧になると良いかと思います。

(ko1)

おわりに

Ruby 3.2の新機能や改善を紹介してきました。ここで紹介した以外でも、バグの修正や細かな改善が行われています。お手元の Ruby アプリケーションでご確認いただければと思います。

Ruby 3.2では、いろいろアグレッシブに改善が行われています。Shopify ではリリース前から大規模に利用して、10%の性能改善を得ているそうです。それだけ安定しているってことですね。ぜひ、お手元にセットアップして新しい Ruby を楽しんでください。

では、よいお年をお迎えください。

(ko1/mame)

*1:正確にはlazyにロードするように少しだけ工夫されています。