豪鬼メモ

一瞬千撃

TkrzwのWindows対応 その壱

やるやる言ってやってなかったデータベースライブラリTkrzwWindows対応を始めた。実装は何段階かに分けて行うが、とりあえず全機能が動くというところまでは来た。ここで作業の方針と進捗についてメモっておこう。
f:id:fridaynight:20210503060155p:plain


2年ほど前に、DellXPS 13 9370というノートブックを買って、それをWindowsとLinuxのデュアルブートにして、Windows上での開発作業を行うつもりだった。しかし、いろいろいじってたらWindows側が起動しなくなってしまったので、Linux専用機としてだけ使われて、Windowsでの開発作業を全くしなくなってしまった。というのは単なる言い訳で、要は面倒くさくなっただけである。最近別件で32ビット環境での検証をする必要があって、そのノートPCにVMWareを入れて作業を行なった。ついでに、そこでWindowsも動かして、先送りにしていた対応作業をやってしまうことにした。

UNIX系で開発しているOSSWindowsで動作させる際には、古くはCygwinとかMinGWとかを使ってUNIX系用のコードの変更点をできるだけ少なくする手法がとられていたりもした。しかし、コードを書く際に処理系依存部分を集約しておけば、そういったラッパー的な手法を使わなくても移植は容易になる。C++11以降でスレッド周りが標準化され、C++17以降はファイルシステム関連の標準化も進んだので、そもそもOS依存のコードの割合が少なくなっているというのも大きい。良い時代だ。ということで、素直にVisual StudioC++環境(Visual C++)上で開発を行うのが最も楽だ。とはいえ、プロジェクトファイルとかを作るのも面倒だし、そもそもGUIがあまり好きでないので、nmakeからコンパイラとリンカをコマンドラインで読んでビルドするという手順で進めた。やることを箇条書きにしておこう。

  • VMWare上にWindows 10の環境を構築する。
    • ノートPCにライセンスは付属しているが、ライセンス番号はBIOSに記録されているので、まずそれを取得する。sudo strings /sys/firmware/acpi/tables/MSDM とかいうコマンドを発行すればよい。
    • マイクロソフトからWindows 10 HomeエディションのISOイメージをダウンロードし、VMWare仮想マシンを作成して読み込ませる。その際にライセンス番号を入力する。
    • 仮想環境にVMWare Toolsを入れて、それからディスプレイ設定で解像度や表示倍率を設定する。
    • Ctr2CapでCaps LockキーにCtrlを割り当てる。
    • 適当なエディタを入れる。今回は昔懐かしいxyzzyを入れてみた。
  • Visual Studio 2019をインストールする。
  • 上記コマンドライン環境にてnmakeで作業を進める。
    • UNIX用のMakefileをnmake用に書き換えて、nmake -f VCMakefileとかやってそれを指定してビルドすればOK。

慣れていないだけだろうが、Windows上での作業はなかなかストレスがたまる。lsでなくdirなのとかが地味に辛い。コマンド履歴の表示順序が直感的でない。エディタはやはり純正GNU Emacsを使いたいが、Windows上でその設定をするのもだるい。いろいろ試したが、結局のところ、編集作業はLinux上で行なって、そのディレクトリをWindows側にマウントして作業することにした。ビルドコマンドの実行と動作確認だけはWindowsで行わねばならないが、それならばコマンドプロンプトの操作だけなのでそんなに苦痛ではない。それでもコマンド履歴機能が貧弱なのでストレスはあるが。


nmake用のMakefileの書き方に結構嵌ったので、メモしておこう。実際のファイルはこんな感じである。まず、依存する標準ライブラリの場所を特定しておかねばならない。VC++の標準C++ライブラリとそのヘッダ、そしてOSのライブラリの場所が必要だ。

VCPATH = C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.28.29910
SDKPATH = C:\Program Files (x86)\Windows Kits\10\Lib\10.0.19041.0
VCINCPATH = $(VCPATH)\include
VCLIBPATH = $(VCPATH)\lib\x64
UMLIBPATH = $(SDKPATH)\um\x64
UCRTLIBPATH = $(SDKPATH)\ucrt\x64

上記の設定を引用して、コンパイラ(cl)とアーカイバ(lib)とリンカ(link)のフラグを設定する。Visual Studioのコマンド環境で作業することで、それらのコマンドおよびnmakeなどのツールに適切なパスが通される。コンパイラのフラグとしては、c++17対応にすべく /std:c++17 /Zc:__cplusplus をつけるのが大事だ。

