2017年3月20日月曜日

.NET Coreが動くまで

(なんかこのネタ、誰かがやってそうな気がしつつ)

一部ではバージョンがわかりづらいなどとも言われている .NET Core の起動の仕組みについて、適宜ソースコードを眺めつつ、整理したいと思います。私は中の人ではなく、コードを解析しながら書いているので、おかしな点がありましたら twitter 等で教えていただけると嬉しいです。

構造

ご存知のように、.NET Core は、コンパイル結果の実行可能アプリケーションまたは dotnet コマンドから実行します。
その時の動作の流れを図にすると、以下のようになっています。

この図において、長方形はファイル、角丸四角形はレイヤー、青の長方形は説明のためのグルーピングです。
この図の要素を簡単に説明すると、以下のようになります。

  • dotnet コマンド。別名 .NET CLI と言われているのはこれ(のはず)です。muxer と呼ばれることもあります。実体としては、以下の 2 つになります。
    • dotnet。Windows の場合は dotnet.exe になります。これは後述するアプリケーションランチャーです。
    • dotnet.dll。.NET CLI の本体です。.NET CLI のリポジトリにあるソースコードをビルドすると生成されます。
  • app。特定のランタイム向けに dotnet publish すると生成される実行可能ファイルです。
  • corehost。Windows の場合は corehost.exe となります。コマンドラインプログラムとしての .NET Core のランチャーです。
  • hostfxr。Windows の場合は hostfxr.dll、Mac OS X の場合は libhostfxr.dylib、Linux の場合は libhostfxr.socorehost の実体になります。
  • hostpolicy。Windows の場合は hostpolicy.dll、Mac OS X の場合は libhostpolicy.dylib、Linux の場合は libhostpolicy.so。CoreCLR ホスティング API に渡すための各種情報を初期化します。まさに、「ホスティングポリシー」です。
  • Hosting API。CLR の Hosting API の CoreCLR 版。COM に準拠した CLR の Hosting API に対して、CoreCLR の Hosting API は UNIX の関数に近い形になっています。ICLRRuntimeHost2 のラッパーとしての C 言語の関数です。
  • ICLRRuntimeHost2。CoreCLR の Hosting API の実体です。ちなみに、CLR の Hosting API は ICLRRuntimeHost インターフェイスです。

これらは、ホスティング API(ICLRRuntimeHost2 とラッパーである Hosting API)と、CoreHost(corehosthostfxrhostpolicy に加え、この後説明する dotnet も含まれます)に大別されます。

ホスティング API

ホスティング API は、その名の通り CoreCLR(.NET Core のランタイム)をプロセスにロードして、実行するための API です。.NET Core のプログラムを実行する場合、このホスティング API を何らかの形で実行する必要があります。

.NET Core の場合、以下の 4 つの関数が定義されています。ここで、文字列は、Windows の場合は ANSI、それ以外の場合は UTF-8 で渡します。

// CoreCLR を初期化。戻り値は HRESULT。
int coreclr_initialize(
    const char* exePath,                    // 実行する exe のパス。
    const char* appDomainFriendlyName,      // 初期 AP ドメインの名前。
    int propertyCount,                      // CoreCLR プロパティの数。つまり、propertyKeys と propertyValues の要素数。
    const char** propertyKeys,              // CoreCLR プロパティキー文字列の配列。
    const char** propertyValues,            // CoreCLR プロパティキー値の配列。
    void** hostHandle,                      // ホストへのハンドルが格納される(実体は ICorHost2)
    unsigned int* domainId                  // 初期 AP ドメインの ID が格納される。
);

// CoreCLR のシャットダウン。戻り値は HRESULT。
int coreclr_shutdown(
    void* hostHandle,                       // coreclr_initialize で返されたホストハンドル。
    unsigned int domainId                   // アンロードする AP ドメインの ID。
);

// 任意の静的メソッドへのデリゲートを作成し、それを呼び出すための関数ポインターを取得する。
int coreclr_create_delegate(
    void* hostHandle,                       // coreclr_initialize で返されたホストハンドル。
    unsigned int domainId,                  // 対象のメソッド(を定義する型)がロードされている AP ドメインの ID。
    const char* entryPointAssemblyName,     // 対象のアセンブリの名前。
    const char* entryPointTypeName,         // 対象の型の名前。
    const char* entryPointMethodName,       // 対象のメソッドの名前。
    void** delegate                         // 関数ポインターが格納される。
);

