在主UI线程中发布消息被阻塞/删除

6

我的问题是,如果一个线程快速地向主UI线程发布消息并且我在此时更新UI,有时主消息队列会卡住(我没有更好的词来描述这个问题)。

以下是简化的重现代码:

const
  TH_MESSAGE = WM_USER + 1; // Thread message
  TH_PARAM_ACTION = 1;
  TH_PARAM_FINISH = 2;

type
  TForm1 = class(TForm)
    Button1: TButton;
    Label1: TLabel;
    procedure Button1Click(Sender: TObject);
  private
    ThreadHandle: Integer;
    procedure ThreadMessage(var Message: TMessage); message TH_MESSAGE;
  public
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

function ThreadProc(Parameter: Pointer): Integer;
var
  ReceiverWnd: HWND;
  I: Integer;
  Counter: Integer;
begin
  Result := 0;
  ReceiverWnd := Form1.Handle;
  Counter := 100000;
  for I := 1 to Counter do
  begin
    PostMessage(ReceiverWnd, TH_MESSAGE, TH_PARAM_ACTION, I);
    //Sleep(1); // <- is this the cure?
  end;
  PostMessage(ReceiverWnd, TH_MESSAGE, TH_PARAM_FINISH, GetCurrentThreadID);
  OutputDebugString('Thread Finish OK!'); // <- I see this
  EndThread(0);
end;

procedure TForm1.ThreadMessage(var Message: TMessage);
begin
  case Message.WParam of
    TH_PARAM_ACTION:
      begin
        Label1.Caption := 'Action' + IntToStr(Message.LParam);
        //Label1.Update;
      end;
     TH_PARAM_FINISH:
       begin
         OutputDebugString('ThreadMessage Finish'); // <- Dose not see this
         Button1.Enabled := True;
         CloseHandle(ThreadHandle);
       end;
  end;    
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  ThreadId: LongWord;
begin
  Button1.Enabled := False;
  ThreadId := 1;
  ThreadHandle := BeginThread(nil, 0, @ThreadProc, nil, 0, ThreadId);
end;

我意识到工作线程循环非常繁忙。我认为由于该线程正在将消息“发布”到主UI线程,因此(主UI线程)有机会在接收来自工作线程的其他消息时处理其消息。
随着计数器的增加,问题不断升级。
问题:
除非我添加Label1.Update,否则我从未看到Label1被更新;而且主UI被阻塞。
TH_PARAM_ACTION从未达到100000(在我的情况下),而是在90000以上随机上升。
TH_PARAM_FINISH从未到达消息队列。
显然,CPU使用率非常高。
问题:
如何正确处理这种情况?从工作线程发布的消息是否已从消息队列中删除(如果是,则为什么)?
循环中的Sleep(1)真的是解决此问题的方法吗?如果是,那么为什么是1?(0不行)
好的。感谢@Sertac和@LU,我现在意识到消息队列有一个限制,并且现在使用ERROR_NOT_ENOUGH_QUOTA检查来自PostMessage的结果。但是,主UI仍然没有响应!
function ThreadProc(Parameter: Pointer): Integer;
var
  ReceiverWnd: HWND;
  I: Integer;
  Counter: Integer;
  LastError: Integer;
  ReturnValue, Retry: Boolean;
begin
  Result := 0;
  ReceiverWnd := Form1.Handle;
  Counter := 100000;
  for I := 1 to Counter do
  begin
    repeat
      ReturnValue := PostMessage(ReceiverWnd, TH_MESSAGE, TH_PARAM_ACTION, I);
      LastError := GetLastError;
      Retry := (not ReturnValue) and (LastError = ERROR_NOT_ENOUGH_QUOTA);
      if Retry then
      begin
        Sleep(100); // Sleep(1) is not enoght!!!
      end;
    until not Retry;
  end;
  PostMessage(ReceiverWnd, TH_MESSAGE, TH_PARAM_FINISH, GetCurrentThreadID);
  OutputDebugString('Thread Finish OK!'); // <- I see this
  EndThread(0);
end;

仅供参考,这是我检查的原始代码:
Delphi threading by example

这个示例程序可以在多个文件中搜索文本(同时使用5个线程)。显然,当你执行这样一个任务时,你必须看到所有匹配的结果(例如,在 ListView 中)。

问题在于,如果我在很多文件中搜索,而搜索字符串很短(比如 "a") - 就会找到很多匹配项。繁忙的循环 while FileStream.Read(Ch,1)= 1 do 会快速地发布消息(TH_FOUND),其中包含匹配项,并使消息队列淹没。

实际上,这些消息并没有进入消息队列。就像 @Sertac 所提到的那样,“消息队列默认有10000个限制”。

来自 MSDN 的PostMessage

每个消息队列最多可以发布10000个消息。这个限制应该足够大。如果您的应用程序超过了这个限制,它应该重新设计以避免消耗太多的系统资源。要调整此限制,请修改以下注册表键(USERPostMessageLimit)

正如其他人所说,这段代码/模式应该重新设计。


2
“工作线程发布的消息是否从消息队列中删除(如果是,为什么)?”是的,默认情况下,消息队列有10000个限制(搜索USERPostMessageLimit)。 - Sertac Akyuz
3
最好检查PostMessage的结果,或将消息放在自己的队列中。 - LU RD
4
普通用户可以每秒辨认出25张图片。如果界面更新的速度超过这个值,一些信息将永远无法传达给用户;o) - Sir Rufo
1
这可能就是为什么Sleep(0)不够用,它只会让出CPU。Sleep(1)实际上相当于Sleep(10)(时钟分辨率),这可能已经足够让主线程清空队列并处理待处理的绘制消息了。我猜最后我会使用Sleep和'mod'一起来完成。 - Sertac Akyuz
1
如果“反馈”比用户能够识别的速度更快,那么它就不是反馈。牢记系统和用户的限制并不是过度工程化。 - Sir Rufo
显示剩余10条评论
2个回答

8
您发送的消息速率超过消息处理速度,导致消息队列被淹没。最终,队列变得满了。
如果您绝对需要主线程处理每条消息,则需要维护自己的队列。并且您可能需要限制添加到队列中的线程。
您的 Sleep(1)将起到限制作用,但是这种方式非常粗糙。也许它会限制太多,也许不足够。通常,您需要更精确地控制节流。通常,通过跟踪队列大小来自适应地限制。如果可以避免限制,请尽量避免。这很复杂,难以实现,并且会影响性能。
调用 Sleep(0)将在存在另一个线程准备好运行时产生作用。否则,Sleep(0)没有效果。如文档所述:
“值为零会使线程放弃其时间片剩余部分,以便任何其他准备运行的线程可以运行。如果没有其他线程准备好运行,则函数立即返回,线程继续执行。”
另一方面,如果您只需要在 GUI 中报告状态,则应完全避免使用队列。不要将消息从线程发布到主线程。仅在主线程中运行 GUI 更新计时器,并要求主线程查询工作线程的当前状态。
将这个想法应用到您的代码中会得到以下结果:
const
  TH_MESSAGE = WM_USER + 1; // Thread message
  TH_PARAM_FINISH = 2;

type
  TForm1 = class(TForm)
    Button1: TButton;
    Label1: TLabel;
    Timer1: TTimer;
    procedure Button1Click(Sender: TObject);
    procedure Timer1Timer(Sender: TObject);
  private
    procedure ThreadMessage(var Message: TMessage); message TH_MESSAGE;
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

var
  Count: Integer;

function ThreadProc(Parameter: Pointer): Integer;
var
  ReceiverWnd: HWND;
  I: Integer;
begin
  Result := 0;
  ReceiverWnd := Form1.Handle;
  for I := 1 to high(Integer) do
  begin
    Count := I;
  end;
  PostMessage(ReceiverWnd, TH_MESSAGE, TH_PARAM_FINISH, GetCurrentThreadID);
end;

procedure TForm1.ThreadMessage(var Message: TMessage);
begin
  case Message.WParam of
  TH_PARAM_FINISH:
    begin
      Button1.Enabled := True;
      Timer1.Enabled := False;
    end;
  end;
end;

procedure TForm1.Timer1Timer(Sender: TObject);
begin
  Label1.Caption := 'Action' + IntToStr(Count);
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  ThreadId: LongWord;
  ThreadHandle: THandle;
begin
  Count := -1;
  Button1.Enabled := False;
  ThreadHandle := BeginThread(nil, 0, @ThreadProc, nil, 0, ThreadId);
  CloseHandle(ThreadHandle);
  Timer1.Enabled := True;
end;

好的,我现在清楚地看到了你的观点(我想)。这比我所希望的更加复杂。我试图避免主线程询问工作线程们的当前状态,特别是如果有几个工作线程同时参与的情况下。但也许这是唯一可靠的模式。顺便问一下,为什么你立刻关闭 ThreadHandle - kobik
1
你没有对句柄进行任何操作,因此最好将其关闭。实际上,它应该是一个本地变量。 - David Heffernan
我没有看到TThread在使用FHandle时做任何操作,但是在TThread.Destroy中关闭了句柄。这就是为什么我在TH_PARAM_FINISH上关闭了句柄。我猜在TThread中可能会对句柄进行一些操作... - kobik
2
TThread使用WaitFor中的句柄。您需要一个线程句柄才能等待它。这里的代码不会等待,因此可以立即关闭句柄。 - David Heffernan

3
这种情况应该如何处理?如果在工作线程中发出的消息被移出了消息队列,那么应该怎么办呢?代码中如果存在可能会导致消息队列过载的情况,应该重新设计,但是如果确实需要处理这种情况,您可以检查PostMessage返回的[boolean]值,并在PostMessage返回False时调用GetLastError。如果消息队列已满,GetLastError应该返回ERROR_NOT_ENOUGH_QUOTA。请注意保留HTML标记。

1
@kobik,如果你的目标是向用户展示一些线程状态,那么最好定期从主线程查询它们的状态(或者在应用程序变得空闲时查询)。一个简单的规则是:如果你的UI线程无法及时处理这些消息,请停止将它们发送到UI线程(或减少它们的频率)。或者专门为消息队列分配一个类(你不应该针对表单句柄),该类将按需向UI提供所有状态的“快照”。 - TLama
@TLama,把这当作一次练习:工作线程必须将每个操作都发布到主线程,我必须记录(每个操作)。也许David是对的,更新UI的唯一方法需要一个定时器。无论如何,数据必须更新到日志中! - kobik
1
@TLama,这只是把问题转移到了其他地方。Windows 没有消息队列,线程才有。所以你需要一个线程。但如果主线程不能快速清除消息,为什么另一个线程会呢? - David Heffernan
@kobik,正如你所发现的那样,在不延迟线程的情况下是不可能实现的。如果你真的想要这个功能,使用“同步”并在更新GUI时阻塞工作线程。性能会非常糟糕,但这就是你所要求的结果。 - David Heffernan
3
@kobik,你的问题都是关于更新GUI,但在评论中你提到必须记录每条消息。我认为这些规格有不同的答案。 - LU RD
显示剩余10条评论

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