Сайт Алексея Муртазина (Star Cat) E-mail: starcat-rus@yandex.ru
Мои программы Новости сайта Мои идеи Мои стихи Форум Об авторе Мой ЖЖ
VB коды Статьи о VB6 API функции Самоучитель по VB.NET
Собрания сочинений Обмен ссылками Все работы с фото и видео
О моём деде Муртазине ГР Картинная галерея «Дыхание души»
Звёздный Кот

Самоучитель по VB.NET
Глава7.

Многопоточность BVB.NET

В классическом научно-фантастическом романе Альфреда Бестера «Звезды — цель моя» описывается психокинетическое взрывчатое вещество ПирЕ, всего одна крупинка которого может взорвать целый дом. Для взрыва требуется совсем немного — чьего-нибудь мысленного пожелания. Герою романа приходится решать, сохранить ли тайну или же открыть ее людям, чтобы судьба мира находилась в руках и мыслях каждого человека на планете.

Нечто похожее можно сказать и о многопоточности.

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

Многопоточность в VB .NET страшит меня больше, чем все остальные новшества, причем, как и во многих новых технологиях .NET, это объясняется скорее человеческими, нежели технологическими факторами.

За несколько месяцев до презентации .NET я принимал участие в конференции VBits. Я спросил свою аудиторию, состоявшую из довольно опытных программистов Visual Basic, хотят ли они видеть свободную Многопоточность в следующей версии Visual Basic. Практически все подняли руки. Затем я спросил, кто из присутствующих знает, на что идет. На этот раз руки подняли всего несколько человек, и на их лицах были понимающие улыбки.

Я боюсь многопоточности в VB .NET, потому что программисты Visual Basic обычно не обладают опытом проектирования и отладки многопоточных приложений1. В реализации многопоточности для VB6 действуют повышенные меры защиты (наряду с довольно жесткими ограничениями). Существует только один путь к безопасному использованию многопоточности — вы должны хорошо разобраться в ней и правильно проектировать свои приложения.

1 Чтобы вы не думали, что я безнадежно зазнался, поясню: я программирую многопоточные приложения в течение многих лет и до сих пор сталкиваюсь с нетривиальными ошибками. Многопоточность используется во внутренней работе пакета Desaware NT Service Toolkit, и половина всего времени была потрачена на проектирование, тестирование и отладку управления программными потоками.

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

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

1 Проще говоря, я постараюсь вас до смерти напугать.

2 А если вы собираетесь пролистать эту главу, потому что не намерены использовать многопоточность, позвольте напомнить, что по умолчанию все завершители объектов работают в отдельном потоке завершения! Следовательно, проблемы многопоточности могут возникнуть и в том случае, если вы не создаете потоков в своих приложениях.

Первое знакомство с многопоточностью

Вероятно, вы как программист Visual Basic среднего или высокого уровня хотя бы в общих чертах представляете себе, что такое многопоточность. Упрощенно говоря, Windows позволяет одновременно выполнять несколько фрагментов программного кода, быстро переключаясь между ними. Но что это означает на практике?

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

Итак, любой код, работающий в системе, выполняется в программном потоке (thread).

Тогда что такое «процесс»?

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

Процесс состоит из одного или нескольких программных потоков, работающих в отдельном пространстве памяти. При запуске приложения Windows с точки зрения процесса все выглядит так, словно он один распоряжается всей системной памятью. На самом деле процесс не может записывать данные в адресное пространство других процессов, а другие процессы не могут записывать в его адресное пространство. Разделение адресных пространств обеспечивает повышенную степень защиты и является главной причиной того, что 32-разрядные версии Windows не «зависают» так часто, как прежние 16-разрядные версии1.

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

Все потоки многопоточного приложения работают в одном и том же пространстве памяти.

Ну и что?

Рассмотрим следующий сценарий.

1 Ситуация также описана несколько упрощенно. В Windows NT, 2000 и ХР пространства памяти изолируются более надежно, чем в Windows 95/98/ME. Помимо разделения пространств памяти, процессы обладают и другими возможностями.

 

Фиаско в магазине

Мистер и миссис Купилл — типичная счастливая супружеская пара, живущая в пригороде. Однажды утром мистер Купилл решает приобрести своей супруге новейшую модель электроутюга с подключением к Интернету. Будучи человеком осторожным и несколько стесненным в средствах (после того, как сбережения всей жизни были потрачены на покупку домика в Пало Альто), он проверяет свою кредитку, убеждается в том, что у него хватит денег, после чего отправляется в магазин за покупкой.

В это время миссис Купилл обнаруживает, что в web-магазине проводится распродажа 125-гигабайтных жестких дисков, о которых так мечтал мистер Купилл (чтобы наконец-то установить Office 2005). Проверив состояние кредитной карты, она видит, что у нее как раз хватает денег на покупку, и оформляет заказ.

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

Мистер Купилл с позором покидает магазин и пытается понять, что же произошло. От огорчения он не смотрит по сторонам, и его сметает толпа из пяти тысяч обезумевших подростков, спешащих в магазин за только что вышедшей приставкой Playstation 4 Х-Вох (последняя разработка MS-Sony).

Какое отношение это имеет к многопоточности, спросите вы?

Самое прямое.

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

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

Что произойдет, если эти два потока обратятся к одному объекту СОМ?

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

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

Почему же эта проблема не существовала в Visual Basic 6? Потому что VB6 создает отдельную копию всех глобальных переменных для каждого потока в многопоточном приложении или DLL1. Разделение осуществлялось на уровне Visual Basic 6, а не на уровне ОС. В VB .NET глобальные и общие переменные совместно используются всеми потоками приложения. Таким образом, все проблемы многопоточности лежат исключительно на вашей совести.

1 Точнее, глобальные переменные VB6 хранятся в локальной памяти потока.

 

Подробнее о многопоточности

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

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

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

Структуры данных

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

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

Иначе говоря, иерархия объектов данного примера наводит на мысли о применении наследования.

Ниже приведен класс банковского счета Account приложения Threadingl. В переменной m_Account хранится текущая сумма на счету. Кроме того, в переменных объекта хранятся суммарный расход и суммарные поступления за время существования счета. Также в классе Account хранится объект типа Random — объект CLR, предназначенный для работы со случайными числами и заменяющий функцию VB6 Rnd. Функция GetRandomAmount создает значение в интервале от 0 до 1 доллара и имитирует расходуемые суммы.

Public nbsp;

Protected m_Account As Double 

Protected m_Spent As Double 

Protected m_Deposited As Double 

Private Shared m_Random As New Random()

' Возвращает случайную сумму от $0 до $1.00.

 Protected Shared Function GetRandomAmount() As Double

Dim amount As Double

amount = Int(m_Random.NextDouble * 100)

GetRandomAmount = amount / 100 

End Function

В листинге 7.1 приведена серия свойств, обеспечивающих доступ к переменным класса.

Листинг 7.1. Свойства класса Account1

Property Withdrawn() As Double 

Get

Withdrawn = m_Spent

 End Get

  Set(ByVal Value As Double)

m_Spent = Value 

End Set

 End Property

Property Balance() As Double 

Get

Balance = m_Account 

End Get

  Set(ByVal Value As Double)

m_Account = Value 

End Set

 End Property

Property Deposited() As Double

 Get

Deposited = m_Deposited 

End Get 

Set(ByVal Value As Double)

m_Deposited = Value 

End Set 

End Property

1 Все исходные тексты можно найти на сайте издательства «Питер» www.piter.com. — Примеч. ред.

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

Метод Withdraw (листинг 7.2) получает в качестве параметра сумму, снимаемую со счета, и возвращает фактически снятую сумму (которая при нехватке денег на счету может быть меньше запрашиваемой). Кроме того, снятая сумма прибавляется к суммарному расходу (m_Spent). Метод Deposit заносит указанную сумму на текущий счет и прибавляет ее к суммарным поступлениям (m_Deposi ted). Метод Clear сбрасывает переменные класса в исходное состояние.

Листинг 7.2. Методы Withdraw и Depost класса Account

' Попытаться снять со счета запрашиваемую сумму,

' вернуть фактически снятую сумму.

Protected Function Withdraw(ByVal amount As Double) As Double

If amount > m_Account Then 

amount = m_Account

End If

m_Account = m_Afcount - amount

m_Spent = m_5pent + amount

Return amount

 End Function

Protected Sub Deposit(ByVal amount As Double)

m_Account = m_Account + amount

m_Deposited = m_Deposfted + amount

 End Sub

PubVic Overridable Sub Clear()

m_Account = 0

m_Deposited = 0

m_Spent = 0 

End Sub

End

Счет ребенка представлен классом KidAccount, производным от класса Account. В этом классе добавлена переменная m_FalledRequests, в которой хранится количество случаев, когда ребенок хотел потратить деньги, но не располагал нужной суммой на счету. Как нетрудно предположить, эта переменная будет быстро увеличиваться.

