Dynamic Type

モバイル基盤部のヴァンサン(@vincentisambart)です。

使っているアプリのフォントサイズを変えたいと思ったことありますか?目があまり良くないから文字を大きくしたい。逆にもっと多くの情報を一目で見られるために文字を少し小さくしたい。

フォントサイズをアプリ内で調整できるアプリもありますが、iOSではシステム全体のフォントサイズを調整できる「Dynamic Type」と呼ばれる機能があります。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettingsにGeneral→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。

標準より少し小さくできるとはいえ、「アクセシビリティ」設定に入っているのもあって文字を大きくする方がメインのユースケースの気がします。

Dynamic TypeはiOS 7から使えるようになりましたが、関連している様々な便利機能がその後のiOSバージョンに追加されました。

一応Dynamic Typeを使うにはAuto Layoutは必須ではないのですが、手動レイアウトと一緒に使うのはおすすめできません。

システム設定変更

設定を変える前に、簡単に戻せるようにまずは標準設定を覚えておきましょう。標準設定は以下のスクリーンショットのように「さらに大きな文字」が無効で、下部のスライダーがど真ん中です。

Dynamic Type標準設定

スライダーを動かすとフォントサイズがどれくらい変わるのかすぐ見られます。一番小さい設定(extra smallまたはXS)にすると、以下のようになります。

f:id:vincentisambart:20190104132028p:plain:w320

「さらに大きな文字」無効のまま一番大きい設定(extra extra extra largeまたはXXXL)にすると以下のようになります。

f:id:vincentisambart:20190104132100p:plain:w320

アクセシビリティサイズはXXXLより大きく、「さらに大きな文字」を有効にすると選択できるようになる5つの設定です。その一番大きい設定(accessibility extra extra extra large、またはAX5)にすると以下のようになります。

f:id:vincentisambart:20190104131951p:plain:w320

因みにDynamic Type対応しているアプリでも、アクセシビリティサイズをXXXLと同じ扱いにしているアプリもあります。文字がとても大きいのでレイアウトが崩れやすいからでしょう。

設定の値

ユーザーに選択されたDynamic Type設定はUIContentSizeCategoryという型で、UIApplication.shared.preferredContentSizeCategoryまたは(iOS 8以上では)traitCollection.preferredContentSizeCategoryで取得できます。現時点(iOS 12)で利用できるすべての値は以下のとおりです。

  • unspecified (iOS 10以上)
  • extraSmall (XS, xSmall)
  • small (S)
  • medium (M)
  • large (L) – 標準設定
  • extraLarge (XL, xLarge)
  • extraExtraLarge (XXL, xxLarge)
  • extraExtraExtraLarge (XXXL, xxxLarge)
  • accessibilityMedium (AccessibilityM, AX1)
  • accessibilityLarge (AccessibilityL, AX2)
  • accessibilityExtraLarge (AccessibilityXL, AX3)
  • accessibilityExtraExtraLarge (AccessibilityXXL, AX4)
  • accessibilityExtraExtraExtraLarge (AccessibilityXXXL, AX5)

設定画面で「さらに大きな文字」が有効になっているときだけに選択できるアクセシビリティサイズは名前がaccessibilityから始まります。iOS 11以上では、アクセシビリティサイズかどうか確認するためにUIContentSizeCategoryisAccessibilityCategoryというメソッドがあります。また、同じくiOS 11以上でUIContentSizeCategory<, <=, >=, >で比較できるようになるのでtraitCollection.preferredContentSizeCategory > .extraExtraExtraLargeも使えます。

content size categoryの値によって、レイアウトを変えたりできます。例えばDynamic Typeの設定画面で大きいアクセシビリティサイズを選ぶ時、「さらに大きな文字」スイッチがラベルの右からその下に移ります。Dynamic Type設定によってUIStackViewの設定が変わるのでしょう。

f:id:vincentisambart:20190104131951p:plain:w320

自分のアプリ内、Dynamic Typeの設定に合わせてするレイアウト変更は作成時以外、どういうタイミングでやれば良いのでしょうか。

変更に反応

アプリがバックグラウンドにある間に、ユーザーがDynamic Typeの設定を変えるとアプリが終了されるわけではありません。ユーザーがアプリに戻ったら、アプリが変更を知らされる仕組みが2つあります:

  • UIContentSizeCategory.didChangeNotificationというnotificationがアプリに送られます。
  • iOS 8以上ではtraitCollectionDidChangeメソッドが呼ばれます。UITraitEnvironmentプロトコルのメソッドですが、UIViewUIViewControllerが実装しているので、自分のビューやビューコントローラに以下のコードでDynamic Typeの設定変更に反応できます。
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
        // 設定が変わったときにやりたいこと
    }
}

注意: ビューコントローラーが表示されていない場合(例えば別のタブが表示されている間)、traitCollectionDidChangeは呼ばれませんが、UIContentSizeCategory.didChangeNotificationは届きます。

Text Style (書式)

設定によってレイアウトを変えても、Dynamic Typeの「Type」は文字、フォントのことなので、肝心のフォントサイズはどうすれば良いのでしょうか。アプリに使われているすべてのスタイルをcontent size categoryごとに用意するのは手間が掛かりすぎます。

そのため、iOS 7からシステムにいくつかのText Styleが用意されています。ワードプロセッサーでいうと「書式」と同じ概念です(ただし自分の好みに合わせて変えることができません)。Human Interface Guidelines(HIG)のTypographyページにスタイルのリストと各content size categoryでのフォントサイズが見られます。アクセシビリティサイズも同じページの少し下にAX1, AX2, AX3, AX4, AX5として紹介されてあります。因みにフォント自体はシステムフォント(San Francisco)です。

そのスタイルはコード上ではUIFont.TextStyleです。iOS 7ではスタイルが少なかった(body, caption1, caption2, footnote, headline, subheadline)のですが、iOS 9(callout, title1, title2, title3)やiOS 11(largeTitle)で少し増えました。

スタイルによって変わるのはフォントサイズだけではなく、headlineスタイルの場合、フォントがボルドになります。

large設定では、iPadで各スタイルを表示すると以下のようになります。

f:id:vincentisambart:20190104132124p:plain:w500

extraExtraExtraLarge設定では、以下のようになります。

f:id:vincentisambart:20190104132150p:plain:w500

accessibilityExtraExtraExtraLarge設定では、以下のようになります。この最大サイズでは各スタイルの差があまりないですね。

f:id:vincentisambart:20190104132207p:plain:w500

コードでは、UIFont.preferredFont(forTextStyle:)がメインのAPIです。UIFont.preferredFont(forTextStyle: .body)が今のDynamic Type設定に合っているbodyスタイルのフォントを返してくれます。

Interface Builderでは、スタイルをlabelやtext viewのFont設定にText Stylesの中で選べます。

f:id:vincentisambart:20190104131624p:plain:w390

自動フォント調整

上記のセクションの使い方だけでは、アプリがバックグラウンドにある間にユーザーがDynamic Typeの設定を変えてアプリに戻ったら、新しい設定が自動的に反映されるわけではありません。自分でcontent size categoryの変更に反応して、labelやtext viewのフォントを指定し直す必要があります。テキストスタイルをコードで指定していればまだ良いのですが、Interface Builderで指定した場合、コードで再指定することになってしまいます。

その問題を避けるため、iOS 10以上ではUIContentSizeCategoryAdjustingプロトコルに準拠しているクラス(UILabel, UITextField, UITextView)にadjustsFontForContentSizeCategoryというプロパティがあります。Interface Builder上の「Automatically Adjusts Font」と同じです。

f:id:vincentisambart:20190104131820p:plain:w325

以下その機能を「自動フォント調整」と呼ぶことにします。

自動フォント調整が上記のプロパティで有効になっているビューは、Dynamic Type設定変更後、バックグラウンドにあったアプリに戻るとフォントサイズが自動的に更新されます。ただし、フォントサイズが更新されるのは上記に紹介したtext style、またはあとで紹介するfont metrics、が使われている場合のみに有効です。

有効にされたら自動フォント調整が効く 有効にされても自動フォント調整が効かない
label.font = UIFont.preferredFont(forTextStyle: .body) label.font = UIFont.systemFont(ofSize: 12)
Interface BuilderでフォントにText Stylesのどれかを選んだ Interface Builderでカスタムやシステムフォントを選んだ

実際Interface Builderでテキストスタイルでないフォントを指定して、「Automatically Adjusts Font」にチェックを入れた場合、ビルド時に警告が出ます。コードでそのビューのフォントに自動フォント調整が効くフォントを指定したら、ちゃんと動きますがビルド時の警告が残ります。なので、コードでフォントを指定する場合、自動フォント調整を有効にするのもコードでやる(view.adjustsFontForContentSizeCategory = true)のが良いでしょう。

カスタム

テキストスタイルAPIはシステムフォントしか使えませんし、システムフォントを使うとしても、スタイルの種類が多くありません。

