Skip to content

Optimized way/API to efficiently "yield to thread pool or no-op if already on it" #130434

Description

@Sergio0694

Follow-up from #130170 (comment)

Opening this issue to discuss the "yield to thread pool or no-op if already on it" semantics, specifically:

  • What's the most efficient way to do it (taking runtime async into account too, cc. @jakobbotsch)
  • Whether it'd make sense to have a dedicated API for this in the BCL (if so, happy to open a separate proposal)

We do this extensively in the Microsoft Store, and especially for GUI apps it's quite useful. Very often people end up making the UI sluggish because they accidentally start UI-driven synchronous CPU-bound work on the UI thread. Using an API like this makes it easy to just offload to a thread pool thread instead (eventually switching back to the UI thread if needed through some other dispatcher API, out of scope here).

Here's the simplest possible implementation for this that I have:

public static class TaskSchedulerExtensions
{
    extension(TaskScheduler)
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public static TaskSchedulerYieldAwaitable Yield()
        {
            return default;
        }
    }

    [EditorBrowsable(EditorBrowsableState.Never)]
    public readonly struct TaskSchedulerYieldAwaitable
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public Awaiter GetAwaiter()
        {
            return default;
        }

        [EditorBrowsable(EditorBrowsableState.Never)]
        public readonly struct Awaiter : ICriticalNotifyCompletion
        {
            private static readonly WaitCallback OnCompletedCallback = static state => Unsafe.As<Action>(state!)();

            public bool IsCompleted
            {
                [MethodImpl(MethodImplOptions.AggressiveInlining)]
                get => Thread.CurrentThread.IsThreadPoolThread;
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public void GetResult()
            {
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public void OnCompleted(Action continuation)
            {
                ThreadPool.QueueUserWorkItem(OnCompletedCallback, continuation);
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public void UnsafeOnCompleted(Action continuation)
            {
                ThreadPool.UnsafeQueueUserWorkItem(OnCompletedCallback, continuation);
            }
        }
    }
}

So you can just do:

await TaskScheduler.Yield();

And you're on the thread pool, or if you already were, then this call is a no-op.

Questions:

  • Is this the best and most efficient way to do this, or could this be optimized
  • Will this be de-optimized with runtime async or can it still be efficient?
  • Should we have a built-in API in the BCL for this?

cc. @VSadov

Note

Not using await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); because that always yields.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions