从工作线程更新UI控件时死锁

3
为了简化我所经历的奇怪行为,我有一个名为Log的简单类,每1000毫秒触发1个日志事件。
public static class Log
{
    public delegate void LogDel(string msg);
    public static event LogDel logEvent;

    public static void StartMessageGeneration ()
    {
        for (int i = 0; i < 1000; i++)
        {
            logEvent.Invoke(i.ToString());
            Task.Delay(1000);
        }
    }
}

我有以下的Form类,它订阅了Log类的日志事件,以便可以处理它们并在一个简单的文本框中显示。 一旦收到日志消息,它就会被添加到一个列表中。每500毫秒,一个计时器对象访问该列表,以便可以将其内容显示在一个文本框中。

public partial class Form1 : Form
{
    private SynchronizationContext context;
    private System.Threading.Timer guiTimer = null;
    private readonly object syncLock = new object();
    private List<string> listOfMessages = new List<string>();

    public Form1()
    {
        InitializeComponent();
        context = SynchronizationContext.Current;
        guiTimer = new System.Threading.Timer(TimerProcessor, this, 0, 500);
        Log.logEvent += Log_logEvent;
    }

    private void Log_logEvent(string msg)
    {
        lock (syncLock)
            listOfMessages.Add(msg);
    }

    private void TimerProcessor(object obj)
    {
        Form1 myForm = obj as Form1;
        lock (myForm.syncLock)
        {
            if (myForm.listOfMessages.Count == 0)
                return;

            myForm.context.Send(new SendOrPostCallback(delegate
            {
                foreach (string item in myForm.listOfMessages)
                    myForm.textBox1.AppendText(item + "\n");
            }), null);

            listOfMessages.Clear();
        }
    }

    private void button1_Click(object sender, EventArgs e)
    {
        Log.StartMessageGeneration();
    }
}

我看到的问题是有时会出现死锁(应用程序卡住)。似乎两个锁(第一个是添加到列表的锁,第二个是从列表中“检索”的锁)在某种程度上互相阻塞。
提示: 1)将发送消息的速率从1秒降低到200毫秒似乎有所帮助(不确定原因) 2)当返回到GUI线程(使用同步上下文)并访问GUI控件时,会发生某些事情。如果我不返回到GUI线程,则两个锁可以正常工作...
谢谢大家!
2个回答

4
你的代码存在一些问题和一些...愚蠢的事情。
首先,你的Log.StartMessageGeneration实际上并没有每秒钟产生一个日志消息,因为你没有等待Task.Delay返回的任务,你只是非常快地(且毫无意义地)创建了一千个定时器。日志生成仅受Invoke限制。如果你不想使用Task、await等,使用Thread.Sleep是一种阻塞替代方案。当然,这就是你最大的问题所在 - StartMessageGeneration与UI线程不是异步的!
其次,在表单上使用System.Threading.Timer没有什么意义。相反,只需使用Windows Forms计时器 - 它完全在UI线程上,因此无需将代码重新调度到UI线程。由于TimerProcessor不执行任何CPU工作,并且仅阻塞了很短的时间,因此它是更直接的解决方案。
如果你决定继续使用System.Threading.Timer,手动处理同步上下文是没有意义的 - 只需在表单上使用BeginInvoke;同样,传递表单作为方法的参数也没有意义,因为该方法不是静态的。this就是你的表单。你可以看到这一点,因为你在listOfMessages.Clear()中省略了myForm - 这两个实例是相同的,myForm是多余的。
简单的调试器暂停可以轻松告诉你程序挂起的位置 - 学会很好地使用调试器,它会节省你很多时间。但是让我们逻辑地看待这个问题。StartMessageGeneration在UI线程上运行,而System.Threading.Timer使用线程池线程。当计时器锁定syncLock时,StartMessageGeneration当然不能进入同一个锁 - 这没问题。但是然后你向UI线程发送,...UI线程无法做任何事情,因为它被StartMessageGeneration阻塞,后者从未给UI机会去做任何事情。而且StartMessageGeneration无法继续进行,因为它正在等待锁。唯一能“工作”的情况是当StartMessageGeneration运行得足够快以在计时器触发之前完成(从而释放UI线程执行其工作)-由于你错误地使用了Task.Delay,这是非常可能的。
现在我们来分析一下您的“提示信息”,结合我们所知道的情况。 1)是您在测量中的偏见。由于您从未以任何方式等待Task.Delay,因此更改间隔根本不起作用(如果延迟为零,则会略微变化)。2)当然-这就是您的死锁所在。两个代码片段依赖共享资源,同时它们都需要占有另一个资源。这是一个非常典型的死锁案例。线程1正在等待A释放B,而线程2正在等待B释放A(在这种情况下,A是syncLock,而B是UI线程)。当您删除Send(或将其替换为Post)时,线程1不再必须等待B,死锁消失。

还有其他简化编写此类代码的方法。例如,使用Action<string>,而不是声明自己的委托;使用await可以在处理混合UI /非UI代码以及管理任何类型的异步代码时提供相当大的帮助。您不需要在只需要简单函数的位置使用event - 如果有意义,您可以将该委托传递给需要它的函数,并且完全没有必要允许调用多个事件处理程序。如果您决定继续使用事件,请确保它符合EventHandler委托。

为了展示如何重写代码以使其更加现代化且实际可行:

void Main()
{
  Application.Run(new LogForm());
}

public static class Log
{
  public static async Task GenerateMessagesAsync(Action<string> logEvent, 
                                            CancellationToken cancel)
  {
    for (int i = 0; i < 1000; i++)
    {
      cancel.ThrowIfCancellationRequested();

      logEvent(i.ToString());

      await Task.Delay(1000, cancel);
    }
  }
}

public partial class LogForm : Form
{
  private readonly List<string> messages;
  private readonly Button btnStart;
  private readonly Button btnStop;
  private readonly TextBox tbxLog;
  private readonly System.Windows.Forms.Timer timer;

  public LogForm()
  {
    messages = new List<string>();

    btnStart = new Button { Text = "Start" };
    btnStart.Click += btnStart_Click;
    Controls.Add(btnStart);

    btnStop = 
      new Button { Text = "Stop", Location = new Point(80, 0), Enabled = false };
    Controls.Add(btnStop);

    tbxLog = new TextBox { Height = 200, Multiline = true, Dock = DockStyle.Bottom };
    Controls.Add(tbxLog);

    timer = new System.Windows.Forms.Timer { Interval = 500 };
    timer.Tick += TimerProcessor;
    timer.Start();
  }

  private void TimerProcessor(object sender, EventArgs e)
  {
    foreach (var message in messages)
    {
      tbxLog.AppendText(message + Environment.NewLine);
    }

    messages.Clear();
  }

  private async void btnStart_Click(object sender, EventArgs e)
  {
    btnStart.Enabled = false;
    var cts = new CancellationTokenSource();
    EventHandler stopAction = (_, __) => cts.Cancel();
    btnStop.Click += stopAction;
    btnStop.Enabled = true;

    try
    {
      await Log.GenerateMessagesAsync(message => messages.Add(message), cts.Token);
    }
    catch (TaskCanceledException)
    {
      messages.Add("Cancelled.");
    }
    finally
    {
      btnStart.Enabled = true;
      btnStop.Click -= stopAction;
      btnStop.Enabled = false;
    }
  }

  protected override void Dispose(bool disposing)
  {
    if (disposing)
    {
      timer.Dispose();
      btnStart.Dispose();
      btnStop.Dispose();
      tbxLog.Dispose();
    }

    base.Dispose(disposing);
  }
}

谢谢您详细的回答!我已经修改了代码,现在它可以工作了!干杯! - LorenB

3

SynchronizationContext.Send是同步运行的。当你调用它时,实际上会阻塞UI线程,直到操作完成。但如果UI线程已经处于lock状态,那么你就会陷入死锁。

你可以使用SynchronizationContext.Post来避免这种情况。

我只是回答了你的问题,但事实是你的代码需要进行“小”重构。


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