如何在不自动降级到同步模式的情况下调用异步方法?

时间:2016-06-16 22:38:03

标签: c# .net task

在开发一些异步代码时,我和我的同事遇到了一部分代码的奇怪问题。组件是以连续的速率触发的,但是我们希望它所调用的一个依赖项永远不会一次执行多个任务。

我们决定只缓存任务,没问题。我们围绕缓存任务的分配进行了锁定,并且单例任务在完成后自行删除。

Task onlyOneTask;

public Task TryToBeginSomeProcess()
{
    lock (someLock)
    {
        if (onlyOneTask == null)
        {
            onlyOneTask = DoSomethingButOnlyOneAtATime();
        }

        return onlyOneTask;
    }
}

public async Task DoSomethingButOnlyOneAtATime()
{
    // do some work

    await SomeWork();

    lock (someLock)
    {
        onlyOneTask = null;
    }
}

所以,这实际上似乎有效......假设SomeWork()调用中的某些内容确实产生了。但是,如果某些工作看起来像这样:

public Task SomeWork()
{
    return Task.FromResult(0);
}

失败了!但是,这完全是这样的。清除onlyOneTask的行在分配任务之前执行!之后,onlyOneTask中总会有一个任务实例,它永远不会重新执行工作。

您可能认为这应该是死锁,但是threads may reacquire locks,并且由于异步代码可能会将自身降级为完全同步代码,因此在同一个线程上获取第二个锁。没有死锁。

如果SomeWork在其执行的某个时刻产生,那么它似乎表现得像我们预期的那样,但它确实引起了一些关于其确切行为的问题。我不确定异步代码同步执行与异步执行时线程和锁如何交互的确切细节。

我想指出的另一件事是,这似乎可以通过使用Task.Run来强制工作在自己的任务上运行来解决。这有一个主要问题:此代码旨在在.NET服务中运行,it's bad form to use Task.Run there

看似解决这个问题的一种方法是,是否有某种方法可以强制任务始终像具有异步实现一样。我想它并不存在,但我不妨问。

所以,问的问题是:

  1. 即使SomeWork()产生了这个场景也会死锁吗?
  2. 如果TaskScheduler在同一个线程上继续并且SomeWork()足够快,异步情况是否可以像同步情况一样(在设置之前清除onlyOneTask)?
  3. 有没有办法强制现有的Task总是表现得像是异步而不会损害线程池?
  4. 有更好的选择吗?
  5. 我们发现,如果我们只是检查了缓存任务的状态以确定何时适合启动SomeWork(),我们的问题就会消失,因此我们发现了它的价值,因此这主要是学术性的。

2 个答案:

答案 0 :(得分:4)

问题是当await命中你的函数返回不完整的任务并退出lock时,允许更多的事情来运行任务。您通常会通过等待锁内部来解决它,但以下代码将无法编译

public async Task TryToBeginSomeProcess()
{
    lock (someLock)
    {
        if (onlyOneTask == null)
        {
            onlyOneTask = DoSomethingButOnlyOneAtATime();
        }
        //does not compile
        await onlyOneTask;
    }
}

我们修复此问题的方法是将lock切换为SemaphoreSlimInitialCount为1并使用其WaitAsync()方法。

private SemaphoreSlim someLock = new SemaphoreSlim(1);

public async Task TryToBeginSomeProcess()
{
    try
    {
        await someLock.WaitAsync();

        await DoSomethingButOnlyOneAtATime().ConfigureAwait(false);
    }
    finally
    {
        someLock.Release();
    }
}

public async Task DoSomethingButOnlyOneAtATime()
{
    // do some work

    await SomeWork();

    // do some more work
}

<强>更新 前面的代码没有复制旧的行为,原来如果你调用函数20次它将从20次调用中返回单个实例并且只运行一次代码。我的代码将运行代码20次,但一次只运行一次。

此解决方案实际上只是StriplingWarrior's answer的异步版本,我还创建了一种扩展方法,以便在Task.Yeild()中轻松添加。

SemaphoreSlim someLock = new SemaphoreSlim(1);
private Task onlyOneTask;

public async Task TryToBeginSomeProcess()
{
    Task localCopy;
    try
    {
        await someLock.WaitAsync();

        if (onlyOneTask == null)
        {
            onlyOneTask = DoSomethingButOnlyOneAtATime().YeildImmedeatly();
        }
        localCopy = onlyOneTask;
    }
    finally
    {
        someLock.Release();
    }

    await localCopy.ConfigureAwait(false);
}

public async Task DoSomethingButOnlyOneAtATime()
{
    //Do some synchronous work.

    await SomeWork().ConfigureAwait(false);

    try
    {
        await someLock.WaitAsync().ConfigureAwait(false);
        onlyOneTask = null;
    }
    finally
    {
        someLock.Release();
    }
}


//elsewhere
public static class ExtensionMethods
{
    public static async Task YeildImmedeatly(this Task @this)
    {
        await Task.Yield();
        await @this.ConfigureAwait(false);
    }
    public static async Task<T> YeildImmedeatly<T>(this Task<T> @this)
    {
        await Task.Yield();
        return await @this.ConfigureAwait(false);
    }
}

答案 1 :(得分:3)

斯科特的回答可能是做你想做的事情的最佳方式。但是,作为如何调用async方法并确保它们不会同步返回的问题的一般答案,有一个方便的小方法Task.Yield()

public async Task DoSomethingButOnlyOneAtATime()
{
    // Make sure that the rest of this method runs after the
    // current synchronous context has been resolved.
    await Task.Yield();
    // do some work
    await SomeWork();

    lock (someLock)
    {
        onlyOneTask = null;
    }
}