hnwの日記

MySQLのFLOAT型を使う理由が見つからない件

MySQLのデータ型としてFLOAT型という型があるのですが、これを採用するのは混乱の元ではないか?と感じたので、その詳細を紹介します。


そもそもこの話のきっかけは「MySQLで6桁までの小数点を丸めずに扱うならFLOAT型を使うべき理由」という記事が目に止まったことです。それなりに人気を集めている記事のようですが、私の読んだ限りではFLOAT型を使うだけの根拠が文中から読み取れず、さらに類似する一次情報や英語記事が全く見つからなかったので、真偽が怪しい情報だと感じました。


その後、MySQL上で実験したりCソースコードを読んでみたりした結果、私の得た結論は真逆のものになりました。MySQL警察の方や浮動小数点数警察の方、追試や反論など頂けると助かります。

MySQLのFLOAT型とは

MySQLのFLOAT型は原則としてIEEE754浮動小数点数単精度型(32bit)で実現されます*1。これ自体はMySQLマニュアルからも読み取れる内容です。

FLOAT および DOUBLE 型は概数値データ値を表します。MySQL は、単精度値には 4 バイトを、倍精度値には 8 バイトを使用します。


https://dev.mysql.com/doc/refman/5.6/ja/floating-point-types.html


名前としてもC言語のfloat型に対応していそうな名前ですから、これ自体に特に違和感は無いと言えるでしょう。

不思議な挙動(1):+0すると見た目の値が変わる

ところで、MySQLでFLOAT型を使うと説明しづらい結果になることがあります。実際に試してみましょう。下記実験に利用したMySQLのバージョンは5.7.20です。

mysql> CREATE TABLE a (f FLOAT, d DOUBLE);
Query OK, 0 rows affected (0.03 sec)

mysql> INSERT INTO a VALUES(0.9,0.9);
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM a;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


FLOAT型とDOUBLE型のカラムを作り、両者に0.9を代入してみました。SELECTしてみると、それぞれのカラムの値は0.9と表示されます。


ところが、それぞれに0を足すと不思議な結果が返ってきます。

mysql> SELECT f+0,d+0 FROM a;
+--------------------+------+
| f+0                | d+0  |
+--------------------+------+
| 0.8999999761581421 |  0.9 |
+--------------------+------+
1 row in set (0.00 sec)


なんとFLOAT型の値の方が0.9では無くなってしまいました。これは+0したせいで値が変わったわけではなく、元々格納されていた値が表示されているだけです。「0.8999999761581421…」はIEEE754単精度浮動小数点数で0.9に一番近い数ですので、FLOAT型では単精度で値が保存されている証拠とも言えるでしょう。


一方、d+0が0.9と表示されているのはdの値が倍精度浮動小数点数で0.9に一番近い数だからです。MySQLはDOUBLE型の値を表示する際、取り得る表現の中で最短となる10進表現で表示します。このことはMySQLマニュアルにも記述があります。

dtoa ライブラリでは、次のプロパティーを使用した変換が提供されます。D は DECIMAL または文字列表現を含む値を表し、F はネイティブバイナリ (IEEE) 書式の浮動小数点数を表します。


F -> D の変換は、最大限の精度で実行され、読み取り時に F が生成されるもっとも短い文字列として D が返され、IEEE で指定されているネイティブバイナリ形式でもっとも近い値に丸められます。


https://dev.mysql.com/doc/refman/5.6/ja/type-conversion.html


そんなわけで、10進小数を格納する場合にはFLOAT型もDOUBLE型も丸め誤差を含む値になるのですが、最初に紹介したQiita記事の読者がそれを読み取れるかは疑問です。もしかするとFLOAT型のことを10進6桁までをピッタリ扱える便利な型だと勘違いしてしまう人さえいるかも知れませんが、そんなわけはありません。

不思議な挙動(2):INSERTした値がWHERE句の検索条件に使えない

先ほどのテーブルを使ってもう少し実験してみます。今度はWHERE句で浮動小数点型を持つレコードを検索してみましょう。

mysql> SELECT * FROM a WHERE f=0.9;
Empty set (0.00 sec)

mysql> SELECT * FROM a WHERE d=0.9;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


fもdもINSERT文で0.9を入れたので、当然WHERE句の検索でも0.9が使えるだろうと考える人がいるかもしれません。ところが、実際に0.9を検索してみるとFLOAT型の方では1件もマッチしません。


