Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

AWS と GCP (GAE) それぞれのリージョン間のレイテンシを計測してみた

この記事は Akatsuki Advent Calendar 2019 の 9 日目として書きました。

はじめに

みなさん、マルチクラウドしてますか!? 株式会社アカツキでエンジニアをしている @sachaos です。

弊社では各ゲームプロダクトから利用される共通基盤をマイクロサービスとして構築しています。 この共通基盤は Google Cloud Platform (以降 GCP) の Google App Engine (以降 GAE) Standard Environment により運用されています。 現在、ゲームプロダクトでは主に Amazon Web Services (以降 AWS) を利用しているため、ゲームサーバは AWS と 共通基盤は GCP のマルチクラウド構成となっています。

アカツキでは海外向けに提供しているゲームもあります。 日本にゲームサーバがあれば日本のプレイヤーは快適にゲームをすることが出来ますが、 海外向けのゲームで日本にゲームサーバがあると、物理的に遠いのでどうしてもレイテンシが発生してしまいます。 なので、海外版を提供する際にはプレイヤーの体験を損なわないようにするため、ゲームサーバをなるべくプレイヤーの近くに構築しています。

また、共通基盤となるサービスは同じ理由でゲームサーバの近くにあるべきです。 しかし、共通基盤を AWS の各リージョンから最も近いところに複数個デプロイするというのは、運用コストの点から現実的ではありません。

なので、運用コストを抑えつつもプレイヤーの体験を損なわないようにするため、 共通基盤を用意する GCP のリージョンを少なく して、かつ 様々な AWS リージョンからもレイテンシが低く保てる ような GCP のリージョンを探したいです。

そこで、AWS 上に構築されるゲームサーバから GCP (GAE) 上に構築されている共通基盤までのレイテンシはどの程度なのか、 AWS の各リージョンから GCP (GAE) の各リージョンまでのレイテンシを計測してみました。

結果

AWS の各リージョンから GCP の各リージョンへ 20 回リクエストを送り、その中央値をとったものをまとめたものが以下の表です。 単位は ms です。 100ms 未満のものを緑色に、300ms より大きいものを赤色で塗っています。

f:id:sachaos:20191209164034p:plain

docs.google.com

計測方法

レイテンシの計測対象として GAE SE の go111 runtime を利用し、単純に Hello World を出力する HTTP サーバを作成し、各リージョンに配置しています。 そしてレイテンシの計測をするものとして Lambda を利用しました。 こちらも各リージョンに配置し、パラメータとして GAE のリージョンを指定すればそこに対して、計測を開始するようなものになっています。 AWS Lambda は Go のランタイムを利用しており、Go 製の負荷テストライブラリを利用しました。

f:id:sachaos:20191209145937p:plain

考察

当たり前ですが、近いところはレイテンシが低いですw どんな AWS のリージョンからでも、71 ms 以下で返せる GAE のリージョンがありそうです。

GAE の us-cental, europa-west リージョンが全体的に優秀です。 us-cental は AWS のどんなリージョンからでも 300ms 未満でリクエストを返しています。 europa-west リージョンも殆どのリージョンから 300ms 未満でリクエストを返しています。

asia-northeast1 に加えてこの2つのリージョンにサービスを配置すれば どんな AWS のリージョンからのリクエストでも 100 - 200ms 未満でリクエストを返すことができそうです。

アカツキでのインターン戦記:クライアントエンジニア編

f:id:kenji-hanada:20190925180518p:plain少し前の話になりますが、9/5 ~ 9/20 まで アカツキ でインターンをしてきたうじまるです。 今回はその体験記を書いていこうと思います。アカツキのインターンにこれから参加する/検討している人の参考になれば嬉しいです。

目次

インターンの内容

八月のシンデレラナイン のクライアント側の開発をしてました。 主にやっていたことは

  ・既存の実装だと動作が重くなっている部分を改善する

  ・累積報酬を試合結果画面からみえるようにする

  ・SR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

ということをやってました。大きめのタスクがあってそれを2週間でやるという形ではなく小さめのタスクをたくさんやるという感じでやっていました。小さめのタスクだったので進捗が出てる感があってよかったです。

これにプラスして 9/14 ~ 9/15 にあった AKATSUKI GAME JAM も参加しました。

既存の実装だと動作が重くなっている部分を改善する

既存の実装だとアイテム一覧や練習参加先一覧がデータの数だけ生成されてしまいその数が大きいと重くなっていました。そこでそれを軽くするために表示に必要な数だけオブジェクトを生成するように修正するようにしました。必要分生成するComponentはすでにあったのでそれを使うように実装するタスクでした。

累積報酬を試合結果画面からみえるようにする

累積報酬の詳細が試合結果画面から見れなかったのでそれを見れるようにしました。 現状だと試合を選択する画面からしか累積報酬の詳細を見ることができなかったのですが再戦をして周回すると現在どのくらいポイントが溜まっていて次がどの報酬なのか見れないようになっていました。

f:id:kenji-hanada:20190925180512p:plain
ここから累積報酬を確認できるようにする

前のタスクの実装は一部のComponentに修正を入れるだけだったのですがこのタスクはSceneの処理の流れやデータの流れを理解しないと実装が難しかったのでコードを読んだり実際にどんな処理をしているのかを理解するのが大変でした。

SSR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

今まで背景画像をデザイナーさんが作っていたのですが、運用コスト的にクライアント側で生成できるならしたほうが良いよね、ということでそれを実装しました。

f:id:kenji-hanada:20190925180507p:plain
この画面のUIの後ろ側の画像を作る

最初に自分が考えていた実装方針もあったのですが、そっちだと現在のコードをうまく使って実装できなかったので別の方針でやることにしました。画像の合成はオブジェクトを2つ作って合わせても良かったのですが、実装をシンプルにしたかったのと現在の処理もうまく使いたかったのでシェーダーで実装することにしました。

マスターデータから画像IDを取ってきたり、デバッグ用にアイテム付与をするツールを使ったりソシャゲを作ってる感があってよかったです。

