どうも、イプシロンソフトウェアで代表をしております渡部です。うちの会社はソフトウェア開発のエンジニア多めのソフトウェア開発企業で主にWebシステムだったりゲームだったりとかを作っています。代表も元プログラマなのでソフトウェア開発者にとって働きやすい環境が整っていると思いますよ(多分)。一緒に働いてくれる仲間を募集しておりますのでもしご興味があるようでしたらページ下のリンクから応募してくれると嬉しいです。
ファイバー is 何
さてさて、今回はちょっとマニアックなお話をしたいと思います。コンピューターソフトウェアの開発にはファイバーというのが用いられることがあります。
ファイバー(英: fiber)は、計算機科学の分野において、非常に軽量な実行スレッドを示す。ファイバー同士はスレッドと同じくアドレス空間を共有するが、両者には区別が存在する。 ファイバーが協調マルチタスクを使用するのに対し、スレッドはプリエンプティブマルチタスクを用いる。スレッドでは、ビジーなスレッドに割り込み他のスレッドを復帰させるためにカーネルのスレッドスケジューラを用いることが多いが、ファイバーは他のスレッドを実行させるために自ら制御を譲る。
コンピューターにおいて「スレッド」という概念はよく耳にすることがありますが「ファイバー」というのはあまり馴染みのない言葉なのではないでしょうか。基本的にはスレッドと同じような概念で、命令の流れのことなのですが、ファイバーとスレッドは以下の点が違います。
- スレッドはOSなどの制御によって切り替えが発生するためプログラマがきめ細かい制御をすることはできません(OSやユーザー権限によっては優先順位などを設定できる場合があります)。一方でファイバーはどこまで命令を実行するか、どこでどのファイバーに制御を移すかなどの自力で制御する必要があります。
- スレッドはどこの命令でどのように制御が移るのかを決められません。そのため共有変数などへのアクセスを行う際は std::mutex などで排他制御を行ってあげる必要があります。ファイバーは制御を自力で行う必要性があるので排他制御は必要なくなります。
- 複数のスレッドを用いるプログラミングは「たまに起こるけど再現性が低い不具合」を誘発しやすく比較的難易度が高いと言えます。特に他人が作ったライブラリを用いる場合は注意が必要でドキュメントを熟読する必要があります。一方でファイバーは排他制御忘れなどが起こりにくいのでたまにしか発生しないような不具合を誘発しにくいです。
- スレッドを使うことで昨今のコンピューターの主流であるマルチコアの性能を活かしやすいです。一方でファイバーは制御を自分で行う必要がありますのでマルチコアによってパフォーマンスが上がるということはありません。
似ているようで結構 違いますね。しっかし、「◯◯ is 何」って言い方、古いんですかね。最近あんまり見ないような気がしなくもないです。「◯◯ is 何」の元ネタ is 何。
それぞれのOSでファイバーを実現する
Wikipediaの説明にあるように、ファイバーはOSのサポートによって実現できる場合があります。
- Windowsの場合、Windows SDKにあるファイバー関連のAPIを使用することができます。
- Linuxの場合、getcontextなどを使用することで実現することができます。makecontextのページを見れば実装例が載っています。
- macOSの場合…はて…macOSの場合はどうすれば…? というのが今回の記事の本題です。
macOSで無理やりmakecontextとかを使ってみる
macOSにおいてmakecontext等のファイバの命令は一応使えます。が、これはdeprecated(廃止予定、非推奨)となっています。そのまま使うと警告が出てしまいますが、 _XOPEN_SOURCE を定義してあげることで警告を出さずにコンパイルすることができます。
makecontextの例のプログラムをそのままコピペし、コードの頭のところで _XOPEN_SOURCE を定義してコンパイルしてあげれば…そうですね、動きません。実行時にクラッシュします。昔は動いていたのかもしれませんが、どうやら現代のコンピューターにおいてはうまく動作しないようです。いつからこういう状況になっているかは分かりません。そして当然のことながらmakecontextの例に載っているプログラムはLinuxでコピペして動かしてあげると当然動いてくれるわけです。
動く環境であれば動いてくれるのかもしれませんが、非推奨のものを無理やり動かし続けるのもリスクがかなり高いですし、動かない環境もあるということも考慮する必要があります。macOSにおいては無理やりコンパイルを通すのではなく、もっと根本的に対応する必要がありそうです。OS側に何も用意されていないのであれば覚悟を決めて自らファイバーを実現するためのコードを書いていくことにしましょう。
ご注意
社内向けのコードを記事用に記述しなおした(機密性が高いところは抜いた)ということもありまして、もしかしたらそのまま書いても動かないかもしれません。もし動かなかったらごめんなさい。掲載されている内容を使ったことによる責任は株式会社イプシロンソフトウェアでは負いません。また、コードはC++11以上で使える特有の書き方をしている箇所があるので古い環境であれば適宜変更してください。
macOSのIntelチップ向けに機能を用意する
2020年後半からmacOS向けにApple M1チップが搭載されるようになりました。ただ、その前のmacOSはIntelチップが搭載されていましたので、まずはIntelチップ向けにファイバーを実装していく感じにしましょう。まずはファイバーを表現するための構造体を用意していきます。
struct FiberInfo
{
char* stackPtr = nullptr; // スタックポインタのバックアップ(構造体の先頭に置くこと!)
size_t stackSize = 0; // ファイバー全体のスタックの大きさ
char* stackBuffer = nullptr; // ファイバー全体のスタックバッファ
void* data = nullptr; // 実行時に使用されるユーザーポインタ
};
ここでいうところのスタックというのはコールスタックのことです。関数を抜けたときに呼び出し元に戻ったり、関数の中で使われているローカル変数を一時的に覚えておく領域となります。ファイバーを表す構造体の先頭に宣言しているポインタは、コンピューターのスタックポインタのレジスタをバックアップするための変数です。
続いて、ファイバーを新しく作成するための関数を書いていきます。
std::shared_ptr<FiberInfo> createFiber(size_t stackSize, void* data) noexcept(false)
{
// 関数の最初のところでスタックサイズに関する制限を確認
assert(MINSIGSTKSZ <= stackSize);
assert(stackSize % 16 == 0);
// ファイバーの情報を作成
auto info = std::make_shared<FiberInfo>();
info->stackSize = stackSize;
info->stackBuffer = // TODO : stackSize で指定されたメモリ領域を16バイトのアライメントで確保
info->data = data;
// TODO : 先の記事の部分で書いていきます
return info;
}
引数としてはスタックのサイズを指定します。スタックのサイズが大きいほど深く関数を呼び出していくことができますが当然メモリをたくさん使ってしまうデメリットがあります。また、注意点としてはスタックはアライメントを指定して確保する必要があるという点です。アライメントはメモリアドレスの境界のことです。メモリアドレスの境界というのはアドレスがぴったり割り切れる値になります。境界が合っていないとメモリの内容が正しく読み書きできなかったりクラッシュしてしまうことがあります。アライメントをしっかり合わせる形でのメモリ確保はOSやコンパイル構成によってやり方が違いますが、posix_memalignや_aligned_malloc_dbgなどを使うことができます。
記事の説明の都合上、ここまでにして開発を進めます。この関数で作成したファイバーに対して切り替えの機能を実現する必要があります。WindowsであればSwitchToFiber関数が該当しますし、Linuxでucontextを使っているならswapcontextが該当します。以下のようなプロトタイプ宣言を記載します。
extern "C" void switch_fiber_asm(
FiberInfo* current,
FiberInfo* target) noexcept;
第一引数 (current) には現在実行中のファイバーの情報を渡してあげます。初めて切り替えを行う場合は単に std::make_shared とかで作ってあげたものを渡してあげてOKです(構造体の中身がnullptrとかで初期化されただけの状態のものですが問題はないです)。第二引数 (target) には前述の createFiber 関数とかで作ったものを渡して上げてください。
で、はい、さっき行った関数のプロトタイプ宣言、末尾に_asmとついていますね。そうです、察しがいい方は分かると思いますが、ここからは残念ながらアセンブラです。拡張子は .s になります。以下のように関数の宣言と、対象プラットフォームの確認を行います。
#if defined __x86_64__
#if !defined (__APPLE__)
# error This platform is not supported.
#endif
.text
.global _switch_fiber_asm
_switch_fiber_asm:
# TODO : ここから先は後述します
関数の呼び出しには呼び出し規約(calling convension)という決まり事があります。x86_64の呼び出し規約はABI(Application Binary Interface)によって大きく2種類に分けることができます。
- System V AMD64 ABI: Linux, macOS, FreeBSD, Solaris
- Microsoft x64 calling convention: Windows
今はmacOS向けのコードを書いているので前者ということになりますね。
関数を呼び出す際は、いくつかのレジスタは関数を抜けたあとでも入る前と同じ状態に原状復帰して戻す必要があります。これを呼び出し先保存レジスタ(callee-saved registers)と呼びます。こういった責任範囲を定めるのが呼出規約ということで、今回の場合はSystem V Application Binary Interface AMD64 Architecture Processor Supplementで規定されています。文書の23ページ(pdfとしては24ページ)のUsageの欄にcallee-saved registerと記載してありますね。これらを現在のスタックに対して情報をpushqで保存していきます。ただし、17ページ(pdfとしては18ページ)にあるようにrbpを最初に書き込む必要があります。
さて、アセンブラの関数の中身を書いていきましょう。
_switch_fiber_asm:
# callee saved registerを退避
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
# ...続く...
次に現在実行中のファイバーの情報として渡した情報 (switch_fiber_asm関数に第一引数currentとして渡した構造体に対して) 現在のスタックポインタをバックアップします。先程の文書 System V Application Binary Interface AMD64 Architecture Processor Supplement の23ページ(pdfとしての24ページ)の表にあるように、%rdiが第一引数の値です。ここが指し示すアドレスに現在のスタックポインタをバックアップさせましょう。
# ...続き...
# アドレスが指し示す情報に保存するということは
# 構造体の先頭にセットすることと等価なので
# current->stackPtr = %rsp; と意味合い的には同じです。
movq %rsp, (%rdi)
# ...続く...
続けてスタックポインタを遷移先のファイバーの情報で置き換えていきます。前述の文書にある表を参照すると%rsiが第二引数であることが分かりますね。
# ...続き...
# 同様に、 %rsp = target->stackPtr; と等価です。
movq (%rsi), %rsp
# ...続く...
この行の実行によってスタックポインタが切り替わりました。新しいスタックを使うようになります。ただしあくまでもスタックポインタのレジスタが変更されているにすぎず、プログラムの実行位置(プログラムカウンタ)は変化しません。
さて、切り替え先のファイバーが指し示すスタックに切り替わったところで呼び出し側が保存すべきレジスタ(callee-saved register)を復元しましょう。当然スタックですから押し込んだ順番と逆に取り出していく必要があります。それでアセンブラのファイバーの切り替え関数を終了しましょう。
# ...続き...
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
ret
#endif
次にファイバーで実行する関数を宣言しましょう。今回はファイバーで実行された関数は必ず元のファイバーに手動で戻さなければならないという仕様にして作ってみました。
// アセンブラ版のファイバー切り替え関数である switch_fiber_asm の内部では
// 引数に該当するレジスタは変化されない(読み出しのみ)という仕様です。
// したがって引数として渡されたアドレスがそのままこの関数の引数として引き渡されます。
// ただしこの関数はファイバーとして切り替えられたあとの処理となりますので
// 引数のアドレスは同じでも意味合いが変わっていることに注意してください。
void runFiber(FiberInfo* from, FiberInfo* self) noexcept(false)
{
// TODO : お好きな処理を書いてください
// !関数を抜けたらダメという仕様で作っています!
}
さてさて、最初に挙げていた createFiber で「あとで説明するから」とすっ飛ばしたところがありましたね。このあたりまで来れば内容が理解できるので中身を書いていきましょう。先程の文書 System V Application Binary Interface AMD64 Architecture Processor Supplement の17ページ(pdfとしては18ページ)とアセンブラの関数を眺めながら確認してください。
std::shared_ptr<FiberInfo> createFiber(size_t stackSize, void* data) noexcept(false)
{
// 前述の通り
// コールスタックはアドレスが上位から下位に向かって伸びていきます。
// まずは確保した新しいバッファの最上位を指させます。
info->stackPtr = info->stackBuffer + stackSize;
// まずは戻り先の実行アドレスとして無効な実行アドレスを書き込みます。
// 前述のとおり runFiber は抜けてはダメという仕様にしましたね。
info->stackPtr -= sizeof(void*);
*reinterpret_cast<void**>(info->stackPtr) = nullptr;
// 次に実際に runFiber 関数の実行アドレスをスタックバッファに対して書き込みます。
// 作成されたファイバーに初めて制御を移すために switch_fiber_asm 関数が呼び出された場合、
// 関数を抜けるタイミングで(ret命令)、スタックが自動的にpopされ、
// 制御が runFiber 関数の冒頭に移ります。
info->stackPtr -= sizeof(void*);
*reinterpret_cast<void**>(info->stackPtr) = reinterpret_cast<void*>(runFiber);
// System V AMD64 ABI環境下のための switch_fiber_asm 関数を
// 実行するために内部で消費するスタックバッファのサイズだけ
// スタックポインタを押し込んでおきます。
// 関数末尾で popq 命令を呼び出してスタックが戻る仕様になっているため、
// 予めここで対応しておく必要があります。
info->stackPtr -= 6 * sizeof(void*);
return info;
}
これでIntelチップ版は終わりです。お疲れ様でした。
次はApple Siliconだね、そうだね
アーキテクチャが変われば呼出規約もまた変わってくるわけです。考え方や全体のコードは同じですがApple Silicon、つまりARM64向けに作っていく必要があります。ARM64の場合は関数の実行前に関数側で使用するスタック領域を確保するみたいな作りになってきます。以下の文書が参考になるかなと思います。
- General-purpose Registers - Procedure Call Standard for the Arm (R) 64-bit Architecture (AArch64)
- Parameters in NEON and floating-point registers
#if defined __arm64__
#if !defined (__APPLE__)
# error This platform is not supported.
#endif
.text
.p2align 2
.global _switch_fiber_asm
_switch_fiber_asm:
# この関数を実行するために必要なローカル変数を保存するための領域をスタック上に確保します。
# ARM64の場合スタックは下の方向に伸びていきますので、現在のスタックポインタ(sp)から減算します。
#
# スタックの残り 関数実行のため使用
# |------------------------|--------|
# 新しいsp 以前のsp
#
# 現時点ではまだファイバーは切り替わっていませんので、ここで言うところのスタックやスタックポインタは
# 切り替え元ファイバーのスタックを指しています。
sub sp, sp, 0xB0
# まずはベクトル命令用の128ビットレジスタである v8 〜 v15 を退避させています。
# これらのレジスタは下位64ビットのみを退避させる必要があります。
# stp命令を使用することで2つのレジスタを同時にストアしています。
# 64ビット(8バイト)レジスタを2つ同時に退避させることになるので16バイトのデータが必要です。
stp d8, d9, [sp, 0x00]
stp d10, d11, [sp, 0x10]
stp d12, d13, [sp, 0x20]
stp d14, d15, [sp, 0x30]
# 同様に退避させる必要がある汎用レジスタを
# 1命令につき8バイトをレジスタを2つずつ、計16バイトずつ退避させていきます。
stp x19, x20, [sp, 0x40]
stp x21, x22, [sp, 0x50]
stp x23, x24, [sp, 0x60]
stp x25, x26, [sp, 0x70]
stp x27, x28, [sp, 0x80]
stp fp, lr, [sp, 0x90]
# リンクレジスタ(戻り先の実行アドレス)をスタック上に退避させます。
# この実行アドレスは switch_fiber 関数を実行したあと、
# 戻り先の実行アドレスとなります。
str lr, [sp, 0xA0]
# 呼び出し元ファイバーのスタック情報をx2経由で格納します。
# 汎用レジスタx0はC言語で言うところの第一引数に該当します
# (詳しくは上述の呼び出し規約に関するページを参照してください)。
# そのポインタが指す値の最初の要素に対して情報を書き込みます。
# つまり以下のようなコードが実行されたのと等しくなります。
# current->stackPtr = sp;
mov x2, sp
str x2, [x0, 0]
# 同様にx3経由で移行先ファイバーのスタックポインタに切り替えます。
# sp = target->stackPtr;
ldr x3, [x1, 0]
mov sp, x3
# 移行先のファイバーが switch_fiber 関数を呼び出す直前のレジスタの状態を復元します。
# これらのレジスタは上述のとおり、関数が呼び出された側の責任で保存し、
# 関数が抜ける前には復元しておく必要があります。
# もしも、切り替え先ファイバーが初期化されたばかりであった場合
# (初めて切り替えられる場合)、これらのレジスタの値は未定義値となります。
# これはC言語の初期化前変数の値が未定義になる挙動に似ています。
ldp d8, d9, [sp, 0x00]
ldp d10, d11, [sp, 0x10]
ldp d12, d13, [sp, 0x20]
ldp d14, d15, [sp, 0x30]
ldp x19, x20, [sp, 0x40]
ldp x21, x22, [sp, 0x50]
ldp x23, x24, [sp, 0x60]
ldp x25, x26, [sp, 0x70]
ldp x27, x28, [sp, 0x80]
ldp fp, lr, [sp, 0x90]
# 移行先のファイバーが switch_fiber 関数のあとで実行を再開すべきアドレスをx4に読み込みます。
# もしも移行先のファイバーが初めて切り替えられる場合は、スタックバッファのこの位置に
# ファイバーが開始され始める関数の実行アドレスが記載された状態で初期化されています。
ldr x4, [sp, 0xA0]
# 関数を抜けますのでスタックフレームを復元します。
# 関数の冒頭で下に下げたのと逆に上に上げます。
#
# 解放されたローカル変数の領域
# |------------------------|--------|
# 以前のsp 新しいsp
add sp, sp, 0xB0
# x4に読み込まれている移行先ファイバーの戻り先の実行アドレスにジャンプします。
ret x4
#endif
ARM64版のcreateFiber関数はこんな感じになります。
std::shared_ptr<FiberInfo> createFiber(size_t stackSize, void* data) noexcept(false)
{
// 前述の通り
// ARM64環境下における switch_fiber_asm 関数は、
// 関数の末尾においてスタックから情報が各レジスタに読み込まれた後に
// スタックフレームが元に戻る仕様です(add sp, sp, 0xb0されます)。
// 元に戻されることを考慮に入れて、予め押し込んだ状態を指しておく必要があります。
//
// stackBuffer stackSize
// |-------------------------|------|
// 0xb0 = アセンブラの関数内で使用するスタックのサイズ
//
info->stackPtr = info->stackBuffer.get() + stackSize - 0xB0;
// ARM64環境下における switch_fiber_asm 関数は、
// 関数の末尾においてスタックポインタから 0xA0 の位置から
// 戻り先の実行アドレスを復元する仕様です(ldr x4, [sp, 0xA0])。
// 初めてファイバーに制御が移ったときに開始関数が実行できるように書き込みます。
*reinterpret_cast<void**>(info->stackPtr + 0xA0) = reinterpret_cast<void*>(runFiber);
return info;
}
as you like - あなた色に染め上げて
エッセンスだけを残して記事にしたのでこのままだとちょっと使いにくいと思います。少し実験してみて、引数を増やして使いやすくしたり他のプラットフォーム向けの機能だとかを加えて使いやすくまとめてみてください。
アドレスサニタイザーとどうやって付き合うの
さて、ファイバーをアセンブラで実装してぴょんぴょん実行位置を飛ばすと、メモリの破壊検知を行うアドレスサニタイザーが問題として検知してきます。ですので、アドレスサニタイザーに対して「これは問題ない」「今はこっちのスタックです」みたいな操作を通知してあげる必要があります。そのあたりはasan_interface.hあたりの関数をいい感じに呼び出す感じになります。ただ、その解説記事を書くには余白が狭すぎる、ということで気が向いたら続きを書くことにして筆を置きたいと思います。