如何在 C# 中触发事件之前阻止代码流

How to block code flow until an event is fired in C#

这里有一个 Grid 和一个 Button。当用户单击该按钮时,将执行 Utility class 中的一个方法,该方法强制应用程序接收对 Grid 的单击。代码流必须在此处停止,直到用户单击 Grid.

才会继续

我之前也遇到过类似的问题:

在那个问题中,我使用 async/await 得到了一个有效的答案,但是因为我打算将它用作 API 的一部分,所以我不想使用 async/await, 因为消费者将不得不用我不想要的异步标记他们的方法。

如何编写 Utility.PickPoint(Grid grid) 方法来实现这个目标?

我看到这个可能有帮助,但老实说我没有完全理解它在这里应用:

Blocking until an event completes

将其视为控制台应用程序中的 Console.ReadKey() 方法。当我们调用这个方法时,代码流停止,直到我们输入一些值。在我们输入某些内容之前,调试器不会继续。我想要 PickPoint() 方法的确切行为。代码流将停止,直到用户单击网格。

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp1"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="3*"/>
            <RowDefinition Height="1*"/>
        </Grid.RowDefinitions>

        <Grid x:Name="View" Background="Green"/>
        <Button Grid.Row="1" Content="Pick" Click="ButtonBase_OnClick"/>
    </Grid>
</Window>

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        // do not continue the code flow until the user has clicked on the grid. 
        // so when we debug, the code flow will literally stop here.
        var point = Utility.PickPoint(View);


        MessageBox.Show(point.ToString());
    }
}

public static class Utility
{
    public static Point PickPoint(Grid grid)
    {

    }
}

我尝试了一些东西,但没有async/await我就做不到。因为如果我们不使用它,它会导致 DeadLock 或 UI 被阻止,然后我们可以接受 Grid_Click 输入。

private async void ToolBtn_OnClick(object sender, RoutedEventArgs e)
{
    var senderBtn = sender as Button;
    senderBtn.IsEnabled = false;

    var response = await Utility.PickPoint(myGrid);
    MessageBox.Show(response.ToString());
    senderBtn.IsEnabled = true;
}  

public static class Utility
{
    private static TaskCompletionSource<bool> tcs;
    private static Point _point = new Point();

    public static async Task<Point> PickPoint(Grid grid)
    {
        tcs = new TaskCompletionSource<bool>();
        _point = new Point();

        grid.MouseLeftButtonUp += GridOnMouseLeftButtonUp;


        await tcs.Task;

        grid.MouseLeftButtonUp -= GridOnMouseLeftButtonUp;
        return _point;
    }


    private static void GridOnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {

        // do something here ....
        _point = new Point { X = 23, Y = 34 };
        // do something here ....

        tcs.SetResult(true); // as soon its set it will go back

    }
}

您可以使用 SemaphoreSlim:

异步阻止
public partial class MainWindow : Window, IDisposable
{
    private readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(0, 1);

    public MainWindow()
    {
        InitializeComponent();
    }

    private async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        var point = Utility.PickPoint(View);

        // do not continue the code flow until the user has clicked on the grid. 
        // so when we debug, the code flow will literally stop here.
        await _semaphoreSlim.WaitAsync();

        MessageBox.Show(point.ToString());
    }

    private void View_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        //click on grid detected....
        _semaphoreSlim.Release();
    }

    protected override void OnClosed(EventArgs e)
    {
        base.OnClosed(e);
        Dispose();
    }

    public void Dispose() => _semaphoreSlim.Dispose();
}

您不能,也不想同步阻塞调度程序线程,因为那样的话它将永远无法处理 Grid 上的点击,即它不能同时被阻塞和处理事件同时

我个人认为这被每个人都过于复杂了,但也许我不完全理解为什么需要以某种方式完成这件事的原因,但似乎可以在这里使用一个简单的 bool 检查。

首先,通过设置 BackgroundIsHitTestVisible 属性使您的网格可命中测试,否则它甚至不会捕获鼠标点击。

<grid MouseLeftButtonUp="Grid_MouseLeftButtonUp" IsHitTestVisible="True" Background="Transparent">

接下来创建一个 bool 值,可以存储 "GridClick" 事件是否应该发生。单击网格时,检查该值,如果等待单击,则从网格单击事件执行。

示例:

bool awaitingClick = false;


private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
   awaitingClick=true;
}

private void Grid_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{     
     //Stop here if the program shouldn't do anything when grid is clicked
     if (!awaitingClick) { return; } 

     //Run event
     var point = Utility.PickPoint(View);
     MessageBox.Show(point.ToString());

     awaitingClick=false;//Reset
}

