Многопоточность в С#

Простое упоминание о многопоточности иногда вселяет страх в сердца некоторых программистов. Для остальных — это хороший вызов. Независимо от того, как вы реагируете на нее, знайте, что многопоточность — это область, усеянная минными полями.

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

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

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

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

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

и .NET

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

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

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

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

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

Если вы доберетесь до потока ОС, используя уровень P/Invoke для выполнения прямых вызовов Win32, убедитесь, что информация о потоке платформы применяется только в отладочных целях, и на ней не основана какая-либо программная логика. В противном случае при переходе на другую реализацию CLR вы непременно столкнетесь с чем-то, что нарушит работу приложения.

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

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

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

Эта задача в среде С# действительно довольно проста, но давайте посмотрим, так ли здесь все просто, как может показаться на первый взгляд.

Запуск потоков

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using System.Threading;
public class EntryPoint {
private static void ThreadFuncO  {
Console.WriteLine ( "Привет из потока {0}!", Thread.CurrentThread.GetHashCode()  ) ;
}
static void Main()  {
// Создание нового потока. 
Thread newThread =
new Thread( new ThreadStart(EntryPoint.ThreadFunc) ); 
Console.WriteLine ( "Главный поток {0}",
Thread.CurrentThread.ManagedThreadld ); 
Console.WriteLine ( "Запуск нового потока..." );
// Запуск нового потока. 
newThread.Start ();
// Ожидание завершения работы нового потока. 
newThread.Join();
Console.WriteLine ( "Новый поток завершен" );

Все, что потребуется сделать — это создать объект System.Thread и передать ему экземпляр делегата ThreadStart в качестве параметра конструктора. Делегат ThreadStart ссылается на метод, ничего не принимающий и не возвращающий. В приведенном примере в качестве стартовой точки выполнения нового потока используется статический метод ThreadFunc.

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

В неуправляемом мире С++ должен использоваться идентификатор потока, полученный через Win32 API. В управляемом мире .NET 1.1 вместо этого применяется значение, возвращенное методом GetHashCode.

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

Начиная с .NET 2.0, получить идентификатор управляемого потока можно с помощью свойства Thread.ManagedThreadld. Кроме того, в коде показано, что для получения ссылки на текущий поток необходимо обратиться к статическому свойству Thread. CurrentThread. И, наконец, обратите внимание на вызов метода Join объекта newThread.

В “родном” коде Win32 ожидание завершения потока обычно осуществляется по его дескриптору. Когда поток завершает работу, операционная система выдает сигнал его дескриптору и ожидание прекращается.

Эта функциональность инкапсулируется в методе Thread.Join. В данном случае код ждет завершения потока вечно. Для Thread. Join также предусмотрено несколько перегрузок, которые позволяют указать время ожидания.

В документации MSDN существует некоторая путаница относительно необходимости вызова Thread.GetHashCode или доступа к свойству Thread.ManagedThreadld. Если вы внимательно почитаете документацию MSDN, то обнаружите на итоговой странице о System.Thread замечание о том, что GetHashCode — это средство получения уникального идентификатора управляемого потока в то время, пока этот поток существует. Но если обратиться к описанию Thread.GetHashCode в документации, то там сказано, что для этого нужно использовать свойство Thread.ManagedThreadld.

Отлаживая приведенное выше приложение с использованием windbg из пакета Debugging Tools for Windows с отладочным расширением sos.dll, обнаруживается, что значение, возвращаемое GetHashCode, и значение свойства ManagedThreadld берется из одного и того же места. Можно предположить, что ManagedThreadld делает код более читабельным, потому что более адекватно отображает суть.

Но если разрабатывается приложений для .NET 1.1, то, учитывая, что свойство ManagedThreadld появилось в .NET 2.0, следует использовать GetHashCode. Скорее всего, эта путаница в документации вскоре будет устранена, поскольку это очевидно просто ошибка.

В общем случае лучше всегда полагаться на ManagedThreadld, даже несмотря на то, что GetHashCode возвращает то же самое значение.

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

Управлять приоритетом потока можно через свойство Thread.Priority.

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

Передача данных новым потокам

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

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

В следующем коде показан один из возможных способов решения этой задачи:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
using System.Threading;
using System.Collections;
public class QueueProcessor
{
public QueueProcessor ( Queue theQueue )  { 
this.theQueue = theQueue;
theThread = new Thread( new ThreadStart(this.ThreadFunc) );
}
private Queue theQueue; 
private Thread theThread; 
public Thread TheThread { 
get {
return theThread;
}
public void BeginProcessData ()  { 
theThread.Start();
}
public void EndProcessData ()  { 
theThread.Join();
}
private void ThreadFunc()  {
// ... здесь извлекать элементы theQueue.
}
public class EntryPoint {
static void Main()  {
Queue queuel = new Queue () ; 
Queue queue2 = new Queue () ;
// ... операции наполнения очередей данными. 
// Обработка каждой очереди в отдельном потоке. 
QueueProcessor procl = new QueueProcessor ( queuel ); 
procl.BeginProcessData ();
QueueProcessor proc2 = new QueueProcessor ( queue2 ); 
proc2.BeginProcessData ();
// ... между тем выполнять какую-то другую работу. 
// Ожидать окончания работы, 
procl.EndProcessData (); 
proc2.EndProcessData();

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

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

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

Использование ParameterizedThreadStart

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

В System.Threading.Thread есть перегруженная версия конструктора, позволяющая указать делегат типа ParametrizedThreadStart, который представляет собой делегат, принимающий единственную ссылку object и возвращающий void.

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

Если воспользоваться конструктором, принимающим делегат ParametrizedThreadStart, то предыдущий пример можно переделать следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;
using System.Threading;
using System.Collections;
public class EntryPoint
{
static void Main()  {
Queue queuel = new Queue () ; 
Queue queue2 = new Queue ();
// ... операции наполнения очередей данными. 
// Обработка каждой очереди в отдельном потоке. 
Thread procl = new Thread( EntryPoint.ThreadFunc ); 
procl.Start( queuel );
Thread proc2 = new Thread( EntryPoint.ThreadFunc ); 
proc2.Start( queue2 );
// ... между тем выполнять какую-то другую работу. 
// Ожидать окончания работы, 
procl.Join (); 
proc2.Join ();
}
static private void ThreadFunc ( object obj )  {
// Выполнить приведение входящего объекта к Queue. 
Queue theQueue = (Queue) obj; 
// ... опустошить очередь.
}

Шаблон IOU и асинхронные вызовы методов

В следующих постах будет показано, что схема BeginProcessData/EndProcessData является общепринятым шаблоном асинхронного программирования в .NET Framework.

Шаблон асинхронного программирования BeginMethod/ EndMethod в .NET Framework подобен шаблону IOU, описанному Алленом Вермуленом (Allan Vermeulen) в его статье An Asynchronous Design Pattern (Асинхронный шаблон проектирования) в журнале Dr.Dobb’s Journal (июнь 1996 г.).

В этом шаблоне для запуска асинхронной операции вызывается некоторая функция, и она возвращает вызывающему коду объект IOU (”I owe you” — “я владею тобой”).

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

Этот шаблон интенсивно используется в .NET Framework, и его рекомендуется применять для асинхронных вызовов методов, поскольку он хорошо знаком вашим клиентам, и они будут себя чувствовать уверенно.

Вы можете следить за любыми ответами на эту запись через RSS 2.0 ленту. Вы можете оставить ответ, или trackback с вашего собственного сайта.

Оставьте отзыв

XHTML: Вы можете использовать следующие теги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

 
Rambler's Top100