如何从另一个线程更新GUI?

1581

如何用最简单的方式从另一个线程更新Label

  • 我有一个在thread1上运行的Form,从那里我启动了另一个线程(thread2)。

  • thread2处理一些文件时,我想通过FormLabel的当前状态更新为thread2的工作状态。

我该怎么做呢?


28
.NET 2.0+有专门用于此的BackgroundWorker类,它可以注意UI线程。1.创建一个BackgroundWorker;2.添加两个委托(一个用于处理,另一个用于完成)。 - Preet Sangha
15
或许有点晚了:http://www.codeproject.com/KB/cs/Threadsafe_formupdating.aspx(请注意,为了保持翻译的准确性和简洁性,我省略了原文中的所有标点符号和格式) - MichaelD
4
请参考.NET 4.5和C# 5.0的答案:https://dev59.com/wnRB5IYBdhLWcg3wUVtd#18033198。 - Ryszard Dżegan
5
此问题不适用于Gtk# GUI。有关Gtk#的信息,请参见答案。 - hlovdal
3
警告:这个问题的回答现在混杂着 OT(“这是我为我的 WPF 应用所做的”)和历史遗留的 .NET 2.0 工件。请注意。 - Marc L.
相关帖子 - 从UI线程强制更新GUI - RBT
47个回答

1210

最简单的方法是将一个匿名方法传递给Label.Invoke

// Running on the worker thread
string newText = "abc";
form.Label.Invoke((MethodInvoker)delegate {
    // Running on the UI thread
    form.Label.Text = newText;
});
// Back on the worker thread

请注意,Invoke 方法会阻塞执行,直到完成--这是同步代码。虽然该问题不涉及异步代码,但在Stack Overflow上有很多关于编写异步代码的内容可供学习。


8
既然原帖没有提及任何其他的类/实例,除了表单(form),那么这并不是一个坏的默认选择... - Marc Gravell
42
不要忘记,"this" 关键字正在引用一个 "Control" 类。 - AZ.
8
这段话的意思是:“无论哪种方式,完成它都是安全的,并且我们已经知道我们在一个工作器上,所以为什么要检查我们已经知道的东西呢?” - Marc Gravell
5
不是真的需要检查 - 使用这种方法的一点就在于你已经知道哪些部分在工作线程上运行,哪些在UI线程上运行。不需要检查。 - Marc Gravell
3
@John,因为这就是Control.Invoke对于任何委托所做的事情——不仅仅对匿名方法。 - Marc Gravell
显示剩余4条评论

822

针对 .NET 2.0 版本,我写了一段很好的代码,可以准确地实现你所需要的功能,并且适用于 Control 上的任何属性:

private delegate void SetControlPropertyThreadSafeDelegate(
    Control control, 
    string propertyName, 
    object propertyValue);

public static void SetControlPropertyThreadSafe(
    Control control, 
    string propertyName, 
    object propertyValue)
{
  if (control.InvokeRequired)
  {
    control.Invoke(new SetControlPropertyThreadSafeDelegate               
    (SetControlPropertyThreadSafe), 
    new object[] { control, propertyName, propertyValue });
  }
  else
  {
    control.GetType().InvokeMember(
        propertyName, 
        BindingFlags.SetProperty, 
        null, 
        control, 
        new object[] { propertyValue });
  }
}

像这样调用:

// thread-safe equivalent of
// myLabel.Text = status;
SetControlPropertyThreadSafe(myLabel, "Text", status);
如果你使用的是 .NET 3.0 或以上版本,你可以将上面的方法重写为 Control 类的扩展方法,这样就可以简化调用方式:
myLabel.SetPropertyThreadSafe("Text", status);

更新于 2010 年 5 月 10 日:

对于 .NET 3.0,您应该使用此代码:

private delegate void SetPropertyThreadSafeDelegate<TResult>(
    Control @this, 
    Expression<Func<TResult>> property, 
    TResult value);

