Thread.SleepとAsync.Sleepの違い

2017/07/08

F#の本で非同期処理について触れている個所では、Thread.SleepではなくAsync.Sleepを使って説明されていた。

この本

ところで、この2つは何が違うのか?

どうやら調べてみると、以下のような違いがあるらしい。

(1)Thread.Sleepは本当にスレッドの動作を停止するらしい

(2)Async.Sleepはスレッドの動きを停止するのではなく、指定された時間経過後に後続の処理をスレッドプールで再開する。

わかりにくいのが、Async.Sleepを使って以下のように記述した場合、

let func2 =
    async {
        (* ここで何かの処理を行う(前半) *)
        do! Async.Sleep 1000
        (* ここで何かの処理を行う(後半) *)
    }

結局のところ、前半の処理を行った後、上記であれば1秒間待ち合わせてから、後半の処理を行うことになるわけで、少なくとも表面的には、以下のようなコードと挙動に相違がない。

let func2 =
    async {
        (* ここで何かの処理を行う(前半) *)
        Thread.Sleep 1000
        (* ここで何かの処理を行う(後半) *)
    }

ただ、厳密にいえば、Async.Sleepを使った場合には、前半の処理と後半の処理とで、実行するスレッドが異なる場合があるので、スレッド毎に依存する何かを使っている場合には、アプリケーションから見える挙動に相違があるともいえる。

例えば、以下のようにすると、スレッドのIDが変わっていることが確認できる。

module  FTest
open System 
open System.Threading

let func2 =
    async {
        printf "%d\n" Thread.CurrentThread.ManagedThreadId
        do! Async.Sleep 1000
        printf "%d\n" Thread.CurrentThread.ManagedThreadId
    }

[<EntryPoint>]
let main(_) = 
    Async.Start func2
    Thread.Sleep 3600000
    0

実行結果はこうなる。




では、スレッドのIDが変わるかもしれない、ということ以外に何か相違はないのか? だとしたら、より直感的に動作してくれるThread.Sleepを使ったほうがいいのではないか? という気がしてくるが、どうやらそうではないらしい。


Thread.Sleepを使った場合は、スレッドを停止させてしまうため、.NETのスレッドプールは、ほかの処理を受け入れられるように追加でスレッドを生成するようだ。一方、Async.Sleepでは必要に応じて(つまり一定時間経過後に)タスクをスレッドプールに放り込むだけなので、スレッド数が増大することがないらしい。

だから、asyncワークフロー内で逐次的に処理されるんだったら一緒じゃねぇかと言って、非同期的なAPIを使わず同期的なAPIを使ってしまっては効率が低下するらしい。

ということで、ちょっと試してみた。

まずはThread.Sleepを使った場合。

module  FTest
open System 
open System.Threading

let func1 =
    async {
        Thread.Sleep 3600000
    }

[<EntryPoint>]
let main(_) = 
    for i = 0 to 10000 do
        Async.Start func1

    Thread.Sleep 3600000
    0

これを実行してしばらくリソースモニターを見ていると、スレッド数が増加し続けていく。




Async.Sleepを使った以下のようなコードでは、

module  FTest
open System 
open System.Threading

let func2 =
    async {
        do! Async.Sleep 3600000
    }

[<EntryPoint>]
let main(_) = 
    for i = 0 to 10000 do
        Async.Start func2

    Thread.Sleep 3600000
    0

スレッド数の増加がみられない。




上でも書いたが、なんでもこれはSleepに限らずI/Oの場合も同様らしいので、できるのであれば同期型のI/O命令を使用するのではなく、非同期型のI/O命令を使って、asyncないのdo!やlet!で待ち合わせるようにしたほうがいいらしい。そうでないと、過剰なスレッドの生成によるメモリの無駄遣いや、コンテキストスイッチの多発による性能の劣化が生じるのだという。


と、いうようなことが、.NET Flameworkの本に書いてあった。


この本


アプリケーションドメインを分離した実装について考えてみる

2017/07/03