Для зачисления денег на счет используется метод GetAllowance. Можно ли было воспользоваться методом Deposit? Напрямую — нельзя. Поскольку метод Deposit базового класса является защищенным, он не может напрямую вызываться извне. Мы также могли создать новый метод Deposit и воспользоваться ключевым словом Shadows, чтобы скрыть унаследованный метод базового класса (а также вызвать метод базового класса через объект MyBase, но об этом речь пойдет в главе 10).

Public unt 

Inherits Account

Private m_FailedRequests As Double

 Readonly Property FailedRequests() As Double

Get

FailedRequests = m_FailedRequests

End Get 

End Property

' Получение карманных денег от родителя

Public Sub GetAllowance(ByVal amount As Double)

deposit (amount)

End Sub

Деньги расходуются методом Spend. Метод выбирает случайную сумму до $1 и пытается снять ее со счета. Если при снятии баланс падает до нуля, попытка считается неудачной, а переменная m_FailedRequests увеличивается. Метод Clear сбрасывает как переменную m_FailedRequests, так и методы базового класса (листинг 7.3).

Листинг 7.3. Методы Spend и Clear класса KidAccount

' Попытка потратить случайную сумму

 Public Sub Spend()

Dim amount As Double

amount = GetRandomAmount()

If amount > m_Account Then amount = m_Account

If amount = 0 Then m_FailedRequests = m_FailedRequests + 1

Else Withdraw (amount)

End If End Sub

' Обнуление переменных объекта и базового класса

 Overrides Sub Clear()

m_FailedRequests = 0

MyBase.Clear() 

End Sub 

End

Класс ParentAccount (листинг 7.4), также объявленный производным от Account, моделирует родительский счет. Метод GiveAllowance выбирает случайную сумму, которая снимается со счета отца. Возвращаемое значение определяет сумму, фактически зачисляемую на счет ребенка методом GetAllowance. Метод Deposi tPayroll зачисляет на родительский счет ежемесячную зарплату.

Листинг 7.4. Класс ParentAccount

Public ccount 

Inherits Account

' Метод выбирает случайную сумму карманных расходов

' и снимает ее со счета.

Public Function GiveAllowanceO As Double

Dim amount As Double

amount = GetRandomAmount() 

amount = Withdraw(amount)

1 Возвращение фактически снятой суммы (может быть равна 0)

Return (amount) 

End Function

Public Sub DepositPayrolKByVal amount As Double)

deposit (amount)

 End Sub

 End

Имитация

Класс FamilyOperation имитирует финансовые отношения в семье. В него включена переменная Kids(), содержащая объекты KidAccount, по одному для каждого ребенка в семье. Переменная Parent содержит объект ParentAccount, представляющий родительский счет.

Имитация рассчитана на работу в одном или нескольких фоновых потоках. Это соответствует применению пула потоков для обработки клиентских запросов или (с небольшими изменениями) наличию отдельного независимого потока для каждого клиента.

Для управления потоками используется переменная Threads, содержащая ссылку на массив объектов System.Threading.Thread — эти объекты управления потоками поддерживаются CLR.

Переменная m_NumberOfKids содержит количество детских счетов. Присваивая переменной m_Stopping значение True, вы сигнализируете фоновым потокам о необходимости завершения. Переменная m_Random относится к типу Random () и используется имитатором для определения суммы, переводимой на счета детей.

Public peration 

Private Kids() As KidAccount 

Private Parent As ParentAccount

Private ThreadsO As System.Threading.Thread

Private m_NumberOfKids As Integer

 Private m_Stopping As Boolean

Private m_Random As New Random()

Объект FamilyOperation спроектирован таким образом, что значение свойства NumberOfKids задается перед началом имитации, причем изменить его уже не удастся. Метод Get тривиален: он просто возвращает значение внутренней переменной m_NumberOfKids. Метод Set сначала проверяет присваиваемую величину; допустимыми считаются значения в интервале от 1 до 50. Кроме того, метод проверяет, было ли значение m_NumberOfKids присвоено ранее; если проверка дает положительный результат, инициируется ошибка (листинг 7.5).

Метод Throw является новым и рекомендуемым способом инициирования ошибок в VB .NET. В CLR определено довольно большое количество документированных исключений. В нашем примере используются исключения ArgumentOutOfRangeException (недопустимое значение параметра или свойства) и InvalidOperationException (попытка выполнения недопустимой операции). Исключения и новый механизм обработки ошибок рассматриваются в главе 8.

Если проверка проходит успешно, метод Переходит к инициализации родительского счета и счетов детей.

Листинг 7.5. Свойство NumberOfKids класса FamilyOperation

Property NumberOfKidsO As Integer

 Get

NumberOfKids = m_NumberOfKids

 End Get

 SeUByVal Value As Integer)

If Value < 1 Or Value > 50 

Then Throw New ArgumentOutOfRangeException(_

"Property must be between 1 and 50")

 End If

If m_NumberOfKids <> 0 Then

Throw New InvalidOperationException(_ 

"NumberOfKids may only be set once")

 End If

Dim Kid As Integer

 m_NumberOfKids = Value 

ReDim Kids(m_NumberOfKids - 1)

 For Kid = 0 To m_NumberOfKids - 1 

Kids(Kid) = New KidAccount() 

Next

Parent = New ParentAccount()

 End Set

 End Property

Метод KillFamily1 просто присваивает переменной m_Stopping значение True, сигнализируя потокам о завершении работы. О том, как это происходит, будет рассказано ниже. Средства зачисляются на родительский счет методом ParentPayday.

1 При написании этого метода ни одна семья не пострадала.

Private Sub KillFamily()

m_Stopping = True

 End Sub

Public Sub ParentPayday(ByVal Amount As Double)

Parent.DepositPayroll (Amount)

 End Sub

Свойства TotalDepositToParent, TotalAllocatedByParent и ParentBalance предназначены для получения статистики о родительском счете.

Всегда должно соблюдаться следующее условие: 

TotalDepositToParent - TotalAllocatedByParent = ParentBalance

Код этих свойств приведен в листинге 7.6.

Листинг 7.6. Свойства TotaiDepositedToParent, TotalAllocatedByParent и ParentBalance

 Public Readonly Property TotalDeposi tedToParentO As Double

 Get

If m_NumberOfKids = 0 Then 

Return 0 Return Parent.Deposited 

End Get

 End Property

Public Readonly Property TotalAllocatedByParentO As Double

 Get

If m_NumberOfKids = 0 Then 

Return 0 Return Parent.Withdrawn 

End Get 

End Property

Public Readonly Property ParentBalance() As Double

Get

If m_NumberOfKids = 0 Then Return 0 

Return Parent.Balance 

End Get

 End Property

Свойства TotalGivenToKids, TotalSpentByKids, TotalKidsBalances и Total FailedRequests используются для получения сводной статистики по всем детским счетам. Следующее условие всегда должно выполняться:

 TotalGivenToKids - TotalSpentByKids = TotalKidsBalance

Программный код этих свойств приведен в листинге 7.7.

Листинг 7.7. Свойства для получения сводной информации о счетах детей

Public Readonly Property TotalGivenToKidsO As Double 

Get

If m_NumberOfKids = 0 Then Return 0

Dim Idx As Integer

Dim Total As Double

For Idx = 0 To m_NumberOfKids - 1

Total = Total + Kids(Idx).Deposited

 Next

Return Total 

End Get 

End Property

Public Readonly Property TotalSpentByKidsQ As Double

 Get

If m_NumberOfKids = 0 Then Return 0

Dim Idx As Integer

Dim Total As Double

For Idx = 0 To m_NumberOfKids - 1

Total = Total + Kids(Idx).Withdrawn

 Next

Return Total

 End Get 

End Property

Public Readonly Property TotalKidsBalancesO As Double

 Get

If m_NumberOfKids = 0 Then Return 0

Dim Idx As Integer

Dim Total As Double

For Idx = 0 To m_NumberOfKids - 1

Total = Total + Kids(Idx).Balance 

Next

Return Total 

End Get 

End Property

Public Readonly Property TotalFailedRequestsO As Double 

Get

If m_NumberOfKids = 0 Then Return 0

 Dim Idx As Integer 

Dim Total As Double For Idx = 0 To m_NumberOfKids - 1

 Total = Total + Kids(Idx).FailedRequests

Next

Return Total 

End Get

 End Property

Вся настоящая работа выполняется методом KidsSpending. Он состоит из цикла, выполняемого до тех пор, пока переменной m_Stopping не будет присвоено значение True (листинг 7.8). В цикле последовательно выполняются следующие действия:

  •  выбор случайного ребенка;
  •  запрос «карманных денег» с родительского счета — случайной суммы не более $1;
  •  зачисление возвращаемой суммы на счет ребенка;
  •  ребенку предоставляется возможность истратить случайную сумму денег.

Листинг 7.8. Функция KidsSpending

Public Sub KidsSpending()

 Dim Childlndex As Integer

 Dim Allowance As Double 

Dim thiskid As KidAccount

 Do

' Случайно выбранный ребенок тратит некоторую сумму.

Childlndex = CInt(Int(m_Random.NextDouble() * СDbl(m_NumberOfKids)))

thiskid = Kids(Childlndex)

Allowance = Parent.GiveAllowance()

 thiskid.GetAllowance (Allowance)

 thiskid.Spend()

 Loop Until m_Stopping

End Sub

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

Метод StartThread создает заданное количество новых потоков и запускает их. Он динамически переобъявляет размер массива Threads и создает новые объекты Thread, каждому из которых передается делегат для метода KidsSpending.

Делегат?

Вероятно, вы знакомы с оператором VB6 AddressOf, возвращающим адрес (указатель) функции в модуле. В VB .NET оператор AddressOf возвращает делегата, которого лучше всего представлять себе как указатель на метод конкретного объекта. В данном примере возвращается указатель на метод KidsSpending текущего объекта1.

1 Напрашивается вопрос: можно ли получить делегата для метода другого объекта и вызвать его? Ответ: да, можно. Более того, как будет показано ниже, именно так работают события VB.NET.

Каждый запущенный поток вызывает метод KidsSpending.

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

Задавая свойству IsBackground значение True, мы сообщаем CLR, что создаваемый поток является фоновым и должен уничтожаться автоматически при прекращении работы всех не фоновых потоков1.

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

Public Sub StartThreads(ByVal ThreadCount As Integer) 

If ThreadCount < 1 Then ThreadCount = 1

 ReDim Threads(ThreadCount - 1)

 Dim Idx As Integer For Idx = 0 To ThreadCount - 1

Threads(Idx) = New Threading.Thread(AddressOf Me.KidsSpending)

Threads(Idx).Priority = System.Threading.ThreadPriority.BelowNormal

Threads(Idx).IsBackground = True

Threads(Idx).Start()

 Next 

End Sub

В методе StopThreads продемонстрирован новый механизм обработки ошибок в VB .NET. Базовый принцип уничтожения потоков прост. Сначала вызывается метод KillFamily, который присваивает переменной m_Stopping значение True. После этого все потоки должны завершиться при достижении конца цикла Do. Метод Join объекта Thread заставляет текущий поток дождаться фактического завершения заданного потока.

В приведенном примере метод Join всегда работает нормально, поскольку все потоки начинают работу при создании. Но если метод будет вызван перед инициализацией массива Threads, метод GetUpperBounds инициирует исключение. В результате управление передается блоку, следующему сразу же за командой Catch. В листинге 7.9 ошибки просто игнорируются.

Листинг 7.9. Метод StopThreads

Public Sub StopThreads() 

Dim Idx As Integer Try

KillFamily() ' Все потоки должны остановиться

 For Idx = 0 To Threads.GetUpper8ound(0) 

' Дождаться завершения потока 

Threads(Idx).Join() Next Catch

' Игнорировать все ошибки

 End Try

End Sub

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

На форме находятся два текстовых поля, в которых вводится количество банковских счетов у детей и количество потоков. Также на ней имеются три кнопки: для занесения денег на счет, для запуска и для остановки имитации. Результаты выводятся в виде списка. С формой ассоциируется единственная переменная ByFamily, содержащая ссылку на объект FamilyOperation. Перед выгрузкой формы вызывается метод StopTnreads, поэтому фоновые потоки останавливаются даже в том случае, если пользователь не нажал кнопку Stop.

Public bsp;

Dim myFamily As FamilyOperation

1 Форма переопределяет Dispose для очистки списка компонентов.

 Public Overloads Overrides Sub Dispose()

 MyBase. Dispose() If Not (components Is Nothing) Then

components.Dispose() 

End If

 ' Остановить потоки If Not myFamily Is Nothing Then

myFamily.StopThreads() 

End If

End Sub

Метод UpdateResults (листинг 7.10) заполняет список сводной статистической информацией. По этим данным можно убедиться в том, что разность между зачисленной и снятой со счета суммой равна текущему балансу (как для отца, так и для детей).

Листинг 7.10. Формы метода UpdateResults

Private Sub UpdateResults()

 IstResults.Items.Clear() 

IstResults.Items.Add ("Parent:")

 IstResults.Items.Add ("- Total Deposited: " +

Format(myFamily.TotalDepositedToParent, "0.00"))

 IstResults.Items.Add ("- Total Withdrawn: " +

Format(myFamily.TotalAllocatedByParent, "0.00"))

 IstResults.Items.Add ("- Expected Balance: " + _

Format(myFamily.TotalDepositedToParent - _

myFamily.TotalAllocatedByParent, "0.00"))

 IstResults.Items.Add ("- Actual Balance: " + _

Format(myFamily.ParentBalance, "0.00")) 

IstResults.Items.Add ("Kids:")

 IstResults.Items.Add ("- Total Deposited: " + _

Format(myFamily.TotalGivenToKids, "0.00"))

 IstResults.Items.Add ("- Total Withdrawn: " + _

Format(myFamily.TbtalSpentByKids, "0.00"))

 IstResults.Items.Add ("- Expected Balance: " +_

Format(myFamily.TotalGivenToKids  _

  myFamily.TotalSpentBykids, "0.00"))

 IstResults.Items.Add ("- Actual Balance: " +

Format(myFamily.TotalKidsBalances, "0.00"))

End Sub

Содержимое списка обновляется раз в одну-две секунды по событиям таймера. Кнопка Deposit заносит заданную сумму на родительский счет при помощи метода FamilyOperation.ParentPayday. Кнопка Start создает новый объект FamilyOperation, задает значение свойства NurtiberOfKids и затем вызывает метод StartThreads с указанным количеством потоков. Метод StopThreads сначала останавливает потоки, а затем выводит окончательный результат в списке. Программный код, выполняющий все эти операции, приведен в листинге 7.11.

Листинг 7.11. Код формы приложения Threading 1

Protected Sub Timerl_Tick(ByVal sender As System.Object, _

ByVal e As System.EventArgs) Handles timerl.Tick 

UpdateResults() 

End Sub

Protected Sub cmdDeposit_Click(ByVal sender As System.Object, _ 

ByVal e As System.EventArgs) Handles cmdDeposit.Click

Dim Amount As Double

Amount = VaHtxtDepositO .Text)

myFamily.ParentPayday (Amount) 

End Sub

Protected Sub cmdStart_Click(ByVal sender As System.Object, _

ByVal e As System.EventArgs) Handles cmdStart.Click 

myFamily = New FamilyOperation()

 Dim Kids As Integer

 Dim Threads As Integer

Kids = CInt(Val(txtKids().Text)) 

Threads = CInt(Val(txtThreads().Text))

 myFamily.NumberOfKids = Kids 

myFamily.StartThreads (Threads) 

IstResults.I terns.Clear() 

timerl.Enabled = True 

cmdStart.Enabled = False

 cmdStop.Enabled = True

 cmdDeposit.Enabled = True 

End Sub

Protected Sub cmdStop_Click(ByVal sender As System.Object, _

 ByVal e As System.EventArgs) Handles cmdStop.Click

myFamily.StopThreads()

cmdStop.Enabled = False

cmdStart.Enabled = True

cmdDeposit.Enabled = False

UpdateResults()

 End Sub

Тестирование

Чтобы поближе познакомиться с работой нашего приложения, попробуем запустить его со стандартными параметрами: для десяти детей и одного потока. Нажмите кнопку Deposit 20 или 30 раз, чтобы почувствовать, как деньги переходят от отца к детям и как дети их расходуют. Типичная ситуация показана на рис. 7.1.

Рис. 7.1. Форма приложения с одним потоком

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

Вам удалось найти какие-нибудь недочеты?

Попробуйте запустить приложение для 10 детей с 10 фоновыми потоками. Нажмите кнопку Deposit несколько раз (чтобы получить результат, показанный на рис. 7.2, потребуется несколько сот нажатий).

Рис. 7.2. Форма приложения с несколькими потоками

В чем проблема?

Тестируемое приложение устроено чрезвычайно просто. В объекте Account определены два метода:

Protected Function Withdraw(ByVal amount As Double) As .Double

If amount > m_Account Then amount = m_Account

End If 

m_Account = m_Account - amount ,

m_Spent = m_Spent + amount

Return amount 

End Function

Protected Sub Deposit(ByVal amount As Double)

m_Account = m_Account + amount

m_Deposited = m_Deposited + amount 

End Sub

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

Однако в нашем примере это не так.

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

m_Account = m_Account - amount

 m_Spent = m_Spent + amount

 m_Account = m_Account + amount

 m_Deposited = m_Deposited + amount

Как это возможно?

Давайте еще раз проанализируем метод Deposit, но на этот раз воспользуемся листингом на промежуточном языке (IL), сгенерированном для этого метода (листинг 7.121)

Листинг 7.12. Промежуточный код метода Deposit объекта Account

.meth'od family instance void Deposit(float64 amount) 11 managed

// Code size 31 (Gxlf)

.maxstack 8

IL_0000: пор

IL_0001: ldarg.0

IL_0002: ldarg.0

IL_0003: Idfld float64 Threading.Account::m_Account

IL_0008: ldarg.1

IL_0009: add

IL_000a: stfld float64 Threading.Account::m_Account

IL_000f: ldarg.0

IL_0010: ldarg.0

IL_0011: Idfld float64 Threading.Account::m_Deposited

IL_0016: ldarg.1

IL_0017: add