"How to block code flow until an event is fired?"

你的做法是错误的。事件驱动并不意味着阻塞和等待事件。你从不等待,至少你总是努力避免它。等待是在浪费资源、阻塞线程并可能引入死锁或僵尸线程的风险(以防释放信号从未发出)。
应该清楚的是,阻塞线程以 wait 事件是一种反模式,因为它与事件的想法相矛盾。

您通常有两个(现代)选项:实施异步 API 或事件驱动 API。由于您不想实现 API 异步,因此您只剩下事件驱动的 API。

事件驱动的关键API是,不是强制调用者同步等待结果或轮询结果,而是让调用者继续并向他发送通知,一旦结果准备就绪或操作已完成。同时,调用者可以继续执行其他操作。

从线程的角度来看问题时,事件驱动 API 允许执行按钮事件处理程序的调用线程,例如 UI 线程,可以自由地继续处理其他 UI 相关操作,例如渲染 UI 元素或处理用户输入(例如鼠标移动和按键)。事件驱动 API 与异步 API 具有相同的效果或目标,尽管它远没有那么方便。

由于您没有提供足够的详细信息来说明您真正想做什么、Utility.PickPoint() 实际在做什么以及任务的结果是什么或用户必须单击“网格”的原因,我无法为您提供更好的解决方案。我只能提供一个关于如何实现您的要求的一般模式。

您的流程或目标显然至少分为两个步骤,使其成为一个操作序列:

  1. 执行操作1,当用户点击按钮时
  2. 执行操作2(continue/complete操作1),当用户点击Grid

至少有两个约束:

  1. 可选:序列必须在允许 API 客户端重复之前完成。一旦操作 2 运行 完成,一个序列就完成了。
  2. 操作 1 总是在操作 2 之前执行。操作 1 启动序列。
  3. 操作 1 必须在 API 客户端被允许执行操作 2 之前完成

这需要 API 的客户端至少有两个通知(事件)以允许非阻塞交互:

  1. 操作 1 已完成(或需要互动)
  2. 操作 2(或目标)完成

您应该让您的 API 通过公开两个 public 方法和两个 public 事件来实现此行为和约束。

由于此实现只允许对 API 的单个(非并发)调用,因此还建议公开 IsBusy 属性 以指示 运行 序列.这允许在开始新序列之前轮询当前状态,但建议等待完成的事件执行后续调用。

Implement/refactor实用程序API

Utility.cs

class Utility
{
  public event EventHandler InitializePickPointCompleted;
  public event EventHandler<PickPointCompletedEventArgs> PickPointCompleted;
  public bool IsBusy { get; set; }
  private bool IsPickPointInitialized { get; set; }

  // The prefix 'Begin' signals the caller or client of the API, 
  // that he also has to end the sequence explicitly
  public void BeginPickPoint(param)
  {
    // Implement constraint 1
    if (this.IsBusy)
    {
      // Alternatively just return or use Try-do pattern
      throw new InvalidOperationException("BeginPickPoint is already executing. Call EndPickPoint before starting another sequence.");
    }

    // Set the flag that a current sequence is in progress
    this.IsBusy = true;

    // Execute operation until caller interaction is required.
    // Execute in background thread to allow API caller to proceed with execution.
    Task.Run(() => StartOperationNonBlocking(param));
  }

  public void EndPickPoint(param)
  {
    // Implement constraint 2 and 3
    if (!this.IsPickPointInitialized)
    {
      // Alternatively just return or use Try-do pattern
      throw new InvalidOperationException("BeginPickPoint must have completed execution before calling EndPickPoint.");
    }

    // Execute operation until caller interaction is required.
    // Execute in background thread to allow API caller to proceed with execution.
    Task.Run(() => CompleteOperationNonBlocking(param));
  }

  private void StartOperationNonBlocking(param)
  {
    ... // Do something

    // Flag the completion of the first step of the sequence (to guarantee constraint 2)
    this.IsPickPointInitialized = true;

    // Request caller interaction to kick off EndPickPoint() execution
    OnInitializePickPointCompleted();
  }

  private void CompleteOperationNonBlocking(param)
  {
    // Execute goal and get the result of the completed task
    Point result = ExecuteGoal();

    // Reset API sequence (allow next client invocation)
    this.IsBusy = false;
    this.IsPickPointInitialized = false;

    // Notify caller that execution has completed and the result is available
    OnPickPointCompleted(result);
  }

  private void OnInitializePickPointCompleted()
  {
    // Set the result of the task
    this.InitializePickPointCompleted?.Invoke(this, EventArgs.Empty);
  }

  private void OnPickPointCompleted(Point result)
  {
    // Set the result of the task
    this.PickPointCompleted?.Invoke(this, new PickPointCompletedEventArgs(result));
  }
}

PickPointCompletedEventArgs.cs

class PickPointCompletedEventArgs : AsyncCompletedEventArgs 
{
  public Point Result { get; }

  public PickPointCompletedEventArgs(Point result)
  {
    this.Result = result;
  }
}

使用API

MainWindow.xaml.cs

partial class MainWindow : Window
{
  private Utility Api { get; set; }

  public MainWindow()
  {
    InitializeComponent();

    this.Api = new Utility();
  }

  private void StartPickPoint_OnButtonClick(object sender, RoutedEventArgs e)
  {
    this.Api.InitializePickPointCompleted += RequestUserInput_OnInitializePickPointCompleted;

    // Invoke API and continue to do something until the first step has completed.
    // This is possible because the API will execute the operation on a background thread.
    this.Api.BeginPickPoint();
  }

  private void RequestUserInput_OnInitializePickPointCompleted(object sender, EventArgs e)
  {
    // Cleanup
    this.Api.InitializePickPointCompleted -= RequestUserInput_OnInitializePickPointCompleted;

    // Communicate to the UI user that you are waiting for him to click on the screen
    // e.g. by showing a Popup, dimming the screen or showing a dialog.
    // Once the input is received the input event handler will invoke the API to complete the goal   
    MessageBox.Show("Please click the screen");  
  }

  private void FinishPickPoint_OnGridMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  {
    this.Api.PickPointCompleted += ShowPoint_OnPickPointCompleted;

    // Invoke API to complete the goal
    // and continue to do something until the last step has completed
    this.Api.EndPickPoint();
  }

  private void ShowPoint_OnPickPointCompleted(object sender, PickPointCompletedEventArgs e)
  {
    // Cleanup
    this.Api.PickPointCompleted -= ShowPoint_OnPickPointCompleted;

    // Get the result from the PickPointCompletedEventArgs instance
    Point point = e.Result;

    // Handle the result
    MessageBox.Show(point.ToString());
  }
}

MainWindow.xaml

<Window>
  <Grid MouseLeftButtonUp="FinishPickPoint_OnGridMouseLeftButtonUp">
    <Button Click="StartPickPoint_OnButtonClick" />
  </Grid>
</Window>

备注

在后台线程上引发的事件将在同一线程上执行它们的处理程序。从在后台线程上执行的处理程序访问 UI 元素之类的 DispatcherObject 需要使用 Dispatcher.Invoke 或 [ 将关键操作排队到 Dispatcher =22=]以避免跨线程异常。
阅读关于 DispatcherObject 的评论,了解有关这种称为调度程序关联或线程关联的现象的更多信息。
为了方便使用 API,我建议通过捕获和使用调用者的 SynchronizationContext or by using AsyncOperation (or the AsyncOperationManager).

将所有事件编组到调用者的原始上下文中

上面的示例可以通过提供取消(推荐)来轻松增强,例如通过公开 Cancel() 方法,例如 PickPointCancel() 和进度报告(最好使用 Progress<T>)。


一些想法-回复您的意见

因为您要找我寻找 "better" 阻塞解决方案,所以给我举了控制台应用程序的例子,我想说服您,您的看法或观点是完全错误的。

"Consider a Console application with these two lines of code in it.

var str = Console.ReadLine(); 
Console.WriteLine(str);

What happens when you execute the application in debug mode. It will stop at the first line of code and force you to enter a value in Console UI and then after you enter something and press Enter, it will execute the next line and actually print what you entered. I was thinking about exactly the same behavior but in WPF application."

控制台应用程序是完全不同的东西。线程概念有点不同。控制台应用程序没有 GUI。只有 input/output/error 个流。您无法将控制台应用程序的体系结构与丰富的 GUI 应用程序进行比较。这行不通。你真的必须理解并接受这一点。

也不要被外表欺骗了。你知道里面Console.ReadLine发生了什么吗?它是如何实现的?它是否阻塞了主线程并并行读取输入?还是只是轮询?
这是 Console.ReadLine 的原始实现:

public virtual String ReadLine() 
{
  StringBuilder sb = new StringBuilder();
  while (true) 
  {
    int ch = Read();
    if (ch == -1) 
      break;
    if (ch == '\r' || ch == '\n') 
    {
      if (ch == '\r' && Peek() == '\n') 
        Read();
      return sb.ToString();
    }
    sb.Append((char)ch);
  }
  if (sb.Length > 0) 
    return sb.ToString();
  return null;
}

如您所见,这是一个简单的同步操作。它在 "infinite" 循环中轮询用户输入。没有魔法块,继续。