先日からプロセス間通信の方法を探っているのは、ものを作るにあたって、プロセスを分離することで万一の障害発生時に影響範囲を限定することで、全体の信頼性を向上することができないかと考えているためである。

だが、調べてみると、.NETではアプリケーションドメインを分離するという手口も使えるらしい

これはなんか期待できそうだと思い、ちょっとやってみた。

open System
open System.Threading
open System.Reflection

let LoopCount = 100000UL

// アプリケーションドメイン境界を越えてアクセスされる型
type public CFoo() =
    inherit MarshalByRefObject()

    // 以下は"domain2"のアプリケーションドメインで実行される
    member public this.goo arg =
        ( arg / 2UL )

// mainはデフォルトのアプリケーションドメインで実行される
[<EntryPoint>]
let main argv = 
    // 新規にアプリケーションドメインを作る
    let ad2 = AppDomain.CreateDomain( "domain2", null, null )

    // 新しいアプリケーションドメインにアセンブリをロードし、
    // CFooにアクセスするプロキシを生成する
    let rfoo =
        ad2.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof<CFoo>.FullName
        ) :?> CFoo

    // 意味もなくメソッドgooを呼び出し続ける処理
    let rec foo cnt sum =
        if cnt < LoopCount then
            foo ( cnt + 1UL ) ( sum + ( rfoo.goo cnt ) )
        else
            sum

    // CFoo.gooを100000回呼び出す処理を5回繰り返す
    for i = 1 to 5 do
        let StartTime = DateTime.Now
        printf "%u\n" ( foo 0UL 0UL )
        let ElapseTime = ( DateTime.Now - StartTime ).TotalMilliseconds
        printf
            "time=%f(ms), count/sec=%f\n"
            ElapseTime ( ( float LoopCount ) / ElapseTime * 1000.0 )

    0

上記を実行すると、以下となる。

2499950000
time=53.564900(ms), count/sec=1866894.178837
2499950000
time=51.071400(ms), count/sec=1958043.053451
2499950000
time=51.042200(ms), count/sec=1959163.202213
2499950000
time=51.086900(ms), count/sec=1957448.974199
2499950000
time=51.556400(ms), count/sec=1939623.402720
続行するには何かキーを押してください . . .

CFoo.gooが、mainを実行している既存のアプリケーションドメインとは異なるアプリケーションドメインで実行されているという証拠は示していないが、その辺りは長くなるから割愛する。その辺りは、CFoo.gooに「Thread.GetDomain().FriendlyName」を表示する処理を入れて実行してみればわかる。

むしろここで問題になるのは性能である。上記は、プロセスを分離してWCFで通信を行うものと同じ処理を行っているのだが、その時には秒間で2万回がせいぜいだった。しかし、今回は195万回を超える回数を実行できている。圧倒的に高速である。ここまで違うとなると、是が非でもこちらを使いたくなる。

では、次はアプリケーションドメインを分離することで、プロセスを分けるのと同じような効果が得られるのかどうかを考えてみる。とはいっても闇雲に調べてもきりがないから、一番気になる、下記のような観点に絞って、問題の有無を調査してみる。

  • 未処理の例外が発生した時の影響範囲を限定できるのか?
  • 問題が生じたときに、狙ったアプリケーションドメインだけ殺すことができるのか?

まず1点目について、下記のような処理を試してみた。

open System
open System.Threading
open System.Reflection

// アプリケーションドメイン境界を越えてアクセスされる型
type public CFoo() =
    inherit MarshalByRefObject()

    // 以下のメソッドは"domain2"のアプリケーションドメインで実行される
    member public this.goo () =
        // 呼ばれたらすぐに例外を発生させる
        raise <| System.Exception "bomb1"
        printf "CFoo.goo %s\n" ( Thread.GetDomain().FriendlyName )