テキストスタイルAPIが用意されたスタイルを使う前提で作られたとしても、コードで自分の定義したサイズやフォントを使う方法がなかったわけではありません。少し考えるだけで以下の2つの方法がすぐ思いつくのでしょう。

  • switch preferredContentSizeCategory { ... } – すべてのcontent size categoryの分のフォントとサイズを用意します。かなり手間が掛かりますし、スタイルを変えたいときも大変です。
  • ((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round() (17.0がbodyスタイルの標準のポイントサイズです)で自分のフォントのサイズをcontent size categoryに合わせて調整します。

実は、現時点でクックパッドiOSアプリがDynamic Typeに対応している唯一の画面、レシピ詳細では長い間後者を使っています。

もちろんこの2つの方法では、自動フォント調整が使えません。Dynamic Typeの設定に変更があったときにフォントサイズを調整したければ、「変更に反応」セクションで紹介した方法で変更に反応して各ビューのフォントを再指定します。

Deployment TargetがiOS 10以前のアプリはカスタムなスタイルを使いたかったら、上記の方法しかありませんが、iOS 11以上だと、もう少し楽なAPIが提供されています。

UIFontMetrics

iOS 11から誕生したUIFontMetricsクラスを使うと、好きなフォントやフォントサイズを選んで、そのカスタムなスタイルのサイズをDynamic Typeの設定に合わせられるだけではなく、自動フォント調整も使えます。

使い方は以下のようです。

let customFont = UIFont(name: "AmericanTypewriter", size: 17)! // 好きなサイズのシステムフォント`UIFont.systemFont(ofSize: myCustomSize)`でも大丈夫です
let scaledFont = UIFontMetrics.default.scaledFont(for: customFont)
label.font = scaledFont
label.adjustsImageSizeForAccessibilityContentSizeCategory = true

もしフォントサイズが大きすぎるとレイアウトが崩れてしまう場合、最大フォントサイズを指定できます。

let scaledFont = UIFontMetrics.default.scaledFont(
    for: UIFont.systemFont(ofSize: customFont),
    maximumPointSize: 50.0
)

UIFontMetricsにはフォントだけではなく、画像のサイズを文字の大きさに合わせるためのメソッドもあります。

let scaledSize = UIFontMetrics.default.scaledValue(for: sizeToScale)

シンプルですね。懸念点が1つあります:Interface Builderでその機能が使えません。ビューをInterface Builderで配置しても良いのですが、フォントはコードで指定する必要があります。

メトリックスはテキストスタイル次第

上記のコードでUIFontMetricsのインスタンスはUIFontMetrics.defaultを使っていましたが、UIFontMetricsのドキュメントを見ると、init(forTextStyle textStyle: UIFont.TextStyle)もあります。実はUIFontMetrics.defaultUIFontMetrics(forTextStyle: .body)に初期化されたものです。UIFontMetrics(forTextStyle: .title1)UIFontMetrics(forTextStyle: .caption2)なども使えます。

どうしてテキストスタイルを指定できるのかと言いますと、Dynamic Type設定によってフォントのサイズがどう変わるのかはテキストスタイルごとに少し違うためです。以下の図を見ればもう少し分かるかと思います。図に使われた数値をどう計算したのかあとで説明します。

f:id:vincentisambart:20190104131516p:plain

カスタムスタイルごとにサイズの推移をどのシステムスタイルに合わせたいのか決めるのが大変だと思うので、分からない時はUIFontMetrics.defaultを使って良い気がします。

UIFontMetricsの計算

上記は説明せずに図を出しましたが、実際サイズがどう計算されるのでしょうか?UIFontMetrics.scaledFont(for:)の返しているフォントの自動フォント調整対応は自分で実装できないかもしれませんが、計算くらいはできるのではないでしょうか。

実はUIFontMetrics.scaledFont(for:)に使われる計算が上記の「カスタム」セクションで紹介したすぐ思いつきそうな((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round()に近いです。ただし、割合はpoint sizeからではなく、leadingから計算されています。

数字は既に紹介しましたHIGのTypographyページのDynamic Type Sizesにある「Leading」にあります。図に使った数字は対象のcontent size categoryのleadingを標準(large)のleadingで割っただけです。

因みにフォントに関して「Leading」は「リーディング」ではなく、「レディング」と読みます。

TypographyページにあるLeadingというのは日本語で「行送り」のことです。1行のベースラインから次の行のベースラインまでの距離です。

UIFontにあるleadingは違っていて、こっちは「行間」のことです。TypographyページにあるLeadingがUIFontでいうとleading + lineHeightです。

いろんな説明よりコードの方が分かりやすいかもしれません。下記のコードに使われているUIFont.preferredFont(forTextStyle:compatibleWith:)がiOS 10以上でしか使えませんが、もっと古いiOSバージョンではleadingの値のテーブルをコードに埋め込めば簡単に実装できるしょう。

因みに下記のコードに使われているUITraitCollectionが表示に影響ありそうな環境の状態(サイズクラス、画面スケール)や設定(Dynamic Type)をまとめるものです。UIFontMetricsの場合、サイズがどう変わるのか見たいとき、Dynamic Type設定をいちいち変えるのが大変なので、UITraitCollectionを渡すのが良いのですが、ビューのフォントを指定する場合は基本的にUITraitCollectionを渡さず、ユーザーの設定に合うものを求めます。

import UIKit

private extension UIFont {
    // https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/#dynamic-type-sizes にある"leading"に相当します
    // 実はUIFontには`_bodyLeading`というメソッドがありますが、プライベートなので再実装が必要です
    var bodyLeading: CGFloat {
        return lineHeight + leading
    }
}

// `UIFontMetrics`の機能の一部を再実装するクラス
// 注意:自動フォント調整対応のフォントを作るには本物の`UIFontMetrics`を使うしかありません
class SimpleFontMetrics {
    private var textStyle: UIFont.TextStyle
    init(forTextStyle textStyle: UIFont.TextStyle) {
        self.textStyle = textStyle
    }

    static let `default`: SimpleFontMetrics = .init(forTextStyle: .body)

    private static let defaultContentSizeCategoryTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)

    private func unroundedScaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection?) -> CGFloat {
        let defaultFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: SimpleFontMetrics.defaultContentSizeCategoryTraitCollection)
        let currentFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traitCollection)

        return (value * currentFont.bodyLeading) / defaultFont.bodyLeading
    }

    // `UIFontMetrics.scaledValue(for:compatibleWith:)`と同じです
    func scaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
        // 表示に使われている画面のスケール(ポイントごとのピクセル数)
        let displayScale: CGFloat
        // `traitCollection.displayScale`が0だったら未定だということなので`traitCollection`が指定されていないと同じ扱いです
        if let traitCollection = traitCollection, traitCollection.displayScale != 0 {
            displayScale = traitCollection.displayScale
        } else {
            displayScale = UIScreen.main.scale
        }
        // ピクセル単位で四捨五入
        return (unroundedScaledValue(for: value, compatibleWith: traitCollection) * displayScale).rounded() / displayScale
    }

    // `UIFontMetrics.scaledFont(for:maximumPointSize:compatibleWith:).pointSize`に相当します
    func scaledFontPointSize(for pointSize: CGFloat, maximumPointSize: CGFloat = 0, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
        assert(pointSize >= 0, "You cannot create a font of negative size.")
        // フォントサイズの四捨五入はピクセル単位ではなくポイント単位です
        let scaledPointSize = unroundedScaledValue(for: pointSize, compatibleWith: traitCollection).rounded()
        if maximumPointSize == 0 {
            return scaledPointSize
        } else {
            return min(scaledPointSize, maximumPointSize)
        }
    }
}

UIFontMetricsの少し不自然なところ

上記にUIFontMetricsがサイズ計算に使っている比率がフォントのサイズ(pointSize)のではなく、行送り(leading + lineHeight)のだと書きましたが、フォントサイズの計算にその比率が使われるから少し不自然な結果になることがあります。

let largeTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)
let axxxlTraitCollection = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)
let baseFont = UIFont.preferredFont(forTextStyle: .body, compatibleWith: largeTraitCollection)
UIFont.preferredFont(forTextStyle: .body, compatibleWith: axxxlTraitCollection).pointSize // => 53
UIFontMetrics.default.scaledFont(for: baseFont, compatibleWith: axxxlTraitCollection).pointSize // => 48

標準文字サイズ(large)のbodyスタイルのフォントをUIFontMetricsで一番大きいDynamic Type設定に拡大されたフォントと、一番大きいDynamic Type設定でのbodyのフォントはサイズが少し異なります。そのため、UIFont.preferredFontUIFontMetrics.scaledFontを同じ画面で混在させない方が良いかもしれません。

因みに、今回はその方が分かりやすかったのですが、普段はUIFont.preferredFontの戻り値をUIFontMetrics.scaledFontを渡さないでおきましょう。一応動くのですが、UIFontMetrics.scaledFontの戻り値をまたUIFontMetrics.scaledFontに渡すとObjective-Cの例外が発生します。

画像

テキストの横に画像があると、その画像をある程度テキストのサイズに合わせたいことがあるかもしれません。手動でUIFontMetrics.scaledValue(for:)を使ってもできますが、iOS 11からUIButtonUIImageViewが準拠しているUIAccessibilityContentSizeCategoryImageAdjustingプロトコルにadjustsImageSizeForAccessibilityContentSizeCategoryというプロパティが登場しました。Interface Builderにある「Adjust Image Size」と同じです。

f:id:vincentisambart:20190104132302p:plain:w325

そのプロパティがtrueの場合、Dynamic Type設定によってビューの画像が拡大されますが、アクセシビリティサイズが選ばれている場合のみです。他の設定ではサイズが変わりません。

サイズ調整で画像が大きくなると荒くなってしまう可能性があります。もとの画像がPDFでしたら、Asset Catalogの設定で「Preserve Vector Data」にチェックを入れたら綺麗に拡大されます。

f:id:vincentisambart:20190104132325p:plain:w325

その他

  • 広い画面でもコンテンツが広がりすぎないためにある読みやすい幅機能はDynamic Typeと一緒に使われるように設計されているので、ぜひ紹介記事をご覧ください。
  • 既存のアプリは全画面を一気に対応するのは難しいでしょう。1つの画面で対応しているビューとしていないビューが混在すると不自然な表示になりますので、画面単位で対応した方が良い気がします。
  • テーブルのセルの高さはできるだけシステムが自動計算するようにしましょう:estimatedRowHeightの指定を忘れず(指定されないと自動計算が動かないので)、高さ(rowHeight)をUITableView.automaticDimensionにします。rowHeightestimatedRowHeightUITableViewのプロパティで指定しても、UITableViewDelegateのメソッドで返しても、どっちでも大丈夫です。
  • Auto Layoutを使ってビューを配置するとき、ベースラインアンカー(firstBaselineAnchorまたはlastBaselineAnchor)に対する縦制約はconstraint(equalToSystemSpacingBelow:multiplier:)(またはlessThanバージョンgreaterThanバージョン)を使うと、制約の高さがフォントのサイズに合わせて変わります。Interface Builder上では「Constant」の値に「Use Standard Value」を選ぶのと同じです。

まとめ

Dynamic Type設定でユーザーが自分の視力や好みに合わせてフォントのサイズを選べますが、アプリ側での対応が必要です。

iOS 7でその機能が導入されてから、対応がやりやすくなる機能が少しずつ増えて、iOS 11以上ではだいぶ楽になったのではないでしょうか。

もっと多くのユーザーの使いやすさのために対応しているアプリを増やしておきましょう。

クックパッド基幹システムのmicroservices化戦略 〜お台場プロジェクト1年半の軌跡〜

インフラストラクチャー部の青木峰郎です。 最近はDWH運用の傍ら、所属とまったく関係のないサービス開発のためのデザインスプリントをしつつ、 Java 10でgRPCサーバーを書きつつ、 リアクティブプログラミングを使った非同期オーケストレーション層を勢いだけで導入したりしています。

ですが今日はそれとはあまり関係なく、クックパッドの中核サービスであるレシピサービスの アーキテクチャ改善プロジェクト、「お台場プロジェクト」の戦略について話します。

これまで、お台場プロジェクトで行った施策について対外的に発表したことはあっても、 全体戦略について話したことはありませんでした。 その一番の理由は、正直に言って、プロジェクトオーナーであるわたしにもプロジェクト全体の姿が見えていなかったからです。 しかし現在プロジェクト開始から1年半が経過してようやく全貌が見えてきたので、すべてをお話ししようと思います。

クックパッドの「本体」システム

クックパッドは現在では大小様々なサービスをリリースしています。 しかしその中でも最初期から存在し、 現在でもあらゆる意味で中核にあるサービスがいわゆる「クックパッド」、 社内では「本体」や「cookpad_all」さらに略して「all」などと呼ばれているレシピサービスです。

このレシピサービスは世界最大のモノリシックなRuby on Railsサービスであり、 いま手元で適当に数えただけでRubyのコードが27万行(テストを除く)、 テストが51万行、HTMLテンプレートが14万行あります。 このコード量でウェブサービスのcookpad、APIサーバーのpantry、 バッチのkuroko、管理画面のpapaの4アプリケーションを主に実装しています。

お台場プロジェクトとは

そして、この巨大な「本体」システムのアーキテクチャを根本から刷新し、 改善するプロジェクトが「お台場プロジェクト」です。 わたしがこのお台場プロジェクトを開始したのは去年(2017年)のバレンタインデー、2月14日のことでした。 そのときやったことは「とりあえず改善したいことをリストアップする場所」としてGitHubにレポジトリを作っただけでしたが、 その後にお台場プロジェクトは技術部の注力課題に昇格してメンバーも大幅に増員され、1年半が経過しました。

約30万行というコードサイズは世間一般で言えば超巨大とは言えないでしょうが、 少なくとも容易なプロジェクトではないことは確かです。 2017年2月の時点では、このプロジェクトを達成できると言う人も、俺がやると言う人も社内にはいませんでした。 むしろ、本体には関わりたくない、できるだけ触りたくない、 最低限の機能追加以外の余計なことはしたくないという雰囲気が充満していたように思います。

お台場プロジェクトが目指すもの

お台場プロジェクトの目的は、「本体」にも大規模な機能追加・変更ができるようにすること、 さらにその大規模な変更をできるだけ少ない時間でできるようにすることです。 逆に言うと、プロジェクト開始時点では、思い切った機能追加はできなかったということになります。