この原因は、浮動小数点数絡みの演算は全てDOUBLE型で計算されるため*2です。fの値は「0.8999999761581421…」となりますが、DOUBLE型としての0.9は「0.900000000000000022…」となるため、異なる値だと判断されてしまうのです。一方で、DOUBLE型のカラムはINSERTもSELECTも全てDOUBLE型で行われるので、期待通りにレコードを取り出すことができます。


どうしてもFLOAT型の値を検索で引っかけたい場合は、欲しいFLOAT型の値をDOUBLE型にキャストしたときの浮動小数点数リテラルを渡す必要があります。実用的とは言いがたいですね。

mysql> SELECT * FROM a WHERE f=0.8999999761581421;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


まとめると、MySQLでは多くの浮動小数点演算がDOUBLE型で行われるので、FLOAT型の利用には注意が必要です。FLOAT型からDOUBLE型へのキャストの性質にピンとこない人が利用すると混乱してしまうかもしれません。

不思議な挙動(3):同じ値に見えても別の値のことがある

さらに別の例を紹介します。テーブルbを作って2レコードをINSERTしてみました。

mysql> CREATE TABLE b (f1 FLOAT, f2 FLOAT);
Query OK, 0 rows affected (0.04 sec)

mysql> INSERT INTO b VALUES(0.9,0.8999996);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO b VALUES(0.9,0.89999996);
Query OK, 1 row affected (0.01 sec)

mysql> SELECT * FROM b;
+------+------+
| f1   | f2   |
+------+------+
|  0.9 |  0.9 |
|  0.9 |  0.9 |
+------+------+
2 rows in set (0.00 sec)


0.9および0.9に近そうな数2種類をINSERTしてみたところ、全て0.9として表示されました。

mysql> SELECT * FROM b WHERE f1=f2;
+------+------+
| f1   | f2   |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)

mysql> SELECT f1+0,f2+0 FROM b;
+--------------------+--------------------+
| f1+0               | f2+0               |
+--------------------+--------------------+
| 0.8999999761581421 | 0.8999996185302734 |
| 0.8999999761581421 | 0.8999999761581421 |
+--------------------+--------------------+
2 rows in set (0.00 sec)


ところが、f1とf2が等しいレコードをSELECTしてみると1レコードしかヒットしません。「0.89999996」と「0.9」とはFLOAT型として同じ値として格納されるのですが、「0.8999996」は異なる値として解釈されることが原因です。


しかし、比較したときは異なる値であるにも関わらずSELECT * FROM bしたときの表示が同じなのは問題です。FLOAT型の値をSELECTで取り出し、取り出した値をUPDATE文で再代入した場合に元の値と変わってしまうことになりますから、混乱の原因になりかねません。


これはMySQLソースコードstrings/dtoa.c中のmy_gcvt関数のバグだと思われます。FLOAT型の値を表示する際、my_gcvt関数の内部で表示精度を決定する処理があるのですが、FLOAT型の場合はFLT_DIG桁となります。

  res= dtoa(x, 4, type == MY_GCVT_ARG_DOUBLE ? width : MY_MIN(width, FLT_DIG),
            &decpt, &sign, &end, buf, sizeof(buf));


このFLT_DIGは「C言語のfloat型で精度を失わずに表現できる10進桁数」を意味する定数で、通常6となります。しかし、この桁数ではFLOAT型として異なる数を同じ表記にしてしまい、適切とは思えません。本来的にはFLT_DIG+2とすべきで、こうすればFLOATとして異なる値を10進でも異なる表記で表示できるはずです。


これが本当にバグなのか仕様なのかはわかりませんが、混乱の元であるのは間違いないところでしょう。


また、この問題が今まで修正されていないこと自体がFLOAT型の利用リスクだと言えるかもしれません。利用者の多さで品質を担保できることが有名OSSの強みであるはずですが、逆に言うとあまり使われていない機能ほどバグは残ってしまうわけです。FLOAT型の採用を検討する場合、他の人がどれほどFLOAT型を採用しているのか?という観点も重要かと思います。


ちなみに、このような問題はDOUBLE型では発生しません。DOUBLE型の場合は、SELECTで取り出した10進表現と内部的な表現とが1:1対応しており、内部的に異なる値であれば必ず10進表現も異なっています。

まとめ