ただ、キャラ画像の位置調整が手に入るデータだけではうまくできなかったのでデザイナーさんと話してマスクの範囲を小さくするという結果になりました。

このままリリースされるかどうかはわかりませんが、基本的な部分は実装できたと思います。

AKATSUKI GAME JAM

テーマが「カラフル」で3~4人一組になって2日間掛けてゲームを作るというインターンです。 僕のチームは3人チームでチームメンバ全員がカレー(特にインドカレー)がまぁまぁ好きという理由で「ナンカレー」というチーム名になりました。

作ったゲームは「縦スクロールでロケットを操作して宇宙人を集めるゲーム」を作りました(なんかよく分かりませんね、僕もうまく説明できないです)

f:id:kenji-hanada:20190925180504j:plain
開発風景

結果、準優勝をして「ハチナイカレー」を貰いました。ナンカレーがハチナイカレーを貰えたのでなんか良かったです。

f:id:kenji-hanada:20190925180459j:plain

今回作ったゲームはリリースまで頑張ろうということになったのでリリース目指して頑張ろうと思います。

まとめ

直前に参加していたインターンとの兼ね合いがあり、3週間の予定が約2週間のインターンになってしまいましたが、それなりに進捗を出すことができたと思います! Unityのコードをレビューしてもらう機会が今までほとんど無かったのでとても勉強になりました。 他にも、インターン参加前に「設計とか見たい」と思ってましたが、この点でも普通に実装や設計で勉強になる部分が多かったのでいい経験になりました。

ゲーム開発の現場もエンジニア以外にも検証の人やデザイナーさん、プランナーさんなどなど色んな職種の人がいて楽しかったです。ソシャゲ開発あるあるなのか分からないですが、インターン初日がメンテの日で、メンテ明けにみんな一斉にハチナイのスカウトを回していたのがちょっと面白かったです。

自分のUnity力を試せた機会だったし、アカツキという会社がどんな会社なのか知ることができたのでとても良いインターンでした!!

UniTaskを使った話

この記事は Akatsuki Advent Calendar 2019 - Adventar 5日目の記事です。

 

はじめに

クライアントエンジニアのkawawa1989です。

現在はUnityを使っての開発を行っています、今回は初めて導入したUniTaskについてご紹介をしたいと思います。

 

UniTaskとは🤔🤔🤔

Cysharpさんが開発された、Unityでメインスレッドを使ってasync/awaitを使えるようにするプラグインです。
ちなみにUniRxは事前に入れる必要があるとか、そういうことはありませんでした。
UniRxなしでも単体で扱うことができます。
tech.cygames.co.jp




今まではIEnumeratorを使って書いていた箇所をasync/awaitで置き換えることができるようになります。

 

やりたいこと🤔

通信などの非同期処理などが多い場合、「通信が成功した(or失敗した)」のコールバック関数だらけになってしまいます。
例えばログインのフローでこんなコードがあったとします。

    // ログインフロー
    // 例えばログインボタンがタップされてログインするとする場合
    void OnLoginButtonClick()
    {
        // 利用規約が読まれていないなら利用規約UIを出したい
        if (!HasReadTermOfUse)
        {
            OpenTermOfUseDialog(onAgreed: OnAgreedEvent);
            return;
        }
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }

    // 利用規約が同意された
    void OnAgreedEvent()
    {
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }


    // ログイン完了
    void OnLoginSuccessEvent()
    {
        LoadNextScene();
    }

 ぶっちゃけこのレベルなら個人的には許容できるのですが、私が実際に経験したコードだとログイン一つでこれを遥かに超える複雑な作りになっていました。
なのですが、あまり具体的なことは社外秘のためとりあえずこの範囲で...
上記のような処理を、できるなら一つの関数の中で何をやっているのかすべて理解できるようにしたいですね。
 

まず最初に考えたやり方🤔

最初はコルーチンでやってしまおうと思いました。

    IEnumerator LoginFlow()
    {
        if (!HasReadTermOfUse)
        {
            var dialog = OpenTermOfUseDialog();
            // ダイアログを表示している間ずっと待機し続ける
            yield return dialog;

            // 終了したら承認されたかどうかの判定を行う
            // 利用規約に同意してもらえなかったらそこで終了
            if (!dialog.Agreed)
            {
                yield break;
            }
        }

        // ログインAPIを叩く
        var login = NetworkManager.Login();
        yield return login;

        // ログインに成功したのかどうか
        // 失敗したなら終了
        if (!login.Success)
        {
            yield break;
        }

        LoadNextScene();
    }

これで一つの関数内に収めることができます
が、いちいちAsyncOperation的なやつを取らないといけないのが面倒くさいです。
UniRxを使えばFromCoroutineでどうにかなるものの、StartCoroutineを呼び出す元になるスクリプトがないと動かせないのも欠点です。

みたいなことを会社の同僚と話していて「そういう事やりたいならUniTaskってのがあるから、それ使ってみては?」
という案が出ました。

というわけで早速導入してみました。
参考になった記事
UniRx.Async(UniTask)機能紹介 - Qiita

当初、Incremental Compilerなるものが必要で、
しかもそれはPreview版とのことだったので若干UniTaskは避けていたのですが、どうやらそれは昔の話で最近は違うらしいと知りました。

 

Incremental Compilerが不要に!😇

neue cc - UniTask(UniRx.Async)から見るasync/awaitの未来
ということでこれで安心して導入できます!!

    async UniTask LoginFlow()
    {
        // ダイアログを表示して結果が返ってくるまで待機する。
        if (!HasReadTermOfUse)
        {
            var agreed = await OpenTermOfUseDialog();
            if (!agreed)
            {
                return;
            }
        }

        // ログインAPIを叩く
        var success = await NetworkManager.Login();
        // エラーだったら終了
        if (!success)
        {
            return;
        }
        LoadNextScene();
    }

よりスッキリしました!
戻り値をawaitで待機しながら受け取れるのは便利ですね!

とはいえ全部をいきなりUniTaskに置き換えるのは難しくない?

私がUniTaskを導入したのはプロジェクトのかなり終盤でした。
そのため、全てをUniTaskに置き換えることはできないのですが、それでもやりようはあります。
例えばダイアログ関係はこのように書き換えることができます。

    // 元のコード
    public void ShowDialog(string title, string message, Action onClose)
    {
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, onClose);
    }

    // これを元に改造
    public UniTask ShowDialogAsync(string title, string message)
    {
        var source = new UniTaskCompletionSource();
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, () =>
        {
            source.TrySetResult();
        });
        return source.Task;
    }

UniTaskCompletionSource

UniTaskCompletionSourceというやつで終了時にTrySetResultすれば元の実装をいじくる必要なくUniTaskで待てるように改造できます!
これも最初は自分はまったく知らなかった機能で、知る前までは毎回

        var isDone = false;
        dialog.Initialize(title, message, () =>
        {
            isDone = true;
        });
        await UniTask.WaitUntil(() => isDone);

こんな風に書いてました。

UniTaskの中身を解析してみよう🤔

どう動いているのか非常に気になったので、中身を解析してみました。

PlayerLoopHelper

コルーチンの場合、StartCoroutineを走らせるためのMonoBehaviourがどうしても必要でした。
でしたが、PlayerLoopを上書きできるようになったので、この仕組みで直接UniTaskを走らせる用のUpdate関数を登録しているようでした。
PlayerLoopに関しての参考リンク
tsubakit1.hateblo.jp

PlayerLoopHelper.csの中身

    public static class PlayerLoopHelper
    {
        public static SynchronizationContext UnitySynchronizationContext => unitySynchronizationContetext;
        public static int MainThreadId => mainThreadId;

        static int mainThreadId;
        static SynchronizationContext unitySynchronizationContetext;
        static ContinuationQueue[] yielders;
        static PlayerLoopRunner[] runners;


yieldersrunnersがどうにも気になります。
PlayerLoopHelperのInitというメソッドがRuntimeInitializeOnLoadMethodによって起動時に呼び出されているようです。

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
        static void Init()
        {
            // capture default(unity) sync-context.
            unitySynchronizationContetext = SynchronizationContext.Current;


Initialize内では以下のような感じになっていました。
InsertRunnerでループ処理を追加しています。

        public static void Initialize(ref PlayerLoopSystem playerLoop)
        {
            yielders = new ContinuationQueue[7];
            runners = new PlayerLoopRunner[7];

            var copyList = playerLoop.subSystemList.ToArray();

            copyList[0].subSystemList = InsertRunner(copyList[0], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldInitialization), yielders[0] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerInitialization), runners[0] = new PlayerLoopRunner());
            copyList[1].subSystemList = InsertRunner(copyList[1], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldEarlyUpdate), yielders[1] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerEarlyUpdate), runners[1] = new PlayerLoopRunner());
            copyList[2].subSystemList = InsertRunner(copyList[2], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldFixedUpdate), yielders[2] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerFixedUpdate), runners[2] = new PlayerLoopRunner());
            copyList[3].subSystemList = InsertRunner(copyList[3], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreUpdate), yielders[3] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreUpdate), runners[3] = new PlayerLoopRunner());
            copyList[4].subSystemList = InsertRunner(copyList[4], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldUpdate), yielders[4] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerUpdate), runners[4] = new PlayerLoopRunner());
            copyList[5].subSystemList = InsertRunner(copyList[5], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreLateUpdate), yielders[5] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreLateUpdate), runners[5] = new PlayerLoopRunner());
            copyList[6].subSystemList = InsertRunner(copyList[6], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPostLateUpdate), yielders[6] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPostLateUpdate), runners[6] = new PlayerLoopRunner());

 
InsertRunnerの中身を見たところ、yieldLoopというのとrunnerLoopというループがあるのですが、
これをUnityの各PlayerLoopに2つずつ挿入しています。
 
f:id:kawawa1989:20191205112536j:plain


このループの中で実際にやっている処理はContinuationQueuePlayerLoopRunnerというやつをそれぞれ走らせています。
yieldLoop内でContinuationQueueのRunを走らせて
runnerLoop内でPlayerLoopRunnerのRunを走らせています。
では次にContinuationQueuePlayerLoopRunnerを見てみます。

ContinuationQueue

内部的にはactionListというActionの配列を管理しているようです。
そしてこれをRun実行時に呼び出している。
Enqueueというメソッドが用意されていて、これを呼び出すことでActionが登録できそうです。
ではこのEnqueueはどこから呼び出されているのだろう?

と思って追ってみたら、PlayerLoopHelperで管理しているようでした。
イメージとしては以下の図のような感じでしょうか。

f:id:kawawa1989:20191205113005j:plain
では、次にこのAddActionとAddContinautionがどこから呼び出されているか調べてみます。

f:id:kawawa1989:20191205114020p:plain
ReusablePromise.csが気になるので見てみます。
PlayerLoopReusablePromiseBaseというクラスのIsCompletedというメソッドで呼び出されていました。

PlayerLoopReusablePromiseBase

        public override bool IsCompleted
        {
            get
            {
                if (Status == AwaiterStatus.Canceled || Status == AwaiterStatus.Faulted) return true;

                if (!isRunning)
                {
                    isRunning = true;
                    ResetStatus(false);
                    OnRunningStart();
#if UNITY_EDITOR
                    TaskTracker.TrackActiveTask(this, capturedStackTraceForDebugging);
#endif
                    PlayerLoopHelper.AddAction(timing, this);
                }
                return false;
            }
        }