大規模な変更を行えない技術的な理由としては以下のような点が挙げられます。

  • コードを変更すると意図しないところが壊れる。例えばウェブサービスをいじるとガラケーの認証が壊れる。
  • ライブラリが古かったとしても依存が多すぎて気軽に更新できない。
  • 実行環境が非常に複雑かつ特殊で、迂闊にデータベースを追加したりできない。
  • 普通のツールが動かない。例えばコードカバレージが取れない、並列テストが動かない。
  • ObjectクラスやStringクラスのような非常に基本的なクラスが改変されており、普通の動きをしない。

また、組織的・プロセス的な理由もあります。

  • あるコードのオーナーが誰かわからない。例えばuserリソースのAPIを変更したくても誰にも相談できない。
  • GitHubのissue・pull requestが多すぎてとても全部は見ていられない。通知も何も機能しない。
  • 「本体」をいじる開発者が多すぎて、改善系のpull requestを作ると頻繁にコンフリクトする。

「本体」で大きな変更を行おうと思うと、これらの問題がすべて同時に襲いかかってきます。 例えばI/Oの激しいシステムを追加するためにDynamoDBを使いたいと思ったとしても、 AWS-SDKのバージョンが古いのでまずはSDKのバージョンを更新するのに1ヶ月かかる、 テストが遅いので検証にも時間がかかり、そのあいだに別のpullreqがマージされてコンフリクト、 実装を進めていくと既存のクラスに変更が必要そうなことがわかってきたがオーナーが誰かはわからない、 がんばって実装してみたが触ってもいないバッチのCIが通らない、 ようやく理由がわかって直してデプロイしたらなぜかガラケーサイトが落ちた……という具合です。

これはさすがに誇張だろうと思われるかもしれませんが、残念ながらすべて事実を元にした話です。 こんな開発を続けていれば、大きな機能を追加しようという気がなくなって当然でしょう。

タスクの優先順位の決定

こんな状態ですから、なんらかの改善をしなければいけないことはわかります。 しかし、はたして何から手を付けたらよいものでしょうか。 まずは問題をリストアップしてはみたのですが、あまりにも問題が多すぎ粒度もバラバラ、しかもどれも大変そうで腰が引けます。 タスクを絞るところが肝であることはどう考えても明らかでした。 そこで、ビジネス上の価値と政治的判断を加味しつつ、「やること」「やらないこと」を次のようにエイヤと決めました。

やること:

  • APIサーバー(pantry)のアーキテクチャ改善
  • 不要なサービスの廃止
  • デッドコードの自動検出と削除
  • ストレージ数の削減
  • 特殊な実行環境・開発環境の廃止

やらないこと:

  • ウェブサービス(cookpad)のビュー改善
  • Railsのメジャーバージョンアップ
  • 細かい実装レベルの改善

最初に決めなかったこと:

  • 全面的にmicroservice化するかどうか

まず、ユーザー数と有料会員数の分布、将来向かう方向を考えると、 ウェブやガラケーよりもスマホアプリのAPIサーバー(pantry)が重要であることは明らかなので、 そのアーキテクチャ改善につながらないタスクは原則捨てると決めました。 そうすると例えばウェブのビューの実装改善などは自動的に「やらないこと」になります。

microservices化はギリギリまで先送り

一方で、先に述べたように、本体のコードはオーナーがよくわかりませんし、 最初からmicroservices化するとは決断できなかったので、 いきなりそこに大々的に踏み込んでいくことはしませんでした。 例外は検索システム1つだけです。

最終的にコードをなにかしら分割することは避けられないとは思っていましたが、 サービスとして分割する以外にも、たとえばRails engineとして分割するなど代替案はいくつかあります。 とにかく戦略的に、大々的に分割するぞーと宣言して分割はしたけど失敗でしたという事態だけは絶対に避けたかったので、 どうしても分割を避けられない時が来るまで決断を遅らせることにしたわけです。

これほど慎重になった背景にはもちろん理由があります。 実は、クックパッドでは2015年から2016年くらいにすでに一度microservices化を試して失敗した経験があるのです。

当時はmicroservices化の機運が社内で盛り上がっており、 今後「本体」に機能を追加するときは必ずmicroservicesに分けようということになりました。 しかしこのときの経過が非常にまずくて、業務プロセスを複数のアプリに分断してしまって作業が増えたり、 管理アプリだけ別サービスに分割したことによってひたすら新規APIを作るはめになったりと、 microservices化全般に悪い印象だけが残ってしまったのです。 後者の管理アプリに至っては最終的に「本体」の管理アプリに統合され、microservicesではなくなってしまいました。

この時の経験があったため、最初からシステム分割を行うのはできるだけ避けることにしました。 最初に社内のエンジニアにお台場プロジェクトの話をしたときも、 microservices化に反対する意見が出た場合に備えて想定問答集を作ったほどです。 もっとも実際に話してみると、当時とは社内外の状況が変わったこともあって、反発はほとんどありませんでした。

f:id:mineroaoki:20181228000116j:plain
お台場プロジェクト発表時の社内の様子

ちなみにそのときの写真がこれで、わたしが用意した大変わかりやすいスライドで成田CTOが喋っているの図です。 お台場プロジェクトが終わってからプレスリリースを打つときに使おうと思っていた秘蔵写真ですが、 いい機会なので公開します。

最初は確実に成果を出せるコード削除を実施

いきなりサービス分割をしない代わりにまずやったタスクが、古いサービスの廃止と、古いAPIサーバーの廃止です。 ほぼ誰も使っていない機能の廃止はトレードオフがほぼないので、どう考えてもやったほうが得であり、 しかも誰も反対しないからです。まず最初にわかりやすい成果を作るために最適のタスクでした。 社内に効果を示すためにも、チームが自信を得るためにも、 比較的簡単にできて結果のわかりやすいタスクから始めるのは妥当でしょう。 時期的にもちょうど大きな機能が分社されて消せるコードがたくさんありました。

また、今後コードを消していくうえで、 本番で使われていないコード(デッドコード)がツールで自動判定できたら非常に楽ができるので、 その方法を少し調べて実施することにしました。 その結果できあがったのがRubyのLazy Loadingを使って実行されないコードを探す手法です。 現在ではこのシステムによって自動的に不要コードを検知できるようになっています。 さらに、このデッドコード検出機能はブラッシュアップされてRuby 2.6にも取り込まれました

「Railsのメジャーバージョンアップはしない」

Railsのメジャーバージョンアップもお台場プロジェクトではやらないと最初に決めました。 これは単純に「Railsをバージョンアップしたところでアーキテクチャの根本改善にはつながらない」 という理由もありますが、その他に一種のシンボル的な意味もあります。

この点を説明するには、まず少しだけクックパッドの組織構造の話をする必要があります。 クックパッドでは永らく、 「本体」のソフトウェアアーキテクチャ(主にRails)については技術部の開発基盤というグループが責任を持ち、 それより上の機能については各事業部が分割して持つという責任分担が行われてきました。 結果として2016年までの数年は、開発基盤グループに新しい人が入るととりあえず 「本体」のRailsバージョンアップをするというのが洗礼の儀式のように行われていたのです。 しかしこのタスクが技術的にも政治的にも非常につらく、 結果として若者が「本体」に対するヘイトをためていく構造になっていました。

そういった歴史の結果として、クックパッドにおいては「Railsのバージョンアップ」というタスクが 「これまでの開発基盤の役割」とほぼ同じ意味を持っています。 そんな状況で、開発基盤で新しいプロジェクトを始めます、 それじゃあまずRailsバージョンアップをやりますと言ったら、いままでと何も変わりません。 中身は同じで名前だけ変えたんですねということになりかねないからです。

お台場プロジェクトはシステムアーキテクチャの改善プロジェクトであると同時に、 組織アーキテクチャの改善プロジェクトでもあります。 巨大な1つのシステムをメンテするのはもはや手に余るので分割統治しよう、 というのがお台場プロジェクトの目的ですから、組織もいまのままであるわけがありません。 具体的には、開発基盤グループが不要にならなければいけません。

つまりある意味で開発基盤を解散させるプロジェクトでもあるお台場プロジェクトで、 これまでの開発基盤と同じことをやるというのはどう考えても筋が通らないわけです。 ですから、お台場プロジェクトでは絶対にRailsのバージョンアップはしないと決めました。

アプリケーション構造の整理

コード削除とほぼ同時にやったのが、アプリケーション構造の整理でした。

これについてはそもそも問題自体の説明が必要でしょう。 「本体」システムはウェブサービスやAPIサーバー、非同期ジョブ、 バッチなど複数のRailsアプリケーションからなるのですが、 そのうちAPIサーバーと非同期ジョブはウェブサービスの「モード」として実装されていました。 ウェブサービスを起動したとき、特定の名前のファイルが存在したら APIサーバーのエントリポイントが生えてAPIサーバーとして動くという、凄まじい実装がされていたのです。

プロジェクト開始から2017年いっぱいくらいにわたって、このものすごい実装を排除しました。 APIサーバー(pantry)は独立したアプリケーションとし、非同期ジョブ(background-worker)はバッチ(kuroko)に置き換えるなどして廃止。 同時に古いAPIサーバー(api, api2)を消したこともあり、アプリケーション構造はだいぶシンプルにすることができました。

f:id:mineroaoki:20181228000249p:plain
アプリケーション構造の整理

全面的なmicroservices化を決断

当初はmicroservices化を前面に出すかどうかはまだ決めかねていたのですが、 現在はすでに全面的にmicroservices化することを決めています。 その決め手となったのは、最初に小さな機能を分割してみて、その効果が明白に感じられたことでした。

具体的には、スマホアプリのA/Bテストなどに使っているuser_featuresという機能を分割した時点です。 この機能はもともと技術部がオーナーでしたし、専用Redis 1つだけにアクセスする構造になっていたため、 政治的にも技術的にも都合がよかったのです。 そこでこの機能を分割してみたところ、分割したあとのほうが明らかにつくりがわかりやすく、 改善しやすくなりましたし、実際に改善が進みました。 誰が実装すべきかも明確で、それでいて他の部署の人間も逆に手を出しやすくなったと感じています。 やはりコード共有というのは「誰も持っていない」のではだめで、オーナーありきのほうがうまくいくなと感じます。

わたし個人としても最近、本体のAPIサーバー(pantry)にとある機能を追加するためにmicroserviceを1つ実装したのですが、 DynamoDBを中心としたアーキテクチャ設計からstaging環境・production環境の構築に最小の実装までを、わたし1人で1週間弱で終えることができました。 これはお台場プロジェクトをやっていなければとてもできなかったことです。 もし2016年時点のpantryでこれをやれと言われたら何ヶ月必要になっていたか予想できません。

すべてがHakoになる

もともとクックパッドではmicroservicesのためのインフラは整備されつつありました。 例えば次のようなアプリケーションやミドルウェアが稼働しています。

さらに直近ではRubyのgRPCライブラリの置き換えなども行われています。 すでに新規のサービスはすべてこれらのインフラに乗っていますが、「本体」をどうするかだけはずっと宙に浮いた状態だったわけです。

2018年になって、「本体」もこの共通インフラに乗せると決めた時点で、話は非常にシンプルになりました。 現在では徐々にではありますが「本体」がHako化(コンテナ化)されつつあり、来年内の完了を見込んでいます。 社内のすべてのシステムがmicroservices構成になり、コンテナで動く状態が視界に入ったと言えるでしょう。

microservicesへの分割戦略