// アセンブリのメインエントリポイント(Main)を実行。戻り値は HRESULT。
int coreclr_execute_assembly(
    void* hostHandle,                       // coreclr_initialize で返されたホストハンドル。
    unsigned int domainId,                  // 対象のメソッド(を定義する型)がロードされている AP ドメインの ID。デフォルトドメインでなければなりません。
    int argc,                               // argv の要素数
    const char** argv,                      // Main メソッドに渡す引数の配列。
    const char* managedAssemblyPath,        // Main を定義するアセンブリへのファイルパス。
    unsigned int* exitCode                  // Main の戻り値。`void` だった場合は `0`。
);

ホスティング API の動き

ホスティング API の実装は、 C 関数版である unixinterface.cpp と、
そこから呼び出す ICLRRuntimeHost2 COM インターフェイスの実装である CorHost2 クラスです

使い方については、ホスティング API のドキュメント を参照しつつ、以下のソースコードを参照するといいでしょう。

  • corerun。CoreCLR に付属する、テスト用の簡易コマンドラインランチャーです。これを使うことで、.NET CLI の開発が追い付いていない状態でも coreclr の開発版を実行できます。
  • Azure IoT Gateway SDK .NET Core Binding。IoT のシステムで、よりセンサーに近いモノ側に配置するプログラム用(いわゆるエッジコンピューティング用)のフレームワークです。.NET Core 以外からのホスティング API の使用例として参考になるでしょう。設計ドキュメントもあるので、こちらの方がわかりやすいかもしれません。
    • 余談ですが、Node.js や Java や CLR をロードするサンプルプログラムとしても面白いかと。

ホスティング API の動きを簡単に説明すると、以下のようになります。

