Подтвердить что ты не робот

Почему явное управление потоками плохое?

В предыдущем вопросе я сделал немного фальшивый па. Видите ли, я читал о потоках и создавал впечатление, что они были самыми вкусными вещами с киви-иелло.

Представьте себе мое замешательство, когда я читаю такие вещи:

[T] hreads - очень плохая вещь. Или, по крайней мере, явное управление потоками - это плохо.

и

Обновление пользовательского интерфейса по потокам обычно является признаком того, что вы злоупотребляете потоками.

Так как я убиваю щенка каждый раз, когда что-то меня смущает, подумайте, что это ваш шанс вернуть вашу карму в черный цвет...

Как мне использовать поток?

4b9b3361

Ответ 1

Enthusiam для изучения пронизывания отлично; не поймите меня неправильно. Энтузиазм по использованию большого количества потоков, напротив, является симптомом того, что я называю "Болезнь счастья".

Разработчики, которые только что узнали о силе потоков, начинают задавать такие вопросы, как "сколько потоков можно создать в одной программе?" Это скорее похоже на английского майора, спрашивающего "сколько слов я могу использовать в предложении?" Типичным советом для писателей является то, чтобы ваши предложения были короткими и точными, вместо того, чтобы пытаться втиснуть столько слов и идей в одно предложение, насколько это возможно. Темы одинаковы; правильный вопрос - это не "сколько я могу сойти с создания?" а скорее "как я могу написать эту программу, чтобы количество потоков было минимальным, необходимым для выполнения задания?"

Темы решают множество проблем, это правда, но они также представляют огромные проблемы:

  • Анализ эффективности многопоточных программ часто чрезвычайно сложный и глубоко противоречивый. Я видел примеры реального мира в сильно многопоточных программах, в которых выполнение функции быстрее без замедления любой другой функции или использования большего объема памяти уменьшает общую пропускную способность системы. Зачем? Потому что потоки часто похожи на улицы в центре города. Представьте себе каждую улицу и волшебство, чтобы она была короче, без повторного выбора светофора. Будут ли дорожные пробки лучше или хуже? Написание более быстрых функций в многопоточных программах быстрее приводит к перегрузкам процессоров.

Вы хотите, чтобы потоки были похожи на межгосударственные магистрали: никаких светофоров, сильно параллельных, пересекающихся в небольшом количестве очень четко определенных, тщательно спроектированных точек. Это очень сложно сделать. Наиболее сильно многопоточные программы больше похожи на плотные городские ядра со стоп-кадрами во всем мире.

  • Написание собственного пользовательского управления потоками безумно сложно. Причина в том, что, когда вы пишете регулярную однопоточную программу в хорошо продуманной программе, количество "глобального состояния", о котором вы должны рассуждать, обычно невелико. В идеале вы пишете объекты, которые имеют четко определенные границы, и которые не заботятся о потоке управления, который вызывает их членов. Вы хотите вызвать объект в цикле, или переключатель или что-то еще, вы идете прямо вперед.

Многопоточные программы с пользовательским управлением потоками требуют глобального понимания всего, что поток будет делать, что может повлиять на данные, которые видны из другого потока. Вы в значительной степени должны иметь всю программу в своей голове и понимать все возможные способы взаимодействия двух потоков, чтобы получить правильное решение и предотвратить взаимные блокировки или повреждение данных. Это большая плата за оплату и сильно подвержена ошибкам.

  • По сути, потоки делают ваши методы ложными. Позвольте привести пример. Предположим, что у вас есть:

    if (! queue.IsEmpty) queue.RemoveWorkItem().Execute();

Правильно ли этот код? Если это однопоточный, возможно. Если он многопоточен, то что останавливает другой поток от удаления последнего оставшегося элемента после выполнения вызова IsEmpty? Ничего, это что. Этот код, который выглядит просто отлично, - это бомба, ожидающая выхода в многопоточной программе. В основном этот код на самом деле:

 if (queue.WasNotEmptyAtSomePointInThePast) ...

который, очевидно, довольно бесполезен.

Итак, предположим, что вы решили устранить проблему, заблокировав очередь. Правильно ли это?

