【C#】ThreadPool.QueueUserWorkItemのpreferLocal

小ネタです。ThreadPool.QueueUserWorkItem()のオーバーロードの話。

事の発端

お仕事でC#コードの最適化をしていた時、こんな感じのコードを見つけました。

void Foo(string foo)
{
    // なんか色々なチェックとか

    ThreadPool.QueueUserWorkItem(_ => FooInternal(foo));
}

void FooInternal(string foo)
{
    // 中身は省略
}

ここで、_ => FooInternal(foo)の部分がクロージャになっていることに気がつきました。これはthisfooをキャプチャするため、GCアロケーションを発生させます。もしstateを一緒に渡せるオーバーロードがあるならクロージャを回避できるため、そちらを利用すべきでしょう。

というわけでドキュメントを確認したところ、ThreadPool.QueueUserWorkItem()には以下の3つのオーバーロードがありました、

public static bool QueueUserWorkItem(System.Threading.WaitCallback callBack);
public static bool QueueUserWorkItem(System.Threading.WaitCallback callBack, object? state);
public static bool QueueUserWorkItem<TState>(Action<TState> callBack, TState state, bool preferLocal);

WaitCallbackobject?stateを受け取るため、thisfooをタプルとして(this, foo)のように渡すとboxingが発生してしまいます。というわけでジェネリクスで型安全に渡せる3つ目のオーバーロードを使いたいところですが、ここに上2つにはないpreferLocalというパラメータがあります。これは何でしょうか...?

preferLocal

preferLocalに関して、公式ドキュメントには以下の説明があります。

true to prefer queueing the work item in a queue close to the current thread; false to prefer queueing the work item to the thread pool's shared queue.

翻訳: 現在のスレッドに近いキューにwork itemをキューイングすることを優先する場合は true、スレッドプールの共有キューにキューイングすることを優先する場合は false(を指定する)

なるほど、何もわからないのでAPI提案時のissueを読みましょう。

Add QueueUserWorkItem for local threadpool queues · Issue #18881 · dotnet/runtimeThreadPool.QueueUserWorkItem always queues to the global queue; however it would be good to have the option to be able to queue to the current threadpool thread's local queue when a threadpool thre...GitHub

つまり、デフォルトのQueueUserWorkItemはグローバルなキューにコールバックを積みますが、実行中のスレッドに追加でローカルキューに積むためのオプションが欲しい、というモチベーションで追加されたパラメータのようです。

なお、このパラメータのデフォルト値をどちらにするかに関しては議論があったようですが、結局既存のオーバーロードの動作であるfalseをデフォルトにする方向で落ち着いたようです。実際にdotnet/runtimeのソースコードを見てみると、デフォルトでpreferLocal: false相当のフラグが設定されています。(内部では意味合いが逆なforceGlobalになっていますが)

public static bool QueueUserWorkItem(WaitCallback callBack) =>
    QueueUserWorkItem(callBack, null);

public static bool QueueUserWorkItem(WaitCallback callBack, object? state)
{
    if (callBack == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.callBack);
    }

    ExecutionContext? context = ExecutionContext.Capture();

    object tpcallBack = (context == null || context.IsDefault) ?
        new QueueUserWorkItemCallbackDefaultContext(callBack, state) :
        (object)new QueueUserWorkItemCallback(callBack, state, context);

    s_workQueue.Enqueue(tpcallBack, forceGlobal: true);

    return true;
}

public static bool QueueUserWorkItem<TState>(Action<TState> callBack, TState state, bool preferLocal)
{
    if (callBack == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.callBack);
    }

    ExecutionContext? context = ExecutionContext.Capture();

    object tpcallBack = (context == null || context.IsDefault) ?
        new QueueUserWorkItemCallbackDefaultContext<TState>(callBack, state) :
        (object)new QueueUserWorkItemCallback<TState>(callBack, state, context);

    s_workQueue.Enqueue(tpcallBack, forceGlobal: !preferLocal);

    return true;
}

また、なぜpublic static void QueueUserWorkItem(WaitCallback callBack, bool preferLocal);に相当するオーバーロードが存在しないかについては、以下の呼び出しで解決されるオーバーロードが変わってしまうという都合のようです。

// 従来はstateとしてboolを渡していたものが、preferLocalとして解決されてしまう
ThreadPool.QueueUserWorkItem(b => Use((bool)b), true);

結論

というわけで、デフォルトの動作を維持したまま新しいオーバーロードに変える場合は、preferLocalfalseにすれば良さそうです。

ThreadPool.QueueUserWorkItem(
    static state => state.@this.FooInternal(state.foo),
    (@this: this, foo),
    false);