在我知道线程池将饱和的环境中,我如何才能 运行 只有异步 API 而不会死锁的代码?

How can I run code that only has async APIs without deadlocking in an environment where I know the Thread Pool will be saturated?

我正在尝试编写用于从 BizTalk 调用的辅助方法。 BizTalk 不理解任务或 async/await,因此辅助方法必须 return 一个普通的 .NET return 类型,而不是提升为 Task<T>.

辅助方法使用 IdentityModel.Clients.ActiveDirectory library and subsequently HttpClient 调用基于 HTTP 的 API,然后缓存此结果。这些 类 只有一个异步 API 即所有完成工作 return Task<T> 并以 -Async.

结尾的方法

BizTalk 管理其线程池的方式本质上保证了线程池在消息负载高时饱和(默认为 25 个工作线程);例如,一次删除了大量文件 - 这是正常使用情况下的可行方案,实际上不是问题。我通过调试观察到这一点。

当帮助程序代码进行 API 调用时,这是非常昂贵的,因为它 return 有很多数据,我希望一次只进行一个调用。如果所有实现都是同步的,我会在缓存刷新周围使用 lock 语句,因为为了确保同步,处理消息的延迟是可以接受的。锁定已被证明会导致死锁,这对我来说很有意义,因为应用程序架构本质上保证没有线程可用于完成异步方法。这是通常给出的不在异步代码上下文中锁定的建议的更极端情况,因为它不仅可能而且肯定会死锁。

我已经尝试使用 SemaphoreSlim.WaitAsync 来执行与锁定等效的操作,但是是以一种非阻塞的方式,即仍然阻止多个线程进入块,但是通过让它们屈服而不是阻塞。这并不能解决问题,因为顶级辅助方法仍必须阻塞以等待缓存更新完成。我的假设是,当这个等待产生线程的那一刻,它就会被吞噬处理一条新消息——然后阻塞它,阻止进入信号量的线程继续。

以下伪代码(即请不要尝试纠正与问题无关的编码风格问题。它也不能是 MCVE,除非你有安装BizTalk handy)说明了我要解决的问题:

public class Helper
{
    public static string GetCachedValue(string key)
    {
        // need to wait until the cache is updated, but blocking here makes all worker threads unavailable
        CacheData().Wait();

        return _cache.GetValue(key);
    }

    private static DateTime _lastRead;

    private static readonly Dictionary<string, string> Cache = new Dictionary<string, string>();

    private static readonly SemaphoreSlim throttle = new SemaphoreSlim(1);

    private static async Task CacheData()
    {
        try{
            // stop more than one thread from entering this block at a time
            await throttle.WaitAsync();

            if(_lastRead < DateTime.Now.AddMinutes(-10))
            {
                var context = new AuthenticationContext(/* uninteresting parameters*/); 
                var token = await context.GetTokenAsync();

                // can't use HttpClientFactory here because the .NET 4.5.2 implementation doesn't supply any way of setting the web proxy
                var client = new HttpClient();

                var data =  await client.GetAsync("api/Data");

                // unimportant cache update code

                _lastRead = DateTime.Now;

            }
        }
        finally 
        {
            throttle.Release();
        }
    }
}

首先我得说,看起来你只是通过尝试在助手中做所有这些奇特的事情而制造了很多不必要的问题 class。因为 A) 助手 class 中的异步操作确实没有实际案例,因为它不会改变消息处理时间,B) 你应该 而不是 在助手 class.

中调用 http 端点

因此,正确的解决方案是使用 Ordered Delivery Send Port 调用 http 端点。

我之所以说 Ordered Delivery 是因为:"and I only want one call to be in progress at a time",但是...您 想要什么并不重要 ,您应该仅在以下情况下进行串行调用服务需要它或有容量问题。

如果你想在内部缓存结果,这里有很多这样的例子(所有 .Net 规则都适用):Implement Caching for your BizTalk applications using "static" classes and methods

你的问题实际上是 "how do I do sync-over-async when the thread pool is saturated",唯一真正的答案是 "you can't"。 sync-over-async 的问题在于它阻塞了一个线程,然后可能需要 另一个 线程来解除阻塞那个线程。

您可以尝试的一件事是在您的线程上(临时)安装您自己的上下文。我的 AsyncEx library 中有一个 AsyncContext 类型可以执行此操作。因此,在您的 BizTalk 入口点,您可以使用它而不是直接阻塞:

// Old code:
//   var result = MyLogicAsync().GetAwaiter().GetResult();
var result = AsyncContext.Run(() => MyLogicAsync());

默认情况下,这将允许 await 延续到 运行。它的行为类似于 UI 消息循环(只是没有 UI)。

不幸的是,您不能保证这将始终有效,因为延续仅捕获该上下文默认。对于 general-purpose 库,如 ActiveDirectoryHttpClient,捕获上下文被认为是不好的做法;大多数库代码总是使用 ConfigureAwait(false).

来使用线程池

因此,在 sync-over-async 代码中避免死锁的唯一方法是确保线程池不饱和。如果有某种方法可以将 BizTalk 限制为某个值并使线程池大于该值,那么就可以了。

更理想的解决方案是 "sync all the way"。您想要将 HttpClient 替换为 WebClient,但根据您的描述,听起来 ActiveDirectory 不支持同步 APIs。

您可能会采用混合解决方案:使用 WebClient 使 API 调用同步,并将所有 ActiveDirectory 调用包装在 AsyncContext.Run 中。我认为这 可能 有效,因为 ASP.NET 核心团队已经删除了他们代码中的 most/all ConfigureAwait(false) 调用。因此 HTTP API 将是同步的,而 ActiveDirectory 将是异步的,但其延续 运行 在等待它的线程中(不需要线程池线程)。

但即使你成功了,也不能保证将来会成功。缺少 ConfigureAwait(false) 可以被认为是 "bug",如果他们通过添加 ConfigureAwait(false) 来 "fix" 错误,那么您的代码将再次陷入死锁。

唯一真正有保证的解决方案是强制异步 API 同步,方法是编写您自己的代理 WebAPI 来包装 ActiveDirectory 调用。然后,您的 BizTalk 代码将使用 WebClient 与那个 API(和另一个 API)对话,并且所有 BizTalk 代码将在此时同步。

如果线程数是主要问题,您可以通过将 address 和 maxconnection 设置添加到 BTSNTSvc64.exe.config 或 BTSNTSvc.exe.config 文件的 connectionMangement 部分来减少或增加线程数,如果它是 32 位的。或者正如 Johns 在他的回答中所说,如果你想要单线程,请在发送端口上勾选有序交付。

    <system.net>
        <connectionManagement>
            <add address="*" maxconnection="25"   />

            <add address="https://LIMITEDSERVER.com*" maxconnection="5"  />
            <add address="https://GRUNTYSERVER.com*" maxconnection="50"  />
       </connectionManagement>
    </system.net>

如果你想要异步,那么你需要在 BizTalk 中有一个与你的目标系统通信的单向发送端口,以及一个 BizTalk 中的接收位置,另一个系统可以将响应发送到。