coreclr_initialize

  1. PAL(Platform Abstraction Layer)を初期化します。
  2. ICLRRuntimeHost2::SetStartupFlags() で初期化フラグを設定します(具体的には、*.runtimeconfig.json にある System.GC.Concurrent System.GC.ServerSystem.GC.RetainVM
  3. ICLRRuntimeHost2::Start() でランタイムを起動します。
    • 内部的には、ランタイムの起動カウントをインクリメントし、 InitializeEE() を呼び出します。
    • なお、シャットダウン済みの場合は単に失敗します。
  4. ICLRRuntimeHost2::CreateAppDomainWithManager() を使用して、以下のように AP ドメインを初期化します。
    • APPDOMAIN_ENABLE_PLATFORM_SPECIFIC_APPS を指定して、Any CPU でないアセンブリの実行を許可します。
    • APPDOMAIN_ENABLE_PINVOKE_AND_CLASSIC_COMINTEROP を指定して、P/Invoke と COM Interop を許可します。
    • APPDOMAIN_ENABLE_PLATFORM_SPECIFIC_APPS を指定して、コードアクセスセキュリティを無効化します。
    • AP ドメインマネージャーは指定しません。

coreclr_shutdown

  1. ICLRRuntimeHost2::UnloadAppDomain() で AP ドメインをアンロードします。
  2. ICLRRuntimeHost2::Stop() でランタイムを停止します。
    • 内部的には、ランタイムの起動カウントをデクリメントし、0 になったらシャットダウン済みフラグを設定します。
  3. PAL をシャットダウンします。

coreclr_create_delegate

  1. ICLRRuntimeHost2::CreateDelegate() を呼び出します。
    • 呼び出すメソッドには以下の制限があります。
    • 静的でなければなりません。
    • ジェネリックメソッドであってはなりません。
    • [AllowReversePInvokeCallsAttribute] で修飾されていなければなりません。
    • メソッドはオーバーロードされていてはいけません。

coreclr_execute_assembly

  1. ICLRRuntimeHost2::ExecuteAssembly() を呼び出します。
    • Assembly::ExecuteMainMethod() を呼び出します。
    • メタデータからエントリポイントを探し、現在のスレッドをフォアグラウンドスレッドにし、エントリポイントを実行します。

CoreHost

さて、ホスティング API は API なので、シェルから直接実行するには、何らかの CLI プログラムが必要になります。それがここで紹介する CoreHost です(正式名称ではない気がしますが、そこは目を瞑ってください)。なお、「dotnet は?」とか「publish すると実行可能ファイルが作れるじゃん」とかいう疑問はちょっとわきに置いておいてください。この後ちゃんと説明します。

CoreHost はホスティング API を呼び出す「コマンドラインランチャー」であり、「マルチプレクサー(muxer)」です……意味が分かりませんね。基本的に、このコマンドは corehost <アプリケーション.dll> <引数> という形式でアプリケーションを実行するためのものです(まさに「ランチャー」です)。

そして、以下のように、corehost の実際の動作は、状況に応じて変わります。

  • <自ファイル名>.dll ファイルがあり、かつ以下の条件を満たす場合、「スタンドアローンモード」になります。
    • <自ファイル名>.deps.json が存在する
    • <自ファイル名>.deps.json<自ファイル名>.runtimeconfig.json も存在しない
  • そうではなく、同じディレクトリに coreclr.dll(または libcoreclr.solibcoreclr.dylib)がある場合、「分離 FX モード」になります。
  • そうでない場合、Muxer モードになります。

この後、各モードの詳細について見ていきますが、もう一つ説明しておくことがあります。それは、corehost はそのままではなく リネームして使用 されるということです。
具体的には、以下のようにリネームされて使用されます。

  • <アプリケーション>[.exe]dotnet publish した結果生成される実行可能アプリケーション。
  • dotnet[.exe]。SDK のルートディレクトリに配置される、.NET CLI と呼ばれるものです。

つまり、これまで実行可能アプリケーションや dotnet コマンドだと思っていたものの実体は、単一の corehost だったということです。

それでは、それぞれのモードについて、その使われ方とともに詳細に見ていきましょう。

各モードについて

CoreHost は、前述のように、自分自身のファイル名がどうなっているかと、同じディレクトリにどのようなファイルがあるかによって 3 つのモードがあり、それぞれ動作が変わります。

分離 FX モード

これがおそらく最も基本的なモードです。コード上は split_fx というシンボルになっています。

以下のように、引数で指定された .NET Core のプログラム(マネージドアセンブリ)を実行するモードです。
dotnet <アプリケーション>.dll から実行するときのモードでもあります。

  • 第1引数で指定されたマネージドアセンブリを実行します。
  • 引数として、--depsfile--runtimeconfig をサポートします。

つまり、dotnet app.dll を実行すると、以下のように動作します。

  1. dotnet.exe という名前の corehost.exe が、分離 FX モードで app.dll を実行します。
  2. dotnet.exe はホスティング API を使用して、app.dll に定義されたエントリポイントを呼び出します。

スタンドアローンモード

これは最も基本的ではないモードなのですが、3 番目のモードが複雑なので先に説明します。

これはあたかも自分自身が .NET Core アプリケーションのように動作するモードで、コンパイルした <アプリケーション>.exe を実行するときのモードでもあります。

  • <自ファイル名>.dll という名前のマネージドアセンブリを実行します。
  • 引数として、--depsfile--runtimeconfig をサポートします。

つまり、dotnet publish で生成される app.exe を実行すると、以下のように動作します。

  1. app.exe という名前の corehost.exe が、スタンドアローンモードで app.dll を実行します。
  2. app.exe はホスティング API を使用して、app.dll に定義されたエントリポイントを呼び出します。

Muxer(マルチプレクサー)モード

これは、2 番目に基本的で、おそらく最も目にすることが多いモード、.NET CLI 用のモードです。

  • 第 1 引数が exec というコマンドが実行された場合、第 2 引数のマネージドアセンブリを実行します。つまり、分離 FX モードとして動作します(exec モード)。
  • 引数として、--fx-version--roll-forward-on-no-candidate-fx をサポートします。
  • そうでない場合、(--fx-version で指定された)coreclr 本体と同じディレクトリにある dotnet.dll を実行します
  • これは、dotnet コマンドで CLI を使用するときのモードです。
    • スタンドアローンモードで dotnet を実行する場合、CoreCLR 本体と dotnet.dlldotnet[.exe] 同じディレクトリに存在する必要があり、バージョン切り替えができなくなるのでこのようになっているのでしょう。

つまり、dotnet run app a b c を実行すると、以下のように動作します(説明を簡単にするため、アプリケーションはビルド済みとします)。

  1. dotnet.exe が Muxer モードで動作し、run に拡張子がないため、dotnet.dll を呼び出します。
  2. dotnet.dll は組み込みコマンド run を認識し、RunCommand オブジェクトを初期化します。
  3. RunCommand はプロジェクトファイルを msbuild の API でロードしします。
  4. msbuild は、RunProgram プロパティとして dotnet を、RunArgument プロパティとして exec path/to/bin/app.dll a b c を設定します。
  5. RunCommand RunProgram プロパティと RunArgument プロパティからコマンドラインを生成します。
    • つまり、dotnetexec path/to/bin/app.dll a b c を生成します。
  6. 外部プロセスとして、dotnet exec path/to/bin/app.dll a b c を実行します。
  7. dotnetcorehost)が exec モードの Muxer モードとして、ホスティング API を使用して、path/to/bin/app.dll に定義されたエントリポイントを呼び出します。