MySQLのFLOAT型の性質および問題点を指摘しました。

  • FLOAT型で小数を扱うと丸め誤差が発生するが、そのことを知らずに採用するのは危険
    • f+0などとするとDOUBLE型として評価され、誤差を含んだ値が観測できる
  • FLOAT型の値は暗黙的にDOUBLE型にキャストされることがあり、利用者がキャストの挙動を知らないと期待通りの処理を行えないことがある
  • FLOAT型をSELECTした結果と内部表現とは1:1対応していないが、これはバグだと考えられる
    • そもそもFLOAT型の利用実績が少ない疑いがあり、他の型ほどバグが出きっていない可能性もあるのではないか


唯一FLOAT型を採用するメリットとしてサイズが小さいことが挙げられますが、大抵の状況では上記の問題によるデメリットの方が大きいのではないでしょうか。


今回指摘した問題を回避するために下記のような案が考えられます。

  • DECIMAL型を使う
  • 与えられた小数を内部的には1000分率で扱うなどして、MySQL上では整数として格納する
  • 与えられた10進表現の小数を文字列として格納する


どれが最適かは状況次第だと思いますが、私個人は2番目の案が好みです。また、浮動小数点数に十分詳しい場合はDOUBLE型を採用してもよいでしょう。

*1:FLOAT(25)のように精度指定した場合は倍精度型になることもあります

*2:MySQLマニュアル上でこれに関する記述は見当たりませんが、実質的には仕様に近いように感じています

とあるPHP拡張のCI事情

PHP Advent Calendar 2017の3日目です。公開が遅くなってしまいました、ごめんなさい。


筆者はphp-timecopというPHP拡張を5年間ほどメンテナンスしています。このPHP拡張はCで書かれているのですが、Travis CIやAppVeyorなど複数のクラウドCIサービスを組み合わせてテストを回しています。本稿では各サービスをどのように利用しているか、それぞれの使い分けや特徴などを紹介していきたいと思います。

背景

本題に入るまえに、なぜPHP拡張でCIが必要か、という話を紹介します。


C言語で書かれたプログラムはポータビリティが高いような印象を持つ人が多いかもしれませんが、実際はむしろ逆で、ポータブルに書くのが難しい言語の1つです。Cではシステムコールやライブラリ関数を直接呼び出すのが普通ですが、これらは環境によって存在しないこともありますし、細かい挙動が異なることも珍しくありません。また、コンパイラごとの方言も存在します。


こうした事情から、C言語で書かれているOSSでは環境の差を埋めるためにAutoconf/Automake/Libtoolを利用するのが定番ですが、それでも未知の環境でリンクに失敗することも珍しくありませんし、環境の差を検出するためのconfigureスクリプトがバグで停止するようなことも起こりうるので、各環境でテストしておくに越したことはありません。


さらに面倒なことに、PHP拡張ではPHP本体のC APIを呼び出したり本体で定義されている構造体を操作したりすることがあるので、PHPの特定バージョンでだけ動かない、というようなバグがありえます。


要するに、PHP拡張をメンテナンスするためには、様々なOS、コンパイラPHPバージョンの組み合わせでテストをし続ける必要があるのです。ひとことで言えば地獄のような状況 ポジティブな言いかたをすると、とてもCIしがいのある題材だと言えるでしょう。

各CIサービスの特徴と実際の使い方

php-timecopで現在利用しているCIサービスは下記の4つです。


いずれのサービスも無料プランで利用しています。筆者自身がこのPHP拡張でお金を得ているわけではないのでお金を払ってまでCIする気にはならないですし、各社さんOSSへの応援の意味も込めて公開リポジトリのCIを無料で提供していると思うので、ありがたく使わせてもらっています。


以下、個別のサービスについて紹介します。

Travis CI

言わずと知れた最大手クラウドCIサービスです。

  • LinuxUbuntu)とmacOSの2つのOS上でCIできる
  • PHP 5.4から7.2までの6バージョンがビルド済み、設定で簡単に切り替えられる(記事執筆時)
  • 無料プランでは最大5並列でジョブが走る


無料プランでもかなりの大盤振る舞いなので、OSSでCIしたい場合は最初の選択肢になるでしょう。


また、PHP拡張のCIに関していうとPHPの全マイナーバージョンの最新版が提供されているのが素晴らしい点です。PHPではマイナーバージョンアップのタイミングで内部構造の大変更が入ることが珍しくない(5.3→5.4など)ので、全マイナーバージョンでテストするのは非常に重要です。それが設定を少し書くだけで簡単に対応できるのは本当に助かります。


実際、筆者はLinux環境上で7つのPHPバージョン*1のテストを走らせています。