さて、microservices化を決断した場合、次に問題になってくるのが、「どこでサービスを切るか」です。 正直、これはシステム設計の話なので、パッケージをどう分けるか、クラスをどう分けるかと同じようなものであり、 決定的な基準がありません。

しかし、特に「本体」システムに限って言えば分割する場合の成功パターンがわかってきました。

そのパターンとは、データベースがすでに分かれている機能については、データベースを中心としてそれに紐付く部分を分割することです。 「本体」はRailsアプリケーションにしては珍しいことに、非常に大量のデータベースにアクセスしています。 database.ymlを見る限りだと、実に20以上のデータベースが接続されているようです。 これらのデータベースを、データベースとそれに紐付くコードをまとめて分割すると、 意味的にもデータフロー的にも無理がなく分割できることがわかりました。 これは冷静に考えてみれば当然と言えば当然なのですが、 このことに気付いてからは、「データベースが切れているならシステムも切れる」というわかりやすい基準ができました。

具体的には、Solrを核として分離したレシピ検索のシステム(voyager)、 専用Redisを中心として分離したA/Bテスト機能(user_features)、 専用Auroraをベースとして分離した「料理きろく」機能などがこのパターンでうまく分割できた例です。 今後もこのパターンに沿って、専用MySQLを持つブックマーク機能(MYフォルダ)や、 投稿者向けの統計機能(キッチンレポート)を分割していく予定です。 逆に、メインの一番巨大なMySQLに紐付いた機能群は最後に分割することになるでしょう。

microservicesに分割するという話になった当初は分割の基準がよくわかっていなかったので、 例えば「レシピのようによく使うリソースを最初に切り出すのがよいのではないか」 「事業部3つに合わせていきなり3つに分割しよう」などなど、様々な考えが錯綜していました。 その根底には、ひとまず切り出せそうな部分はいくらか見えているのだが、 それを順番に地道に分割していくくらいではいつまでたっても本丸のコア部分の分割まで至らないのではないか……という焦りがあったと思います。

しかし実際にやってみて最もうまくいった分割方法はやはり「データフローが明確に切れるところで切る」ことです。 慌てず騒がずデータフローを分析して、端から削り切るのが結局は最短の道だと思います。

大きな静的データの共有問題

microservicesへ分割していくうえで他に困ることの1つが「大きな静的データの共有」の問題です。 例えばクックパッドだとテキストの分析に使われている専用辞書がこれにあたります。 この辞書は検索サービスからも検索バッチからもレシピサービスからも、 その他ありとあらゆるところから頻繁にアクセスされており、 これを果たして単純に単体サービスとして分割してしまっていいものか難しいところでした。

『マイクロサービスアーキテクチャ』 などによると、 このようなタイプのデータは原則としてはサービスにするよりデータとして配布してしまったほうがよいようです。 クックパッドでは偶然にも、この辞書をGDBM化してメモリに乗せる仕組みが少し前に入っていました。 そこでこの仕組みを利用して、GDBMファイルを各アプリケーションに配布することでひとまず解決をみました。

その後、GDBMファイルのバージョン問題にぶちあたって少し方式を変更したりもしましたが、 いまのところうまく動いています。

APIオーケストレーション層の導入: Orcha

microservices化に関する直近の試みはAPIオーケストレーション層「Orcha(オルカ)」の導入です。 オチャではありません。 これはわたしが勢いだけで入れてみたものなのですが、思ったより便利で驚いています。

OrchaはJavaで実装されており、 Spring ReactorとSpring Fluxをベースとしたリアクティブプログラミングを活用しています。 下図のようにリバースプロクシ(rproxy)とAPIサーバー(pantry)の間に入り、 pantryを含めたmicroservices群のAPIを統合して、スマホアプリ用のAPIを提供します。

f:id:mineroaoki:20181228000351p:plain
APIオーケストレーション層Orcha

オーケストレーション層を入れようと思った最初の動機は、 スマホアプリから複数のAPIを呼ぶレイテンシーを削減することでした。 しかし実際に入れてみていま感じている最大の利点は、 「本体」にさわらずに既存のAPIを拡張できるという点です。 アーキテクチャを改善していくうえで非常に便利な道具が一つ加わったと感じています。

例えばレシピを取得するAPIに新しい情報を差し込みたい場合であれば、 本体のAPIサーバーが返したJSONを加工して情報を追加することで達成できます。 ようするに、「高機能なJSON用sed」のような動きをしているわけですね。

今後の展開

お台場プロジェクトは来年2019年から、最終の第4期に突入します。 これから1年強は、本体を片っ端から分離しHako化するという正面対決になるでしょう。 最初はデータベースが分かれている機能から分離を始め、徐々にメインDBを切り崩します。

また、せっかくOrchaという新しい自由な遊び場ができたので、 それを活用してGraphQLの導入を試してみようと思っています。 ちょうどスマホアプリの側でもiOS, Androidともにアーキテクチャが刷新されつつあるので、 新しい仕組みを導入するにはいいタイミングです。

まとめ

本稿では、クックパッドの中核たるレシピサービスのアーキテクチャを改善する 「お台場プロジェクト」について、その戦略のすべてをお話ししました。 特に意識して行ってきたことは次のような点です。

  • 意図的にこれまでとの違いを出すタスク選択
  • 最初は成果の出しやすいコード削除から
  • microservices化は小さく試して全面展開
  • オーケストレーション層で展開の自由度を高める

現在のところプロジェクトの最大の問題は、とにかく人が足りないということです。 エンジニアは全分野で足りていないのですが、サーバー側は特に足りません。 Railsならまかせろ!な方にも、Railsブッ殺す!な方にも、 やりごたえのある楽しいタスクがありますので、 ぜひ以下のフォームから応募をお願いいたします。

https://info.cookpad.com/careers

Special Thanks 〜またの名を戦績リスト〜

わたしはお台場プロジェクトについてはあくまで戦略レベルしか関与しておらず、 実装レベルの判断は特に聞かれない限り担当者にすべて任せています。 その点で、お台場プロジェクトは個々のエンジニアの力量によるところが大きいプロジェクトであり、 ここまで来られたのはすべてメンバーのおかげと言ってよいでしょう。 この記事の最後に、各自が撃破したタスクを記して、終わりにしたいと思います。 なお、プロジェクト開始から2018年内までに完了したものだけを、だいたい時間順に列挙しています。

※書き忘れてるやつあったらすまん……

Ruby 2.6 の改善を自慢したい

技術部で Ruby インタプリタの開発をしている笹田です。娘のために、今年はじめて大きなクリスマスツリー(1.8 m)を買いました。

本稿では、私が Ruby 2.6 で取り組んだ中から、次の新しい機能と性能改善について紹介します。どちらのトピックも、普通に Ruby を使っているだけなら気にならない、玄人向きの記事になっていると思います。興味がある人にお読み頂ければ幸いです(居ればいいのですが)。

  • TracePoint の拡張
    • 新しいイベント script_compiled の導入
    • フックを有効にする場所を制限する機能の導入
    • デバッガの実装が、10~100倍くらい速くなる、かもしれない
    • ブレイクポイントの実装を例に解説
  • Transient Heap の導入
    • 短寿命メモリオブジェクトの高速化
    • 世代別コピーGCのアイディアを利用
    • Rails とかには効かないかも...。

そういえば、両方とも "Tra" で始まってますね。寅年はまだ先ですが。

本稿は、Cookpad techlife で三日連続で掲載する Ruby 2.6 紹介記事の最後になります。

タイトルは 昨年の記事 Ruby 2.5 の改善を自慢したい の二番煎じです。来年もやるのかな...。

目次

TracePoint の拡張

まず、簡単なほう、TracePoint の拡張の話から始めます。

TracePoint 基礎

TracePoint は、プログラム中のイベントで起動する Proc を登録するための仕組みです。イベントには、毎行ごとに実行する line イベントや、メソッドの呼び出しと、そのリターンごとに実行する call、return などがあります。

例えば、すべてのメソッド呼び出し、およびそのリターンをログに出力したい、というプログラムは次のように簡単に書くことができます。

     1  def foo; bar; end
     2  def bar; nil; end
     3
     4  TracePoint.trace(:call, :return){|tp| p tp}
     5
     6  foo

このプログラムを実行すると、次のような出力が得られます。

#<TracePoint:call `foo'@t.rb:1>
#<TracePoint:call `bar'@t.rb:2>
#<TracePoint:return `bar'@t.rb:2>
#<TracePoint:return `foo'@t.rb:1>

foo が呼ばれ、bar が呼ばれ、bar から戻り、foo から戻る、ということが見て取れます。が、ちょっと出力が読みづらいですね。呼び出しレベルごとにインデントを付けるようにしてみましょう。

indent = 0
TracePoint.trace(:call, :return){|tp|
  indent -= 2 if tp.event == :return
  print ' ' * indent; p tp
  indent += 2 if tp.event == :call
}

実行結果:

#<TracePoint:call `foo'@t.rb:1>
  #<TracePoint:call `bar'@t.rb:2>
  #<TracePoint:return `bar'@t.rb:2>
#<TracePoint:return `foo'@t.rb:1>

ちょっと読みやすくなりました。もうちょっと整形してみましょう。

indent = 0
TracePoint.trace(:call){|tp|
  puts "#{' ' * indent}-> #{tp.method_id}@#{tp.path}:#{tp.lineno}"
  indent += 2
}
TracePoint.trace(:return){|tp|
  indent -= 2
  puts "#{' ' * indent}<- #{tp.method_id}@#{tp.path}:#{tp.lineno}"
}

TracePoint の設定を、call と return の2つに分けてみました。実行結果です。

-> foo@t.rb:1
  -> bar@t.rb:2
  <- bar@t.rb:2
<- foo@t.rb:1

どうでしょう。それっぽくなったでしょうか。

実は、TracePoint.trace(events){...}TracePoint.new(events){...}.enable(新しい TracePoint を作成し、それを有効にする、という意味)の略なので、今後は TracePoint#enable を使うようにしてみます。

TracePoint の基礎はだいたいこんなものですが、詳細はるりまのドキュメント "class TracePoint (Ruby 2.5.0)" をご覧下さい。

TracePoint の問題

便利に、悪巧みに、色々使えそうな TracePoint ですが、性能の問題があります。

性能が気になる一番顕著な例はブレイクポイントの実装です。あるファイルのある行で実行を止める、ということを考えてみましょう。breakpoint('t.rb', 10) とすると、t.rb:10 で irb が起動するなんてどうでしょうか。

TracePoint を使うと、こんな感じで簡単に作ることができます。

def breakpoint file, line
  TracePoint.new(:line){|tp|
    if tp.path == file && tp.lineno == line
      tp.binding.irb
    end
  }.enable
end

各行で実行するフック(line イベントによるフック)中で、if tp.path == file && tp.lineno == line という if 文で、指定された場所かどうかを判断し、もしそうであれば irb を実行します。Ruby 2.5 から binding.irb という便利なメソッドが追加されたので、もし指定された行を実行したら binding.irb を呼ぶようにしてみました。

では、早速使ってみましょう。

def foo a, b
  p a, b # line 11
end

breakpoint __FILE__, 11

foo 10, 20
foo 30, 40

foo メソッドの1行目の p が11行目だったと思ってください。

実行結果:

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     end
     7:   }.enable
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint __FILE__, 11
    15:
    16: foo(10, 20)

irb(main):001:0> p [a, b]
[10, 20]
=> [10, 20]
irb(main):002:0> exit
10
20

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     end
     7:   }.enable
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint __FILE__, 11
    15:
    16: foo(10, 20)

irb(main):001:0> p [a, b]
[30, 40]
=> [30, 40]
irb(main):002:0>
30
40