補足:CoreHost とアセンブリプロービング

.NET Core におけるアセンブリのプロービングは CLR のものとは異なります。
具体的には、ホスティング API で指定するプロパティによってその動作が変わります。
通常、ホスティング API を呼び出すのは、dotnetcorehost)になるため、プロービングの動作は、corehost の実装に依存することになります。

ざっくりいうと deps.json にない値はカレントディレクトリからロードしません。
具体的に言うと、Assembly.Load などで動的にロードする分には問題ないのですが、そのロードしたアセンブリが参照するアセンブリはロードできません。
詳細についてはこちらを参照してください

プロービングの詳細については、今回は説明しません(また機会があれば……)。
ただ、プロービングの動作がホストプログラムに依存すること、dotnetcorehost)の仕様は coreclr ではなく cli のドキュメントとして存在すること、
そして具体的な動作を調査するための corehost のコードは(なぜか)インストーラー(core-setup)のリポジトリにあることを覚えておいてください。

ランチャーの動作から言えること

これまでの説明をもとに、いくつかの動作が説明できます。

.NET Core 実行可能アプリケーションとは

スタンドアローンモードで説明したように、<アプリケーション名>.exe(Windows 以外では <アプリケーション名>)という名前にリネームされ、スタンドアローンモードで実行される corehost です。
(リネーム処理は .NET Core SDK の Microsoft.NET.Publish.targets ファイルに書かれています)

.NET CLI とは

Muxer モードで動作する corehost.exedotnet.dll に実装されたコマンドフレームワーク(と組み込みコマンド)です。

実行可能ファイルが RID(ランタイム ID)を指定した publish でしか生成されない理由

corehost は配布先のプラットフォーム依存だからですね。

dotnet と dotnet help の違い

しばやんさんのブログでの .NET Core のバージョンが難しいの中で出た、CLI のバージョン番号がよくわからないという話も、これまでの説明にちょっと引数パースの仕様を加えることで、説明できます。

  • dotnet を実行した場合、corehost の必須引数が足りないため、corehost のヘルプが表示されます。そのため、CoreCLR(のランチャーである hostfxr)のバージョンが表示されます。
  • dotnet help を実行した場合、corehost dotnet.dll help 扱いになり、CLI の HelpCommand が実行されます。そのため、.NET CLI のバージョンが表示されます。

まとめ

  • .NET Core プログラムを実行するということは、Hosting API を実行するということ。
  • 通常、Hosting API は corehost[.exe] から実行する。
  • dotnet[.exe] コマンドはリネームされた corehost[.exe] である。
  • 実行可能アプリケーションもリネームされた corehost[.exe] である。
  • corehost はその配置場所に応じて動作を変える。これによって、.NET CLI としての動作や、汎用ランチャーとしての動作、実行可能アプリケーションとしての動作を使い分けている。

参考文献

3 件のコメント:

  1. Is there any working example to host the dotnet core clr please? thanks

    返信削除
    返信
    1. I think azure iot gateway SDK is good start point for it.

      https://github.com/Azure/iot-edge-v1/blob/d031cb951d1e2456509439ba85ff7fbe3cec3aab/v1/bindings/dotnetcore/src/dotnetcore.cpp#L111

      削除
  2. 大変勉強になる記事で参考になります。
    ところで次の内容について、もしよろしければご意見いただけないでしょうか。
    とあるSoCで.net5を使おうとしたところ、次のissue
    https://github.com/dotnet/runtime/issues/9969
    で議論のあるIllegal instructionが表示されました。
    これはそのSoCがある命令をサポートしていないことが原因のようです。.NET5に限ると、
    https://github.com/dotnet/runtime/pull/471
    の方法で動作するランタイムをビルドできることが分かりました。
    簡単なプログラムで動作確認もしました。
    PCで開発したプログラムを--no-self-containedでpublishして動かしました。

    今、ASP.NET Coreを使いたいと思っているのですが、どうすればそのようなSoCでASP .NET Coreを使えるでしょうか?
    .NETとASP .NET CoreのランタイムのZIPの中身は、sharedフォルダにaspnetcoreのフォルダがあるか否かの違いだけのようです。
    おそらく、aspnetcoreのランタイムのZIPの中のもう一つのフォルダ(Microsoft.NETCore.App)は.NETのランタイムであり、
    これを自前ビルドしたもので置き換えればいいと想像しています。
    実際に試してみようと思っていますが、もし理論的な観点から助言頂ければ幸いです。

    返信削除