graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

.Net Framework から .Net Core 3.1 への Assembly Loading/Unloading 移植メモ。

自作のツールを .Net 4.6 から .Net Core 3.1 に移植したので、メモがてら記事化しておく。


GitHub - hal1932/csi: CSharp pseudo Interpreter
C# をランタイムコンパイルしてPythonとかRubyの代わりに使う - graphics.hatenablog.com
GitHub - hal1932/Csi3

AppDomain → AssemblyLoadContext

.Net Framework ではアセンブリ単位でのアンロードができないので、新規作成した AppDomain の中でロードしてから、AppDomain ごとアンロードする 必要があった。一方で .Net Core では AppDomain 自体が廃止されている ため、代わりに AssemblyLoadContext を使う必要がある。*1

AssemblyLoadContext 自体はその名の通り「アセンブリをロードするためのコンテクスト(≒空間)」であって、.Net Core ではこの単位でアセンブリのロードとアンロードを行うことができる。

AssemblyLoadContext のカスタム

さて、何も考えずアセンブリをロードしたいだけなら、AssemblyLoadContext.Default をそのまま使えばいい。ただし、後述する理由で Default を使うとアンロードができなくなってしまうので、実際的にはカスタムが必須になってくると思う。*2

カスタム自体は下記のとおり、「アンロードできるようにアセンブリを読み込む」だけならこれで十分だったりする。コンストラクタで isCollectible=true を渡すとアンロードが可能になる*3のと、Load が null を返すと既定のアセンブリ解決プロセスが後続で走る。

using System.Reflection;
using System.Runtime.Loader;

class MyAssemblyLoadContext : AssemblyLoadContext
{
    public MyAssemblyLoadContext() : base(isCollectible: true) {}
    protected override Assembly Load(AssemblyName assemblyName) => null;
}

アセンブリのアンロード

意外と面倒なので、詳細は下記のドキュメントを参照。ざっくりいうと、下記の条件が揃った時点でアセンブリがアンロードされる。

  1. AssemblyLoadContext.Unload が呼び出された
  2. ロードされているアセンブリと、アセンブリ内のシンボルのすべてが GC に回収された

docs.microsoft.com

アンロードが完了したかどうかは、AssemblyLoadContext.Unloading イベントが発行されたか、あるいは AssemblyLoadContext 自身が GC に回収されたかで知ることができる。後者の場合は ロードに使用した Context の WeakReference を監視 すればいい。

ひとつ注意点として、ロードとアンロード待ちをすべて同じスレッド上で行う場合、これらが確実に異なるスコープに置かれるようにする*4こと。アセンブリ内からロードされたシンボルに null を代入したりしても、スコープを抜けない限り GC に回収されなかったりする。アセンブリ内部への参照がすべて切れた状態で、GC.Collect 等を行うこと。

Assembly.ReflectionOnlyLoad → MetadataLoadContext.Load

型情報だけをロードする仕組み

Assembly.Load や AssemblyLoadContext.Load は基本的に「アセンブリ(内のシンボル)を実行するため」にロードを行うので、各種シンボルやその型情報はもちろん、実行コードまで含めてすべてをロードするようになってる。一方で、「求める型情報を持つシンボルがあるかどうか確認する」「アセンブリ内のシンボルの依存アセンブリを取得する」といった「リフレクション情報の取得」だけが目的の場合、実行のために必要な情報をロードする必要*5はない。.Net Framework では Assembly.ReflectionOnlyLoad を利用することで、それを実現することができた。

.Net Core では read-only reflection に特化した MetadataLoadContext が追加されたので、ReflectionOnlyLoad の代わりにこれを使うことになる。

MetadataLoadContext のコンストラクタには MetadataAssemblyResolverインスタンスを渡す必要があるのだけど、残念ながら MetadataAssemblyResolver.Default が実装されていないので、自前でリゾルバを実装する必要がある。

.Net Standard 2.0 以降でのコアライブラリ参照

ちょっとだけ面倒なのが、.Net Standard は 2.0 からコアライブラリのアセンブリ参照の仕組みが変わっていて、リゾルバに渡された AssemblyName から素直にロードしてもうまくいかないことがある。詳しくは以下の記事を参照。

neno-garden.com


どうするのが正解なのかは正直よくわからんので、とりあえず Github 上にばらまかれている CoreMetadataAssemblyResolver を参考に 実装 することにした。今後もこの実装がずっと使えるかどうかはわからないけど……。

Visual Studio デバッガ(Version 16.4.2 時点)との相性問題?

以下、コマンドラインオプションで指定された C#ソースコードファイルをランタイムビルドして実行する処理なのだけど、「ビルド後にアセンブリをロードして実行」「リビルドまでに既存のアセンブリをアンロード」ということを行っている。詳しい処理はソースコードを追ってもらうとして、.Net Framework の頃に比べるとだいぶすっきり書けるようになったなぁという印象。

Csi3/Program.cs at master · hal1932/Csi3 · GitHub

ただ、現時点(Version 16.4.2)での Visual Studio のデバッガでこれを実行しながらアセンブリのロード/アンロードを繰り返していると、以下のようなポップアップが表示されて異常終了することが間々ある。

f:id:hal1932:20200126181101p:plain

デバッガを接続しているときだけに起こる問題なのと、HRESULT 的には "Method call with an invalid argument." らしい ので、正直ちょっとよくわからない。おそらくデバッガ周りの何かがバグってるのかなと勝手に思ってはいるのだけど、はてさて。

*1:公式ドキュメントにある通り、正確には「.Net Core では規定の AppDomain のみが利用可能になった」というのが正しい。セキュリティ等を理由とした実行ドメインの分離するにはプロセス自体を切り離すようにしましょうと。この変更で、「アセンブリ実行ドメイン」というざっくりした概念が、「アセンブリをロードする空間」「アプリを実行する空間」の 2 つに切り離された。

*2:そもそも Default Context にロードするなら Assembly.LoadFrom を使えばいい。

*3:AssemblyLoadContext.Default は、パフォーマンスのために isCollectible=false になってる。

*4:ソースコード上でスコープが分割されているようにみえても、コンパイラがそれをインライン化してしまう可能性がある。上記公式ドキュメント内のサンプルコードでは、これを防ぐために MethodImpl(MethodImplOptions.NoInlining) を利用している。

*5:というか、そもそも無駄なのでロードしたくない。