wp-cron を調査してみる

wordpress に wp-cron という機能が存在するのですが一体どんなものなのでしょうか。一見奇妙なことですが置いてあるパスは wordpress コンポーネントの最上位である公開されているディレクトリに存在します。
今回はこの wp-cron.php の内容を確認してみることにします。

調べてみる

検索すると驚くことに、本家の解説とかがあまりないようです。
なのでとりあえず wordpress のソースコードを DL してきて wp-cron.php を覗いてみることにしてみました。
今回参照したのは調査時点で最新版である 4.9.7 となります。

コメントヘッダ部分

まずファイルを開くと冒頭にコメント部分記載されているので確認してみます。

/**
 * A pseudo-CRON daemon for scheduling WordPress tasks
 *
 * WP Cron is triggered when the site receives a visit. In the scenario
 * where a site may not receive enough visits to execute scheduled tasks
 * in a timely manner, this file can be called directly or via a server
 * CRON daemon for X number of times.
 *
 * Defining DISABLE_WP_CRON as true and calling this file directly are
 * mutually exclusive and the latter does not rely on the former to work.
 *
 * The HTTP request to this file will not slow down the visitor who happens to
 * visit when the cron job is needed to run.
 *
 * @package WordPress
 */

超訳すると

  • 基本的にはユーザがサイトにアクセスしたことをフックしスケジュールされた処理を実施します
  • もし規定時間内にユーザからのアクセスがなければ直接またはサーバのcron経由でスケジュールされた処理を実施します
  • DISABLE_WP_CRONによってユーザアクセスのフックを無効化することができます
  • その際にも直接叩けば実施は可能です
  • ユーザレスポンスを遅くするようなことはないですよ

またファイル自体もそこまでではないので目を通してみます。

初期化部分

ignore_user_abort( true );

いきなり知らない関数が出てきました。wordpress のグローバル関数かと思ったのですが、恥ずかしながらphp標準の関数のようです。初めてみました。

この関数を呼ぶことでユーザが途中でブラウザを閉じたりして接続が切れたとしても、この wp-cron.php のスクリプトは最後まで実施されることが保証されます。
中には非常に時間が掛かる処理があることもあるというような対応でしょうか。
余談ですが php はユーザの切断をスクリプトの途中で検知することが connection_status 関数を利用することで可能です。

if ( ! empty( $_POST ) || defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) ) {
    die();
}

/**
 * Tell WordPress we are doing the CRON task.
 *
 * @var bool
 */
define( 'DOING_CRON', true );

wp-cron を叩くときには post リクエストである必要があるようです。
また DOING_AJAX という wordpress 標準の ajax リクエストまたは、 DOING_CRON という cron リクエストであれば無視します。
ちなみに DOING_CRON というのは すぐその下で定義されており、本ファイルの実行を再帰的に実行しないような処理となっています。

if ( ! defined( 'ABSPATH' ) ) {
    /** Set up WordPress environment */
    require_once( dirname( __FILE__ ) . '/wp-load.php' );
}

環境設定が済んでいなければ、諸々の環境ファイルを読み込みます。

ローカル関数部分

続いてその下の関数をまるっと見ましょう。

/**
 * Retrieves the cron lock.
 *
 * Returns the uncached `doing_cron` transient.
 *
 * @ignore
 * @since 3.3.0
 *
 * @global wpdb $wpdb WordPress database abstraction object.
 *
 * @return string|false Value of the `doing_cron` transient, 0|false otherwise.
 */
function _get_cron_lock() {
    global $wpdb;

    $value = 0;
    if ( wp_using_ext_object_cache() ) {
        /*
         * Skip local cache and force re-fetch of doing_cron transient
         * in case another process updated the cache.
         */
        $value = wp_cache_get( 'doing_cron', 'transient', true );
    } else {
        $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", '_transient_doing_cron' ) );
        if ( is_object( $row ) ) {
            $value = $row->option_value;
        }
    }

    return $value;
}

wp_using_ext_object_cache という関数は wordpress がキャッシュを使うかどうかの設定を取得します(この関数は引数を与えると setter として機能するらしくややこしい)
wp_cache_get は文字通り cache からデータを取得します。第三引数が true となっているので永続キャッシュから値を取得してローカルキャッシュを上書き更新します。
また第二引数までがいわゆるキーとして認識されますが、どっちかというと第二引数が一番上部にあるような概念としてのキー、第一引数がその下にぶら下がるキーとして認識するとわかりやすいと思います。
今回は transient -> doing_cron という変数値のキャッシュを取得します。

また補足ですが ObjectCache を参照すると、wordpress はデフォルトではキャッシュの生存期間はリクエストのみとなります。永続キャッシュ(persistent cache)を利用するためにはプラグインをインストールする必要があります。
加えて transients API という cache の有無をユーザが意識しないで利用できるインタフェースも存在するようです。

つまり上記は cron_lock のためのデータをキャッシュが有効であればキャッシュ機構から取得し、無効であればデータベースをから取得しているような実装となります。

cron取得部分

if ( false === $crons = _get_cron_array() ) {
    die();
}

まず cron 設定を取得します。上記の関数はさらに get_option という関数を内部で呼び出します。
途中キャッシュなどから option の取得を実施しますが、option 設定の大本は wp_options というテーブルで管理されているようです。
これも実は wordpress で汎用的に設計されている OptionsAPI という機能を用いています。
OptionsAPI を用いることで wordpress 側がデータを取得したり保存したりということを自動的にやってくれるため、ユーザは新しい機能を考えるたびに自分でデータをどこに保存するかということを考えなくてよくなります。

実行時間判定部分

cron 処理を実施するかどうかを下記の処理で判定しています。

$keys     = array_keys( $crons );
$gmt_time = microtime( true );

if ( isset( $keys[0] ) && $keys[0] > $gmt_time ) {
    die();
}

この処理を解釈するために参考のため適当に構築された wordpress から wp_options テーブルに保存されている内容をデシリアライズしたものを一部引っ張ってきました。下記のような構成になっています。

array(7) {
  [1532311725]=>
  array(1) {
    ["wp_privacy_delete_old_export_files"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(6) "hourly"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(3600)
      }
    }
  }
  ...
}

キーをスケジュール時刻として実行のためのメタ情報で構成されています。
処理では実行判定のため現在時刻を取得して、配列の先頭のキー値と時刻の比較を行っています。ここから推測されるのはどうやら cron の処理は実行予定時間の早いに整列して並べられているようです。先頭の一番実行時間の早いタスクと現在時刻の比較を行うことで実行する必要があるかどうかを判定していると解釈できます。

競合解決部分

下記の処理はロックファイルと自分のコンテキストの時間を比較し、同時に cron 処理が起動しないように競合状態の解決を行います。
ここで先程少し細くした transient API が出ています。transient API はキャッシュ処理を透明的に処理するだけなので、基本的に内部的に何らかの機構で値が保存されるくらいに理解しておければ十分だと思います。

unix timestamp を格納したものがロックファイルの実態であり、その保存のキーは doing_cron として保存されています。

// The cron lock: a unix timestamp from when the cron was spawned.
$doing_cron_transient = get_transient( 'doing_cron' );

// Use global $doing_wp_cron lock otherwise use the GET lock. If no lock, trying grabbing a new lock.
if ( empty( $doing_wp_cron ) ) {
    if ( empty( $_GET['doing_wp_cron'] ) ) {
        // Called from external script/job. Try setting a lock.
        if ( $doing_cron_transient && ( $doing_cron_transient + WP_CRON_LOCK_TIMEOUT > $gmt_time ) ) {
            return;
        }
        $doing_cron_transient = $doing_wp_cron = sprintf( '%.22F', microtime( true ) );
        set_transient( 'doing_cron', $doing_wp_cron );
    } else {
        $doing_wp_cron = $_GET['doing_wp_cron'];
    }
}