IL_0018: stfld float64 Threading.Account::m_Deposited

IL_001d: пор

IL_001e: ret } // end of method Account::Deposit

1 Листинг получен при помощи дизассемблера .NET. Проблемы, связанные с относительной простотой дизассемблирования приложений .NET (VB или С#), рассматриваются в главе 16.

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

ra_Account = m_Account + amount 

преобразуется в фрагмент

IL_0001: ldarg.0

IL_0002: ldarg.0

IL_0003: Idfld float64 Threading.Account::m_Account

IL_0008: ldarg.1

IL_0009: add

IL_000a: stfld float64 Threading.Account::m_Account

Как можно предположить, в начале этого фрагмента m_Account дважды заносится в стек, после чего верхняя величина в стеке загружается в регистр. Затем загружается указатель на аргумент amount и две величины суммируются. Результат присваивается переменной m_Account, находящейся в стеке (поскольку она была загружен дважды). Вероятно, происходящее будет понятно читателям, имеющим опыт работы с обратной польской записью1 (например, владельцам инженерных калькуляторов HP). Остальные пусть не огорчаются; не так уж важно, что именно здесь происходит. Вам лишь необходимо понять, что действия выполняются в следующей последовательности.

1 Информацию об обратной польской записи можно найти по адресу http://www.hpmuseum.org/rpn.htm.

1. Переменная m_Account загружается в регистр.

2. Параметр amount прибавляется к значению m_Account.

3. Результат сохраняется в переменной m_Account.

Что произойдет, если два потока попытаются одновременно выполнить одну и ту же операцию? Например, возможна следующая последовательность событий.

1. Поток 1 загружает m_Account в регистр.

2. Операционная система прерывает поток 1.

3. Поток 2 загружает m_Account в регистр.

4. Поток 2 прибавляет параметр amount к значению в регистре.

5. Поток 2 сохраняет результат в переменной m_Account.

6. Поток 2 прерывается.

7. Поток 1 прибавляет параметр amount к содержимому регистра (в котором восстанавливается значение, содержавшееся до прерывания потока на шаге 2, однако текущее содержимое регистра не учитывает изменения, внесенные потоком 2!).

8. Поток 1 сохраняет результат сложения в переменной m_Account, фактически стирая значение, сохраненное потоком 2.

Результат — значение amount прибавлялось к переменной m_Account дважды, но в переменной отражена лишь одна из этих операций!

Другими словами, поскольку операционная система выполняет переключение задач на ассемблерном уровне (более низком, чем уровень IL), вы должны учитывать возможность переключения внутри отдельных команд VB .NET.

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

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

Как вы помните, в нашем примере переводимые суммы составляли от 0 до 1 доллара. Если предположить, что средняя сумма равна 50 центам, а общие зачисления на счет равны 7 миллионам долларов (см. рис. 7.2), значит, вероятность ошибки равна примерно 1/14 000 000.

Спрашивается, как отлаживать код, в котором ошибка возникает один раз из 14 000 000 выполнений? А ведь последствия ошибок могут быть самыми разными: от пропажи нескольких центов со счета в банковской программе до смерти пациента в больнице (при выборке сведений о дозировках лекарств из медицинской базы данных).

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

Означает ли это, что вы не должны использовать многопоточность?

Нет.

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

А если вы еще недостаточно напуганы, учтите еще одно обстоятельство.

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

 

Первый уровень защиты: проектирование

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

Существует несколько разных подходов к устранению этих проблем.

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

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

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

Каждый поток должен сохранить свой индекс. Для сохранения можно было бы воспользоваться стековой переменной (например, одной из переменных метода KidsSpending), однако в приложении Threading2 продемонстрирован другой подход. Переменная ThisThreadlndex объявляется общей для всех экземпляров класса, что эквивалентно ее объявлению как глобальной. Но еще важнее тот факт, что с этой переменной ассоциируется атрибут ThreadStatic(), означающий, что для каждого потока приложения создается отдельная копия этой переменной1. Вам это ничего не напоминает? Вспомните, о чем говорилось выше: именно так VB6 поступает со всеми глобальными переменными. Для поддержки хранения отдельных копий переменных для каждого потока операционная система использует локальную память потока (как при указании атрибута ThreadStatiс, так и при хранении глобальных переменных в VB6).

Поскольку каждый поток создает и выполняет свой метод KidsSpending, копия переменной Threadlndex загружается текущим значением счетчика Thread-Counter, который после этого увеличивается.

Впрочем, на самом деле индекс потока сохраняется несколько иначе: сначала переменная ThreadCounter увеличивается, а затем ThisThreadlndex присваивается новое значение, уменьшенное на 1. Существует очень малая вероятность того, что сама переменная ThreadCounter приведет к проблемам многопоточности, поскольку она совместно используется всеми потоками. Чтобы ликвидировать даже малейшую вероятность конфликта между потоками за эту переменную (например, получения одинаковых индексов двумя потоками), для увеличения переменной ThreadCounter применяется метод Threading. Interlocked. Increment. Этот общий метод2 класса Threading. Interlocked выполняет атомарное увеличение переменной. Другими словами, процесс увеличения не может быть прерван операционной системой3.

1 Как будет показано в главе 11, атрибуты .NET — весьма обширная тема. Пока можете рассматривать атрибуты как способ передачи данных CLR — в нашем примере это информация о том, как должна компилироваться переменная.

2 Напоминаю: общие методы принадлежат всему классу, а не его конкретным объектам и могут вызываться без указания объекта.

3 Возможно, вы подумали, нельзя ли воспользоваться методом Interlocked. Increment для увеличения переменной m_Account класса Account? Нет, нельзя (метод позволяет увеличивать переменные только на 1), но вы мыслите в верном направлении, и позднее я покажу один из вариантов применения этой методики.

Переменная ThisThreadlndex содержит уникальный номер потока, который используется при индексации массива Kids (листинг 7.13) и гарантирует, что с каждым детским счетом работает один и только один поток.

Листинг 7.13. Метод KidsSpending приложения Threading?

Private ThreadCounter As Integer

<ThreadStatic()> Private Shared ThisThreadlndex As Integer

Public Sub KidsSpending()

 Dim Allowance As Double

 Dim thiskid As KidAccount

ThisThreadlndex = Threading.Interlocked.Increment(ThreadCounter) - 1

 Do

' Каждый детский счет обслуживается одним потоком 

Childlndex = CInt(Int(m_Random.NextDouble()*_ 

CDbl(m_NumberOfKids)))

 thiskid = Kids(ThisThreadlndex)

Allowance = Parent.GiveAllowance()

 thiskid.GetAllowance (Allowance)

thiskid. SpendO Loop Until m_Stopping

End Sub

Результат показан на рис. 7.3. После многочисленных нажатий кнопки Deposit становится видно, что родительский объект по-прежнему подвержен многопоточным ошибкам, потому что он совместно используется разными потоками. Тем не менее объекты Kids работают правильно: в результате изменений в архитектуре приложения они перестали быть общими.

Рис. 7.3. Приложение Threading

Почему я представил вам пример, ограничивающийся частичным решением проблемы?

Потому что он демонстрирует одну из основных концепций СОМ, которая может сыграть важную роль при выполнении нетривиальных операций VB .NET, включая операции, связанные со взаимодействием с объектами СОМ.

Из-за применения механизма подсчета ссылок объекты СОМ также подвержены многопоточным проблемам: внутренние счетчики ссылок являются общими для всех потоков, использующих объект. В СОМ определяются три разных потоковых модели: однопоточная (single thread), совместная модель (STA, Single Threaded Apartment) и свободная (МТА, Multi-Threaded Apartment). Решения для однопоточной и совместной модели основаны на описанных выше принципах. Ограничивая доступ к объекту потоком, создавшим объект, эти модели фактически ликвидируют проблемы многопоточности, поскольку с заданным объектом (и всеми его методами/свойствами) может работать только один поток.

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

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

 

Второй уровень защиты: синхронизация

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

В проекте Threading3 продемонстрирован подход, основанный на «грубой силе», при котором синхронизируется весь класс. Это означает, что со всеми методами и свойствами класса потоки могут работать лишь поочередно. Если некоторый поток работает с методом или свойством класса, никакие другие потоки не могут работать с методами или свойствами этого класса. CLR переводит их в состояние ожидания до тех пор, пока первый поток не завершит операции с методом или свойством. Данное приложение основано на приложении Threading 1 (другими словами, потоки выбирают детские счета случайным образом).

Фрагменты, приведенные в листинге 7.14, дают представление об изменениях в классах Account и KidsAccount.

Листинг 7.14. Изменения в классах Account и KidsAccount в приложении Threading3

Imports System.Runtime.Remoting 

' Наследует от ContextBoundObject 

' для синхронизации KidAccount.

 Public nbsp;

Inherits ContextBoundObject

.

.

.

End

' Синхронизировать KidAccount,

' чтобы избавиться от проблем с многопоточным доступом.

 <Contexts.Synchronization()> Public nt 

Inherits Account

.

.

.

End

Прежде всего бросается в глаза то, что класс Account объявляется производным от ContextBoundObject. В первоначальном варианте он был производным только от Object (поскольку в .NET все типы являются производными от Object). Класс, производный от ContextBoundObject (который, в свою очередь, является производным от MarshalByRefObject и Object), наследует дополнительную функциональность, позволяющую CLR ассоциировать объекты с контекстом. В частности, контекст позволяет управлять внешним доступом. Объявление класса производным от ContextBoundObject не влияет на внутреннее устройство самого класса Account. Однако при работе с классом KidAccount CLR видит установленный атрибут Synchronization, указывающий на то, что одновременный доступ к классу должен ограничиваться одним потоком.

На рис. 7.4 показан результат выполнения примера Threading3 после многих нажатий кнопки Deposit.

Рис. 7.4. Приложение Threading3

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

Мы уже выяснили, что в данном случае синхронизация критична только для двух методов: Deposit и Withdraw. Следовательно, синхронизация всех методов и свойств класса — явный перебор, замедляющий работу программы.

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

Помимо атрибута Synchronization классу можно назначить атрибут Thread-Af inity. Атрибут Synchronization сообщает CLR о том, что с объектом в любой момент времени может работать только один поток. Атрибут ThreadAffinity сообщает CLR, что объект доступен только для того потока, которым он был создан, и в случае необходимости CLR следует организовать передачу данных между потоками, чтобы один поток мог получить доступ к методам и свойствам объекта, существующего в контексте другого потока. Аналогичная концепция используется СОМ для реализации потоковой модели STA. Учтите, что эти атрибуты не обеспечивают синхронизации общих методов и свойств, относящихся ко всем экземплярам класса.

Ручная синхронизация

Атрибуты Synchronization и ThreadAffinity обеспечивают простейшую возможность синхронизации доступа ко всем методам и свойствам класса. Как оценить разумность этого подхода? Очень просто: заглянуть в бета- документацию, где под заголовком «Принципы синхронизации» сказано: «...Годится для наивных пользователей».

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

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

В программе Threading4 продемонстрирован один из способов решения этой задачи с применением новой команды VB .NET Sync Lock. Во внутренней реализации этой команды синхронизация программных блоков осуществляется при помощи специального объекта, называемого монитором. В листинге 7.15 приведены изменения исходной программы Threadingl.

Листинг 7.15. Измененные фрагменты приложения Threading4 (относительно Threadingl) 

Imports System.Runtime.Remoting

 Public /font>

Protected m_Account As Double

Protected m_Spent As Double

Protected m_Deposited As Double

Private Shared m_Random As New Random()

Protected LockingObject As String = "HoldTheLock"

' Попытаться снять со счета запрашиваемую сумму, 

' вернуть фактически снятую сумму.

Protected Function Withdraw(ByVal amount As Double) 

As Double SyncUock LockingObject 

If amount > m_Account Then

amount = m_Account 

End If

m_Account = m_Account - amount

 m_Spent = m_Spent + amount

 End SyncLock 

Return amount 

End Function

Protected Sub Deposit(ByVal amount As Double)

 SyncLock LockingObject 

m_Account = m_Account + amount

 m_Deposited = m_Deposited + amount

 End SyncLock

 End Sub

End

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

В начале блока SyncLock CLR проверяет, не установил ли какой-нибудь другой поток блокировку переменной LockingObject. Если блокировка отсутствует, то переменная LockingObject блокируется и программе разрешается дальнейшее выполнение. При выходе из блока SyncLock блокировка переменной LockingObject снимается. Если при достижении команды SyncLock переменная LockingObject оказывается заблокированной, текущий поток приостанавливается и продолжает работу лишь после того, как поток, установивший блокировку, снимет ее с объекта.

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

Как видно из рис. 7.5, синхронизация базового класса Account (вместо производных классов KidAccount и ParentAccount) обеспечивает правильную синхронизацию как родительских, так и детских счетов.

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

Допустим, у вас имеется два потока, А и В.

Поток А входит в блок SyncLock и блокирует переменную LockingObject.

Поток В пытается войти в блок SyncLock, но ему это не удается из-за блокировки переменной LockingObject

Рис. 7.5. Приложение Threading4

Тем временем поток А, все еще находящийся в блоке Sync Lock, переходит в ожидание некоторой операции, выполняемой потоком В1.

Поток А ожидает действий со стороны потока В, но работа потока В приостановлена до тех пор, пока объект А не снимет блокировку с объекта LockingObject. В результате оба потока стоят на месте.

Подобная ситуация называется взаимной блокировкой (deadlock). Она возникает каждый раз, когда работа двух или более потоков приостанавливается в ожидании действий со стороны других потоков2.

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

1 Существуют разные ситуации, при которых потоку приходится ожидать выполнения операций другим потоком. Может быть, поток В отвечает за некоторую фоновую операцию или содержит класс с установленным атрибутом ThreadAf f i ni ty, обращения к которому должны происходить из этого потока.

2 Один из принципов, известных многим программистам, гласит: «Если взаимная блокировка может произойти, она непременно произойдет».

Синхронизация и ожидание

При внимательном взгляде на рис. 7.5 можно заметить в конце списка новую строку. В ней выводится количество неудачных попыток — сумма переменных m_FailedRequests по всем объектам детских счетов. Другими словами, значение в этой строке увеличивается каждый раз, когда ребенок хочет потратить деньги, но оказывается, что у него на счету нет достаточной суммы1.

При внимательном анализе метода KidsSpending (листинг 7.16) можно заметить, что в нем работает бесконечный цикл, который пытается тратить деньги даже в том случае, если счет пуст.

Листинг 7.16. Метод KidsSpending (приложение Threading4)

Public Sub KidsSpending() 

Dim Childlndex As Integer 

Dim Allowance As Double

 Dim thiskid As KidAccount

 Do

' Случайно выбранный ребенок тратит некоторую сумму.

Childlndex = CInt(Int(m_Random.NextDouble() * CDbl(m_NumberOfKids)))

thiskid = Kids(Childlndex)

Allowance = Parent.GiveAllowance() 

thiskid.GetAllowance (Allowance)

thiskid.Spend() Loop Until m_Stopping

End Sub

Чтобы ознакомиться с побочными эффектами такого решения, откройте окно диспетчера задач во время работы этой программы.

Рис. 7.6. Влияние приложения Threading4 на быстродействие системы

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

Если вы увидите, что загрузка процессора составляет 99 % (см. рис. 7.6), значит, что-то определенно не так. И действительно, наша программа поглощает все свободное процессорное время и существенно снижает быстродействие системы. Короче говоря, это приложение спроектировано просто ужасно.

На самом деле наше приложение должно быть достаточно «умным» и дожидаться появления денег на счету ребенка перед тем, как их тратить. Одна из приятных особенностей многопоточных приложений заключается в том, что вы можете приостанавливать отдельные потоки без остановки всей программы. Например, можно приостановить поток, выполняющий фоновую операцию, и это никак не отразится на пользовательском интерфейсе. Более того, приостановленный поток в режиме ожидания (например, ожидающий объекта, заблокированного вызовом Sync Lock) практически не расходует системных ресурсов.

Проблема решается в приложении Threading5. Начнем с рассмотрения листинга 7.17, в котором для синхронизации доступа вместо блока SyncLock используется объект Mutex.

Листинг 7.17. Класс Account приложения Threading5

Public nbsp;

Protected m_Account

 As Double Protected m_Spent As Double

 Protected m_Deposited As Double 

Private Shared m_Random As New Random()

Protected myMutex As New Threading.Mutex(False)

Protected Shared MoneyAvailable As New Threading.ManualResetEvent(False)

Property Deposited() As Double 

Get

Deposited = m_Deposited

 End Get

 Set

m_Deposited = Value 

End Set

 End Property

' Попытаться снять со счета запрашиваемую сумму, 

' вернуть фактически снятую сумму.

Protected Function Withdraw(ByVal amount As Double) As Double

 Try

myMutex.WaitOne()

 Catch e As Threading.ThreadlnterruptedException

Return 0 

End Try

If amount > m_Account Then

amount = m_Account 

End If

m_Account = m_Account - amount 

m_Spent = m_Spent + amount 

myMutex.ReleaseMutex() 

Return amount

 End Function

Protected Sub Deposit(ByVal amount As Double)

 Try

myMutex.WaitOne() Catch e As Threading.ThreadlnterruptedException

Return 

End Try

m_Account = ra_Account + amount 

m_Deposited = m_Deposited + amount

 myMutex.ReleaseMutex() 

End Sub

End

Чем же применение объекта Mutex принципиально отличается от решения с SyncLock? B данном случае — ничем. В некоторых ситуациях объект Mutex обеспечивает большую гибкость, поскольку ожидание может выполняться сразу для нескольких объектов Mutex. Я привел этот пример только для того, чтобы показать, что существуют и другие объекты синхронизации. Обработчик ошибок предотвращает ошибку времени выполнения при прерывании потока (как вы вскоре убедитесь, это существенно). Прежде чем переходить к написанию многопоточных приложений, непременно прочитайте справочную документацию пространства имен System.Threading и познакомьтесь с разными объектами синхронизации1.

В классе Account также определяется общий объект ManualResetEvent, использующий события синхронизации Win32 (термин «event» не имеет ничего общего с привычными событиями Visual Basic). Объект объявлен общим, поскольку признак наличия денег в нашей имитации является общим для всех объектов детских счетов2:

Protected Shared MoneyAvailable As New Threading.ManualResetEvent(False)

Метод Spend класса KidAccount проверяет, равна ли доступная сумма нулю. При отсутствии денег вызывается метод MoneyAvailable.WaitOne(), который приостанавливает выполнение потока и ожидает установки объекта ManualResetEvent другим потоком.

Команда Wait находится внутри блока Try. Это связано с тем, что в некоторых ситуациях ожидание прерывается самим приложением, точнее говоря, в конце своей работы приложение должно прервать все ожидающие потоки, чтобы обеспечить корректное завершение3. В листинге 7.18 показано, как мьютекс MoneyAvailable используется классом KidAccount.

1 Почему я не рассматриваю их в книге? Потому что моя цель — представить основные концепции, лежащие в основе многопоточности, и помочь вам научиться программировать многопоточные приложения в VB.NET с приемлемым уровнем надежности. От пересказа сведений, содержащихся в документации, никакого проку не будет. Небольшой совет: не ограничивайтесь описаниями объектов. Загляните в документацию Win32 Platform SDK и ознакомьтесь с синхронизационными функциями API; это даст вам более глубокое представление о работе различных объектов.

2 В более строгой иерархии объект ManualResetEvent следовало бы ассоциировать с объектом Pa rent, а объекты Kid Ac count — с конкретным объектом Pa rent Account. В нашем простом примере это несущественно, однако такие обстоятельства должны учитываться при проектировании иерархий классов, предназначенных для повторного использования.

3 В нашем приложении используются потоки со свойством IsBackground = True, поэтому при прекращении работы основного потока приложение будет успешно завершено. С другой стороны, хороший стиль программирования требует останавливать все потоки перед выходом из приложения, не полагаясь на капризы CLR.

Листинг 7.18. Класс KidAccount приложения Threading5

' Синхронизировать KidAccount,

' чтобы избавиться от проблем с многопоточным доступом.

 Public nt

 Inherits Account

Private m_FailedRequests As Double

Readonly Property Fai ledRequests() As Double

Get

FailedRequests = m_FailedRequests

End Get End Property

' Получить карманные деньги от родителя

Public Sub GetAllowance(ByVal amount As Double)

deposit (amount) 

End Sub

' Попытаться потратить случайную сумму 

Public Sub SpendO Dim amount As Double

' Дождаться поступления денег Try

If m_Account = 0 Then MoneyAvailable.Wai tOne()

 Catch

' Ожидание прерывается при выходе из приложения.

Exit Sub

 End Try

amount = GetRandomAmount()

If amount > m_Account Then amount = m_Account

If amount = 0 Then

m_FailedRequests = m_FailedRequests + 1

 Else

Withdraw (amount)

 End If 

End Sub

' Обнуление переменных объекта и базового класса Overrides 

Sub Clear() m_FailedRequests = 0 

MyBase.Clear() 

End Sub 

End

Объект MoneyAvailable класса ManualResetEvent находится под управлением родительского счета. Если при попытке перевода денег на детские счета выясняется, что текущий баланс равен 0, объект MoneyAvailable сбрасывается. Когда объект ManualResetEvent находится в установленном состоянии, все потоки, ожидающие этого объекта, могут продолжать работу. Когда объект сбрасывается, как в листинге 7.19, все потоки, ожидающие объекта ManualResetEvent, приостанавливаются до его установки. Метод DepositPayroll устанавливает объект ManualResetEvent, тем самым оповещая ожидающие потоки о наличии денег на счету.

Листинг 7.19. Класс ParentAccount приложения Threading5

Public count 

Inherits Account ;

' Метод выбирает случайную сумму карманных расходов

1 и снимает ее со счета.

Public Function GiveAllowance() As Double

Dim amount As Double

amount = GetRandomAmount()

amount = Withdraw(amount)

' Вернуть фактически снятую сумму (может быть равна 0).

' Если денег не осталось, остановить процесс.

' Внимание: здесь присутствует

' нетривиальная ошибка синхронизации.

' Удастся ли вам ее найти?

If m_Account = 0 Then MoneyAvailable.Reset()

Return (amount) End Function

Public Sub DepositPayroll(ByVal amount As Double)

deposit (amount)

' Установить объект ManualResetEvent -

' сообщить детям о наличии денег.

MoneyAvailable.Set() 

End Sub 

End

Для корректного завершения приложения также необходимо модифицировать метод StopThreads. При обнаружении ожидающего потока (листинг 7.20) ожидание прерывается методом Threads. Interrupt. В результате операция ожидания инициирует исключение, которое в нашем примере перехватывается и игнорируется.

Листинг 7.20. Метод StopThreads приложения Threading5

Public Sub StopThreads() 

Dim Idx As Integer Try

KillFamily()

 ' Остановить все потоки 

For Idx = 0 To Threads.GetUpperBound(0)

 ' Ожидать завершения потока

' Теоретически не исключена редкая ошибка синхронизации -

' что, если поток перейдет в состояние ожидания

 ' после этого сравнения? 

If (Threads(Idx).ThreadState And

  System.Threading.ThreadState.WaitSleepJoin) <> 0 Then

Threads(Idx).Interrupt()

 End If

Threads(Idx).Join() 

Next Catch

' Игнорировать все ошибки 

End Try

End Sub

Результат показан на рис. 7.7.

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

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

Рис. 7.7. Приложение Threading5

Некоторые тонкости синхронизации

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

Какой поток остановлен?

Пример Threading5 построен на основе примера Threadingl. В листинге 7.21 приведен основной код метода ThreadingS.

Листинг 7.21. Метод KidsSpending приложения Threading5

Public Sub KidsSpending() 

Dim Childlndex As Integer 

Dim Allowance As Double

 Dim thiskid As KidAccount

 Do

' Случайно выбранный ребенок тратит некоторую сумму.

Childlndex = CInt(Int(m_Random.NextDouble() * _ 

CDbl(m_NumberOfKids)))

thiskid = Kids(Childlndex)

Allowance = Parent.GiveAllowance()

if (Not m_Stopping) Then thiskid.GetAllowance (Allowance)

thiskid. Spend()

 Loop Until m_Stopping

End Sub

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

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

Как справиться с этой проблемой?

Например, можно организовать дополнительную проверку в функции Kids-Spending и не пытаться тратить деньги, если баланс равен нулю.

Неожиданности с общими переменными

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

Листинг 7.22. Методы GiveAllowance и DepositPayroll приложения Threading5 (повторение)

Public Function GiveAllowance() As Double

Dim amount As Double

amount = GetRandomAmount()

amount = Withdraw(amount)

' Вернуть фактически снятую сумму (может быть равна 0).

' Если денег не осталось, остановить процесс.

' Внимание: здесь присутствует

' нетривиальная ошибка синхронизации.

' Удастся ли вам ее найти?

If m_Account = 0 Then MoneyAvailable.Reset()

Return (amount)

 End Function

Public Sub DepositPayroll(ByVal amount As Double)

Deposit (amount)

' Установить объект ManualResetEvent -

' сообщить детям о наличии денег.

MoneyAvailable.Set() 

End Sub

Ну как, увидели?

Рассмотрим следующую последовательность событий.

  •  Поток вызывает метод DepositPayroll.
  •  Второй поток продолжает работать. (Даже если родительский счет опустел, другие потоки продолжают работать в течение некоторого промежутка времени, поскольку у детей еще есть деньги.) Этот поток вызывает метод GiveAllowance.
  •  Второй поток прерывается непосредственно после проверки условия m_Account = 0.
  • Первый поток продолжает выполнять метод Deposi tPayroll, вызывает метод Deposit и устанавливает объект MoneyAvailable класса ManualResetEvent.
  •  Второй поток продолжает работу и сбрасывает объект MoneyAvai lable.

Результат — излишнее ограничение доступа к объектам детских счетов.

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

Почему возникла эта проблема?

Потому что сам объект MoneyAvailable класса ManualResetEvent является общей переменной, к которой могут одновременно обращаться несколько потоков!

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

В нашем примере эта потенциальная проблема решается включением проверки условия в метод GiveAllowance и оба вызова DepositPayroll в блоке SyncLock (с той же переменной).

Окончание работы приложения

Давайте вернемся к новому варианту функции StopThreads (листинг 7.23).

Листинг 7.23. Функция StopThreads приложения Threading5

Public Sub StopThreads() 

Dim Idx As Integer 

Try

KillFamily() ' Остановить все потоки 

For Idx = 0 To Threads.GetUpperBound(O)

 ' Ожидать завершения потока.

' Теоретически не исключена редкая ошибка синхронизации -

' что, если поток перейдет в состояние ожидания 

' после этого сравнения? 

If (Threads(Idx).ThreadState And

System.Threading.ThreadState.WaitSleepJoin) <> 0 Then

 Threads(Idx).InterruptO 

 End If

Threads(Idx).Join()

 Next Catch

' Игнорировать все ошибки 

End Try

End Sub

Также рассмотрим функцию Spend:

Public Sub Spend() 

Dim amount As Double

' Дождаться поступления денег.

 Try

If m_Account = 0 Then MoneyAvailable .WaitOne()

 Catch

' Ожидание прерывается при выходе из припожения.

Exit Sub

 End Try

Рассмотрим следующую ситуацию.

  •  Метод StopThreads вызывается в тот момент, когда поток выполняет метод Spend, а переменная m_Account равна нулю (у ребенка кончились деньги). У родителя тоже нет денег, поэтому объект MoneyAvailable сбрасывается.
  •  Система переключается с этого потока на другой, в котором выполняется StopThreads.
  •  Метод StopThreads проверяет условие, обнаруживает, что поток не находится в состоянии ожидания, переходит к методу Join и ждет завершения потока.
  •  Система снова переключается на поток с методом Spend, который обнаруживает, что счет пуст, и переводит поток в состояние ожидания вызовом метода WaitOne.
  •  Поток StopThreads приостанавливается методом Jоin в ожидании завершения потока Spend, однако последний приостановлен в ожидании наличия денег.

В результате возникает взаимная блокировка, а приложение не завершится.

Как и прежде, это очень редкая ситуация, и вероятность ее чрезвычайно мала. Она тоже обусловлена тем, что объекты синхронизации (как MoneyAvailable, так и сам объект Thread) являются общими.

Однако на этот раз простым решением уже не обойтись.

На первый взгляд хочется заключить вызов Spend в функции KidsSpending в блок SyncLock и включить проверку m_Stopping перед вызовом метода Spend, как показано в следующем фрагменте: 

Public Sub KidsSpending()

SyncLock simeobject

If Not m_Stopping Then thiskid.Spend() 

End SyncLock Loop Until m_Stopping

Вызов функции KillFamily тоже заключается в блок SyncLock.

Тем самым мы предотвращаем присваивание True переменной m_Stopping во время выполнения метода Spend. После успешного вызова KillFamily вызов thiskid Spend уже не состоится, поскольку проверка m_Stopping и вызов Spend находятся в одном блоке SyncLock.

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

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

1 Принимаются предложения.

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

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

Многопоточность также сильно затрудняет тестирование. Все проблемы, описанные в этом разделе (кроме первой), носят в основном теоретический характер и на практике возникают крайне редко. Я знаю о них только потому, что внимательно проанализировал программу и спросил себя: «А что, если...?» Если вы собираетесь писать многопоточные приложения, привыкните к мысли, что вам придется тщательно анализировать каждую строку программы, в которой потоки могут взаимодействовать посредством общих переменных или методов. Для поиска и решения многопоточных проблем следует использовать лучший отладчик из всех существующих — тот, что находится у вас в голове.

Помните о завершителях

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

Странности Random

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

 Private Shared m_Random As New Random()

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

Но кто сказал, что этот объект безопасен по отношению к потокам?

Я не говорил. Более того, в документации об этом тоже ничего не сказано.

Запомните раз и навсегда: многие классы CLR не являются безопасными по отношению к потокам.

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

Dim amount As Double

amount = int(m_Random.NextDouble() * 100)

в очень редких случаях вызывает ошибку переполнения.

Как может возникнуть переполнение, если m_Random. NextDouble всегда возвращает значение из интервала от 0 до 1? Никак, если только m_Random. NextDouble пo каким-то причинам не нарушает границы этого интервала. Это может объясняться либо ошибкой в программной реализации генератора случайных чисел (теоретически возможно), либо порчей данных, обусловленной тем, что объект небезопасен по отношению к потокам (гораздо более вероятно). За долгие часы тестирования эта ошибка возникла всего два раза; это в очередной раз доказывает, что с многопоточными проблемами следует бороться на уровне проектирования. Обнаружить их в процессе тестирования очень трудно.

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

 

Преимущества многопоточности

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

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

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

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

 

Эффективное ожидание

Приложение Threading5 демонстрирует одну из самых простых и притом полезных особенностей многопоточности — возможность перевода фоновых потоков в состояние ожидания с высокой эффективностью. Windows поддерживает фоновую реализацию для многих операций (например, пересылки данных между файлами или сетевыми сокетами) и позволяет потокам ожидать завершения этих операций. Возможны и другие условия ожидания, например истечение некоторого интервала времени или прекращение работы потока/процесса. Однопоточные приложения и компоненты не могут использовать эти возможности оптимальным образом, поскольку приостановка основного потока заметно повлияла бы на работу приложения/компонента. В частности, это привело бы к полной блокировке всего пользовательского интерфейса приложения. В таких ситуациях приходится использовать таймер и проводить периодический опрос объекта с проверкой завершения операции — такое решение работает, но крайне неэффективно.

В VB6 проблема эффективного ожидания решается плохо. Хотя ЕХЕ- приложения ActiveX могут создавать новые потоки, вызовы методов этих объектов обычно производятся синхронно. Следовательно, даже в случае приостановки потока ЕХЕ- приложения ActiveX, он не сможет вернуть управление вызывающему методу, что приведет к фактической блокировке работы клиента (а нередко и к тайм-ауту OLE Automation). Компоненты, оформленные в виде ActiveX DLL, в Visual Basic 6 не могут надежно создавать потоки, поэтому операция ожидания приводит к приостановке потока клиентского приложения1.

В VB .NET вы просто порождаете новый объект потока, приказываете ему выполнить операцию ожидания (и таким образом войти в высокоэффективное состояние ожидания) и инициировать событие при ее завершении.

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

1 Замечу, что в распоряжении пользователей пакета Desaware SpyWorks уже давно находится компонент для работы с фоновыми потоками, упрощающий ожидание и общие фоновые операции в Visual Basic 6.

 

Фоновые операции

С концептуальной точки зрения идея фоновых операций проста. Конечно, все приложения этой главы в той или иной степени демонстрируют фоновые операции в многопоточном приложении. Программа Threading5 показывает, как использовать многопоточность для организации ожидания; фоновые операции всего лишь расширяют этот принцип. Если ваше приложение или компонент выполняет продолжительные операции (например, пересылку данных), ожидание конца этих операций обычно приводит к неприемлемому снижению быстродействия. Для примера возьмем текстовый редактор. Отдав команду сохранения файла, пользователь вполне может подождать ее завершения, но автоматическое сохранение резервной копии, периодически «замораживающее» приложение, вызвало бы сильное раздражение. Средства фонового выполнения операций — от фоновой печати до пересчета формул в электронной таблице, от построения сложных изображений до передачи данных по сети — играют важную роль во многих современных приложениях.

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

 

Эффективный доступ со стороны клиента

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

Одна из главных причин, по которой программисты Visual Basic давно стремились к свободной многопоточности, заключается в том, что программы типа Internet Information Server (US) лучше всего работают с компонентами, использующими свободную потоковую модель. Сейчас я объясню, с чем это связано.

Каждый web-запрос, поступающий к IIS, полностью изолирован от всех остальных. Чтобы функции web-сайта выходили за рамки простого просмотра статических страниц, сервер должен располагать средствами для сохранения информации (состояния) между запросами от одного пользователя. Эту задачу можно решить разными способами, которые мы не рассматриваем, — достаточно просто сказать, что IIS позволяет web-приложениям сохранять информацию между запросами. Например, это позволяет создать web-приложение, которое читает информацию из базы данных, генерирует форму и отправляет ее пользователю. При поступлении следующего запроса от того же пользователя приложение продолжает работу и обрабатывает введенные данные. IIS обладает необходимыми средствами, чтобы написанное вами web-приложение могло сохранять информацию состояния между отправкой формы и получением заполненной формы от пользователя.

Для обеспечения максимальной эффективности IIS передает новые запросы любым свободным потокам.

Что произойдет, если ваше web-приложение использует объект Visual Basic 6 и потребует у IIS сохранить его?

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

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

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

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

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

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

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

К сожалению, многие программисты полагают, что многопоточность является обязательным атрибутом любых приложений «клиент-сервер». Об этом стоит поговорить особо.

Оценка быстродействия в многопоточных приложениях

Один из моих любимых афоризмов звучит так:

«Существует ложь, наглая ложь и эталонные тесты»1.

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

1 Редактор потребовал, чтобы я дал ссылку па источник. Марк Твен однажды сказал: «Существует ложь, наглая ложь и статистика» — по я уверен, что он не упомянул эталонные тесты только потому, что в его время не существовало компьютеров.

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

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

Приложение ThreadPerformance

В приложении ThreadPerformance определяется класс WorkerThread, выполняющий различные операции по запросу клиента. Этот класс также позволяет измерять продолжительность этих операций. В CLR определяется объект System.TimeSpan, представляющий промежуток времени и хорошо подходящий для хронометража. Свойство ElapsedTimeForCall возвращает ссылку на текущий объект TimeSpan.

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

Imports System.Threading

Public read Private myTimeSpan As TimeSpan

Public Readonly Property ElapsedTimeForCall() As TimeSpan

 Get

Return myTimeSpan 

End Get 

End Property

Public LongDuration As Boolean

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

Public Sub WorkingOperation() 

Dim counter As Long 

Dim upperlimit As Long 

Dim temp As Long

myTimeSpan = TimeSpan.FromTicks(DateTime.Now.Ticks)

 upperlimit = 50000000

If LongDuration Then upperlimit = 5 * upperlimit For counter = 1 

To upperlimit 

temp = 5

Next myTimeSpan = _

TimeSpan.FromTicks(DateTime.Now.Ticks).Subtract(myTimeSpan)

 End Sub

Метод SynchronousRequest отделяет присваивание переменной myTimespan от самой операции. Это необходимо, поскольку мы будем последовательно вызывать метод SynchronousOperation для каждого объекта, чтобы измерить быстродействие одного потока, последовательно обрабатывающего серию клиентских запросов. Нас интересует суммарное время, затраченное с начала первой операции, а не текущие затраты. В остальном метод SynchronousOperation идентичен методу WorkingOperation.

Public Sub SynchronousRequest()

myTimeSpan = TimeSpan.FromTicks(OateTime.Now.Ticks)

 End Sub

Public Sub SynchronousOperation()

Dim counter As Long

Dim upperlimit As Long

Dim temp As Long

upperlimit = 50000000

If LongDuration Then upperlimit = 5 * upperlimit'

For counter = 1 

To upperlimit

 temp = 5

Next

myTimeSpan = TimeSpan.FromTicks(DateTime.Now.Ticks).Subtract(myTimeSpan)

 End Sub

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

Листинг 7.24. Методы SleepingOperation и SleepingSynchronous модуля WorkerThread

Public Sub SleepingOperation()

Dim sleepspan As Integer

sleepspan = 1000

If LongDuration Then sleepspan = sleepspan * 5

myTimeSpan = TimeSpan.FromTicks(DateTime.Now.Ticks)

Thread.CurrentThread.Sleep (sleepspan)

myTimeSpan = _

TimeSpan.FromTicks(DateTime.Now.Ticks).Subtract(myTimeSpan)

 End Sub

Public Sub SleepingSynchronous()

Dim sleepspan As Integer

sleepspan = 1000

If LongDuration Then sleepspan = sleepspan * 5

Thread.CurrentThread.Sleep (sleepspan)

myTimeSpan = _

TimeSpan.FromTicks(DateTime.Now.Ticks).Subtract(myTimeSpan)

 End Sub

End

Программа ThreadingPerformance, оформленная в виде консольного приложения, создает пять объектов WorkerThread и пять потоков. Функция RunTest создает пять потоков, по одному для каждого объекта WorkerThread. Обратите внимание: сначала мы в цикле создаем потоки, а затем в следующем цикле их запускаем. Это сделано для повышения точности измерений, чтобы потоки запускались по возможности одновременно.

Метод RunTest2 (листинг 7.25) использует метод SleepingOperation для тестирования ситуаций без интенсивной загрузки процессора. В остальном этот метод практически идентичен RunTest.

Листинг 7.25. Модуль Modulel приложения ThreadingPerformance

Module Modulel

Dim WorkerObjects(4) As WorkerThread 

Dim Threads(4) As Threading.Thread

Sub RunTestO Dim x As Integer

' Создать потоки For x = 0 To 4 

Threads(x) = New Threading.Thread(AddressOf _

WorkerObjects(x).WorkingOperation)

 Next x

' Запустить 5 потоков For x = 0 To 4

Threads(x).StartQ 

Next

' Ожидать их завершения For x = 0 To 4

Threads(x).Join()

 Next

End Sub

Sub RunTest2() Dim x As Integer

' Создать потоки For x = 0 To 4 Threads(x) = New

 Threading.Thread(AddressOf _

WorkerObjects(x).SleepingOperation)

 Next x

' Запустить 5 потоков For x = 0 To 4

Threads(x) .Start()

 Next

' Ожидать их завершения For x = 0 To 4

Threads(x). Join() 

Next

End Sub

Методы RunSynchronous и RunSynchronousZ сначала фиксируют время запуска для каждого объекта вызовом SynchronousRequest, а затем несколько раз вызывают SynchronousOperation или SleepingSynchronous, имитируя последовательную обработку клиентских запросов (листинг 7.26).

Листинг 7.26. Модуль Modulel приложения ThreadingPerformance (продолжение)

Public Sub RunSynchronous()

 Dim x As Integer 

For x = 0 To 4

WorkerObjects(x).SynchronousRequest()

 Next For x = 0 To 4

WorkerObjects(x).SynchronousOperation()

 Next

End Sub

Public Sub RunSynchronous2() 

Dim x As Integer For x = 0 To 4

WorkerObjects(x).SynchronousRequest()

 Next 

For x = 0 To 4

WorkerObjects(x).SleepingSynchronous()

 Next

End Sub

Метод ReportResults (листинг 7.27) выводит суммарные затраты времени по каждому объекту WorkerThread и среднее время по всем объектам. Главная программа проводит тестирование дважды: в первый раз все клиентские запросы имеют равную длину, а во второй раз первый клиентский запрос значительно длиннее остальных (для чего свойству LongDuration объекта WorkerThread задается значение True).

Листинг 7.27. Модуль Modulel приложения ThreadingPerformance (продолжение)

Sub ReportResults() 

Dim x 

As Integer

 Dim tot As Double

 Dim ms As Double For x = 0 To 4

ms = WorkerObjects(x).ElapsedTimeForCall.TotalMilliseconds

tot = tot + ms

Console.Write(Int(ms).ToString + " ,")

 Next

Console.Wri te(" Average: " + Int(tot / 5) .ToString())

 Console.Wri teLine()

 End Sub

Sub Main() 

Dim x As Integer

 For x = 0 To 4

WorkerObjects(x) = New WorkerThread()

 Next

Console.WriteLine ("Running tests...")

 Console.WriteLine ("CPU-Intensive operations")

Console.WriteLine ("Synchronous Equal length operations")

WorkerObjects(O).LongDuration = False

RunSynchronous()

ReportResults()

Console.WriteLine ("Synchronous one long operation")

 WorkerObjects(0).LongDuration = True

 RunSynchronous()

 ReportResults()

Console.WriteLine ("Multithreaded Equal length operations")

WorkerObjects(0).LongDuration = False

RunTest()

ReportResults()

Console.WriteLine ("Multithreaded One long operations")

WorKerObjects(O).LongDuration = True

RunTest()

ReportResults()

Console.WriteLine ("Non CPU-Intensive operations")

Console.WriteLine ("Synchronous Equal length operation")

 WorkerObjects(0).LongDuration = False

 RunSynchronous2()

 ReportResults()

Console.WriteLine ("Synchronous one long operation")

 WorkerObjects(O).LongDuration = True 

RunSynchronous2()

 ReportResults()

Console.WriteLine ("Multithreaded Equal length operations")

WorkerObjects(Q).LongDuration = False

RunTest2()

ReportResults()

Console.WriteLine ("Multithreaded One long operations")

WorkerObjects(0).LongDuration = True

RunTest2()

ReportResults()

Console.ReadLine()

 End Sub 

End Module

Результаты работы программы ThreadPerformance

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

Running tests...

CPU-Intensive operations

Synchronous Equal length operations

2093, 4186, 6238, 8271, 10284, Average: 6214

Synchronous one long operation

10184, 12217, 14250, 16303, 18346, Average: 14260

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

Среднее время равно 6,2 секунды, что достаточно близко к теоретическому среднему (6 секунд).

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

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

Multithreaded Equal length operations 

8442. 8331, 8221, 8111, 7991, Average: 8219 

Multithreaded One long operation 

15241, 7921, 8301, 8181, 7921, Average: 9513

Когда все запросы имеют равную длину, время обработки каждого запроса заметно увеличивается. Дело в том, что многопоточность не следует воспринимать как магическое повышение мощности процессора — ресурсы процессора распределяются между потоками, что приводит к замедлению каждой операции. Суммарное время чуть превышает 8 секунд, что ниже теоретического значения (10 секунд). Вероятно, это объясняется тем, что алгоритм распределения процессорного времени операционной системой не сводится к простому делению 100 % доступного времени между потоками конкретного приложения. Когда процессорное время запрашивается несколькими потоками, ОС выделяет им больше процессорного времени, чем однопоточному приложению.

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

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

Если клиентские запросы имеют примерно одинаковую длину и конкурируют за ограниченные системные ресурсы (например, процессорное время), последовательная обработка обеспечивает лучшее быстродействие по сравнению с обработкой запросов в разных потоках!

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

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

Non CPU-Intensive operations

Synchronous Equal length operation

1001, 2002, 3004. 5007, Average: 3004

Synchronous one long operation

5007, 6008, 7010, 8011, 9012, Average: 7010

Multithreaded Equal length operations

1001, 1001, 1001, 1001, 1001, Average: 1001

Multithreaded One long operation

5007, 1001, 1001, 1001, 1001, Average: 1802

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

 

Итоги

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

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

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

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

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

Назад   Вперёд

 


Инфо
Сайт создан: 20 июня 2015 г.
Рейтинг@Mail.ru
Главная страница