Переключение контекста потока
Многопоточность
Обзор и ключевые понятия
Поток (thread) является единицей обработки данных. Многозадачность (multithreading) — это одновременное выполнение нескольких потоков. Существует два вида многопоточности — совместная (cooperative) и вытесняющая (preemptive). Ранние версии Microsoft Windows, IBM System/38 и OS/2 поддерживали совместную многопоточность. Это означало, что каждый поток отвечал за возврат управления процессору, чтобы тот смог обработать другие потоки. Безусловно, это является источником проблем, т.к. поток может просто «забыть» вернуть управление процессору, и это приведёт к «зависанию» всей операционной системы.
С течением времени, совместная многопоточность (главным достоинством которой является, по сути, простота реализации) потеряла свою популярность. Практически все широко используемые операционные системы стали поддерживать вытесняющую многопоточность (и вытесняющую многозадачность). При этом операционная система отвечает за выдачу каждому потоку определенного количества времени, в течение которого поток может выполняться, — кванта времени (timeslice). Далее процессор переключается между разными потоками, выдавая каждому потоку его квант времени. При использовании вытесняющей многопоточности программист может не заботится о том, как и когда возвращать управление операционной системе – это происходит автоматически, по истечению отведённого кванта времени. Таким образом, переключение потоков происходит автоматически (независимо от «желания» или «нежелания» потока отдавать управление обратно операционной системе). Как следствие, за счёт быстрого переключения потоков создаётся иллюзия параллельного выполнения многопоточной программы. С другой стороны, при использовании вытесняющей многопоточности, потоки, работающие параллельно могут даже не подозревать о существовании друг друга.
Обратите внимание, даже в случае вытесняющей многопоточности, если вы работаете на однопроцессорной машине, то все равно в любой момент времени реально будет исполняться только один поток. Но поскольку интервалы между переключениями процессора от потока к потоку измеряются миллисекундами, возникает иллюзия многозадачности. Чтобы несколько потоков на самом деле работали одновременно, вам потребуется многопроцессорная машина (или машина с многоядерным процессором).
Далее в этой работе мы будем исследовать вытесняющую многопоточноть, характерную (в частности) для Microsoft Windows. В качестве инструментария для работы с потоками мы будем использовать классы платформы .NET (пространство имён System.Threading), это позволит работать с потоками операционной системы не прибегая к использованию низкоуровневых функций WinAPI.
Переключение контекста потока
Неотъемлемый атрибут многопоточности — переключение контекста потоков (context switching). Именно переключение контекста вызывает как правило трудности при изучении многопоточного программирования. Итак, что же представляет собой переключение контекста потока. Процессор с помощью аппаратного таймера определяет момент окончания кванта времени, выделенного для данного потока. Когда аппаратный таймер генерирует прерывание, процессор сохраняет содержимое всех регистров для данного потока. Затем процессор перемещает содержимое этих же регистров в структуру данных CONTEXT. При необходимости переключения обратно на выполнение потока, выполнявшегося прежде, процессор выполняет обратную процедуру и восстанавливает содержимое регистров из структуры CONTEXT, ассоциированной с потоком. Весь этот процесс называется переключением контекста.
Пример многопоточного приложения (C#)
Прежде чем приступить к детальному изучению классов .NET для работы с потоками, создадим простое многопоточное приложение. Обсудив этот пример, мы перейдём к рассмотрению пространства имен System. Threading и класса Thread. В этом примере создаётся второй поток в методе Main. Затем метод, ассоциированный со вторым потоком, выводит строку, сигнализирующую о вызове этого потока. Это действительно простой пример.
using System;
using System.Threading;
namespace multithreading_01
{
class SimpleThreadApp {
/// <summary>
/// Код потока, который будет выполняться параллельно главному потоку.
/// </summary>
public static void ThreadProc()
{
Console.WriteLine("Второй поток работает...");
}
/// <summary>
/// Главный поток приложения.
/// </summary>
public static void Main() {
ThreadStart worker = new ThreadStart(ThreadProc);
Console.WriteLine("Главнй поток - создаём ещё один поток.");
Thread t = new Thread(worker);
t.Start();
Console.WriteLine ("Главный поток - второй поток запущен.");
}
}
}
Результат выполнения этой, казалось бы простой, программы:
Главный поток - создаём ещё один поток.
Главный поток - второй поток запущен.
Второй поток работает...
Press any key to continue . . .
А может быть и такой:
Главный поток - создаём ещё один поток.
Второй поток работает...
Главный поток - второй поток запущен.
Press any key to continue . . .
Это действительно не предсказуемо. Дело в том, что в данном случае мы имеем дело с асинхронным выполнением кода. Именно операционная система (а не программа!) определяет, что произойдёт после запуска второго потока: будет продолжено выполнение главного потока или управление будет передано новому потоку.
При рассмотрении этого примера мы несколько раз упоминали понятие «главный поток приложения». Главным потоком называется поток, который запускается операционной системой при старте приложения. В данном случае при старте приложения запускается метод Main(), таким образом можно сказать, что код метода Main() выполняется в главном потоке приложения. Главный поток может запустить несколько других потоков, которые называются дочерними. Все эти потоки (и главный, и дочерние) будут работать параллельно, разделяя между собой ресурсы, выделенные данному приложения операционной системой.
Работа с потоками в C#
Управление потоками
Рассмотрим некоторые приёмы работы с классом Thread:
using System;
using System.Threading;
namespace multithreading_02
{
class ThreadSleepApp
{
/// <summary>
/// Код потока, который будет выполняться параллельно главному потоку.
/// </summary>
public static void ThreadProc()
{
Console.WriteLine("Второй поток работает.");
int sleepTime = 5000;
Console.WriteLine("Второй поток засыпает на {0} секунд.",
sleepTime/1000);
Thread.Sleep(sleepTime);
Console.WriteLine("Второй поток снова работает.");
}
/// <summary>
/// Главный поток приложения.
/// </summary>
public static void Main()
{
Console.WriteLine("Главный поток - создаём ещё один поток.");
Thread t = new Thread(ThreadProc);
t.Start();
Console.WriteLine("Главный поток - второй поток запущен.");
}
}
}
Как видите, мы ещё больше упростили создание объекта Thread в Main(). В остальном функция Main() эквивалента предыдущему примеру. Остаётся выяснить что же делает функция ThreadProc(). Итак, результаты работы это программы:
Главный поток - создаём ещё один поток.
Главный поток - второй поток запущен.
Второй поток работает.
Второй поток засыпает на 5 секунд.
Второй поток снова работает.
Press any key to continue . . .
Вам уже известно, что порядок строк, выводимых программой, может немного отличаться от приведенного выше. Тем не менее, строка «Второй поток снова работает.» не появится раньше, чем через 5 секунд после старта программы. Дело в том, что второй поток «засыпает» на 5 секунд, т.е. сообщает ОС, что в ближайшие 5000 миллисекунд ему не понадобятся кванты времени для работы. Как мы видим, делает от это сам (т.е. «добровольно», по собственному желанию) вызывая метод Sleep().
В принципе, есть два варианта вызова метода Thread.Sleep(). Первый — вызов Thread. Sleep() со значением 0. При этом вы заставите текущий поток освободить неиспользованный остаток своего кванта. С другой стороны, передача в качестве параметра положительного числа приведёт к остановке выполнения потока на заданное количество миллисекунд. При передаче же значения Timeout.Infinite поток будет приостановлен на неопределенно долгий срок, пока это состояние потока не будет прервано другим потоком, вызвавшим метод приостановленного потока Thread.Interrupt() – прервать работу потока, находящегося в состоянии WaitSleepJoin. Перевод потока в состояние бесконечного ожидания и вывод его из этого состояния при помощи метода Thread.Interrupt() не является хорошей (и как следствие широко распространённой практикой), но это приводит нас к пониманию того, что поток обладает «состоянием»: он может «выполняться», «быть остановленным», «спать» и так далее. Полный граф состояний потока приведён ниже. Обратите внимание, что переход между состояниями можно инициировать соответствующими методами. Узнать текущее состоянии потока можно с помощью его свойства ThreadState.
Вернёмся к управлению потоками. Второй способ приостановить исполнение потока — вызов метода Thread.Suspend(). Между этими методиками (Interrupt() и Suspend()) есть несколько важных отличий. Во-первых, можно вызвать метод Thread. Suspend() для потока, выполняющегося в текущий момент, или для любого другого потока. Во-вторых, если таким образом приостановить выполнение потока, любой другой поток способен возобновить его выполнение с помощью метода Thread.Resume(). Обратите внимание, что, когда один поток приостанавливает выполнение другого, первый поток не блокируется. Возврат управления после вызова происходит немедленно. Кроме того, единственный вызов Thread.Resume() возобновит исполнение данного потока независимо от числа вызовов метода Thread.Suspend(), выполненных ранее.
Таким образом, пара методов Suspend()/Resume() является предпочтительной, когда речь идёт о временной приостановке работы потока. Здесь мы имеем в виду, что этот механизм является удобным, когда речь идёт об управлении одним потоком выполнением другого потока. Когда же поток хочет приостановить своё собственное выполнение, то более применим метод Sleep(), как сделано в предыдущем примере.
Уничтожение потоков
Уничтожить поток можно вызовом метода Thread.Abort(). Исполняющая среда насильно завершает выполнение потока, генерируя исключение ThreadAbortException (это исключение возникнет в коде потока, для которого был вызван метод Abort()). Даже если поток попытается обработать ThreadAbortException, исполняющая среда этого не допустит. Однако она исполнит код из блока finally потока, выполнение которого прервано, если этот блок присутствует (что позволяет среагировать на аварийное завершение). Проиллюстрируем это следующим примером. После запуска главный поток создаёт и запускает второй (рабочий) поток. Далее, выполнение метода Main приостанавливается на 5 секунд, чтобы дать исполняющей среде время для запуска рабочего потока. После запуска рабочий поток считает до десяти, останавливаясь после каждого отсчета на секунду. Когда выполнение метода Main возобновляется после пятисекундной паузы, он прерывает выполнение рабочего потока (после этого исполняется блок finally).
using System;
using System.Threading;
class ThreadAbortApp {
public static void ThreadProc()
{
try
{
Console.WriteLine("Рабочий поток запущен.");
for (int i = 0; i < 10; i++) {
Thread. Sleep(1000);
Console.WriteLine("Рабочий поток -> {0}", i);
}
Console.WriteLine("Рабочий поток завершён");
}
catch(ThreadAbortException e)
{
// ThreadAbortException здесь обработано не будет!
}
finally
{
Console.WriteLine("В рабочем потоке возникло необработанное исключение!");
}
}
public static void Main()
{
Console.WriteLine("Главный поток - запускаем рабочий поток.");
Thread t = new Thread(ThreadProc);
t.Start();
Console.WriteLine("Главный поток - засыпаем на 5 секунд.");
Thread.Sleep(5000);
Console.WriteLine("Главный поток - прерываем рабочий поток.");
t.Abort();
}
}
Результаты работы программы:
Главный поток - запускаем рабочий поток.
Рабочий поток запущен.
Главный поток - засыпаем на 5 секунд.
Рабочий поток -> 0
Рабочий поток -> 1
Рабочий поток -> 2
Рабочий поток -> 3
Рабочий поток -> 4
Главный поток - прерываем рабочий поток.
В рабочем потоке возникло необработанное исключение!
Press any key to continue . . .
Планирование потоков
При переключении процессора по окончании выделенного потоку кванта времени, процесс выбора следующего потока, предназначенного для исполнения, далеко не произволен. У каждого потока есть приоритет, указывающий процессору, как должно планироваться выполнение этого потока по отношению к другим потокам системы. Для потоков, создаваемых в период выполнения, уровень приоритета по умолчанию равен Normal. Для просмотра и установки этого значения служит свойство Thread.Priority. Свойству Thread.Priority можно присвоить значение типа Thread.ThreadPriority, которое представляет собой перечисление, определяющее значения Highest, AboveNormal, Normal, BelowNormal и Lowest (наивысший, выше нормального, нормальный, ниже нормального, низший).
Рассмотрим пример:
using System;
using System.Threading;
class ThreadAbortApp
{
/// <summary>
/// Код рабочего потока.
/// </summary>
public static void ThreadProc(object args)
{
string name = args.ToString();
Console.WriteLine("Рабочий поток запущен ({0}).", name);
for (int i = 0; i < 10; i++)
{
// Цикл с бесполезной работой (для загрузки процессора).
for (int j = 0; j < 1000000; j++)
Math.Sin(Math.Cos(j) + Math.Atan(j) * Math.Sin(Math.Cos(j)
+ Math.Atan(i)));
Console.WriteLine("{0} -> {1}", name, i);
}
Console.WriteLine("Рабочий поток завершён ({0}).", name);
}
public static void Main()
{
// Разрешить данному процессу использовать только один процессор.
System.Diagnostics.Process.GetCurrentProcess().ProcessorAffinity
= new IntPtr(0x0001);
// Создаём два рабочих потока.
Console.WriteLine("Главный поток - запускаем рабочие потоки.");
Thread t1 = new Thread(ThreadProc);
Thread t2 = new Thread(ThreadProc);
// Устанавливаем приоритеты потоков.
t1.Priority = ThreadPriority.BelowNormal;
t2.Priority = ThreadPriority.AboveNormal;
// Запускаем потоки.
t1.Start("первый поток");
t2.Start("второй поток");
// Ждём завершения обоих потоков.
t1.Join();
t2.Join();
Console.WriteLine("Главный поток - выполнение обоих потоков завершено.");
}
}
Результаты работы программы (потоки запущены с одинаковым приоритетом):
Главный поток - запускаем рабочие потоки.
Рабочий поток запущен (первый поток).
Рабочий поток запущен (второй поток).
первый поток -> 0
второй поток -> 0
первый поток -> 1
второй поток -> 1
первый поток -> 2
второй поток -> 2
первый поток -> 3
второй поток -> 3
второй поток -> 4
первый поток -> 4
второй поток -> 5
первый поток -> 5
второй поток -> 6
первый поток -> 6
второй поток -> 7
первый поток -> 7
второй поток -> 8
первый поток -> 8
второй поток -> 9
Рабочий поток завершён (второй поток).
первый поток -> 9
Рабочий поток завершён (первый поток).
Главный поток - выполнение обоих потоков завершено.
Press any key to continue . . .
Потоки запущены с разными приоритетами:
Главный поток - запускаем рабочие потоки.
Рабочий поток запущен (первый поток).
Рабочий поток запущен (второй поток).
второй поток -> 0
второй поток -> 1
второй поток -> 2
второй поток -> 3
второй поток -> 4
второй поток -> 5
второй поток -> 6
второй поток -> 7
второй поток -> 8
второй поток -> 9
Рабочий поток завершён (второй поток).
первый поток -> 0
первый поток -> 1
первый поток -> 2
первый поток -> 3
первый поток -> 4
первый поток -> 5
первый поток -> 6
первый поток -> 7
первый поток -> 8
первый поток -> 9
Рабочий поток завершён (первый поток).
Главный поток - выполнение обоих потоков завершено.
Press any key to continue . . .
Обратите внимание, что второй поток завершился раньше, несмотря на то, что был запушен позже первого.
В приведённом примере также демонстрируются ещё два новых механизма работы с потоками:
- передача параметров потоку;
- ожидание завершения выполнения потоков.
Каждому потоку передаётся его «имя» - это позволяет различать вывод первого и второго потоков на консоли. В реальных программах для этой цели используется свойство Thread.Name, что избавляет от необходимости передавать лишние параметры потоку.
Ожидание завершения выполнения потоков также является одним из важнейших механизмов работы с потоками. В .NET это осуществляется при помощи метода Thread.Join(), который блокирует вызывающий поток до завершения потока, метод Join() которого был вызван. Т.е. в нашем примере:
public static void Main()
{
...
t1.Join();
t2.Join();
...
}
выполнение метода Main() будет приостановлено сначала до тех пор, пока первый поток не завершится, а затем – до завершения и второго потока. Таким образом, мы дождёмся завершения обоих потоков.
См. также:
· System.Threading - пространство имен
· Thread - класс
· Создание потоков (Руководство по программированию на C#)
· Использование потоков (Руководство по программированию на C#)
· Синхронизация потоков (Руководство по программированию на C#)
Задание категории А.
Написать приложение, содержащее не менее двух тредов. Каждый из этих тредов должен искать файлы:
- с определенным заданным шаблоном;
- содержащие в своем составе определенную строку;
- начиная с определенного директория;
- обеспечить возможность поиска в поддиректориях.
Задание категории Б.
Разработать многопоточное приложение, моделирующее движение бильярдных шаров по игровому столу. Поведение каждого шара (т.е. вычисление новых координат и перерисовка) программируется как отдельный поток. На игровом столе действуют обычные физические законы - шары отскакивают от стенок и углов стола так, что угол падения равен углу отражения, единственным исключением для данной задачи является отсутствие взаимодействий между шарами (т.е. проще говоря, они не сталкиваются).
При запуске процесса моделирования каждый шар получает некоторый (случайный) импульс, под действием которого он движется по инерции, постепенно останавливаясь. Когда шар останавливается, соответствующий поток должен завершиться. Приложение следит за тем, чтобы был хотя бы один поток, который ещё не закончил свою работу. Когда все потоки будут завершены, требуется выдать соответствующее сообщение.
Пример подобного приложения находится в папке либо архиве MultiThreadBalls.
Недостатки данного приложения:
· шары движутся бесконечно долго, с постоянной скоростью;
· скорость всех шаров одинакова и не от чего не зависит;
· в программе используются целочисленные координаты и, следовательно, проиллюстрирована возможность движения только по прямым с углами ±45°.
Многопоточность
Обзор и ключевые понятия
Поток (thread) является единицей обработки данных. Многозадачность (multithreading) — это одновременное выполнение нескольких потоков. Существует два вида многопоточности — совместная (cooperative) и вытесняющая (preemptive). Ранние версии Microsoft Windows, IBM System/38 и OS/2 поддерживали совместную многопоточность. Это означало, что каждый поток отвечал за возврат управления процессору, чтобы тот смог обработать другие потоки. Безусловно, это является источником проблем, т.к. поток может просто «забыть» вернуть управление процессору, и это приведёт к «зависанию» всей операционной системы.
С течением времени, совместная многопоточность (главным достоинством которой является, по сути, простота реализации) потеряла свою популярность. Практически все широко используемые операционные системы стали поддерживать вытесняющую многопоточность (и вытесняющую многозадачность). При этом операционная система отвечает за выдачу каждому потоку определенного количества времени, в течение которого поток может выполняться, — кванта времени (timeslice). Далее процессор переключается между разными потоками, выдавая каждому потоку его квант времени. При использовании вытесняющей многопоточности программист может не заботится о том, как и когда возвращать управление операционной системе – это происходит автоматически, по истечению отведённого кванта времени. Таким образом, переключение потоков происходит автоматически (независимо от «желания» или «нежелания» потока отдавать управление обратно операционной системе). Как следствие, за счёт быстрого переключения потоков создаётся иллюзия параллельного выполнения многопоточной программы. С другой стороны, при использовании вытесняющей многопоточности, потоки, работающие параллельно могут даже не подозревать о существовании друг друга.
Обратите внимание, даже в случае вытесняющей многопоточности, если вы работаете на однопроцессорной машине, то все равно в любой момент времени реально будет исполняться только один поток. Но поскольку интервалы между переключениями процессора от потока к потоку измеряются миллисекундами, возникает иллюзия многозадачности. Чтобы несколько потоков на самом деле работали одновременно, вам потребуется многопроцессорная машина (или машина с многоядерным процессором).
Далее в этой работе мы будем исследовать вытесняющую многопоточноть, характерную (в частности) для Microsoft Windows. В качестве инструментария для работы с потоками мы будем использовать классы платформы .NET (пространство имён System.Threading), это позволит работать с потоками операционной системы не прибегая к использованию низкоуровневых функций WinAPI.
Переключение контекста потока
Неотъемлемый атрибут многопоточности — переключение контекста потоков (context switching). Именно переключение контекста вызывает как правило трудности при изучении многопоточного программирования. Итак, что же представляет собой переключение контекста потока. Процессор с помощью аппаратного таймера определяет момент окончания кванта времени, выделенного для данного потока. Когда аппаратный таймер генерирует прерывание, процессор сохраняет содержимое всех регистров для данного потока. Затем процессор перемещает содержимое этих же регистров в структуру данных CONTEXT. При необходимости переключения обратно на выполнение потока, выполнявшегося прежде, процессор выполняет обратную процедуру и восстанавливает содержимое регистров из структуры CONTEXT, ассоциированной с потоком. Весь этот процесс называется переключением контекста.
Пример многопоточного приложения (C#)
Прежде чем приступить к детальному изучению классов .NET для работы с потоками, создадим простое многопоточное приложение. Обсудив этот пример, мы перейдём к рассмотрению пространства имен System. Threading и класса Thread. В этом примере создаётся второй поток в методе Main. Затем метод, ассоциированный со вторым потоком, выводит строку, сигнализирующую о вызове этого потока. Это действительно простой пример.
using System;
using System.Threading;
namespace multithreading_01
{
class SimpleThreadApp {
/// <summary>
/// Код потока, который будет выполняться параллельно главному потоку.
/// </summary>
public static void ThreadProc()
{
Console.WriteLine("Второй поток работает...");
}
/// <summary>
/// Главный поток приложения.
/// </summary>
public static void Main() {
ThreadStart worker = new ThreadStart(ThreadProc);
Console.WriteLine("Главнй поток - создаём ещё один поток.");
Thread t = new Thread(worker);
t.Start();
Console.WriteLine ("Главный поток - второй поток запущен.");
}
}
}
Результат выполнения этой, казалось бы простой, программы:
Главный поток - создаём ещё один поток.
Главный поток - второй поток запущен.
Второй поток работает...
Press any key to continue . . .
А может быть и такой:
Главный поток - создаём ещё один поток.
Второй поток работает...
Главный поток - второй поток запущен.
Press any key to continue . . .
Это действительно не предсказуемо. Дело в том, что в данном случае мы имеем дело с асинхронным выполнением кода. Именно операционная система (а не программа!) определяет, что произойдёт после запуска второго потока: будет продолжено выполнение главного потока или управление будет передано новому потоку.
При рассмотрении этого примера мы несколько раз упоминали понятие «главный поток приложения». Главным потоком называется поток, который запускается операционной системой при старте приложения. В данном случае при старте приложения запускается метод Main(), таким образом можно сказать, что код метода Main() выполняется в главном потоке приложения. Главный поток может запустить несколько других потоков, которые называются дочерними. Все эти потоки (и главный, и дочерние) будут работать параллельно, разделяя между собой ресурсы, выделенные данному приложения операционной системой.
Работа с потоками в C#