在 C# 中创建异步资源观察器(服务代理队列资源)

Creating an async resource watcher in c# (service broker queue resource)

部分作为探索异步的练习,我想尝试创建一个 ServiceBrokerWatcher class。这个想法与 FileSystemWatcher 非常相似 - 观察资源并在发生某些事情时引发事件。我希望用异步而不是实际创建一个线程来做到这一点,因为野兽的本性意味着大部分时间它只是在等待 SQL waitfor (receive ...) 语句。这似乎是异步的理想用法。

我编写了 "works" 代码,因为当我通过代理发送消息时,class 会注意到它并触发相应的事件。我认为这非常整洁。

但我怀疑我对正在发生的事情的理解有些根本性的错误,因为当我试图停止观察者时,它的行为并不像我预期的那样。

首先简单介绍一下组件,然后是实际代码:

我有一个存储过程,它在收到消息时向客户端发出 waitfor (receive...) 和 returns 结果集。

有一个 Dictionary<string, EventHandler> 将消息类型名称(在结果集中)映射到适当的事件处理程序。为简单起见,我在示例中只有一种消息类型。

观察者 class 有一个循环 "forever" 的异步方法(直到请求取消),其中包含过程的执行和事件的引发。

所以,问题是什么?好吧,我尝试在一个简单的 winforms 应用程序中托管我的 class,当我按下按钮调用 StopListening() 方法(见下文)时,执行并没有像我想象的那样立即取消. listener?.Wait(10000) 行实际上会等待 10 秒(或者我设置的超时时间)。如果我观察 SQL 探查器发生了什么,我可以看到正在发送注意事件 "straight away",但该函数仍然没有退出。

我已经为以“!”开头的代码添加了注释。我怀疑我误解了什么。

所以,主要问题:为什么我的 ListenAsync 方法 "honoring" 不是我的取消请求?

另外,我认为这个程序(大部分时间)只消耗一个线程是对的吗?我做过什么危险的事吗?

代码如下,我尽量减少它:

// class members //////////////////////
private readonly SqlConnection sqlConnection;
private CancellationTokenSource cts;
private readonly CancellationToken ct;
private Task listener;
private readonly Dictionary<string, EventHandler> map;

public void StartListening()
{
    if (listener == null)
    {
        cts = new CancellationTokenSource();
        ct = cts.Token;
        // !I suspect assigning the result of the method to a Task is wrong somehow...
        listener = ListenAsync(ct); 
    }
}

public void StopListening()
{
    try
    {
        cts.Cancel(); 
        listener?.Wait(10000); // !waits the whole 10 seconds for some reason
    } catch (Exception) { 
        // trap the exception sql will raise when execution is cancelled
    } finally
    {
        listener = null;
    }
}

private async Task ListenAsync(CancellationToken ct)
{
    using (SqlCommand cmd = new SqlCommand("events.dequeue_target", sqlConnection))
    using (CancellationTokenRegistration ctr = ct.Register(cmd.Cancel)) // !necessary?
    {
        cmd.CommandTimeout = 0;
        while (!ct.IsCancellationRequested)
        {
            var events = new List<string>();    
            using (var rdr = await cmd.ExecuteReaderAsync(ct))
            {
                while (rdr.Read())
                {
                    events.Add(rdr.GetString(rdr.GetOrdinal("message_type_name")));
                }
            }
            foreach (var handler in events.Join(map, e => e, m => m.Key, (e, m) => m.Value))
            {
                if (handler != null && !ct.IsCancellationRequested)
                {
                    handler(this, null);
                }
            }
        }
    }
}

您没有展示如何将其绑定到 WinForms 应用程序,但如果您使用的是常规 void button1click 方法,则可能 运行 进入 this issue

因此,您的代码在控制台应用程序中 运行 正常(我尝试时它确实如此),但在通过 UI 线程调用时会死锁。

我建议更改您的控制器 class 以公开 async 启动和停止方法,并通过例如:

调用它们
    private async void btStart_Click(object sender, EventArgs e)
    {
        await controller.StartListeningAsync();
    }

    private async void btStop_Click(object sender, EventArgs e)
    {
        await controller.StopListeningAsync();
    }

彼得答对了。我对什么是僵局感到困惑了几分钟,但随后我的额头拍了一下。它是ExecuteReaderAsync被取消后ListenAsync的延续,因为它只是一个任务,而不是它自己的线程。毕竟,这才是重点!

然后我想知道...好吧,如果我告诉 ListenAsync() 的异步部分它不需要 UI 线程呢?我会用.ConfigureAwait(false)ExecuteReaderAsync(ct)!啊哈!现在 class 方法不必再异步了,因为在 StopListening() 中我可以 listener.Wait(10000),等待将在不同的线程内部继续任务,而消费者是 none越聪明。小伙子,真聪明

但是不,我不能那样做。至少在 webforms 应用程序中没有。如果我这样做,则文本框不会更新。其原因似乎很清楚:ListenAsync 的内部调用一个事件处理程序,而该事件处理程序是一个想要更新文本框中的文本的函数——这无疑必须在 UI 线程上发生。所以它不会死锁,但它也无法更新 UI。如果我在要更新 UI 的处理程序中设置断点,则会命中代码行,但无法更改 UI。

所以最后似乎在这种情况下唯一的解决办法确实是"go async all the way down"。或者在这种情况下,up!

我希望我不必那样做。事实上,我的 Watcher 的内部正在使用异步方法而不是仅仅生成一个线程,在我看来,这是调用者不必关心的 "implementation detail"。但是 FileSystemWatcher 有完全相同的问题(如果您想根据观察者事件更新 GUI,则需要 control.Invoke),所以这还不错。如果我是必须在使用异步或使用 Invoke 之间做出选择的消费者,我会选择异步!