見事にブレイクポイントを持つデバッガっぽいものが出来ました! たった数行(7行)でデバッガっぽいものが作れる Ruby って凄いですね! ちなみに、byebug などどの Ruby 用デバッガも、基本的にこんな感じで実装されています。

ただ、性能に問題があります。この簡単なブレイクポイントの設置場所「とは関係ない」コードの実行時間を測ってみます。

def fib n
  case n
  when 0, 1; n
  else fib(n-1) + fib(n-2)
  end
end

require 'benchmark'
Benchmark.bm{|x|
  x.report{
    fib(30)
  }
  x.report{
    breakpoint __FILE__, 11  # enable breakpoint here
    fib(30)
  }
}
       user     system      total        real
   0.095480   0.000000   0.095480 (  0.095490)
   0.904503   0.000000   0.904503 (  0.904552)

上が breakpoint なし、下が breakpoint ありです。実に 9.5 倍くらい遅くなっているのがわかります。実行している全ての行で上記(無駄な)チェックを行っているので、しょうがないといえばしょうがないかもしれません。

ちなみに byebug を使って、byebug のブレイクポイント機能を有効にした状態で fib(30) を実行してみましょう。

$ byebug /home/ko1/src/ruby/trunk/test.rb
...
(byebug) b 11
Created breakpoint 1 at /home/ko1/src/ruby/trunk/test.rb:11
(byebug)
       user     system      total        real
   7.406062   0.000000   7.406062 (  7.406415)

さらに遅いです。何もしない場合に比べ、80倍程度遅いようです。きっと、他にもいろいろな処理をしているんでしょうね。

今回はブレイクポイントを例にしましたが、最初に紹介したメソッド呼び出し履歴のロギングでも、例えば gem は除く、といった要望は当然出てくると思います。

TracePoint#enable(target:, target_line:) 拡張

さて、TracePoint では無駄なフックを呼んでしまうことで性能上問題が生じる、ということをご紹介しました。10倍とか100倍遅くなってしまいました。なんとかしたい。

そこで、Ruby 2.6 では、TracePoint を有効にする場所を制限するという拡張を TracePoint#enable メソッドに行うことにしました。

enable(target: code, target_line: line) と、target:target_line: キーワードを追加しました。これらのキーワードを利用することで、指定された code および行番号に、イベント発火を絞ることができます。

なお、target_line:line イベントにだけ有効です。そのため、line イベントと一緒に、他のイベント(call など)が指定されていると、target_line: を指定していても、call などは有効になります。わかりやすいように、target_line: 指定は line イベントのみと一緒に使うことをオススメします。

さて、この拡張を用いて、実際にちゃんと動くブレイクポイントを作ることができるか試してみましょう。まず、breakpoint メソッドの仕様を変更します。

def breakpoint method, line
  TracePoint.new(:line){|tp|
    tp.binding.irb
  }.enable(target: method, targe_line: line)
end

breakpoint メソッドの第一引数がファイルではなく、method とあるのに注意してください。ここでは、メソッドオブジェクトを渡します。

使ってみましょう。

def foo a, b
  p a, b # line 11
end

breakpoint method(:foo), 11
foo(10, 20)
foo(30, 40)

実行結果です。

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     tp.binding.irb
     7:   }.enable(target: method, target_line: line)
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint method(:foo), 11
    15: foo(10, 20)
    16: foo(30, 40)

irb(main):001:0> p [a, b]
[10, 20]
=> [10, 20]
irb(main):002:0> exit
10
20

From: /home/ko1/src/ruby/trunk/test.rb @ line 11 :

     6:     tp.binding.irb
     7:   }.enable(target: method, target_line: line)
     8: end
     9:
    10: def foo a, b
 => 11:   p a, b # line 11
    12: end
    13:
    14: breakpoint method(:foo), 11
    15: foo(10, 20)
    16: foo(30, 40)

irb(main):001:0> p [a, b]
[30, 40]
=> [30, 40]
irb(main):002:0> exit
30
40

先ほどと同様、ちゃんと動いていることが確認できました。

では、性能はどうなっているでしょうか。

       user     system      total        real
   0.096092   0.000000   0.096092 (  0.096180)
   0.095210   0.000000   0.095210 (  0.095306)

上が breakpoint なし、下が breakpoint ありです。ほぼ、実行時間に変わりが無いことがわかるかと思います。やった!

TracePoint#enable(target: code)code って何?

いやいや、ちょっと待って。いちいちメソッドオブジェクトを渡すのって大変じゃないですか。breakpoint(path, line) を実装したかったらどうすればいいんでしょうか。

そもそも、target: code で指定する code とは、なんでしょうか?

ここには、Ruby で記述したプログラムにおける MethodUnboundMethodProc および RubyVM::InstructionSequence (長いので、以降 ISeq)が指定できます。何やら難しそう...。そもそも、最後の ISeq って何。

Ruby で記述されたプログラムはバイトコードにコンパイルされます。そのバイトコードのことを Ruby (MRI) の文脈では命令列、Instruction Sequence、つまり ISeq と言ってます。

Ruby で記述したメソッドから取り出した Method オブジェクトなどは、たどっていくと ISeq が取り出せます。実は、code で指定しているのは、ISeq なのです。

ちなみに、RubyVM::InstructionSequence.of(code) で、MethodProc オブジェクトなどから ISeq が取り出せます。C で実装されたメソッドの場合、nil が返ります。TracePoint#enable(target: code) では、内部でまさに ISeq.of(code) を呼んで、ISeq を取り出しています。もし、ISeq が取り出せない場合はエラーになります。

p RubyVM::InstructionSequence.of(method(:p)) #=> nil
TracePoint.new{}.enable(target: method(:p))
#=> <internal:prelude>:137:in `__enable': specified target is not supported (ArgumentError)

今回の拡張は、ある ISeq(および、その ISeq から辿ることができるすべての ISeq)に TracePoint を限定する、というのが正しい理解です。

例えば、alias を使うとメソッドを増やすことができますが、指し示す ISeq は同じものです。

def foo; end
alias bar foo

ISeq = RubyVM::InstructionSequence
p ISeq.of(method(:foo)) == ISeq.of(method(:bar)) #=> true

そのため、enable(code: method(:bar)) とすると、foobar で有効な TracePoint になります。

同じように、ある Module を include したクラス C1, C2 も、同じメソッドを共有することになります。

module M; def foo; end; end
class C1; include M; end
class C2; include M; end

ISeq = RubyVM::InstructionSequence
p ISeq.of(C1.new.method(:foo)) == ISeq.of(C2.new.method(:foo)) #=> true

モジュールの例は、もしかしたらはまりやすいかもしれませんね。「そこからたどれるソースコードにイベントで発火する hook を登録する」という機能であることを抑えておくことが大事です。

メソッドを指定するブレイクポイント

メソッドを指定するブレイクポイントを実装する場合を考えます。あるメソッドが起動したとき、というタイミングにブレイクポイントを指定する場合ですね。

前節で実装した breakpoint が、そのまま使えそうです。

def method_breakpoint method, line = nil
  TracePoint.new(:call){|tp|
    tp.binding.irb
  }.enable(target: method)
end

「メソッドが呼ばれるとき」なので、call イベントのみを利用しています。target_line: 指定がないことに気を付けてください。line イベントがないのに target_line: 指定があると、target_line is specified, but line event is not specified (ArgumentError) と怒られます。

簡単ですね。

C で実装されたメソッドの場合は、この方法ではうまくいきません。その点がちょっと残念ですね(これまで通り、c_call イベントをフックするか、Ruby でラッパーメソッドを書く、という方法があります。今だと後者が速いかな)。

場所を指定するブレイクポイント

さて、ファイル(path)と行(line)で場所を指定したいブレイクポイントを実装する場合、対象となる ISeq をどうやって集めてくるか、という問題になります。

実は、今の MRI には、そのための方法がないので、今回は外部の gem を使います。iseq_collector です。この gem が提供する ObjectSpace.each_iseq は、インタプリタ中に存在するすべての ISeq を辿るという API です。

ISeq には ISeq#path というメソッドがあり、そのメソッドが定義されたファイル名を知ることができるので、これで path と比較することで、必要な ISeq を絞ることができます。

次に、line の絞り方です。これには2通りのやり方があります。

まず、ISeq#trace_points を用いて指定の行があるかどうかを調べる方法です。

     1  def foo
     2    p 1
     3  end
     4
     5  ISeq = RubyVM::InstructionSequence      # 長いので
     6  pp ISeq.of(method(:foo)).trace_points
     #=> [[1, :call], [2, :line], [3, :return]]

結果を見るとわかりやすいと思いますが、1行目に call イベント、2行目に line イベント、3 行目に return イベント、計 3 イベントがfooメソッド(を実装する ISeq)に登録可能である、ということを示しています。

場所を指定するので、line イベントを利用します。このメソッドでは、2 行目のみ、場所指定のブレイクポイントをしかけることができる、と捉えることができます(call, return イベントを利用すれば、もうちょっと頑張れますが、ここでは触れません)。

もう一つの絞り方ですが、ちょっと乱暴に、とにかく path で絞って得られた ISeq を用いて enable(target: iseq, target_line: line) を指定してしまう、というものです。もし、対象 ISeq に target_line: で指定した行がなければ、例外を返します。

     1
     2  def foo
     3    p 1
     4  end
     5
     6  TracePoint.new(:line){}.enable(target: method(:foo), target_line: 100)
     #=> <internal:prelude>:137:in `__enable': can not enable any hooks (ArgumentError)

100行目が見つからなかったので、「フックがどこにも指定できなかった」という例外を出しています。これを利用すれば、とにかく TracePoint#enable をしまくってみると、いつかはヒットするかも、という戦略が取れます。

今回は、前者、ISeq#trace_points を利用する方法を用いて、場所指定のブレイクポイントを設定するメソッドである location_breakpoint を作ってみましょう。

     1  require 'iseq_collector'
     2  def location_breakpoint path, line
     3    ObjectSpace.each_iseq{|iseq|
     4      if iseq.path == path &&
     5         iseq.trace_points.find{|(l, ev)| l == line && ev == :line}
     6        TracePoint.new(:line){|tp|
     7          tp.binding.irb
     8        }.enable(target: iseq, target_line: line)
     9      end
    10    }
    11  end
    12
    13  def foo a, b
    14    p a, b # line 14
    15  end
    16
    17  location_breakpoint __FILE__, 14
    18  foo(10, 20)
    19  foo(30, 40)

実際に動かしてみると、きちんと動いていることがわかると思います。やりました!

さて、ご紹介した2つ、method_breakpoint および location_breakpoint ですが、1つ大きな問題があります。それは、「すでに require などでロードしているプログラムにしか適用できない」です。

デバッガの通常の利用方法として、「プログラムのある場所にブレイクポイントを指定する。そして、実行を開始する」というものがあります。一度、すべてのプログラムをロードしたどこかのタイミングでブレイクポイントを(これまで作成してきたメソッドを利用して)設定する、ということをしてもいいですが、Ruby の場合、この「すべてのプログラムをロードしたどこかのタイミング」を特定するのは困難です。dynamic reloading などをしていると、そもそもそういうタイミングがありません。

この問題を解決するのが、Ruby 2.6 から導入された新しいイベントである script_compiled です。

script_compiled によるコンパイル後の処理

Ruby 2.6 から、script_compiled イベントが新しく追加されました。

MRI は Ruby スクリプトを実行するために、

  • (1) スクリプトをパースして AST(構文木)を作成
  • (2) AST を ISeq に変換
  • (3) ISeq を実行