CL = cl
LIB = lib
LINK = link
CLFLAGS = /nologo \
  /std:c++17 /Zc:__cplusplus \
  /I "$(VCINCPATH)" \
  /DNDEBUG /D_CRT_SECURE_NO_WARNINGS \
  /D_TKRZW_PKG_VERSION="\"0.9.10\"" /D_TKRZW_LIB_VERSION="\"1.0.0\"" \
  /O2 /EHsc /W3 /wd4244 /wd4267 /wd4334 /wd4351 /wd4800 /MT
LIBFLAGS = /nologo \
  /libpath:"$(VCLIBPATH)" /libpath:"$(UMLIBPATH)" /libpath:"$(UCRTLIBPATH)"
LINKFLAGS = /nologo \
  /libpath:"$(VCLIBPATH)" /libpath:"$(UMLIBPATH)" /libpath:"$(UCRTLIBPATH)"

あとは、UNIXmakefileと同様に、サフィックスルールやらビルドターゲット毎のルールやらを書けばよい。今のところ静的ライブラリの設定しか書いていないが、DLLの設定もそのうち書き足す。/EHsやらの例外処理設定や、/MTやらの依存ライブラリの設定に気を遣う。細かいことは公式サイトの説明を読むのが良い。


まあそういうわけで、Tkrzwのソースパッケージのアーカイブファイルを展開したディレクトリで、以下のコマンドを実行すれば、ビルドと動作確認が完了する。

> nmake -f VCMakefile
> nmake check

インストールは、管理者権限で行う必要がある。コマンドプロンプトを管理者権限で呼び出し、以下のコマンドを入力する。

> nmake -f VCMakefile install

インストールは手動で行ってもよい。C:\Program Files (x86)\tkrzwとかいうディレクトリを作って、その中のlibにライブラリtkrzw.libと、includeに各種ヘッダファイル(*.h)を置き、binにコマンドラインツール(*.exe)を置くのが良いだろう。そしたら、アプリケーションのビルドは以下のように行うことができる。

cl /std:c++17 /Zc:__cplusplus \
  /I "C:\Program Files (x86)\tkrzw\include" \
  /O2 /EHsc /W3 /MT helloworld.cc tkrzw.lib \
  /link /libpath:"C:\Program Files (x86)\tkrzw\lib"


このように、Tkrzwのほぼ全ての機能はWindows上でも利用できるようになっている。ただし、現状で満たしているのは機能要件のみであり、性能要件は棚上げしている。つまり、最適化を全くしていないので、遅いのだ。なぜ機能要件のみが実現されているのかというと、ファイル入出力を抽象化したレイヤーで、C++標準ライブラリのみを使った移植性重視の実装を用いているからである。本当に標準ライブラリのstd::fstreamしか使っていないので、標準C++17を実装した環境にならほぼ無変更で移植できることになる。しかし、本来はストリーム用の実装なので、データベースのようなランダムアクセスの用途に使うとかなり非効率な実装になっていて、Linux上でもスループットは最善の10%くらいになってしまう。とはいえ、機能用件さえ満たせていれば、それをリファレンス実装として比較しつつ、より最適化された実装を逐次投入できる素地ができる。

本来、Tkrzwでは4種類のファイルI/Oクラスを利用することができる。デフォルトはメモリマップを使った速度重視の実装で、100バイトのレコードのランダムアクセスを900万QPSのスループットで行う能力がある。それより劣るがメモリ使用量が少ない実装では500万QPSくらいである。それに比べると、今回使った標準ライブラリの実装は12万QPSしか出ず、やたら遅いと言えよう。ベンチマークの結果についてはこちらに載せてあるWindows版でも、それぞれメモリマップと位置指定アクセスの両方を使ったクラスを追って提供するので、それなりの性能にはなると思う。

機能要件を満たすのも実はそんなに楽ではなかった。以前に詳述したが、ランダムアクセスのファイル入出力とは主に以下の抽象操作の集合体であるとみなせる。それを標準ライブラリだけで実現できるかどうかの挑戦である。

// 指定した位置から指定したサイズのデータを読み込んでバッファに格納
Status Read(int64_t offset, void* buffer, size_t record_size);

// 指定したバッファのデータを指定した位置から指定したサイズだけ書き込む
Status Write(int64_t offset, void* buffer, size_t record_size);

// ファイルサイズを変更する
Status Truncate(int64_t size);

Readに関してはstd::fstreamだとtellpとreadの組み合わせで実現できる。Writeに関してはtellgとwriteの組み合わせで実現できる。それぞれの操作で位置の変更とデータ入出力の2回のシステムコールが発行されるし、さらにバッファリングを前提としているのでデータの無駄な複製が発生している。機能的には実現できるが、ひたすら効率が悪い。また、std::fstreamはスレッドセーフではないので、全ての操作をmutexで保護しなければならない。それより問題なのは、std::fstream単体だとTruncateが実装できないことであった。開いているファイルのサイズを小さくする標準的な方法はないのだ。しかたないので、C++17から利用できるstd::filesystem::resize_fileという関数を利用する。この関数があってラッキーだったとも言えるが、これはファイル名を指定して外側からファイルに干渉するAPIだ。よって、開いているファイルに使うのには都合が悪い。POSIX系では問題ないが、Win32だと、開いているファイルの操作は、開いた際のハンドラを介して以外は認められないのだ。よって、ファイルを閉じてから、ファイルサイズを変更して、またファイル開くという手順にせねばならない。