Travis CIではビルド済みのPHPバイナリがZTS(Zend Thread Safe)有効になっているのが特徴的です。大抵の人はPHPNTS(Non Thread Safe)で利用しているはずなので、ある意味テスト環境として不適切と言えなくもありません。一方で、ZTSでだけビルドが通らないようなバグもありうるので、個人的にはテスト環境としてありがたいと感じます。

AppVeyor

Windows環境のCIが必要ならほぼ唯一の選択肢*2と言えるでしょう。

  • Windows環境でのビルドに対応
    • Visual C++も各バージョンがインストール済み
    • chocolateyやMSYS2-pacmanがインストール済みなので追加パッケージのインストールも簡単


PHPWindows版はバージョンごとに要求するVisual C++のバージョンが異なるため、自分でCI環境を準備するのは比較的面倒です。その意味で、クラウドCIサービスが利用できるのは良いですね。


php-timecopでは現在PHP 5.4、PHP5.6、PHP 7.1の3バージョンでテストを行っています。

Wercker

最近Oracleに買収されたクラウドCIサービスです。「わーかー」と読むらしいですね。CI環境を整えた当時、Dockerコンテナの扱いが一番楽そうだったので利用することにしました。


php-timecopではCentOSと32bit Ubuntuの2環境でのテストを走らせるのに利用しています。


CentOSでのテストは、specファイルによるバイナリパッケージ作成の動作確認のために行っています。通常Linuxディストリビューションごとのテストを行う必要性はあまり無いと思いますが、パッケージングのテストや、バイナリパッケージのデプロイなどの必要性があればディストリビューションの数を増やす必要があるでしょう。


一方、32bit Ubuntuでのテストは拡張自体のテスト目的です。PHPは32bit環境と64bit環境とで整数のサイズが変わるなど言語レベルで影響があるので、32bit環境でのテストには十分意味があります。

CircleCI

クラウドCIサービスの中でも有名どころの1つです。最近大幅バージョンアップしてコンテナベースになったと聞きましたが、筆者はまだ古いバージョンを利用しています。


現状ではNTS環境でのテストの意味で走らせているだけで、あまり活用できていません。

実際にCIは有用か?

PHP拡張でのCIを回してみて個人的には非常に有用だと感じています。客観的証拠のようなものは挙げにくいのですが、メリットだと思われる内容を何点か紹介します。


たとえば、新機能を実装するときなど、大変更の際はCIのおかげで特定バージョンでのバグを未然に防げることは珍しくありません。これまでの経験としてはOSやコンパイラの差で怒られることはそれほど多くなく、PHPの特定バージョンで動かないバグが多いので、まずはTravis CIで複数PHPバージョンでのテストを走らせるのがオススメです。


また、しっかりテストできているとPull Requestを受け取ったときの安心感も大きいので、その意味でもオススメです。


PHP 5時代はZTS環境で必要な「おまじない」の付け忘れにCIで気づく、ということが頻繁にありましたが、PHP 7になっておまじないが減ったので、最近はあまりそんなことはありません。


変わったところでは、Windows上でテストが失敗すると思ったらWindowsPHPのバグ(仕様?)だった、ということがありました。(参照:「PHPのsleep関数とusleep関数の挙動を調べてみた」)

まとめ

php-timecopのCIでは複数サービスを活用しており、有用だと感じているという話を紹介しました。また、テストの軸が複数あることも紹介しました。


何個もCIサービスを使うのは若干趣味的に思われるかもしれませんが、LinuxWindowsの両OSに対応するためにTravis CIとAppVeyorの2サービス併用、くらいまでは十分実用的だと思います。


PHP拡張を公開しているけどテストしてないという方はphp-timecopのリポジトリから設定ファイルをコピーすれば簡単に導入できますので、ぜひお試しください。

*1:PHP 5.4から7.2まで6バージョン、およびmasterブランチ

*2:本来ならVSTSが本命だと思うんですが、イマイチ知名度が低い気がします…

PHPカンファレンス2017でphp-timecopをPECLに登録した話をしました

10月8日に開催されたPHPカンファレンス2017でLT発表をしました。以下が発表資料です。



PECLは登録までの敷居が高そうな印象があったので、以前は自作のPHP拡張を登録するなんて考えもしなかったのですが、やってみたら案外あっさり登録できた、という内容を紹介しました。詳細の手順については過去の記事「php-timecopをPECLに登録しました」に書きましたので、合わせてご確認ください。


また、PECLに登録すると自動的にRemi's RPM repositoryに入ること、さらに運が良ければ(?)Fedoraリポジトリにも入ることを紹介しました。これはRemiさんがPHPコアコミッターであるだけでなく、RedHat社員でもあるという事情もあると思いますが、何にせよやってみないとわからなかったことかなと思います。


余談ですが、PECLに登録してからissueが増えたという話を紹介しましたが、実は難しいissueが増えて私自身が捌き切れていない、というオチがあったりします。特に「PHP 7.2.0RC3でPHPの内部実装を変えたらphp-timecop動かなくなったからよろしくな」というissueの難易度が高く現在進行形で悩んでいるのですが、7.2.0リリースまでに解決できるよう頑張ります。

他のセッションについての感想など

今年のPHPカンファレンスも刺激をたくさんもらった集まりでした。個人的には内山さんの「OPcacheの最適化器の今」が興味深い内容でした。私もPHP 5.5の頃に似た内容の発表(「Zend OPcacheの速さの秘密を探る」)をしたことがあるのですが、当時は未実装だったconstant propagationやdead code eliminationといった最適化が実装されているというのは知りませんでした。


発表後に「われわれの普段書くコードの性能改善につながるのか?」というような質問があり、内山さんは「大抵の場合WebアプリケーションのボトルネックはDBアクセスなどになるのではないか、その意味ではOPcacheの最適化が実コードに与える影響は少ないと思われる、という回答をされていました。


大抵の現場で起きている問題を解決する特効薬にはならない、という意味では非常に正しい回答なのですが、現実のコードに対して意味のある最適化ではない、という風に捉えてしまった人もいたような気がします。しかし、特にconstant propagationは身近なコードでも有用な最適化です。たとえば下記のようなコードを書いたことがある人は多いのではないでしょうか。

<?php
$seconds_per_minute = 60;
$minutes_per_hour = 60;
$hours_per_day = 24;
$seconds_per_day = $seconds_per_minute * $minutes_per_hour * $hours_per_day


このようなコードは読みやすさの観点では意味がありますが、これまでのPHPでは性能面で僅かに不利になっていたわけです*1。これがきちんと最適化されるのであれば、安心して読みやすさ優先でコードを書けるわけですから、多くのPHPユーザーにとって嬉しい内容であるはずです。


それ以外の方々の発表も楽しかったですし、懇親会も2次会も非常に盛り上がった気がします。毎度おなじみの方も久々の方ともお会いでき、色々なお話が聞けて良かったです。php-timecop使ってます、という話も複数の方から聞けて非常に参考になりました。ありがとうございます。


最後になりますが、スタッフの皆様、今年もおつかれさまでした。これほどの規模になると運営は本当に大変だと思いますが、来年も期待しておりますのでよろしくお願いいたします。

*1:このコードを最適化するためには各行間へのジャンプ命令が存在しないことを知らないといけませんが、過去のPHPではそのような解析を行っていませんでした

PHPのmysqlndの圧縮プロトコルについてのメモ

PHP+PDO+MySQLの環境では、PHP-MySQL間の通信についてzlibを使った圧縮プロトコルを利用することができます。この機能は、DBサーバのCPU利用率に十分余裕があり、かつPHP-MySQL間のネットワーク帯域が逼迫している状況で有用です。

MySQLの圧縮プロトコルとそのマニュアル


PHP+MySQLの環境で、圧縮プロトコルは下記のようなコードで利用できます。

<?php
    $options = [
        PDO::MYSQL_ATTR_COMPRESS => true
    ];
    $db = new PDO($dsn, $user, $pass, $options);


MySQLドライバとしてmysqlndを利用している場合*1PHP 5.3.11(2012年4月リリース)以降であれば圧縮プロトコルに対応しています。このことはPHPマニュアルにも下記の通り記載があります。

PDO::MYSQL_ATTR_COMPRESS (integer)


Enable network communication compression. This is also supported when compiled against mysqlnd as of PHP 5.3.11.


https://secure.php.net/manual/en/ref.pdo-mysql.php#pdo.constants.mysql-attr-compress


ただし、この記述は2015年9月頃に追加されたものです。それまでマニュアル上では「PDO_MYSQL+mysqlndは圧縮をサポートしていない」という記述しかありませんでした(2010年頃の記述で、当時は正しかった)。