PlayerLoopReusablePromiseBaseの実装を見ればわかりますが、IPlayerLoopというやつを実装していました。

    public abstract class PlayerLoopReusablePromiseBase : ReusablePromise, IPlayerLoopItem
    {

これはMoveNextというメソッドを提供するインターフェースのようで、
MoveNextメソッドはPlayerLoopReusablePromiseBaseを継承した先で実装されていました。
WaitUntilPromise
WaitWhilePromise
YieldPromise

PlayerLoopRunnerは基本的にこのIPlayerLoopItemを配列で管理して、毎ループ時に各IPlayerLoopItemのMoveNextを呼び出しているようです。
次はUniTaskを見ます。
AddActionメソッドにログを仕込んでみました。
で、下記のようなサンプルコードを書いてみました。とりあえず3秒間待つタスクです。

    bool isDone = false;
    private void Awake()
    {
        Run().Forget();
        StartCoroutine(WaitCoroutine());
    }

    private IEnumerator WaitCoroutine()
    {
        yield return new WaitForSeconds(3.0f);
        isDone = true;
    }

    private async UniTask Run()
    {
        Debug.Log("Run Start!!");
        await UniTask.WaitUntil(() => isDone);
        Debug.Log("Run End!!");
    }

f:id:kawawa1989:20191205115942p:plain
呼ばれています!
呼び出し先を見てみると、PlayerLoopReusablePromiseBaseIsCompletedというプロパティで呼び出されているのがわかります。
PlayerLoopReusablePromiseBaseIsCompletedを呼び出しているのはUniTaskIsCompletedプロパティのようです。

UniTask

IsCompleted

UniTask.IsCompleted

        [DebuggerHidden]
        public bool IsCompleted
        {
            get
            {
                return awaiter == null ? true : awaiter.IsCompleted;
            }
        }

UniTaskawaiterというフィールドを持っていることがわかります。
このawaiterにPlayerLoopReusablePromiseBaseのインスタンスを設定しているということになります
そして、これを呼び出しているのはAwaiterというやつのようですAwaiterはUniTask内に定義されています。
Awaiter

Awaiter
        public struct Awaiter : IAwaiter
        {
            readonly UniTask task;
            [DebuggerHidden]
            public Awaiter(UniTask task)
            {
                this.task = task;
            }

こいつの中にUniTaskが内包されているようです。
で、実際はAwaiterのIsCompletedからUniTaskのプロパティにアクセスしているようです。

IAwaiter

IAwaiterの定義自体はこんな感じです。

    public interface IAwaiter : ICriticalNotifyCompletion
    {
        AwaiterStatus Status { get; }
        bool IsCompleted { get; }
        void GetResult();
    }
ICriticalNotifyCompletion

ICriticalNotifyCompletionはちょっとよくわからないので調べます。
どうもTaskでも使用されているインターフェースのようです。

.NET Framework標準で提供されているTaskAwaiter/TaskAwaiter型はINotifyCompletionインターフェースを継承するICriticalNotifyCompletionインターフェースを実装しています。

参考記事
このIsCompletedはどこで呼ばれているのか?
IsCompleted内部にログを仕込んでみます。
f:id:kawawa1989:20191205121706p:plain

どうやらAsyncUniTaskMethodBuilderとかいうやつが居るようで、そいつが呼び出しているようです。
では今度はこいつのStartメソッドに同じようなログを仕込んでみましょう。
f:id:kawawa1989:20191205121755p:plain

これ以上先はもう何もありません。
このAsyncUniTaskMethodBuilderが何者なのか?といろいろ調べていたら、これはどうもコンパイラ側が使用するクラスのようです
www.atmarkit.co.jp

IAsyncStateMachineというやつが何か関連がありそうです。
コイツについて調べてみましょう。
docs.microsoft.com

非同期メソッドに生成されるステート マシンを表します。 この型はコンパイラでのみ使用されます。

他にもこのような記事がありました。
blog.xin9le.net

・async/awaitは何かの糖衣構文で、実際はコンパイラがもっと複雑な形に変換している
・exeやdllには変換後の形で格納されている

とのことなので、試しにILSpyで中身を開いてみます。

    .method /* 06000006 */ private hidebysig 
        instance valuetype [UniRx.Async]UniRx.Async.UniTask Run () cil managed 
    {
        .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
            01 00 12 54 65 73 74 43 6f 64 65 2b 3c 52 75 6e
            3e 64 5f 5f 36 00 00
        )
        .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2080
        // Code size 59 (0x3b)
        .maxstack 2
        .locals /* 11000001 */ init (
            [0] class TestCode/'<Run>d__6',
            [1] valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder
        )

        IL_0000: newobj instance void TestCode/'<Run>d__6'::.ctor() /* 06000012 */
        IL_0005: stloc.0
        IL_0006: ldloc.0
        IL_0007: ldarg.0
        IL_0008: stfld class TestCode TestCode/'<Run>d__6'::'<>4__this' /* 04000008 */
        IL_000d: ldloc.0
        IL_000e: call valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Create() /* 0A000012 */
        IL_0013: stfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0018: ldloc.0
        IL_0019: ldc.i4.m1
        IL_001a: stfld int32 TestCode/'<Run>d__6'::'<>1__state' /* 04000006 */
        IL_001f: ldloc.0
        IL_0020: ldfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0025: stloc.1
        IL_0026: ldloca.s 1
        IL_0028: ldloca.s 0
        IL_002a: call instance void [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Start<class TestCode/'<Run>d__6'>(!!0&) /* 2B000001 */
        IL_002f: ldloc.0
        IL_0030: ldflda valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0035: call instance valuetype [UniRx.Async]UniRx.Async.UniTask [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::get_Task() /* 0A000014 */
        IL_003a: ret
    } // end of method TestCode::Run

AsyncUniTaskMethodBuilder::Create()とかAsyncUniTaskMethodBuilder::Start()とか呼ばれてます!
ILコードがよくわかりませんが、callとか呼ばれてますね。
(この辺もそのうち勉強してみたい)

[参考リンク]
www5b.biglobe.ne.jp

次に気になったこと

どうやってAsyncUniTaskMethodBuilderを呼び出している?🤔

これがちょっと謎でしたが、UniTask.csの定義をよく見るとAsyncMethodBuilderというやつがあります!

    [AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))] // ←これ!!!!
    public partial struct UniTask : IEquatable<UniTask>

AsyncMethodBuilderに関して参考リンク
qiita.com

つまりawaitが呼ばれたとき、その戻り値の型に対応されたAsyncMethodBuilderが呼び出される
ということでしょうか?
ここ、調べてもあまり出てこないのでちょっとわからない。
おそらくこんな感じ

f:id:kawawa1989:20191205122359j:plain
試しに
UnsafeOnCompleted(Action act)
で受け取るデリゲートを最後に呼び出さないようにしてみたら
永遠にawaitから戻ってこれない状態になったので、おそらくこれは処理が終了したあとに呼び出さないといけないやつ。
GetAwaiterが何者なのか気になったけど、これは戻り値の型が独自定義型であろうとなんだろうと関係なく呼ばれるように見えます。
docs.microsoft.com
ちなみにGetAwaiterをコメントアウトするとawaitする箇所全てでコンパイルエラーになって死にます。

要約

全体のライフサイクルはこんな感じ?だと思われる🤔
f:id:kawawa1989:20191205122539j:plain


オレオレTaskを作ってみよう!

試しに自分でawaitできるタスクを作ってみます。
UniTaskはstructで定義されていましたが、structだとデフォルトコンストラクタが定義できないので今回はclassで定義しています。

using System.Runtime.CompilerServices;
using System;

namespace Bz.Brotherhood
{
    [AsyncMethodBuilder(typeof(BzTaskBuilder))]
    public class BzTask
    {
        IAwaiter awaiter;

        public BzTask()
        {
            UnityEngine.Debug.Log($"BzTask.Constructor");
        }

        public BzTask(IAwaiter awaiter)
        {
            this.awaiter = awaiter;
        }

        public IAwaiter GetAwaiter()
        {
            UnityEngine.Debug.Log($"BzTask.GetAwaiter");
            return new Awaiter(awaiter);
        }

        private struct Awaiter : IAwaiter
        {
            IAwaiter awaiter;
            public Awaiter(IAwaiter awaiter)
            {
                this.awaiter = awaiter;
            }

            public bool IsCompleted
            {
                get
                {
                    var isCompleted = true;
                    if (awaiter != null)
                    {
                        isCompleted = awaiter.IsCompleted;
                    }
                    UnityEngine.Debug.Log($"Awaiter.IsCompleted :{isCompleted}");
                    return isCompleted;
                }
            }

            public void OnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter OnCompleted");
                awaiter?.OnCompleted(moveNext);
            }

            public void UnsafeOnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter UnsafeOnCompleted");
                awaiter?.UnsafeOnCompleted(moveNext);
            }

            public void GetResult()
            {
                UnityEngine.Debug.Log("Awaiter GetResult");
                awaiter?.GetResult();
            }
        }
    }

