Тема 12. Многопоточное программирование
Одновременное выполнение задач
C# поддерживает параллельное выполнение кода через многопоточность. Поток – это независимый путь исполнения, способный выполняться одновременно с другими потоками. Программа на C# запускается как единственный поток, автоматически создаваемый CLR и операционной системой (“главный” поток), и становится многопоточной при помощи создания дополнительных потоков.
Для создания и управления потоками в C# существует класс Thread. При помощи этого класса можно созавать потоки, давать им приоритет выполнения, приостанавливать их, запускать заново.
Пример одновременной работы:
static void Main()
{
Thread t = new Thread(WriteY);
t.Start(); // Выполнить WriteY в новом потоке
while (true)
Console.Write("x"); // Все время печатать 'x'
}
static void WriteY()
{
while (true)
Console.Write("y"); // Все время печатать 'y'
}
Результат ( может немного отличаться ):
xxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyxxxxyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyxxxxy...
Как работает многопоточность
Управление многопоточностью осуществляет планировщик потоков, эту функцию CLR обычно делегирует операционной системе. Планировщик потоков гарантирует, что активным потокам выделяется соответствующее время на выполнение, а потоки, ожидающие или блокированные, к примеру, на ожидании эксклюзивной блокировки, или пользовательского ввода – не потребляют времени CPU.
На однопроцессорных компьютерах планировщик потоков использует квантование времени – быстрое переключение между выполнением каждого из активных потоков. Это приводит к непредсказуемому поведению, как в самом первом примере, где каждая последовательность символов ‘X’ и ‘Y’ соответствует кванту времени, выделенному потоку. В Windows XP типичное значение кванта времени – десятки миллисекунд – выбрано как намного большее, чем затраты CPU на переключение контекста между потоками (несколько микросекунд).
На многопроцессорных компьютерах многопоточность реализована как смесь квантования времени и подлинного параллелизма, когда разные потоки выполняют код на разных CPU. Необходимость квантования времени все равно остается, так как операционная система должна обслуживать как свои собственные потоки, так и потоки других приложений.
Говорят, что поток вытесняется, когда его выполнение приостанавливается из-за внешних факторов типа квантования времени. В большинстве случаев поток не может контролировать, когда и где он будет вытеснен.
Процессы
Все потоки одного приложения логически содержатся в пределах процесса – модуля операционной системы, в котором исполняется приложение.
В некоторых аспектах потоки и процессы схожи – например, время разделяется между процессами, исполняющимися на одном компьютере, так же, как между потоками одного C#-приложения. Ключевое различие состоит в том, что процессы полностью изолированы друг от друга. Потоки разделяют память (кучу) с другими потоками этого же приложения. Благодаря этому один поток может поставлять данные в фоновом режиме, а другой – показывать эти данные по мере их поступления.
Когда использовать потоки
Типовое приложение с многопоточностью выполняет длительные вычисления в фоновом режиме. Главный поток продолжает выполнение, в то время как рабочий поток выполняет фоновую задачу. В приложениях Windows Forms, когда главный поток занят длительными вычислениями, он не может обрабатывать сообщения клавиатуры и мыши, и приложение перестает откликаться. По этой причине следует запускать отнимающие много времени задачи в рабочем потоке, даже если главный поток в это время демонстрирует пользователю модальный диалог с надписью “Работаю... Пожалуйста, ждите”, так как программа не может перейти к следующей операции, пока не закончена текущая. Такое решение гарантирует, что приложение не будет помечено операционной системой как “Не отвечающее”, соблазняя пользователя с горя прикончить процесс. Опять же, в этом случае модальный диалог может предоставить кнопку “Отмена”, так как форма продолжает получать сообщения, пока задача выполняется в фоновом потоке. Класс BackgroundWorker наверняка пригодится вам при реализации такой модели.
В случае приложений без UI, например, служб Windows, многопоточность имеет смысл, если выполняемая задача может занять много времени, так как требуется ожидание ответа от другого компьютера (сервера приложений, сервера баз данных или клиента). Запуск такой задачи в отдельном рабочем потоке означает, что главный поток немедленно освобождается для других задач.
Другое применение многопоточность находит в методах, выполняющих интенсивные вычисления. Такие методы могут выполняться быстрее на многопроцессорных компьютерах, если рабочая нагрузка разнесена по нескольким потокам (количество процессоров можно получить через свойство Environment.ProcessorCount).
C#-приложение можно сделать многопоточным двумя способами: либо явно создавая дополнительные потоки и управляя ими, либо используя возможности неявного создания потоков .NET Framework – BackgroundWorker, пул потоков, потоковый таймер, Remoting-сервер, Web-службы или приложение ASP.NET. В двух последних случаях альтернативы многопоточности не существует. Однопоточный web-сервер не просто плох, он попросту невозможен! К счастью, в случае серверов приложений, не хранящих состояние (stateless), многопоточность реализуется обычно довольно просто, сложности возможны разве что в синхронизации доступа к данным в статических переменных.
Когда потоки не нужны
Многопоточность наряду с достоинствами имеет и свои недостатки. Самое главный из них – значительное увеличение сложности программ. Сложность увеличивают не дополнительные потоки сами по себе, а необходимость организации их взаимодействия. От того, насколько это взаимодействие является преднамеренным, зависит продолжительность цикла разработки, а также количество спорадически проявляющихся и трудноуловимых ошибок в программе. Таким образом, нужно либо поддерживать дизайн взаимодействия потоков простым, либо не использовать многопоточность вообще, если только вы не имеете противоестественной склонности к переписыванию и отладке кода.
Кроме того, чрезмерное использование многопоточности отнимает ресурсы и время CPU на создание потоков и переключение между потоками. В частности, когда используются операции чтения/записи на диск, более быстрым может оказаться последовательное выполнение задач в одном или двух потоках, чем одновременное их выполнение в нескольких потоках. Далее будет описана реализация очереди Поставщик/Потребитель, предоставляющей такую функциональность.
Блокирование и потоковая безопасность
Блокировка обеспечивает монопольный доступ и используется, чтобы обеспечить выполнение одной секции кода только одним потоком одновременно. Для примера рассмотрим следующий класс:
class ThreadUnsafe
{
static int val1, val2;
static void Go()
{
if (val2 != 0)
Console.WriteLine(val1 / val2);
val2 = 0;
}
}
Он не является потокобезопасным: если бы метод Go вызывался двумя потоками одновременно, можно было бы получить ошибку деления на 0, так как переменная val2 могла быть установлена в 0 в одном потоке, в то время когда другой поток находился бы между if и Console.WriteLine.
Вот как при помощи блокировки можно решить эту проблему:
class ThreadSafe
{
static object locker = new object();
static int val1, val2;
static void Go()
{
lock (locker)
{
if (val2 != 0)
Console.WriteLine(val1 / val2);
val2 = 0;
}
}
}
Только один поток может единовременно заблокировать объект синхронизации (в данном случае locker), а все другие конкурирующие потоки будут приостановлены, пока блокировка не будет снята. Если за блокировку борются несколько потоков, они ставятся в очередь ожидания – "ready queue" – и обслуживаются, как только это становится возможным, по принципу “первым пришел – первым обслужен”. Эксклюзивная блокировка, как уже говорилось, обеспечивает последовательный доступ к тому, что она защищает, так что выполняемые потоки уже не могут наложиться друг на друга. В данном случае мы защитили логику внутри метода Go, так же, как и поля val1 и val2.
Класс ThreadPool
Потоки можно создавать при помощи класса Thread. Однако параллельный запуск большого количества потоков может привести к существенному торможению системы так как требуется частое переключение потоков. При этом многие потоки останавливаются и большее количество потоков будет требовать больше процессорного времени на переключения. Чтобы снизить количество переключений нужно знать количество свободных ресурсов. Для этого можно использовать класс пула потоков ThreadPool.
Дополнительная литература
http://rsdn.ru/article/dotnet/CSThreading1.xml
http://esate.ru/uroki/OpenGL/uroki-OpenGL-c-sharp/mnogopotochnost-i-potoki-v-c-sharp/
http://habrahabr.ru/post/126495/