Почему Thread.Sleep ведёт себя неправильно? Как мне сделать задержку или длинные вычисления в графической программе?

1,00
р.
Мне нужно выводить информацию пользователю с задержкой. К примеру, менять содержимое текстовой метки каждую секунду. (Или выводить промежуточные результаты длинных вычислений.) В программах командной строки я делал так:
Console.WriteLine("значение 1") Thread.Sleep(1000) Console.WriteLine("значение 2") Thread.Sleep(1000) Console.WriteLine("значение 3")
Это работало. Теперь мне нужно сделать то же самое в графической программе. Я написал метод
void OnClick(object sender, EventArgs args) { label.Text = "значение 1" Thread.Sleep(1000) label.Text = "значение 2" Thread.Sleep(1000) label.Text = "значение 3" }
но он работает как-то не так. Промежуточные значения не показываются, а программа надолго перестаёт реагировать. А когда она отвисает, сразу показывает последнее значение.
Что происходит? Почему программа ведёт себя неправильно, и как же сделать правильно?

Ответ
Графические программы отличаются от консольных тем, что в них главный поток занимается многими вещами. В консольной программе у нас есть полный контроль, мы полностью управляем её пробегом. В графических программах мы запускаем приложение, и фреймворк для нас создаёт цикл сообщений. В этом цикле фреймворк обрабатывает передвижение мыши, нажатия на клавиши, изменения размеров окна, колбеки от таймера и тому подобные штуки, а также вызывает наши обработчики событий, по одному на итерацию цикла (окей, это упрощённая картина, но для целей изложения подойдёт). После отработки итерации цикла выполнение переходит к следующей итерации.
Всё это работает в одном и том же потоке, который называется UI-потоком.
Теперь, что происходит, если мы в UI-потоке выполняем Thread.Sleep(1000)? А вот что: поток блокируется и ничего не делает целую секунду. Эту самую секунду наш цикл сообщений простаивает, потому что поток выполнения заблокирован нами! Эту секунду не обрабатываются оконные сообщения, не происходит реакция на мышь, не вызываются колбеки, и даже не перерисовывается содержимое окна — ведь всё это делается в том же самом цикле сообщений, который мы и заблокировали!
Чтобы программа работала нормально, наши обработчики событий (наподобие OnClick), конструкторы объектов, и вообще весь код, бегущий в UI-потоке, должны пробегать максимально быстро, без задержек.
Как же сделать паузу в одну секунду? К счастью, в современной версии языка (начиная с C# 5) есть простое решение. Это async/await. Сделаем наш обработчик асинхронным (ключевое слово async), и заменим Thread.Sleep на await Task.Delay:
async void OnClick(object sender, EventArgs args) { label.Text = "значение 1" await Task.Delay(1000) label.Text = "значение 2" await Task.Delay(1000) label.Text = "значение 3" }
Этот метод работает правильно!¹
Что же произошло? Дело в том, что await Task.Delay на время ожидания не блокирует поток. На время ожидания метод как бы прекращает своё выполнение, и цикл сообщений больше не блокируется. [Будьте внимательны, он может быть заблокирован ещё где-то.] Когда ожидание оканчивается, цикл сообщений возобновляет выполнение метода с прерванной точки, до следующего await или до конца метода.²
Таким образом, наш код больше не блокирует UI-поток, и фреймворк может и дальше отрисовывать окно и заниматься прочими служебными заданиями.

А что делать, если вместо задержки нужно выполнить какие-то вычисления? Их так просто не вырезать из хода выполнения функции, они всё равно должны быть выполнены. Для этих целей их можно выгрузить в другой поток. Не пугайтесь, это очень просто. Вместо кода
label.Text = "парсим большой файл" size = ParseBigFile() label.Text = "закончили, результат = " + size
вы пишете вот что:
label.Text = "парсим большой файл" size = await Task.Run(() => ParseBigFile()) label.Text = "закончили, результат = " + size
Task.Run выполняет ваш код в фоновом потоке, а на время этого выполнения функция опять-таки не блокирует UI-поток.³ Профит! Обратите только внимание на то, что из фонового потока нельзя считывать значения из контролов, поэтому их нужно считать заранее:
Было:
label.Text = "парсим большой файл" size = ParseBigFileFromPath(textbox.Text) label.Text = "закончили, результат = " + size
Стало:
label.Text = "парсим большой файл" string path = textbox.Text // читаем из контрола в UI-потоке size = await Task.Run(() => ParseBigFileFromPath(path)) // обращается к переменной label.Text = "закончили, результат = " + size

В более старых версиях языка, без async/await, приходилось достигать того же самого более сложным образом. Например, заводить таймер, подписываться на его тики, и на них менять значения в контролах. При этом локальные переменные приходилось выносить в поля класса (или в специальную структуру-контекст). Или можно было делать грязные трюки с DoEvents. К счастью, те старые недобрые времена давно прошли.

Связанные вопросы:
Использование метода sleep() Работа с многопоточностью в c# Работа с Thread.Sleep() Thread blocks window wpf

¹ Но для остальных асинхронных методов, не обработчиков событий, нам нужно возвращать не void, а Task или какой-нибудь Task, чтобы вызывающий код мог дождаться их окончания и получить результат.
² Изложение грешит упрощениями, так что не принимайте его за истину в последней инстанции. Это примерная картина, а если вы хотите знать точную, лучше всего почитать книги или документацию. Или задать вопрос, если что-то ведёт себя непонятно.
³ Если нужно делать большую и длительную работу в фоновом потоке, то, возможно, имеет смысл выгрузить эту работу целиком и сообщать о результатах в UI через Progress.