AsyncMethodBuilderAttributeは存在しない?

コードを書いてみたのですがエラーが出ます。
f:id:kawawa1989:20191205122824p:plain
UniTaskはAsyncMethodBuilderAttributeを定義しているようでした。
blog.meilcli.net

また、現時点でほとんどのプラットフォームではAsyncMethodBuilderAttribute属性も自分で定義する必要があります。

理由はよくわからないのですが、とにかくこっちで定義しないと駄目らしい。
以下のようなオレオレタスクビルダーを作ってみました(といってもほぼUniTaskBuilderのコピペ)

namespace Bz.Brotherhood
{
    public struct BzTaskBuilder
    {
        IAwaiter awaiter;

        // 1. Static Create method.
        public static BzTaskBuilder Create()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Create");
            var builder = new BzTaskBuilder();
            return builder;
        }

        // 2. TaskLike Task property.
        public BzTask Task
        {
            get
            {
                UnityEngine.Debug.Log("BzTaskBuilder.Task");
                return new BzTask(awaiter);
            }
        }

        // 3. SetException
        public void SetException(Exception exception)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetException");
        }

        // 4. SetResult
        public void SetResult()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetResult");
        }

        // 5. AwaitOnCompleted
        public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : INotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.OnCompleted(stateMachine.MoveNext);
        }

        // 6. AwaitUnsafeOnCompleted
        [SecuritySafeCritical]
        public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : ICriticalNotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitUnsafeOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.UnsafeOnCompleted(stateMachine.MoveNext);
        }

        // 7. Start
        public void Start<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Start");
            stateMachine.MoveNext();
        }

        // 8. SetStateMachine
        public void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetStateMachine");
        }
    }
}


そして最後にこんなテストコードを試しに作ってみます。

    void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    async BzTask HelloWorldTask()
    {
        Debug.Log("Hello World!!");
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await HelloWorldTask();
        Debug.Log("TestTask End");
    }

これでも一応エラーになりません(まったく待機とかしないけど)
そして実行してみます。
f:id:kawawa1989:20191205123138p:plain

動いた!
現在のままだとawaitとか言っておきながら全く待機していないので今度は例えば60フレーム後に処理が進行するようにしてみたいです。
このBzTaskのIAwaiterに何か実装を渡せばその実装に合わせて動くようになっています。
IAwaiterの中身はUniTaskのものとほぼ同じです。

    public interface IAwaiter : ICriticalNotifyCompletion
    {
        // BzTask.Awaiter内部で呼び出される。
        bool IsCompleted { get; }
        void GetResult();
    }


そしてこのIAwaiterを実装した60フレーム待機するクラスを作ってみます。

    private interface IOnUpdate
    {
        bool OnUpdate();
    }

    private class FrameAwater : IAwaiter, IOnUpdate
    {
        int frame = 0;
        int waitFrameCount = 0;
        Action moveNext = null;

        public FrameAwater(int waitFrameCount)
        {
            this.waitFrameCount = waitFrameCount;
        }

        public bool OnUpdate()
        {
            Debug.Log($"OnUpdate[frame:{frame}, waitFrameCount:{waitFrameCount}]");
            if (frame == waitFrameCount)
            {
                moveNext();
                return false;
            }
            frame += 1;
            return true;
        }

        // BzTask.Awaiter内部で呼び出される。
        public bool IsCompleted { get { return false; } }
        public void GetResult()
        {
        }

        public void OnCompleted(Action continuation)
        {
            moveNext = continuation;
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            moveNext = continuation;
        }
    }


そしてMonoBehaviour側にIOnUpdateをUpdate時にコールする、みたいなやつを追加します。

    List<IOnUpdate> list = new List<IOnUpdate>();
    List<IOnUpdate> removeList = new List<IOnUpdate>();
    void Update()
    {
        removeList.Clear();
        foreach (var update in list)
        {
            if (!update.OnUpdate())
            {
                removeList.Add(update);
            }
        }
        foreach (var item in removeList)
        {
            list.Remove(item);
        }
    }

