.NET非同期処理で例外を安全に対処する

.NETの非同期処理で例外を処理する方法について、重要な点を簡潔にまとめました。

ついつい例外処理を汚く書いてしまうとか、手を抜いてしまう、またはGUI環境で例外処理が悩みの種、という人向けです(一部、やや深い話をしていますが、Level 200~300ぐらいです)。async voidへの対処で悩んでいる方にも、方法を示します。

コード例はC#で書いています。ターゲットはすべての.NET処理系、つまり、.NET Framework, .NET Core, .NET 5, mono, Xamarin, Unityなど、全てです。


基本的な例外処理

例外を補足するには、try…catchを使用します:

public static FooBar Fetch()
{
    try
    {
        var body = webClient.DownloadString("https://example.com/foobar");
        return JsonConvert.DeserializeObject<FooBar>(body);
    }
    catch (IOException ex)
    {
        Trace.WriteLine($"Log: {ex}");
        throw;
    }
}

catch句には、そのブロックで受信すべき例外の種類を型名で指定出来ます。上の例では、IOException、またはそれを継承したクラスがスローされると、catchブロックのTrace.WriteLine(…)が実行されます。
注意すべき点は以下の通りです:

  • 再スロー、つまり例外を呼び出し元に伝搬させる場合は、throw句を単独で使います。throw exのように書いてしまうと、例外中に保存された、発生元を示すスタックトレースの情報が失われ、throw exを書いた場所からにリセットされてしまいます。
  • catch句に、例外の型を指定しない事もできます:
try
{
    // ...
}
catch // 受信する型を書かない
{
    Trace.WriteLine("Log: Caught uninterpreted exception.");
    throw;
}

これは、どのような例外をも受信し、catchブロックを実行するというものです。一見すると、catch (Exception ex)のように、Exceptionクラスを指定するのと同じに見えるかもしれません(.NETの例外は、すべてExceptionクラスを継承するため)。しかし、「すべての例外」とは、.NETでは補足されない(出来ない)ような例外を含む、という意味です。

代表的な例として、SEH (Structured Exception Handling:構造化例外処理) と呼ばれる、Windows環境の例外を挙げます。これは.NETだけではなく、Win32のネイティブコード実行時に発生する「非同期例外」という種類の例外です。名前が非常に紛らわしいのですが、.NETの非同期処理で扱われる例外とSEHの非同期例外は、まったく関係ありません。

(なお、都合でWindows環境のネイティブコードをx86と呼称していますが、arm64を含む全てのWindows環境に当てはまります。)

.NETのマネージ例外と構造化例外を対比させてみます:

  • .NETマネージ例外: 私たちに馴染みのある例外。IOException, ArgumentException, …
  • Structured Exception (構造化例外)
    * ハードウェア例外: CPU由来の例外や、外部割込みによって発生するもの。代表的な例外に、0除算エラーがある。
    * ソフトウェア例外: 意図して発生させる構造化例外。.NETマネージ例外をスローする事に似ている。Win32のRaiseException APIで発生させる。

構造化例外の説明を見て、色々疑問が湧くかもしれません。


構造化例外と.NETマネージ例外の位置づけ

0除算エラーとDivideByZeroException

広義の0除算エラーといえば、こういうコードです:

public static int Div(int a, int b)   // bに0を与える...
{
    try
    {
        return a / b;
    }
    catch (DivideByZeroException ex)
    {
        Trace.WriteLine($"Log: {ex}");
        throw;
    }
}

.NETでは、0除算エラーは、DivideByZeroExceptionクラスで受信、つまりcatchする事が出来ます。

SEHでは、EXCEPTION_INT_DIVIDE_BY_ZEROというコードを使用して、RaiseExceptionを呼び出し… ません。ネイティブコードであっても、0除算エラーが発生するような計算命令を実行するだけで、(明示的に判定するようなコードを書かなくても)、自動的に、このコードのSEH例外が発生します。

ネイティブコードで発生する0除算エラーは、.NETのランタイムによって自動的にDivideByZeroException例外に変換されて、マネージ例外として再スローされます。そのため、私たちは普段、内部でSEH例外が発生したということは意識しません(これは、JITがCIL(MSIL)を直接x86の除算命令に置き換えても、まったく同じ方法で0除算を検出することに符合します)。

しかし、0除算エラーのような代表的な例外ではなく、カスタムコードを持つSEH例外が発生する可能性もあるので、そのような例外は.NETランタイムが変換できません。そういった場合でも、冒頭の、型を指定しないcatch句であれば受信できるというわけです。

SEH例外が「非同期例外」と呼ばれる理由を補足すると、意図しないタイミングで発生する可能性があるから、という意味を含みます。0除算エラーの場合、x86のdiv命令を実行中に発生するのですが、CILのDivからすると、div命令を含むx86 2~3命令に対応します。そのため、どのx86命令までが正常に実行できて、どこで実際のSEH例外が発生したのか、を正確に判断するのが難しくなります。0除算エラーを含むいくつかのSEH例外は、.NETランタイムから見て保障可能なものに限り、DivideByZeroExceptionのようなマネージ例外に自動的にマッピング出来ているのです。

最初の脇固め

そのようなわけで、全ての例外的事象に対応して、何らかの処理を行わなければならない場合は、catch (Exception ex) { … } ではなく、catch { … } を使いましょう。但し、後者は例外が発生したという事実だけで、例外の内容を識別する情報が得られません。そこで、このようなboilerplateが必要になります:

try
{
    // ...
}
// すべての.NETマネージ例外
catch (Exception ex)
{
    Trace.WriteLine($"Log: {ex}");
    throw;
}
// すべての例外
catch
{
    Trace.WriteLine("Log: Caught uninterpreted exception.");
    throw;
}

これは… まあ… めんどくさいですね…


本題

構造化例外なんて、Linuxで.NET CoreやってるAさんや、Xamarin iOSやってるBさんや、Unity for AndroidやってるCさんには何も関係ないので、気にする必要ないと思うかも知れません。実際、私もそう思うのですが、ここからが本題です。

私たちは、結構な頻度でこのようなコードを書きがちです:

// WPF, Windows Forms, Xamarinなどのボタンクリックイベント
public async void Button_Click(object sender, EventArgs e)
{
    // 非同期で取得
    var body = await httpClient.GetStringAsync("https://example.com/foobar");
    var foobar = JsonConvert.DeserializeObject<FooBar>(body);

    // 表示する
    textBox.Text = $"${foobar.Price}";
}

これの何が問題なのかと言うと、サーバーへのアクセスに失敗したときの例外の処理が何もないことです。いや、Applicationクラスのグローバル例外ハンドラで処理しているから問題ないと思うかもしれません:

public void Main(string[] args)
{
    // WPFの場合、Applicationクラスのイベントをフックすることで、未処理の例外を捕捉できる
    Application.DispatcherUnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");

    // ...
}

あるいは、コンソールアプリケーションやXamarin Formsなら、AppDomainの例外ハンドラを使うこともできます:

public void Main(string[] args)
{
    // TIPS: ExceptionObjectがobject型なのは、SEHを意識しているからです
    AppDomain.CurrentDomain.UnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.ExceptionObject}");

    // ...
}

他の環境でも、大体似たようなグローバル例外ハンドラが用意されている筈です。

しかし、残念ながら、上記のようなasync voidのシグネチャを持つ非同期メソッドで発生した例外は、これらの方法では受信できません。

思っていたのと違う原因

問題は3点あります:

  • 非同期処理の例外をフックする方法が異なる: 非同期処理で発生した未処理の例外は、TaskScheduler.UnobservedTaskExceptionを使えば受信できます。但し…
  • 例外が発生したタイミングで受信できない。
  • 補足できても対処可能なことが限られる。

非同期処理中に発生した例外は、上記の通りTaskSchedulerクラスのUnobservedTaskExceptionで同様にフックする事が出来ます(これを推奨している記事も結構あるのですが、ここでは敢えて参照しません):

public void Main(string[] args)
{
    Application.DispatcherUnhandledException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");

    // 非同期処理の未処理例外も補足する
    TaskScheduler.UnobservedTaskException += (s, e) =>
        Trace.WriteLine($"Log: {e.Exception}");       // <-- (A)
}

ところが、この (A) の箇所が呼ばれるタイミングが、実際に例外が発生したタイミングよりもかなり遅れます。まず、async voidとして実装したメソッドの等価実装を見て下さい:

// ボタンクリックイベント
public void Button_Click(object sender, EventArgs e)
{
    // 非同期処理を呼び出し、そしてTaskを捨てる
    var task = this.OnClickAsync(sender, e);
}

// 実処理 (Taskを返す)
private async Task OnClickAsync(object sender, EventArgs e)
{
    // 非同期で取得
    var body = await httpClient.GetStringAsync("https://example.com/foobar");
    var foobar = JsonConvert.DeserializeObject<FooBar>(body);

    // 表示する
    textBox.Text = $"${foobar.Price}";
}

このように、内部的には非同期処理を示すTaskが捨てられた格好になります。Taskのインスタンスは誰も保持していないため、例外で処理が中断した後はしばらくメモリ上に残り、その後、ガベージコレクタが回収しようとします。ここで、Taskのファイナライザーが(ファイナライザースレッドから)呼び出されます。

Taskのファイナライザーは、自身が保持している未処理の例外を、TaskScheduler.UnobservedTaskExceptionを呼び出して処理させます。このような動作なので、例外が発生したタイミングと、ハンドラが呼び出されるタイミングがずれるのです。

そして、(A) の時点のスレッドは、ファイナライザースレッドなので:

  • ハンドラ内からユーザーインターフェイスを操作できない。
  • 何なら他の操作もできないか、または制限がある。

事になります。上のコードで示したようにコンソールに出力するだけであれば、実害はないかもしれません。しかし、一般的なライブラリは、ファイナライザースレッドから呼び出される事を想定していないと思われるので、ファイルにアクセスしたり、DBにログを出力したり、別のサーバーに要求を投げたりすると、もしかしたら更に別の問題を引き起こして頭を悩ませるかもしれません。

(注: ファイナライザースレッドから呼び出されるという事は、少なくともマルチスレッドでアクセスすることが可能である必要があり、かつ、ファイナライザースレッドの厳しい環境が回避可能でなければなりません。大半のクラスライブラリはこれを満たしません。実際のところ、Trace.WriteLine()が安全かどうかも疑わしいです…)

また、例外が発生したタイミングを追跡しにくい、という問題が残ります。自分の周りでは、いつの間にかUnobservedTaskExceptionがデバッガのコンソールやログファイルに(意図しないタイミングで)表示され、これの原因をどうやって特定しようか、という悩みをちょくちょく観測します。


対処方法

結局、例外が未処理で放置されないように、根元で受信することです:

public async void Button_Click(object sender, EventArgs e)
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
}

おっと、そういえばSEHの件がありましたね:

public async void Button_Click(object sender, EventArgs e)
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

他のプラットフォームの例として、Unityのハンドラも挙げておきます。と言っても、メソッドのシグネチャが違うだけで、やることは全く変わりません:

// UnityでUX buttonを使った時のクリックハンドラ
public async void UXButton_OnClick()
{
    try
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // ...
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Debug.LogError($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Debug.LogError("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

めんどくさい…

まあそうですよね。SEHのリカバリーを捨てたとしても、毎回(忘れないように)catch句を書くのは非常にだるいです。なので、boilerplateを発展させましょう:

// 非同期処理で発生する例外を安全に処理する
public static async void SafeAsyncBlock(Func<ValueTask> action)
{
    try
    {
        // 非同期処理を実行
        await action();
    }
    // 未処理の例外を逃さず受信する
    catch (Exception ex)
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine($"Log: {ex}");
        // 再スローしない
    }
    // SEHも逃さない
    catch
    {
        // ここなら比較的安全にいろんなことができる...
        Trace.WriteLine("Log: Caught uninterpreted exception.");
        // 再スローしない
    }
}

こういうものを作っておくと、このように書き直せます:

public void Button_Click(object sender, EventArgs e) =>
    SafeAsyncBlock(async () =>
    {
        // 非同期で取得
        var body = await httpClient.GetStringAsync("https://example.com/foobar");
        var foobar = JsonConvert.DeserializeObject<FooBar>(body);

        // 表示する
        textBox.Text = $"${foobar.Price}";
    });

かなり楽に書けるようになりました。ValueTaskではなくTaskでも構いません。ValueTaskにしておけば、アロケーションコストを削減できる可能性があります。

Level 400~500: 非常に細かいことで、普通は考慮する必要はないと思いますが、SEHを非同期処理の例外として伝搬し、継続させることはできません。従って、catch句で受信できるSEHは、最初のタスクコンテキストスイッチが発生する以前のSEHのみとなります。一度タスクコンテキストスイッチが発生した後の非同期継続処理でSEHが発生した場合は、この方法では対処できません。恐らくはThreadPoolから割り当てられたワーカースレッドの根元に伝搬するか、SynchronizationContextがホストするスレッドの根元に伝搬します。AppDomainでフックする以外に、良い方法は、ないかも知れません…


まとめ

.NETの非同期処理で例外に安全に対処する方法をまとめました。この方法なら、冒頭に示した通り、あらゆる.NET環境で例外を安全に対処できます。

ですが、私としては、これらの対処が行われている補助ライブラリやフレームワークを利用することをお勧めします。恐らく、SafeAsyncBlockのようなboilerplateを見て「これだ!」と思った方は、全て自前で実装しようとしていると思いますが、抜け漏れは往々にして発生しますし、細かい考慮点はほかにもいくつかあります。

XAML環境では、私が作ったEpoxyでは当然このような対処を行っています(ICommandのハンドラを非同期処理にできますが、内部では同様の実装を行っています)。また、他の著名なライブラリも対処していると推測するので、検討してみると良いでしょう。

それではまた。

非同期処理、なにもわからない

最近のお仕事で、C#の非同期処理の書き方が分からずに、溶岩地帯に自爆していくコードを沢山みるようになったので、ケースとして日常風の記事にしてみました。

どんな風にハマってしまい、どうやって解決するのかが分かると思います。

結論

先に書いておきます。

  1. Task.Run()を使ってはいけません
  2. Task.Result, Task.Wait()を使ってはいけません
  3. Threadクラスを使ってはいけません
  4. async-await構文だけを使って書きます
  5. async voidにするのは特殊な場合だけです

がんばれー、わかってしまえば難しくない!

補足: もしあなたがJavaScriptで非同期処理を書いた事があるなら、その知識をそのまま生かせます。
JavaScriptではTaskの代わりにPromiseを使いますが、Promiseには上記1,2,3は存在しません。しかし、それで困ることはありませんよね? (これはとても重要な示唆です)

平和の終焉

以下のような実装を考えます:

// Web APIからデータを取得する
public string FetchData()
{
    // TODO: ここにWeb APIからのデータ取得コードを書く
}

ここからコードを書こうとしている人は既に罠にはまっているわけですが、気が付けないので、そのままここに肉付けをしていくわけです。

Web APIにアクセスするには、今はHttpClientを使いますが、彼らは使い慣れているWebClientを使います:

// Web APIからデータを取得する
public string FetchData()
{
    WebClient wc = new WebClient();
    string body = wc.DownloadString("https://example.com/api/data");
    return body;
}

出来ました。ただ、WebClientのドキュメントを見ると、今から使用するのは推奨しない、代わりにHttpClientを使うように、という事が書かれています。また、レビュアーに指摘されたり、ググったりして見ても、やはりもうWebClientは使うな、と言うような情報が得られます。そこでこれを書き換えようとするわけです。

(なお、今回の題材はWeb APIですが、例えばRedisだとかAzureのサービスとかで提供されるSDKとか、もう大半が非同期処理対応のAPIしか用意されていなかったりするので、Web API固有の話ではないという事に注意して下さい)

Day 1

Sync氏は、WebClientをHttpClientに書き換えていきます。しかし、GetStringがありません。代わりに、GetStringAsyncという似たようなのがあるので、これを使おうとします:

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // エラー: 型が合わない
    string body = hc.GetStringAsync("https://example.com/api/data");

    return body;
}

GetStringAsyncというのを使ったら、型が合わないというエラーが出ます(幸いなことに? Sync氏はvarを使わないので、ここでエラーを検出)。よくよく見ると、Task<string>という型です。

ここで彼らの運命は分岐します。どちらも溶岩地帯です。

Day 2 – ピュアなResult

MSDN公式のTaskクラスのドキュメントを読むとサンプルコードがあり、どうやらResultプロパティで結果を取得できるらしい。

(すいません、この記事書いててちょっと頭クラクラしてます… 上のドキュメントは初心者をサイレントに殺しに掛かっているので、見ない方が良いです。リンクは消しました)

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskは良く分からないけど、Resultプロパティを見れば結果が得られそうだ
    // 型もばっちり一致して、コンパイルが通ったぞ!
    string body = hc.GetStringAsync("https://example.com/api/data").Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない

Day 3 – 分身するResult

GUIアプリケーションを苦労してデバッグしていくと、どうもResultが返ってこないことがあることが分かった。ASP.NETの方は、皆目見当がつかない。そこそこ良いVMを割り当てているのに、.NETって性能悪いの?それともWindows VMのせいなの? Linuxにすれば良いの?

そうは言っても納期が迫っているので、何とかしなければならない…

そうだ、ワーカースレッドでResult取得するところを分離すれば固まらなく出来るのでは? 性能上げられるのでは?? さっきのドキュメント読んだら、Task.Runを使えば簡単にワーカースレッド使えるみたいだし、これで行こう!

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskって便利だね!ワーカースレッド簡単に使えるんだ
    string body = Task.Run(() =>
        hc.GetStringAsync("https://example.com/api/data").Result).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2より悪化

Day 4 – async-awaitの存在を見出す

もうわかんない、HttpClientとかTaskとか何もわからない… Taskでググるとasync-awaitとか言うやつが出てくるから、これを使えばいいのかな…

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // async-awaitの書き方本当にわからない...
    // 1日試行錯誤してやっとエラー出なくなった...
    string body = Task.Run(async () =>
        await hc.GetStringAsync("https://example.com/api/data")).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2ぐらい。多分この人はその違いを測定してない…

Day 5 – スレッド手動操作

わかったわかった、Taskなんて使うからダメなんだ。初めからThreadクラスを使えば良かったんだよ。

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskなんて意味わかんないし、これでいいや。
    // でも何でこんなの使うの? WebClientでよくね?
    string body;
    Thread thread = new Thread(() =>
        body = hc.GetStringAsync("https://example.com/api/data").Result);
    thread.Start();
    thread.Join();

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 一応動作はする。但し、Web APIアクセス中は固まる
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2と同じ

わけがわからないよ。どうしてみんなはそんなにHttpClientにこだわるんだい?

Day 6 – 真実

Sync氏「HttpClientだと固まったり性能が上がりません。HttpClientは動作が怪しいので多分バグってます。WebClientを使う方が安全です」

レビュアー「駄目です、HttpClientを使ってください。こう書きます」

// Web APIからデータを取得する
public async Task<string> FetchDataAsync()
{
    var hc = new HttpClient();
    return await hc.GetStringAsync("https://example.com/api/data");
}

レビュアー「非同期処理を行うメソッドは、必ずTaskクラスを返すようにします。また、実際にはHttpClientはstaticで保持る必要があります。とりあえずこんな感じで他のも全部直してください」

Sync氏「え、でもこれだと、メソッド名も戻り値も変わっちゃってるので、呼び出し元にも修正が入ってしまいます」

レビュアー「呼び出し元も直してください。他にうまくやる方法はありません。実際には、メソッド名は直さなくても良いですが、直しておいた方が後々困らないです」

Sync氏「(絶望…)」

(補足: 文字通りです。大げさな話ではなく、他に方法はありません。試行錯誤は時間の無駄になります。警告しましたよ?)

Day 7 – GUIの真実

Sync氏は、必死にGUIのコードを直していた。

// コンストラクタ
public FoolishForm()
{
    // エラー: 型が合わない
    button.Click += this.Button_ClickAsync;
}

// ボタンがクリックされたら、データを取得して反映
public async Task Button_ClickAsync(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

良い所まで来ていたが、Button_ClickAsyncメソッドをイベントハンドラとして登録するところで、型のエラーが発生して悩んでいた。

数時間後…

Sync氏「イベントハンドラの部分はどう書けばいいんですか?型が合わないと言われてしまいます」

レビュアー「こう書きます」

// コンストラクタ
public FoolishForm()
{
    // 問題ない
    button.Click += this.Button_Click;
}

// voidでいい / Task返さないので~Asyncも不要
public async void Button_Click(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「え、でも非同期処理ではTaskを返さなければならないのでは?」

レビュアー「この場合だけ、つまりイベントハンドラだけ、async voidを使えます。この場合だけと考えてほぼOKです。他では使わないでください」

数分後…

Sync氏「試してみたら固まらなくなりました!ただ、動いているときにボタンを連打すると、何度もサーバーにアクセスしてしまうのですが」

レビュアー「古典的なJavaScriptの問題と同じで、アクセス中にボタンをクリックできないように仕様変更して下さい。実装はこんな感じにすれば良いと思います」

// ボタンがクリックされたら、データを取得して反映
public async void Button_Click(object sender, EventArgs e)
{
    // まずボタンをクリックできなくする
    this.button.IsEnabled = false;

    try
    {
        // データを得る
        var data = await this.FetchDataAsync();
        // テキストボックスに反映
        this.textBox.Text = data;
    }
    finally
    {
        // ボタンをクリック可能にする
        this.button.IsEnabled = true;
    }
}

Sync氏「ああなるほど! そうか、ロード中くるくる回るやつを追加で表示したほうが良いかもしれないですね」

レビュアー「そうですね。あと、async voidとする場合は、必ず例外をcatchして下さい」

Sync氏「何故ですか? 今はApplicationクラスの共通例外ハンドラで処理しているので問題ないと思いますが…」

レビュアー「その例外は、共通例外ハンドラでは処理できません。見たことの無い例外がデバッガのログに出てませんでしたか?」

Sync氏「ああ… UnobservedTaskExceptionというのが出てました。それも変なタイミングで表示されていた気がします」

レビュアー「私はこれから出張があるので詳しくは省きますが、メソッド内でcatchして処理するのがベストです」

(補足: この事については、別に記事を書きましたので、興味があれば参照してください

Day 8 – サーバーサイドの真実

さらに数日後…

Sync氏「サーバー側の実装もわかりました、前は呼び出し元はこうしていたのですが」

[HttpPost]
public string GetData()
{
    // データを得る
    var data = this.FetchData();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「こうすれば良いという事ですね?」

// APIエンドポイント名にAsyncとついていても、自動的に無視される
[HttpPost]
public async Task<string> GetDataAsync()
{
    // データを得る
    var data = await this.FetchDataAsync();
    // 返却する
    return data;
}

レビュアー「はい、それで良いと思います」

Sync氏「GUI側と違って、サーバー側の実装は特に変わったことは無いんですね… もう一つだけ、わからない事があるんですが」

レビュアー「なんですか?」

Sync氏「今回の非同期処理対応で、結局Task.Runメソッドを使わなかったのですが、あれはいつ使うものなのですか? C#の非同期処理についてググってみると、大体Task.Runを使う例が載っているので使おうとしてああいうコード書いてしまったんですが…」

レビュアー「大量の計算だけを行う処理を並行して同時に処理させたい時だけに使います。我々が普段書いているコードで、そういう処理は殆ど無いですよね? なので、知らなくても良いレベルで使わない筈です」

(注意: 初稿ではHttpPost属性でAsyncを付けていない名前を明示していましたが、不要でした。Ovisさん、ありがとう)

輝かしい未来への第一歩

Sync氏に横たわっていた暗い霧は、今晴れわたった。そこにはまだ何も存在しないが、やっとスタート地点に立った。そう感じたのである…


お約束の…

ここで挙げた例は、どうしてTask.RunやTask.Resultを使いたがってしまうのかという所に焦点を当てているため、実際にはもっと効率の良い実装テクニックが色々ありますが、最低限このように書けば、Taskをいじり倒して溶岩地帯に突っ込んで死亡する、ということは無くなると思います。なお、ASP.NET (Core), WinForms, WPF, Xamarinについて取り上げましたが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。

もう少しかっちりした解説が必要なら、こちらを参照してください

では、いつものやつ、行ってみましょう:

Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。

今、記事冒頭の「結論」の項を読み返せば、納得できると思います。「C# 非同期処理」とか「C# async await」などでググってみると、Task.Run()やTask.Resultを最初に説明したり引き合いに出す記事の何と多い事か… これらは本当に、知らなくて良いんですよ

もし、自信が無くなってきたら、JavaScriptにもPromiseとasync-await構文があるのに、これらに該当するものは無くても実用出来ているということを思い出すと良いと思います。

(更に補足すると、.NET/C#の場合は、async-awaitよりも先にTaskが生まれてしまった事が、不幸の始まりだったのです)

この記事で少しでも救われる人が増えますように。

それではまた。

2021年のXAML事情とEpoxyを作った話

昔からXAMLのMVVMライブラリで自分に合うものがなく、作ろうと何度かトライ(しては飽きた)していたのですが、とうとうまともに使えるレベルにまで持って行けたので、先日宣伝を垂れ流しました。

Epoxy は、.NETの様々なXAML環境で使うことが出来る、MVVM (Model-View-ViewModel)を楽に実装するための補助ライブラリです。特徴を抜粋すると:

  • 非同期処理 (async-await) を安全に書くことが出来るように配慮しています。
  • C# 8.0でサポートされた、null許容参照型を使えます。
  • 小さなライブラリで、理解しやすいAPIです。
  • プラットフォーム標準以外のフレームワークやライブラリに依存していません。
  • 大げさにならない、最小の手間とコストで Model-View-ViewModel 設計を実現します。Viewにコードビハインドを書かずに済むことが着地点ですが、そのために煩雑な処理を記述しなければならなくなる事を避ける方針です。
  • MVVMビギナーが躓きそうな部分に焦点を当てています。
  • ほかのフレームワークライブラリ(例: ReactiveProperty)と組み合わせて使えるように、余計な操作や暗黙の前提を排除しています。

Epoxyの具体的な解説や動画などは、GitHubのREADMEに書いておいたので、そちらを参照してもらえればと思います(現在はまだありませんが、スタートガイドを書くか撮るかしようかなとは思っています)。フィードバックがあると嬉しいです。

(ここまで、前振りとEpoxyの宣伝)


今や、.NETでXAML環境と言うとかなり選択肢が増えていて、観測しているだけでも以下のようなものがあります:

  • WPF (Windows Presentation Foundation)
  • UWP (Universal Windows platform / 旧Windows Store App)
  • Xamarin Forms
  • Avalonia
  • Uno platform
  • WinUI 3
  • MAUI

(多分、たまたまXamarin Forms公開直前に作ってプチバスってしまったジョークGtk XAML環境、のようなものなら、探せばいくらでも出てきそうです)

EpoxyをこれらのXAML環境で同じように使えるようにするため、移植を試みてみました。MAUIは理由があって取りやめたのですが、それ以外の環境では動作するようになりました。

この記事では、移植作業によって分かった、各XAML環境の現状の差異をまとめてみようと思います。対象読者は、現在のXAML環境の選択肢について知りたい人や、XAML環境の知識のアップデートをしたい人、各XAML環境の差異を知りたい人です。

但し、Epoxyの移植を下敷きにしているため、フレームワークアーキテクチャの情報に偏っていることに注意して下さい。一般的にXAMLに期待されることとして、リッチなユーザーインターフェイスの実現、という視点がありますが、そこはすっぽり抜けていると思います。

なお、この記事の情報は2021年2月時点のものです。

XAMLについて

そもそもXAMLとは、という話を一応しておきます。XAMLはユーザーインターフェイスの定義をXMLで記述するものです。生まれた頃(.NET Framework 3.0)は、丁度XMLがデータ記述形式として広く用いられるようになった頃で、Windows Formsの後継技術として生まれました。

Windows Formsは、画面上(Visual Studio)で、ユーザーインターフェイスのボタンやテキストボックスなどを視覚的に配置することが出来て、その結果(例えば座標とか色とか)が、C#のソースコードとして自動的に生成・変更され、ビルド時に一緒にコンパイルされることでGUIアプリケーションが完成する、というものでした。ここで:

  1. コントロールの合成に難がある。例えば、リストビュー内にボタンやドロップダウンリストを配置するとかは、難易度が高い
  2. 時々、自動編集されたソースコードが壊れる。逆に、手動で定義するのは現実的ではない
  3. すべてのコントロールがWindows HWNDベース(ウインドウを管理する最小単位)のためにパフォーマンスが悪い

と言う問題があり、後継のUIフレームワーク、つまりWPFを作ることになったのではないかと思います。

XMLで記述することで、1と2は解決(2の自動編集は、今はRoslynがあるので、難易度は高いがXMLでなくとも実現出来ると言えるかも)し、HWNDによる単一のウインドウ上で、WPFコントロールをすべて自前で描画することにより3を解決した(場合にとってはDirectXを使う)、という感じです。

以下にXAMLで記述したユーザーインターフェイスの例を示します:

<Window x:Class="EpoxyHello.Wpf.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="EpoxyHello.Wpf" Height="450" Width="800">
    <ListBox ItemsSource="{Binding Items}">
        <ListBox.ItemTemplate>
            <DataTemplate>
                <StackPanel Margin="5">
                    <Image Margin="5,3,5,0"
                        Source="{Binding Image}"
                        Stretch="UniformToFill" />
                </StackPanel>
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Window>

この記事で挙げるXAML環境には:

  • XMLの階層構造で、ユーザーインターフェイスの論理的な構造を表す
  • コントロールの合成が可能。例えば(意味があるかどうかは別として)ボタンの表面に文字だけではなくテキストボックスを配置したりみたいな無茶も簡単にできる
  • コントロールのプロパティがインスタンス基準のものの他に、定義を後付けすることが出来る「添付プロパティ」という概念がある
  • 測定(Measure)と配置(Arrange)、と呼ばれる、ユーザーインターフェイス要素の配置位置を計算する、2段階のステップが組み込まれている
  • データバインディングと呼ばれる、コントロールと対になる情報を提供するインスタンスを結びつける機能がある

と言うような、共通の特性があります。逆に言えば、これら以外の部分は、それぞれのXAML環境によってバラバラと言えます。

(余談ですが、ユーザーインターフェイス以外の環境にXAMLを適用した例がありますが、あまりにマイナーであり、Epoxyとは関係ないため、本記事では省きます)

WPF (Windows Presentation Foundation)

そもそものMVVMの始まりは、WPFからでした。それも(私見ですが)はじめからMVVMの概念があったわけではなく、WPFのデータバインディングの機能・構造を体系化するとこうなる、というような後付けのアーキテクチャ概念だったと思います。

(それが、MVVMをやってると歯がゆい事がある理由だと思っています)

歴史はともかく、MVVMの基本的な構造は殆どこのWPFを基準にしています。私も、WPFを触っていた期間は長いため、まずWPF向けにEpoxyを作って、そこから移植を開始しました。

WPFは3.0の頃から、ほとんど構造は変わっていません。現在は、.NET Framework 4.8が最終バージョンであり、このバージョンがそのまま動く(事になっている)のが、.NET Core 3.0で移植されたWPFです。当初、.NET Coreへの移植という事から、WPFがマルチプラットフォーム対応したのではないか?という誤解がありましたが、あくまで.NET Core 3.0 on Windowsのみがターゲットです。

「マルチプラットフォーム対応は無理だったけど、もうすぐ.NET Frameworkがカンストしちゃうので、何とかWindowsだけでも動くようにしたから、とりあえず.NET Coreに移行してね?」という意図が感じられました。そういうわけで、今でもWPFを選択するという事は、Windowsでしか動作しないGUIアプリケーションを作ることと同義になります。なお、.NET 5においても(恐らくは.NET 6においても)WPFはWindows上でのみ動作します。

(ダメ出ししておくと、WPFを他のプラットフォームに移植するには、C++/CLIで書かれたコードを移植しなければならず、そこを全部リライトするか、またはまずVisual C++をターゲットの環境向けに移植して公開するところから始める必要があるので、UWPとその後継にあれだけリソースをつぎ込んでいるのを見るに、今後も移植は無いだろうなと思います)

余談ですが、WPFは成功したのでしょうか?私のように、次世代UIとの売り言葉に乗って、早くからWPFを触ってきた技術者にとっては、とりあえず無くてはならないフレームワークになっていると思います。一方で、2020年越えの現在でも、Windows Formsを使い続けるユーザーは居て、WPFやXAML環境の何れかに乗り換えようという気配はあまりありません(Epoxyはそのようなユーザーの事も考えて作っています)。

個人的に思うのは、WPFは段階的に移行できるAPIや手段を用意しなかったのが痛いのと、設計概念の変更に踏み込んだのに、その事を詳しく解説せず、コントロールインターフェイスの表面的に似ているところばかり強調していた(個人の感想)ことが大きいです。これは、次節のUWPでも同じことを繰り返しているなと思います。おそらく、今Windows Formsに留まっているデベロッパーは、このままなすすべなく経過するか、どうせ同じ苦労を味わうなら、もっと別の選択肢を選ぼう(.NETに限らず。今はいろいろ選択肢がありますね?)、と思っているような気がします。

EpoxyははじめにWPFで作りましたが、同じXAMLのインフラ(構造)を使うのだから、他のプラットフォームへの移植はそこまで難しくないだろうとは思っていました。それは、部分的に正しく、部分的に間違ってもいました。以降では、各XAML環境の簡単な紹介と、差についてまとめます。

UWP (Universal Windows Platform)

UWPとその前身のWindows Store Appは、Windows 8でアプリケーションのモダン化を目指したものです。スマートフォンアプリケーションが、検証されて配布されることで一定の安心感を得られるのと同じように、MicrosoftがUWPで作られたアプリケーションを検証して公開することで、ユーザーが簡単に安全に利用できるようにしようとしました。

が、様々な理由から、UWPが市民権を得ているとは言い難い状況です。EpoxyをUWPに移植するにあたり、技術的には、以下のような課題がありました:

  • UWPの構造が、WinMDというCOMをベースとしたメタデータインターフェイスで定義され、WPFの「ほぼ普通の.NETクラスベース」と異なる。具体例だが、WPFでは普通のメソッド実装だったのが、UWPで明示的インターフェイス実装として定義されていて、キャストしないとアクセス出来ない場面があった。他にも、C#が普通に使用できない情報が含まれている事がある(イベントのaddメソッドが値を返し、removeメソッドの引数が要求する型が異なるなんて想像したことがあるだろうか?)。Epoxyの移植作業以前にもちょくちょくこのような定義を見かけているので、割と沢山の落とし穴があるように思う。
  • UIスレッドの考え方の違い。WPFではすべてメインスレッドからアクセスすることになっているが、UWPでは基準となるウインドウ毎に別のスレッドを使用することになっているため、今までのセオリーをそのまま適用すると問題が起こることがある(そのUIをホストしているDispatcherを使用する)。但しこれは、UIメッセージキューをウインドウ毎に分けたいという願望が実現されたとも言えるので、必ずしも悪い事ではないが、面食らうと思う。
  • .NETメタデータアクセスに制約がある。特にリフレクションは、.NETの常識が通用しない場合がある。

もちろん、WPFのコントロールと、直接対応するUWPコントロールは、存在する場合もあれば存在しない場合もある(例: TextBoxクラスは双方に存在するが、DockPanelクラスに直接対応するものは無い)ので、XAMLの記述と言う点では、WPFとUWPに直接の互換性はありません。名前空間にも違いがあります。

.NETメタデータの操作については、リフレクションに関するクラスやメソッドは存在しますが、一部の使用には制約があります(コンパイル時に警告は出してくれる)。これは、UWPアプリケーションが、不正な動作を誘発させていないことを(Windowsストアの審査で)確認可能にするために行った仕様変更や制約です。

Epoxyの移植では、イベントをバインディングする機能の移植のところで、UWP専用のAPIを使用する必要がありました(しかも情報がほとんどなく、Rxのコードを参考にした)。個人的な意見としては、もっと段階的に移行可能な方法があったのではないかと思います。このような制約のために、リフレクションに限らず、例えばストリームI/O操作も.NETと異なる独自のクラスやインターフェイスが定義されているため、UWPを使う場合はそれらについて知っておく必要があります。

WPFと似たアーキテクチャのような体裁でありながら、XAMLとは無関係なところで、一般の.NET開発で常識と思われる知識が使えないことが多いと感じます。これらすべての欠点は、今俯瞰してみると、すべて「ネイティブコードへの変換時に、可能な限りの最適化を行う」ことを前提としているように思います。しかし、次節のXamarin Formsでも、iOSではネイティブコードへの変換(AOT)を行うわけで、効率に差はあるにしても、この欠点を受け入れてでも実現すべき事だったのかはわかりません。

私的には、もう少し冷静に判断して欲しかったところですが、スマートフォンとタブレットの迫る外圧に負けたのかもしれません…

Xamarin Forms

元々、Xamarin自体は、AndroidやiOSのアプリケーションをC#で記述可能で、ランタイムにmonoを使用する、という環境でした。ここには、AndroidとiOSを共通のコードで記述出来る、という意味は「含まれていません」。AndroidのアプリをC#で書ける、あるいはiOSのアプリをC#で書ける、という以上の意味は無く、それぞれの環境にあるAPIを使用します。

Xamarin Formsは、そこに共通のAPIをXAMLと共に定義し、可能な限り同一のコードで、AndroidやiOSのアプリを記述可能にしようとしたものです。例えば、ターゲットがAndroidやiOSのどちらであっても、共通のTextBoxクラスやButtonクラスを使います。

AndroidにはAndroidのスタートアップコードがあり、iOSにはiOSのスタートアップコードがあり、それらが共通のアプリケーションコードを呼び出す(共通コードでXAMLを定義し、Xamarin FormsのAPIを使う)、と言うような構造を採ります。

WPFからXAMLの歴史が始まっていることもあり、コントロールの扱いはWPFに似ていますが、現実的なスマートフォンアプリケーションを実装可能にするため、やはりWPFとは異なるコントロールは多いです。基本的にはUWPと同じように、ユーザーインターフェイス設計は、Xamarin Forms専用に考える必要があります。

Xamarin Formsを使う利点は、何といってもUWPのように独自の似て異なる.NET APIを使う必要が無いことです。例えば、ストリームI/OはStreamクラスを使えば良いし、リフレクションも基本的に.NETのそれと同一です。もちろん、iOSの場合はAOTコンパイルされるため、リフレクションAPIの使用には一定の制約があります(使うと実行時に死ぬという問題があります)が、APIが同一なので、.NETの一般的な知識がそのまま通用します。

Xamarinは、.NETでモバイル開発を行う選択肢としては、速い段階で実用レベルに達していたため、Xamarin Formsは実用可能な処理系として、急速に浸透しました。なので、商用サービスとしてのアプリケーション開発では、選択肢に加えることが出来そうなXAML環境の一つです。

なお、Xamarin FormsはUWPを実行環境とすることも出来ます。これは、ビルドした結果がUWPアプリケーションとして、ストアアプリ展開可能という意味です。もちろん、同じXAML環境であっても、UWPとXamarin FormsのXAMLには互換性は無く、この方法で実装する場合はあくまでXamarin Formsとして開発する事になります。Epoxyはもちろん、Xamarin Forms on UWPにも移植しています。

また、Xamarin FormsはGtkで動かすことも出来るため、macOSやLinuxのようなPosix互換環境(つまりはX Window System)をターゲットにすることも出来ます。但し、まだ標準的な手順でHello worldが動くというレベルに無いため、試してみるには追加の手作業が必要になります。別の例として、Unity(ゲームエンジン)にXamarin Formsを移植した例もあります。

Avalonia

Avaloniaは割と昔からあるXAML環境の一つで、最初からマルチプラットフォーム対応を謳っていました。Xamarin Forms同様に、WindowsだけではなくmacOSやPosix互換環境を選択可能で、開発時点ではまだ.NET Coreは無かったので、mono上で動作させることが前提でした。

現在は.NET Core/.NET 5環境でも動作させることが出来ます。XAML環境として見た場合、基本的なコントロールはサポートされており、私見ですが、リッチなユーザーインターフェイスでなければ、今現在マルチプラットフォームXAML環境としての完成度は高いように思います。XAMLはコントロールの合成が可能なので、この時点でも十分な能力を備えていると言えるのかも知れません。

その昔、かなり初期のバージョンで試したことがありましたが、その頃とは比較するまでもなく一発で動作し、不安定なそぶりはありませんでした。

Avaloniaの良い所は、WPFの構造上、あまりイケてない部分が改良されている事です:

  • WPFでは、object型やDependencyObject型から型キャストを要求される場面が多いが、Avalonia APIはそういう所がきちんとジェネリック引数を要求するように変更されており、コンパイル時型チェックが機能する
  • ほぼすべてのコントロールに、インターフェイス型が併設されている。このお陰で、インターフェイスを使った高度な抽象実装が可能だったり、独自のコントロールが基底クラスに縛られない
  • ターゲットプラットフォームがnet461, netcoreapp2.0, netstandard2.0なので、非常にニュートラルであり、多くのライブラリに適合する
  • C# 8.0のnull許容参照型が有効になっている(0.10.0現在)。ソースコードを参照すると、結構な勢いでC#の新しい構文で書き直されている。

全体的に自然な環境・綺麗な設計・実装で、アーキテクチャとして奇抜なところが無いので、.NETデベロッパは素直に扱えると思います。よく練られていて、WPFがこうだったら良かったのに、という感想です。一部のイベントハンドリングはRxを前提としていて、なるほどと思わせる部分があり、専用のReactiveUIライブラリを完備しています。また、現時点では、あまりトラブルを起こすことなく、LinuxやmacOS(注: macOSは未評価)で実際に動かせるというのも大きいでしょう。dotnet CLIにも対応しているので、すぐに始められます。

欠点もあります。それは、AvaloniaのXAML編集時に、インテリセンスが全く効かない事です(Visual Studio 2019上)。XAMLのインテリセンスは、この記事で紹介するすべての環境で満足行く状態ではない(C#インテリセンスとの比較)と考えていますが、Avaloniaはそもそも全くインテリセンスを使えないため、注意が必要です。恐らくauthorは問題を認識しているでしょうから、将来改善される見込みはあると思います。

また、マルチプラットフォーム対応として挙げているAndroidとiOSは、まだ追加の作業が必要なので、一般の開発者が扱える状態にはありません。この部分を見ると、Xamarin Formsとは真逆の状況にある印象です。

Avaloniaについて色々書きたいと思ったのですが、あまり書くことがありませんでした。その位良い実装だったという事です。Epoxyの移植時も、「そうだよ、それだよそれ!」って言いながらやっていたぐらいです。

(追記: なぜかAvalonia XAMLのインテリセンスが働くようになっていました。この記事を書いてから2~3日の話なので、何かアップデートがあったとかそう言う事ではない気がします。自席環境のせいかもしれません。XAMLインテリセンスは機能的に良くないと書いていますが、それでもあるのと無いのとでは雲泥の差です。もし、原因など分かれば、別記事で改めて紹介しようと思います)

Uno platform

Uno platformは最近出てきたXAML環境です。当然マルチプラットフォーム対応を謳っているのですが、wasm (Web Assembly、つまりはChromeなどのウェブブラウザ上で動作する)に対応しているなど、かなり尖っています。そのためか、少し前に大きな話題となりました(その頃は私事で忙しく、試している余裕が無かった)。

そのようなわけで、期待と共にEpoxyの移植に取り掛かったのですが、その内容は色々な面で驚きでした:

  1. UnoのAPIはUWP準拠。つまり、UWPの知識があれば、殆ど問題なくコードを書けることになる。UWPを下敷きにするのは、大胆と言うかびっくりですが、エコシステムを考えると、他のXAML環境のように一から設計するよりも良いのかも知れない。
  2. 大半のクラスに基底クラスが無い。え、そんな馬鹿な?UWPを下敷きにしているのでは?DependencyObjectがあるのでは?と思うが、何とDependencyObjectはクラスではなくインターフェイスとして定義されている。
  3. この時点で(既存のXAML環境の知識がある場合)、一体どうなっているのかわけがわからないと思うだろうが、何とDependencyObjectに相当する実装は、コンパイル時に自動生成されて(C#のpartialを使って)流し込まれる。
  4. 以上はUno on UWP以外の環境の話であり、Uno on UWPの場合は、大半の実装はUWPの実装をそのまま使う。つまりUnoがUWP準拠なのは、実際にUWPを使う場合はほぼそのまま、それ以外の環境では半自動生成された実装が流し込まれて、UWPのAPIにそれっぽく似せた状態にした上で、不足する実装を環境固有のライブラリで補う、と言う形式になっている

よくこんな事を思いついたなというのが感想で、アーキテクチャは間違いなく一番尖っています。メタプログラミングを駆使したXAML環境です。これを見たときには、半ばスルーしていたUWPを真面目に頑張ってみようかと思いました。

しかし、完成度がかなり残念な状態でした…

UnoをUWP環境で使う場合は、実際UWPのAPIをそのまま使うので、ちゃんと機能します。しかし、それ以外の環境では、エミュレーションライブラリに頼るわけですが、ここがまだまだこれからという状態です。例えば、UWP用ストリームI/O APIを使った以下のようなシンプルなコード:

static async ValueTask<ImageSource?> FetchImageAsync(Uri url)
{
    // Redditから画像をダウンロードする (UWP)
    var raStream = new InMemoryRandomAccessStream();
    await raStream.WriteAsync(
        (await Reddit.FetchImageAsync(url)).AsBuffer());
    raStream.Seek(0);
    var bitmap = new BitmapImage();
    await bitmap.SetSourceAsync(raStream);
    return bitmap;
}

は、Uno on UWPでは動作しますが、それ以外ではNotImplementedExceptionとなります。また、XAMLを試行錯誤中、ビルド時に何度もMSBuildタスクがエラーになってビルドが失敗することがありました。そのため、Hello worldぐらいならwasmでも動作して「おお、ブラウザで動くXAMLアプリが作れるぞ?」と思いますが、その後が続きません。

Epoxyでは移植を行いましたが、このような理由でUno on UWPのみ検証しています。

斬新で、他のXAML環境とも全く異なるアーキテクチャのアプローチでしたが(嫌いじゃない)、まだまだ問題を自力で解決できる強い人向けという感じです。私的には、もう少し時間を置いてから評価したいと思います。実際に完成すれば、かなり良い選択肢になる可能性はあると思います。

WinUI 3

WinUIは現在バージョン3の評価中で、内容は見も蓋もありませんが、UWPの焼き直しです。実際に移植を行ってわかったのは、名前空間を変更するだけで殆どそのままUWPのコードを移植できる、という事です。また、新たに、普通のデスクトップアプリケーションとして振る舞わせる、つまり、一般的なウインドウを持つようなアプリケーションを開発できるようになったのが新しい(古い?)所です。

UWPやWinUI、そしてこの後に紹介するMAUIも含めて、直近の.NETユーザーインターフェイスフレームワークは混乱を極めていて、UWPからWinUIに移行するのも、私は本当のところの理由を正確に理解していません。UWPでは、GUIの部分(つまり今回記事にしているXAMLとその周り)と、Windowsに新たに追加された非ユーザーインターフェイスAPIを分離する、という話をチラ見した記憶があるため、その結果がWinUIなのかも知れません。

参照パッケージをUWPからWinUIに切り替えると、当然シンボル名などが未定義になったりします。そこで、名前空間をWinUIのものに合わせてやると解消します。例えば以下のような感じです:

#if WINDOWS_UWP || UNO
using Windows.UI;
using Windows.UI.Xaml.Media;
#endif

#if WINUI
using Windows.UI;
using Microsoft.UI.Xaml.Media;
#endif

// ...
var brush = new SolidColorBrush(Color.FromArgb(...))

これはEpoxyの実装を一部抜き出したものですが、見てなにか気が付きますか? WinUIでは、一部の名前空間がUWPのままだったりします。これがユーザーインターフェイスとそれ以外のAPIの分離結果なのかも知れませんが、詳しく確認すると、SolidColorBrushは「Microsoft.UI.Xaml.Media」、Colorは「Windows.UI」に定義が存在します。もはや覚えられません…

勿論、C#であるならインテリセンスで自動補完が効くので、それほど問題ではないのでは?と思うかも知れませんが、Epoxyの移植時にはかなり混乱しました。

それでも、UWPのメンテナンスを行っている方は、WinUIへの移行に大きな負担は無いと思われます。同時に、デスクトップアプリケーションとしてもサポートされるので、今までスマートフォンやタブレットライクなアプリの挙動に躊躇していた方も使える環境と言えるかも知れません。

しかし、注意すべき点は、デスクトップアプリケーションとしてビルドした場合も、動作させるのにいわゆる「デプロイ」操作が必要で、そのためにパッケージングする必要もあります。ビルドすればEXEファイルが出来上がって、それをクリックすれば実行できる、とか、ファイルをzipで固めて別のマシンで展開して実行したい、のような事を考えていると、コレジャナイ感があると思います(つまり、xcopyデプロイは出来ません。WinUI3のissueとして数件挙がっていたので、やっぱりそうだよねと思った)。

そのような事もあり、実際にWinUIを直接使う必要性があまり感じられない(≒他のマルチプラットフォーム向けXAML環境を使ったほうが良いのでは?)と言う疑問を退けられませんでした。UWPのアップグレードと考えると、どうしてもUWPであるが故の制約(既に解説しました)が存在する事になります。デスクトップアプリケーションを開発すると考えた場合、これが制約に映ってしまいます。

今後もストアアプリでアプリケーションを展開したいと考えている場合は、有効な選択肢の一つでしょう。

MAUI

MAUIは次世代の.NETマルチプラットフォームユーザーインターフェイスフレームワークです。一方、Xamarin Formsの焼き直しという話をどこかで見ました。それだとちょっと見も蓋もないので、把握したことを書くと:

  • .NET 6のリリースに合わせて作業中で、まだ公開されていない(?)
  • Xamarin FormsはMAUIに置き換える予定で、とりあえず名前空間は変わります、という話は見た(アレ?これどこかで?)
  • MVU (Model-View-Update)アーキテクチャに対応。Cometと呼ばれるプロジェクトで進行中

結局、まだ動くMAUIの実装は参照できないようなので、Epoxyの移植を試みるのは諦めました。そのため、MAUIでわかっているのは上記の事だけです。すいません。

TAN-Yさんが、MVUを試す記事を書いているのを見つけました。

MVUは、ユーザーインターフェイスをXMLではなくコードで記述します。アーキテクチャも異なりますが、XAML視点で見た場合、誤解を恐れずに書くならば、ある意味Windows Formsへの先祖返りのようにも思えます。最終的な仕上げには、Visual Studio上でのデザイン時編集を可能にするというゴールがあると思われ、今ならRoslynがあるので、それを実現する土台はあると言えると思います。また、XAMLのインテリセンスが散々であることからも、コードで書いたほうが精神的にも楽なのは想像出来ることです。

EpoxyがMVVMのためのライブラリなので、MVUやCometについて何か考慮することは無いと思います。

MAUI上でCometが実装されるより、CometのようなMVUフレームワークが、他の既存のXAML環境でも使えるようになると良いと思います。私がちょっと不安に思うのは、これでまたリタイヤしてしまうOSSが出るのではないかという事です。AvaloniaもUnoも良い挑戦だと思います。相互に発展するような方法で伸びることが出来ると良いのですが。

まとめ

ここまで読んでいただいた方なら、XAML環境の決定打は存在しない、という現実について大体わかっていただけたかなと思います。個人的には、できればデスクトップ開発はAvaloniaにシフト、モバイル開発はXamarin Formsという使い分けで行こうかと思っています。

デスクトップ開発は引き続きWPFを死に絶えるまで使うという選択肢はありだと思いますが、私の場合メインマシンをUbuntuに乗り換えてしまったこともあり… (EpoxyはUWPとかあるので、結局Windows上で開発していますが)

他にも、UWP系列を使わないのは、UWPの技術的な制約だけではなく、Microsoftの標準技術という位置づけでありながら、.NET側から見ると異端すぎること。そしてビルド環境が特殊なせいで、ビルドの再現性やCI/CDに馴染みにくい(イミュータビリティやポータビリティが殆どないので、自動化でいつも頭を悩ます)事を挙げておきます。WPFのビルド環境が.NET Coreで遜色ない方法でサポートされ、非常に親和性が高くなったので、余計にUWPの欠点が目立ちます。

このオチはある程度予想していたものですが、元々Epoxyが、複数の異なるXAML環境でも同じように使えるMVVMライブラリという落とし所を目指していたので、Epoxyの存在理由も含めて、丁度良かったかなと思っています。

(開発で使用するXAMLの環境を何にするのかは、それぞれの個人やお仕事上の事情があるでしょう。この記事は、いずれかのXAML環境への移行を推奨しているわけではありません。Epoxyの開発理由もそこにあります)

また、MAUIにはあまり過度の期待はしていませんが、一方でMVVMアーキテクチャがMVUに取って替わられる可能性は十分ありうるとも思います。Epoxyの開発は、MVVMでのあらゆる面倒くささが動機づけになっているので、それが解消する可能性があるならMVVMにこだわり続ける理由はありません。果たして実用に足りるのか?2022年には判明するかも知れないですね。

“パターンでわかる! .NET Coreの非同期処理” と楽屋ネタ

先日、“.NET Conf 2019 meetup in AICHI”を開催・登壇したので、その話をまとめます。

2つ目のセッションの話を先に

私は2つのセッションで喋りました:

1. “.NET Core 3の更新内容・紹介”
2. “パターンでわかる! .NET Coreの非同期処理”

最初の方はキーノートのまとめ的なものにしようと思っていたのですが、その話はまた後で。

非同期処理のセッションですが、今回はあんまり内部の技術的な話ではなくて、.NET Core 3.0とC# 8.0で.NETの非同期処理が一応の完成を見たと思っているので、改めて全体を網羅するような内容にしようと思ったものです。その上で、

* 度々非同期処理の誤用が話題に登ったり、辛しみのあるコードを目撃することから「こんな」記事を書いたりしていたのですが、もっと網羅的にまとめたいなーと思っていたので良い機会として共有しよう

というところを目標にしました。

スライド: パターンでわかる! .NET Coreの非同期処理 (SlideShare)

セッション50分でこの分量は無理ゲーなのはわかっていたので、スライドは後で読み返すことが出来るような構成にして、如何に喋りで補えるかに注意しました。が、セッションを聞きながら見ていない場合は、少し足りない(わかりにくい)部分があるような気はしています。セッションビデオと両方で補完してもらえればと思います。

なお、最近twitter上で非同期の事について話ししたものをいくつか置いておきます。この辺の話が今回のセッションをやりたいと思う動機につながっています:

上記の内容や過去の自分が書いた記事、構文的な書き方はわかるんだけど、具体にどう書いていいのかわからない… みたいなところから、ちょっとずつネタを拝借。

で、最初のセッション

最初のセッションの方は、参加した方には普通に楽しんでもらえたと思っているのですが、懇親会で舞台裏の話をしていたら、それをブログにまとめろとのお達しが… なので、普段、この方面の話は書かないのですが、楽屋ネタを書きます。

Scott Hanselmanこのセッションは、当初は私が全部.NET Core 3.0のキーポイントをまとめて1セッション構成するつもりだったのですが、水面下ではMSHQのScott Hanselmanが、日本のコミュニティ向けに何か喋ってくれるかもしれない、という話がもちあがり、それならその方向から構成を考えるために、決定するまで待つかと思っていました。

みなさんご存知の通り、大変に忙しいキーパーソンなので、この試みがキャンセルされるという事態も考えられましたが、関係者が頑張って調整に入っているようなので、ギリギリまで待つつもりでした。

ところで、コミュニティイベントを開催された方なら同意してもらえると思うのですが、イベント開催のためには以下のようなハードルをこなす必要があります:

* 会場の地理的なアクセスのしやすさの検討
* 会場収容人数
* 会場は有償か無償か。有償なら費用をどうするか
* プロジェクターや音声設備の有無・レンタル費用
* WiFiや有線LANなどの通信経路の確保
* 運営メンバーの募集と決定
* 登壇者の募集・登壇内容の調整
* ビデオ撮りあるいはストリーミングライブ配信するのかどうか(合意形成も含めて)
* チェックイン作業
* スポンサーを募集するのかどうか
* 集金や支払いなどの金回り
* ノベルティーなどのグッズ・抽選の有無
* 提供するおかしなど
* 終了後のハッピーアワーや懇親会の検討・場所の確保と誘導・支払い徴収

運営は本当に頭の痛い問題なのですが(これで折れてしまう方もいるので、私は自分が出来る範囲で出来ることしかやらない事を守るスタンスです)、登壇する側としても、セッションネタを考える場合に、上記の制限条件からネタ構成を考える必要も出てきます。例えば:

* 収容人数: 10名・30名・50名・100名かそれ以上、によって、セッションの構成(しゃべる内容や構成、進行方法)を変えたほうが良い。人数が多くなると、後ろの方の席の人は、スライドが読みにくいだとか、声が聞こえないとか、没入感が得られない、といった潜在的な問題が発生しそう。
* プロジェクターや音声設備: スライドの色校正(たいてい黒地だとハマる会場は多いが、パリッとした映像を出せる会場なら、逆に黒地が映える。動画を流す場合は音量に注意しないと、うるさいとか音が小さいとかの問題につながる。しかし、実際に会場で調整しないと思ったとおりには行かない(マイクとかミキサーなどの設備にも影響される)ので、セッションのネタにするのは結構なリスクとなる)
* WiFiや有線LAN: 参加人数が多いと、特別に設計されたWiFiアンテナなどを備えた会場でないと、話にならない事態になります。私はあまりオンライン向きのネタを扱わないので良いのですが、最近だとクラウドアクセスをデモの前提にする必要があったりして、非常にやりにくいでしょうね。逆に、ネットワークの信頼性が高い事が前提にできるなら、クラウドだろうがYouTubeだろうが、セッションの子道具としてバリバリ使えます。

まあ、細かいところを挙げるとキリがないのでこのぐらいで。つまり(人にもよりますが)、セッションネタ一つ決めるのにも、こういう要素が絡んできます。なので、登壇する側は事前にこれらの要素が早く確定していれば、それだけ登壇者や参加者にとって、実りのある内容に詰めていける可能性があるわけです。

で、冒頭の話につながるのですが、今回は会場を日立ソリューションズさん(HISOLさん)にお借り出来たのですが、その前にもいくつか候補があって調整していました。全ての会場が完全に同一条件なら、「まあ確保できるところでやればいいよね」と、問題の一つが片付くのですが、現実はそうじゃないのでヤキモキします。

結局、検討していた会場は確保できなかったのでHISOLさんに打診したところ、快諾得られたので良かったのですが、確定までに2週間前までかかったこともあって、なかなか会場を公表できませんでした。登壇者のみなさんごめんなさい。

こういう点で、名古屋は非常に苦労するというのはなかなか理解してもらえないところでツライです。東京に近いところは楽そうだなと思うし、もっと地方ではそもそも会場になる場所さえ無く、勉強会というものを想像するのも難しいと言うのが現実でしょう。

で、問題は上記の会場環境の話に繋がります。HISOLさんの会場では、残念ながらパブリックなWiFi設備を借りる事が出来ません(もちろん有線LANも)。すると例えば、クラウドベースのデモとかかなり厳しい(デモの悪魔に取り憑かれる可能性が高い)わけです。

言わずもがな、YouTubeを参照したりなど出来ず、オンラインで遠隔会場と連携しながらビデオチャットでセッションを進行するという、海の向こうでは割とポピュラーな手法も取れず… これはイベントの一体感もあるので、ある程度の規模のイベントではやりたくなることの一つです。

実際、もしかしたらHanselmanさんとTeamsで完全オンラインでの相互コミュニケーション、というのも検討の一つとしてありましたが、まあ会場的に無理なので、実現するとなったら有償で会場を確保するかということも考えなければなりません。

(そうすると参加費500円とかでは無理ですし、そもそもそんなギリギリで会場を予約できるか怪しいし、堅苦しい契約書をかわさなければならないですよ、個人で。請求や領収、今だと税金をどうするかみたいな話も出てくるかも)

なので、主催が本当に小規模な勉強会を小規模な会場でやる分には良いんですが、ちょっとでも規模が大きくなり始めると、非常に辛くなります。そこを踏ん張ってやるからには、それを超える動機づけが必要になるでしょう。私の場合は自分の技術的な知見をシェアする、まあ淡良くば技術ポートフォリオとして誰かの目に止まるかどうか、ぐらいで、これで食っていく気は全く無い(食える気がしない)ので、必然的に掛けられる労力もそのぐらいになります。

さて、そういうわけで、様々な軸での考えがあるため、これらをどこにバランスさせて、セッションのネタにするのか、というのは、真面目に考えるととんでもなく難しいです。今回のネタとして、実は以下のような移り変わりがありました:

ごく初期

.NET Confの開催より後に開くことを前提に、.NET ConfでMSHQで公開されるビデオをネタに同じような感じでローカライズバージョンをやればいいかな(鼻ホジ)

環境リッチな会場で開催できるか

.NET Confを拾い上げるにしても、様々なセッションから何をネタにするのかは、上で挙げたような要素が、特に会場確保によって大きく変わるので、まずはどこの会場が使えるのかが最大の課題。

第一としていた候補はダメ。あと2~3考えていたが、それらはどれも環境的に厳し目なため(WiFiが無いとか、ちょっと会場の母体の趣旨と合わないとか)、この時点でセッション内容についてはかなり幅が狭くなる。

Hanselmanさんがビデオメッセージを送ってくれる(あるいはリアルタイムで!)かも??という打診

すごい話だけど、運営側としては、完全に確定だからあとはやれるように組む、というのと違い、不確定だとどう動けば良いのかわからずツライ(それもギョムの合間を縫って行動する必要があるので)。早く決まって欲しい… 早く… 早く…

別の会場を打診

アテの一つは早々に辞退(上記の後者側)、もう一つを打診。

自分の登壇ネタを検討する

.NET Confのセッションを拾い上げるという案は、会場の自由度があるから考えられる手法であって、もうそれは無理なので、結局自分でネタをひねり出すしか無い。非同期の話をしたいから、まあ2枠って事になるかな。

で、問題はもうほかの登壇者さんの募集とか打診とかして枠が埋まりつつ合ったので、もしHanselmanさんの話が現実化する場合、どこの枠(時間帯)をその話にするのかを考える必要がある。内容がキーノート的なものなら、時間的にはやっぱり最初に喋って欲しいけど、そうでないならべつの時間帯でも良いわけだけど、果たしてオンラインで喋るとすれば、本当にJST(日本時間)に合わせてくれるの?(いや、そもそも無理ちゃうの?)

WiFiすら使えない環境でどうやって実現するの? 安定性は? Hanselmanの生の話が聞けるなら、みたいな期待で参加者が押し寄せて、出来ませんでしたテヘッ、みたいな事態を想定しなきゃいかんのか、とか…

非常にツライ…

正直なところは、予定が決まらなかったら、こちらから辞退することも視野に入れていました。自分のネタを考えているはずが、全体の進行まで気になり始めて、それどころじゃない感じ。全然集中できない…

気を取り直してネタ考案

最悪、キャンセルになって自分で全部仕切る(つまり2枠考えて喋る)ということを想定して準備。なんか色々頑張っても無駄に終わりそうで、それもつらい。 最初の方は、.NET Confのキーノートを読み込んで概要にまとめて、面白そうなトピックを実演を交えて喋る、みたいに構成するしかないかな(もはやキャパオーバーだけど、主催自分だから仕方ない)。

3日前の夜に、Hanselmanさんのビデオが送られてきた!!

もうこの時点では、8割方断る方向で心中決まっていて(でないと間に合わない)、手間が空いたときに断りのメッセージを送るしか無いか、と、散々たる気持ちでいたのですが、こっちからメッセージを送る前に向こうから来た。しかも、いきなりビデオが来た… マジか… もうこれで構成するしか無い(非同期のセッションをほぼ書き終えていたので、首の皮一枚つながった感じ)

ということで、20分のメッセージをまずはAzure Video
Indexerで字幕データを作り
、それを補正(わりと全自動では駄目な感じの字幕なので、手修正する)して、ffmpegで合成して英語版字幕付きビデオを作る(成果物はここ、但しビデオそのものは含まれていません。時間の関係で、訳を直したりタイムラインを調整したりと言ったところはまだ細部が荒いです。関係者はエンハンスして欲しいです、是非に)。

2日前

ビデオの内容を読み込み、字幕がアレだったことから日本語字幕化する事を考え始めた。でも、SRTファイルをそのままGoogle Translatorにかけるとフォーマットが壊れてしまうのと、センテンスがつながっていないのが原因でダメな翻訳になってしまうこともあって考えあぐねていた(Azure Video Indexerで日本語訳出せばいいじゃん、と思うかもしれません。機能的にはありますが、まあ、察してください…)

それと同時に、セッションをどう進行するかを考えていたけど、ビデオ流しながら補足を入れつつ、(50分枠なので)後半は小さいコードをライブで何かデモするか、そうだねぇ、あんまりどこにも話題にかかっていないgRPCとか良いかもしれない、と考えて、概要を調べる。

その後、字幕の調整のために、ぼーっとSRTファイルの中身を見てたら、これ、フォーマット簡単だよね? もしや自分でパース出来るのでは?と思いつく。そこで、あえてビデオを流さず、いきなりライブコーディングでパーサーを書き、Google Translator APIを使って日本語に翻訳(4G経由の通信なので、ここにリスクはあるが)して日本語字幕ファイルを出力するツールを書き、ffmpegで合成して、日本語字幕のついたビデオをその場でみんなに見てもらう、というのは、かなりエキサイティングじゃないか?

というのを思いついたので、パーサーを書き始める。パーサーは簡単だった。しかもここに非同期イテレーターを使っているので、.NET Core 3としてもふさわしい。

そして、Google Translator APIのメチャクチャ簡単で使いやすいこと!! しかも.NET向けのNuGetパッケージもあって、面倒くさそうなアカウントキーもどこからサインアップしてGetすればいいのか、わかりやすいドキュメント… なんてことだ!15分ぐらいで実際に翻訳出来た!! ええ、なんでGoogle API使っているのかって、自問してましたよ…

しかしここで字幕特有の難しさにブチ当たる。それは、1センテンス(日本語なら読点まで)が必ず1画面に収まるわけではなく、分割されたセンテンスに推測不能(一定時間ではない)時間タグがついて分割されている、ということなのです。

“My name is (ここで分割されている) Kouji Matsui.”

“私の名前は松井幸治です。”

果たしてどこで分割する? System.Stringで英文の割合で発生する分割点と同じ割合の文字で切るか? 例えば、46/54なら、「は」の後ろで丁度切れるかもしれない。しかし、こんなにうまくフィットするわけがなくて、普通は非常に読みにくい位置で分割されてしまう…

では、形態素解析するか?形態素解析すれば、助詞とか句読点位置を検出してそこで区切って、自然な感じの字幕に出来るのでは? えーやったこと無いぞ、今からは無理では… .NET実装はあるのか?と考えて検索したら、あった: .NET形態素解析エンジンNMeCab (SourceForge.jp)

NMeCabで検索すると、割と紹介記事があるのだが… ライブラリ更新されてない… net20とか古すぎる。そうか、じゃあNMeCabをベースにチャッチャと移植して、これはこれで別の機会にネタとしてまとめよう、と思いついて移植を始める。

1日前

NMeCabの.NET Coreへの移植が出来た… アンセーフブロック使ってたり古いリソース(Settingsとか)して、ちょっとハマる点があったけど、そのあたりを軽く手を入れ、net40, net45, netstandard1.3, netstandard2.0, netstandard2.1対応のNuGetパッケージ、しかも標準のIPADIC同梱でありながら、自分の辞書を使う事も出来るように構成可能にして公開しました。

まあ、気がつくともう翌日が本番なのに何やってるんだろう、みたいな…

で、日本語字幕を付けるコードの続きを書き始めたのですが、日本語字幕センテンスを割合で再分割してSRTのフレームにする部分が思いの外面倒で、疲れ切った頭では正しいコードをひねり出せず… もう寝る時間が無くなって本番の運営に支障をきたしそうなので、無念だけどここでピボットする事に。

MeCab.DotNetのことと日本語字幕ツールの話は、また後日のネタにする事にして、当日はHanselmanの英語字幕付きビデオを流してコメントを入れつつ、その後にHanselmanのデモと同じことを私が再現して、多分言い足りない部分を補う感じにすれば、丁度50分に収まるだろうと考えました。

(今回のセッションではなく)不特定の人に英語技術ビデオを見た後に感想を聞くと、なんか腑に落ちない顔をしていることがあります。英語や英語の字幕のせいでピンときていないのかも知れないし、ビデオでは簡単にやって見せているけど、周到に準備してるからじゃないの?というような疑念があるんじゃないかと思っているので、同じことを目の前でやって見せることで払拭したかったのです。

(Hanselmanさんのデモ自体は、上記のような不安要素は殆どなくて、押さえるべきところが押さえられていて流石だなと思っています。)

ポイントはそんなに多くなくて、実際やって見せればシンプルな話だと思っていて、目の前で動いているプロセスの状態を見たりとか、今まさにWindows側でビルドしたコードが、WSLのubuntu上でそのまま動きましたよね(間違いなく)、みたいなことじゃないかなと。

そういうわけで、これで50分のセッションを構成することにしました。この内容の利点は、資料が一切不要なこと。自分の喋りと進行をその場で工夫して乗り切ればOKだ。この時点でもう23時なので、他の手段は取れそうにない…

妄想

ということで、今回のイベントの裏がどうなっていたのかが何となくわかっていただけたかと思います。半分は運営全体の話、半分は個人的な事情によるものですが、こうしてみるとやっぱり運営に割ける時間が足りない、運営に手間を取られすぎる、ということが大きかったと思います。

コミュニティへの貢献は色んな方法が考えられます。私が一番して欲しいと思っていることは、ぜひ、自分の時事ネタでも興味のあることや知見でもなんでも良いので、発表・シェアしてほしいです。元々そういう場なので。

そして、コミュニティに物理的な(生々しい話をするなら金銭的な)貢献をして頂ける場合は、運営の裏舞台、特に発表とは関係のない諸々の作業(これは私の場合ですが、それぞれのコミュニティの事情を読み取って)の負担を軽減してもらえて、結果的に時間を削減出来ると、本当に助かります。時間を確保出来るようになれば、発表する内容をより吟味したり、面白く楽しい内容に出来るようになり、より良いコミュニティイベントを開催・継続できることにつながると思います。

(あえて挙げるとすれば、名古屋の場合はやっぱり会場ですかね。リッチな環境が整備された会場が提供されると、悩みの半分は解決します。場合によっては7割ぐらい楽になる感じ。今や募集とかはconnpassのようなサービスで出来るし、あとは当日そこに行って発表する事を考えるだけなので。いつも会場提供して頂ける団体や組織には、本当に感謝しています :)

de:code 2019 CM12 – Interoperability of .NET assemblies.

de:code 2019

はじめに

この記事は、2019年5月29,30日に東京で開催される「de:code 2019」のセッション「CM12: .NET Core マルチプラットフォームの本質」に対応するサンプルコードと解説を公開しています。

ご注意: 原本はGitHubにあります。このブログの記事は、タグ1.0のコピーです。GitHubのドキュメントに対する検索性の悪さのため、ここに転載するものです(ブログスタイルに合わせるため、HTMLは多少変更していますが、記載内容は同じです)。

  • 本記事はKouji Matsuiが独自に構成、執筆したものであり、「Microsoft MVPパーソナルスポンサー」として提供するものです。de:code 2019のオフィシャルドキュメントではありません。
  • 本記事執筆時には、セッションの概要のみ公開されているため、内容が密接に連携しない場合があります。また、内容は、原稿執筆時の情報によります。
  • サンプルコードの開発検証環境は、Visual Studio 2017 (15.9.11)です。2019でも動作すると思われますが、都合により未確認です。また、Linux環境はWSL1のUbuntu 18.04 + dotnet-sdk-2.2 (2.2.204-1)で検証しました。
  • 読者の想定難易度は、セッションの基準であるLevel 400より多少低いところを出発点とし、章を追うことでセッションの基準難易度に近づくようにしています。できるだけ抽象的な解説を避け、具体例や図による解説、既存のコードへの参照を含めています。

著作権表記

  • Copyright (c) 2019 Kouji Matsui All rights reserved.
    本コンテンツの著作権、および本コンテンツ中に出てくる商標権、団体名、ロゴ、製品、サービスなどはそれぞれ、各権利保有者に帰属します。
  • ライセンスは外部参照を除き、MITとします。コードにはいかなる保証もありません。ご自身のプロジェクトに適用する場合は、各自の判断で行ってください。
  • この資料はコミュニティコンテンツです。ライセンスに従っていただけるのであれば、GitHub上でissueとpull requestを受け付けます

サンプルコード

  • このリポジトリ内に配置され、ドキュメント内からリンクされているので参照してください。
  • Azure PipelinesでCIしています: Azure PipelinesでCIしています

概要

.NETにおけるマルチプラットフォーム対応を支えるための、アセンブリレベルでの互換性の対処方法について、サンプルコードと解説を行います。章が進むにつれて、より細部についての解説を行っています。

  • マネージドサイド
    • インターフェイス分離
    • Bait and switchテクニック
  • アンマネージ連携
    • P/Invoke
    • ガベージコレクションの影響
  • ランタイムサイド
    • System.Private.CoreLib
    • ランタイム機能呼び出しの手段

マネージドサイド

マルチプラットフォーム戦略で最も一般的に使用されるテクニックは、インターフェイス分離と依存注入(Dependency Injection)でしょう。この章では、インターフェイス分離と、それに似たもう一つのテクニックについて解説します。

インターフェイス分離

インターフェイス分離は、最も一般的で基本的なOOPのテクニックです。.NETのインターフェイス型を使用して、異なる実装の詳細を、同一のインターフェイスで分離する事により、マルチプラットフォームに対応した処理として共通に扱えるようにします。

Part1_SplitByInterfaceプロジェクトは、以下の機能を持ちます:

  • 複数の値を入力すると、計算結果が出力される。
  • 入出力の対象は、コンソール(コマンドライン)と、GUI(WPF)である。

計算を司る共通のライブラリ「Calculator.Core.dll」と、それらを使用する各アプリケーションをインターフェイス定義で分離します。

コンソールアプリケーションの場合:

Part1_Diagram1

GUIアプリケーションの場合:

Part1_Diagram2

この図のように、右側の計算ライブラリには、プラットフォーム固有の実装は全く含まれず、左側のアプリケーション側に固有の実装を用意しておき、インターフェイス定義経由で計算ライブラリから操作できるようにします。

ConsoleCalculatorの実行結果:

Part1_ConsoleCalculator

WpfCalculatorの実行結果:

Part1_WpfCalculator

ここでは、手動での依存注入(メソッド引数で直接注入する)を行っています(より発展した例として、MEFや各種依存注入ライブラリを併用することが考えられますが、割愛します)。このサンプルコードで重要な点を以下に挙げます:

  • 計算を実行する本体(Calculator.Core)ライブラリは、プラットフォーム中立であり、netstandard2.0としてビルドしている。
  • Calculatorライブラリには、コンソールやGUIへの入出力コードは一切含まれておらず、それぞれ独立したアセンブリ(ConsoleCalculator, WpfCalculator)にのみ含まれる。
  • 同じ手法を用いて、その他のプラットフォームにも移植できる。その際、計算ライブラリに手を入れる必要がない。

Bait and switchテクニック

このテクニックは、以下のような二種類のアセンブリを用意しておき、コードのビルド時と実行時で異なるアセンブリを使用するものです:

  • ビルド用アセンブリ:
    ユーザーのアプリケーションやライブラリのビルドに使用するためだけのアセンブリ(「参照アセンブリ」と呼ぶ)。アセンブリ内の実装コードは、まったく存在しないか部分的にしか存在しない。コンパイラが、型のメタデータ定義(クラス名やメソッド名、型や引数群など)さえ確認できれば良い。
  • 実行時用アセンブリ:
    実行時に使用するアセンブリ。それぞれのプラットフォームに応じたアセンブリ(但し、アセンブリの厳密名とインターフェイス定義は上記と同一)を用意する。当然、アセンブリ内部の実装はそれぞれのプラットフォームによって異なる。

これは.NET標準に含まれるライブラリでも活用されています。例えば、「System.Runtime.dll」について考えます。ビルド時に参照するアセンブリ(SDKに含まれる)は、実は内部に実装が存在しません。しかし、型のメタデータ定義は存在するため、ビルド自体は成功します。その結果、アプリケーション内部には、System.Runtime.dllを使用している、という参照情報だけが残ります。

次に、アプリケーションを実行環境に移して実行すると、実行環境に存在する「別の」System.Runtime.dllが使用されます(Windowsの場合、GACに配置されているか、.NET Coreのランタイムディレクトリに配置されています)。このアセンブリにはプラットフォーム環境に依存した実装が入っていますが、型のメタデータ定義は同一であり、アセンブリローダーは問題なくロード出来るため、アプリケーションが実行できます。

Part2_Diagram

もちろん、参照アセンブリとメタデータが異なっていた場合(例えば、あるクラスのメソッドの引数の型が違っているなど)は、実行時にMissingMethodExceptionなどの例外が発生する可能性があります。そのため、参照アセンブリと実行時アセンブリのメタデータが同一となるように、注意深く実装しなければなりません。

Part2_BaitAndSwitchプロジェクトは、最初の計算アプリケーションをBait and switchテクニックを使って実装したものです:

  • 参照アセンブリとして、Host.Referenceプロジェクトを用意する。実装は空で、クラスとメソッドが定義されているだけである。
  • 上記参照アセンブリの定義と同一だが、それぞれコンソールとWPFに対応する実装を含んだ、Host.ConsoleプロジェクトHost.Wpfプロジェクトを用意する(アセンブリのファイル名はすべて同一で、Host.Core.dll)。
  • 他のアセンブリはすべて共通で、アプリケーション実行前にHost.RefereceをHost.ConsoleまたはHost.Wpfに入れ替える。
    • Console.batから起動すると、Host.Consoleに置き換えて実行する。
    • Wpf.batから起動すると、Host.Wpfに置き換えて実行する。
    • 置き換えずに実行すると、Host.Referenceを使うことになるため、実行時例外が発生する。

Bait and switchテクニックは、実装を入れ替えることができると言う点で、インターフェイス分離とよく似ています。しかし、インターフェイス分離の場合は、設計者が最初から適切に分離設計をする必要があります。Bait and switchは、CLRがアセンブリを同一視する方法に乗じて、コードに全く手を入れずに、プラットフォーム依存処理を入れ替えることを可能にします。

以下にこの2つの分離手法を比較した表を示します:

手法 メリット デメリット
インターフェイス分離 OOPの一般的な手法。誰でもリスクなしで使用でき、コンパイラが型チェックでエラーを検出してくれる 初めから使用する前提で設計する必要がある
Bait and switch 後から依存性の分離を行うことができる 型チェックはコンパイル時に行われないため、問題があると実行時エラーを起こす

かつてのPortable Class Libraryは、Bait and switch手法で互換性を実現しました。ここまでの解説で、PCLが何十というプロファイルを持っていたことを考えると、互換性を維持することがいかに困難であったかが想像出来ます。

Bait and switchを適用する場合は、メタデータの不一致を何らかの方法で開発時に検証できるようにするか、あるいはテンプレートコードの自動生成や合成ができるようにするなどの手法を用意し、開発の困難さを軽減することが重要です。


アンマネージ連携

P/Invokeは、.NETのアンマネージドコード連携の手段の一つです。もう一つはCOM連携ですが、本稿では割愛します。

P/Invoke

P/InvokeはCLRに組み込まれている、ネイティブコードライブラリとの連携機能です。また、C#などのコンパイラは、P/Invokeを簡便なシンタックスで利用可能にしています。例えば、以下のコードは、C#から直にWin32 APIを呼び出します:

public static class Program
{
    // Win32デバッグメッセージ出力API
    [DllImport("kernel32.dll", CharSet=CharSet.Unicode)]
    private static extern void OutputDebugString(string lpOutputString);

    public static void Main(string[] args) =>
        OutputDebugString("Hello P/Invoke!");
}

ネイティブコードのライブラリがダイナミックリンクライブラリとして用意されていれば、全く同じ手法でコードを記述することが出来ます。LinuxやMacOSといったプラットフォーム向けのコードは、それぞれで利用可能なネイティブコードライブラリが異なることが多々ありますが、P/Invoke自体の使用方法は同じです。.NET Coreやmono向けのライブラリも、同じように作ることができます。特に、ネイティブコードライブラリがクロスプラットフォームで(dll、dylib、soなど、ライブラリの形式が異なっても)同じAPIを提供していれば、同一のP/Invokeコードがそれぞれのプラットフォームで実行できます。

前章で述べた、インターフェイス分離設計やBait and switchテクニックをP/Invokeと組み合わせると、.NETでマルチプラットフォームの一貫したライブラリ設計を行うことができます。つまり、前章の依存コードの部分をP/Invokeを使って実装すれば、そのプラットフォーム固有のAPIがネイティブコードであったとしても、部品として使える共通のアセンブリを構築できる、と言うことです。

Part3_PInvokeプロジェクトは、これまでの説明を踏まえた、マルチプラットフォームアプリケーションです:

  • コマンドライン引数に指定した文字列が、デバッグメッセージとしてシステムに送信される。
  • デバッグメッセージは以下のように処理される:
    • Bait and switchテクニックを使用する。Windows用とLinux用で、実装は異なるが共通のメタデータを持つアセンブリを用意する。
    • Windows環境の場合、Win32のOutputDebugString APIを使用して出力する。これはSysinternalsのDebugViewユーティリティで確認できるほか、各種デバッガがアタッチされていれば、デバッガ上で確認できる。
    • Linux環境の場合、syslog APIを使用して出力する。

注意: 一般的な開発でこの程度の規模であれば、Bait and switchテクニックを使用する理由は全くありません。ここでは、後述のランタイムサイドの解説に関係するため、あえてBait and switchで実装を行っています。

DebugMessageプロジェクトは、DebugMessage.Referenceの参照アセンブリを使ってビルドします。実際に必要なのは、実行用のアセンブリ、DebugMessage.Win32とDebugMessage.Linuxです。前章のサンプルと同じく、これらを実行時に置き換える必要があります。

以下はWindows環境でWin32.batを実行したときの、DebugViewユーティリティの出力です:

Part3_DebugMessage1

以下はWSL1のUbunt 18.04環境でlinux.shを実行したときの、/var/log/syslogの出力です (syslogを使うため、あらかじめservice rsyslog startでrsyslogを動かしておく必要があります):

Part3_DebugMessage2

ガベージコレクションの影響

P/Invokeを初めて使う場合、ネイティブコード実行中の.NETインスタンスがどのように扱われるのか、疑問に思う方も居ると思います。例えば、.NETで生成したインスタンスをP/Invokeを通じてネイティブコードに渡した場合、ガベージコレクタが意図せず回収する可能性は無いのか、という点です。

例えば、以下の疑似コード:

// foo.dll内のReadToBuffer APIを呼び出す
// extern "C" void ReadToBuffer(uint8_t* pBuffer, int32_t size);
[DllImport("foo.dll")]
private static extern void ReadToBuffer(byte[] buffer, int size);

public void Read()
{
    var buffer = new byte[100];

    // byte配列は自動的にマーシャリングされる。
    // API呼び出し中にbufferがGCによって回収されることは無いのか?
    ReadToBuffer(buffer, buffer.Length);

    // (A)
    var data0 = buffer[0];
}

ガベージコレクタは、引数・ローカル変数・フィールドなどからインスタンスが参照されているかどうかを追跡しています。この場合、bufferのインスタンスが回収されてしまうことはありません。一般的に、APIが実行を終えて戻ってきたとき(A)、当然bufferにアクセス出来ることが期待できます。

(一般的には、マーシャリングという用語は、データ形式の相互変換とスレッドなどのコンテキスト切り替えの両方の意味で使いますが、本稿では前者のデータ形式の相互変換にのみ使用します。)

ガベージコレクタがインスタンスを回収するかどうかの他にも、ネイティブコードとして考慮すべき点があります。それは、インスタンスの物理的なアドレス(ポインタ値、但し仮想メモリ上の)が変わるのか同じ位置にあるのか、ということです。ガベージコレクタの手法にもよりますが、.NET CLRの場合は、ヒープコンパクションと呼ばれる機能により、必要に応じてインスタンスが移動する事があります。これらをまとめると、以下のようになります:

維持される 回収されるかも知れない
移動しない Pinned
移動するかもしれない Normal Weak

ネイティブコードライブラリを設計する場合は、CLRの伺い知れないところで動作するため、通常、インスタンスが維持され、かつインスタンスが移動しないこと(Pinned)が要求されます。稀にインスタンスが存在し続けて欲しいが、インスタンス自体にはアクセスしない(Normal)、という場合もあります(Weakについては本資料では割愛します)。

先程説明したように、インスタンスが追跡可能な状態であれば、何もしなくてもNormalと同様に維持されます。インスタンスがどこからも追跡できなくなるような状況下では、明示的にNormalまたはPinnedとしてCLRに通知する必要があります。

GCHandle構造体を使用すると、追跡可能かどうかにかかわらず、PinnedやNormalの状態を作り出すことができます。Pinnedとした場合は、インスタンスへの生のポインタを取得できます。例えば:

// foo.dll内のReadToBuffer APIを呼び出す
// extern "C" void ReadToBuffer(uint8_t* pBuffer, int32_t size);
[DllImport("foo.dll")]
private static extern void ReadToBuffer(IntPtr buffer, int size);

public void Read()
{
    var buffer = new byte[100];

    // GCHandleを使って生のポインタを得る(手動マーシャリング)
    // 生のポインタを得るには、アドレスが固定されなければならない(Pinned)
    var bufferHandle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    try
    {
        // pBufferは配列の先頭を示す、本物のアドレス
        // (この他にToIntPtrメソッドがあるが、これはGCHandleの抽象表現であり、ポインタとしては使えない)
        var pBuffer = bufferHandle.AddrOfPinnedObject();
        ReadToBuffer(pBuffer, buffer.Length);
    }
    finally
    {
        // 不要になったら解放する必要がある。解放しなかった場合はメモリリークする。
        bufferHandle.Free();
    }
}

このコードは、先程の例とほぼ同じです。P/Invoke呼び出しのためのポインタへのマーシャリングを自動で行うか、手動で行うかの違いです。以下に特徴を示します:

手法 メリット デメリット
自動マーシャリング 安全で簡潔に記述できる マーシャリングのタイミングをコントロールできないため、パフォーマンスの問題につながることがある
手動マーシャリング マーシャリングのタイミングをコントロールできるため、パフォーマンスを最大化出来る ガベージコレクタに誤って解放されたり、メモリリークしないように注意を払う必要がある

例えば、ネイティブライブラリの関数によっては、引数として渡されたポインタを保持して、ずっと後になってから参照して使うこともあります。そのような場合には、最初に明示的にNormalとし、明らかにネイティブコードで使用されなくなるまで保持し、最終的に解放する必要があります。

また、バッファへのポインタが頻繁に必要になる場合は、自動マーシャリングするよりも、一度だけ手動マーシャリングしてPinnedとし、連続してポインタを使用し、最後に手動で解放したほうが効率的かもしれません。

Part4_Marshalingプロジェクトは、手動マーシャリング処理の例です:

  • Win32NativeLibraryプロジェクトは、Visual C++のネイティブプロジェクトで、GenerateData APIを公開します。GenerateData APIは指定されたバッファアドレスに、数列を書き込みます。
  • BothMarshalingTypesプロジェクトから、P/InvokeでGenerateData APIを呼び出します。
  • この呼び出しの、自動マーシャリングと手動マーシャリングの例を示します。
  • 両者の処理時間差を、簡易的に計測します。具体的な結果は、環境に依存するためここでは示しませんが、自動マーシャリング > 手動マーシャリング、となっている事を確かめることが出来ると思います。
    • 両者の差はかなり小さいはずです。なぜなら、配列のマーシャリングコストはそれほど高くないからです。

この例では、非常に単純なデータ構造(配列)ですが、実際のマーシャリングシナリオはもっと複雑です。場合によっては、手動マーシャリングを行わないと、現実的なパフォーマンスを引き出せない可能性があります。マーシャリングの詳細については、「プラットフォーム呼び出しによるデータのマーシャリング」を参照してください。

以下に、P/Invokeを使用する際に、暗黙または意図して注意する必要のある事項をまとめます:

  • マーシャリングを自動で行うのか、手動で行うのか、及びパフォーマンスへの影響。
  • ガベージコレクタによって意図せずインスタンスが移動・解放されないようにする、あるいは正しく解放されるようにする。

ランタイムサイド

ここまでは、一般の開発者がマルチプラットフォーム対応アプリケーションを設計する場合において考慮すべき点を、如何に依存コードを分離して実現するか、という視点で解説しました。

この節では、.NET自身が、マルチプラットフォーム対応するためにどのような手段を使用しているのかを述べます。つまり、.NET Coreにおいて、coreclrやcorefxがどのようにマルチプラットフォームを実現しているのかという点に注目します。

基本的に、今まで述べてきたテクニックが利用できる部分では、そのまま利用できます。例えば、System.IO.FileStreamクラスは、ファイルへのアクセスにWindowsとLinuxで異なるAPIを使用するはずです。しかし、インターフェイスで分離されているわけではないため、Bait and switchを利用して切り分け、P/InvokeでそれぞれのAPIを呼び出している、と想像出来ます。

System.Private.CoreLib

かつて、.NET Frameworkでは、BCL (Base class library)を、「mscorlib.dll」という単一のアセンブリで担っていました。

.NET Core 1.0のリリースにあたり、.NET Frameworkの歴史で巨大化してしまったmscorlib.dllを分割するために、前節で例示した「System.Runtime.dll」と「System.Private.CoreLib.dll」、その他の細かいアセンブリ群に細分化しました。特に、.NET CoreCLRランタイムと密接に絡む実装が含まれるアセンブリが、System.Private.CoreLibアセンブリです。

Part5_Diagram1

この図は、Part1_SplitByInterfaceプロジェクトのCalculator.Core.dllをILSpyで確認したものです。左上がCalculator.Coreアセンブリで、netstandardアセンブリに依存しています。netstandardアセンブリはかなり多くのアセンブリに依存していますが、その中にSystem.Runtimeアセンブリが存在します。

System.RuntimeアセンブリはSystem.Private.CoreLibアセンブリに依存し、ここから先に依存する.NETのアセンブリはありません。そして、System名前空間を確認すると、見慣れたArray,Boolean,Byte,Charといった型が定義されていることがわかります。

  • 余談: monoは、.NET Framework 1.0が出てから数年でプロジェクトが始まっていますが、アセンブリ参照の厳密名さえ同じであれば、実体が別のアセンブリをロードして実行できるという性質を利用して、mscorlib.dllという同じ名前を使いつつ中身はmono独自に実装されている、別のアセンブリをロードすることで、.NET Frameworkとの互換性を実現していました。マルチプラットフォーム動作の実現については、後述するQCallやFCallに類似する仕組みを用いて、同一の実装アセンブリで実現していました。ただし、monoの実装は、corefxのコード取り込み作業が継続的に進んでいることもあって、流動的です。

System.Private.CoreLibアセンブリ内には、かなり多くのクラスや構造体が定義されています。これらの型のメソッドは、そのプラットフォームに依存した処理が実装されているはずです。例として、System.Diagnostics.Debugクラスを追ってみましょう:

Debug.Write(string)

internal static Action<string> s_WriteCore = WriteCore;

public static void Write(string message)
{
    lock (s_lock)
    {
        if (message == null)
        {
            // 空文字を出力
            s_WriteCore(string.Empty);
        }
        else
        {
            if (s_needIndent)
            {
                message = GetIndentString() + message;
                s_needIndent = false;
            }
            // 引数の文字列を出力
            s_WriteCore(message);
            if (message.EndsWith(Environment.NewLine))
            {
                s_needIndent = true;
            }
        }
    }
}

付加機能のためのコードが前後にありますが、結局s_WriteCore(message)でメッセージを出力しているようです。このフィールドはActionデリゲートで、初期化時にはWriteCore(string)メソッドを指しています。

Debug.WriteCore(string)

private static void WriteCore(string message)
{
    lock (s_ForLock)
    {
        if (message == null || message.Length <= 4091)
        {
            WriteToDebugger(message);
        }
        else
        {
            int i;
            for (i = 0; i < message.Length - 4091; i += 4091)
            {
                WriteToDebugger(message.Substring(i, 4091));
            }
            WriteToDebugger(message.Substring(i));
        }
    }
}

引数のmessageを4091文字づつ分割して、WriteToDebugger()メソッドで出力しています (4091文字づつである理由については定かではありませんが、PAGE_SIZE * 2に収まる範囲、とかそういう事かもしれません)。

Debug.WriteToDebugger(string)

private static void WriteToDebugger(string message)
{
    if (Debugger.IsLogging())
    {
        Debugger.Log(0, null, message);
    }
    else
    {
        Interop.Kernel32.OutputDebugString(message ?? string.Empty);
    }
}

見慣れたOutputDebugStringというシンボルが出てきました。

Interop.Kernel32.OutputDebugString(string)

// Win32 API OutputDebugString
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "OutputDebugStringW", ExactSpelling = true)]
internal static extern void OutputDebugString(string message);

これで、Windows環境のSystem.Private.CoreLibアセンブリには、Win32 APIを直接呼び出す実装が含まれていることが確認できました。Linux版.NET Core SDK 2.2のSystem.Private.CoreLibアセンブリをILSpyで見ると、以下のようになっていました:

(/usr/share/dotnet/shared/Microsoft.NETCore.App/2.2.5/ 配下にあります。バージョンは各自の環境に合わせて読み替えて下さい)

private static void WriteToDebugger(string message)
{
    if (Debugger.IsLogging())
    {
        Debugger.Log(0, null, message);
    }
    else
    {
        Interop.Sys.SysLog((Interop.Sys.SysLogPriority)15, "%s", message);
    }
}
[DllImport("System.Native", EntryPoint = "SystemNative_SysLog")]
internal static extern void SysLog(SysLogPriority priority, string message, string arg1);

Windowsの方はWin32 APIを直接呼び出すようになっていましたが、Linuxの方はSystem.Nativeと付けられたネイティブライブラリのSystemNative_SysLogメソッドを呼び出しているようです。確認してみると、確かに存在します:

Part5_LinuxSystemNative

このネイティブライブラリを、「PAL (Platform abstraction layer)」と呼んでいます。歴史的に、Windowsへの実装が第一であったことからか、Win32 APIのインターフェイスを模すように作られて、今に至るようです。

corefxの実装

以上を踏まえて、ソースコードがどうなっているかを確認してみます。System.Diagnostics.Debugクラスのコードはここにあります。partial classで分割されていて、WriteToDebuggerメソッドの実装は、DebugProvider.Windows.csと、DebugProvider.Unix.csの2つのファイルに分割されています。おそらくビルド時に、Windows版とLinux版で使い分けるのでしょう。内容はILSpyで見たものと全く同じです。

ところで、.NET CoreはLinuxだけではなく、FreeBSDもサポートしています。しかし、DebugProvider.Unix.csは名前の通り、Unix環境で共通に使われるようです。ここではこれ以上掘り下げませんが、LinuxとFreeBSDで実装に違いがあるとすれば、以下の図のようにネイティブライブラリのPALの方で吸収しているのではないかと思います。

Part5_Diagram2

  • 余談: PALの呼び出しは、#if FEATURE_PALと言うプリプロセッサ指令で切り分けられていることがあります。興味深いことにこのシンボル名は、.NET Frameworkのソースコードを参照できる「referencesource.microsoft.com」でも、ところどころで見ることが出来ます。つまり、.NET Core世代ではなく、.NET Frameworkの頃から、PALによるネイティブ実装の切り替えを想定(あるいは使用)していた可能性があります。残念ながら、サイトで見ることができるソースコードはマネージド側だけなので、PALが何に移植されていたのかはわかりません。

ランタイム機能呼び出しの手段

前節では、プラットフォームごとの実装を切り分けるのに、System.Private.CoreLibアセンブリを基準として、Bait and switchで実行アセンブリが差し替わっていることを確認しました。例としてDebugクラスの実装を追いましたが、そこでは普通にP/Invoke機能を使ってネイティブライブラリを呼び出していました:

// Windows環境において、Win32 APIを呼び出すP/Invokeの定義
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "OutputDebugStringW", ExactSpelling = true)]
internal static extern void OutputDebugString(string message);

// Linux(Unix)環境において、PALの実装を呼び出すP/Invokeの定義
[DllImport("System.Native", EntryPoint = "SystemNative_SysLog")]
internal static extern void SysLog(SysLogPriority priority, string message, string arg1);

しかし、.NET CLRの内部機能を呼び出す場合は、P/Invokeではなく、別の方法を使用する必要があります。以下に、3種類の呼び出し方法QCall, FCall, HCallについて示します。それぞれの手法は異なり、目的によって使い分けられています。同じ機能を別の呼び出し方法で呼び出すことは出来ません。

QCall

QCallは、P/Invokeにかなり似ています。P/Invokeで想定できるマーシャリングは、プリミティブ型であればそのまま使えます。文字列も、自動的にLPCWSTR (const wchar_t*)でマーシャリングされます。P/Invokeの場合、文字列の返却にStringBuilderを使いますが、代わりの低コストな手段が用意されています。それに加えてP/Invokeでは実現しない、ネイティブからの例外のスローが可能です。

内部でQCall呼び出しをラップする、C#のメソッドの実装例を示します:

// QCallでネイティブコードを呼び出すための宣言
// (P/Invokeと似ているが異なる。基本的にpublicにはできない)
[DllImport("QCall", CharSet = CharSet.Unicode)]
private static extern bool Foo(
    int arg1, string arg2,
    System.Runtime.CompilerServices.StringHandleOnStack returnValue);

// ラップして安全なメソッドとして公開
public static string Foo(int arg1, string arg2)
{
    // 文字列を結果として受け取る場所を、スタックに用意する
    string returnValue = null;

    // ネイティブメソッドの呼び出し
    // スタック上のreturnValueを指すように、StringHandleOnStackが初期化される
    if (!Foo(arg1, arg2,
        System.Runtime.CompilerServices.JitHelpers.GetStringHandle(ref returnValue)))
    {
        throw new InvalidOperationException("...");
    }
    return returnValue;
}

第一に、QCallはP/Invokeと同じようにDllImport属性を使います。ライブラリ名は固定的に”QCall”を指定しておきます。ネイティブ側の実装は、マネージド側に値を返したい場合(この例のように、戻り値として文字列を返す想定)は、スタック上の文字列参照を追跡できるようにするStringHandleOnStackという型を使います。同様に、任意の参照型(objref)には、ObjectHandleOnStackを使います。これらを使うことで、P/Invokeで必要であったマーシャリングのコストを削減できます。

しかし、そもそもStringHandleOnStackや、そのインスタンスを取得するJitHelpersは、publicではありません(ソースコードはこちら)。

つまり、QCallを処理するメソッドは、ほぼ公開メソッドにはなり得ず、System.Private.CoreLibアセンブリ内に閉じている必要があります。したがって上記の例のように、QCallメソッドはprivateとし、publicなファサードメソッドを定義する必要があります。そして、当然ですが、QCallの対象となるネイティブメソッドの実装は、.NET CLR内部に用意しておく必要があります。

仮に実装した場合、以下のような体裁を持ちます:

// C++で記述する
BOOL QCALLTYPE FooNative::Foo(
    int arg1, LPCWSTR arg2,
    QCall::StringHandleOnStack returnValue)
{
    // QCall呼び出しを処理可能にするためのマクロ
    QCALL_CONTRACT;

    // QCall処理中に発生する例外が正しく伝搬するようにするマクロ
    BEGIN_QCALL;

    if (arg1 < 0)
    {
        // 例外をスローする
        COMPlusThrow(kArgumentException, L"arg1");
    }

    // 文字列もC/C++で想定するように、普通に使える
    // (つまり、文字列を示すポインタがGCによっていきなり移動したりしない。Pinned相当)
    printf("%d, %S", arg1, arg2);

    // 文字列への参照を、直接呼び出し元のスタックに設定できる
    // (P/InvokeにおけるStringBuilderほどのオーバーヘッドはないが柔軟性には欠ける)
    returnValue.Set(L"Hello");

    // QCallの後始末マクロ
    END_QCALL;

    // (BEGIN_QCALLからEND_QCALLまでの間で、returnすることは出来ない)
    return TRUE;
}

このメソッドを呼び出せるようにする(マネージド側のQCall宣言との結び付け)には、ecalllist.hヘッダファイルに対応を記述する必要があります(次に解説するFCallの宣言も、ここに記述します)。

ところで、QCallで実装したネイティブコードを実行中は、ガベージコレクションが起きる可能性が常に存在します。つまり、P/Invokeのときと同様に、ヒープに確保されたインスタンスに生ポインタでアクセスする場合は、ガベージコレクションによって移動したり回収されたりしないように、実装者が管理する必要があります。

FCall

QCallの概要を見ると、その他の呼び出し手段は必要ないように見えます。FCallがQCallと決定的に異なるのは、FCallの呼び出しを実行している間、自動的にガベージコレクタが停止することです。

P/Invokeの場合、ガベージコレクションによる破壊を回避するには、GCHandleを使ってインスタンスをPinned状態にするかNormal状態に強制することでした。QCallの場合は、QCallのためのヘルパー型を使うことで、安全に処理することができますが、そこには少なからずコストを伴います。

FCall呼び出しを使用すると、その間ガベージコレクタが動かないため、いつでも生ポインタをPinnedされているものとして扱えるようになります。当然ですが、FCallで呼び出されたメソッドの処理時間が長引くと、.NETプロセス全体に悪影響を及ぼします。

以下は、System.Stringクラスのインデクサのgetter実装です:

[IndexerName("Chars")]
public char this[int index]
{
    // System.Stringのインデクサ: get_Chars()
    // DllImportではなく、以下の属性を使う
    [MethodImpl(MethodImplOptions.InternalCall)]
    get;
}

ILSpyを定常的に使っている方は、メソッド呼び出しをたどっていくと、この属性に行き当たるのを見たかも知れません。あなたが目にしたのは、.NET CLR内部のFCallメソッドへの扉、と言うわけです。

ecalllist.hには、インデクサに対応するネイティブメソッドの対応付けが定義されています:

// ecalllist.h: get_Chars()とGetCharAt()を結びつける宣言
FCFuncStart(gStringFuncs)
    FCIntrinsic("get_Chars", COMString::GetCharAt, CORINFO_INTRINSIC_StringGetChar)
FCFuncEnd()

そして、対応するネイティブ実装です:

// C++で記述する
FCIMPL2(FC_CHAR_RET, COMString::GetCharAt,
    StringObject* str, INT32 index)    // (strは生ポインタであることに注意)
{
    // FCall呼び出しを処理可能にするためのマクロ
    FCALL_CONTRACT;

    // GCを長時間止めておく想定はなく、処理中にGCポーリングを必要としない
    FC_GC_POLL_NOT_NEEDED();

    VALIDATEOBJECT(str);
    if (str == NULL) {
        // 例外をスローする
        FCThrow(kNullReferenceException);
    }
    _ASSERTE(str->GetMethodTable() == g_pStringClass);

    // indexが文字数範囲内なら、バッファポインタから1文字を返す
    if (index >=0 && index < (INT32)str->GetStringLength()) {
        return str->GetBuffer()[index];
    }

    // 例外をスローする
    FCThrow(kIndexOutOfRangeException);
}
// (FCallのためのFCIMPLマクロの終端定義)
FCIMPLEND

この例では、対象のSystem.Stringインスタンス(つまり文字列のthis)は、str引数で参照されますが、これは生ポインタです。つまりFCallなら、ガベージコレクタがインスタンスを移動したり回収したりする心配をすることなく、生ポインタを直接操作することが出来ます。

インデクサアクセスは、上記の通りO(1)です。FCallではガベージコレクタが動かないと言っても、コードのサイズは小さいため、処理中にそのタイミングが重なる可能性はかなり低いと思われます。もし、FCallで呼び出されたメソッドが長時間の処理を必要とする場合は、途中で(問題ないタイミングで)一時的にガベージコレクションを行わせることができます。この事をGCポーリングと呼びます(上記コードでは使用していません。ここでは詳細を省きます)。

FCallとQCallの使い分けについての決定的な差は、上記の通り、ガベージコレクション操作とマーシャリングの影響を最小限に抑えたいかどうかが、判断の基準となります。あるいは、非常に密接にCLRの内部操作と連携する必要がある場合(その間にガベージコレクションが実行されては困る場合)にも、FCallを使うことがあります。

HCall

HCallはFCallとほとんど違いがありません。HCallとして実装したネイティブメソッドから例外をスローした場合に、記録されるスタックフレームにHCallのメソッドが含まれなくなります。これは、主にJITが生成したコードが使うヘルパーメソッドに使われ、例外をスローする際に内部的なメソッドを記録しないようにしています。例えば、JIT_ChkCastArrayは、配列がキャスト可能かどうかの判定を行うHCallメソッドです。キャストできない場合に例外をスローしますが、例外のスタックトレースにこのメソッドは含まれません。

まとめ

マルチプラットフォームの環境によっては、表面的に同じ操作を行うメソッドであっても、内部のネイティブコード連携では、QCall,FCall,HCall(場合によってはP/Invoke)を使い分けるということがあるかもしれません。そのような場合でも、Bait and switchテクニックで、System.Private.CoreLibアセンブリが差し替わることで、柔軟に対応できることがわかります。

Try writing code using both the Azure Sphere Development Kit and C#

This blog post’s 23th entries of “.NET, .NET Core and mono runtimes/frameworks/libraries Advent Calendar 2018 (Japanese)” and “Seeed Users group Advent Calendar 2018 (Japanese)”.

(For Japanese edition, click here / 日本語の記事はこちら)

“Azure Sphere Development Kit” is the evaluation electorical circuit board for “Azure Sphere MCU” produced by Seeed copmpany and the development SDK produced by Microsoft.

“MT3620” is a MCU chip of “Azure sphere specification” designed by MEDIATEK company. In this blog post, it evaluation board is called “MT3620.”

Currently we can design and write a program only “C language” because Azure Sphere SDK limitation, so I tried using for C# language with IL2C.

The IL2C is an open source project I started. It’s a translator for ECMA-335 CIL/MSIL to C language.

(Hosting on GitHub)

IL2C translation process illustrates in this graph. We can write a program with C# language. Then generate the .NET assembly file using the C# compiler (Roslyn).

Next step, IL2C will generate the C language source code both the header “SphereApp.h” and source code “SphereApp.c” files from the assembly file “SphereApp.dll”.

Finally we can build native binary using the target platform C compiler.

In the short word, “Write code using both the Azure Sphere Development Kit and C#” ;)

We’re able to receive advantage for using IL2C, very little footprint less than .NET Core native, ngen and mono with tiny option. I shown demonstration for IL2C on these platforms:

  • Polish notation calculator: On the Win32 native application. (Truly native apps without .NET CLR)
  • Polish notation calculator: The UEFI application. (Truly native apps without any OSes)
  • Polish notation calculator: M5Stack with ten-key block option. (The embedded apps on ESP32)
  • The kernel mode WDM driver, it’s realtime hooking and overwriting for the storage sector fragment.
  • LED arrow feedback with the accelometer sensor on micro:bit. (Both C# and F#)

That’s meaning for we can use the C# language power to any device and any platform. The goal for this blog post, I’ll add Azure Sphere in this list.

If you’ll test this post, we have to receive the device claim at first time use MT3620, I recommend reading for several Azure Sphere SDK documents.

And you can refer the total document for MT3620 in this Seeed studio site.

(By the way, since I don’t have work since next year 2019 ¯\_(ツ)_/¯ If you have a job that will use this solution, please send twitter DM (@kozy_kekyo) so I’m welcome ;)


The strategy: Analyze the LED Blinker sample SDK code

This is a part of the LED Blinker sample code for Azure Sphere development kit. (You can see all code by creating the Blinker sample project at “New solution” menu on Visual Studio 2017.)

/// <summary>
///     Main entry point for this application.
/// </summary>
int main(int argc, char *argv[])
{
    Log_Debug("Blink application starting.\n");
    if (InitPeripheralsAndHandlers() != 0) {
        terminationRequired = true;
    }

    // Use epoll to wait for events and trigger handlers, until an error or SIGTERM happens
    while (!terminationRequired) {
        if (WaitForEventAndCallHandler(epollFd) != 0) {
            terminationRequired = true;
        }
    }

    ClosePeripheralsAndHandlers();
    Log_Debug("Application exiting.\n");
    return 0;
}

I feel there are several topics:

  • We can show the logging message by the “Log_Debug” function.
  • There’re device initialization and finalizing code at the “InitPeripheralsAndHandlers” and the “ClosePeripheralsAndHandlers” functions.
  • The center code has the polling structure.

I analyze at the InitPeripheralsAndHandlers function:

static int InitPeripheralsAndHandlers(void)
{
    struct sigaction action;
    memset(&action, 0, sizeof(struct sigaction));
    action.sa_handler = TerminationHandler;
    sigaction(SIGTERM, &action, NULL);

    epollFd = CreateEpollFd();
    if (epollFd < 0) {
        return -1;
    }

    // ...

These code fragments use the POSIX signals and the epoll API. Azure Sphere contains the Linux kernel (But fully customized, distributed by Microsoft and we can’t do manipulating directly), so our application code can use these APIs.

And, we don’t immediately know where the LED is blinking… The sample contains a lot of code fragments.

I feel this Blinker sample code is too complex and painful for first step!! Because these code are overwork strictly. It contains the discarding resource, multiplex events using epoll API and using the timers. It’s noisy, but we can improve and hiding it if we use C# language abilities!

This is agenda for this post:

  1. Prepare for use the C# language.
  2. Only LED blinking.
  3. Handles button input and completes Blinker like code.
  4. Improves by C# language style.
  5. Comparison binaries between C language and C# language.

Step1: Prepare for use the C# language

This link contains the completed code on the GitHub repository. If you try this sample code, you have to clone from this repo and folloing these steps below:

  1. Enable C# language, C++ (VC++) language and NUnit3 test adapter from extension manager on Visual Studio 2017.
  2. Open the il2c.sln file and build entire solution. If you can’t success building, you can skip next step for the unit tests.
  3. Run all of the unit tests at the Test Explorer. The test execution has to past long time. (Note: It’ll download the MinGW gcc toolchain sets from internet for first time execution.)
  4. Open the samples/AzureSphere/AzureSphere.sln file and build entire solution. This solution contains three projects and it builds automated by predefined project dependencies.

The AzureSphere.sln solution contains these three projects:

  • IL2C.Runtime: Build IL2C runtime code library. It’s VC++ project.
    (It’s referrering to the runtime code file at IL2C repository, so you have to clone entire IL2C repository if you would like to build successful.)
  • MT3620Blink: The Blink project written by C# language. (This blog post focused it.)
  • MT3620App: It’s VC++ project, only deployment usage for MT3620. (Reduced not important code from SDK sample app.)

The C# project file (MT3620Blink.csproj) is written by new simple MSBuild format. We have to choice the target framework moniker values net46 and above or netcoreapp2.0.

And add reference NuGet package named “IL2C.Build.” (version 0.4.22 or above.) It’ll compile by Roslyn and translate by IL2C automatically.

IL2C will store the translated C language source files defaulted into under the directory “$(OutDir)/IL2C/” . We have to change store directory to under the MT3620App project folder, add the “IL2COutputPath” element at this csproj xml node:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
    <OutputType>Library</OutputType>
    <AssemblyName>MT3620Blink</AssemblyName>

    <!-- ... -->

    <!-- IL2C puts on for translated source files -->
    <IL2COutputPath>$(ProjectDir)../MT3620App/Generated</IL2COutputPath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IL2C.Build" Version="0.4.6" />
  </ItemGroup>
</Project>

The interoperability topics for IL2C

.NET supports interoperability technologies named “P/Invoke.” It’s standard for .NET world and supports by IL2C. But one more option has IL2C named “IL2C/Invoke.”

IL2C/Invoke is easier interoperability for the C language world. For example, if we would like to use “OutputDebugString” the debugger API at the Windows using the P/Invoke, following below:

public static class InteroperabilityDemonstration
{
    // Write message to the debugger console
    [DllImport("kernel32.dll", EntryPoint = "OutputDebugStringW", CharSet = CharSet.Unicode)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // We can direct call the native API using standard .NET method rules.
        OutputDebugString(message);
    }
}

The “DllImport” attribute understands by .NET CLR (.NET Core CLR and mono runtime), and the runtime will analyze and call bypassing to the native API inside the “kernel32.dll” dynamic link library.

It’s same as using IL2C/Invoke:

public static class InteroperabilityDemonstration
{
    // Write message to the debugger console
    [NativeMethod("windows.h", SymbolName = "OutputDebugStringW", CharSet = NativeCharSet.Unicode)]
    [MethodImpl(MethodImplOptions.InternalCall)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // We can direct call the native API using standard .NET method rules.
        OutputDebugString(message);
    }
}

If we uses IL2C/Invoke, replace the DllImport attribute to the “NativeMethod” attribute and we have to set the C language header file name at the attribute argument.

Because IL2C translates to the C language source code. It has to refer to the API sets via C header files. The NativeMethod attribute translates to the “#include” preprocessor directive.

(And unfortunately, we have to apply with “MethodImpl” attribute because the C# compiler (Roslyn) requires it if method declaration has “extern”.)

IL2C/Invoke likes as P/Invoke for now, but it has advantages, will describe it later.

IL2C/Invoke first example

We can use Azure Sphere SDK with IL2C/Invoke. For first try, will cover the “nanosleep” API:

namespace MT3620Blink
{
  [NativeType("time.h", SymbolName = "struct timespec")]
  internal struct timespec
  {
      public int tv_sec;
      public int tv_nsec;
  }

  public static class Program
  {
      [NativeMethod("time.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern void nanosleep(ref timespec time, ref timespec remains);

      public static int Main()
      {
          var sleepTime = new timespec { tv_sec = 1 };
          var dummy = new timespec();
        
          while (true)
          {
              nanosleep(ref sleepTime, ref dummy);
          }
      }
  }
}

The nanosleep API declarated into the “time.h” header from Azure Sphere SDK. It has the pointer of “timespec” structure type. We have to replace the pointer argument to the ref attribute same as P/Invoke.

And declares timespec value type inside .NET world. We have to apply the “NativeType” attribute to timespec value type. The defaulted name “timespec” overwrites to the symbol named “struct timespec”.

The defaulted name becomes from .NET type name, but SDK’s timespec structure type is NOT typedef’ed, so we have to apply prefix “struct” keyword before name.

It’s IL2C/Invoke advantage. P/Invoke requires binary layout equality both C structure types and the .NET value types. It’s difficult for average developer.

If NativeType attribute applies for the .NET value type, IL2C doesn’t generate the structure fields and places typedef’ed alias to the C structure type by only symbol naming. For example, This is illustrates for IL2C translated C source code (noise stripped):

#include <time.h>

// The alias for "struct timespec"
typedef struct timespec MT3620Blink_timespec;

void MT3620Blink_Program_nanosleep(MT3620Blink_timespec* time, MT3620Blink_timespec* remains)
{
    nanosleep(time, remains);
}

int32_t MT3620Blink_Program_Main(void)
{
    MT3620Blink_timespec sleepTime = { 1 };
    MT3620Blink_timespec dummy;
    // ...
    MT3620Blink_Program_nanosleep(&sleepTime, &dummy);
    // ...
}

The string type (System.String) translates to NULL terminated “wchar_t*”. I’ll support passing “char*” if apply UTF8 flag at the NativeMethod’s CharSet property, and will support StringBuilder class too.

Step2: Only LED blinking

We’re able to finish the Blink code with the GPIO API. It’s illustrated:

namespace MT3620Blink
{
  [NativeType("applibs/gpio.h")]
  internal enum GPIO_OutputMode_Type
  {
      GPIO_OutputMode_PushPull = 0,
      GPIO_OutputMode_OpenDrain = 1,
      GPIO_OutputMode_OpenSource = 2
  }

  [NativeType("applibs/gpio.h")]
  internal enum GPIO_Value_Type
  {
      GPIO_Value_Low = 0,
      GPIO_Value_High = 1
  }

  public static class Program
  {
      [NativeValue("mt3620_rdb.h")]
      private static readonly int MT3620_RDB_LED1_RED;

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_OpenAsOutput(
          int gpioId, GPIO_OutputMode_Type outputMode, GPIO_Value_Type initialValue);

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_SetValue(int gpioFd, GPIO_Value_Type value);

      public static int Main()
      {
          var fd = GPIO_OpenAsOutput(
              MT3620_RDB_LED1_RED,
              GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
              GPIO_Value_Type.GPIO_Value_High);
          var flag = false;

          while (true)
          {
              GPIO_SetValue(fd, flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
              flag = !flag;

              // nanosleep(...);
          }
      }
  }
}

The GPIO output manipulates indirectly from GPIO_SetValue API. It returns the “descriptor” value by 32bit integer. It equals System.Int32 type on the .NET world.

IL2C/Invoke third feature, the “NativeValue” attribute can refer the C language world symbols (macro and anything) directly. But it has limitation because causes C# compiler’s “uninitialized field” warning. Future release for IL2C, maybe it cancel or replace for another methods.

We can blink LEDs by enum type values both GPIO_Value_High and GPIO_Value_Low.

The NativeType attribute is usable too when apply to the enum type. These examples apply to enum types both GPIO_OutputMode_Type and GPIO_Value_Type. Unfortunately, IL2C requires applying to the real numeric value at these enum value field. I’ll improve removing it for future release.

These interoperability code fragment is too ugly for the application layer. We can refactor moving to another class or appling partial class by the C# feature. IL2C doesn’t concern and not problem for using these technics, because it handles only CIL/MSIL (intermediate language.)

Our interoperability code moved to the “Interops” class.

Step3: Handles button input and completes Blinker like code

Next step for the button input, we can handle it with both GPIO_OpenAsInput and GPIO_GetValue API:

internal static class Interops
{
    // ...

    [NativeValue("mt3620_rdb.h")]
    public static readonly int MT3620_RDB_BUTTON_A;

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_OpenAsInput(int gpioId);

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_GetValue(int gpioFd, out GPIO_Value_Type value);
}

If we defined the arguments with “ref” or “out” attributes, IL2C will translate to the C language pointer type. But in this case, GPIO_GetValue’s argument is only output direction. So we can apply “out” attribute. It’s able to use with “out var” C# style syntax sugar.

Completed this step:

// Wait for nsec
private static void sleep(int nsec)
{
    var sleepTime = new timespec { tv_nsec = nsec };
    Interops.nanosleep(ref sleepTime, out var dummy);
}

public static int Main()
{
    var ledFd = Interops.GPIO_OpenAsOutput(
        Interops.MT3620_RDB_LED1_RED,
        GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
        GPIO_Value_Type.GPIO_Value_High);

    var buttonFd = Interops.GPIO_OpenAsInput(
        Interops.MT3620_RDB_BUTTON_A);

    var flag = false;

    // Interval durations (nsec)
    var blinkIntervals = new[] { 125_000_000, 250_000_000, 500_000_000 };
    var blinkIntervalIndex = 0;

    var lastButtonValue = GPIO_Value_Type.GPIO_Value_High;

    while (true)
    {
        Interops.GPIO_SetValue(
            ledFd,
            flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
        flag = !flag;

        // Read button state: we can write with "out var"
        Interops.GPIO_GetValue(buttonFd, out var buttonValue);
        // If changed button state for last reading
        if (buttonValue != lastButtonValue)
        {
            // If current button state is pushing?
            if (buttonValue == GPIO_Value_Type.GPIO_Value_Low)
            {
                // Change interval duration for next value
                blinkIntervalIndex = (blinkIntervalIndex + 1) % blinkIntervals.Length;
            }
        }
        lastButtonValue = buttonValue;

        sleep(blinkIntervals[blinkIntervalIndex]);
    }
}

GPIO_GetValue API is nonblocking-reader. So we have to monitor it has changed.

This code doesn’t have any error test. For example if calls GPIO_OpenAsInput and the API failing, we have to check return value for API. I know we can throw the exception.

Current code applied the ac2018-step1 tag on GitHub.

Step4: Improves by C# language style

It’s bit good. We can use some Visual Studio C# IDE ability at Azure Sphere development. With full power intellisense, refactoring and a lot of extensions.

But maybe you’ll feel not fun, because these code fragments are simply replaced from C language to C# language. Further we have to write additional interoperability declarations.

Azure Sphere SDK’s Blinker sample has one of more topic. Blinker sample uses multiplexing technics by the “epoll” API for timer events both detector the button trigger and LED blinking. (It’s too complex because these reason.)

Our code has a minor issue, can’t detect button trigger at “stop the world” if code call the nanosleep API.

We try to fix these problems with the .NET and C# power!

This graph is the .NET types of we’ll make. We’ll declarate some classes and a interface type and construct these derived topology.

The “Descriptor” class is base class for managing the descriptor value.

The “Application” class receives the instance of IEPollListener interface and will register using epoll API, and aggregates event triggers and dispatching.

The “GpioBlinker” class handles LED blinking by the timer event. The “GpioPoller” class handles LED blinking by the timer event too. Both classes derived from the “Timer” class. It handles timer event and do callback.

Let’s walk through!

First topic, the all APIs handle with the “descriptor value” from opening APIs. We declare the Descriptor class below:

public abstract class Descriptor : IDisposable
{
    public Descriptor(int fd)
    {
        if (fd < 0)
        {
            throw new Exception("Invalid descriptor: " + fd);
        }
        this.Identity = fd;
    }

    public virtual void Dispose()
    {
        if (this.Identity >= 0)
        {
            Interops.close(this.Identity);
            this.Identity = -1;
        }
    }

    protected int Identity { get; private set; }
}

This class marks the base class and implements the “System.IDisposable” interface type. Because the descriptor value is the unmanaged resource, we can use RAII technics by the C# languauge “using” clause.

And throw the exception if descriptor value isn’t valid.

We can implement classes both the GPIO input/output where derive from the Descriptor class:

internal sealed class GpioOutput : Descriptor
{
    public GpioOutput(int gpioId, GPIO_OutputMode_Type type, bool initialValue)
        : base(Interops.GPIO_OpenAsOutput(
            gpioId,
            type,
            initialValue ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low))
    {
    }

    public void SetValue(bool value) =>
        Interops.GPIO_SetValue(
            this.Identity,
            value ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
}

internal sealed class GpioInput : Descriptor
{
    public GpioInput(int gpioId)
        : base(Interops.GPIO_OpenAsInput(gpioId))
    {
    }

    public bool Value
    {
        get
        {
            Interops.GPIO_GetValue(this.Identity, out var value);
            return value == GPIO_Value_Type.GPIO_Value_High;
        }
    }
}

It uses our Interops class. The descriptor value will safe free at the inherited Dispose method.

Next step, the timer function does into a class. The class implements the “IEPollListener” interface, it will use receiving the timer event by the epoll API.

// Receives the timer event by the epoll API
public interface IEPollListener
{
    // The descriptor for managing by epoll API
    int Identity { get; }
    // The callback method for raise the event
    void OnRaised();
}

“Timer” class has to implement this interface, so it can receive event from the epoll API:

internal abstract class Timer : Descriptor, IEPollListener
{
    [NativeValue("time.h")]
    private static readonly int CLOCK_MONOTONIC;
    [NativeValue("time.h")]
    private static readonly int TFD_NONBLOCK;

    protected Timer()
        : base(Interops.timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK))
    {
    }

    public void SetInterval(long nsec)
    {
        var tm = new timespec
        {
            tv_sec = (int)(nsec / 1_000_000_000L),
            tv_nsec = (int)(nsec % 1_000_000_000L)
        };
        var newValue = new itimerspec
        {
            it_value = tm,
            it_interval = tm
        };

        Interops.timerfd_settime(this.Identity, 0, ref newValue, out var dummy);
    }

    // The descriptor for managing by epoll API
    int IEPollListener.Identity => this.Identity;

    // The callback method for raise the event
    void IEPollListener.OnRaised()
    {
        // Consume current timer event.
        Interops.timerfd_read(this.Identity, out var timerData,(UIntPtr)(sizeof(ulong)));
        // Invoke the overrided callback method.
        Raised();
    }

    // The abstract method for timer triggered (Will override)
    protected abstract void Raised();
}

We gonna do derive from it and override the “Raised” method, we can write interval action easier.

The “Application” class handles all events using the epoll APIs and invoke to the target instance implemented IEPollListener interface. The epoll APIs relate for the descriptor value too, so we can inherit from the Descriptor class:

public sealed class Application : Descriptor
{
    public Application()
        : base(Interops.epoll_create1(0))
    {
    }

    // The IEPollListener implemented instance register the epoll API
    public void RegisterDescriptor(IEPollListener target)
    {
        // "Pinned (Fixed)" the instance and get the handle value
        GCHandle handle = GCHandle.Alloc(target, GCHandleType.Pinned);

        // The informations (with the handle) for the epoll API
        var ev = new epoll_event {
            events = Interops.EPOLLIN,
            data = new epoll_data_t { ptr = GCHandle.ToIntPtr(handle) }
        };

        // Register it
        Interops.epoll_ctl(
            this.Identity,
            Interops.EPOLL_CTL_ADD,
            target.Identity,
            ref ev);
    }

    // Unregister the instance
    public void UnregisterDescriptor(IEPollListener target)
    {
        // ...
    }

    public void Run()
    {
        while (true)
        {
            var ev = new epoll_event();
            var numEventsOccurred = Interops.epoll_wait(this.Identity, ref ev, 1, -1);

            if (numEventsOccurred == -1)
            {
                break;
            }
            if (numEventsOccurred == 1)
            {
                // Reconstruct (restore) the handle value uses GCHandle.ToIntPtr method
                GCHandle handle = GCHandle.FromIntPtr(ev.data.ptr);
                // Get the instance reference from the handle value
                var target = (IEPollListener)handle.Target;
                target.OnRaised();
            }
        }
    }
}

It’s same as usage the Windows Forms and the WPF, so I applied this name. It exposes the “RegisterDescriptor” and the “UnregisterDescriptor” methods, these methods register descriptor value sending from he IEPollListener interface to the epoll API. And the “Run” method observes these epoll events.

Maybe you would like to hear what and how works these code fragments. The topic is the “GCHandle” value type. It saves instance references from the garbage collector actions.

This blog post doesn’t explain IL2C’s internal structures, it has the mark-sweep algorithm based garbage collector. If we do “new a class instance”, it’ll place into the heap memory (using the “malloc” function the C runtime.)

The mark-sweep algorithm doesn’t compact memory (not move instance), but automatically free the unreferenced instances (by the “free” function.)

It has two problems at this point:

  • Transfer the instance reference pointer safely.
  • And guards freeing the floating instance by the garbage collector.

(If we use at truly the .NET CLR, .NET Core and mono, we have to save memory compaction. But IL2C doesn’t do it.) These problems can fix using GCHandle value type.

At first, we guard freeing instance, using GCHandle.Alloc method with GCHandle.Pinned argument. The instance isn’t freed even if unreferenced from IL2C.

Next, we can get the real pointer for the instance using the GCHandle.ToIntPtr method. It’s the “System.IntPtr” value type (means “intptr_t” type at the C language.) We can’t any action for this pointer value, it’s opaque but the alias number for this instance.

We must understand the instance absolutely not released if pinned by the GCHandle.Alloc method. It can free again with call the GCHandle.Free method strictly. If we don’t free it, may leak memory. (For more information, see the UnregisterDescriptor method.)

The pointer from the “GCHandle.ToIntPtr”, store into the epoll_data_t structure (nested into the epoll_event structure.) This type applied the NativeType attribute. (The magic shown you again, it’s the union type for C language. IL2C/Invoke easier for use it.):

[NativeType("sys/epoll.h")]
internal struct epoll_data_t
{
    // The epoll_data_t.ptr field is "void*" type on the C language
    public NativePointer ptr;
}

[NativeType("sys/epoll.h", SymbolName = "struct epoll_event")]
internal struct epoll_event
{
    public uint events;
    public epoll_data_t data;
}

The “ptr” field declared by the “NativePointer” type. It real C language type is “void*”, but the C# language can’t use it at the field. We can use NativePointer type in this case, it can transfer from/to the System.IntPtr type.

Then, the related information will store into the “epoll_event” structure if the “epoll_wait” API receives a event. It equals for registered by the “epoll_ctl” API. Therefore we can get the handle value from the “ptr” field using the “GCHandle.FromIntPtr” method.

The instance reference will come from the “GCHandle.Target” property. It type is the System.Object, so can cast to the IEPollListener interface. We made it!

The last step, we have to call the “IEPollListener.OnRaised” method, it delegates to the real class implementation. This is the “Timer.OnRaised” method.

These are the infrastructures. Finally we get the entire Blink samples using it:

public static class Program
{
    private sealed class GpioBlinker : Timer
    {
        private readonly long[] blinkIntervals = new[] { 125_000_000L, 250_000_000L, 500_000_000L };
        private readonly GpioOutput output;
        private bool flag;
        private int blinkIntervalIndex;

        public GpioBlinker(int gpioId)
        {
            output = new GpioOutput(
                gpioId,
                GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
                true);
            this.NextInterval();
        }

        public override void Dispose()
        {
            base.Dispose();
            output.Dispose();
        }

        protected override void Raised()
        {
            output.SetValue(flag);
            flag = !flag;
        }

        public void NextInterval()
        {
            this.SetInterval(blinkIntervals[blinkIntervalIndex]);

            blinkIntervalIndex++;
            blinkIntervalIndex %= 3;
        }
    }

    private sealed class GpioPoller : Timer
    {
        private readonly GpioInput input;
        private readonly GpioBlinker blinker;
        private bool last;

        public GpioPoller(int gpioId, GpioBlinker blinker)
        {
            input = new GpioInput(gpioId);
            last = input.Value;
            this.blinker = blinker;
            this.SetInterval(100_000_000L);
        }

        public override void Dispose()
        {
            base.Dispose();
            input.Dispose();
        }

        protected override void Raised()
        {
            var current = input.Value;
            if (current != last)
            {
                if (!current)
                {
                    blinker.NextInterval();
                }
            }
            last = current;
        }
    }

    public static int Main()
    {
        using (var epoll = new Application())
        {
            using (var ledBlinker = new GpioBlinker(Interops.MT3620_RDB_LED1_RED))
            {
                using (var buttonPoller = new GpioPoller(Interops.MT3620_RDB_BUTTON_A, ledBlinker))
                {
                    epoll.RegisterDescriptor(ledBlinker);
                    epoll.RegisterDescriptor(buttonPoller);

                    epoll.Run();
                }
            }
        }

        return 0;
    }
}

The “GpioBlinker” class blinks the LED, the “GpioPoller” class detects clicking the button and controlling blinking rate by the GpioBlinker. And the “Main” method registers both class instances by the Application class and dispatches the events.

A lot of code fragments are the infrastructure, so we are hard to understand it. But it can the simplist implementation for at the “Program” class. We can design the only concern (We designed LED blinker in this post, you forgot? ;)

The infrastructure can make the library (The .NET assembly.) We can package by the NuGet and can expose it better reusable.

Finished. These code in the ac2018-step2 tag in the repository.

Step5: Comparison binaries between C language and C# language.

Finally, comparison the compiled binary footprint between generated by IL2C and handmaded C source code.

The binary file come from the gcc (not deployment image.) For example location “$(OutDir)/Mt3620App.out” and disable debugger informations using the gcc option “-g0”:

Debug Release
SDK Blinker (C sample code) 16KB 16KB
IL2C Step1 (Non OOP) 111KB 42KB
IL2C Step2 (OOP) 142KB 54KB

The SDK Blinker size same as both Debug and Release. It’s maybe minimum and simplest code, the gcc optimizer can’t do it. The optimizer did hardworks at both IL2C Step1 and Step2. I predict it by IL2C design.

IL2C outputs are simpler (but easier detects optimizing topics at the C compiler) and predicts eliminating the unused symbols/references by the gcc options (-fdata-sections -ffunction-sections -Wl,–gc-sections).

The IL2C’s runtime code is small now, but gonna do larger if merge the corefx library. Even so, this premise is important because we want binaries to contain only the minimum necessary code.

Another design, IL2C uses the standard C runtime library as much as we can. Because larger the footprint if IL2C implements self-designed functions.

For example, the “System.String” class has to the string manipulation methods. IL2C’s runtime delegates to the C library called “wcslen”, “wcscat” and etc. And the garbage collector only uses strategy for mark-sweep with the “malloc” and “free” standard C heap backends. (It means don’t use memory compaction technics.)

However, it is still somewhat large compared to the size of SDK Blinker. It is still feeling that effort is missing…

I still have various ideas to make footprint smaller, so I will try it in a future version.

Conclusion

I tried to write modernized C# style code using IL2C on the Azure Sphere MCU based MT3620 evaluation board. I already did feedback known problems and suggestions. The “Dogfooding” makes better and improves the projects.

The likely projects come the world. It uses the LLVM based. It makes C code from Roslyn abstract syntax tree directly. It’s bootable independent OS with C#, it surprised me.

The IL2C different to these projects. It’ll have and focus the portability and footprint. It attracts the .NET powers to the everywhere.

Azure Sphere Development Kitで C# を使ってコードを書いてみる

この記事は、.NET, .NET Core, monoのランタイム・フレームワーク・ライブラリ Advent Calendar 2018と、Seeed UG Advent Calendar 2018の23日目のダブルエントリーです(遅刻)。

(For English edition, click here)

Azure Sphere Development Kitとは、 Seeedさんが出しているAzure Sphere評価ボード と、開発用SDKのことです。リンクはこのSeeed公式のページからでも買えますが、国内にも代理店がいくつかあるので、今ならそちらから買うほうが入手性も良いでしょう。

MT3620とは、 MEDIATEK社が開発した、Azure Sphereの仕様に対応したMCU(CPUチップ) です。そして、上記のSeeedさんが販売しているボードはMT3620を載せた、評価用の開発ボードです。なので、ちょっと語弊あるのですが、この評価ボードの事を、以降MT3620と呼びます。

で、何でこれをAdvent Calendarのネタにしたかと言うと、現状のAzure Sphere SDKは、「C言語」でのみ開発が可能というもので… .NET界隈の読者はもう言わなくてもわかりますよね ;)

Seeed UGから来られた方は、タイトルにはC言語じゃなくてC#って書いてあるんだけど? と、若干意味不明に思うかも知れないので、以下に背景を書いておきます。

私のblogをちょっと遡ると 「IL2C」 に関する記事が沢山出てきますが、これは私が始めた「.NETのバイナリをC言語のソースコードに変換する」オープンソースのプロジェクトの事です。まだ完成していなくて、開発途上です。

開発を始めたのは、丁度1年半ぐらい前からです。1年前には、 .NET Conf 2017 とか、いくつかの勉強会やグロサミやらで細部とか進捗とかを発表していました。今年はつい先日開催した dotNET600 2018 でも発表しています。あと、YouTubeに大量にIL2Cを解説したビデオを公開しています。

資料などはそれぞれの勉強会サイトやGitHubをあたってもらえると良いと思いますが、ざっくり言うと、この図のとおりです。

C#でプログラムを書いて(もちろん、MT3620向けに)、それを普通にC#のプロジェクトとしてビルドします。そうすると、”SphereApp.dll”のような.NETのバイナリが出来ます。これをIL2Cに掛けると、C言語のソースコードに変換されたファイルが生成されます。これらのソースコードファイルを、ターゲットデバイスのC言語コンパイラ(この記事ではAzure Sphere SDKが提供するgcc)に掛けて、デバイス用のネイティブバイナリを生成します。

つまり簡単に言えば、この記事のタイトル通り、「Azure Sphere Development Kitで C# を使ってコードを書く」と言うことになるわけです。

IL2Cの最大の特徴であり目標は、例えば.NET Coreのネイティブビルドやngen、monoの極小ビルドと比較しても、到底不可能なぐらいにフットプリントを小さく出来て、内部構造がシンプルで、ネイティブコードとの連携をコントローラブルにする、というものです。これまでにデモンストレーションで作ってみたものとしては:

  • 簡易電卓: Win32ネイティブコンソールアプリケーション(つまり.NET CLRを使わず、Win32ネイティブで動く)
  • 簡易電卓: UEFIアプリケーション(つまりOSなしで動く電卓をC#で書いたって事です)
  • 簡易電卓: M5Stack テンキーブロック(ESP32上で組み込みソフトウェアとして)
  • ディスクのセクタデータをリアルタイムに書き換える、カーネルモードWDM(つまりWindowsカーネルモードで動くコードをC#で書いたって事です)
  • micro:bitで、加速度センサーを使用してLEDに方向をフィードバックするプログラム(これはC#だけではなく、F#でもデモしました)

という感じで、極小リソースのデバイスでも.NET ILを変換したネイティブコードを、インタプリタではなくAOTコンパイルしたコードで動かせるという実証や、UEFIやWDMなどの極めて特殊な環境でも動作するという実証実験をしています。今回はここに、Azure Sphereを加えるという目標です。

ということで、

  • MT3620のセットアップの流れ
  • サンプルコードの構造の確認とC#での実現方法

の大きく二章立て構成で解説します。

(ところで、来年から仕事無いので、これをやらせてくれる仕事ありましたら、凄く嬉しいのでDMください)


MT3620のセットアップの流れ

MT3620は割と早い時期に入手していたのですが、(某人がこれの環境構築に四苦八苦してたのを横目で見てたこともあり…)色々忙しくて放置してました。なので、今の時期にデバイスクレーム(MT3620の登録作業)からやる話は、同様にこれから始める人向けにも良いんじゃないかと思ってます。

@linyixian さんの、Azure Sphere 開発ボードでLチカをやってみる が非常に参考になりました。ほぼこの通りで出来ましたが、私固有の問題が少しあったので、ログ含めてセットアップの記録を残しておきます。

なお、使用したAzure Sphere SDKのバージョンは “18.11.3.23845” でした。SDKもそこそこバージョンアップされているので、あまりバージョンがかけ離れると、ここに書いてあることも無意味になるかも知れません。

さて、Azure AD面倒くさいんですが、組織アカウントを作っておくところまでは、記事通りやっておきます。私の場合、個人的にいくつかのサブスクリプションと組織が紐付いている状態で実験したりしていた関係で、シンプルに無料チャージから始めた方とは事情が違ってしまっている可能性があります。Azure ADはいつ作ったか作ってないのかわからないものが一つだけあったので、この中に組織アカウントを作りました(管理的に問題のある選択だったかも知れない… 消さないようにしなければ)。

Azure ADに組織アカウントを作ったら、Azure Sphereテナントを作ります。テナントはAzure Sphere SDKのコマンドプロンプトで作業します。つまり、ここからの操作はAzureのダッシュボードは不要です。が…

C:\>azsphere tenant create -n ***********
error: An unexpected problem occurred. Please try again; if the issue persists, please refer to aka.ms/azurespheresupport for troubleshooting suggestions and support.
error: Command failed in 00:00:39.0889958.

何故か失敗(以下、ログはIDとか伏せ字です)…

しかも意味不明なエラーメッセージなので、こりゃ早速サポート案件か… と思ったのですが、@linyixian さんのLチカ記事読み直すと、この時点でMT3620をUSB接続していたようなので(テナント生成にデバイス関係あるの?という疑問は残るものの)MT3620を接続して試したら、うまく行きました:

C:\>azsphere tenant create -n ***********
warn: Your device's Azure Sphere OS version (TP4.2.1) is deprecated. Recover your device using 'azsphere device recover' and try again. See aka.ms/AzureSphereUpgradeGuidance for further advice and support.
Created a new Azure Sphere tenant:
 --> Tenant Name: ***********
 --> Tenant ID:   ***********
Selected Azure Sphere tenant '***********' as the default.
You may now wish to claim the attached device into this tenant using 'azsphere device claim'.
Command completed successfully in 00:00:23.2329116.

(そう言えばMatsuoka氏も最初の頃そんな事を言っていた事を思い出した)

ただ、上記のように、私のMT3620はファームウェアが古すぎだと言われた(まあ、放ったらかしにしてたしね)ので、早速suggestされたコマンドで更新をかけました。

C:\>azsphere device recover
Starting device recovery. Please note that this may take up to 10 minutes.
Board found. Sending recovery bootloader.
Erasing flash.
Sending images.
Sending image 1 of 16.
Sending image 2 of 16.
Sending image 3 of 16.
Sending image 4 of 16.
Sending image 5 of 16.
Sending image 6 of 16.
Sending image 7 of 16.
Sending image 8 of 16.
Sending image 9 of 16.
Sending image 10 of 16.
Sending image 11 of 16.
Sending image 12 of 16.
Sending image 13 of 16.
Sending image 14 of 16.
Sending image 15 of 16.
Sending image 16 of 16.
Finished writing images; rebooting board.
Device ID: ***********
Device recovered successfully.
Command completed successfully in 00:02:41.2721537.

難なく、更新されたようです。では、いよいよ後戻りできない、MT3620のデバイスクレームを実行します:

C:\>azsphere device claim
Claiming device.
Successfully claimed device ID '***********' into tenant '***********' with ID '***********'.
Command completed successfully in 00:00:04.6818769.

これで、このMT3620はこのAzure Sphereテナントに登録されてしまったので、もう誰にも譲れなくなりました… (Azure Sphere MCU対応デバイスは、デバイスクレームを一度しか実行できません。詳しくはググってみてください)

さて、気を取り直して、WiFi出来るようにしておきます。

C:\>azsphere device wifi add --ssid *********** --key ***********
Add network succeeded:
ID                  : 0
SSID                : ***********
Configuration state : enabled
Connection state    : unknown
Security state      : psk

Command completed successfully in 00:00:01.7714741.

WiFi接続ができているかどうかを確認

C:\>azsphere device wifi show-status
SSID                : ***********
Configuration state : enabled
Connection state    : connected
Security state      : psk
Frequency           : 5500
Mode                : station
Key management      : WPA2-PSK
WPA State           : COMPLETED
IP Address          : ***********
MAC Address         : ***********

Command completed successfully in 00:00:01.0424492.

いい感じです。一度動き出せば、管理は非常に簡単です。WiFi構成はいつでも上記コマンドで変更できます。

最後に、MT3620を「開発モード」にします。

MT3620自体はDevKitなので、文字通り「開発」に使わないのなら何に使うんだ? ということで、この操作は意味不明に思われるかも知れません(特にArduinoやESP32のようなプラットフォームを触っていると)。冒頭で触れたように、元々のAzure Sphere MCUは、様々な組み込み機器のCPUとして使われることを想定しており、真にIoTデバイスとして使う場合は、逆に開発モードと言う危なげな状態にならないほうが良いわけです。

この辺は、最近のスマートフォンでも同じように制限を掛けていると思うので、それと同じように考えればわかりやすいと思います。では、開発モードに変更します:

C:\>azsphere device prep-debug
Getting device capability configuration for application development.
Downloading device capability configuration for device ID '***********'.
Successfully downloaded device capability configuration.
Successfully wrote device capability configuration file 'C:\Users\k\AppData\Local\Temp\tmp6CD5.tmp'.
Setting device group ID '***********' for device with ID '***********'.
Successfully disabled over-the-air updates.
Enabling application development capability on attached device.
Applying device capability configuration to device.
Successfully applied device capability configuration to device.
The device is rebooting.
Installing debugging server to device.
Deploying 'C:\Program Files (x86)\Microsoft Azure Sphere SDK\DebugTools\gdbserver.imagepackage' to the attached device.
Image package 'C:\Program Files (x86)\Microsoft Azure Sphere SDK\DebugTools\gdbserver.imagepackage' has been deployed to the attached device.
Application development capability enabled.
Successfully set up device '***********' for application development, and disabled over-the-air updates.
Command completed successfully in 00:00:32.3129153.

これで開発モードに切り替えできました。

後は、@linyixian さんのLチカの記事通り、Blinkサンプルを実行してLチカ出来る、言い換えればDevKitの用を成しているかどうかをを確認しておきます。

これで、MT3620が使える状態になったかどうかの確認は完了です。

DevKitの詳細については、

を参照すると良いでしょう。


サンプルコードの構造の確認とC#での実現方法

で、ここからが本題なのですが、とりあえず先程のBlinkのサンプル(このサンプルはあくまでC言語)を眺めてみると、以下のような定義が見つかります:

/// <summary>
///     Main entry point for this application.
/// </summary>
int main(int argc, char *argv[])
{
    Log_Debug("Blink application starting.\n");
    if (InitPeripheralsAndHandlers() != 0) {
        terminationRequired = true;
    }

    // Use epoll to wait for events and trigger handlers, until an error or SIGTERM happens
    while (!terminationRequired) {
        if (WaitForEventAndCallHandler(epollFd) != 0) {
            terminationRequired = true;
        }
    }

    ClosePeripheralsAndHandlers();
    Log_Debug("Application exiting.\n");
    return 0;
}

なんだか、至って普通の、なんのヒネリもないmain関数です。あと、いくつか気がつくことがあります:

  • Log_Debug関数でログが出せそう。
  • InitPeripheralsAndHandlersとClosePeripheralsAndHandlersでデバイスの初期化処理と終了処理らしきことをしている。これはSDKの関数ではなく、このサンプル内にコードがあるので、すぐ後で見てみます。
  • ど真ん中にポーリングのようなコードがある。コメントに”SIGTERM”とかコメントがある。

ぐらいですかね。InitPeripheralsAndHandlersを見てみると:

static int InitPeripheralsAndHandlers(void)
{
    struct sigaction action;
    memset(&action, 0, sizeof(struct sigaction));
    action.sa_handler = TerminationHandler;
    sigaction(SIGTERM, &action, NULL);

    epollFd = CreateEpollFd();
    if (epollFd < 0) {
        return -1;
    }

    // ...

えぇ… POSIX signal使えるんだ… ちょっと予想外だった。いや、やや不透明ですが、内部ではLinuxカーネルが動いているので、当然といえば当然かも知れません。同様に、main関数のポーリングのようなコードは、epollと関係あるのかも知れません。

また、C言語なのである程度は仕方がないのですが、質素なLチカのサンプルコードなのにグローバル変数がてんこ盛りかつ複雑なので、これで何かしようというやる気が初っ端からへし折られていく感じがします… (一応擁護しておくなら、頑張ってきれいに書こうとしている匂いはします)

では、そろそろ今回やろうとしていることの本題に入りましょうか。

  1. 本格的に何か出来るようにしようとすると、Advent Calendarでは収集つかなくなりそうなので、Blink同様のものを目標にします。
  2. 真のLチカ(つまり、LEDの点滅のみ)をやってみます。
  3. ボタン入力に対応させてみます。
  4. C#らしいコードにします。
  5. 生成されたコードを比較します。

ビルド環境の準備

完成したサンプルコードはここにあります。

上記サンプルコードを動かす場合は、リポジトリ全体をcloneした後、以下のように準備しておきます。

  1. Visual Studio 2017にC#, C++(VC++), NUnit3 test adapter(拡張機能から)が入っている必要があります。普通はVC++入れないと思いますが、Azure Sphere SDKを入れてあるなら入っているでしょう。
  2. ルートにあるil2c.slnを開いてビルドします。どうしてもビルドが通らない場合は、3のテストは諦めることも出来ます(.NET Framework 4.0以降・.NET Core 1.0以降・.NET Standard 1.0以降の全てのバージョンに依存しているため、環境によってはビルドできないかも知れません。Visual Studio Installerを起動して、とにかく全てのコンポーネントをインストールしてみるのはありかも知れません。私は常に全部入りです)。
  3. Test Explorerからテストを実行して全部パスすることを確認しておきます(結構時間がかかります。CPUコア数多いと速いです。i7-4790Kで5分ぐらい。あと、1GBぐらいのディスクスペースが必要で、初回実行時に一度だけMinGWのgcc toolchainをダウンロードするため、遅い回線だと辛いかもしれません)。masterブランチには安定的に動作するコードが入っているはずですが、テストを実行しておけば、少なくとも私が見た結果と同じものが得られるはずです。
  4. samples/AzureSphere/AzureSphere.slnを開いてビルドしてください。このソリューリョンには3つのプロジェクトが含まれていますが、依存関係が正しく設定されているはずなので、ビルドすれば全てが正しく構築されるはずです。

AzureSphere.slnソリューションに含まれるプロジェクトは、以下のとおりです:

  • IL2C.Runtime: IL2Cのランタイムコードをライブラリ化するVC++プロジェクト
    (このプロジェクトはIL2Cリポジトリに含まれるランタイムのソースファイルを参照しているので、このプロジェクトをビルドするためにリポジトリ全体が必要です)
  • MT3620Blink: C#で書かれたBlinkのプロジェクト(以降で説明するC#で書くコード)
  • MT3620App: MT3620向けのデプロイ用VC++プロジェクト(SDKのサンプルコードから不要なコードを省いたものです)

以降の解説は、上記プロジェクトのうちのC# Blinkのプロジェクトを一から書く場合の解説です。

このC#プロジェクトは、いわゆるMSBuildの新形式(.NET Coreや.NET Standardで使われている、新しいシンプルなcsprojファイル)ライブラリプロジェクトを使って、net46以上かnetcoreapp2.0以降をターゲットにします。そして、NuGetパッケージのIL2C.Build 0.4.22以降(この記事を書いた時点の最新)を参照してください。

これでビルド時に裏でIL2Cが自動的に実行され、C言語のソースコードが出力されるようになります(IL2Cのバイナリを明示的にインストールしたりする必要はありません)。

(説明が前後しますが、C#プロジェクト自体は、IL2C.Buildパッケージを参照するだけで作ることが出来ます。今回、IL2Cのリポジトリ全体をcloneするのは、IL2C.Runtimeのビルドのためです。この辺りはスマートではない事は認知しているので、将来的にはもっと簡単に出来るようにしたいと思っています)

C言語のソースコードは、デフォルトでは$(OutDir)/IL2C/の下に出力されます。今回は、別のVC++プロジェクト配下のGeneratedフォルダに出力したいため、csprojのPropertyGroupに以下のように1行加えておきます:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net46</TargetFramework>
    <OutputType>Library</OutputType>
    <AssemblyName>MT3620Blink</AssemblyName>

    <!-- ... -->

    <!-- C言語ソースコードの出力先フォルダを指定 -->
    <IL2COutputPath>$(ProjectDir)../MT3620App/Generated</IL2COutputPath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IL2C.Build" Version="0.4.6" />
  </ItemGroup>
</Project>

なお、旧形式のMSBuild、つまり.NET Frameworkで使われているcsprojのライブラリプロジェクトでも出来ますが、IL2C.Interopパッケージが正しく参照されないため、手動でこのパッケージも追加する必要があります。

また、net46より古いか、netstandard辺りのプラットフォームをターゲットとすることも出来ますが、現状のIL2C.Buildではビルドできないので、IL2C.exeを自力で呼び出す必要があります(PostBuildEventに書きます。引数の種類は限られているのでそれほど難しくはありませんが、ここでは省略します)。

IL2CのInteroperability(相互運用機能)

.NETの世界で Interoperability(相互運用)と言うと、.NETのコードから、ネイティブのコード(例えばWin32やLinuxのAPI)を呼び出したりする事を指します。.NETのコードは、全てが.NETだけで実現されているわけではなく、例えばファイルの入出力やネットワークアクセスなどは、OSのAPIを呼び出して実現する必要があります。

普通のプログラムは、これらの「外部リソース」にアクセスします。したがって、如何に簡単にプログラムの処理系からAPIを呼び出せるかが鍵となります。.NETの場合、標準の相互運用機能に P/Invoke と呼ばれる機能があり、これを使って相互運用を実現します。

例えば、WindowsのWin32 APIにはデバッグAPIがあって、デバッグ用の文字列を出力することが出来ます(デバッガのログに表示されます)。これを.NET上から呼び出すには、以下のようなコードを書きます:

public static class InteroperabilityDemonstration
{
    // Win32 APIのデバッガAPIへの呼び出しを定義する
    [DllImport("kernel32.dll", EntryPoint = "OutputDebugStringW", CharSet = CharSet.Unicode)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // デバッガに文字列を出力する
        OutputDebugString(message);
    }
}

詳細は省きますが、DllImportという属性を適用したメソッドは、対応するWin32 APIと紐付けられ、まるで.NETにそのメソッドが存在するかのように呼び出すことが出来るようになります。

例としてWin32 APIを挙げましたが、.NET Coreやmonoで想定されるマルチプラットフォーム(つまりLinuxやMac)でも、全く同じ方法でネイティブのAPIを呼び出すことが出来ます。

IL2Cでは、P/Invokeを踏襲した相互運用機能を実現するのと同時に、独自の相互運用機能も用意しています(注:現状、どちらの実装もまだ完全ではありません)。この、独自の相互運用機能(便宜上、IL2C/Invokeと呼びます)で同じ事を実現する場合は、以下のように書きます:

public static class InteroperabilityDemonstration
{
    // Win32 APIのデバッガAPIへの呼び出しを定義する
    [NativeMethod("windows.h", SymbolName = "OutputDebugStringW", CharSet = NativeCharSet.Unicode)]
    [MethodImpl(MethodImplOptions.InternalCall)]
    private static extern void OutputDebugString(string message);

    public static void WriteMessageToDebugger(string message)
    {
        // デバッガに文字列を出力する
        OutputDebugString(message);
    }
}

何故、IL2C/Invokeを用意したのかは、このコードを注意深く見ると気がつくかも知れません。P/InvokeのDllImport属性は、対象のAPIが「ダイナミックリンクライブラリ」に含まれている事を前提としています。kernel32.dllを参照することで、プログラムの実行時に指定されたライブラリを「動的」に読み込んで、APIの呼び出しを可能にします。

しかし、IL2CはC言語のソースを出力し、それらがすべて事前にコンパイルされる必要があります。参照すべきAPIもコンパイル時に解決される必要があるため、ライブラリ本体ではなく、C言語のヘッダファイルへの参照が必要なのです。当初はDllImport属性を流用していたのですが、このような理由から独自の属性を定義して区別するようにしました。また、区別したことで、後で述べるような利点も生まれています。

唯一、MethodImpl属性が邪魔ですね。これはC#コンパイラが要求していて省略できないため、どうしても一緒に書いておく必要があります。今のところ回避方法はありません。

Azure Sphere SDKのAPIを参照する

このIL2C/Invokeを使って、Azure Sphere SDKのAPIを.NETから使えるようにしてみます。まずはnanosleep APIをカバーしてみましょう。

namespace MT3620Blink
{
  [NativeType("time.h", SymbolName = "struct timespec")]
  internal struct timespec
  {
      public int tv_sec;
      public int tv_nsec;
  }

  public static class Program
  {
      [NativeMethod("time.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern void nanosleep(ref timespec time, ref timespec remains);

      public static int Main()
      {
          var sleepTime = new timespec { tv_sec = 1 };
          var dummy = new timespec();
        
          while (true)
          {
              nanosleep(ref sleepTime, ref dummy);
          }
      }
  }
}

nanosleep APIは、Azure Sphere SDKのtime.hに定義されています。そして、引数にtimespec構造体へのポインタを取ります。P/Invokeで使われるのと同じ技法で、ポインタはrefで参照するようにします。

また、.NETから構造体を参照できるように、timespec構造体を宣言します。ここに、NativeType属性を適用して、この構造体がどのヘッダファイルに定義されているのかを宣言し、合わせて実際の構造体の宣言名(ここでは “struct timespec”)をSymbolNameプロパティで適用しておきます。

宣言名を省略すれば、.NET構造体の名前がそのまま使用されます。しかし、SDKのtimespec構造体はtypedefで別名宣言されていないため、”struct”の接頭語を付けておく必要があります(Azure Sphere SDKはC++をサポートしていないので、この場合は接頭語を省略できません)。

先程、IL2C/Invokeには利点があるという話をしましたが、このNativeType属性が正にそれです。P/Invokeの場合、定義する構造体の型は、バイナリレイアウトを含めて完全な状態で再定義しなければならず、比較的難易度の高い作業でした。

NativeType属性が適用された構造体は、基本的にシンボル名と型が合っていれば、問題なくネイティブコードと結合できます。何故なら、NET構造体の定義はIL2Cによって無視され、C言語の構造体を(定義が同一であるという前提で)そのまま使うからです。

以下に、変換結果を概念的にしたものを示します:

#include <time.h>

// struct timespec構造体の別名を定義
typedef struct timespec MT3620Blink_timespec;

void MT3620Blink_Program_nanosleep(MT3620Blink_timespec* time, MT3620Blink_timespec* remains)
{
    nanosleep(time, remains);
}

int32_t MT3620Blink_Program_Main(void)
{
    MT3620Blink_timespec sleepTime = { 1 };
    MT3620Blink_timespec dummy;
    // ...
    MT3620Blink_Program_nanosleep(&sleepTime, &dummy);
    // ...
}

C言語のソースコード中に、timespec構造体の詳細な定義が無く、typedefを使って本物のtimespec構造体にバイパスしているだけです。

なお、文字列(.NETのSystem.String型)は、NULLで終端されている wchar_t* が渡されるようになっています。NativeMethod属性のCharSetにUTF8を指定することで char* を渡すことも出来るようにしたり、P/Invokeと同じように、StringBuilderクラスを使ったやり取りも出来るようにする予定です。

GPIO APIをカバーする

GPIO APIをカバーして、Lチカを完成させます。以下に、GPIOにアクセスする部分だけを抜き出したC#のコードを載せます:

namespace MT3620Blink
{
  [NativeType("applibs/gpio.h")]
  internal enum GPIO_OutputMode_Type
  {
      GPIO_OutputMode_PushPull = 0,
      GPIO_OutputMode_OpenDrain = 1,
      GPIO_OutputMode_OpenSource = 2
  }

  [NativeType("applibs/gpio.h")]
  internal enum GPIO_Value_Type
  {
      GPIO_Value_Low = 0,
      GPIO_Value_High = 1
  }

  public static class Program
  {
      [NativeValue("mt3620_rdb.h")]
      private static readonly int MT3620_RDB_LED1_RED;

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_OpenAsOutput(
          int gpioId, GPIO_OutputMode_Type outputMode, GPIO_Value_Type initialValue);

      [NativeMethod("applibs/gpio.h")]
      [MethodImpl(MethodImplOptions.InternalCall)]
      private static extern int GPIO_SetValue(int gpioFd, GPIO_Value_Type value);

      public static int Main()
      {
          var fd = GPIO_OpenAsOutput(
              MT3620_RDB_LED1_RED,
              GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
              GPIO_Value_Type.GPIO_Value_High);
          var flag = false;

          while (true)
          {
              GPIO_SetValue(fd, flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
              flag = !flag;

              // nanosleep(...);
          }
      }
  }
}

MT3620のGPIOは、GPIO_OpenAsOutput APIで出力用にデバイスをオープンして、その時得られたディスクリプタをGPIO_SetValue APIに渡すことで操作します。入力の場合はまた別のAPIを使いますが、まずはLチカを実現しましょう。

ディスクリプタは32ビット整数(C言語でint)なので、.NETでもint(System.Int32)でOKですね。戻り値で得られるので、これをローカル変数に保存しておきます。

NativeValue属性もIL2C/Invokeで使用する属性で、定数シンボル(C言語マクロなど)を参照出来るようにするために使用します。但し、staticフィールドの初期値を割り当てないため、コンパイル時に警告が発生し、具合がよくありません。将来のバージョンではやり方を変えるかも知れません。

ループ内で、現在のフラグに応じて、GPIO_Value_HighとGPIO_Value_Lowを切り替え、LEDの点滅を実現します。

ここでもNativeType属性を便利に使うことが出来ます。列挙型GPIO_OutputMode_TypeとGPIO_Value_Typeを、C言語の列挙型に対応付けています。残念ながら、今はまだ、列挙型の値(数値)をC言語の定義と一致させる必要があります(将来的には省けるようにしようと考えています)。

このコードを俯瞰すると、C#でP/Invokeを使ったことがある人なら「この醜い相互運用の定義は、別のクラスに移動しておこう。そうすれば、Mainメソッドがスッキリする」と想像出来ると思います。

実際、別のクラスに定義を移動しても問題ありませんし、フットプリントが気になるならpartial classを使えば良いでしょう。IL2CはC#のソースファイルではなく、コンパイルされたアセンブリファイル(MT3620Blink.dll)に対して処理を行うからです。

(というように各自で工夫してくれると良いなーと思ってたのですが、Matsuoka氏に見せたら初見で同じことをつぶやいたので、まあ作戦は成功したと言えるでしょう :)

サンプルコードでは、Interopsというクラスを作り、そこに移動しました。

横道にそれましたが、ボタン入力をGPIO APIで取得するには、GPIO_OpenAsInput APIでオープンしてから、GPIO_GetValue APIを使います。これらは以下のように定義できます:

internal static class Interops
{
    // ...

    [NativeValue("mt3620_rdb.h")]
    public static readonly int MT3620_RDB_BUTTON_A;

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_OpenAsInput(int gpioId);

    [NativeMethod("applibs/gpio.h")]
    [MethodImpl(MethodImplOptions.InternalCall)]
    public static extern int GPIO_GetValue(int gpioFd, out GPIO_Value_Type value);
}

IL2Cでは、ref引数でもout引数でもポインタ渡しになります。しかし、GPIO_GetValueは(ポインタ経由で)値を読み取ることは無いのでoutにしておきます。すると、メソッドを使う側ではout varを使って簡単に書けるようになります。nanosleepも直しておきます。全体的なコードは以下の通りです:

// 指定されたnsecだけ待機する
private static void sleep(int nsec)
{
    var sleepTime = new timespec { tv_nsec = nsec };
    Interops.nanosleep(ref sleepTime, out var dummy);
}

public static int Main()
{
    var ledFd = Interops.GPIO_OpenAsOutput(
        Interops.MT3620_RDB_LED1_RED,
        GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
        GPIO_Value_Type.GPIO_Value_High);

    var buttonFd = Interops.GPIO_OpenAsInput(
        Interops.MT3620_RDB_BUTTON_A);

    var flag = false;

    // 待機時間(nsec)
    var blinkIntervals = new[] { 125_000_000, 250_000_000, 500_000_000 };
    var blinkIntervalIndex = 0;

    var lastButtonValue = GPIO_Value_Type.GPIO_Value_High;

    while (true)
    {
        Interops.GPIO_SetValue(
            ledFd,
            flag ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
        flag = !flag;

        // ボタンの状態を読み取る: out varで簡単に書ける
        Interops.GPIO_GetValue(buttonFd, out var buttonValue);
        // 直前のボタンの状態から変化していれば
        if (buttonValue != lastButtonValue)
        {
            // ボタンが押されていれば(ボタンの信号は論理が逆なので注意)
            if (buttonValue == GPIO_Value_Type.GPIO_Value_Low)
            {
                // 待機時間を変更
                blinkIntervalIndex = (blinkIntervalIndex + 1) % blinkIntervals.Length;
            }
        }
        lastButtonValue = buttonValue;

        sleep(blinkIntervals[blinkIntervalIndex]);
    }
}

ボタンの状態は瞬時に読み取られるので、連続的に待機時間が変わってしまわないように、ボタン状態の変化を見るようにしています。

Azure Sphere SDKのBlinkerサンプルは、ディスクリプタの取得に失敗した場合(-1が返されるなど)の処理もきちんと実装していますが、このコードでは省略しています。もしやるならC#らしく、例外を定義してスローするという方法が考えられます。

なお、ここまでのコードは ac2018-step1のタグ で保存してあります。

応用: epollを使って疑似イベント駆動にする

最初に取り組むLチカ+αの課題はこれで十分だと思いますが、C#を使っているのにC言語でプログラムを書いているのとあまり変わりがなく、IL2C/Invokeのための記述が増えるだけで面白みがありませんね。

また、Azure Sphere SDKのBlinkerサンプルは、実装がもう一捻りしてあります(それであんなに複雑なことになっているのですが…)。それは、ボタンクリックのトリガーと点滅を、それぞれタイマーを割り当てて、タイマー経過をepollを使ってマルチプレクスして、擬似的なイベント駆動として処理しているのです。実際、前述のコードには些末ですが問題があります。それは、sleepを呼び出している間は、ボタンの入力が受け付けられないことです。

最後にこの処理を、.NETとC#のパワーでキレイに対処して見ましょう。

この図は、これからC#で作る型の関係を示したものです。いくつかのクラスとインターフェイスを定義して、このような継承関係を構築します。

Descriptorクラスは、このすぐ後で説明する、APIのディスクリプタを管理する基底クラスです。Applicationクラスが、IEPollListenerインターフェイスを実装するインスタンスを受け取ってepollに登録し、発生するイベントの集約と配信を行います。

GpioBlinkerとGpioPollerクラスが、LEDの点滅とボタンのトリガーを監視します。両方共にTimerクラスを継承して、インターバルタイマーの経過を使って処理を行います。

それでは、順を追って説明します。

まず、すべてのAPIへのアクセスは、共通の「ディスクリプタ」で行われている、という事実に着目し、ディスクリプタを管理するクラスを用意します:

public abstract class Descriptor : IDisposable
{
    public Descriptor(int fd)
    {
        if (fd < 0)
        {
            throw new Exception("Invalid descriptor: " + fd);
        }
        this.Identity = fd;
    }

    public virtual void Dispose()
    {
        if (this.Identity >= 0)
        {
            Interops.close(this.Identity);
            this.Identity = -1;
        }
    }

    protected int Identity { get; private set; }
}

このクラスのポイントは、基底クラスとして継承可能にしておき、IDisposableインターフェイスを実装してディスクリプタを破棄可能にします。コンストラクタで渡されたディスクリプタが負数の場合は、例外をスローしておきます。

このクラスを継承して、GPIOのIn,Outを実装してみます:

internal sealed class GpioOutput : Descriptor
{
    public GpioOutput(int gpioId, GPIO_OutputMode_Type type, bool initialValue)
        : base(Interops.GPIO_OpenAsOutput(
            gpioId,
            type,
            initialValue ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low))
    {
    }

    public void SetValue(bool value) =>
        Interops.GPIO_SetValue(
            this.Identity,
            value ? GPIO_Value_Type.GPIO_Value_High : GPIO_Value_Type.GPIO_Value_Low);
}

internal sealed class GpioInput : Descriptor
{
    public GpioInput(int gpioId)
        : base(Interops.GPIO_OpenAsInput(gpioId))
    {
    }

    public bool Value
    {
        get
        {
            Interops.GPIO_GetValue(this.Identity, out var value);
            return value == GPIO_Value_Type.GPIO_Value_High;
        }
    }
}

各APIへのアクセスは、前章で作ったInteropsクラスの定義をそのまま流用しています。また、ディスクリプタはDisposeメソッドで解放されるため、破棄についてはここでは何もしていません。特に説明不要で理解できると思います。

次に、タイマーの処理もクラスにカプセル化してしまいましょう。その前に、以下のようなインターフェイスを用意しておきます。これは、後でepollがタイマーの経過を通知するための窓口となるものです:

// epollを使ってイベントを受信するクラスが実装するインターフェイス
public interface IEPollListener
{
    // epollで管理に使用するディスクリプタ
    int Identity { get; }
    // epollがイベントを受信した際に呼び出すコールバックのメソッド
    void OnRaised();
}

タイマークラスはこのインターフェイスを実装して、epollからのイベントを受信できるようにします:

internal abstract class Timer : Descriptor, IEPollListener
{
    [NativeValue("time.h")]
    private static readonly int CLOCK_MONOTONIC;
    [NativeValue("time.h")]
    private static readonly int TFD_NONBLOCK;

    protected Timer()
        : base(Interops.timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK))
    {
    }

    public void SetInterval(long nsec)
    {
        var tm = new timespec
        {
            tv_sec = (int)(nsec / 1_000_000_000L),
            tv_nsec = (int)(nsec % 1_000_000_000L)
        };
        var newValue = new itimerspec
        {
            it_value = tm,
            it_interval = tm
        };

        Interops.timerfd_settime(this.Identity, 0, ref newValue, out var dummy);
    }

    // epollで管理に使用するディスクリプタ
    int IEPollListener.Identity => this.Identity;

    // epollがイベントを受信した際に呼び出すコールバックのメソッド
    void IEPollListener.OnRaised()
    {
        // タイマーイベントを消化する
        Interops.timerfd_read(this.Identity, out var timerData,(UIntPtr)(sizeof(ulong)));
        // 派生クラスの実装を呼び出す
        Raised();
    }

    // タイマー経過を示す純粋仮想メソッド(継承して処理を実装)
    protected abstract void Raised();
}

タイマークラスは更に継承して、Raisedメソッドを実装することで、タイマー経過時の処理を簡単に記述できるようにしておきます。

C#で書き始めた途端に、ものすごく設計が捗るようになった気がしますね。最後にepollを処理するクラスを用意します。epoll自体もディスクリプタで管理されるので、Descriptorクラスを継承します:

public sealed class Application : Descriptor
{
    public Application()
        : base(Interops.epoll_create1(0))
    {
    }

    // 指定されたインスタンスをepollに登録する
    public void RegisterDescriptor(IEPollListener target)
    {
        // インスタンスの参照を固定する
        GCHandle handle = GCHandle.Alloc(target, GCHandleType.Pinned);

        // epollにインスタンスハンドルを結びつけるための情報
        var ev = new epoll_event {
            events = Interops.EPOLLIN,
            data = new epoll_data_t { ptr = GCHandle.ToIntPtr(handle) }
        };

        // 登録する
        Interops.epoll_ctl(
            this.Identity,
            Interops.EPOLL_CTL_ADD,
            target.Identity,
            ref ev);
    }

    // 指定されたインスタンスをepollから解除する
    public void UnregisterDescriptor(IEPollListener target)
    {
        // ...
    }

    public void Run()
    {
        while (true)
        {
            var ev = new epoll_event();
            var numEventsOccurred = Interops.epoll_wait(this.Identity, ref ev, 1, -1);

            if (numEventsOccurred == -1)
            {
                break;
            }
            if (numEventsOccurred == 1)
            {
                // GCHandle.ToIntPtrメソッドで得たポインタを使ってGCHandleを復元する
                GCHandle handle = GCHandle.FromIntPtr(ev.data.ptr);
                // GCHandleからインスタンス参照を入手する
                var target = (IEPollListener)handle.Target;
                target.OnRaised();
            }
        }
    }
}

この後で紹介しますが、このクラスはWinFormsやWPFのApplicationクラスに似た位置づけなので、同じようにApplicationクラスと名付けました。IEPollListenerインターフェイスを実装したクラスをepollに連携させるように登録・解除出来るように、RegisterDescriptorメソッドとUnregisterDescriptorメソッドを公開しています。そして、Runメソッドでepollイベントの監視を行います。

このコードには少し解説が必要でしょう。ポイントはGCHandle構造体です。この構造体はインスタンスへの参照をガベージコレクタの操作から保護します。

この記事ではあえてIL2Cの内部構造に触れてきませんでしたが、IL2Cはマークアンドスイープ方式のガベージコレクタを持っています。クラスのインスタンスをnewすると、そのインスタンスはヒープメモリ(最終的にはmalloc関数)に格納されます。マークアンドスイープ方式では、メモリコンパクションと呼ばれる操作は行われませんが、使われていないインスタンスについては自動的に解放(free関数)されます。

ここで問題が2つあります:

  • ネイティブAPIに、インスタンス参照のポインタを渡す方法
  • ネイティブAPIがそのポインタを操作する可能性がある時に、誤ってインスタンスを解放してしまわないようにする方法

本物の.NET CLRや.NET Core、monoではこれに加えて、インスタンスが別の場所に移動してしまうかもしれない問題もある(メモリコンパクションが発生)のですが、IL2Cではその問題は発生しません。これらの問題は、GCHandleを使えば解決できます。

まず、インスタンスが誤って解放されないようにするには、GCHandle.AllocメソッドをGCHandleType.Pinnedで呼び出します。これで指定されたインスタンスは、IL2C上で使用されていなくても破棄されなくなります。

次に、GCHandle.ToIntPtrメソッドを呼び出すと、Allocで指定したインスタンスへのポインタが得られます。ポインタの型はSystem.IntPtr型(C言語ではintptr_t型)です。このポインタは、基本的に直接操作するためのものではありません。間接的にインスタンスを示す値と考えておけばよいでしょう。

これで上記2点についてはクリアできました。但し、注意すべきことがあります。一度GCHandle.Allocでインスタンスを固定すると、解除するまでは永久にヒープメモリに保持され続けます。不要になったタイミングでGCHandle.Freeメソッドを呼び出して固定を解除する事を忘れないようにしましょう。メモリリークに繋がります(具体的な方法についてはUnregisterDescriptorメソッドを参照して下さい)。

上記で得たポインタは、epoll_event構造体の中のepoll_data_t構造体に保存します。この構造体はNativeType属性をつけてあります(実際は共用体です。IL2C/Invokeなら簡単に共用体を扱えます):

[NativeType("sys/epoll.h")]
internal struct epoll_data_t
{
    // C言語のepoll_data_t.ptrは、void*型
    public NativePointer ptr;
}

[NativeType("sys/epoll.h", SymbolName = "struct epoll_event")]
internal struct epoll_event
{
    public uint events;
    public epoll_data_t data;
}

ptrフィールドはNativePointer型で宣言してあります。実際の型がvoid*で宣言されていますが、C#ではvoid*は限られた使用法でしか使えません。NativePointer型としておくと、System.IntPtr型と自由に変換することが出来るようになります。

さて、epoll_wait関数がイベントを受信すると、そのイベントに対応する情報がepoll_event構造体に格納されます。これは、epoll_ctl関数で登録した情報と同じものです。つまり、イベントが発生すると、GCHandle.ToIntPtrメソッドで得たポインタを取得できることになります。このポインタはGCHandle.FromIntPtrメソッドを使ってGCHandleに復元できます。

GCHandleさえ手に入ってしまえば、Targetプロパティから(C#で認識可能な)インスタンス参照を得ることが出来ます。プロパティの型はSystem.Object型なので、明示的にIEpollListenerインターフェイスにキャストしておきましょう。

最後にIEPollListener.OnRaisedメソッドを呼び出せば、イベントを登録したときのインスタンスのOnRaisedが呼び出されます(ここではTimer.OnRaisedメソッド)。

これでインフラは全部揃いました。Blinkのサンプルとしての実装を含めた、Mainメソッドの実装です:

public static class Program
{
    private sealed class GpioBlinker : Timer
    {
        private readonly long[] blinkIntervals = new[] { 125_000_000L, 250_000_000L, 500_000_000L };
        private readonly GpioOutput output;
        private bool flag;
        private int blinkIntervalIndex;

        public GpioBlinker(int gpioId)
        {
            output = new GpioOutput(
                gpioId,
                GPIO_OutputMode_Type.GPIO_OutputMode_PushPull,
                true);
            this.NextInterval();
        }

        public override void Dispose()
        {
            base.Dispose();
            output.Dispose();
        }

        protected override void Raised()
        {
            output.SetValue(flag);
            flag = !flag;
        }

        public void NextInterval()
        {
            this.SetInterval(blinkIntervals[blinkIntervalIndex]);

            blinkIntervalIndex++;
            blinkIntervalIndex %= 3;
        }
    }

    private sealed class GpioPoller : Timer
    {
        private readonly GpioInput input;
        private readonly GpioBlinker blinker;
        private bool last;

        public GpioPoller(int gpioId, GpioBlinker blinker)
        {
            input = new GpioInput(gpioId);
            last = input.Value;
            this.blinker = blinker;
            this.SetInterval(100_000_000L);
        }

        public override void Dispose()
        {
            base.Dispose();
            input.Dispose();
        }

        protected override void Raised()
        {
            var current = input.Value;
            if (current != last)
            {
                if (!current)
                {
                    blinker.NextInterval();
                }
            }
            last = current;
        }
    }

    public static int Main()
    {
        using (var epoll = new Application())
        {
            using (var ledBlinker = new GpioBlinker(Interops.MT3620_RDB_LED1_RED))
            {
                using (var buttonPoller = new GpioPoller(Interops.MT3620_RDB_BUTTON_A, ledBlinker))
                {
                    epoll.RegisterDescriptor(ledBlinker);
                    epoll.RegisterDescriptor(buttonPoller);

                    epoll.Run();
                }
            }
        }

        return 0;
    }
}

GpioBlinkerクラスはLEDを点滅させるクラス、GpioPollerはボタン入力を検知してLEDの点滅速度を変化させるクラス、そしてMainメソッドでApplicationクラスと2つのクラスを生成して登録し、イベント処理のループに入ります。

今までインフラを作っていたので全容が見えにくかったのですが、このMainメソッドと2つのクラスだけを見れば、かなりシンプルでわかりやすいと思います。この図のように、Lチカ固有のコードにだけ関心を向けることが出来ます。

インフラの部分は、MT3620(又はAzure Sphere)のライブラリとして固めておけば、更に捗りそうです。NuGetパッケージ化すればなお良いでしょう。

これで完成です。このコードは ac2018-step2のタグ で保存してあります。

生成されたコードを比較する

最後に、IL2Cが生成するコードが、手でC言語コードを書いたものと比較してどの程度のフットプリントなのかを見てみます。

比較対象は、ビルドするとgccによって生成されるネイティブバイナリです(デプロイ用パッケージではありません)。例えば、$(OutDir)/Mt3620App.outです。なお、以下の全ての計測は、デバッグ情報を含んでいない(-g0)サイズです:

Debug Release
SDK Blinker (C sample code) 16KB 16KB
IL2C Step1 (Non OOP) 111KB 42KB
IL2C Step2 (OOP) 142KB 54KB

SDK BlinkerのサイズがDebugとReleaseで同一なのは、間違いではありません。それだけ、コードの複雑性がなく、最適化の余地は無かったということでしょう。IL2CのStep1・Step2共に、最適化がかかった場合のフットプリント縮小が劇的です。これはIL2Cの設計上狙ったとおりの結果です。

IL2Cは、比較的単調なコード(言い換えればC言語コンパイラが最適化を行いやすいコード)を出力し、かつ、gccのオプション(-fdata-sections -ffunction-sections -Wl,–gc-sections)によって、使用されていないシンボルをリンク時に削除する事を前提としています。

IL2Cのランタイムはまだ小規模ですが、やがてcorefxのマージを行うようになると、非常に大きくなることが予想されます。その場合でも、最低限必要なコードだけがバイナリに含まれて欲しいので、この前提が重要になります。

他にも、C言語標準ライブラリをできるだけ使用する、という方針もあります。独自のコードを含めば含んだだけ、フットプリントが増えてしまうからです。System.Stringの文字列操作も、内部ではC言語のwcslenやwcscatなどの関数を使ったり、マークアンドスイープガベージコレクタが、mallocやfree関数をバックエンドとして使っている(そしてコンパクションを行わない)のも、このような背景によるものです。

しかし、それでもSDK Blinkerのサイズと比較すると、やや大きいですね。不本意です。まだ努力が足りない感じです。フットプリントを小さくするアイデアは、まだ色々あるので、今後のバージョンでトライして行こうと思います。

まとめ

IL2Cを使って、Azure Sphere MCUを搭載したMT3620で、C#を使ってモダンなスタイルのコードを書くという試みを行いました。今回の試行でうまく動くようにするための技術的なフィードバックは、結構沢山出てきました(詳しくはコミットログを参照して下さい。かなり難易度高くて、初見では意味不明かもしれませんが…)。ドッグフーディングはやはりやっておくべきですね。

IL2Cのような試みは他でもいくつか行われていて、LLVM向けのものとか、Roslynの構文木からC言語のコードを生成するとか、はてはC#でブート可能なOSを作る、なんてものもあります(ググってみて下さい)。それらのコミュニティプロジェクトや、本家.NET Core・monoとの違いとしては、ポータビリティとフットプリントかなと思っています。まだまだ完成には遠い感じですが、これからも、ただの概念実験にならないようにしていきたいと思います。

Center CLR Try!開発 #3

今日は、「Center CLR Try!開発 #3」に参加ました。

クリエイトベース金山さんのスペースでやりました。冬場だからか、自分も含めて体調良くない人が居るようですね…

私はIL2Cのオブジェクト参照の追跡が足りない部分の修正と本を読む、MatsuokaさんはnanoFrameworkとMbed-Cli、neco3csさんはAngularの勉強、中村さんはNGK2018BのLT資料作成、加藤さんは非公開案件、という目標でした。

NGK2018B、今年はLT登壇エントリーしなかったので、気が楽ですわ :)


IL2C、昨日の時点で、 classとvalue typeのメソッドオーバーライド・オーバーロード・interfaceの暗黙実装・明示実装とそれぞれが複雑に絡んだパターンについてテストを書きまくって網羅した ので、長らく放置してた value type内のobjrefが追跡されていないので勝手にGCに回収されてしまう問題 を対処する、その方策を考えました。

今日中に対処できれば良いんですが、そう簡単でも無いことは分かっていたので、今日は算段をつけるという感じ。
例えば以下のようなコード: (ちょっとC#とIL混ぜちゃってますが、適当に読んでください)

public struct ObjRefInsideValueTypeType
{
    public string Value;  // <-- ここに動的に生成された文字列"ABCDEF"が保持される
    public ObjRefInsideValueTypeType(string value) => this.Value = value;
}

.class public IL2C.RuntimeSystems.ValueTypes
{
    .method public static string ObjRefInsideValueType() cil managed
    {
        .maxstack 3
        .locals init (
            [0] valuetype IL2C.RuntimeSystems.ObjRefInsideValueTypeType   // <-- IL2Cはこの構造体から上のValueフィールドを追跡できなければならない(がしていない)
    	)
        ldloca.s 0
        ldstr "ABC"
        ldstr "DEF"
        call string [mscorlib]System.String::Concat(string, string)
        call instance void IL2C.RuntimeSystems.ObjRefInsideValueTypeType::.ctor(string)

        // Release concat string from the evaluation stack
        ldstr "dummy1"
        ldstr "dummy2"   // <-- IL2Cはevaluation stackをCのローカル変数として確保するので、そこに参照が残っていると追跡されてしまうことから
        pop              //     別の参照を上書かせて追跡されないようにしてテストしている。
        pop              //     本来なら、こうしたとしても、value typeのフィールドは別の手段で追跡されなければならない(が今はダメ)

        call void [mscorlib]System.GC::Collect()
        ldloc.0
        ldfld string IL2C.RuntimeSystems.ObjRefInsideValueTypeType::Value
        ret
    }
}

で、ObjRefInsideValueType()を呼ぶとこういったケースのテストを行うのですが、構造体ObjRefInsideValueTypeType内のValueフィールドにセットされた文字列(System.String.Concatによって動的に生成されてヒープに配置された文字列のインスタンス)が、GC.Collect()によって回収されてしまうという問題です。

(文字列を結合しているのは、単なるリテラル文字列だとstatic constに配置されてしまってGCから無視されてしまうので、動的に生成させています。まあboxingされたインスタンスとか使っても良かったんですが)

とりあえずこのテストを書いて実行すれば、(IL2Cの問題によって) 文字列はGCに回収されてしまい、メソッドから返された文字列への参照 (IL2C上はポインタ) は無効な値を示していて、その後のアサートで刺さるからテストに失敗する、というシナリオです。

なんですが… 何故かテストが成功する…

で、VC++で実際にデバッグしてみると、問題なく無効ポインタで刺さったことが検出されます。IL2CのランタイムがこれをNullReferenceExceptionとしてスローする所に問題があって、想像してなかった死に方をしましたが(Unhandled exceptionはTODOで放置してたんだった…)

IL2CのテストはNUnitで書いていますが、実際には:

  1. テストコードのC#は、Roslynによって普通にアセンブリ(IL)になる
  2. アセンブリを普通に実行して、正しい結果が得られることをNUnitでアサートする
  3. アセンブリをIL2C.Coreに食わせてCソースコードを生成する
  4. Cソースコードをコンパイル(MinGW gcc4 32bitを使用)してネイティブの実行コードを生成する(今の所Windowsでやってるので、test.exe的なものが生成される)
  5. 実行コードを実際に実行する。内部ではテスト結果が同じようにアサートされる(この実行には.NET CLRは一切関与しない)。成功時は”Success”とstdoutに出しているので、それを確認

という感じで、テスト結果が完全に一致することを自動的に確認するようにしています。なので、今度はVC++とgccで結果が異なるという可能性がありえたので、VSCodeでデバッグ(C++ extensionでGDBを使ってデバッグできる)してみたのですが、こちらも正しく無効ポインタを踏んで死んでました。

んー何故なのか… と調べてるところで時間切れ。嫌な感じだな…


Try!開発は定期的にやる予定なので、興味があれば是非参加してください。

Center CLR Try!開発 #2

今日は、「Center CLR Try!開発 #2」に参加ました。

クリエイトベース金山さんのスペースを借りることが出来ました。募集が遅かったので人の集まりが悪かったのですが、参加者3人ともいろいろ吸収出来たようで良かったです。

私はIL2Cの例外処理、Matsuokaさんは次週のプレゼン資料、ayumaxさんはUnityで年末忘年会用の出し物を作る、という目標でした。


IL2Cは例外処理に取り組んでいます。
例外のうち、単純なcatch・複数のcatchの呼び分け・ネストしたcatch・rethrowと、それらそれぞれについてlocal unwind・global unwindは動いていて、finallyを変換可能にするのが今日の目標でしたがダメでした。

この図はIL内にtry-catch-finallyブロックの定義があった場合に、どのようなCのソースコードに変換すれば実現できるかを検討していたものです。ざっくり言うと、sjlj方式でsetjmpの戻り値によって例外ブロックに分岐して処理させ、finallyはそれらがbreakでブロック外に遷移したときに面倒を見る、という感じです。

(sjljの効率が悪いことは承知。移植性重視で、この作業が終わってからWindowsについてはSEHを使えるように、その先ではlibunwindを使うことも考えています)

悩んで解決できなかった部分が、最近C# 6で追加された例外フィルタ条件のサポートです。これはVB.net 1.0から使える(つまりはIL的には最初から実現可能だった)ものです。以下にC# 6で書いた例を示します:

class Program
{
    static string GetMessage(Exception ex, string banner)
    {
        Console.WriteLine(banner);
        return ex.Message;
    }
    static void Main(string[] args)
    {
        try
        {
            try
            {
                // C D B E F H
                throw new Exception("111");
                // C D [Unhandled exception]
                //throw new Exception("333");
            }
            catch (Exception ex) when (GetMessage(ex, "C") == "222")
            {
                Console.WriteLine("A");
            }
            finally
            {
                Console.WriteLine("B");
            }
            Console.WriteLine("G");
        }
        catch (Exception ex) when (GetMessage(ex, "D") == "111")
        {
            Console.WriteLine("E");
        }
        finally
        {
            Console.WriteLine("F");
        }
        Console.WriteLine("H");
    }
}

C#クイズとかで出てきそうですが、例外フィルタ式(コード上 GetMessage()を呼び出しているwhen句)の呼び出し順序を見て、のけ反る人もいるかも知れません。コメントに書いておきました。

例外がスローされるとき、

  1. 現在のスタックフレームの直近に存在するcatchブロックのうち、指定された例外型にキャスト可能なものがあるかどうかを探索し、なければよりスタックの底に向かって探索し続ける。
  2. 上記が存在する場合、更にフィルタ条件式があればそれを実行し、結果が満たされるかどうかを確認する。
  3. 1又は2が満たされてから、そのcatchブロックに遷移する。

という順序で実行する必要があります。ここで苦しいのは、スタックの底まですべての条件を確認し終えるまでは、catchブロックに遷移してはならない、という点です。したがって、まず、catch句の条件とwhenのフィルタ条件をすべてチェックする必要があります(満たされるものが見つかった時点で打ち切ることは可能)。

問題なのは、例外の型のチェックだけなら、IL2Cが自分の都合の良い判定Cコードを生成すればいいのですが、フィルタ条件式はILで指定されるので、ILを実行してフィルタ条件を満たしているかどうかを確認しなければなりません。しかし、catchブロックやfinallyブロックは、そのブロックを実行することが判明した時点でそこまでスタックを巻き戻し(sjljであればlongjmpする)ても問題ないことが自明ですが、フィルタ条件式の実行中は、まだスタックを巻き戻すことは出来ません。巻き戻してしまったらもう元に戻せず、それまでのスタック上の情報はすべて失われます(移植性を考えると、失われたと見なす必要があります)。

(上記のサンプルコードはまだ良いのですが、メソッドのローカル変数を使ったフィルタ条件式を考えてみると、問題の大きさがわかります)

これは頭を抱えました。

そもそも、例外フィルタ条件式を真面目に処理することをやめて、以下のように評価するという代替え案も考えられます:

try
{
    throw new Exception("111");
}
catch (Exception ex) // when (GetMessage(ex, "C") == "222")
{
    // Rethrow if additional condition is false
    if (!(GeMessage(ex, "C") == "222")) throw;
    Console.WriteLine("A");
}

しかし、この方法には2つの問題があります:

  • 例外ハンドラに遷移してから、更にrethrowで遷移するので、コストが高い。
  • 同じ型でcatchするブロックについて、一つのブロックで処理するように統合する必要がある。

11/11追記: これもやっぱり駄目ですね。スタック上位にfinallyブロックがある場合、このcatchに遷移した時点でfinallyを実行してしまいます。例外フィルタ式を使った場合と、実行順序が入れ替わってしまいます。

色々考えた結果、結局、以下の理由で、今その対処を行うことをやめました:

  • 現在(メソッドの)ローカル変数群のうち、オブジェクト参照を追跡する必要のある変数(GCがトラッキングして、不要なインスタンスではないことを確認する)は、そのアドレス群を “EXECUTION_FRAME”という構造体に記録し、リンクリストに追加することでGCがトラッキングできるようにしていますが、ここの実行効率が悪いことがわかっています。
  • 近い内にこれを改良する予定ですが、その際にこれらのローカル変数へのベースとなるポインタが用意に得られるようになるはずなので、フィルタ条件式のILを変換する場合には、このポインタ経由でローカル変数群にアクセス可能にすれば、スタックを一時的に巻き戻さなくとも式の評価が出来るはずです。
  • そのため、手順として、EXECUTION_FRAMEの改良が終わってから、改めて考えても良いかも?

と考えました。
なので、本日の目標としては、フィルタ条件式に対応しないが例外は処理できる、という感じに決定しました(そして、完成しなかった… 8割ぐらい?家に戻ったらfinishしたい)

ちなみに、.NET Framework CLRや.NET Core、monoとかはどうやっているのかという興味が出てきますね。自力でネイティブコードを出力してるので、如何様にでも出来るといえば出来ますが…

——-

Try!開発は定期的にやる予定なので、興味があれば是非参加してください。

ただの素人がフロー解析を通過した – 言語実装 Advent Calendar 2017

この記事は「言語実装 Advent Calendar 2017」の11日目のネタです。

今、私は「IL2C」 というプロジェクトをやっているのですが、その実装中に起きた予期せぬフロー解析を、素人なりに実装した話です。IL2Cについては以下を参照してもらえればわかりますが、.NETのIL (Intermediate Language) を、C言語ソースコードに変換します。

GitHub: IL2C step-by-step design project
YouTube: Playlist: Making archive IL2C

「AOT技術 Advent Calendar 2017」でもこれまでの内容を要約して書いてます。この記事はダブルエントリーにしました。

また、「Extensive Xamarin」 でその時点までの技術的なポイントを執筆したので、興味があれば参照して下さい… いや、買って下さい :)

※本記事は抜粋ですが、.NETにあまり明るくない方向けに、細部を省いて焦点に絞って編集してあるため、省略した解説があります。


厨二変換病

私は、IL2Cを実際に作り始めるまでは、こう考えていた —

(ゲームエンジンの)Unityには、”IL2CPP”というツールがあります。IL2CPPとは、.NETのIL(Intermediate Language)を、C++のコードに変換するツールです。ILとは、JavaのバイトコードやLLVM-IRに相当する、.NETの中間言語です。

“IL2CPP”と聞けば、ILのバイトコードを(多少面倒ではあるものの)パースし、一対一でC++のコードに変換していけば完成し、後は、ランタイムをどう実装するのかが大きなトピックだろうと想像できます。

プリミティブタイプは、C++の対応する型に置き換えればよいでしょう。.NET CLRが扱うクラスや構造体の型は、C++のクラスにマッピング出来そうな気がします。

型全体について、System.Object(.NETにおける基底クラス)と同じような基底クラスを軸に、C++でも継承関係を構築すれば、例外クラスもほぼそのまま変換できるように思えます。しかも、C++にはtry-catchが存在するため、例外ハンドリングも楽に実装できるかもしれません。

そして、C++で記述できるコードは、多少冗長になったり面倒になったりしますが、C言語だけでも実装できることを知っています。むしろツール化によって自動生成するのであれば、C言語で出力することは大した問題ではありません。

大きな問題があるとすれば、ガベージコレクションとスレッド周りですが、これらは主にランタイムとの協調が主軸となります —

しかし、IL2Cの設計を進めていくうちに、型の扱いがそれほど簡単でもないことに気が付いた…

立ちはだかる様々な問題のうち、全く想定していなかった「フロー解析」についての知見を残しておきます。同じような事を妄想しちゃった仲間のために :)


.NET CLR評価スタック

.NETのランタイムのことを、「.NET CLR」と呼びます。CLRには仮想計算器が定義されています。この計算器(CPUと読み替えてよい)は、ILのバイトコードを逐次解釈しながら、OpCodeの定義に従った計算を実行します。CLRの仮想計算器は「スタックマシン」と呼ばれている種類のアーキテクチャです。

スタックマシンとは、仮想計算器が使用するスクラッチパッドに、文字通り「スタック」構造のバッファを使います。このスタックのことを「評価スタック」と呼んでいます。例えば、以下のような3つの引数の値を加算するメソッドを見ます:

.method public hidebysig static 
    int64 Add (uint8 a, int32 b, int64 c) cil managed 
{
    ldarg.0     // (1)
    ldarg.1     // (2)
    add         // (3)
    conv.i8     //  |  (expand int32 --> int64)
    ldarg.2     // (4)
    add         // (5)
    ret         // (6)
}

これを、「擬似的」にC++ STLで書いてみます:

int64_t Add(uint8_t a, int32_t b, int64_t c)
{
    // The evaluation stack
    std::stack<object> stack();

    stack.push_back(a);   // (1)
    stack.push_back(b);   // (2)

    // (3)
    stack.push_back((int64_t)(stack.pop_back() + stack.pop_back()));

    stack.push_back(c);   // (4)

    // (5)
    stack.push_back(stack.pop_back() + stack.pop_back());

    // (6)
    return (int64_t)stack.pop_back();
}

評価スタックとは「スタック」と呼ばれるように、LIFO構造のスクラッチパッドです。ポイントは、評価スタックが「スタック」ゆえに、必ずスタックに積まれた順序を守りながら操作するということです。この点は、一般的な物理CPU、たとえばx86・x64・ARMなどのCPUと異なります。これらのCPUは「レジスタ」と呼ばれるスクラッチパッドを使い、評価スタックのようにLIFOであることを想定しません。

また、評価スタックにはどのような値も入れることが出来ます。プリミティブ型・値型・オブジェクト参照・マネージ参照などです。

以下に評価スタックの処理を示します:

  1. aの値を評価スタックにプッシュ
  2. bの値を評価スタックにプッシュ
  3. 2つの値を取り出し(ポップ)ながら加算処理を行い、結果をプッシュ
  4. cの値を評価スタックにプッシュ
  5. 2つの値を取り出し(ポップ)ながら加算処理を行い、結果をプッシュ
  6. 値を取り出し(ポップ)、呼び出し元に返す

評価スタックの正体

このC++コードには、明らかに問題のありそうな実装が含まれています。もう一度、ILと対応するC++コードを示します:

.method public hidebysig static 
    int64 Add (uint8 a, int32 b, int64 c) cil managed 
{
    ldarg.0     // uint8 a
    ldarg.1     // int32 b
    add
    conv.i8     // int32 --> int64
    ldarg.2     // int64 c
    add
    ret         // int64
}
int64_t Add(uint8_t a, int32_t b, int64_t c)
{
    // First problem: cannot declaration object type using C++
    std::stack<object> stack();

    stack.push_back(a);
    stack.push_back(b);

    // Second problem: cannot find operator +() overload
    stack.push_back(stack.pop_back() + stack.pop_back());

    stack.push_back(c);

    // Third problem: cannot find operator +() overload
    stack.push_back(stack.pop_back() + stack.pop_back());

    return (int64_t)stack.pop_back();
}

まず、std::stackのテンプレート型引数”object”が解決できません。つまり、C++にはSystem.Object(.NETにおける基底クラス)に対応するクラスが存在しないため、このコードは成り立ちません。(関連して、operator +()が解決できないという問題も存在します)

仮に、全ての型の基底となるクラスを導入すれば解決できそうな気もします(同程度の新たな問題も発生しますが、ここでは述べません)。しかし、私達の目標はC++ではなくCです。C言語で上記のコードを模倣する場合、次の方法が考えられます:

  1. std::stackと等価な、テンプレートクラスベースではないスタック操作関数を用意する
    この方法は、スタックポインタの操作(現在のスタック位置を把握する)を実装する必要があり、Cコンパイラが最適化によってこれらを不要と検出できるかどうかが鍵となります(実際のところ、スタックポインタの計算処理が含まれるため、難しいかもしれません)。しかし、結局、System.Object型をC言語でどのように扱うのかが解決されません。
  2. スタックに値が2個しか出し入れされていない前提で、それぞれをローカル変数に置き換える
    「人間の目」で確認すれば、評価スタックのインデックス(スタックに積まれた位置)毎に、何型の値を格納しているのかがわかります。

次のILの例では:

ldarg.0     // [0] int32
ldarg.1     // [1] int32    (A)
add         // [0] int32    (B)
conv.i8     // [0] int64    (C)
ldarg.2     // [1] int64    (D)
add         // [0] int64    (E)

OpCode実行後のスタックの位置とその型が定められているため、この情報を使って、次のようなCコードに変換できます:

// Evaluation stack simulated by C
int32_t stack0_0;
int32_t stack1_0;
int64_t stack0_1;
int64_t stack1_1;

// ldarg.0
stack0_0 = a;
// ldarg.1  (A)
stack1_0 = b;

// add      (B)
stack0_0 = stack0_0 + stack1_0;

// conv.i8  (C)
stack0_1 = stack0_0;

// ldarg.2  (D)
stack1_1 = c;

// add      (E)
stack0_1 = stack0_1 + stack1_1;

// ret
return stack0_1;

評価スタックの使われ方を図示すると、次のようになります:

これで、評価スタックをCコードで問題なくシミュレートできたことになります。出力されるコードの問題自体は解決しましたが、IL2Cの変換処理は非常に複雑になります。当初の想定では、リフレクションAPIで取得できるメタデータ情報とILのバイトコードを、逐一Cコードに置き換えるだけでした。

たとえば、メソッドの引数群・戻り値・ローカル変数群の型は、Typeクラスのインスタンスによって直接かつ容易に特定できます。しかし、評価スタックの操作だけは、型が明示的に指定されない(PushやPop時に型指定されない)ため、PushやPop操作を追跡し、どこでどのスタック位置をどのように使っているか、を解析しなければならないのです。


評価スタック解析の方法

ILのバイトコードを上から順に解析するのではなく、評価スタックの操作を追跡して解析しなければならないとすると、実際に仮想計算器が解析を行う挙動を模倣する必要があります。具体的には、バイトコードのエントリポイント(先頭)から順に解析を行いつつ、

  1. OpCodeのPushやPopの操作を追跡する(その際に、Pushされた型を記憶する)
  2. ブランチ命令を発見した場合は、ブランチ先を新たなフローとしてキューに保存する
  3. ret命令や、解析済みのバイトコードに到達した時点で、そのフロー解析を終了する

という動作を、キューを全て消費するまで繰り返します。ここで重要なのは、評価スタックの使用方法のチェックです。

ブランチ命令によって遷移する先のバイトコードは、すでにフローとして解析済みの可能性があります。その場合、そこから先で使用(Pop)する予定のスタックの型が、ブランチ直前のスタックの型と一致していれば、Cコード上でもまったく同じコードが使用可能であるとみなせます。しかし、スタックにPushされている値の型が異なる場合、ブランチ元で想定される型で新たにCコードを出力しなければなりません。次のコードは、手動で技巧的なILを構成した例です:

// Handmade IL code
.method public hidebysig static 
    native int Multiple10 (native int a) cil managed 
{
    .locals (
        [0] int32 count
    )

    ldc.i4.s 10
    stloc.0
    ldc.i4.0            // [0] int32   (A)

L_0000:
    ldarg.0             // [1] native int
    add                 // [0] native int  (B)
    ldloc.0
    ldc.i4.1
    sub
    stloc.0
    ldloc.0
    brtrue.s L_0000     // (C)

    ret
}

OpCodeの”add”命令は、次のような評価スタックの入力を受け付けます(他にもバリエーションがありますが省略します):

  1. int32 + int32 → int32
  2. int64 + int64 → int64
  3. int32 + native int → native int
  4. native int + native int → native int

native intとは、”System.IntPtr”のことです。これは32ビット環境では32ビット値、64ビット環境では64ビット値を保持します。(但し、現在のIL2Cは、まだnative intをサポートしていません)

ここで問題なのは、(B)で計算されるadd命令が、初回は(A)により3)であり、次回以降が4)として処理されることです。こういったコードはC#では書くことができませんが、ILとしては有効です。上記の判定は、ILのバイトコードをデコードした、静的な情報だけではわからないため、フローを解析し、

  1. 最初のフローで(A)によって、スタック[0]位置がint32として扱われていること
  2. (C)のブランチによってL_0000へ遷移する新たなフローが発生し、かつ、その際のスタックの使用状況がint32→native intと変わっていること
  3. (C)のブランチが条件を満たさない場合の、後続のフローが存在すること

を判断し、特に2)によって、「似たような、しかし異なるCコードを生成する必要」を判断します。

結果として、次のようなCのコードを出力します:

intptr_t Multiple10 (intptr_t a)
{
    int32_t count;

    int32_t stack0_0;
    intptr_t stack0_1;
    int32_t stack1_0;
    intptr_t stack1_1;
    int32_t stack2_0;

    stack0_0 = 10;
    count = stack0_0;

    stack0_0 = 0;

    stack1_1 = a;
    stack0_1 = stack0_0 + stack1_1;     // (D)

    stack1_0 = count;                   // ---+
    stack2_0 = 1;                       //    |
    stack1_0 = stack1_0 - stack2_0;     //    |
    count = stack1_0;                   //    | (F)
                                        //    |
    stack1_0 = count;                   //    |
    if (stack1_0 != 0) goto L_0000      //    |
                                        //    |
    return stack0_1;                    // ---+

L_0000:
    stack1_1 = a;
    stack0_1 = stack0_1 + stack1_1;     // (E)

    stack1_0 = count;                   // ---+
    stack2_0 = 1;                       //    |
    stack1_0 = stack1_0 - stack2_0;     //    |
    count = stack1_0;                   //    | (F)
                                        //    |
    stack1_0 = count;                   //    |
    if (stack1_0 != 0) goto L_0000      //    |
                                        //    |
    return stack0_1;                    // ---+
}

(D)と(E)によって、スタック[0]に想定される型が異なるのがわかりますか? C言語では、ローカル変数に型を指定しなければなりません。同じスタック位置でも異なる型を必要とする場合は、
その変数を使用する全ての箇所で、ソースコードを再生成する必要があります。

但し、(F)の部分は完全に同一なので、ラベルとgotoを駆使することで共通化できる可能性があります。(現在のIL2Cはその検知を行っていません。判定は複雑ではなさそうですが、難易度は高いと思います。また、ただgotoで遷移させただけだと、可読性が極端に低下する可能性があります)

まとめ

.NET ILのメタデータには、.NETの型情報が全て含まれています。リフレクション(やメタデータを解析するサードパーティのライブラリ)を使用すれば、型の情報は容易に引き出すことが出来ます。これによって、フィールドの型・メソッドの戻り値の型・メソッド引数群の型・ローカル変数群の型など、あらゆる型を直接特定できます。しかし、評価スタックの各スロットに格納される値の型については、どのような型が格納されるのかを静的に定めることが出来ません。

IL2Cでは、IL内での評価スタックの使われ方を解析して、評価スタックのスロットにどんな型を想定しうるかを調べています。そして、その情報を元に、完全に静的に定める必要のあるC言語向けに、型ごとのコード生成を行っています。

当初はこのような事を考えていなかったので、必要性を知ったときには「これは大変そうだ」と思いましたが、何とか乗り切ったので良かったです。