1

I am trying to read a large text file into a TextBox and keep the ui responsive when a file is dragged to the textbox.

Not works as expected, the windows forms is frozen and only seems working the task reading the file and appending the content to the textbox.

A ContextSwitchDeadLock was thrown by the IDE, but not really an error. This is a long running task. I have fixed it changing the behaviour under the exception menu.

Thanks to JSteward, Peter changed the code to this.

How I can keep the ui ( main thread) responsive when running this task ? Thanks.

private SynchronizationContext fcontext;

public Form1()
{      
    InitializeComponent();            
    values.DragDrop += values_DragDrop; //<----------- This is a textbox
    fcontext = WindowsFormsSynchronizationContext.Current;
}

// The async callback 
async void values_DragDrop(object sender, DragEventArgs e)
{
    try
    {
        string dropped = ( (string[]) e.Data.GetData(DataFormats.FileDrop))[0];
        if ( dropped.Contains(".csv") || dropped.Contains(".txt"))
        {
                using ( StreamReader sr = File.OpenText(dropped) )
                {
                    string s = String.Empty;
                    while ( ( s = await sr.ReadLineAsync() ) != null )
                    {                                                                
                       values.AppendText(s.Replace(";",""));
                    }
                }                 
         }
     }
  catch (Exception ex) { }
}
ppk
  • 568
  • 1
  • 7
  • 20
  • 2
    You could just `...AppendText(await ...ReadLineAsync)` instead of using the blocking variant of the `File` api. That way you wouldn't need to store or post to a context manually. – JSteward Dec 20 '17 at 20:35
  • @JSteward Tried values.AppendText(await sr.ReadLineAsync()) but await only can be applied to a lamba expression. – ppk Dec 20 '17 at 20:48
  • 2
    I think you mean it can only be applied to an **`async`** lamda expression. Could you update the code in the question? – JSteward Dec 20 '17 at 20:50
  • @JSteward Thanks for the async lambda tip. But now really I don`t know what is the problem. – ppk Dec 20 '17 at 21:01
  • 2
    You won't be able to `AppendText` from the `Task.Run` default context. Using the `async` api you shouldn't need the `Task.Run` but understand that your drag drop is likely going to end before you finish reading the file. Also, you may need to load the file in batches to keep the ui responsive and let the `Forms` loop take its turn. – JSteward Dec 20 '17 at 21:09
  • @JSteward The trouble seems to be the loading function. It is a long task and the ui thread is blocking or unable to process the async messages. This is what I want to know and fix. – ppk Dec 20 '17 at 21:16
  • 3
    _"The trouble seems to be the loading function"_ -- what function do you consider to be _"the loading function"_? @JSteward has already explained that you don't need `Task.Run()`, because you are reading using async/await. It is impossible to actually _answer_ your question, because you haven't provided a good [mcve] that reproduces the issue. But, most likely you are reading too little data at a time, saturating the UI thread with update requests, which is just as bad as blocking it. Try refactoring the code so that updates to the UI occur only every 100-500 ms or so. – Peter Duniho Dec 20 '17 at 21:46
  • @PeterDuniho Maybe I misunderstood JSteward.Thanks. But I think the question is complete and verificable. Run it with a large text file (100M is my test file) and without disable the ContextSwitchDeadLock exception. However you are right and I am finding a way to update the ui fewer times. Because the idea is running long running tasks and keeping the ui responsive. – ppk Dec 20 '17 at 22:04
  • @ppk - Do you really code like `catch (Exception ex) { }`?? – Enigmativity Dec 20 '17 at 22:24
  • @Enigmativity This is an example. I usually log all exceptions to file. – ppk Dec 20 '17 at 22:26
  • 2
    _"I think the question is complete and verificable"_ -- if you were the person answering the question, then what you think might be relevant. But, you're not and it isn't. Please read [mcve]. Also, read [ask], along with all of the articles linked at the bottom of that page, so that you understand what is actually meant by [mcve], and what is needed to present your question in a clear, answerable way. – Peter Duniho Dec 20 '17 at 22:27
  • 1
    As far as your recent edit: first, edit the question to _improve_ it, not to _change_ it. Second, your new version of the code, while it does remove the superfluous `Task.Run()`, does not remove the calls to `Post()` (now no longer needed, because the `AddToValues()` method is now called in the UI thread), and those calls are almost certainly at least part of, if not the main problem (they are what are saturating the UI thread). – Peter Duniho Dec 20 '17 at 22:29
  • Trying to do this async makes the perf significantly worse. What is expensive is the stuff you can't do async, adding text and the TextBox control itself having to constantly reallocate memory, wrap the lines, recalculate the scrollbar thumb position, repaint the control surface. Doing it non-async is actually better, textBox1.Text = File.ReadAllText(path); On a 100 MB text file that takes 3.0 seconds on my pokey laptop when the textbox is empty, 1.1 second when it already has sufficient buffer space allocated. With the WordWrap property set to False. – Hans Passant Dec 20 '17 at 22:36
  • 1
    Good enough. If you need more volumes of War and Peace in the control then you ought to consider shopping for a better text editor. Scintilla.NET might be able to do a better job. – Hans Passant Dec 20 '17 at 22:40
  • @ppk - But you log all exceptions with a global exception logger or do you put them in each method like your sample code? – Enigmativity Dec 20 '17 at 23:01
  • @Enigmativity I usually use a global exception logger. Thanks for the Rx approach. – ppk Dec 20 '17 at 23:21

3 Answers3

2

If you need to keep the UI responsive, just give it the time to breath.
Reading one line of text is so fast that you are (a)waiting almost nothing, while updating the UI takes longer. Inserting even a very little delay lets the UI update.

Using Async/Await (SynchronizationContext is captured by await)

public Form1()
{
   InitializeComponent();
   values.DragDrop += new DragEventHandler(this.OnDrop);
   values.DragEnter += new DragEventHandler(this.OnDragEnter);
}

public async void OnDrop(object sender, DragEventArgs e)
{
   string dropped = ((string[])e.Data.GetData(DataFormats.FileDrop))[0];
   if (dropped.Contains(".csv") || dropped.Contains(".txt")) {
      try {
         string line = string.Empty;
         using (var reader = new StreamReader(dropped)) {
            while (reader.Peek() >= 0) {
               line = await reader.ReadLineAsync();
               values.AppendText(line.Replace(";", " ") + "\r\n");
               await Task.Delay(10);
            }
         }
      }
      catch (Exception) {
         //Do something here
      }
   }
}

private void OnDragEnter(object sender, DragEventArgs e)
{
   e.Effect = e.Data.GetDataPresent(DataFormats.FileDrop, false) 
            ? DragDropEffects.Copy 
            : DragDropEffects.None;
}

TPL using Task.Factory
TPL executes Tasks through a TaskScheduler.
A TaskScheduler may be used to queue tasks to a SynchronizationContext.

TaskScheduler _Scheduler = TaskScheduler.FromCurrentSynchronizationContext();

//No async here
public void OnDrop(object sender, DragEventArgs e)
{
   string dropped = ((string[])e.Data.GetData(DataFormats.FileDrop))[0];
   if (dropped.Contains(".csv") || dropped.Contains(".txt")) {
      Task.Factory.StartNew(() => {
         string line = string.Empty;
         int x = 0;
         try {
            using (var reader = new StreamReader(dropped)) {
               while (reader.Peek() >= 0) {
                  line += (reader.ReadLine().Replace(";", " ")) + "\r\n";
                  ++x;
                  //Update the UI after reading 20 lines
                  if (x >= 20) {
                     //Update the UI or report progress 
                     Task UpdateUI = Task.Factory.StartNew(() => {
                        try {
                           values.AppendText(line);
                        }
                        catch (Exception) {
                           //An exception is raised if the form is closed
                        }
                     }, CancellationToken.None, TaskCreationOptions.PreferFairness, _Scheduler);
                     UpdateUI.Wait();
                     x = 0;
                  }
               }
            }
         }
         catch (Exception) {
            //Do something here
         }
      });
   }
}
Jimi
  • 29,621
  • 8
  • 43
  • 61
2

Sometimes it is indeed required to do some asynchronous, background operation on the UI thread (e.g., syntax highlighting, spellcheck-as-you-type, etc). I am not going to question the design issues with your particular (IMO, contrived) example - most likely you should be using the MVVM pattern here - but you can certainly keep the UI thread responsive.

You can do that by sensing for any pending user input and yielding to the main message loop, to give it the processing priority. Here's a complete, cut-paste-and-run example of how to do that in WinForms, based on the task you're trying to solve. Note await InputYield(token) which does just that:

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WinFormsYield
{
    static class Program
    {
        // a long-running operation on the UI thread
        private static async Task LongRunningTaskAsync(Action<string> deliverText, CancellationToken token)
        {
            for (int i = 0; i < 10000; i++)
            {
                token.ThrowIfCancellationRequested();
                await InputYield(token);
                deliverText(await ReadLineAsync(token));
            }
        }

        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            // create some UI

            var form = new Form { Text = "Test", Width = 800, Height = 600 };

            var panel = new FlowLayoutPanel
            {
                Dock = DockStyle.Fill,
                FlowDirection = FlowDirection.TopDown,
                WrapContents = true
            };

            form.Controls.Add(panel);
            var button = new Button { Text = "Start", AutoSize = true };
            panel.Controls.Add(button);

            var inputBox = new TextBox
            {
                Text = "You still can type here while we're loading the file",
                Width = 640
            };
            panel.Controls.Add(inputBox);

            var textBox = new TextBox
            {
                Width = 640,
                Height = 480,
                Multiline = true,
                ReadOnly = false,
                AcceptsReturn = true,
                ScrollBars = ScrollBars.Vertical
            };
            panel.Controls.Add(textBox);

            // handle Button click to "load" some text

            button.Click += async delegate
            {
                button.Enabled = false;
                textBox.Enabled = false;
                inputBox.Focus();
                try
                {
                    await LongRunningTaskAsync(text =>
                        textBox.AppendText(text + Environment.NewLine),
                        CancellationToken.None);
                }
                catch (Exception ex)
                {
                    MessageBox.Show(ex.Message);
                }
                finally
                {
                    button.Enabled = true;
                    textBox.Enabled = true;
                }
            };

            Application.Run(form);
        }

        // simulate TextReader.ReadLineAsync
        private static async Task<string> ReadLineAsync(CancellationToken token)
        {
            return await Task.Run(() =>
            {
                Thread.Sleep(10); // simulate some CPU-bound work
                return "Line " + Environment.TickCount;
            }, token);
        }

        //
        // helpers
        //

        private static async Task TimerYield(int delay, CancellationToken token)
        {
            // yield to the message loop via a low-priority WM_TIMER message (used by System.Windows.Forms.Timer)
            // https://web.archive.org/web/20130627005845/http://support.microsoft.com/kb/96006 

            var tcs = new TaskCompletionSource<bool>();
            using (var timer = new System.Windows.Forms.Timer())
            using (token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false))
            {
                timer.Interval = delay;
                timer.Tick += (s, e) => tcs.TrySetResult(true);
                timer.Enabled = true;
                await tcs.Task;
                timer.Enabled = false;
            }
        }

        private static async Task InputYield(CancellationToken token)
        {
            while (AnyInputMessage())
            {
                await TimerYield((int)NativeMethods.USER_TIMER_MINIMUM, token);
            }
        }

        private static bool AnyInputMessage()
        {
            var status = NativeMethods.GetQueueStatus(NativeMethods.QS_INPUT | NativeMethods.QS_POSTMESSAGE);
            // the high-order word of the return value indicates the types of messages currently in the queue. 
            return status >> 16 != 0;
        }

        private static class NativeMethods
        {
            public const uint USER_TIMER_MINIMUM = 0x0000000A;
            public const uint QS_KEY = 0x0001;
            public const uint QS_MOUSEMOVE = 0x0002;
            public const uint QS_MOUSEBUTTON = 0x0004;
            public const uint QS_POSTMESSAGE = 0x0008;
            public const uint QS_TIMER = 0x0010;
            public const uint QS_PAINT = 0x0020;
            public const uint QS_SENDMESSAGE = 0x0040;
            public const uint QS_HOTKEY = 0x0080;
            public const uint QS_ALLPOSTMESSAGE = 0x0100;
            public const uint QS_RAWINPUT = 0x0400;

            public const uint QS_MOUSE = (QS_MOUSEMOVE | QS_MOUSEBUTTON);
            public const uint QS_INPUT = (QS_MOUSE | QS_KEY | QS_RAWINPUT);

            [DllImport("user32.dll")]
            public static extern uint GetQueueStatus(uint flags);
        }
    }
}

Now you should ask yourself what you're going to do if user modifies the content of the editor while it's still being populated with text on the background. Here for simplicity I just disable the button and the editor itself (the rest of the UI is accessible and responsive), but the question remains open. Also, you should look at implementing some cancellation logic, which I leave outside the scope of this sample.

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • Nice! But I have a question. Why you use Thread.Sleep instead Task.Delay ? – ppk Dec 22 '17 at 12:59
  • @ppk, the `ReadLineAsync` implementation here is just a mockup for testing. I use `Thread.Sleep` inside a `Task.Run` lambda on purpose here, to denote some CPU-bound work of generating a line of text, which is offloaded to a pool thread. Other than for testing, you'd almost never need `Thread.Sleep` in a production code, indeed. – noseratio Dec 22 '17 at 22:02
  • 2
    Thanks for the additional explanation. – ppk Dec 24 '17 at 12:04
1

Perhaps use Microsoft's Reactive Framework for this. Here's the code you need:

using System.Reactive.Concurrency;
using System.Reactive.Linq;

namespace YourNamespace
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            IDisposable subscription =
                Observable
                    .FromEventPattern<DragEventHandler, DragEventArgs>(h => values.DragDrop += h, h => values.DragDrop -= h)
                    .Select(ep => ((string[])ep.EventArgs.Data.GetData(DataFormats.FileDrop))[0])
                    .ObserveOn(Scheduler.Default)
                    .Where(dropped => dropped.Contains(".csv") || dropped.Contains(".txt"))
                    .SelectMany(dropped => System.IO.File.ReadLines(dropped))
                    .ObserveOn(this)
                    .Subscribe(line => values.AppendText(line + Environment.NewLine));
        }
    }
}

Should you want to clear the text box before adding values then replace the .SelectMany with this:

.SelectMany(dropped => { values.Text = ""; return System.IO.File.ReadLines(dropped); })

NuGet "System.Reactive" & "System.Reactive.Windows.Forms" to get the bits.

When closing your form just do a subscription.Dispose() to remove the event handler.

Enigmativity
  • 113,464
  • 11
  • 89
  • 172