2015年8月頃に筆者が業務で関わっていた環境で圧縮プロトコルを導入したのですが、マニュアルに明記されていない機能を商用環境で使うのは気が引けたので、マニュアルにバグがあるよ!というバグレポをPHP本家に投げてみました。その甲斐あってか、しばらくしてマニュアルが修正されました。これで今後は安心して利用できるというわけです。

mysqlndの圧縮プロトコルの実装

PHP 7.1.7のソースコード上で、mysqlndの圧縮プロトコルの実装部分を探してみました。


すると、ext/mysqlnd/mysqlnd_net.cext/mysqlnd/mysqlnd_protocol_frame_codec.cに同じようなコードが見つかります。

MYSQLND_METHOD(mysqlnd_pfc, encode)(zend_uchar * compress_buffer, size_t * compress_buffer_len,
                                    const zend_uchar * const uncompressed_data, const size_t uncompressed_data_len)
{
(略)
    error = compress(compress_buffer, &tmp_complen, uncompressed_data, uncompressed_data_len);
(略)
}


このcompress()はzlibの提供する関数で、Deflateアルゴリズムを使って圧縮するものです。ちゃんと真面目な圧縮を使ってるんですね!(驚くところではない気もしますが)


コードを追っていくと、クエリやクエリ結果だけでなく通信内容全体を圧縮していることもわかります。


また、compress()を使っているので圧縮レベルを与える方法はありません。常にデフォルト値(Z_DEFAULT_COMPRESSION、おそらく6)になります。

*1:PHP 5.4.0以降の環境ではデフォルトでmysqlndを利用しているはずです

PHPのsleep関数とusleep関数の挙動を調べてみた

筆者はPHPの現在時刻を上書きするPHP拡張モジュールphp-timecopを開発しているため、PHPの時間がらみのテストを世間一般の人より多く書いていると思います。テストケース中でusleep関数を多用しているのは世界中でも筆者くらいかもしれません。

ところで、先日php-timecopのテストをWindows上で動かしたところ、 usleep(100000) が99.8msくらいで帰ってきてテストに失敗するということがありました。

筆者はsleep関数やusleep関数は指定した時間と同じかそれより長い時間スリープすると考えていたのですが、本当にそのような性質があるのでしょうか?また、sleep関数やusleep関数はどの程度の誤差があるのでしょうか?

本稿ではこうしたsleepやusleepの挙動について深掘りしてみます。

sleep関数の挙動

まずはsleep関数の挙動から調べてみましょう。LinuxmacOSWindowsの各環境で sleep(1) して帰ってくるまでの時間を1000回測定したときの結果を下記に示します。

Linuxの場合

下図がLinux環境(Debian 8.7, Kernel 3.16.0, x86_64, PHP 5.6.30)での実験結果のヒストグラムです。横軸の単位はusです。

1.0001秒あたりにピークがあるのがわかります。言い換えると約100usのズレです。

Windowsの場合

Windows環境(Windows 10, x86_64, PHP 7.1.6)での実験結果のヒストグラムです。

ピークは1.001秒あたりになっています。言い換えると約1msのズレです。

macOSの場合

macOS環境(Mac OS X 10.11.6, x86_64, PHP 7.1.6)での実験結果のヒストグラムです。

ピークは1.005秒あたりになっています。言い換えると約5msのズレです。

3環境とも、1秒より早く帰ってきたものは1件もありませんでした。

POSIXの仕様を確認する

ちなみに、PHPのsleep関数はどの環境でもOS/標準ライブラリのsleepを呼び出しています。POSIXのsleep()のmanpageには次のような記述があります。

The sleep() function shall cause the calling thread to be suspended from execution until either the number of realtime seconds specified by the argument seconds has elapsed or a signal is delivered to the calling thread and its action is to invoke a signal-catching function or to terminate the process. The suspension time may be longer than requested due to the scheduling of other activity by the system.

http://pubs.opengroup.org/onlinepubs/9699919799/functions/sleep.html

指定された時間が経過するまで実行を停止するよ、スケジューリングの都合で指定されたよりも長く停止することがあるよ、とのことですから、指定時間より早く帰ってくることはないことが保証されているわけです。

usleep関数の挙動

次にusleepの傾向を調べてみます。sleep関数と同様に3環境でusleep(1000000)して帰ってくるまでの時間を測定しました。

Linux/macOSの場合

LinuxmacOSの場合はsleep(1)のときと大差ない結果になりました。下記のヒストグラムLinuxの結果です。

以下はmacOSの結果です。