lock(queue) {if (!queue.IsEmpty) queue.RemoveWorkItem().Execute(); }

Это тоже не правильно. Предположим, что выполнение вызывает запуск кода, который ждет ресурса, который в настоящий момент заблокирован другим потоком, но этот поток ожидает блокировки для очереди - что происходит? Оба потока ждут вечно. Включение блокировки в кусок кода требует, чтобы вы знали все, что мог бы сделать код с любым общим ресурсом, чтобы вы могли решить, будут ли какие-либо взаимоблокировки. Опять же, это чрезвычайно тяжелое бремя, чтобы надеть кого-то написание того, что должно быть очень простым кодом. (Правильная вещь, которую можно сделать здесь, вероятно, состоит в том, чтобы извлечь рабочий элемент в блокировку, а затем выполнить его за пределами блокировки. Но... что, если элементы находятся в очереди, потому что они должны выполняться в определенном порядке? код неверен, потому что другие потоки могут затем выполнить более поздние задания.)

  • Все ухудшается. Спецификация языка С# гарантирует, что однопоточная программа будет иметь наблюдаемое поведение, точно такое же, как указано в программе. То есть, если у вас есть что-то вроде "if (M (ref x)) b = 10; то вы знаете, что сгенерированный код будет вести себя так, как будто x получает доступ к M до того, как будет записано b. Теперь, компилятор, джиттер и процессор могут свободно оптимизировать это. Если один из них может определить, что M будет истинным, и если мы знаем, что в этом потоке значение b не читается после вызова M, тогда b может быть назначено до того, как будет получен доступ к x. Все, что гарантировано, заключается в том, что однопоточная программа работает так, как она была написана.

Многопоточные программы не обеспечивают эту гарантию. Если вы исследуете b и x в другом потоке, пока этот выполняется, вы можете увидеть b изменение до того, как x будет доступен, если эта оптимизация будет выполнена. Считывание и запись могут логически перемещаться вперед и назад во времени по отношению друг к другу в однопоточных программах, и эти перемещения можно наблюдать в многопоточных программах.

Это означает, что для написания многопоточных программ, где есть зависимость в логике от вещей, которые наблюдаются, происходит в том же порядке, что и код, написанный на самом деле, вы должны иметь подробное понимание "памяти" модель "языка и времени выполнения. Вы должны точно знать, какие гарантии сделаны в отношении того, как доступ может перемещаться во времени. И вы не можете просто протестировать свою коробку x86 и надеяться на лучшее; чипы x86 имеют довольно консервативную оптимизацию по сравнению с некоторыми другими чипами там.

Это всего лишь краткий обзор нескольких проблем, с которыми вы столкнулись при написании своей многопоточной логики. Есть еще много. Итак, некоторые советы:

  • Узнайте о потоковой передаче.
  • Не пытайтесь писать собственное управление потоками в производственном коде.
  • Используйте библиотеки более высокого уровня, написанные экспертами для решения проблем с потоками. Если у вас есть куча работы, которая должна выполняться в фоновом режиме, и вы хотите обработать ее для рабочих потоков, используйте пул потоков, а не создавайте собственную логику создания потоков. Если у вас есть проблема, подходящая для решения несколькими процессорами одновременно, используйте параллельную библиотеку задач. Если вы хотите лениво инициализировать ресурс, используйте ленивый класс инициализации, а не пытайтесь самостоятельно писать код блокировки.
  • Избегайте совместного использования.
  • Если вы не можете избежать совместного использования, обменивайте неизменяемое состояние.
  • Если вам нужно разделить изменчивое состояние, предпочитайте использовать блокировки для методов блокировки.

Ответ 2

Явное управление потоками не является, по сути, плохим, но оно связано с опасностями и не должно быть сделано, если это абсолютно необходимо.

Говорить нити - это очень хорошо, так как говорить, что пропеллер - это очень хорошо: пропеллеры отлично работают на самолетах (когда реактивные двигатели не являются лучшей альтернативой), но не было бы хорошей идеей на автомобиле.

Ответ 3

Вы не можете оценить, какие проблемы может возникнуть при потоковой передаче, если вы не отлаживаете трехсторонний тупик. Или провел месяц, преследуя состояние гонки, которое происходит только один раз в день. Итак, идите вперед и прыгайте обеими ногами и делайте всевозможные ошибки, которые вам нужно сделать, чтобы научиться бояться зверя и что делать, чтобы избежать неприятностей.

Ответ 4

Я не мог предложить лучшего ответа, чем то, что уже здесь. Но я могу предложить конкретный пример некоторого многопоточного кода, который у нас был на моей работе, который был катастрофическим.

Один из моих коллег, как и вы, с энтузиазмом относился к нитям, когда впервые узнал о них. Итак, во всей программе появился такой код:

Thread t = new Thread(LongRunningMethod);
t.Start(GetThreadParameters());

В основном, он создавал потоки повсюду.

Итак, в конце концов, другой сотрудник обнаружил это и сказал ответственному разработчику: не делайте этого! Создание потоков дорого, вы должны использовать пул потоков и т.д. и т.д. Так много мест в код, который первоначально выглядел как приведенный выше фрагмент, начал переписываться как:

ThreadPool.QueueUserWorkItem(LongRunningMethod, GetThreadParameters());

Большое улучшение, не так ли? Все в порядке?

Ну, за исключением того, что в этом LongRunningMethod был особый вызов, который мог бы долгое время блокировать - . Неожиданно время от времени мы начали видеть, что что-то наше программное обеспечение должно было сразу отреагировать... это просто не было. Фактически, он мог бы не реагировать на несколько секунд (уточнение: я работаю в торговой фирме, так что это была полная катастрофа).

То, что случилось, состояло в том, что пул потоков фактически заполнялся длинными блокирующими вызовами, что приводило к другому коду, который, как предполагалось, очень быстро становился в очереди и не работал до значительно позже, чем он должен был.

Мораль этой истории, конечно, не в том, что первый подход к созданию ваших собственных потоков - это правильная вещь (это не так). Это действительно так, что использование потоков жестко и подвержено ошибкам, и что, как уже говорили другие, вы должны быть очень осторожны, когда используете их.

В нашей конкретной ситуации было сделано много ошибок:

  • Создание новых потоков в первую очередь было неправильным, потому что это было намного дороже, чем реализовал разработчик.
  • Очередь всех фоновых работ в пуле потоков была неправильной, потому что она обрабатывала все фоновые задачи без разбора и не учитывала возможность фактического блокирования асинхронных вызовов.
  • Наличие длинного блокирующего метода само по себе было результатом некоторого небрежного и очень ленивого использования ключевого слова lock.
  • Недостаточное внимание было уделено обеспечению того, чтобы код, который выполнялся в фоновом потоке, был потокобезопасным (это было не так).
  • Недостаточно было уделено внимание вопросу о том, стоит ли даже делать много затронутого кода многопоточным, даже с самого начала. В большинстве случаев ответ был не так: многопоточность, только что введенная сложность и ошибки, сделали код менее понятным, и (здесь кикер): пострадали производительность.

Я рад сказать, что сегодня мы все еще живы, и наш код находится в гораздо более здоровом состоянии, чем когда-то. И мы используем многопоточность в большом количестве мест, где мы решили, что это уместно, и измерили прирост производительности (например, уменьшенная латентность между получением тикета рыночных данных и наличие исходящей цитаты, подтвержденной обменом). Но мы усвоили некоторые довольно важные уроки. Скорее всего, если вы когда-нибудь работаете над большой, многопотоковой системой, вы тоже.

Ответ 5

Если вы не можете написать полноценный планировщик ядра, вы получите неправильное управление потоками всегда.

Нитки могут быть самой замечательной вещью с горячего шоколада, но параллельное программирование невероятно сложно. Однако, если вы создаете свои потоки, чтобы быть независимыми, вы не можете стрелять себе в ногу.

Как правило, для большого пальца, если проблема разбивается на потоки, они должны быть как можно более независимыми, с максимально возможными, но четко определенными совместно используемыми ресурсами, с минималистичной концепцией управления.

Ответ 6