という手順を踏んで実行します。 script_compiled イベントは、(2) 終了時に挿入されるフックです。

この機能を使うと、例えば、どんなファイルが requireload で実行されるか、実行直前で知ることができます。

コンパイルされ、生成された ISeq は TracePoint#instruction_sequence メソッドで取得することができます。

では試しに、net/http ライブラリを require すると、何が起こるのか、観察してみましょう。

TracePoint.new(:script_compiled){|tp|
  pp ["#{tp.path}:#{tp.lineno}", tp.instruction_sequence.path]
}.enable

require 'net/http'

このプログラムでは、Ruby スクリプトを ISeq に変換したとき、「ロードしたソースコードの位置」、「ロードされたファイル名」を表示します。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:23",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/protocol.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/socket.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/timeout.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1645",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/exceptions.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1647",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/header.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1649",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/generic_request.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1650",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/request.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1651",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/requests.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1653",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/response.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1654",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/responses.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1656",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/proxy_delta.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http.rb:1658",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/net/http/backward.rb"]

このように、多くのファイルがロードされていることがわかります。

また、同じことを fileutils で試してみます。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils/version.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1695",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1695",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1721",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]
...
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1721",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb"]

なぜか、同じ場所で沢山のプログラムがロードされているようです。ちょっとソースコードを確認してみましょう。

  1669      names.each do |name|
  1670        module_eval(<<-EOS, __FILE__, __LINE__ + 1)
  1671          def #{name}(*args, **options)
  1672            super(*args, **options, verbose: true)
  1673          end
  1674        EOS
  1675      end
  1676      private(*names)

module_eavl がある部分です。eval(str)module_eval(str), instance_eval(str) なども同様です)も require などと同様に、スクリプト src を ISeq に変換して実行するので、script_compiled イベントでフックできるのです。

Ruby 2.6 からは、eval に渡した文字列を取得する TracePoint#eval_string というメソッドがあります。これを利用することで、どんな文字列を eval で実行しているかがわかります。なお、require などファイル指定でファイルをロードした場合はこのメソッドは nil を返します。

では、試してみましょう。

TracePoint.new(:script_compiled){|tp|
  pp ["#{tp.path}:#{tp.lineno}", tp.instruction_sequence.path, tp.eval_script]
}.enable

require 'fileutils'

結果はこんな感じです。

["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 nil]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils/version.rb",
 nil]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 "        def cd(*args, **options)\n" +
 "          super(*args, **options, verbose: true)\n" +
 "        end\n"]
["/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb:1670",
 "/home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb",
 "        def mkpath(*args, **options)\n" +
 "          super(*args, **options, verbose: true)\n" +
 "        end\n"]
...

module_eval によって何が実行されようとしているかがよくわかりますね。

さて、これらの機能を使うと、スクリプトがロードされた瞬間にブレイクポイントを仕込む、ということが可能になります。

では、これまでの知識を利用して、reserve_location_breakpoint path_pattern, line というメソッドを作ってみましょう。パスを指定するパラメータ名が path ではなく path_pattern なのは、パス名は Regexp でパターンマッチ出来た方が便利かなと思ったためです。

require 'iseq_collector'
def location_breakpoint path, line
  ObjectSpace.each_iseq{|iseq|
    if iseq.path == path &&
       iseq.trace_points.find{|(l, ev)| l == line && ev == :line}
      TracePoint.new(:line){|tp|
        tp.binding.irb
      }.enable(target: iseq, target_line: line)
    end
  }
end

def reserve_location_breakpoint path_pattern, line
  TracePoint.new(:script_compiled){|tp|
    compiled_script_path = tp.instruction_sequence.path
    if path_pattern =~ compiled_script_path
      location_breakpoint(compiled_script_path, line)
    end
  }.enable
end

reserve_location_breakpoint(/lib\/ruby\/2.6.0\/fileutils\.rb/, 9)

# ロードする

require 'fileutils'

実行結果です。

From: /home/ko1/ruby/install/trunk/lib/ruby/2.6.0/fileutils.rb @ line 9 :

     4:   require 'rbconfig'
     5: rescue LoadError
     6:   # for make mjit-headers
     7: end
     8:
 =>  9: require "fileutils/version"
    10:
    11: #
    12: # = fileutils.rb
    13: #
    14: # Copyright (c) 2000-2007 Minero Aoki

irb(main):001:0> exit

ちゃんと、これからロードするファイルに対してブレイクポイントを設定できました。

あと少し整備すれば、デバッガなんて簡単に作れそうですね! やってみたくありませんか?

(場所指定ブレイクポイントは、だいたいこれでいいとして(出来た!)、ではメソッド指定ってどうやりますかね。こちらは、実はなかなか難しいけど、さすがに長くなりすぎるので省略します)

TracePoint#enable(target:) の背景と実装

さて、ここまでの記事は、Ruby 2.6 で導入された新しい TracePoint の拡張を用いて、デバッガのブレイクポイントをどうやって作るのか、ということを題材に、使い方をご紹介しました。これくらいの記事なら、ちょっと気の利いた人なら書けそうです。

本稿では差別化を図るために、これをどうやって実装したか、その背景とテクニックをご紹介します。これはさすがに私しか書けないでしょう。「筆者はこのとき何を考えていたか答えなさい」ってやつですね。

この拡張を導入した背景

TracePoint#enable(taget:) なんですが、ずっと導入したかった機能でした。それこそ、TracePoint 導入した Ruby 2.0 から。しかし、なかなか API が決められなかったんですよね。いろいろ考えちゃって、あーでもない、こーでもないと悩んで、時間がかかりました。欲しいって言う人も居ないし。今回、入った原因は2つ、外圧と割り切りでした。

まず、外圧ですが、背景として、RUBY_EVENT_SPECIFIED_LINE という隠し機能が、たしか Ruby 2.1 だかそこらで入れてありました。まさに、ブレイクポイントを実装するための仕組みです。が、Ruby 2.5 で命令書き換えによる trace prefix 命令への置換へ舵を切ったタイミング(詳細は Ruby 2.5 の改善を自慢したい)で、削除したんですよね。同じことは、選択的に命令書き換えすれば出来るだろう、って。

ただ、そのインターフェースを用意していなかった。正確には、用意をしていたんだけど、使おうとするととても使いづらかった。で、RUBY_EVENT_SPECIFIED_LINE なんて隠し機能で誰も知らないだろうと思っていたら、JetBrain でデバッガを作るために使っていたそうなんですよね。で、復活させろー、というリクエストが来まして。ただ、今更戻すのは、いろいろな理由で良くない、ということで、本腰を入れて考え始めました。

割り切りは、ある TracePoint オブジェクトに対して、1 target しか指定できないようにしたことです。いろいろ考えていたときは、複数箇所をどのように対応させるか、それを指定するインターフェースはどうするべきか、というのが悩みどころだったんですが、もう単純に 1 TracePoint object ごとに 1 target、増減はしない! と決めるとすんなり API が決まりました。

この拡張の技術的要点

ISeq ごとに、有効にする hooks を持たせた、というのがキモです。target: で指定された ISeq に、必要な命令を trace_ prefix 命令に変換しておきます。

target_line: 指定があったときは、該当する行の命令のみ、trace_ prefix 命令に置き換えています。ただし、global に有効になる TracePoint と conflict すると、登録していないのに発火する hook を作ることが出来てしまうので、line で内部的にフィルタする仕組みを導入しました。

この辺はソースコードをちゃんと説明しないとわかりづらいので、雰囲気だけ、お伝えしました。

まとめ:TracePoint の拡張

TracePoint の拡張の話をちょっと説明しようと思ったら、Ruby でどうやってデバッガのブレイクポイントを実装するか、という説明になってしまいました。お楽しみ頂けたでしょうか。デバッガ作りたくなってきませんか? 私はなりました。

TracePoint は、デバッガを作る以外にも、いくつか使いようがあります。

例えば、今回導入された script_compiled イベントを用いると、実際にどこでソースコードがロードされたかがわかります。特に、eval の利用を検知することができます。大規模プロジェクトで、なぜか良くわからない挙動をする、「もしかしたらどっかで意図しない eval があるかも?」といったときに、こっそり含まれていた eval を検出する、などといった応用ができるかもしれません。

なお、幸いなことに、script_compiled イベントでは、コンパイル結果の ISeq を差し替えることはできません。平和は保たれました。

用法用量を守って、楽しくお使い頂ければと思います。

Ruby 2.6 で入れようと検討していて、時間切れで入らなかった機能が、沢山あるのですが、その中でも下記の 2 つについては、Ruby 2.7 で、可能なら入れたいと思っています。欲しい人、一緒に検討しませんか?

  • caller/callee での call/return イベント
    • 現状、call/return イベントは、caller で止まるのか callee で止まるのか、決まっていません(call/return は callee、c_call/c_return は caller)。どちらも明確に用意したいと思っています。
    • その際、渡すパラメータ一覧なんかが取れると良さそうだと思っています。
  • method_defined など、メソッド定義等などの変更をフックするイベント

長くなりましたが、TracePoint の拡張については、この辺で終わりにします。

Transient Heap (theap) の導入

もう誰もここまで読んでいない気がしますが、もう少しだけ続けます。

Ruby 2.6 では、Transient Heap (theap) という、メモリ管理のための新しい仕組みを導入しました。Ruby のメモリ管理が、また複雑になりました。

theap を大雑把に紹介すると、世代別コピーGCのテクニックを使うことで、対応済みのオブジェクトについて、短寿命なオブジェクトのために確保されたメモリを効率よく管理する仕組みを導入した、というものです。Array、Object(ユーザー定義クラス)、Struct、および 8 要素以下の Hash が theap を利用しています。

本章では、この theap について、かいつまんで紹介します。

なお、ここからは C のコードばかりになります。

現在のメモリ管理

Ruby でメモリ管理というとガーベージコレクタ(GC)を思い浮かべると思います。メモリ領域(とか、リソース)は GC によって寿命が管理されているので、やはり GC は花形です。昔の Ruby は遅い、と言われていましたが(今も、利用分野によっては言われていると思います)、GC の性能に問題があったことが、その理由の1つであったかと思います。

今(Ruby 2.2 以降)は、世代別インクリメンタル GC を実装しているので、あまり GC アルゴリズムが性能に問題を与えることは少なくなったのではないかと思います。現在の Ruby の GC の話は、何を見るのが一番いいかわからなかったんですが、とりあえず YARV Maniacs 【第 12 回】 インクリメンタル GC の導入 を参考文献としてあげておきます。

さて、GC はオブジェクトの寿命管理をします。今回は、その話とは(あんまり)関係ありません。寿命管理は GC に任せて、その際に生じるメモリ割り当て・解放の話が今回の主役です。

オブジェクトを 1 つ生成すると、GC 管理の領域から 1 つ、メモリ領域を割り当てられます。このメモリ領域のことを、ここでは RValue と呼んでおきましょう。1 word をポインタのサイズとして、RValue は 5 word の固定長のメモリ領域になります。イマドキの 64 bit CPU では、8 byte * 5 = 40 byte のメモリ領域ですね。GC 対象のすべてのオブジェクトは RValue を最低限割り当てられる、ととらえても良いと思います*1

RValue の 5 words のうち、最初の 2 words には、RBasic というヘッダが含まれています。RBasic は flags という、オブジェクトの管理に必要になる情報、および klass という、オブジェクトのクラス情報が含まれています。すべてのオブジェクトはクラスを持っているので、RValue にクラスの情報がついているわけですね。

さて、Ruby のオブジェクトには型があります。クラスとは違う概念で、この RValue をどのように利用するか、というデータ型です。例えば、文字列を格納するために使うなら T_STRING 型、配列を扱うなら T_ARRAY です。型の種類は、Ruby 2.6 では 15 種類、インターナルで利用するための 4 種類(Ruby プログラムからは見えないが、インタプリタには存在する)の計 19 種類あります。この情報は、RBasic::flags の最初の 5 bit に格納されています。

参考までに、Ruby 2.6 の include/ruby/ruby.h から、どんな型があるか、引用してみます(RUBY_ prefix は無視してもらって構いません)。

enum ruby_value_type {
    RUBY_T_NONE   = 0x00,

    RUBY_T_OBJECT = 0x01,
    RUBY_T_CLASS  = 0x02,
    RUBY_T_MODULE = 0x03,
    RUBY_T_FLOAT  = 0x04,
    RUBY_T_STRING = 0x05,
    RUBY_T_REGEXP = 0x06,
    RUBY_T_ARRAY  = 0x07,
    RUBY_T_HASH   = 0x08,
    RUBY_T_STRUCT = 0x09,
    RUBY_T_BIGNUM = 0x0a,
    RUBY_T_FILE   = 0x0b,
    RUBY_T_DATA   = 0x0c,
    RUBY_T_MATCH  = 0x0d,
    RUBY_T_COMPLEX  = 0x0e,
    RUBY_T_RATIONAL = 0x0f,

    RUBY_T_NIL    = 0x11,
    RUBY_T_TRUE   = 0x12,
    RUBY_T_FALSE  = 0x13,
    RUBY_T_SYMBOL = 0x14,
    RUBY_T_FIXNUM = 0x15,
    RUBY_T_UNDEF  = 0x16,

    RUBY_T_IMEMO  = 0x1a, /*!< @see imemo_type */
    RUBY_T_NODE   = 0x1b,
    RUBY_T_ICLASS = 0x1c,
    RUBY_T_ZOMBIE = 0x1d,

各データ型ごとに、T_STRING なら struct RStringT_ARRAY なら struct RArray のように、 5 word のメモリ領域のレイアウトを示すための構造体が定義されています。それぞれ最初に 2 word の RBasic を持っているので、残り 3 word をどのように利用するか決める、というのが、RString だったり RArray だったりの役目になります。

というところまで紹介しましたが、ちょっと不思議なことがあります。1つのStringオブジェクトが持つ文字列の長さは(メモリのある限り)いくらでも大きくすることが可能です。そのため、3 word に収まるわけがないのです。そこで、MRI はどうしているかというと、例えば RString では、1 word に malloc() で確保したメモリへのポインタ、1 word に確保したメモリのサイズ、1 word に文字列の長さを格納しています*2。この構造でしたら、扱う文字列の長さを RString の大きさに気にすることなく大きくすることが可能です。

String オブジェクトを例にご紹介しましたが、他のデータ型も、3 word で収まらない場合は同じようなテクニックを使います。

このあたりのレイアウトは、RHG が執筆された 2002 年から、ほとんど変っていません。というわけで、詳細は RHG の第2章をご参照ください:第2章 オブジェクト

さて、この malloc() したメモリですが、オブジェクトが GC によって回収されるときに free() されます。つまり、Ruby のオブジェクトがある程度の大きさのメモリを必要とする場合、生成と解放時に malloc()free() を実行することになります。もちろん、必要なメモリ量が変更される場合(例えば String オブジェクトが破壊的に伸張されるような場合)は realloc() などを用いて確保する量を変えたりします。

現在のメモリ管理の問題点

現在の問題点は、malloc()free() による確保・解放を頻繁に行っている、という点が挙げられると思います。細かい話はおいといて、だいたいこれくらいのデメリットがあります。

  • malloc/free の操作が重い(malloc ライブラリの実装によります)
  • メモリの断片化が起こる
  • マルチスレッドプログラミングをするとメモリを余計に食ってしまう(malloc ライブラリの実装、設定によります)

GC を持つフツーの言語処理系は、たいてい GC で管理する領域を Ruby のように固定長ではなく、可変長にして、malloc()free() を用いないで実装されます。そのため、これらのデメリットは MRI ならでは、と言えるかも知れません。

Transient Heap のアイディア

malloc()free() を使うとまずそう、ということがわかりました。では、どうすれば良いか。

Transient Heap (theap)は、これを解決するために導入されました。theap のアイディアを簡単に説明すると、GC と仲良くするメモリ領域です。

malloc()free() で管理する領域の他にメモリ領域を用意して、malloc() で確保していたところを theap からメモリ確保するようにします。解放するときは、何もしません。オブジェクト回収時に free() を呼んでいたところは、何もしないように変えるだけです。GC 終了後、theap をまとめて消してしまうため、それぞれの領域を free() する必要がないんです。なんか、速そうじゃないですか。

また、theap は、単にポインタをずらすだけでメモリを確保します(bump allocation と言います。専門用語っぽくて格好いいですね)。なので、メモリ確保が malloc() より速いです。

theap 良さそうですね!

うまい話には裏がつきもので、今回の裏は「生き残るオブジェクトが余計な仕事をしなければならない」です。生き残るオブジェクトが theap を使っていると、GC 終了時に全部消されてしまうと困ります。そこで、生き残るオブジェクトが theap を使っていた場合、別の領域を割り当てて、そこにデータをコピーすることで対処します。この、別の領域を割り当て、そこにコピーすることを、ここでは 待避(evacuate)と言います。

ちなみに、すぐに(GC タイミングごとに)メモリが解放されてしまうので、「Transient(つかの間の) heap」と名付けました。

表にまとめるとこんな感じです。

malloc/free theap
確保 malloc() theap_alloc() (bump allocation)
生存 N/A evacuate
解放 free() N/A

theap のほうが、確保と解放が速いです。GC での生存時、malloc/free では、オブジェクトの生存時には何もする必要がありませんでしたが、theap では GC が起こる度に待避が必要です。利点と欠点があるんですが、さて、どっちが得でしょうか。

そこで、たびたび世代別GCの解説で紹介される世代別仮説というものを引用します。若いオブジェクトは死にやすく、古いオブジェクトは死ににくい、のではないかという経験則です。Ruby プログラムを見ていると、若いオブジェクトをどんどん作って捨てていく、というプログラムがそこそこありそうですので、この仮説は多分正しいことが多いんじゃないでしょうか。そして、生存する率が低く、待避操作が少ないのであれば、theap はうまく効きそうです。効くんじゃないかな。効いてくれると良いな。

そこで、使えるのが以前 インタプリタ開発者によるRubyの挙動解析への道 ご紹介した debug_counter を見てみます。

discourse benchmark の結果を下記に引用します。

[RUBY_DEBUG_COUNTER]    obj_newobj                       162,910,687
[RUBY_DEBUG_COUNTER]    obj_free                         161,117,628
[RUBY_DEBUG_COUNTER]    obj_promote                        7,289,299

この結果を見ると、promote された、つまり古い世代になったオブジェクトの数は、(7,289,299 / 162,910,687) * 100 = 4.47% と、実に 95% のオブジェクトが新世代のまま、ということがわかります。まぁそんなわけで、多分効くんじゃないかな。効いてくれると良いな。

さて、待避のためには、待避先が必要になります。この領域には2つ候補があります。再度 theap から割り当てる方法、それから従来通り malloc() を利用する方法です。一度 malloc() 領域に移してしまえば、以降 theap を気にすることがありません(待避をこれ以降行う必要はありません)。そこで今回は、オブジェクトが GC における若い世代であれば、待避先に theap の領域を選び、古い世代であれば、待避先を malloc() で確保する、ということにしました。

GC のたびに領域をコピーして待避するので、コピーGC、古い世代の管理を persistent 領域(malloc() 領域)で行うので世代別、なので、世代別コピー GC に似ています(アイディアはそこそこ同じです)。寿命管理は mark&sweep なので、ちょっと特殊ですね。

なお、ものすごく大きいデータを theap に確保してしまうと、いざコピーが必要になったとき大変そうなので、theap で一度に確保できる領域は 2KB に制限しています。このサイズに何か根拠があったわけではなく、当てずっぽうです。もしサイズを超える場合は、最初から malloc() して、theap を利用しないようにします。

Transient Heap の工夫:待避のタイミング

theap 良さそうです。が、いくつか問題があります。一番の問題は、素朴に実装してしまうと C 拡張ライブラリの互換性が壊れてしまう、という問題です。どういうことでしょうか。

theap を使った Array オブジェクトについて考えてみます。Array は theap から確保したメモリへのポインタを持っています。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary);
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr[0]; /* (2) */
}

例えば、こんな C のコードがあったとします。ptr が theap から確保したメモリへのポインタですね。

さて、theap を素朴に実装すると、ary が参照している Array オブジェクトは生きているので、GC が起きた直後に ary の実体を待避する、というものになります。

ただ、もしそうだとすると、ソースコードの (2) での ptr アクセスが危険なものになります。というのも、ptr を取得してから (2) までの間に GC が起こってしまった場合、待避が行われてしまうので、ptr は古い領域をさすことになり、おかしなことになります。この問題は、ptr の寿命が、C 言語のソースコードの見た目と直感的に反する、という言い方もできるかもしれません(GC がらみでは、こういう問題がいくつも出てきます)。

これを避けるためには、ptr が必要になったときには、毎回 ptr を Array オブジェクトから取得しなければなりません。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary); 
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr = RARRAY_CONST_PTR(ary); 
  ptr[0]; /* (2) */
}

ただ、すでに公開されている C-extension は無数にあります。このようにすべて書き換えてください、というのは、そもそも、それを徹底するのが難しい、それから、実際に正しく書き換えるのが難しい(どこで GC が起こるか、すべてチェックする必要があります)、という問題から、現実的ではありません。

そこで、2つの工夫を行うことにしました。

待避のタイミングをファイナライザタイミングにする

GC は、いろいろなところで起きます。これを予測するのはとても難しい。

そこで、待避が行われるタイミングを、GC の直後ではなく、ファイナライザが起動するタイミングに遅延することにしました。え、なんでファイナライザ?

Ruby では、オブジェクトを解放するときに起動するファイナライザを、任意の Ruby コードで記述することができます。つまり、任意の Ruby プログラムが動き得るタイミングということになります。

任意の Ruby コードは、たとえばオブジェクトの破壊的操作を行うことができるので、C 側で取得したポインタが無効になることがあり得ます。つまり、C 側で取得したポインタは、任意の Ruby コードを実行した後では、それがそのまま利用可能かどうかわからない、ということです。

先ほどの例を見てみましょう。

func(VALUE ary){
  /* ary から配列の実体を格納するポインタを取得 */
  const VALUE *ptr = RARRAY_CONST_PTR(ary);
  ptr[0]; /* (1) */
  func_can_cause_gc();
  ptr[0]; /* (2) */
}

もし func_can_cause_gc() 関数が任意の Ruby コードを実行するとしたら、この C 関数は危険です。なぜなら、そこで ary.replace(other_ary) といった、ptr が無効になる処理が実行されるかもしれないためです。というわけで、任意の Ruby コードを実行すると、事前に取得したポインタは保障できない、という問題は、昔からありました。C-extension 開発者は、この制限を受け入れているはずのですので、「このようなコードは無い」という仮定を置くことは妥当です*3

そこで、任意の Ruby コードが動くタイミングで待避を行うのは妥当と言えると思います。そして、GC の後で任意の Ruby コードが動くタイミングというのが、ファイナライザを動かすタイミングなのです。

ポインタを取り出すとき、theap を無効にする

