Q. Как сделать ожидание завершения множества потоков из главного потока?
A. Основная идея проста – создаем массив флажков, по одному флажку на поток. Когда поток отработал – он устанавливает свой флажок. Главный поток в это время ожидает, пока все флажки будут установлены - и когда это происходит – продолжает свое выполнение. Для нашего примера программы скачивания сайтов это будет выглядеть так:
Код:
…
static void Main(string[] args)
{
URLs.Enqueue("http://microsoft.com");
URLs.Enqueue("http://google.com");
URLs.Enqueue("http://ya.ru");
URLs.Enqueue("http://forum.antichat.ru");
//создаем массив хендлеров, для контроля завершения потоков
ManualResetEvent[] handles = new ManualResetEvent[3];
//создаем и запускаем 3 потока
for (int i = 0; i < 3; i++)
{
handles[i] = new ManualResetEvent(false);
(new Thread(new ParameterizedThreadStart(Download))).Start(handles[i]);
}
//ожидаем, пока все потоки отработают
WaitHandle.WaitAll(handles);
//
Console.WriteLine("Download completed");
Console.ReadLine();
}
public static void Download(object handle)
{
//будем крутить цикл, пока не закончатся ULR в очереди
while (true)
{
…
}
//устанавливаем флажок хендла, что бы сообщить главному потоку о том, что мы отработали
((ManualResetEvent)handle).Set();
}
Здесь главный поток запускает три дочерних потока, которые начинают скачивать сайты. Пока они работают – главный поток ожидает в точке WaitHandle.WaitAll(handles);. Как только все три потока завершат работу – главный поток продолжит свое выполнение.
Для ожидания завершения потоков в .Net Framework есть набор специальных объектов. Здесь мы воспользовались двумя из них: ManualResetEvent и WaitHandle.
В приведенном примере есть одна загвоздка. Приложение может работать в двух режимах MTA и STA, здесь мы не будем рассматривать что это значит (welcome to Google). Так вот, в режиме STA метод WaitHandle.WaitAll(handles); не работает. Что бы выкрутиться из такой ситуации, можно использовать свой метод WaitAll, вместо приведенного:
Код:
/// Ожидаем завершения всех потоков
static void WaitAll(WaitHandle[] waitHandles)
{
if (Thread.CurrentThread.ApartmentState == ApartmentState.STA)
// WaitAll для STA не поддерживается, поэтому делаем это вручную
foreach (WaitHandle myWaitHandle in waitHandles)
WaitHandle.WaitAny(new WaitHandle[] { myWaitHandle });
else
//Вызываем стандартный метод
WaitHandle.WaitAll(waitHandles);
}
Q. Как принудительно завершить поток?
A. Вообще говоря – никак

