从线程向GUI发送消息的最佳实践?

3
我正在开发一款小型监控应用程序,其中将有一些线程通过SNMP、TCP、ICMP与某些设备通信,其他线程必须执行一些计算。我需要将所有这些结果输出到GUI中(一些表单或选项卡)。关于实现方式,我考虑了以下几种可能性:
  • 从每个工作线程使用Synchronize
  • 使用共享缓冲区和Windows消息机制。线程将在共享缓冲区(队列)中放置消息,并通过Windows消息通知GUI。
  • 使用单独的线程来监听同步原语(事件、信号量等),并仅从专门的GUI线程使用Synchronize,或者在GUI上使用关键部分来显示消息。
  • 更新:(由一个同事提出)使用共享缓冲区和主窗体中的TTimer,定期(100-1000毫秒)检查共享缓冲区并消费,而不是使用Windows消息。(这样做是否比使用消息更有优势?)
  • 其他方法?
亲爱的专家们,请解释什么是最佳实践,以及暴露的替代方案的优缺点是什么。 更新:
作为思路:
//共享缓冲区+发送消息变量
LogEvent全局函数将从任何地方调用(包括工作线程):
procedure LogEvent(S: String);
var
  liEvent: IEventMsg;
begin
  liEvent := TEventMsg.Create; //Interfaced object
  with liEvent do
  begin
    Severity := llDebug;
    EventType := 'General';
    Source := 'Application';
    Description := S;
  end;
  MainForm.AddEvent(liEvent); //Invoke main form directly
end;

在主窗体中,事件列表视图和共享部分(fEventList: TTInterfaceList 已经是线程安全的)将会是:
procedure TMainForm.AddEvent(aEvt: IEventMsg);
begin
  fEventList.Add(aEvt);
  PostMessage(Self.Handle, WM_EVENT_ADDED, 0, 0);
end;

消息处理程序:

procedure WMEventAdded(var Message: TMessage); message WM_EVENT_ADDED;
...
procedure TMainForm.WMEventAdded(var Message: TMessage);
var
  liEvt: IEventMsg;
  ListItem: TListItem;
begin
  fEventList.Lock;
  try
    while fEventList.Count > 0 do
    begin
      liEvt := IEventMsg(fEventList.First);
      fEventList.Delete(0);
      with lvEvents do //TListView
      begin
        ListItem := Items.Add;
        ListItem.Caption := SeverityNames[liEvt.Severity];
        ListItem.SubItems.Add(DateTimeToStr(now));
        ListItem.SubItems.Add(liEvt.EventType);
        ListItem.SubItems.Add(liEvt.Source);
        ListItem.SubItems.Add(liEvt.Description);
      end;
    end;
  finally
    fEventList.UnLock;
  end;
end;

有什么问题吗?主表单在应用程序启动时分配一次,在应用程序退出时销毁。

提示:Main Form指的是应用程序的主窗体。


通常我会直接在wParam / lParam中发布请求/结果对象,处理完对象后会释放它们或将它们重新池化在消息处理程序中。确实有一种“运动”使用一个单独的线程安全队列来实际传输数据,并使用窗口消息作为一种标志来弹出队列,但我从未理解过这种方法-为什么使用两个队列而不是一个? - Martin James
1
@TLama:当然,消息可能会丢失,因为消息队列具有有限的长度,所以如果发布消息的速度快于处理它们的速度,那么在某个时候,队列将变满,并且除非发送方正确处理PostMessage()的错误结果,否则消息将被“丢失”。请参见https://dev59.com/OnVD5IYBdhLWcg3wAGiD。 - mghie
1
不需要计时器;最好使用Application.OnIdle来检查缓冲区。 - kludg
1
顺便提一下,如果您打算在WMEventAdded方法中消耗整个队列(似乎您已经这样做了),请不要忘记为该列表视图锁定更新(Items.BeginUpdateItems.EndUpdate)。这将锁定控件以进行绘制,因此当系统请求时,列表视图不会在每次添加项目时重新绘制自身。 - TLama
2
@ALZ "从前我写了一个Delphi的HelloWorld应用程序..." 这就是为什么你应该始终检查PostMessage()的返回值。此外,填充消息队列最简单的方法是在同一线程中的循环中调用PostMessage()。因为在你发送消息时没有任何东西会移除队列条目。所以一般来说,你要小心线程向自己发送大量消息。 - Disillusioned
显示剩余8条评论
2个回答

6

使用每个工作线程的同步

这可能是实现最简单的方法,但正如其他人所指出的,会导致您的IO线程被阻塞。这在您特定的应用程序中可能/可能不是一个问题。

然而,应该注意到有其他避免阻塞的原因。阻塞可能会使性能分析变得有些棘手,因为它有效地将花费在“匆忙和等待”例程中的时间推高了。

