The lack of non-capturing Task.Yield forces me to

2020-01-29 16:23发布

问题:

Apologies in advance if this question is opinion-based. The lack of Task.Yield version which wouldn't capture the execution context was already discussed here. Apparently, this feature was present in some form in early versions of Async CTP but was removed because it could easily be misused.

IMO, such feature could be as easily misused as Task.Run itself. Here's what I mean. Imagine there's an awaitable SwitchContext.Yield API which schedules the continuation on ThreadPool, so the execution will always continues on a thread different from the calling thread. I could have used it in the following code, which starts some CPU-bound work from a UI thread. I would consider it a convenient way of continuing the CPU-bound work on a pool thread:

class Worker
{
    static void Log(string format, params object[] args)
    {
        Debug.WriteLine("{0}: {1}", Thread.CurrentThread.ManagedThreadId, String.Format(format, args));
    }

    public async Task UIAction()
    {
        // UI Thread
        Log("UIAction");

        // start the CPU-bound work
        var cts = new CancellationTokenSource(5000);
        var workTask = DoWorkAsync(cts.Token); 

        // possibly await for some IO-bound work 
        await Task.Delay(1000);
        Log("after Task.Delay");

        // finally, get the result of the CPU-bound work
        int c = await workTask;
        Log("Result: {0}", c);
    }

    async Task<int> DoWorkAsync(CancellationToken ct)
    {
        // start on the UI thread
        Log("DoWorkAsync");

        // switch to a pool thread and yield back to the UI thread
        await SwitchContext.Yield();
        Log("after SwitchContext.Yield");
        // continue on a pool thread

        int c = 0;
        while (!ct.IsCancellationRequested)
        {
            // do some CPU-bound work on a pool thread: counting cycles :)
            c++;
            // and use async/await too
            await Task.Delay(50);
        }

        return c;
    }

}

Now, without SwitchContext.Yield, DoWorkAsync would look like below. It adds some extra level of complexity in form of async delegate and task nesting:

async Task<int> DoWorkAsync(CancellationToken ct)
{
    // start on the UI thread
    Log("DoWorkAsync");

    // Have to use async delegate
    // Task.Run uwraps the inner Task<int> task
    return await Task.Run(async () =>
    {
        // continue on a pool thread
        Log("after Task.Yield");

        int c = 0;
        while (!ct.IsCancellationRequested)
        {
            // do some CPU-bound work on a pool thread: counting cycles :)
            c++;
            // and use async/await too
            await Task.Delay(50);
        }

        return c;
    });
}

That said, implementing SwitchContext.Yield may actually be quite simple and (I dare to say) efficient:

public static class SwitchContext
{
    public static Awaiter Yield() { return new Awaiter(); }

    public struct Awaiter : System.Runtime.CompilerServices.INotifyCompletion
    {
        public Awaiter GetAwaiter() { return this; }

        public bool IsCompleted { get { return false; } }

        public void OnCompleted(Action continuation)
        {
            ThreadPool.QueueUserWorkItem((state) => ((Action)state)(), continuation);
        }

        public void GetResult() { }
    }
}

So, my question is, why should I prefer the second version of DoWorkAsync over the first one, and why would using SwitchContext.Yield be considered a bad practice?

回答1:

You don't have to put the Task.Run in DoWorkAsync. Consider this option:

public async Task UIAction()
{
    // UI Thread
    Log("UIAction");

    // start the CPU-bound work
    var cts = new CancellationTokenSource(5000);
    var workTask = Task.Run(() => DoWorkAsync(cts.Token)); 

    // possibly await for some IO-bound work 
    await Task.Delay(1000);
    Log("after Task.Delay");

    // finally, get the result of the CPU-bound work
    int c = await workTask;
    Log("Result: {0}", c);
}

This results in code with much clearer intent. DoWorkAsync is a naturally synchronous method, so it has a synchronous signature. DoWorkAsync neither knows nor cares about the UI. The UIAction, which does care about the UI thread, pushes off the work onto a background thread using Task.Run.

As a general rule, try to "push" any Task.Run calls up out of your library methods as much as possible.