【问题标题】:Delphi: How to prevent a single thread app from losing responses?Delphi:如何防止单线程应用程序丢失响应?
【发布时间】:2021-07-15 07:27:05
【问题描述】:

我正在使用 Delphi 开发一个单线程应用程序,它会执行一项耗时的任务,如下所示:

// time-consuming loop
For I := 0 to 1024 * 65536 do
Begin
    DoTask();
End;

当循环开始时,应用程序将失去对最终用户的响应。那不是很好。由于它的复杂性,我也不想将其转换为多线程应用程序,因此我相应地添加了 Application.ProcessMessages,

// time-consuming loop
For I := 0 to 1024 * 65536 do
Begin
DoTask();
Application.ProcessMessages;
End;

不过,这一次应用虽然会响应用户操作,但在循环中消耗的时间比原来的循环要多很多,大约是10倍左右。

是否有解决方案可以确保应用程序不会丢失响应,同时又不会过多增加消耗的时间?

【问题讨论】:

  • 每当我看到有人使用Application.ProcessMessages 并想知道为什么事情不正常时,我总是会哭一会儿。
  • 把它放在一个线程中。放弃ProcessMessages。
  • 同意 - 它需要一个线程。当然,我可以理解重构复杂遗留应用程序的痛苦。上次我这样做是并行化以前的程序算法的唯一方法。这意味着从几十个单元中提取成百上千个全局变量,重构跨越近万行代码的一百多个方法……一行一行。包括调试,花了几个月的时间。我可以理解想要在像ProcessMessages 这样的快速单行修复下畏缩 - 我的建议是不要。从长远来看,它永远不值得。修复它。
  • 也就是说...if I mod 1000 = 0 then... 是修补此罪行的常见嫌疑人。

标签: delphi


【解决方案1】:

你真的应该使用工作线程。这就是线程的好处。

使用Application.ProcessMessages() 是一种创可贴,而不是解决方案。在DoTask() 工作时,您的应用程序仍将无响应,除非您乱扔DoTask() 并额外调用Application.ProcessMessages()。另外,如果不小心,直接调用Application.ProcessMessages() 会引入重入问题。

如果你必须直接调用Application.ProcessMessages(),那么除非有消息真正等待处理,否则不要调用它。您可以使用 Win32 API GetQueueStatus() 函数来检测该情况,例如:

// time-consuming loop
For I := 0 to 1024 * 65536 do
Begin
  DoTask();
  if GetQueueStatus(QS_ALLINPUT) <> 0 then
    Application.ProcessMessages;
End;

否则,将 DoTask() 循环移动到一个线程中(是的,是的),然后让你的 main 循环使用MsgWaitForMultipleObjects() 等待任务线程完成。 这仍然允许您检测何时处理消息,例如:

procedure TMyTaskThread.Execute;
begin
  // time-consuming loop
  for I := 0 to 1024 * 65536 do
  begin
    if Terminated then Exit;
    DoTask();
  end;
end;

var
  MyThread: TMyTaskThread;
  Ret: DWORD;
begin
  ...
  MyThread := TMyTaskThread.Create;
  repeat
    Ret := MsgWaitForMultipleObjects(1, Thread.Handle, FALSE, INFINITE, QS_ALLINPUT);
    if (Ret = WAIT_OBJECT_0) or (Ret = WAIT_FAILED) then Break;
    if Ret = (WAIT_OBJECT_0+1) then Application.ProcessMessages;
  until False;
  MyThread.Terminate;
  MyThread.WaitFor;
  MyThread.Free;
  ...
end;