public static void SetPropertyThreadSafe<TResult>(
    this Control @this, 
    Expression<Func<TResult>> property, 
    TResult value)
{
  var propertyInfo = (property.Body as MemberExpression).Member 
      as PropertyInfo;

  if (propertyInfo == null ||
      !@this.GetType().IsSubclassOf(propertyInfo.ReflectedType) ||
      @this.GetType().GetProperty(
          propertyInfo.Name, 
          propertyInfo.PropertyType) == null)
  {
    throw new ArgumentException("The lambda expression 'property' must reference a valid property on this Control.");
  }

  if (@this.InvokeRequired)
  {
      @this.Invoke(new SetPropertyThreadSafeDelegate<TResult> 
      (SetPropertyThreadSafe), 
      new object[] { @this, property, value });
  }
  else
  {
      @this.GetType().InvokeMember(
          propertyInfo.Name, 
          BindingFlags.SetProperty, 
          null, 
          @this, 
          new object[] { value });
  }
}

使用 LINQ 和 lambda 表达式,可以实现更加清晰、简洁和安全的语法:

// status has to be of type string or this will fail to compile
myLabel.SetPropertyThreadSafe(() => myLabel.Text, status);

现在属性名称不仅在编译时进行检查,属性的类型也会被检查,因此无法(例如)将字符串值分配给布尔属性,从而导致运行时异常。

不幸的是,这并不能阻止任何人做一些愚蠢的事情,比如传递另一个Control的属性和值,所以以下代码可以成功编译:

myLabel.SetPropertyThreadSafe(() => aForm.ShowIcon, false);
因此,我添加了运行时检查,以确保传入的属性确实属于调用该方法的Control。虽然不完美,但比.NET 2.0版本要好得多。

如果有人有进一步建议来提高编译时安全性,请留言!


3
有些情况下,this.GetType() 和 propertyInfo.ReflectedType 的值相同(例如 WinForms 上的 LinkLabel)。我在 C# 方面经验不太丰富,但我认为异常的条件应该是:如果 propertyInfo 为空, 或者 @this.GetType() 不是 propertyInfo.ReflectedType 的子类,且它们的类型也不相同, 或者 @this.GetType().GetProperty(propertyInfo.Name, propertyInfo.PropertyType) 为空。 - Corvin
9
这个 SetControlPropertyThreadSafe(myLabel, "Text", status) 可以从另一个模块、类或窗体中调用吗? - Smith
85
如果你注重简洁性,建议查看Marc Gravell或Zaid Masud提供的解决方案,因为当前提供的方案过于复杂。请注意,不要改变原意。 - Frank Hileman
8
如果更新多个属性,每次调用都会消耗大量资源,因此这种解决方案会浪费大量资源。我认为这并不是线程安全特性的本意。请封装您的UI更新操作,并仅调用一次(而不是每个属性都调用)。 - quadroid
4
为什么你会使用这段代码而不是BackgroundWorker组件呢? - Andy
显示剩余3条评论

462

处理长时间工作

.NET 4.5和C# 5.0以来,您应该在所有领域中(包括GUI)使用基于任务的异步模式(TAP)以及async-await关键字:

TAP是新开发的推荐异步设计模式

建议在新开发中,使用以下方法代替 异步编程模型(APM)基于事件的异步模式(EAP)(后者包括 BackgroundWorker Class)。