[<EntryPoint>]
let main argv = 
    printf "main start %s\n" ( Thread.GetDomain().FriendlyName )

    // アプリケーションドメインを作る
    let ad2 = AppDomain.CreateDomain( "domain2", null, null )

    // とりあえず、自分自身のexeをロードして、CFooオブジェクトを作る
    let rfoo =
        ad2.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof<CFoo>.FullName
        ) :?> CFoo

    // 呼び出す
    rfoo.goo ()

    Thread.Sleep 10000

    printf "main end %s\n" ( Thread.GetDomain().FriendlyName )
    0

実行結果は以下となる。

main start ADTest.exe

ハンドルされていない例外: System.Exception: bomb1
   場所 Program.CFoo.goo() 場所 D:\MO\F#\ADTest\ADTest\Program.fs:行 12
   場所 Program.CFoo.goo()
   場所 Program.main(String[] argv) 場所 D:\MO\F#\ADTest\ADTest\Program.fs:行 30
続行するには何かキーを押してください . . .

余裕でプロセス全体が玉砕。

スタックトレースを見れば明らかだが、CFoo.gooで発生した例外は、アプリケーションドメインの境界を越えてmainにまで伝播し、そこでも例外が処理されずにプロセスダウンに至っている。

なので、CFoo.gooの呼び出し前後で例外を捕まえるようにすれば、プロセスダウンを回避できる。それについては当たり前すぎるので、プログラム例は割愛する。

次に、新規に作成したアプリケーションドメイン内で例外が発生したらどうなるのか試してみた。すなわち、確実にmainにまで伝播しない例外が発生して、それが未処理となった場合の挙動を試してみる。

open System
open System.Threading
open System.Reflection

// アプリケーションドメイン境界を越えてアクセスされる型
type public CFoo() =
    inherit MarshalByRefObject()

    // 以下のメソッドは"domain2"のアプリケーションドメインで実行される
    member public this.goo () =
        // 3秒後に別スレッドで例外を発生させる
        async {
            Thread.Sleep 3000
            raise <| System.Exception "bomb1"
        } |> Async.Start
        printf "CFoo.goo %s\n" ( Thread.GetDomain().FriendlyName )

[<EntryPoint>]
let main argv = 
    printf "main start %s\n" ( Thread.GetDomain().FriendlyName )

    // アプリケーションドメインを作る
    let ad2 = AppDomain.CreateDomain( "domain2", null, null )

    // とりあえず、自分自身のexeをロードして、CFooオブジェクトを作る
    let rfoo =
        ad2.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof<CFoo>.FullName
        ) :?> CFoo

    // 呼び出す
    rfoo.goo ()
    printf "main rfoo.goo called %s\n" ( Thread.GetDomain().FriendlyName )
    Thread.Sleep 10000
    printf "main end %s\n" ( Thread.GetDomain().FriendlyName )
    0

こうなる。

main start ADTest.exe
CFoo.goo domain2
main rfoo.goo called ADTest.exe

ハンドルされていない例外: System.Exception: bomb1
   場所 Program.goo@13.Invoke(Unit unitVar) 場所 D:\MO\F#\ADTest\ADTest\Program.fs:行 14
   場所 Microsoft.FSharp.Control.AsyncBuilderImpl.callA@839.Invoke(AsyncParams`1 args)
--- 直前に例外がスローされた場所からのスタック トレースの終わり ---
   場所 Microsoft.FSharp.Control.CancellationTokenOps.Start@1292-1.Invoke(ExceptionDispatchInfo edi)
   場所 .$Control.loop@425-51(Trampoline this, FSharpFunc`2 action)
   場所 Microsoft.FSharp.Control.Trampoline.ExecuteAction(FSharpFunc`2 firstAction)
   場所 Microsoft.FSharp.Control.TrampolineHolder.Protect(FSharpFunc`2 firstAction)
   場所 .$Control.-ctor@509-1.Invoke(Object state)
   場所 System.Threading.QueueUserWorkItemCallback.WaitCallback_Context(Object state)
   場所 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()
   場所 System.Threading.ThreadPoolWorkQueue.Dispatch()
   場所 System.Threading._ThreadPoolWaitCallback.PerformWaitCallback()
続行するには何かキーを押してください . . .

俺としてはここで、"domain2"だけが終了させられて、既定のドメイン(ここではADTest.exe)の処理は続行してほしいのだが、そうはいかない。見事にプロセスごと崩御なされる。この野郎。

なんでも、CLRの未処理例外に対するデフォルトのエスカレーションポリシーなるものがあり、未処理例外が起きた場合には、プロセスを終了させる処理を行うようになっているらしい。でもって、この挙動を変えるためには、CLRのホストインタフェースを叩いてやらねばならぬらしい。何を言っているかというと、別のプログラム(例えばアンマネージなC++のプログラム)から、COMのインタフェースを通じてCLRを呼び出して、マネージコードを実行させるような処理を自作してやれば、エスカレーションポリシーを変更することができる。

結局のところ、C#やF#で作ったマネージexeファイルを直接実行した場合でも、Windows自体がCLRのロードやマネージコードのロードを行っているだけであるため、なんか設定を変えるかどうかすれば挙動を変えられるのではないかという気もするのだが、起動する過程に介入するような方法が見当たらなかった。

だから、自前で以下のようなC++のプログラムを作ってみた。

#include "stdafx.h"
#include <mscoree.h>
#include <metahost.h>
#include "ADTest3.h"

#pragma comment(lib, "mscoree.lib")

int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
                     _In_opt_ HINSTANCE hPrevInstance,
                     _In_ LPWSTR    lpCmdLine,
                     _In_ int       nCmdShow)
{
    HRESULT hr;
    DWORD dwResult;
    ICLRMetaHost *pMetaHost;
    ICLRRuntimeInfo *pRuntimeInfo;
    ICLRRuntimeHost *pRuntimeHost;
    ICLRControl *pCLRControl;
    ICLRPolicyManager *pCLRPolicyManager;

    hr = CLRCreateInstance( CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&pMetaHost );
    hr = pMetaHost->GetRuntime( L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&pRuntimeInfo );
    hr = pRuntimeInfo->GetInterface( CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&pRuntimeHost );
    hr = pRuntimeHost->GetCLRControl( &pCLRControl );
    hr = pCLRControl->GetCLRManager( IID_ICLRPolicyManager, (LPVOID*)&pCLRPolicyManager );
    hr = pCLRPolicyManager->SetUnhandledExceptionPolicy( eHostDeterminedPolicy );

    hr = pRuntimeHost->Start();
    hr = pRuntimeHost->ExecuteInDefaultAppDomain( L"ADTest.exe", L"prog", L"bar", NULL, &dwResult );

    hr = pRuntimeHost->Stop();

    hr = pCLRPolicyManager->Release();
    hr = pCLRControl->Release();
    hr = pRuntimeHost->Release();
    hr = pRuntimeInfo->Release();
    hr = pMetaHost->Release();

    return 0;
}

コメントはないが、適当に察してほしい。ICLRPolicyManager::SetUnhandledExceptionPolicyで、未処理例外が生じたときの挙動を変更できる。

上記のC++のプログラムから、下記のようなF#のプログラムを起動する。

module prog

open System
open System.Threading
open System.IO
open System.Reflection
open System.Runtime.InteropServices

// 未処理例外が生じたときに呼び出されるハンドラ
// (ここでは、例外を握りつぶすことはできない)
let handler ( e:UnhandledExceptionEventArgs ) =
    File.AppendAllText( @"d:\a.txt", "handler called " + ( Thread.GetDomain().FriendlyName ) + "\r\n" )
    let ex = e.ExceptionObject :?> Exception
    File.AppendAllText( @"d:\a.txt", ex.StackTrace + "\r\n" )
    AppDomain.Unload <| AppDomain.CurrentDomain

type public CFoo() =
    inherit MarshalByRefObject()
    member public this.goo () =
        AppDomain.CurrentDomain.UnhandledException.Add handler

        async {
            // 3秒後に別スレッドで例外を発生させる
            Thread.Sleep 3000
            raise <| System.Exception "bomb1"
        } |> Async.Start
        File.AppendAllText( @"d:\a.txt", "CFoo.goo " + ( Thread.GetDomain().FriendlyName ) + "\r\n" )