どちらの環境もsleep関数のときと同様、1秒より早く帰ってくるものは1件もありませんでした。両OSとも内部的にPOSIX準拠のusleepを呼び出しているため、sleep関数のときと似た挙動なのは当然と言えそうです。

Windowsの場合

さて、Windowsの場合は意外とも言える結果になりました。

コブが2つある分布になっており、最初のピークは1000000ns(=1秒)より僅かに前になっています。本稿の最初でWindows上のPHPではusleep関数が指定した時間より少し早く帰ってくることがあるという話を紹介しましたが、その通りの結果になっているわけです。

PHPWindows用のソースコードを見てみると、usleep関数の実現にはWindowsの「Waitable Timer Objects」が利用されています。また、これを利用すると停止時間が指定より短くなることがあるようです(たとえば下記の記事を参照)。

まとめ

  • PHPのusleep関数はWindows上では指定された時間より短い停止時間になることがある
    • Windows APIを使った独自実装をしているため
    • 手元の環境では最大1ms程度早まった(仮想環境ではさらにズレる印象)
  • 全ての環境のsleep関数・Linux/macOS上のusleep関数は指定された時間と同じかそれ以上停止する
    • 内部的に呼び出しているライブラリコールsleep・usleepの仕様
    • 指定された秒数からどれくらいズレるかはOSによって異なる

usleepという関数名なのにWindows上の挙動が他環境と異なっているのはバグとまでは言い切れませんが、ポータビリティ上は問題がありそうです。念のためPHP本体にバグレポートを出しておこうと思います。

追試用の情報

今回の実験に使ったPHPスクリプトは下記です。カーネルコンパイルオプションやその他の条件次第で結果が変わるかと思います。

php-timecopをPECLに登録しました

かれこれ5年ほどメンテしている拙作のPHP拡張「php-timecop」ですが、このたびPECLに登録しました(PECL :: Package :: timecop)。


PECLというのはPHP本体に含まれないPHP拡張を提供する公式のリポジトリです。PECLのアカウントは承認制になっており、誰でも登録できるわけではありません。イタズラやお試しでの登録は減るでしょうが、代わりに登録への精神的ハードルが上がってしまうような仕組みだと言えるでしょう。実際、PECLに登録されているパッケージ総数は365個(2017/7/8時点)と多くはありません。また、日本人と思われるPECLアカウントは筆者以外では5人でした。


本稿では、PHP拡張をPECLに登録するまでのプロセスや、実際に登録してみてわかったことなどを紹介します。

PECLに登録するメリット

さて、そのPECLですが、PEAR*1の衰退とともに徐々に存在感が薄れている印象があります。今さらPECLに登録するメリットとは何でしょうか?


今回PECLに登録してみて、メリットとして次の3点を感じました。

  • peclコマンドを利用している人はインストールが簡単になる
  • PECLに登録することでコード品質が高そうに見える
  • PHP拡張のWindowsビルドが勝手に行われる


1番目ですが、peclコマンドで拡張をインストールしている人が一定数いるようです。そういう人にとっては、下記のコマンドでPHP拡張がインストールできるのは大きなメリットでしょう。

$ pecl install timecop-beta # 正式リリース後には「-beta」が不要になります


私個人はphpizeでビルドするのが普通になってしまったのでpeclで扱えてもありがたみは感じないのですが、インストールまでの手間は確実に減るので、悪いことではないでしょう。


2番目は印象論に近い話になりますが、PECLに登録してあるパッケージの方がロングサポートが期待できたり、多くの人のチェックが入っているように見えるかと思います。実際、後述するようにアカウント取得時にレビューが入りますので、PECLに登録されている時点で一定以上の品質だと期待できるでしょう。また、PECLのサイトに掲載されることで宣伝になるような側面もあるはずです。


最後はあまり知られていない気がしますが、PECLにはWindows版DLLの自動ビルドの仕組みがあります。PECLにパッケージをリリースすると、数時間後にWindows版DLLが勝手にアップロードされます。しかも、5.5から7.1までの4バージョン、スレッドセーフ有効/無効、32bit版/64bit版の全組み合わせ16個のDLLが提供されます。自力でここまで対応するのは大変なので助かりますね。

PECLアカウント取得まで