. Поток – это отдельный и самостоятельный кусок кода, который продолжает работать, пока не выполнится целиком. Собственно любая программа (однопоточная) работает также. Гарантированно завершить поток можно только сняв процесс в диспетчере задач. Но здесь мы все же рассмотрим случаи негарантированного завершения потоков.
Случай первый – хороший. Это случай корректного завершения потока. Под корректностью понимается то, что поток завершит какой-то участок своей работы и затем просто завершится, не выполняя оставшуюся работу, но и не теряя сделанную. Автоматических средств такого процесса быть не может потому что понятие “корректности” завершения зависит от вашего алгоритма, который вы реализуете в потоке. В приведенном примере выше, мы бы могли реаилзовать следующий простой алгоритм: создать глобальный флажок bool stopThreads, а в методе Download() регулярно проверять его перед тем, как начать скачивать новую страницу. Вот так
Код:
bool stopThreads = false;
…
void Download()
{
while(true)
{
if(stopThreads)
break;
...
}
}
(здесь по-хорошему нужно было бы использовать ManualResetEvent вместо bool, но не будем усложнять пример)
Далее, когда пользователь хочет прервать скачивание сайтов, главный поток устанавливает stopThreads = true, и потоки завершают выполнение, после скачивания текущего сайта.
Случай второй – плохой. Все бы хорошо, но нужно понимать, что описанный вариант в первом случае работает только если отдельная задача, которую выполняет поток в цикле – выполняется довольно быстро. А если поток производит вычисления, которые выполняются часами? Если, например, программа скачивает гигабайтный файл? Хотелось бы иметь механизм прерывания потока не только в точке проверки флажка, но и в любой точке. Для этого у объекта Thread есть метод Abort(). Он прерывает поток, вызывая в нем исключение типа ThreadAbortException. Таким образом, если главный поток вызовет метод Abort для каждого из потоков, они все сгенерируют исключение и прервутся.
К сожалению, применение Abort – не очень хорошее решение. Во-первых это не является корректным завершением потока, поскольку исключение может возникнуть в любой точке кода потока, и поток не успеет сохранить ту работу, которую он уже сделал. Во-вторых злостный поток может обработать ThreadAbortException простым try{}catch{}, и свободно продолжать выполнение. И в-третьих – самое неприятное то, что Abort работает только внутри управляемого кода. Вызов Abort в то время как поток выполняет неуправляемый код (например ожидание сокета) – не приведет ни к какому результату. Исключение будет сгенерировано только в тот момент, когда начнет выполняться управляемый код. Таким образом Abort делает некорректное завершение потока, да и то – не гарантированное и не сиюминутное.
Случай третий – совсем плохой. Если уж нам приспичило стопроцентно завершить приложение, вместе со всеми потоками, и нам начхать на несохраненные данные и вообще на все – мы просто хотим выйти – можно применить следующий код:
Код:
Process currentProcess = Process.GetCurrentProcess();
int pid = currentProcess.Id;
Process.Start(Application.StartupPath + "tskill.exe", pid.ToString());
(предварительно нужно скопировать файл tskill.exe из C:\WINDOWS\system32\tskill.exe в папку с программой)
Здесь мы просто получаем идентификатор своего процесса, и просим ОС завершить данный процесс, вместе со всеми потоками, запустив программку tskill.exe, которая входит в стандартный набор утилит Windows.
Другой вариант (правда это не всегда возможно) – установить свойство IsBackground для дочерних потоков. Background потоки не отличаются о обычных, за исключением того, что они автоматически завершаются, если завершается процесс в которым они работают.
A. Обработка ошибок в потоке
Q. Обработка ошибок – еще одна из проблем, возникающих в многопоточных приложениях. Решение этой пролблемы специфично для каждой задачи и я приведу лишь одно из всевозможных решений.
Во-первых, определим для себя, что именно нужно делать, если в процессе работы возникает исключение (политика обработки исключений). Допустим, у нас есть все та же программа – загрузчик сайтов. И я хочу, что бы при любой возникающей ошибке в потоках, потоки продолжали работу, но в конце работы программы, ошибки отобразились бы пользователю. Именно так и сделаем.
Но перед этим отметим для себя следующую особенность потоков: поскольку поток выполняется не в главном потоке приложения (извините за каламбур), то блок try{}catch{} в методе который создает потоки – не сможет отловить ошибки самих потоков. Это значит, что отлавливать ошибки потоков нужно внутри того метода, где собственно поток работает.
Итак, более совершенная версия нашего загрузчика, в котором реализована наша политика обработки исключений, ожидание завершения всех потоков и сохранение результатов в файлы:
Код:
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Net;
using System.IO;
namespace Downloader
{
class Program
{
//очередь адресов для закачки
static Queue<string> URLs = new Queue<string>();
//список скачанных страниц
static List<string> HTMLs = new List<string>();
//локер для очереди адресов
static object URLlocker = new object();
//локер для списка скачанных страниц
static object HTMLlocker = new object();
//очередь ошибок
static Queue<Exception> exceptions = new Queue<Exception>();
static void Main(string[] args)
{
URLs.Enqueue("http://microsoft.com");
URLs.Enqueue("http://google.com");
URLs.Enqueue("http://ya.ru");
URLs.Enqueue("http://gfdgjfhjkgfjgfds.com");//запросим несуществующий сайт, что бы вызвать исключение
URLs.Enqueue("http://forum.antichat.ru");
//создаем массив хендлеров, для контроля завершения потоков
ManualResetEvent[] handles = new ManualResetEvent[3];
//создаем и запускаем 3 потока
for (int i = 0; i < 3; i++)
{
handles[i] = new ManualResetEvent(false);
(new Thread(new ParameterizedThreadStart(Download))).Start(handles[i]);
}
//ожидаем, пока все потоки отработают
WaitHandle.WaitAll(handles);
//проверяем ошибки, если были - выводим
foreach (Exception ex in exceptions)
Console.WriteLine(ex.Message);
//сохраняем закачанные страницы в файлы
try
{
for (int i = 0; i < HTMLs.Count; i++)
File.WriteAllText("c:\\" + i + ".html", HTMLs[i]);
Console.WriteLine(HTMLs.Count+" files saved");
}
catch(Exception ex) { Console.WriteLine(ex); }
//
Console.WriteLine("Download completed");
Console.ReadLine();
}
public static void Download(object handle)
{
//будем крутить цикл, пока не закончатся ULR в очереди
while (true)
try
{
string URL;
//блокируем очередь URL и достаем оттуда один адрес
lock (URLlocker)
{
if (URLs.Count == 0)
break;//адресов больше нет, выходим из метода, завершаем поток
else
URL = URLs.Dequeue();
}
Console.WriteLine(URL + " - start downloading ...");
//скачиваем страницу
WebRequest request = WebRequest.Create(URL);
HttpWebResponse response = (HttpWebResponse)request.GetResponse();
string HTML = (new StreamReader(response.GetResponseStream())).ReadToEnd();
//блокируем список скачанных страниц, и заносим туда свою страницу
lock (HTMLlocker)
HTMLs.Add(HTML);
//
Console.WriteLine(URL + " - downloaded (" + HTML.Length + " bytes)");
}
catch (ThreadAbortException)
{
//это исключение возникает если главный поток хочет завершить приложение
//просто выходим из цикла, и завершаем выполнение
break;
}
catch (Exception ex)
{
//в процессе работы возникло исключение
//заносим ошибку в очередь ошибок, предварительно залочив ее
lock (exceptions)
exceptions.Enqueue(ex);
//берем следующий URL
continue;
}
//устанавливаем флажок хендла, что бы сообщить главному потоку о том, что мы отработали
((ManualResetEvent)handle).Set();
}
}
}
Обратим внимание, что потоки сами отлавливают возникающие в них исключения, и складывают их в очередь исключений (разумеется, с синхронизацией), а затем продолжают работать. Главный поток, после завершения всех потоков, отображает пользователю возникшие исключения.
В результате работы был получен такой результат:
Код:
http://microsoft.com - start downloading ...
http://ya.ru - start downloading ...
http://google.com - start downloading ...
http://ya.ru - downloaded (4818 bytes)
http://gfdgjfhjkgfjgfds.com - start downloading ...
http://forum.antichat.ru - start downloading ...
http://microsoft.com - downloaded (1020 bytes)
http://forum.antichat.ru - downloaded (103709 bytes)
http://google.com - downloaded (7720 bytes)
The remote name could not be resolved: 'gfdgjfhjkgfjgfds.com'
4 files saved
Download completed
A. Накладные расходы на поток.
Q. Когда мы обсуждали применимость потоков для повышения производительности, мы не учитывали, что сам по себе поток также может занимать ресурсы системы. Настало время немного поговорить и об этом. Рассмотрим два заблуждения, относительно потоков:
Заблуждение первое: сделав два потока вместо одного, мы повысим производительность в два раза. Это не верно. Во-первых потому что ресурс который мы делим между потоками может просто не дать нам повысить производительность. Например, если сеть имеет ширину канала 100mbs, и один поток использовал из них 60mbs, то понятно, что второй поток сможет использовать только оставшихся 40mbs, но никак не 60mbs. Во-вторых само по себе содержание потоков, довольно расточительно для системы. Само по себе переключение между потоками занимает процессорное время. Кроме того, потоки не работают с максимальной эффективностью, из-за необходимой синхронизации доступа к данным.
Заблуждение второе: мы можем создать любое необходимое число потоков. Это не так.
Нужно помнить, что создание потока – ресурсоемкая операция. Так, при создании потока за ним сразу закрепляется 1mb памяти под стек. Это значит, что если у вас памяти имеет размер 2ГБ, то вы не сможете создать более 2000 потоков. Размер стека определяется линковщиком, и для VS это значение по умолчанию равно 1 мегабайту. Однако, мы можем изменить размер стека при создании потока. Для этого нужно использовать конструктор Thread(ThreadStart start, int maxStackSize). Здесь второй параметр задает размер стека для потока. Однако даже в таком случае система выделяет минимум 64КБ под стек (или 256КБ для Vista и Win7), меньший стек сделать нельзя.
Q. Особенности пула потоков.
A. Для решения проблемы накладных расходов при создании потоков, в .NET Framework существует пул потоков ThreadPool. Суть его проста – создается массив, содержащий потоки Thread. Когда нужно выполнить какую-то задачу, пул не создает поток заново, а просто берет уже созданный поток и в нем запускает выполнение кода. Для этого он конечно выбирает не занятый поток. А если все потоки пула заняты? Тогда пул создает новый поток и заносит его в список своих потоков. По умолчанию, пул содержит 25 потоков на каждый системный процессор. То есть, если у вас двуядерный процессор, то пул будет содержать 50 потоков.
Применение пула потоков оправдано, когда число потоков невелико и они часто создаются/уничтожаются. Если же вам нужно создать, например, 200 потоков – использовать пул – плохая идея. Это может привести к существенному падению производительности. Дело в том, что если у пула не хватает потоков для выполнения задач он создает новые потоки, но лишь по одному в секунду (приблизительно). Таким образом, на создание 200 потоков пулу потребуется более трех минут! Что бы убедится в этом, а заодно и привести пример использования пула потоков приведем код (под .NET Framework 3.5):
Код:
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
namespace ConsoleApplication19
{
class Program
{
static void Main(string[] args)
{
for(int i=0;i<200;i++)
{
ThreadPool.QueueUserWorkItem(
new WaitCallback(delegate(object s)
{
//внутри потока - выводим сообщение и спим минуту
Console.WriteLine("Hi from thread at "+DateTime.Now);
Thread.Sleep(60000);
}));
}
Console.ReadLine();
}
}
}
Казалось бы данный код должен мгновенно вывести 200 сообщений «Hi from thread», но этого не произойдет. Сначала будет быстро выведено несколько сообщений (это сработают те потоки, которые уже были в пуле), а затем сообщения будут выдаваться очень медленно – по одному в секунду. Пул создает потоки постепенно, поскольку бережет ресурсы системы (об этом можете почитать более подробно у Рихтера, например). Но факт тот, что если вам нужно быстро создать много потоков – пул не для вас.
Кстати из-за этой особенности пула, наблюдаются и другие артефакты, в системах, которые используют пул. Например, .Net Remouting не позволяет быстро и одновременно обработать много запросов на сервер, поскольку берет потоки для коннектов – из пула.