let bar ( str : string ) =
    File.AppendAllText( @"d:\a.txt", "bar start " + ( Thread.GetDomain().FriendlyName ) + "\r\n" )

    // 新規にアプリケーションドメインを作る
    let ad2 = AppDomain.CreateDomain( "domain2", null, null )
    let rfoo =
        ad2.CreateInstanceAndUnwrap(
            typeof<CFoo>.Assembly.FullName,
            typeof<CFoo>.FullName
        ) :?> CFoo

    // 呼び出す
    rfoo.goo ()
    File.AppendAllText( @"d:\a.txt", "rfoo.goo called " + ( Thread.GetDomain().FriendlyName ) + "\r\n" )

    // ここで待っている間に、domain2内の別のスレッドで例外が発生するはず
    Thread.Sleep 5000

    // 例外が発生したアプリケーションドメインがどうなったのか?
    try
        File.AppendAllText( @"d:\a.txt", ad2.FriendlyName + "\r\n" )
    with
    | _ as e ->
        File.AppendAllText( @"d:\a.txt", "domain2 was unloaded \r\n" )

    File.AppendAllText( @"d:\a.txt", "bar end " + ( Thread.GetDomain().FriendlyName ) + "\r\n" )
    0

// C++で作った自作のローダ(?)から起動した場合は、下記は実行されない
[<EntryPoint>]
let main argv =
    bar "a"


これを実行すると、以下のような結果が(d:\a.txtに)出力される。(※、一応書いておくと、C++の実行バイナリとF#の実行バイナリは同一フォルダに格納する必要がある)

bar start DefaultDomain
CFoo.goo domain2
rfoo.goo called DefaultDomain
handler called domain2
   場所 Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   場所 prog.goo@25-5.Invoke(Unit unitVar)
   場所 Microsoft.FSharp.Control.AsyncBuilderImpl.callA@839.Invoke(AsyncParams`1 args)
--- 直前に例外がスローされた場所からのスタック トレースの終わり ---
   場所 Microsoft.FSharp.Control.CancellationTokenOps.Start@1292-1.Invoke(ExceptionDispatchInfo edi)
   場所 .$Control.loop@425-51(Trampoline this, FSharpFunc`2 action)
   場所 Microsoft.FSharp.Control.Trampoline.ExecuteAction(FSharpFunc`2 firstAction)
   場所 Microsoft.FSharp.Control.TrampolineHolder.Protect(FSharpFunc`2 firstAction)
   場所 .$Control.-ctor@509-1.Invoke(Object state)
   場所 System.Threading.QueueUserWorkItemCallback.WaitCallback_Context(Object state)
   場所 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()
   場所 System.Threading.ThreadPoolWorkQueue.Dispatch()
   場所 System.Threading._ThreadPoolWaitCallback.PerformWaitCallback()
domain2 was unloaded 
bar end DefaultDomain

わかりにくいが、期待通り、未処理例外が生じたときに、追加で作った"domain2"のアプリケーションドメインだけがアンロードされ、デフォルトのアプリケーションドメインは処理が続行している。

もうちょっと正確に言うと、例外が未処理のままだった場合にはCLRは何もしないで無視するという設定で動作しているのだが、追加で作った"domain2"のアプリケーションドメインでUnhandledExceptionのイベントを登録して、そこでアプリケーションドメインのアンロードを明示的に行うようにしている。

後は、C++で作った処理をF#で実行するようにしてやればいい。実際、上記のF#のコードでは、C++のローダ(?)まがいの物から起動した場合にはmain関数は実行されないが、直接EXEを叩いた場合にはmainから実行される。なので、このmain関数でCLRを(もう1つ追加で)ロードして、明示的に自分自身のアセンブリを読み込んでやれば、同じことがF#だけで実現できるはずである。

でももう疲れた。だれかやってくれないかな。狙ったアプリケーションドメインだけを殺す術については、日を改めて書く。