【讨论】:

  • +1 从未听说过GetQueueStatus。谢谢你教我那个。哦,重复直到 False,我不确定我以前见过那个,在那里逆潮游泳!! ;-)
  • 如果使用多线程是个好方法,那么为了简单起见,我是否可以将DoTask保留在主线程中。并创建一个新线程来调用 Application.ProcessMessages 以保持 GUI 对用户输入的响应?
  • 没有。 Application.ProcessMessages() 处理调用它的线程的消息队列。如果您在工作线程中调用它,则在 DoTask() 运行时不会处理主线程的消息,从而使您回到原来的问题。解决方案是根本不阻塞主线程。在一个单独的线程中做你的工作,甚至不要等待它。启动它,让它告诉你什么时候完成。让主线程做它应该做的事——只为 UI 服务。当涉及 UI 时,学习异步编程。
  • 嗨,Remy Lebeau,我仔细阅读了您的帖子并有以下问题: 1. 如果我放置一个按钮并在其单击事件中,我调用 MyThread.Terminate,如下所示:procedure OnButton1Click()开始我的线程。终止;结尾;那么线程的 Terminated 标志将变为真,循环中的以下代码行将执行,是真的吗?如果终止则退出; 2. 在以下代码行中: MyThread.Terminate;我的线程。等待; MyThread.免费; MyThread.Terminate 的用途是什么?和 WaitFor 在调用 Free 之前?我认为线程已经终止了。
  • 3.如何将自定义消息从 MyThread 发送到主窗体,以便它可以响应该消息? 4.如果我写Execute如下:procedure TMyTaskThread.Execute; begin // 调用 DLL 中的耗时函数 CalTimeCONsumingFunctionInDLL(MyFile);结尾;然后想创建一次 TMyTaskThread,但是调用了几次 Execute,如何实现呢?以下代码有效吗? MyThread := TMyTaskThread.Create(); try for I := 0 to 20 do begin MyThread.MyFile := MyFileList.Items[I]; MyThread.Execute();结尾;最后是MyThread.Free;结束;
【解决方案2】:

你说:

我也不想将其转换为多线程应用程序 因为它的复杂性

我可以把它理解为两件事之一:

  1. 您的应用程序是一团乱七八糟的遗留代码,它们如此庞大且编写​​得如此糟糕,以至于将 DoTask 封装在一个线程中意味着大量的重构,而无法为其制定可行的业务案例。
  2. 您觉得编写多线程代码太“复杂”,不想学习如何去做。

如果情况是 #2,那么就没有任何借口 - 多线程是这个问题的明确答案。将方法滚动到线程中并不可怕,您将成为学习如何做的更好的开发人员。

如果情况是 #1,我让你来决定,那么我会注意到,在循环期间,你将调用 Application.ProcessMessages 6700 万次:

For I := 0 to 1024 * 65536 do
Begin
  DoTask();
  Application.ProcessMessages;
End;

掩盖这种罪行的典型方法是不每次运行循环时都调用Application.ProcessMessages。

For I := 0 to 1024 * 65536 do
Begin
  DoTask();
  if I mod 1024 = 0 then Application.ProcessMessages;
End;

但是如果Application.ProcessMessages 的执行时间实际上是DoTask() 的十倍,那么我真的怀疑DoTask 到底有多复杂,以及将其重构为线程是否真的是一项艰巨的工作。如果您使用ProcessMessages 解决此问题,您真的应该将其视为临时解决方案。

特别注意使用ProcessMessages 意味着您必须确保所有消息处理程序都是可重入的。