Я думаю, что первое утверждение лучше всего объяснить следующим: теперь доступно много современных API-интерфейсов, вручную написание собственного кода потока почти никогда не нужно. Новые API намного проще в использовании, и намного сложнее испортить!. Принимая во внимание, что при старинном потоке вы должны быть неплохо не испортить. API-интерфейсы старого стиля (Threadet al.) По-прежнему доступны, но новые API (параллельная библиотека задач, Parallel LINQ, и Reactive Extensions) - путь будущего.

Второе утверждение относится к большей части проектной точки зрения, ИМО. В дизайне, который имеет чистое разделение проблем, фоновая задача не должна действительно проникать непосредственно в пользовательский интерфейс, чтобы сообщать об обновлениях. Там должно быть некоторое разделение, используя шаблон, подобный MVVM или MVC.

Ответ 7

Я бы начал с опроса этого восприятия:

Я читал о потоках, и у меня сложилось впечатление, что это были самые вкусные вещи с киви-джелло.

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

Как указывали другие, обработка потоков дополнительно сложна и сложна. Обертки для потоков существуют, и только в редких случаях они должны обрабатываться явно. Для большинства приложений могут подразумеваться потоки.

Например, если вы просто хотите вывести вычисление в фоновый режим, оставив отзывчивый графический интерфейс, лучшим решением является либо использование обратного вызова (что заставляет задуматься, как будто вычисление выполняется в фоновом режиме во время выполнения в том же потоке) или с помощью удобной обертки, такой как BackgroundWorker, которая принимает и скрывает всю явную обработку потоков.

Последнее, создавая поток, на самом деле очень дорого. Использование пула потоков смягчает эту стоимость, потому что здесь среда выполнения создает несколько потоков, которые впоследствии повторно используются. Когда люди говорят, что явное управление потоками плохое, это все, что они могут иметь в виду.

Ответ 8

Многие расширенные приложения GUI обычно состоят из двух потоков, один для пользовательского интерфейса, один (или иногда более) для обработки данных (копирование файлов, ведение тяжелых вычислений, загрузка данных из базы данных и т.д.).

Потоки обработки не должны обновлять пользовательский интерфейс напрямую, пользовательский интерфейс должен быть черным ящиком для них (проверьте Википедию для инкапсуляции).
Они просто говорят: "Я закончил обработку" или "Я выполнил задание 7 из 9" и вызвал событие или другой метод обратного вызова. Пользовательский интерфейс подписывается на событие, проверяет, что изменилось, и соответственно обновляет интерфейс.

Если вы обновите пользовательский интерфейс из потока обработки, вы не сможете повторно использовать свой код, и у вас появятся большие проблемы, если вы захотите изменить части своего кода.

Ответ 9

Я думаю, что вы должны экспериментировать как можно больше с Threads и узнавать о преимуществах и ловушках их использования. Только путем экспериментов и использования ваше понимание их возрастает. Прочитайте как можно больше по этому вопросу.

Когда дело доходит до С# и пользовательского интерфейса (который является однопоточным, и вы можете изменять только элементы пользовательского интерфейса в коде, выполняемом в потоке пользовательского интерфейса). Я использую следующую утилиту, чтобы держать себя в здравом уме и спать спокойно ночью.

 public static class UIThreadSafe {

     public static void Perform(Control c, MethodInvoker inv) {
            if(c == null)
                return;
            if(c.InvokeRequired) {
                c.Invoke(inv, null);
            }
            else {
                inv();
            }
      }
  }

Вы можете использовать это в любом потоке, который должен изменить элемент пользовательского интерфейса, например:

UIThreadSafe.Perform(myForm, delegate() {
     myForm.Title = "I Love Threads!";
});

Ответ 10

Потоки - очень хорошая вещь, я думаю. Но работать с ними очень сложно и нужно много знаний и обучения. Основная проблема заключается в том, когда мы хотим получить доступ к общим ресурсам из двух других потоков, которые могут вызвать нежелательные эффекты.

Рассмотрим классический пример: у вас есть два потока, которые получают некоторые элементы из общего списка и после выполнения чего-то они удаляют элемент из списка.

Метод потока, который вызывается периодически, может выглядеть так:

void Thread()
{
   if (list.Count > 0)
   {
      /// Do stuff
      list.RemoveAt(0);
   }
}