因此,建议的解决方案是:

  1. Asynchronous implementation of an event handler (Yes, that's all):

     private async void Button_Clicked(object sender, EventArgs e)
     {
         var progress = new Progress<string>(s => label.Text = s);
         await Task.Factory.StartNew(() => SecondThreadConcern.LongWork(progress),
                                     TaskCreationOptions.LongRunning);
         label.Text = "completed";
     }
    
  2. Implementation of the second thread that notifies the UI thread:

     class SecondThreadConcern
     {
         public static void LongWork(IProgress<string> progress)
         {
             // Perform a long running work...
             for (var i = 0; i < 10; i++)
             {
                 Task.Delay(500).Wait();
                 progress.Report(i.ToString());
             }
         }
     }
    

请注意以下内容:

  1. 代码简短清晰,按顺序编写,没有回调和明确的线程。
  2. 使用Task代替Thread
  3. async关键字允许使用await,同时防止事件处理程序达到完成状态,直到任务完成,并在此期间不阻塞UI线程。
  4. Progress类(参见IProgress接口)支持分离关注点(SoC)设计原则,不需要显式调度程序和调用。它使用其创建位置的当前SynchronizationContext(这里是UI线程)。
  5. TaskCreationOptions.LongRunning提示不要将任务排队到ThreadPool中。

如果需要更加详细的示例,请参阅C#的未来:等待者皆有好处,作者为Joseph Albahari

另请查看UI线程模型概念。

异常处理

以下代码段是如何处理异常并切换按钮的Enabled属性以防止在后台执行期间出现多次点击的示例。

private async void Button_Click(object sender, EventArgs e)
{
    button.Enabled = false;

    try
    {
        var progress = new Progress<string>(s => button.Text = s);
        await Task.Run(() => SecondThreadConcern.FailingWork(progress));
        button.Text = "Completed";
    }
    catch(Exception exception)
    {
        button.Text = "Failed: " + exception.Message;
    }

    button.Enabled = true;
}

class SecondThreadConcern
{
    public static void FailingWork(IProgress<string> progress)
    {
        progress.Report("I will fail in...");
        Task.Delay(500).Wait();

        for (var i = 0; i < 3; i++)
        {
            progress.Report((3 - i).ToString());
            Task.Delay(500).Wait();
        }

        throw new Exception("Oops...");
    }
}

2
如果 SecondThreadConcern.LongWork() 抛出异常,能否被 UI 线程捕获?顺便说一句,这是一篇很棒的文章。 - kdbanman
2
我已经添加了一个额外的部分来满足您的要求。谢谢。 - Ryszard Dżegan
3
ExceptionDispatchInfo类 负责在异步等待模式下,在UI线程上重新抛出后台异常的奇迹。 - Ryszard Dżegan
1
根据YAGNI和KISS原则,您应该使用最简单和最易读的解决方案。如果“Invoke”满足您的需求并且您的代码库很小,那么您可以为了提高生产力和便利性而违反SoC,而不会有明显的损失。然后,当您的源代码变得更大且难以维护时,您可以通过重构来引入SoC(假设您有可靠的回归测试)。 - Ryszard Dżegan
4
创建一个Task并阻塞当前线程的目的是什么?在线程池线程上阻塞是不好的做法! - Yarik
显示剩余7条评论

274

1
在这个例子中,“control”是什么?我的UI控件吗?我正在尝试在标签控件上实现它,在我的标签中Invoke不是成员。 - Dbloom
像@styxriver在https://dev59.com/wnRB5IYBdhLWcg3wUVtd#3588137中提到的***扩展方法***是什么? - Kiquenet
在类或方法内部声明 'Action y;',并使用以下代码更新文本属性:'yourcontrol.Invoke(y=() => yourcontrol.Text = "new text");' - Antonio Leite
8
@Dbloom不是成员,因为它仅适用于WinForms。对于WPF,您需要使用Dispatcher.Invoke。 - slow
5
我遵循这个解决方案,但有时我的用户界面没有更新。我发现需要使用this.refresh()来强制使GUI无效并重新绘制它。如果有帮助的话。 - Rakibul Haq

154

针对 .NET 3.5+ 的一种“快速执行,不等待返回结果”的扩展方法

using System;
using System.Windows.Forms;

public static class ControlExtensions
{
    /// <summary>
    /// Executes the Action asynchronously on the UI thread, does not block execution on the calling thread.
    /// </summary>
    /// <param name="control"></param>
    /// <param name="code"></param>
    public static void UIThread(this Control @this, Action code)
    {
        if (@this.InvokeRequired)
        {
            @this.BeginInvoke(code);
        }
        else
        {
            code.Invoke();
        }
    }
}

您可以使用以下代码调用此函数:

this.UIThread(() => this.myLabel.Text = "Text Goes Here");

5
@this的用途是什么?“控制”不是等价的吗?使用@this有什么好处? - argyle
16
@this只是变量名,在这种情况下它引用当前调用扩展的控件的引用。你可以将其重命名为source或者其他任何名称。我使用@this,因为它指的是调用该扩展的"此控件"并且与在正常(非扩展)代码中使用关键字"this"是一致的(至少在我的头脑中)。 - StyxRiver
2
这是一个很棒、简单且对我来说最好的解决方案。你可以在UI线程中包含所有需要完成的工作。例如: this.UIThread(() => { txtMessage.Text = message; listBox1.Items.Add(message); }); - Auto
1
我真的很喜欢这个解决方案。小问题:我会将这个方法命名为OnUIThread而不是UIThread - ToolmakerSteve
5
这就是为什么我把这个扩展命名为“RunOnUiThread”。但那只是个人喜好。 - Grisgram
显示剩余2条评论

72
这是您应该采取的经典方式:
using System;
using System.Windows.Forms;
using System.Threading;

namespace Test
{
    public partial class UIThread : Form
    {
        Worker worker;

        Thread workerThread;

        public UIThread()
        {
            InitializeComponent();

            worker = new Worker();
            worker.ProgressChanged += new EventHandler<ProgressChangedArgs>(OnWorkerProgressChanged);
            workerThread = new Thread(new ThreadStart(worker.StartWork));
            workerThread.Start();
        }

        private void OnWorkerProgressChanged(object sender, ProgressChangedArgs e)
        {
            // Cross thread - so you don't get the cross-threading exception
            if (this.InvokeRequired)
            {
                this.BeginInvoke((MethodInvoker)delegate
                {
                    OnWorkerProgressChanged(sender, e);
                });
                return;
            }

            // Change control
            this.label1.Text = e.Progress;
        }
    }

    public class Worker
    {
        public event EventHandler<ProgressChangedArgs> ProgressChanged;

        protected void OnProgressChanged(ProgressChangedArgs e)
        {
            if(ProgressChanged!=null)
            {
                ProgressChanged(this,e);
            }
        }

        public void StartWork()
        {
            Thread.Sleep(100);
            OnProgressChanged(new ProgressChangedArgs("Progress Changed"));
            Thread.Sleep(100);
        }
    }


    public class ProgressChangedArgs : EventArgs
    {
        public string Progress {get;private set;}
        public ProgressChangedArgs(string progress)
        {
            Progress = progress;
        }
    }
}

你的工作线程有一个事件。你的UI线程启动另一个线程来完成工作,并连接那个工作者事件,以便你可以显示工作线程的状态。
然后在UI中,你需要跨线程改变实际控件...比如标签或进度条。

69
简单的解决方法是使用 Control.Invoke
void DoSomething()
{
    if (InvokeRequired) {
        Invoke(new MethodInvoker(updateGUI));
    } else {
        // Do Something
        updateGUI();
    }
}

void updateGUI() {
    // update gui here
}

做的好,很简单!不仅简单,而且运行良好!我真的不明白为什么微软不能像它应该的那样简单化!为了在主线程上调用1行代码,我们需要编写几个函数! - MBH
1
@MBH 同意。顺便问一下,你有没有注意到 https://dev59.com/wnRB5IYBdhLWcg3wUVtd#3588137 上面的答案,它定义了一个扩展方法?在自定义实用程序类中执行一次,然后就不必再关心微软没有为我们做这件事了 :) - ToolmakerSteve
@ToolmakerSteve,这正是我的意思!你说得对,我们可以找到一种方法,但我指的是从DRY(不要重复自己)的角度来看,具有共同解决方案的问题可以通过微软以最小的努力解决,这将为程序员节省大量时间 :) - MBH

51

编写线程代码经常会出现错误,而且很难进行测试。你不需要编写线程代码来从后台任务更新用户界面。只需要使用BackgroundWorker类来运行任务和它的ReportProgress方法来更新用户界面。通常只需报告完成百分比,但还有另一个包括状态对象的重载。下面是一个仅报告字符串对象的示例:

    private void button1_Click(object sender, EventArgs e)
    {
        backgroundWorker1.WorkerReportsProgress = true;
        backgroundWorker1.RunWorkerAsync();
    }

    private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
    {
        Thread.Sleep(5000);
        backgroundWorker1.ReportProgress(0, "A");
        Thread.Sleep(5000);
        backgroundWorker1.ReportProgress(0, "B");
        Thread.Sleep(5000);
        backgroundWorker1.ReportProgress(0, "C");
    }

    private void backgroundWorker1_ProgressChanged(
        object sender, 
        ProgressChangedEventArgs e)
    {
        label1.Text = e.UserState.ToString();
    }

如果您只想更新同一个字段,这样做是可以的。如果您需要进行更复杂的更新操作,您可以定义一个类来表示UI状态并将其传递给ReportProgress方法。

最后,请确保设置WorkerReportsProgress标志,否则ReportProgress方法将被完全忽略。


2
在处理结束时,也可以通过 backgroundWorker1_RunWorkerCompleted 更新用户界面。 - DavidRR

50

绝大多数答案使用 Control.Invoke,这是一个等待发生竞争条件的情况。例如,请考虑接受的答案:

string newText = "abc"; // running on worker thread
this.Invoke((MethodInvoker)delegate { 
    someLabel.Text = newText; // runs on UI thread
});

如果用户在调用this.Invoke之前关闭了表单(记住,thisForm对象),那么很可能会触发一个ObjectDisposedException异常。

解决方案是使用SynchronizationContext,特别是像hamilton.danielb建议的SynchronizationContext.Current(其他答案依赖于特定的SynchronizationContext实现,这是完全不必要的)。我会稍微修改他的代码来使用SynchronizationContext.Post而不是SynchronizationContext.Send(因为通常不需要让工作线程等待):

public partial class MyForm : Form
{
    private readonly SynchronizationContext _context;
    public MyForm()
    {
        _context = SynchronizationContext.Current
        ...
    }

    private MethodOnOtherThread()
    {
         ...
         _context.Post(status => someLabel.Text = newText,null);
    }
}
请注意,在.NET 4.0及以上版本中,您应该使用任务来执行异步操作。请参阅n-san的答案,了解等效的基于任务的方法(使用TaskScheduler.FromCurrentSynchronizationContext)。
最后,在.NET 4.5及以上版本中,您还可以使用Progress<T>(基本上在创建时捕获SynchronizationContext.Current),如Ryszard Dżegan's所演示的那样,用于需要同时运行UI代码和长时间运行操作的情况。

由于_context是Myform中的变量,如果它被处理掉了,你的解决方案也会失败,对吗? - AaA
@AaA 不会,因为释放 MyForm_context 没有任何影响,它仍然是 SynchronizationContext.Current(只要 WinForms 应用程序在运行,它始终有效)。 - Ohad Schneider
没错,但如果MyForm被释放了,那么someLabel也会被释放吗? - AaA
1
@AaA 我认为这里的假设是一个普通的WinForms应用程序,如果窗体被释放,那么它基本上意味着WinForms应用程序正在关闭,因此不会在消息循环中运行您的委托(否则同步上下文解决方案也不安全)。如果您的情况不同,我想您可以添加一个检查,例如 if (someLabel.IsDisposed) { someLabel.Text = newText }(安全,因为此时您正在UI线程上运行,具体地说是阻止消息泵释放标签)。 - Ohad Schneider
正确,我有一个应用程序,在每运行10k次中会出现异常日志,指示组件已被释放且不可访问(或类似的消息)。无论如何,应该检查IsDisposed属性,以便某些人在其日志中不需要那一个在10,000个消息中的内容,特别是当你的老板看到日志中的异常时会很疯狂! - AaA

43
您需要确保更新发生在正确的线程上,即UI线程。
为了做到这一点,您需要通过调用 Invoke 方法而不是直接调用事件处理程序来引发事件。
您可以像这样引发事件:
(代码是根据我脑海中的记忆输入的,因此我没有检查��法等是否正确,但应该可以让您开始工作。)
if( MyEvent != null )
{
   Delegate[] eventHandlers = MyEvent.GetInvocationList();

   foreach( Delegate d in eventHandlers )
   {
      // Check whether the target of the delegate implements 
      // ISynchronizeInvoke (Winforms controls do), and see
      // if a context-switch is required.
      ISynchronizeInvoke target = d.Target as ISynchronizeInvoke;

      if( target != null && target.InvokeRequired )
      {
         target.Invoke (d, ... );
      }
      else
      {
          d.DynamicInvoke ( ... );
      }
   }
}

请注意,上述代码无法在WPF项目上运行,因为WPF控件没有实现ISynchronizeInvoke接口。

为了确保上述代码适用于Windows Forms和WPF以及所有其他平台,您可以查看AsyncOperationAsyncOperationManagerSynchronizationContext类。

为了以这种方式轻松地触发事件,我创建了一个扩展方法,使我可以通过简单地调用来简化引发事件:

MyEvent.Raise(this, EventArgs.Empty);

当然,你也可以使用 BackGroundWorker 类,它将为你抽象这个问题。


确实如此,但我不喜欢用这个问题“混乱”我的GUI代码。我的GUI不应该在需要调用或不需要调用时关心它。换句话说:我认为执行上下文切换的责任不应该由GUI来承担。 - Frederik Gheysels
1
将委托拆分似乎有些过度设计——为什么不直接使用:SynchronizationContext.Current.Send(delegate { MyEvent(...); }, null); - Marc Gravell
你是否总是可以访问SynchronizationContext?即使你的类在一个类库中? - Frederik Gheysels

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