そして試しに60フレーム待機するように以下のように書き換えてみます。

    void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    BzTask WaitForFrame(int frame)
    {
        Debug.Log("WaitForFrame");
        var frameAwaiter = new FrameAwater(frame);
        list.Add(frameAwaiter);
        return new BzTask(frameAwaiter);
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await WaitForFrame(60);
        Debug.Log("TestTask End");
    }

意図した通りに動くのであれば、
60フレーム経過したらUnsafeOnCompleted()で受け取ったデリゲートを実行し、それによってタスクは終了するはずです。

f:id:kawawa1989:20191205123431p:plain
f:id:kawawa1989:20191205123516p:plain
しっかり60フレーム待機してくれています!
長くなりましたがたまにこうやって他の人の作ったプラグインのソースコードを除くのは楽しいですね。


ここまで読んでくださってありがとうございました!

Redash上からBigQueryスクリプトを利用してみた

この記事は Akatsuki Advent Calendar 2019 - Adventar 4日目の記事です。

こんにちは! suwahime です。

昨今どの業界を見渡しても、アクティブユーザー数や入会数といったデータは、当たり前のように日々追っているかと思います。 私の所属するチームでは、主にRedashを用いてKPIを可視化しています。

今日は、先月BigQueryにBetaリリースされたスクリプトとプロージャ機能を使って、RedashのQuery作成を、よりシンプルに、管理しやすい形で書いてみたことをお話しさせていただきます。

RedashはQueryごとに定義が分散してしまいがち

例として、「期間内にサービスを訪れたユーザー」の利用デバイスを調べる、以下のようなQueryをRedashに作成していたとします。

SELECT B.device_name, COUNT(DISTINCT A.user_id) FROM
  (SELECT * FROM dataset.login_user_ids
    WHERE timestamp >= TIMESTAMP("{{start_time}}")
    AND timestamp <= TIMESTAMP("{{end_time}}")
    GROUP BY user_id) AS A
  LEFT JOIN
  (SELECT user_id, device_name FROM dataset.user_devices) AS B
  ON A.user_id = B.user_id
GROUP BY B.device_name

テーブルの内容等は割愛しますが、利用者はstart_timeとend_timeをRedash上のGUIから入力することで、数字が見られるというイメージです。

Redashはこのように愚直なSQLを書いても簡単にグラフ化までできる魔法のようなツールなのですが、いかんせん簡単すぎて、考えなしに様々なQueryを作成してしまい、後々の定義変更が大変だったりすることがあります。

たとえば、以下のような要求がきた場合はどうなるでしょうか。

  • 期間ごとの不正なユーザーIDを検知することができたので、その一覧をKPIから削減したい。
  • ログインしたユーザーではなく、サービスをプレイしたユーザーから取るように変えてほしい。

中間テーブルを作成するなど、やり方はいろいろあると思いますが、ここではまた愚直に以下のように修正してみます。

SELECT B.device_name, COUNT(DISTINCT A.user_id) FROM
(SELECT * FROM dataset.play_user_ids -- 参照するテーブルを置き換え
  WHERE timestamp >= TIMESTAMP("{{start_time}}")
  AND timestamp <= TIMESTAMP("{{end_time}}")
  AND user_id NOT IN -- 不正ユーザーIDを弾く
  (SELECT user_id FROM dataset.wrong_user_ids
    WHERE timestamp >= TIMESTAMP("{{start_time}}")
    AND timestamp <= TIMESTAMP("{{end_time}}"))
  GROUP BY user_id) AS A
LEFT JOIN
(SELECT user_id, device_name FROM dataset.user_devices) AS B
ON A.user_id = B.user_id
GROUP BY B.device_name

Redashの埋め込み記法である {{}} が増えて少々見づらくなりましたが、なんとか簡単にできました。Redashはこのフットワークの軽さが良いですね。

では、更に次のような要求が来た場合はどうでしょう?

  • 「期間内にサービスを訪れたユーザー」を元に調査している他の全てのQueryについても、同様に置き換えて欲しい。

…これは少々困りました。Query数が少ないプロジェクトなどは、そんなにコストがかからず置き換え可能なのかもしれません。しかし、誰でも好き勝手にQuery作成が可能なプロジェクトにおいては、どこでこの指標を利用しているのか、検索して見ていくしかありません。

もし「期間内にサービスを訪れたユーザー」の定義が一元管理できていたら、こんな手間は無くなると思いませんか?

RedashとBigQueryスクリプトを組み合わせて使ってみる

ではここで、BigQueryスクリプトを使った解決策を試してみましょう。

まずは「期間内にサービスを訪れたユーザー」をaccess_user_idsというTEMP TABLEに吐き出すプロシージャを、以下のスクリプトをBigQuery上で実行することで登録してみます。

CREATE PROCEDURE dataset.create_access_user_ids (start_date TIMESTAMP, end_date TIMESTAMP)
BEGIN
  CREATE TEMP TABLE access_user_ids AS
  SELECT * FROM dataset.play_user_ids
    WHERE timestamp >= start_date
    AND timestamp <= end_date
    AND user_id NOT IN
    (SELECT user_id FROM dataset.wrong_user_ids
      WHERE timestamp >= start_date
      AND timestamp <= end_date)
    GROUP BY user_id;
END;

これをCALL関数で呼び出すことで、RedashのQueryは以下のように書けるようになります。

CALL dataset.create_access_user_ids(TIMESTAMP("{{start_time}}"), TIMESTAMP("{{end_time}}"));

SELECT B.device_name, COUNT(DISTINCT access_user_ids.user_id) FROM
access_user_ids
LEFT JOIN
(SELECT user_id, device_name FROM dataset.user_devices) AS B
ON access_user_ids.user_id = B.user_id
GROUP BY B.device_name;

見た目がだいぶスッキリしましたね。しかも、今後定義変更があった場合にはプロシージャ側を編集するだけで、access_user_idsを使っているすべてのQueryが同じ指標に置き換わります。