Помните, что потоки, теоретически, могут переключаться на любую строку вашего кода, которая не синхронизирована. Поэтому, если список содержит только один элемент, один поток может передать условие list.Count, перед list.Remove переключатель потоков и другой поток передают list.Count (список все еще содержит один элемент). Теперь первый поток продолжается до list.Remove, и после этого второй поток продолжается до list.Remove, но последний элемент уже был удален первым потоком, поэтому второй сбой. Поэтому его необходимо синхронизировать с помощью инструкции lock, так что не может быть ситуации, когда в инструкции if находятся два потока.

Поэтому именно поэтому пользовательский интерфейс, который не синхронизирован, должен всегда запускаться в одном потоке, и ни один другой поток не должен вмешиваться в интерфейс.

В предыдущих версиях .NET, если вы хотите обновить интерфейс в другом потоке, вам придется синхронизировать с помощью методов Invoke, но поскольку его было достаточно сложно реализовать, новые версии .NET поставляются с классом BackgroundWorker что упрощает дело, обертывая все содержимое и позволяя делать асинхронный материал в событии DoWork и обновлять интерфейс в событии ProgressChanged.

Ответ 11

Огромная причина попытаться сохранить поток пользовательского интерфейса и поток обработки как можно более независимыми, так это то, что если зависает поток пользовательского интерфейса, пользователь заметит и будет недоволен. Очень важно, чтобы поток пользовательского интерфейса быстро пылал. Если вы начнете перемещать компоненты пользовательского интерфейса из потока пользовательского интерфейса или перемещать обрабатывающие материалы в поток пользовательского интерфейса, вы рискуете потерять ваше приложение.

Кроме того, много кода рамки намеренно написано с ожиданием, что вы отделите интерфейс и обработку; программы будут работать лучше, когда вы отделите два выхода, и столкнетесь с ошибками и проблемами, когда вы этого не сделаете. Я не помню каких-либо специфических проблем, с которыми я столкнулся в результате этого, хотя в прошлом я имел смутные воспоминания, пытаясь установить определенные свойства материала, которые пользовательский интерфейс несет за пределами пользовательского интерфейса, и с отказом от кода работать; Я не помню, не скомпилировалась ли она или выбросила исключение.

Ответ 12

При обновлении пользовательского интерфейса из потока, отличного от UI, важно отметить пару слов:

  1. Если вы часто используете "Invoke", производительность вашего потока, отличного от UI, может сильно пострадать, если другие вещи заставляют поток пользовательского интерфейса работать вяло. Я предпочитаю избегать использования "Invoke", если поток, не относящийся к пользовательскому интерфейсу, должен ждать выполнения действия пользовательского интерфейса перед его продолжением.
  2. Если вы используете "BeginInvoke" безрассудно для таких вещей, как контрольные обновления, чрезмерное количество делегатов вызова может оказаться в очереди, некоторые из которых могут быть довольно бесполезными к моменту их фактического появления.

Мой предпочтительный стиль во многих случаях заключается в том, чтобы каждое состояние управления было инкапсулировано в неизменяемый класс, а затем иметь флаг, который указывает, не требуется ли обновление, не ожидается или не требуется, но не ожидается (последняя ситуация может возникнуть, если запрос делается для обновления элемента управления до его полного создания). Процедура обновления управления должна, если требуется обновление, начать с очистки флага обновления, захвата состояния и рисования элемента управления. Если флаг обновления установлен, он должен заново зацикливаться. Чтобы запросить другой поток, подпрограмма должна использовать Interlocked.Exchange, чтобы установить флаг обновления для обновления в ожидании и - если он не был отложен - попробуйте BeginInvoke процедуру обновления; если BeginInvoke не работает, установите флаг обновления "необходимо, но не ожидайте".

Если попытка управления происходит сразу после того, как процедура обновления управления проверяет и очищает свой флаг обновления, вполне может случиться, что первое обновление будет отражать новое значение, но флаг обновления будет установлен в любом случае, заставляя лишнюю перерисовку экрана, В тех случаях, когда это происходит, это будет относительно безвредно. Важно то, что управление в конечном итоге будет нарисовано в правильном состоянии, если не было ожиданий более одного BeginInvoke.