似たような問題が、ファイルのリネームの実装でも起こる。データベースの最適化は一時ファイルにデータを書き出して行うのだが、その結果を元のファイルと置き換える際のリネーム処理で困るのだ。POSIXのrenameシステムコールの実装では、宛先のファイルが存在している場合には、その削除と置き換えを同時に行なってくれる。一方で、Win32のrenameシステムコールの実装では、宛先のファイルが存在する場合には単に失敗する。したがって、宛先のファイルがある場合にはアプリケーション側の責任で削除を行う必要があるが、その一連の操作の原子性(Atomicity)と一貫性(Consistency)を保証することができないのだ。つまり、宛先の削除に成功した後でファイルのリネームを行う前の状態が他のプロセスから見えてしまうことが原子性の問題となり、またその状態で自分のプロセスが死ぬと、宛先のパスに正常なデータがない状態になって一貫性の問題となる。この潜在的な問題の標準準拠の解法はない。Win32のAPIを直で叩けるならばこの制限は回避できるが、それは今後の課題だ。

ファイル入出力以外にもディレクトリの操作やパスの正規化やその他ファイルシステム周りに処理系依存があるが、それらも全て#if _SYS_WINDOWS_ などとプリプロセッサ条件分岐を書きまくって対処した。意外に面倒なのが、Win32では標準パス区切り文字がバックスラッシュ「\」であるにも関わらず、スラッシュ「/」を指定しても問題ないということである。じゃあスラッシュを使ったディレクトリ文字列に対してJoinPathというAPI関数を読んだ場合、区切り文字はバックスラッシュとスラッシュのどちらを使うべきだろうか。C:/ みたいなドライブ指定はスラッシュで良いのにUNCパスだけはバックスラッシュを使わねばならないことにはどう対処すべきか。とりあえずAPIが受け取るパスにはスラッシュを許容するが生成するパスは全てバックスラッシュ方式に変換するというポリシーで対処しているが、それが一般的なのかは自信がない。

あと、処理系定義済みマクロがうざかった。windows.hではminとmaxというマクロが定義されていて標準C++の同名関数と衝突することは有名だが、それはNOMINMAXというマクロを事前に定義すれば抑制できるのでまだよい。問題は、パスを利用する全てのWin32 APIは、そのASCIIバージョンとUnicodeバージョンが存在し、それをマクロで切り替えていることだ。例えばCopyFileというAPIは実際にはASCII版のCopyFileAおよびUnicode版のCopyFileWとして実装されており、フラグによってCopyFileAマクロがどちらを指すかを決めているのだ。これはもろにマクロの弊害を引き起こす。実際、Tkrzwはそのtkrzw::DB::CopyFileというAPIを提供しているが、それもマクロによってtkrzw::DB::CopyFileAとかに書き換えられてしまう。つまり、たまたまWin32 APIの関数と被った識別子は、名前空間の中にあっても、全てビルドできなくなってしまうのだ。仕方ないのでこちらでCopyFileDataという名前に変えた。

ファイルパスの文字コードの話も結構深い。POSIXAPIはバイト文字列としてパスを扱って、マルチバイト文字はUTF-8等のエンコーディングで扱う。Windowsでは、同じ機能をASCII版(コードページ版)のAPIとして提供するとともに、Unicode版と称してUTF-16LEの同名関数をも提供している。UTF-16の1要素は基本他言語面65536文字までしか使えないのでサロゲートペアを導入せざるを得なくなって利点がまったくない挙句、エンディアンの影響も受けるので筋悪としか言いようがない。ASCII版でもSJIS等でマルチバイト文字は扱えるので、それをUTF-8にすれば何の問題もなさそうだが、実際には後方互換性のためにいまだにSJIS等のローカル符号化が使われていて、他言語対応のためにはUnicode版の利用が推奨されている。それはともかく、TkrzwのAPIではパスはASCII(コードページ)の文字列しか受領しない。簡単なラッパーを書けばUTF-16LE文字列にも対処できるが、組み合わせ問題になって複雑化する恐れがあるので、それは利用者側でやってもらおう。


まとめ。TkrzwがWindowsで動くようになった。性能はまだ低いが、機能的には充足している。性能についてはこれから鋭意改善していく。