注意すべきは、1クエリではなくスクリプトになるため、区切り文字「;」が必要になることと、プロシージャ内で作られるTEMP TABLEの名前がRedashからだと隠蔽されてしまうため、命名規則などで対応する必要があることです。

RedashからBigQueryスクリプトを使うことのメリット、デメリット 

プロシージャを利用することの最大のメリットは、RedashのQueryごとに指標がバラけることなく一元管理することができることです。他にも、BigQueryからRedashにSQLをコピペしてから、時間範囲の部分だけを {{start_time}} と {{end_date}} で置き換えて…なんていう手間を省くこともできます。最終的にはプロシージャや中間テーブルだけでクエリを作成して、RedashからはCALLするだけという運用にすれば、ダッシュボードツールに縛られることのない未来もありそうです。

デメリットは、BigQueryスクリプト内で書かれたSQLに関して、処理される推定バイト数が実行前にわからないことです。想定外に検索費用がかかってしまうこともあるかもしれません。これに関しては、たとえば先にdry runを実行して処理する推定バイト数を算出し、一定以上の費用がかかりそうなら実行しない、ということができるようになればありがたいですね。(現時点では、コンソール上からdry runをすることはできないようです。)

BigQueryスクリプトで可能になることはまだまだありそうなので、今後も追ってみたいと思います。

参考

cloud.google.com

cloud.google.com

cloud.google.com

RailsでTZ環境変数を設定するハックを不要にした話

TL;DR

環境変数を設定するだけでRuby on Railsサーバが10%高速化する(かもしれない)話』  でRailsを高速化させる素晴らしいハックが紹介されましたが。いまや有効なハックではなくなりました。

 

TZハックさん、ながい間(2日間)おつかれさまでした。

 

はじめに

 アカツキさまで技術顧問をさせていただいている小崎です。

 

このエントリは『環境変数を設定するだけでRuby on Railsサーバが10%高速化する(かもしれない)話』をRubyコミッタが読んだらこうなったというアンサーソングになっています。合わせてお読みください

 

TZ環境変数でTime.newが10倍近く速くなるのは素晴らしい発見ですが、コミッタとしてはTZなしでも速くなって欲しいなと思いました。だってめんどうだし。

 

現状分析

まず問題のテストプログラムを軽く分析してみましょう

 

% strace -c ruby ../t.rb
% time  seconds     usecs/call  calls     errors    syscall
------ ----------- ----------- --------- --------- ----------------
99.86     4.248080          11    400076         1 stat
0.03      0.001304           3       458       319 openat
0.02      0.000786           2       362         7 lstat
0.02      0.000682           4       184           read
0.01      0.000407           3       141           close
0.01      0.000390          11        34           mmap
...

Time.new 10万回に対し、statが40万回、つまりTime.new1回あたり4回のstatが呼ばれていることが分かります。

 ltraceでも確認してみます

% ltrace -c ./miniruby ../t.rb 
% time   seconds    usecs/call     calls function
------ ----------- ----------- --------- --------------------
 30.97   47.608101         158    300000 tzset
 25.24   38.804739         129    300000 localtime_r
 16.28   25.027547         120    207016 malloc_usable_size
 10.51   16.163021         161    100000 mktime
 8.31    12.781192         123    103912 calloc
 8.26    12.698693         123    103204 strlen
 0.18     0.282174          77      3645 memcpy
 0.13     0.204240          76      2674 malloc
 0.08     0.129064          80      1601 memcmp
..

 

おや?tzset(), lcoaltime_r() 呼び出しが30万回しか呼ばれておらず数が合いません。

 が、よく訓練された低レイヤー開発者は glibcの__tzfile_read() が呼ばれるのはtzset(3)が呼ばれたときだけだけども、tzset(3)はmktimeが呼ばれたときにも内部的に呼ばれていることを知っています。

つまり、Time.newするたびに、tzsetを3回、mktimeを1回呼んでおり、それがstat 4回につながります。

だいたい計算があってきました。

 

余談

 

tzset()とはTZ環境変数にもとづいてlibcのタイムゾーン初期化する関数ですが、TZ環境変数が未設定の場合は処理系依存です。

glibcの場合は /etc/localtimeのファイルに従いタイムゾーン情報を使って初期化します(まあ妥当ですよね)。ところがお節介なことに、glibcは /etc/localtime があとから変更された場合に備え、tzset()が呼ばれるたびに、 /etc/localtimeをstatして変更がないかチェックし、変更があれば再初期化するという実装になっています。

ほとんど使い道のないお節介のために遅くなっているのですから、余計なことをしてくれたものです。

 

 

localtime_r 問題(すでに解決済みでした)

 

さて、Rubyのtime.c をみると

#define LOCALTIME(tm, result) (tzset(),rb_localtime_r((tm), &(result)))

のような行があり、ようするに localtime_r()を呼ぶときには毎回 tzset()を呼んでいることがわかります。

これはlibcのマニュアルの

https://linuxjm.osdn.jp/html/LDP_man-pages/man3/ctime.3.html

POSIX.1-2004 によると、 localtime() はあたかも tzset(3) が呼ばれたかのように振舞うことが要求されているが、 localtime_r() にはこの要件はない。 移植性が必要なコードでは、 localtime_r() の前に tzset(3) を呼び出しておくべきである。

 

の記述に従っているためです。

・・・・と言いたいところですが、別に毎回呼べとは書いてありませんね。最初に一回だけ呼べばいいのでは?

 

とここまで調べたところで、実はこの問題はRuby 2.6で解決済みであったことに気がつきます。一番悔しいパターンです。元blogにあわせて Ruby 2.5で調査していたための不覚でした。

修正は以下のコミットです。

 

commit 4bc16691279e98ecdb3e19ff23902be671d46307
Author: nobu <nobu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e>
Date: Wed Jul 18 10:30:41 2018 +0000
reduce tzset

* time.c (rb_localtime_r): call tzset() only after TZ environment
variable is changed.

 

 

ところで、さきほどの localtime_r の man を注意深く読まれた読者は、あれ? localtime_r も tzset()を内部的に呼びだすっぽいこと書いてあるけど?と思うかもしれません。賢いですね。

 

関数 localtime() は、カレンダー時刻 timep を ユーザーが指定したタイムゾーンでの時刻要素別の表現へ変換する。 この関数は tzset(3) を呼び出したかのように振舞い、 大域変数 tzname に現在のタイムゾーンの情報を設定する。 また、timezone に協定世界時 (UTC) とローカル標準時との 時差の秒数を設定し、 一年の一部で夏時間が適用される場合は daylight に 0 が設定される。 返り値は静的に確保された構造体を指しており、この後で 日付や時刻に関する関数のいずれかが呼び出されると 上書きされる可能性がある。 localtime_r() も同様だが、 データはユーザーが用意した構造体に格納される。 この関数は tzname, timezone, and daylight を設定する必要はない。

 

実は glibc は localtime() は毎回 tzset() を呼び出すが、localtime_r() は最初の1回だけtzset() を呼び出すという最適化をしています。

 

具体的には glibcのソースコード中の time/tzset.c の以下のあたりで切り分けています。

tzset_internal()の引数が0になると最初の一回のみ初期化という意味、tp == &_tmbuf はlocaltime()のとき1、 use_localtime はlocaltime(),localtime_r()どちらでも1です(gmtimeなどのときに0になります)

 

/* Return the `struct tm' representation of TIMER in the local timezone.                                                                                  
   Use local time if USE_LOCALTIME is nonzero, UTC otherwise.  */
struct tm *
__tz_convert (__time64_t timer, int use_localtime, struct tm *tp)
{
  long int leap_correction;
  int leap_extra_secs;

  __libc_lock_lock (tzset_lock);

  /* Update internal database according to current TZ setting.                                                                                            
     POSIX.1 8.3.7.2 says that localtime_r is not required to set tzname.                                                                                 
     This is a good idea since this allows at least a bit more parallelism.  */
  tzset_internal (tp == &_tmbuf && use_localtime);

 

コメントに理由が書いてありますね。つまり、tzset() で毎回 /etc/localtimeをstatするのはタイムゾーン処理のためで、localtime_r()はタイムゾーンまわりの処理が不要だから、ということのようです。

 

 mktime問題

残った mktime 問題ですが、grepすると呼び出し箇所は簡単に見つかります。 

 

time.c 

static const char *
find_time_t(struct tm *tptr, int utc_p, time_t *tp)
{
(snip)

#if defined(HAVE_MKTIME)
    tm0 = *tptr;
    if (!utc_p && (guess = mktime(&tm0)) != -1) {
        tm = GUESS(&guess);
        if (tm && tmcmp(tptr, tm) == 0) {
            goto found;
        }
    }
#endif

 

これはラッキーです。mktime() があれば使うが、なければないで特に困らないという実装になっています。このような実装になっている理由については 

 

APIデザインケーススタディ ~Rubyの実例から学ぶ。問題に即したデザインと普遍の考え方』 

 

という本に解説があるので興味がある人は一読するといいと思います。このケースに限らずRubyのいろいろなAPIデザインの知見がつまったとてもいい本です。

 

要約すると mktime() があれば使うが、なかったり信用できなかったりした場合は、64bit integerの全空間を二分探索でlocaltime_r() を使って力業で探索するということです。

 

 ふつうに考えれば、前者のほうが明らかに速いのですが、今回我々は、mktimeは毎回tzset() を呼ぶが、localtime_r () は最初の一回のみ tzset() を呼ぶ。そして tzset() はとても遅い、ということを学びました。

つまり、毎回力業探索をしたほうが速いと言うことは十分ありえるとことです。やってみましょう!

 

 結果測定

t = Time.now; 100000.times { Time.new(2019) }; Time.now - t

 

のケースで
Ruby 2.6:                                          0.387sec
Ruby 2.6 + TZ環境変数:                   0.197sec
Ruby 2.7開発ツリー:                        0.162sec
Ruby 2.7開発ツリー+TZ環境変数:   0.165sec

 

と約2倍の高速化を達成し、かつTZ環境変数ありなしで速度が同等になりました。

 

 さらに検討をすすめる

 

じゃあ、削ろうかなと準備していたところに、オリジナル作者のakrさんから

 

mktime が tzset する副作用は意図して使っていたわけじゃないけど、ユーザから見ると /etc/localtime の変化に自動的に追従する挙動を提供していたと思う。
追従しない挙動に変えるのなら、代替手段が存在した方が説明しやすいと思う。
誰も気にしない可能性もあるけど。

 

というコメントをいただきました。その後しばらくコミッタ同士で議論して、

ENV['TZ'] = nil

したら、次のTime.newはtzsetを呼びなおすという挙動にすることにしました。これはRuby 2.6でのlocaltime_rの高速化をした

 

commit 4bc16691279e98ecdb3e19ff23902be671d46307
Author: nobu <nobu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e>
Date: Wed Jul 18 10:30:41 2018 +0000
reduce tzset

* time.c (rb_localtime_r): call tzset() only after TZ environment
variable is changed.

 

において、TZ環境変数を設定したらtzset()を呼びなおすという実装がすでに入っていたため、それを素直に拡張して、ENV['TZ'] = nil のときは元々nilだったとしてtzset()をしなおす(/etc/localtimeが変更されていればtimezoneは変わる)という挙動にしました。

  

  

まとめ

今回議論した mktime削除パッチは

https://github.com/ruby/ruby/commit/4d7a6d04b2c71aabb9d6e619f4405887806a5be8

としてコミット済みのため、とくに問題なければRuby 2.7に含まれるはずです。

 

よって要約すると

  • Ruby 2.6ではTime.newが3倍ぐらい速くなった
  • Ruby 2.7ではさらに2倍ぐらい速くなる(予定)
  • そのため、2.7以降では、TZ環境変数を設定する必要はまったくない

 ということになります。

 

アカツキさまのおかげで全世界のRailsアプリが高速化されました。このような改善につながるバグ報告は大変ありがたく、とても素晴らしい貢献だと思います。