使用共享缓冲区和Windows消息机制

这是一种不错的方法,但需要考虑一些特殊情况。

如果您的数据非常小,PostMessage可以将其全部打包到消息的参数中,使其成为理想的选择。

然而,由于您提到了共享缓冲区,似乎您可能有更多的数据。这就需要你小心一点了。在直觉上使用“共享缓冲区”可能会暴露您于竞态条件(但稍后我将详细介绍这一点)。

更好的方法是创建一个消息对象,并将对象的所有权传递给GUI。

  • 创建一个包含GUI更新所需的所有详细信息的新对象。
  • 通过PostMessage中的附加参数传递对该对象的引用。
  • 当GUI完成处理消息时,它负责销毁该对象。
  • 这样可以避免竞态条件。
  • 警告:您需要确保GUI收到了所有消息,否则就会出现内存泄漏。您必须检查PostMessage的返回值以确认它是否实际发送,如果未发送,则最好销毁该对象。
  • 如果数据可以通过轻量级对象发送,则此方法非常有效。

使用单独的线程...

使用任何类型的单独的中间线程仍然需要类似的考虑方式来将相关数据传递给新线程,然后仍需以某种方式传递给GUI。这可能只有在应用程序需要在更新GUI之前执行聚合和耗时计算时才有意义。与阻塞IO线程一样,您不希望阻塞GUI线程。

使用共享缓冲区和主窗体中的TTimer

我先前提到过,“共享缓冲区”的“直观想法”即:“不同的线程同时读写”;会使您面临竞态条件的风险。如果在写操作中间开始读取数据,那么您就有可能读取不一致状态的数据。这些问题可能非常难以调试。

为了避免这些竞态条件,您需要借助其他同步工具(如锁)来保护共享数据。当然,锁会让我们回到阻塞问题,尽管形式稍微好一些。这是因为您可以控制所需保护的粒度。

与消息相比,这确实有一些好处:

  • 如果您的数据结构庞大且复杂,那么您的消息可能效率低下。
  • 您不需要定义一个严格的消息协议来覆盖所有更新场景。
  • 使用消息传递方法可能会导致系统中数据重复,因为GUI会保留其自己的数据副本以避免竞态条件。

有一种方式可以改进共享数据的想法,仅当适用时:某些情况下,您可以选择使用不可变的数据结构。也就是说:创建后不会更改的数据结构。(注意:前面提到的消息对象应该是不可变的。)这样做的好处是,您可以安全地读取数据(从任意数量的线程),而无需任何同步原语 - 前提是您能够保证数据不会更改。


1
通过将该对象的引用通过PostMessage中的附加参数传递是意料之外的内存泄漏的导火索,我认为一个线程安全的队列要安全得多:在这种情况下,您只需发送通知消息,所有待处理信息都从队列中检索,甚至可以绕过已弃用的值。如果需要,锁定也不是问题,只要访问时间不长 - 而且您可以使用私有副本或取消排队以进一步处理信息,然后释放锁定,而无需使用不可变结构。 - Arnaud Bouchez

0

最好的方法是使用GDI自定义消息,然后调用PostMessage()来通知GUI。

type
  TMyForm = class(TForm)
  .
  .
  .
  private
    procedure OnMyMessage(var Msg: TMessage); message WM_MY_MESSAGE;
    procedure OnAnoMessage(var Msg: TMessage); message WM_ANO_MESSAGE;
  .
  .


  PostMessage(self.Handle,WM_MY_MESSAGE,0,0);

请参阅这篇优秀文章以获取完整解释

这是一种更轻便/更快速的方法,依赖于操作系统的内部功能。


5
请注意,TWinControl.Handle 属性的获取器不是线程安全的!你应该采用下列之一:1) 向 TApplication.Handle 发送消息并使用 TApplication(Events).OnMessage 事件来检索它,或者 2) 向使用 AllocateHWnd() 创建的专用窗口发送消息。 - Remy Lebeau
2
你怎么确定这是最好的呢? - David Heffernan
2
@ArnaudBouchez 这个问题是窗口重建。AllocateHWnd 是正确的方法。 - David Heffernan
1
@ALZ 你需要一个窗口句柄,它不会被重新创建。AllocateHWnd 就是你要找的东西。 - David Heffernan
1
@ArnaudBouchez:PostMessage()是线程安全的,但是TWinControl.Handle属性获取器不是线程安全的。 TWinControl HWND不是持久的,它可以(并且确实)在控件存续期间动态重建。 当在正在被线程使用的HWND上发生重新创建时,会发生丢失消息,崩溃,句柄泄漏和死控件无法响应主线程等问题。 有关技术细节已在过去的讨论中发布。 这就是为什么您应该使用一个持久的、最好是专用的HWND来进行后续处理。 - Remy Lebeau
显示剩余12条评论

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