WPF 是围绕渲染线程和 UI 线程构建的。这些线程保持 始终 旋转以便与 OS 通信,例如处理用户输入 - 保持应用程序 响应 。你永远不想 pause/block 这个线程,因为它会阻止框架进行必要的后台工作,比如响应鼠标事件 - 你不希望鼠标冻结:

等待 = 线程阻塞 = 无响应 = 糟糕的用户体验 = 恼火 users/customers = 办公室里的麻烦。

有时,应用程序流程需要等待输入或例程完成。但是我们不想阻塞主线程。
这就是为什么人们发明了复杂的异步编程模型,以便在不阻塞主线程的情况下允许等待,并且不会强迫开发人员编写复杂且错误的多线程代码。

每个现代应用程序框架都提供异步操作或异步编程模型,以允许开发简单高效的代码。

你极力抵制异步编程模型,这表明我有些缺乏理解。每个现代开发人员都更喜欢异步 API 而不是同步。没有认真的开发人员关心使用 await 关键字或声明他的方法 async。没有人。你是我遇到的第一个抱怨异步 APIs 并且发现它们使用不便的人。

如果我检查你的框架,其目标是解决 UI 相关问题或使 UI 相关任务更容易,我会 期望 它是异步 - 一路走来。
UI related API 不是异步的是浪费,因为它会使我的编程风格复杂化,因此我的代码变得更容易出错且难以维护。

不同的观点:当您承认等待阻塞 UI 线程时,会造成非常糟糕和不受欢迎的用户体验,因为 UI 会冻结直到等待结束,现在您意识到这一点,你为什么要提供一个 API 或插件模型来鼓励开发人员这样做 - 实现等待?
您不知道第 3 方插件会做什么,也不知道例程需要多长时间才能完成。这简直就是一个糟糕的 API 设计。当您的 API 在 UI 线程上运行时,您的 API 的调用者必须能够对其进行非阻塞调用。

如果您否认唯一廉价或优雅的解决方案,请使用我的示例中所示的事件驱动方法。
它做你想做的事:开始一个例程 - 等待用户输入 - 继续执行 - 完成目标。

我真的尝试了好几次来解释为什么 waiting/blocking 是一个糟糕的应用程序设计。同样,您不能将控制台 UI 与丰富的图形 UI 进行比较,例如单独的输入处理比仅仅监听输入流要复杂得多。我真的不知道你的经验水平和你从哪里开始,但你应该开始拥抱异步编程模型。我不知道你试图避免它的原因。但这一点也不明智。

今天,异步编程模型无处不在,在每个平台、编译器、每个环境、浏览器、服务器、桌面、数据库 - 无处不在。事件驱动模型可以达到同样的目的,但是使用起来不太方便(subscribe/unsubscribe to/from 事件,阅读文档(有文档时)了解事件),依赖后台线程.事件驱动是老式的,只有在异步库不可用或不适用时才应使用。

作为旁注:.NET Framwork(.NET 标准)提供 TaskCompletionSource(以及其他目的)以提供一种简单的方法将现有的偶数驱动 API 转换为异步 API.

"I have seen the exact behavior in Autodesk Revit."

行为(您体验或观察到的)与这种体验的实现方式有很大不同。两种不同的东西。您的 Autodesk 很可能使用异步库或语言功能或其他一些线程机制。它也与上下文相关。当您想到的方法在后台线程上执行时,开发人员可能会选择阻塞该线程。他要么有很好的理由这样做,要么只是做出了错误的设计选择。你完全走错路了 ;) 阻塞是不好的。
(Autodesk的源代码是开源的吗?或者你怎么知道它是怎么实现的?)

我不想冒犯你,请相信我。但是请重新考虑实现您的 API 异步。开发人员不喜欢使用 async/await 只是在您的脑海中。你显然是错误的心态。忘记那个控制台应用程序参数 - 这是胡说八道 ;)

UI 相关 API 必须 尽可能使用 async/await。否则,您将编写非阻塞代码的所有工作都留给了 API 的客户端。你会强迫我将对你的 API 的每次调用都包装到后台线程中。或者使用不太舒服的事件处理。相信我 - 每个开发人员都宁愿用 async 装饰他的成员,而不是进行事件处理。每次您使用事件时,您都可能面临潜在的内存泄漏风险 - 取决于某些情况,但这种风险是真实存在的,并且在编程粗心时并不少见。

我真的希望你明白为什么阻塞是不好的。我真的希望你决定使用 async/await 来编写现代异步 API。尽管如此,我还是向您展示了一种非常常见的非阻塞等待方式,即使用事件,尽管我强烈建议您使用 async/await.