PECLアカウントの取得は承認制だと書きましたが、割とゆるい感じで運用されています。手順は全て「PECL :: Request Account」に書いてあるのですが、改めて紹介します。

  1. メーリングリスト pecl-dev@lists.php に参加して「自己紹介」「PECLに登録したい拡張の紹介」「コードへのリンク」を書き込む
  2. 誰かがレビューしてくれるので、返事をしたりコードを修正したりする
  3. 頃合いを見て「PECL :: Request Account」のフォームを埋める


1番目はそのままです。私は自己紹介が若干適当でしたが、仕事でPHPに触るようになって何年、とか言っておけばいいんじゃないでしょうか。


2番目については、割とすぐに誰かがコードを見た上で返事をくれます。人によっては「拡張の中でfork()するのは頂けない」みたいな真面目なコメントがついたりします。私の場合はRemiさん*2がコードレビュー&動作確認をしてくれて、「PHP 7.2で動かないよ」「テストが何件か通らないよ」という指摘をもらいました。


MLでの議論を尽くしたらアカウント申請を行います。リクエストフォームの「Sponsoring users」の欄にML上でレビューしてくれた人の名前を書けば良いでしょう。ここで登録したメールアドレスなどの情報はアカウントが作成された後のプロフィールページの初期値になります(参考:「PECL :: Yoshio HANAWA」)。


ちなみに、PECLアカウント申請だけではphp.netアカウントは作成されません。必要なら別途申請する必要があります。

PECLパッケージを作るまで

PECLにアップロードするパッケージは適当なtar ballではダメで、peclコマンドが取り扱える形式でないといけません。この作成にはpeclコマンドが必要です。もし手元に見当たらない場合はpearをインストールする必要があります。


また、パッケージングには package.xml が必要です。既存のパッケージを参考に適当に書いた上で、同じディレクトリで下記のようにすればパッケージが作られます。

$ pecl package
Package timecop-1.2.8.tgz done


XMLの中身がおかしいと警告やエラーが出たりしますので、適宜修正してください。できたtgzファイルをPECLのWeb管理画面からアップロードすればリリース完了です。CIと連携するようなオシャレなAPIは無いみたいです。


既に紹介した通りPECLパッケージのリリースのたびにWindowsのビルドが走るのですが、ビルドに失敗するとDLLがアップロードされません。ビルド時のログが下記のようなURLから確認できるので、失敗していた場合はログから原因を推測する必要があります。Windowsビルドを通すために何度もリリースするのも格好悪いので、真面目にやるなら手元にビルド環境を用意した方がいいでしょう。

まとめ

そんなわけで無事PECLへのリリースができました。3年前にchobieさんに言われたときからの宿題がようやく終わって良かったなーと思っています。



手元にPHP拡張を隠し持っているみなさんもPECLにリリースしてみてはいかがでしょうか。

*1:PHPライブラリのパッケージマネージャ&リポジトリ。現在はComposerが主流

*2:Remi's RPM repositoryの運営者、かつPHPコアコミッターの一人

古いPHPでDateTime::modify(’+0 days’) すると時間がずれるバグ

表題の通りですが、PHPの特定のバージョンにおいて、生成したDateTimeオブジェクトに対して時刻の操作を行うと期待と1時間ずれてしまうことがあります。

<?php
$dt = date_create('@0');
var_dump($dt->format('c')); // string(25) "1970-01-01T00:00:00+00:00"
$dt->modify('+0 days');
var_dump($dt->format('c')); // string(25) "1970-01-01T01:00:00+00:00" (PHP 5.3.9 - 5.4.7)


上記コードの場合で言えば、Unix epoch(Unix time 0秒)のDateTimeオブジェクトを生成してmodifyメソッドで0日後を指定すると、なぜかDateTimeオブジェクトの指す時刻が1時間後になってしまうのです。


このバグの再現には、次の条件が揃う必要があります。

  • PHP 5.3.9 - 5.3.29 または 5.4.0 - 5.4.7
  • 実行環境の現在時刻が夏時間
  • DateTimeオブジェクトをUnix time指定で作成している("@12345"のように)
  • 作成したDateTimeオブジェクトに対してmodifyメソッドやsetTimestampメソッドで時刻操作する


バグの再現に夏時間が必須となると日本人である我々には関係ないようにも見えますが、たとえばTravis CI上のPHP 5.3上でテストを実行すると、今の時期は上記4条件のうち最初の2つを満たしてしまいます。実際に私はハマりました。


このバグは「PHP :: Bug #62896 :: "DateTime->modify('+0 days')" Modifies DateTime Object」で修正されており、PHP 5.4.8以降ではこのような不思議な現象は起こりません。