先ほど利用した RARRAY_CONST_PTR() といった、ポインタを取り出す操作を行うと、malloc() ヒープにコピーすることで、theap から待避することにしました。C-extension が扱うポインタは、これで「待避されるかも...」という不安を払拭することができます。この、theap を無効にする処理を detransient と呼んでいます。rb_ary_detransient(ary) という処理を行っています。

だいぶ保守的な決定です。theap のままなら、性能はもう少し高いままかもしれないのに。ただ、動かなくなるプログラムがでるよりは良いだろう、とこのような仕様にしました。

theap に置いたままポインタを取り出したい、ポインタを使っている間は絶対に待避を起こさない自信がある、という場合は、RARRAY_CONST_PTR_TRANSIENT(ary) を用います。detransient しません。array.c など、私が確認したメソッドの実装については、可能な限り theap のまま扱うようにしています。どっちかなー、ちょっと考えるの面倒くさいなー、というときは、detransient してしまうようにしています。

Array を例に説明しましたが、他のデータ型においても似たような方針で実装しています。

なお、本当は、Ruby のデータ型はの内部は時々変ってしまうので、ポインタを取り出すような操作をしないのが一番です。例えば、Array でしたら RARRYA_AREF(), _ASET() などを用いるのが良いでしょう。

Transient Heap の工夫:Hash の実装

Array, Object(ユーザ定義オブジェクト)、Struct に関しては、素直に theap を用いることが出来ました。しかし、Hash はそうはいきません。なぜでしょうか。

Array の場合は、RArray から、1つの連続したメモリオブジェクトを参照します。しかし、Hash の場合はハッシュテーブルという複雑なデータ構造を用いているため(Towards Faster Ruby Hash Tables)、ぽんっと theap を用いるようにすることが出来ませんでした。具体的なハッシュテーブルの実装は、st.c と言うファイルに収められています。この st.c が提供するハッシュテーブルは、Ruby の Hash オブジェクトだけでなく、インタプリタの中で利用する表などでも利用されています。そのため、Hash オブジェクトのためだけに、ごちゃごちゃ theap 対応を入れることが出来ません。

そこで、Hash オブジェクトの実装を2つにわけることにしました。8 要素以下の場合と、8要素以上の場合です。

8要素以下では、ar_table、8要素より大きい場合は st_table(st.c が提供するテーブル)を用いる、というものです。ar_table は、Hash ではありますが、単なる配列のみで実装されており、線形探索を行います。線形探索だけど、たかだか 8 要素だから、まぁいいかなと。もうちょっと工夫してもいいかもしれませんが。

ar_table は、連続した 8B * 3 words * 8 entries = 192 byte の領域を theap から取得します。

もし、要素の追加などで 8 要素以上になれば、st_table を利用するようにスイッチします。

実は、st_table でも、省メモリ化のために 4 要素以下の場合は同じように線形探索するテーブルに変更するようにしているのですが、その処理を ar_table という形で切り出した、というのが今回の変更になります。

さて、そもそも 8 要素以下の要素数の Hash オブジェクトはどの程度あるんでしょうか。先ほどと同じく、インタプリタ開発者によるRubyの挙動解析への道 から、Hash の値を確認してみます。

[RUBY_DEBUG_COUNTER]    obj_hash_empty                     3,632,018
[RUBY_DEBUG_COUNTER]    obj_hash_under4                    4,204,927
[RUBY_DEBUG_COUNTER]    obj_hash_ge4                       2,453,149
[RUBY_DEBUG_COUNTER]    obj_hash_ge8                         841,866

この例では、空の要素数が 3M個、1~3要素までが4M、4~7要素が 2.5M個、それ以上が 0.8M個と、個数が 8 未満の Hash オブジェクトが支配的です。DB 的に Hash を用いると多くのエントリ数の Hash ができますが、沢山作るのは小さい要素数の Hash のようです。キーワード引数とかで使うからですかね。

ちなみに、ar_table の導入によって、theap を用いなくても(すべて malloc() で確保しても)若干 st_table よりも効率が良くなるケースが出てきました。

なお、Hash の theap 対応は、今年の Google Summer of Code で Evan Zhao さんにプロトタイプを作って頂きました(Tacinight/ruby-gsoc-2018)。

Transient Heap の工夫:その他

他にもいろいろ、ちゃんと動くようにするため、思ったよりも苦労しました。

  • ファイナライザタイミングまで遅延することと、旧世代オブジェクトとの食い合わせが悪かったので、いろいろ頑張った
  • 省メモリにするため、いろいろとケチケチするテクニックを使った
  • デバッグが大変だったので、書き込み禁止メモリを用いるオプションを作った
  • それでも後から後から、「時々」出現するバグが見つかるので、テストを沢山実行した

他にもあったような気がしますが、思い出せない。

面倒なので、詳細は割愛します。

Transient Heap の評価

実際、どの程度速くなるんでしょうか。少し評価をご紹介します。

どれくらい theap を使っているか

debug_counter に theap を使っているかどうかを見るカウンタを新設したので、それでチェックしてみましょう。

実行するのが簡単なので、rdoc ベンチマークの結果を見てみます。

# Object
[RUBY_DEBUG_COUNTER]    obj_obj_embed                           11,360
[RUBY_DEBUG_COUNTER]    obj_obj_transient                      541,445
[RUBY_DEBUG_COUNTER]    obj_obj_ptr                             47,567
# Array
[RUBY_DEBUG_COUNTER]    obj_ary_embed                        8,261,454
[RUBY_DEBUG_COUNTER]    obj_ary_transient                    2,818,104
[RUBY_DEBUG_COUNTER]    obj_ary_ptr                            558,292
# Hash
[RUBY_DEBUG_COUNTER]    obj_hash_empty                         523,811
[RUBY_DEBUG_COUNTER]    obj_hash_under4                        541,901
[RUBY_DEBUG_COUNTER]    obj_hash_ge4                             1,598
[RUBY_DEBUG_COUNTER]    obj_hash_ge8                             2,432
[RUBY_DEBUG_COUNTER]    obj_hash_ar                          1,066,943
[RUBY_DEBUG_COUNTER]    obj_hash_st                              2,799
[RUBY_DEBUG_COUNTER]    obj_hash_transient                     955,418
# Struct
[RUBY_DEBUG_COUNTER]    obj_struct_embed                       791,055
[RUBY_DEBUG_COUNTER]    obj_struct_transient                 1,351,924
[RUBY_DEBUG_COUNTER]    obj_struct_ptr                         543,895

Object は 90%、24%(ただし、埋め込み Array を排除すると83%)、Hash は 89%、Struct は50%(埋め込み Struct を除くと 71%)と、結構支配的です。きちんと、theap が活用されていることがわかります。

[RUBY_DEBUG_COUNTER]    heap_xmalloc                         8,244,862
[RUBY_DEBUG_COUNTER]    heap_xrealloc                          318,127
[RUBY_DEBUG_COUNTER]    heap_xfree                           7,905,729
[RUBY_DEBUG_COUNTER]    theap_alloc                          7,332,681
[RUBY_DEBUG_COUNTER]    theap_alloc_fail                       583,852
[RUBY_DEBUG_COUNTER]    theap_evacuate                       1,257,952

こちらでは、malloc した回数と、theap から確保した回数(theap_alloc)が見て取れます。malloc 回数と同程度の回数、theap で割り当てていることがわかります。原因をさがしたら、もうちょっと theap の率を増やすことができるかもしれません。

待避(theap_evacuate)したのは、17% 程度みたいですね。そこそこ少なそうです。

theap_alloc_fail は、theap から確保しようとしたが、何らかの理由で失敗した数です。もうちょっと減らせそうですね。

マイクロベンチマーク

次のグラフは、対応している Array オブジェクトの要素数を変えながら、沢山作るのを繰り返す、というものを、 theap ありとなしで比べた結果です。

f:id:koichi-sasada:20181226145007p:plain

このマイクロベンチマークでは、だいたい、1.5 倍程度の性能向上が得られているのがわかると思います。

f:id:koichi-sasada:20181226171946p:plain

Hash もだいたい 1.5~2倍程度の性能向上を実現しています。ただ、9要素になると theap を使わないため、以前と同程度の性能になります。

実アプリケーション

rdoc

USE_TRANSIENT_HEAP というマクロを 0 にすると theap を無効にできるので、オンとオフ、それぞれを調べてみます。

# without theap
      user     system      total        real
 23.757590   0.363964  24.121554 ( 24.124386)
VmHWM: 423932 kB

# with theap
      user     system      total        real
 22.334208   0.355940  22.690148 ( 22.693046)
VmHWM: 358584 kB

時間は 24.12/22.69 = 6% ほど速くなっています。良かった良かった。

VmHWM は、Linux だと取れる、必要になった実メモリのサイズですが、なぜか 18% ほど削減しています。あまりちゃんと調べていないし、他のアプリではどうか、という調査はしていないのですが、とりあえず「良かったね」と捉えておきます。

sinatra-benchmark

https://github.com/benchmark-driver/sinatra を用いてみました。

Calculating -------------------------------------
                     without-theap  with-theap    ruby_2_5
             sinatra       10.293k     10.493k     10.263k i/s -    100.000k times in 9.714985s 9.530253s 9.743398s

Comparison:
                          sinatra
          with-theap:     10492.9 i/s
       without-theap:     10293.4 i/s - 1.02x  slower
            ruby_2_5:     10263.4 i/s - 1.02x  slower

うーん、2% 速いらしい。微妙ですね...。

discourse

discourse rails benchmark で試したんですが、ちょっと手元に結果がないのですが、たしかさっぱり変らなかった気がします。

この辺は、そもそも malloc/free 自体があまりオーバーヘッドになってないってところでしょう。まぁ、そうですよね。でも、もうちょっと効くと思ったんだけどなあ。

速くなった! という話があれば、お寄せ下さい。

こんな話もあるみたいです。

まとめ:Transient Heap

いろいろ複雑な工夫を入れた割に、Rails とかで効かないとか、イマイチ感動の少ない工夫ではありますが、効くところではもしかしたら効くかもな、という感じです。

一番 theap が効きそうな String ですが、対応していません。C レベルで、すぐにポインタを取り出す処理をしてしまうので、あまり効果がないためです。効果を出すためには、絶対に待避されない、ということを保障する必要がありますが、それを行うのが大変なのですよね。というわけで、将来のバージョンでは対応するかもしれないし、そもそも効果が無いから theap 自体が削除されるかもしれません。効くアプリには効きそうなんですけどねぇ。

もう一つ。現在 theap 用のメモリ領域は、最初に 32MB 固定長を確保しています。これを可変に、必要なときに多く、不用なときには少なく、みたいにするのも手かもしれません。現状は、その辺の調整が難しくて(例えば、OS に返す、要求する、を繰り返すと、とても遅くなります)、手を入れられていません。

おわりに

本稿では、私が Ruby 2.6 に導入した次の2つのトピックについてご紹介しました。

  • TracePoint の拡張
    • 新しいイベント script_compiled の導入
    • フックを有効にする場所を制限する機能の導入
    • デバッガの実装が、10~100倍くらい速くなる、かもしれない
    • ブレイクポイントの実装を例に解説
  • Transient Heap の導入
    • 短寿命メモリオブジェクトの高速化
    • 世代別コピーGCのアイディアを利用
    • Rails とかには効かないかも...。

三日連続で Ruby 2.6 の新機能をお伝えしました。いつになく長い記事になりましたが、冬休みにでもお楽しみ頂ければ幸いです。

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

*1:GC 対象でない Ruby オブジェクトもあります。

*2:本当はもっと複雑ですが、ここでは単純化しておきます。

*3:いや、たまたま動いてたから、と言うケースはそこそこありそうですが...。