"The API will allow the programmer to have access to the UI and etc. Now suppose the programmer wants to develop an add-in that when a button is clicked, the final user is asked to pick a point in the UI"

如果您不想让插件直接访问 UI 元素,您应该提供一个接口来委托事件或通过抽象对象公开内部组件。
API 在内部将代表外接程序订阅 UI 事件,然后通过向 API 客户端公开相应的 "wrapper" 事件来委托事件。您的 API 必须提供一些挂钩,加载项可以连接到这些挂钩以访问特定的应用程序组件。插件 API 就像适配器或门面一样,可以让外部访问内部。
允许一定程度的隔离。

看看 Visual Studio 如何管理插件或允许我们实施它们。假设您想为 Visual Studio 编写一个插件,并研究一下如何做到这一点。您将意识到 Visual Studio 通过接口或 API 公开其内部结构。例如。您可以操纵代码编辑器或获取有关编辑器内容的信息,而无需 真正 访问它。

从技术上讲,使用 AutoResetEvent 和不使用 async/await 都是可行的,但是有一个明显的缺点:

public static Point PickPoint(Grid grid)
{
    var pointPicked = new AutoResetEvent(false);
    grid.MouseLeftButtonUp += (s, e) => 
    {
        // do whatever after the grid is clicked

        // signal the end of waiting
        pointPicked.Set();
    };

    // code flow will stop here and wait until the grid is clicked
    pointPicked.WaitOne();
    // return something...
}

缺点:如果像示例代码那样直接在按钮事件处理程序中调用此方法,则会发生死锁,您会看到应用程序停止响应。因为您正在使用唯一的 UI 线程来等待用户的点击,它无法响应任何用户的操作,包括用户在网格上的点击。

该方法的使用者应该在另一个线程中调用它以防止死锁。如果能保证就好了。否则,您需要像这样调用方法:

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    // here I used ThreadPool, but you may use other means to run on another thread
    ThreadPool.QueueUserWorkItem(new WaitCallback(Capture));
}

private void Capture(object state)
{
    // do not continue the code flow until the user has clicked on the grid. 
    // so when we debug, the code flow will literally stop here.
    var point = Utility.PickPoint(View);


    MessageBox.Show(point.ToString());
}

这可能会给您 API 的消费者带来更多麻烦,除非他们曾经管理自己的线程。这就是发明 async/await 的原因。

我认为问题出在设计本身。如果您的 API 适用于特定元素,那么它应该用在这个元素的事件处理程序中,而不是用在另一个元素上。

比如这里我们要获取点击事件在Grid上的位置,需要在与Grid元素上的事件关联的事件处理程序中使用API,而不是在按钮上元素.

现在如果需求是点击Button之后才处理Grid的点击,那么Button的职责就是在Grid上添加事件处理器,Grid上的点击事件会显示消息框并删除由按钮添加的事件处理程序,以便在单击后不再触发...(无需阻止 UI 线程)

只是说,如果你在按钮点击时阻塞了UI线程,我认为UI线程之后将无法触发Grid上的点击事件。

首先,UI线程不能像您从早期问题中得到的答案那样被阻塞。
如果你同意,那么避免async/await让你的客户做更少的修改是可行的,甚至不需要任何多线程。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        Utility.PickPoint(View, (x) => MessageBox.Show(x.ToString()));
    }
}

public static class Utility
{
    private static Action<Point> work;

    public static void PickPoint(Grid grid, Action<Point> work)
    {
        if (Utility.work == null)
        {
            grid.PreviewMouseLeftButtonUp += Grid_PreviewMouseLeftButtonUp;
            Utility.work = work;
        }
    }

    private static void Grid_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        var grid = (Grid)sender;
        work.Invoke(e.GetPosition(grid));
        grid.PreviewMouseLeftButtonUp -= Grid_PreviewMouseLeftButtonUp;
        Utility.work = null;
    }
}   

但是如果你想阻塞UI线程或者"code flow",答案是不可能的。因为如果 UI 线程被阻塞,则无法接收进一步的输入。
既然你提到了控制台应用程序,我只是做一些简单的解释。
当您 运行 控制台应用程序或从未附加到任何控制台 (window) 的进程调用 AllocConsole 时,conhost.exe 可以提供控制台 (window) 将被执行,控制台应用程序或调用进程将附加到控制台 (window).
因此,您编写的任何可能阻塞调用者线程的代码(例如 Console.ReadKey 都不会阻塞控制台 window 的 UI 线程,这就是为什么当控制台应用程序等待您的输入但仍然可以响应鼠标点击等其他输入。