如何调用异步方法而不自动降级为同步模式?

3
在开发一些异步代码时,我和同事们遇到了一个奇怪的问题。一个组件以连续的速率触发,但我们希望其中调用的一个依赖项永远不会同时执行多个任务。
我们决定只是缓存任务,没有问题。我们在缓存任务的赋值周围加了一个锁,并让单例任务在完成后删除自身。
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()调用中的某些操作会产生yield。但是,如果某些操作看起来像这样:

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

它失败了!尽管是微妙的。清除onlyOneTask的那一行在任务分配之前执行!之后,onlyOneTask中始终存在一个任务实例,并且它永远不会重新执行工作。
你可能认为这应该会死锁,但是线程可以重新获取锁定,并且由于异步代码可以将自身降级为完全同步代码,因此第二个锁在同一线程上被获取。没有死锁。
如果SomeWork在执行过程中某个时候产生yield,则似乎表现符合我们的期望,但它确实引发了一些关于其精确行为的问题。我不确定异步代码在以同步方式和异步方式执行时线程和锁定如何交互的确切细节。
我想指出的另一件事是,通过使用Task.Run来强制在其自己的任务上运行工作似乎可以修复此问题。但是有一个主要问题:这段代码旨在在.NET服务中运行,在那里使用Task.Run是不好的形式

有一种看似解决这个问题的方法,即强制Task始终表现得像是有异步实现一样。我想这是不可能的,但还是问一下吧。

那么,问题来了:

  1. 即使SomeWork()进行了yield,这种情况是否会发生死锁?
  2. 如果TaskScheduler在同一线程上继续执行,在SomeWork()足够快的情况下,异步案例是否会像同步案例一样(在设置onlyOneTask之前清除)?
  3. 有没有一种方法可以强制现有任务始终表现为它是异步的,而不会影响线程池?
  4. 是否有更好的替代方案?

值得一提的是,我们发现如果只是检查缓存任务的状态并确定何时再次启动SomeWork(),我们的问题就消失了,所以这主要是学术性质。


1
有一些方法可以强制让一个任务(Task)始终表现得像它有一个异步实现。你尝试过在await SomeWork();之前加上await Task.Yield()吗? - StriplingWarrior
到目前为止我还没有,这似乎应该可以工作! - Bill Nadeau
2个回答

4
问题在于当await到达时,您的函数将返回不完整的任务并退出lock,从而允许更多的内容运行该任务。通常情况下,您可以通过在锁内等待来解决这个问题,但以下代码将无法编译。
public async Task TryToBeginSomeProcess()
{
    lock (someLock)
    {
        if (onlyOneTask == null)
        {
            onlyOneTask = DoSomethingButOnlyOneAtATime();
        }
        //does not compile
        await onlyOneTask;
    }
}

我们解决这个问题的方法是,从lock切换到SemaphoreSlim,使用一个InitialCount为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答案的异步版本,我还创建了一个扩展方法,使添加Task.Yield()更容易。

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);
    }
}

这个功能与上面的代码示例不完全相同(如果我理解正确的话)。在问题的示例中,如果我调用TryToBeginSomeProcess 100次,但是所有的调用都在DoSomethingButOnlyOneAtATime完成之前被调用,那么我只会执行一次DoSomething。在你的例子中,我将始终调用DoSomething 100次。话虽如此,我并不知道SemaphoreSlim的这种用法,它似乎能够解决这些问题。 - Bill Nadeau
@BillNadeau 你说得没错,我完全没有重现出这个行为。给我一分钟,也许我能够调整它。 - Scott Chamberlain
@BillNadeau请确保您获取最新版本,我之前发布的某个版本可能存在一些潜在的竞态条件问题。 - Scott Chamberlain

3

Scott的回答可能是你要做的最好方法。然而,作为如何调用异步方法并确保它们不会同步返回的一般性答案,有一个叫做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;
    }
}

这是一个非常实用的小功能,我完全忽略了它。也许有些特定,但仍然很有用! - Bill Nadeau

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接