【讨论】:

    【解决方案3】:

    Application.ProcessMessages 应该避免。它可能会给您的程序带来各种奇怪的事情。必读:The Dark Side of Application.ProcessMessages in Delphi Applications。

    在您的情况下,线程是解决方案,即使 DoTask() 可能需要稍微重构才能在线程中运行。

    这是一个使用anonymous thread 的简单示例。 (需要 Delphi-XE 或更新版本)。

    uses
      System.Classes;
    
    procedure TForm1.MyButtonClick( Sender : TObject);
    var
      aThread : TThread;
    begin
      aThread :=
        TThread.CreateAnonymousThread(
          procedure
          var
            I: Integer;
          begin
            // time-consuming loop
            For I := 0 to 1024 * 65536 do
            Begin
              if TThread.CurrentThread.CheckTerminated then
                Break;
              DoTask();
            End;
          end
        );
      // Define a terminate thread event call
      aThread.OnTerminate := Self.TaskTerminated;
      aThread.Start;
      // Thread is self freed on terminate by default
    end;
    
    procedure TForm1.TaskTerminated(Sender : TObject);
    begin
      // Thread is ready, inform the user
    end;
    

    线程是自毁的,你可以在你的表单中添加一个OnTerminate调用到一个方法。

    【讨论】:

    • 这假定DoTask 是线程安全的。根据我的经验(这里讨论的是遗留代码),创建线程的任务通常不是困难的部分,而是系统地重构DoTask 中的所有内容以使其成为线程安全的。除非我们知道DoTask 的内容是什么,否则这很可能是一个危险的提议。
    • @DavidHeffernan ...知道吗?如果它充满了 UI 调用、图表更新、触摸全局变量,一旦控制返回,主线程就会搞砸等等?
    • @LURD Application.ProcessMessages 的任何重入问题也将存在于线程模型中。这部分总是让我很兴奋——线程不会让ProcessMessages 导致的大部分问题消失——在这两种情况下你仍然必须处理并发,在这两种情况下它都会给你带来麻烦。添加线程 imo 保留了ProcessMessages 的并发问题,并且还添加了跨线程警告。如果有的话,我认为你必须比ProcessMessages 更小心线程。
    • @J... 使用Application.ProcessMessages 的每个人都应该先阅读以下内容:The Dark Side of Application.ProcessMessages in Delphi Applications。
    • @J... 这就是我所说的线程亲和力。我通常认为线程安全的种族。
    【解决方案4】:

    在每次迭代时调用Application.ProcessMessages 确实会降低性能,如果你无法预测每次迭代需要多长时间,每隔几次调用它并不总是很好,所以我通常会使用GetTickCount 来100 毫秒过去的时间 (1)。这足够长,不会过多地降低性能,并且足够快以使应用程序看起来响应。

    var
      tc:cardinal;
    begin
      tc:=GetTickCount;
      while something do
       begin
        if cardinal(GetTickCount-tc)>=100 then
         begin
          Application.ProcessMessages;
          tc:=GetTickCount;
         end;
        DoSomething;
       end;
    end;
    

    (1):不完全是 100 毫秒,但在某个接近的地方。有更精确的方法来测量时间,例如 QueryPerformanceTimer,但同样需要更多的工作并且可能会影响性能。

    【讨论】:

    • QueryPerformanceCounter 我认为足够快。 System.Diagnostics.TStopwatch 很好地总结了这一点。
    • 不要忘记考虑到GetTickCount() 在 Windows 连续运行的每 49.7 天回绕回 0,因此简单地从之前的 GetTickCount 中减去当前的 GetTickCount 是不安全的一个没有先检查包装。否则,请改用GetTickCount64(),它不会受到包装的影响。
    • GetTickCount 在 Windows 95 中很好,当时不可能让机器长时间运行,但是随着 XP SP2 的出现并改变了这一切......无论如何,这里不需要 GTC64,测试一下包装很简单。
    • @Remy:注意到显式转换为红衣主教了吗?即使在 GetTickCount 和 tc 之间发生了换行,并且随着时间的推移,您一定会得到一个合理的值。
    • @StijnSanders:如果 tc 在达到 49.7 天限制之前被初始化,然后在超过限制并发生包装后调用 GetTickCount(),则转换不会给你一个合理的结果。 tc 在MAX_DWORD 附近仍然是一个高值,随后的刻度将是接近 0 的低值,因此将两者相减将导致非常高的值,即使实际上只经过了几毫秒,直到您重置 @987654332 @ 具有新的较低值。必要的计算因连续滴答之间是否发生回绕而有所不同。
    【解决方案5】:

    @user2704265,当您提到“应用程序将失去对最终用户的响应”时,您的意思是您希望您的用户继续在您的应用程序中点击和打字吗?在这种情况下 - 注意前面的答案并使用线程。

    如果它足以提供您的应用程序正忙于冗长的操作[并且没有冻结]的反馈,那么您可以考虑一些选项:

    • 禁用用户输入
    • 将光标更改为“忙碌”
    • 使用进度条
    • 添加取消按钮

    根据您对单线程解决方案的要求,我建议您首先禁用用户输入并将光标更改为“忙碌”。

    procedure TForm1.ButtonDoLengthyTaskClick(Sender: TObject);
    var i, j : integer;
    begin
      Screen.Cursor := crHourGlass;
      //Disable user input capabilities
      ButtonDoLengthyTask.Enabled := false;
    
      Try
        // time-consuming loop
        For I := 0 to 1024 * 65536 do
        Begin
           DoTask();
    
          // Calling Processmessages marginally increases the process time
          // If we don't call and user clicks the disabled button while waiting then
          // at the end of ButtonDoLengthyTaskClick the proc will be called again
          // doubling the execution time.
    
          Application.ProcessMessages;
    
        End;
      Finally
        Screen.Cursor := crDefault;
        ButtonDoLengthyTask.Enabled := true;
      End;
    End;
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2016-06-29
      • 2013-10-26
      • 2023-03-11
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多