/*
 * The cron lock (a unix timestamp set when the cron was spawned),
 * must match $doing_wp_cron (the "key").
 */
if ( $doing_cron_transient != $doing_wp_cron ) {
    return;
}

$doing_wp_cron というグローバル変数の有無で処理が分岐するようですが、通常存在しないケースになります。
‘doing_wp_cron’ というGETパラメータの有無によってもにも処理が分岐します。
もしGETパラメータが存在しない場合、$doing_cron_transient が存在するようであれば他の cron 処理が先行して動作しているということでタイムアウトチェックを行います。もしタイムアウトに達していないようであれば何もせずに終了します。

タイムアウトしている、または先行している cron が存在しないようであればロックファイルを transient API を通して作成します。
こちらの場合は、一番下部にある条件には必ず一致しないため、処理が実行されます。

しかしながら GETパラメータが付与されている場合に関しては パラメータの値と transient API 経由で保存してある ロック値が一致しなければ実行されません。
このファイルを読むだけでは、このGETパラメータが付与されているパターンがどういうパターンなのかを理解するのは難しいです。
なぜならコールする際にすでに実施されている cron のタイムスタンプ値を把握している必要があるからです。システム上何らかのつながりがある状態で実施されるのでしょうか。

また 先行するcronロックも存在しないし、GETパラメータも存在しない場合に関しては get_transient が false を返却するため結果的には処理が実行される形になります。

cron処理起動部分

最後に残りのコード部分を見ていきます。

foreach ( $crons as $timestamp => $cronhooks ) {
    if ( $timestamp > $gmt_time ) {
        break;
    }

    foreach ( $cronhooks as $hook => $keys ) {

        foreach ( $keys as $k => $v ) {

            $schedule = $v['schedule'];

            if ( $schedule != false ) {
                $new_args = array( $timestamp, $schedule, $hook, $v['args'] );
                call_user_func_array( 'wp_reschedule_event', $new_args );
            }

            wp_unschedule_event( $timestamp, $hook, $v['args'] );

            /**
             * Fires scheduled events.
             *
             * @ignore
             * @since 2.1.0
             *
             * @param string $hook Name of the hook that was scheduled to be fired.
             * @param array  $args The arguments to be passed to the hook.
             */
            do_action_ref_array( $hook, $v['args'] );

            // If the hook ran too long and another cron process stole the lock, quit.
            if ( _get_cron_lock() != $doing_wp_cron ) {
                return;
            }
        }
    }
}

if ( _get_cron_lock() == $doing_wp_cron ) {
    delete_transient( 'doing_cron' );
}

die();

至ってシンプルで現在時刻と比較し、実施すべき処理をイテレートしながら実行していきます。途中で他のタスクによって lock 値が更新された場合は途中中断するような処理もあります。
最終的に処理が完了した時点で lock 値を削除して完了となります。

終わりに

wp-cron.php を読み解くことができ、加えて wordpress のキャッシング機構なども確認することができました。
実装を確認したのは初めてですが、なかなかパンチがきいたコードですね。wordpress はもう長期的なプロダクトですが、歴史的な流れからか様々な概念や設計が同居していてカオス感が漂っているような。
特に _get_cron_lock に関しては transient API が提供している機能と同等のことをスクラッチでやっているように感じます。
これは例えば transient API が出る以前に実装されたり、はたまた transient API を使うと動かなかったりするのか、単純に実装した人が何故かそう作ってしまっただけなのか・・・気になりますね。

また wp-cron はユーザのリクエストをフックしているとのことでしたが、この処理を単純にユーザリクエストに乗っけてしまうと当たり前ですがレスポンスが待ちきれないほど遅くなることもあると思います。
冒頭のコメントにもユーザのリクエストには影響を与えないとの記載があったので、おそらく直接ユーザリクエストのプロセスに乗っかるわけではなく何らかの中間レイヤの概念が存在すると思われます。
機会があればこの点についても調べてみたいと思います。

1 個のコメント

  • コメントを残す

    メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

    このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください