简体   繁体   中英

Deadlock when updating a UI control from a worker thread

To simplify the explanation of the strange behavior I am experiencing, I have this simple class named Log which fires 1 log events every 1000msec.

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);
        }
    }
}

I have the Form class below which is subscribed to the log events of the Log class so it can handle them and display in a simple text box. Once a log message arrives, it is added to a list. Every 500msec, a timer object access that list so its content can be displayed in a text box.

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();
    }
}

The problem I see is that sometimes, there is a dead lock (application stuck). Seems that the 2 locks (1st one for adding to the list and the 2nd one for "retrieving" from the list) are somehow blocking each others.

Hints: 1) reducing the rate of sending the messages from 1 sec to 200msec seems to help (not sure why) 2) Somehow something happens when returning to the GUI thread (using the synchronization context) and accessing the GUI control. If I don't return to the GUI thread, the 2 locks are working fine together...

Thanks everyone!

There's a few problems with your code, and a few... silly things.

First, your Log.StartMessageGeneration doesn't actually produce a log message every second, because you're not await ing the task returned by Task.Delay - you're basically just creating a thousand timers very quickly (and pointlessly). The log generation is limited only by the Invoke . Using Thread.Sleep is a blocking alternative to Task.Delay if you don't want to use Task s, await etc. Of course, therein lies your biggest problem - StartMessageGeneration is not asynchronous with respect to the UI thread!

Second, there's little point in using System.Threading.Timer on your form. Instead, just use the windows forms timer - it's entirely on the UI thread so there's no need for marshalling your code back to the UI thread. Since your TimerProcessor doesn't do any CPU work and it only blocks for a very short time, it's the more straight-forward solution.

If you decide to keep using System.Threading.Timer anyway, there's no point in manually dealing with synchronization contexts - just use BeginInvoke on the form; the same way, there's no point in passing the form as an argument to the method, since the method isn't static. this is your form. You can actually see this is the case since you omitted myForm in listOfMessages.Clear() - the two instances are the same, myForm is superfluous.

A simple pause in the debugger will easily tell you where the program is hung - learn to use the debugger well, and it will save you a lot of time. But let's just look at this logically. StartMessageGeneration runs on the UI thread, while System.Threading.Timer uses a thread-pool thread. When the timer locks syncLock , StartMessageGeneration can't enter the same lock, of course - that's fine. But then you Send to the UI thread, and... the UI thread can't do anything, since it's blocked by StartMessageGeneration , which never gives the UI an opportunity to do anything. And StartMessageGeneration can't proceed, because it's waiting on the lock. The only case where this "works" is when StartMessageGeneration runs fast enough to complete before your timer fires (thus freeing the UI thread to do its work) - which is very much possible due to your incorrect use of Task.Delay .

Now let's look on your "hints" with all we know. 1) is simply your bias in measurements. Since you never wait on the Task.Delay in any way, changing the interval does absolutely nothing (with a tiny change in case the delay is zero). 2) of course - that's where your deadlock is. Two pieces of code that depend on a shared resource, while they both require to take posession of another resource. It's a very typical case of a deadlock. Thread 1 is waiting for A to release B, and thread 2 is waiting for B to release A (in this case, A being syncLock and B being the UI thread). When you remove the Send (or replace it with Post ), thread 1 no longer has to wait on B, and the deadlock disappears.

There's other things that make writing code like this simpler. There's little point in declaring your own delegate when you can just use Action<string> , for example; using await helps quite a bit when dealing with mixed UI/non-UI code, as well as managing any kind of asynchronous code. You don't need to use event where a simple function will suffice - you can just pass that delegate to a function that needs it if that makes sense, and it may make perfect sense not to allow multiple event handlers to be called. If you decide to keep with the event, at least make sure it conforms to the EventHandler delegate.

To show how your code can be rewritten to be a bit more up-to-date and actually work:

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);
  }
}

SynchronizationContext.Send is run synchronously. When you call it, you actually block the UI thread until the operation is complete. But if UI thread is already in lock state, then it just make sense that you are in deadlock.

You can use SynchronizationContext.Post to avoid this.

I just answer on your question, but the truth is that your code need a "little" refactoring..

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM