3. Модуль форматирования записывает в поток идентификатор сборки и полное имя типа.
4. Модуль форматирования пересчитывает элементы двух массивов, записывая в поток ввода-вывода имя каждого члена и его значение.
А вот как выглядит процедура автоматической десериализации объекта, тип
которого помечен атрибутом SenializableAttnibute:
1. Модуль форматирования читает из потока ввода-вывода идентификатор сборки и полное имя типа. Если сборка еще не загружена в домен, он загружает ее (как описано ранее). При невозможности загрузки появляется исключение SenializationException, и десериализация объекта останавливается. Если же сборка успешно загружена, модуль форматирования передает статическому методу GetTypeFromAssembly класса FormatterServices ее идентификатор и полное имя типа:
public static Type GetTypeFromAssembly(Assembly assem, String name);
Метод возвращает объект System.Type, содержащий информацию о типе десериализованного объекта.
2. Модуль форматирования вызывает статический метод GetUninitializedOb ject класса FormatterServices:
public static Object GetUninitializedObject(Type type);
Этот метод выделяет память под новый объект, но не вызывает его конструктор. При этом все байты объекта инициализируются значением null или 0.
1. Тем же способом, что и раньше, модуль форматирования создает и инициализирует массив Memberlnfo, вызывая метод GetSerializableMembers класса FormatterServices. Данный метод возвращает набор полей, которые были сериализованы и теперь нуждаются в десериализации.
2. Из содержащихся в потоке ввода-вывода данных модуль форматирования создает и инициализирует массив Object.
3. Ссылки на только что размещенный в памяти объект, массив Memberlnfo, и параллельный ему массив Object со значениями полей передаются статическому методу PopulateObjectMembers класса FormatterServices:
public static Object PopulateObjectMembers(
Object obj, MemberInfo[] members, Object[] data);
Этот метод перебирает элементы массивов, инициализируя каждое поле соответствующим значением. В результате объект оказывается полностью десериализованным.
Управление сериализованными и десериализованными данными
Как уже упоминалось в этой главе, управлять процессами сериализации и десериализации лучше всего при помощи атрибутов OnSerializing, OnSerialized, OnDesenializing, OnDesenialized, NonSerialized HOptionalField. Однако иногда встречаются сценарии, для которых данных атрибутов недостаточно. Кроме того, работа модулей форматирования основана на отражении, а это — не быстрый процесс, значительно замедляющий сериализацию и десериализацию объектов. Чтобы получить полный контроль над данными процедурами и исключить отражение, тип может реализовать интерфейс System.Runtime.Serialization.ISerializable, определяемый следующим образом:
public interface ISerializable {
void GetObjectData(Serializationlnfo info, StreamingContext context);
}
Внутри этого интерфейса имеется всего один метод — GetOb jectData. Но большинство реализующих его типов реализуют также специальный конструктор, который кратко описан далее.
ВНИМАНИЕ
Основной проблемой интерфейса ISerializable является тот факт, чтоегодолжны реализовывать и все производные типы, гарантированно вызывая метод GetObjectData базового класса и специальный конструктор. Кроме того, реализацию данного интерфейса типом нельзя отменить, потому что это приведет к потере совместимости с производными типами. Впрочем, для изолированных типов реализация интерфейса ISerializable всегда происходит без проблем. Кроме того, избежать потенциальных неприятностей, связанных с данным интерфейсом, можно при помощи описанных ранее настраиваемых атрибутов.
ВНИМАНИЕ
Интерфейс ISerializable и специальный конструктор предназначены для модуля форматирования. Вызов метода GetObjectData другим кодом потенциально может привести к возвращению конфиденциальной информации. Другой код может при этом создать объект, передающий поврежденные данные. Поэтому методу GetObjectData и специальному конструктору рекомендуется назначить следующий атрибут:
[SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter =
true)]
При сериализации графа модуль форматирования просматривает каждый объект. Если тип объекта реализует интерфейс ISerializable, модуль форматирования игнорирует все пользовательские атрибуты и конструирует новый объект System. Runtime.Serialization.Serializationlnfo, содержащий реальный набор всех подлежащих сериализации значений объекта.
В конструируемый объект Senializationlnfo модуль форматирования передает два параметра: Туре и System. Runtime. Serialization .IFonmattenConverten. Первый параметр идентифицирует сериализуемый объект. Для уникальной идентификации типа требуется два фрагмента данных: строковое имя типа и идентификатор его сборки (включающий имя сборки, ее версию, региональные стандарты и открытый ключ). Готовый объект Senializationlnfo получает полное имя типа (запросив свойство FullName), сохраняя его в закрытом поле. Для получения полного имени типа используйте свойство FullTypeName класса Serializationlnfo. Аналогичным образом конструктор получает определяющую тип сборку (запрашивая сначала свойство Module класса Туре, затем свойство Assembly класса Module и, наконец, свойство FullName класса Assembly), сохраняя полученную строку в закрытом поле. Для получения идентификатора сборки используйте поле AssemblyName класса Senializationlnfo.
ПРИМЕЧАНИЕ
Задать свойства FullTypeName и AssemblyName класса Serializationlnfo не всегда возможно. Для изменения типа после сериализации рекомендуется вызвать метод SetType класса Serializationlnfo и передать ему ссылку на желаемый объект Туре. Это гарантирует корректность задания полного имени и определяющей сборки. Пример применения данного метода приводится далее в этой главе.
После создания и инициализации объекта Senializationlnfo модуль форматирования передает ссылку на него в метод GetObjectData типа. Именно метод GetObjectData определяет, какая информация необходима для сериализации объекта, и добавляет эту информацию к объекту Senializationlnfo. Определение необходимой для сериализации информации происходит при помощи одной из множества перегруженных версий метода AddValue типа Senializationlnfo. Для каждого фрагмента данных, который вы хотите добавить, вызывается один метод AddValue.
Показанный далее код демонстрирует, каким образом тип Dictionany<TKey, TValue> реализует интерфейсы ISenializable и IDesenializationCallback, добиваясь контроля над сериализацией и десериализацией своих объектов:
[Serializable]
public class DictionarycTKey, TValue>: ISerializable,
IDeserializationCallback {
// Здесь закрытые поля (не показанные)
private Serializationlnfo m_siInfoj // Только для десериализации
// Специальный конструктор (необходимый интерфейсу ISerializable)
// для управления десериализацией [SecurityРеrmissionAttгibute(
SecurityAction.Demand, SerializationFormatter = true)] protected Dictionary(Serializationlnfo info, StreamingContext context) {
//Во время десериализации сохраним // Serializationlnfo для OnDeserialization m_siInfo = info;
}
// Метод управления сериализацией
[SecurityCritical]
public virtual void GetObjectData(
Serializationlnfo info, StreamingContext context) {
info.AddValue("Version", m_version);
info.AddValue("Comparer", mcomparer, typeof(IEqualityComparer<TKey>)); info.AddValue("HashSize", (m_ buckets == null) ? 0 : m_buckets.Length); if (m_buckets != null) {
KeyValuePaircTKey, TValue>[] array = new KeyValuePaircTKey, TValue>[Count];
CopyTo(array, 0); info.AddValue(
"KeyValuePairs", array, typeof(KeyValuePaircTKey, TValue>[]));
}
}
// Метод, вызываемый после десериализации всех ключей/значений объектов public virtual void IDeserializationCallback.OnDeserialization(
Object sender) {
if (msilnfo == null) return; // Никогда не присваивается,
// возвращение управления
Int32 num = m_siInfo.GetInt32("Version");
Int32 num2 = m_siInfo.GetInt32("HashSize"); m_comparer = (IEqualityComparer<TKey>)
msilnfо.GetValue("Comparer", typeof(IEqualityComparer<TKey>)); if (num2 != 0) {
m_buckets = new Int32[num2];
for (Int32 i = 0; i < mbuckets.Length; i++) m_buckets[i] = -1; mentries = new EntrycTKeyj TValue>[num2]; m_freeList = -1;
KeyValuePaircTKeyj TValue>[] pairArray = (
KeyValuePaircTKeyj TValue>[]) m_siInfo.GetValue(
"KeyValuePairs", typeof(KeyValuePaircTKeyj TValue>[])); if (pairArray == null)
ThrowHelper.ThrowSerializationException(
ExceptionResource.SerializationMissingKeys);
for (Int32 j = 0; j < pairArray.Length; J++) { if (pairArray[j].Key == null)
ThrowHelper.ThrowSerializationException(
ExceptionResource.Serialization_NullKey);
Insert(pairArray[j].Кеул pairArray[j].Value, true);
}
![]() |
} else { m_buckets = null; }
m_version = num; m_siInfo = null;
}
Каждый метод AddValue принимает в качестве параметра имя типа String и набор данных. Обычно эти данные принадлежат к простым значимым типам, таким как Boolean, Char, Byte, SByte, Intl6, UIntl6, Int32, UInt32, Int64, UInt64, Single, Double, Decimal и DateTime. Впрочем, методу AddValue можно передать ссылку на тип Object, к примеру, String. После того как метод GetObjectData добавит всю необходимую для сериализации информацию, управление возвращается модулю форматирования.
ПРИМЕЧАНИЕ
Добавлять к типу информацию сериализации следует только с помощью одного из перегруженных методов AddValue. Для полей, тип которых реализует интерфейс I Serializable, нельзя вызывать метод GetObjectData. Чтобы добавить поле, используйте метод AddValue; модуль форматирования проследит за тем, чтобы тип поля реализовал ISerializable, и вызовет метод GetObjectData. Если же вы решите вызвать этот метод для описанного ранее поля, при десериализации потока ввода-вывода модуль форматирования не будет знать, что он должен создать новый объект.
На этом этапе модуль форматирования берет все добавленные к объекту Serializationlnfo значения и сериализует их в поток ввода-вывода. Обратите внимание, что методу GetObjectData передается еще один параметр: ссылка на объект System.Runtime.Serialization.StreamingContext. Этот параметр игнорируется методом GetObjectData большинства типов, поэтому здесь мы на нем останавливаться не станем, а рассмотрим его отдельно ближе к концу главы.
Итак, вы уже знаете, как задать всю необходимую для сериализации информацию. Пришла пора рассмотреть процедуру десериализации. Извлекая объект из потока ввода-вывода, модуль форматирования выделяет для него место в памяти (вызывая статический метод GetUninitializedObject типа System. Runtime. Serialize.FormatterServices). Изначально всем полям объекта присваивается значение 0 или null. Затем модуль форматирования проверяет, реализует ли тип интерфейс ISerializable. В случае положительного результата проверки модуль пытается вызвать специальный конструктор, параметры которого идентичны параметрам метода GetObjectData.
Для классов, помеченных модификатором sealed, этот конструктор рекомендуется объявлять с модификатором private. Это предохранит код от случайного вызова и повысит уровень безопасности. Также конструктор можно пометить модификатором protected, предоставив доступ к нему только производным классам. Впрочем, модули форматирования могут вызывать его вне зависимости от того, каким именно способом был объявлен конструктор.
Конструктор получает ссылку на объект Serializationlnfo, содержащий все значения, добавленные на этапе сериализации. Он может вызывать любые методы
GetBoolean,GetChan,GetByte, GetSByte, Getlntl6, GetUIntl6, Getlnt32, GetUInt32, Getlnt64, GetUInt64, GetSingle, GetDouble, GetDecimal, GetDateTime,GetString и GetValue, передавая им строку с именем, которое использовалось при сериализации значения. Значения, возвращаемые этими методами, затем инициализируют поля нового объекта.
При десериализации полей объекта нужно вызвать метод Get с тем же самым типом значения, которое было передано методу AddValue в процессе сериализации. Другими словами, если метод GetObjectData передал в метод AddValue значение типа Int32, метод Getlnt32 должен вызываться для значения этого же типа. Если значение в потоке ввода-вывода отличается от того, которое вы пытаетесь получить, модуль форматирования попытается воспользоваться объектом IFormatterConvert для «приведения» к нужному типу значения в потоке ввода-вывода.
При конструировании объекта Serializationlnf о ему передается объект типа, реализующего интерфейс IFormatterConverter. Так как за конструирование отвечает модуль форматирования, он выбирает нужный тип IFormatterConverter. Модули BinaryFormatter и SoapFormatter разработки Microsoft всегда конструируют экземпляр типа System. Runtime. Serialization. FormatterConverter. Выбрать для этой цели другой тип IFormatterConverter не удастся.
Тип FormatterConverter использует статические методы класса System. Convert для преобразования значений между базовыми типами, например из Int32 в Int64. Но при необходимости преобразования между произвольными типами FormatterConverter вызывает метод ChangeType класса Convert для приведения сериализованного (или исходного) типа к интерфейсу IConvertible. После этого вызывается подходящий интерфейсный метод. Следовательно, если объекты сериализуемого типа требуется десериализовать как другой тип, нужно, чтобы выбранный тип реализовывал интерфейс IConvertible. Обратите внимание, что объект FormatterConverter используется только при десериализации объектов и при вызове метода Get, тип которого не совпадает с типом значения в потоке ввода-вывода.
Вместо одного из многочисленных методов Get специальный конструктор может вызвать метод GetEnumerator, возвращающий объект System.Runtime. Serialization.SerializationlnfoEnumerator, при помощи которого можно перебрать все значения внутри объекта Serializationlnfo. Каждое из перечисленных значений представляет собой объект System.Runtime.Serialization. SerializationEntry.
Разумеется, никто не запрещает вам создавать собственные типы, производные от типа, реализующего метод GetObjectData класса ISerializable, а также специальный конструктор. Если ваш тип реализует также интерфейс ISerializable, для корректной сериализации и десериализации объекта ваши реализации метода GetObjectData и специального конструктора должны вызывать аналогичные функции в базовом классе. В следующем разделе мы поговорим о том, как правильно определить тип ISerializable, базовый тип которого не реализует данный интерфейс.
Если ваш производный тип не имеет дополнительных полей и, следовательно, не нуждается в особых сериализации и десериализации, реализовывать интерфейс ISenializable не нужно. Метод GetObjectData, как и все члены интерфейса, является виртуальным и вызывается для корректной сериализации объекта. Кроме того, модуль форматирования считает специальный конструктор «виртуализиро- ваным». То есть во время десериализации модуль форматирования проверяет тип, экземпляр которого он пытается создать. При отсутствии у этого типа специального конструктора модуль форматирования начинает сканировать базовые классы, пока не найдет класс, в котором реализован нужный ему конструктор.
ВНИМАНИЕ
Код специального конструктора обычно извлекает поля из переданного ему объекта Serializationlnfo. Однако извлечение полей не гарантирует полной десериализации объекта, поэтому код специального конструктора не должен модифицировать объекты, которые он извлекает.
Если вашему типу нужен доступ к членам извлеченного объекта (например, вызов методов), следует снабдить тип методом с атрибутом OnDeserialized или заставить реализовывать метод OnDeserialization интерфейса IDeserializationCallback (как показано в примере Dictionary). Вызов этого метода задает значения полей всех объектов. Но порядок вызова методов OnDeserialized и OnDeserialization для набора объектов заранее не известен. Так что, несмотря на инициализацию полей, если объект, на который вы ссылаетесь, снабжен методом OnDeserialized или реализует интерфейс IDeserializationCallback, нельзя быть уверенным в его полной десериализации.
Определение типа, реализующего интерфейс ISerializable, не реализуемый базовым классом
Как уже упоминалось, интерфейс ISerializable является крайне мощным инструментом, позволяющим типу полностью управлять сериализацией и десериализацией своих экземпляров. Однако за эту мощь приходится платить ответственностью типа за сериализацию еще и всех полей базового типа. Если базовый тип реализует также интерфейс ISerializable, нет никаких проблем — достаточно вызвать метод GetObjectData базового типа.
Однако иногда требуется определить тип, управляющий сериализацией, при условии, что его базовый тип не реализует интерфейс ISerializable. В этом случае производный класс должен вручную сериализовать поля базового типа, добавив их значения в коллекцию Serializationlnfo. Затем в специальном конструкторе нужно будет извлечь эти значения и задать поля базового класса. В случае когда поля базового класса помечены модификатором public или protected, это несложно, а вот для закрытых полей задача может стать даже вообще неразрешимой.
Следующий код показывает, как правильно реализовать метод GetObjectData интерфейса ISerializable и его конструктор, обеспечивающий сериализацию полей базового типа:
[Serializable] internal class Base {
protected String mname = "left";
public Base() { /* Наделяем тип способностью создавать экземпляры */ }
}
[Serializable]
internal class Derived : Base, ISerializable { private DateTime mdate = DateTime.Now;
public Derived() { /* Наделяем тип способностью создавать экземпляры */ }
// Если конструктор не существует, выдается SerializationException.
// Если класс не запечатан, конструктор должен быть защищенным.
[SecurityPermissionAttribute(
SecurityAction.Demand, SerializationFormatter = true)] private Derived(SerializationInfo info, StreamingContext context) {
// Получение набора сериализуемых членов для нашего и базовых классов Type baseType = this.GetTypeQ .BaseType;
MemberInfo[] mi = FormatterServices.GetSerializableMembers( baseType, context);
// Десериализация полей базового класса из объекта данных for (Int32 i = 0; i < mi.Length; i++) {
// Получение поля и присвоение ему десериализованного значения Fieldlnfo fi = (FieldInfo)mi[i]; fi.SetValue(this, info.GetValue(
baseType.FullName + "+" + fi.Name, fi.FieldType));
}
// Десериализация значений, сериализованных для этого класса m_date = info.GetDateTime("Date");
}
[ SecurityРеrmissionAttribute(
SecurityAction.Demand, SerializationFormatter = true)] public virtual void GetObjectData(
Serializationlnfo info, StreamingContext context) {
// Сериализация нужных значений для этого класса info.AddValue("Date", mdate);
// Получение набора сериализуемых членов для нашего и базовых классов Type baseType = this.GetTypeQ .BaseType;
MemberInfo[] mi = FormatterServices.GetSerializableMembers( baseType, context);
// Сериализация полей базового класса в объект данных for (Int32 i = 0; i < mi.Length; i++) {
// Полное имя базового типа ставим в префикс имени поля info.AddValue(baseType.FullName + "+" + mi[i].Name,
((FieldInfo)mi[i]).GetValue(this));
}
продолжение
public override String ToStringO {
return String.Format("Name={0}j Date={l}", т_пате, mdate);
>
>
В этом коде присутствует базовый класс Base, помеченный только настраиваемым атрибутом SerializableAttribute. Производным от него является класс Derived, также помеченный этим атрибутом и реализующий интерфейс ISerializable. Ситуацию усугубляет тот факт, что оба класса определяют поле типа String с именем m_name. При вызове метода AddValue класса Serializationlnfo нельзя добавлять значения с одним и тем же именем. Это ограничение обходится путем присвоения каждому полю нового имени с префиксом из имени класса. К примеру, когда метод GetOb jectData вызывает метод AddValue для сериализации поля m_name класса Base, имя значения записывается в форме "Base+m_name".
Контексты потока ввода-вывода
Как уже упоминалось, сериализовать объект можно куда угодно: в тот же самый процесс, в другой процесс на этой же машине, в другой процесс на другой машине и т. п. Бывают ситуации, когда объект нужно заранее уведомить, куда он будет десериализован, так как это влияет на его состояние. Например, объект, являющийся оболочкой для Windows-семафора, может инициировать сериализацию дескриптора ядра при условии, что десериализация произойдет в том же процессе — ведь дескрипторы ядра действительны только в пределах одного процесса. При этом если десериализация будет произведена в другой процесс на этой же машине, объект сможет сериализовать строковое имя семафора. Если же вдруг окажется, что десериализация ожидается на другую машину, появится исключение, так как семафоры действительны только в пределах одной машины.
Ряд упомянутых в данной главе методов в качестве параметра принимают структуру StreamingContext. Это простая структура значимого типа, имеющая всего два открытых, предназначенных только для чтения свойства (табл. 24.1).
Таблица 24.1. Свойства перечисления StreamingContext
|
|
Получивший структуру StreamingContext метод может исследовать битовые флаги свойства State и определить источник или приемник сериализуемых/десе- риализуемых объектов. Возможные значения флагов перечислены в табл. 24.2.
Таблица 24.2. Флаги перечисления StreamingContextStates
|
|
Теперь, когда вы знаете, как получить информацию, поговорим о том, как ее задать. В интерфейсе IFormatter (реализуемом как типом BinaryFormatter, так и типом SoapFormatter) определено доступное для чтения и записи свойство типа StreamingContext с именем Context. В процессе конструирования модуль форматирования инициализирует свойство Context, присваивая StreamingContextStates значение АН, а ссылке на дополнительный объект состояния — значение null.
После создания модуля форматирования можно создать структуру Streaming- Context, используя любые битовые флаги StreamingContextStates, с возможностью передачи ссылки на объект, содержащий дополнительную контекстную информацию. После чего остается присвоить свойству Context новый объект StreamingContext до вызова метода Serialize или Deserialize. Показанный ранее метод DeepClone демонстрирует, как сообщить модулю форматирования, что сериализация/десериализация графа объектов выполняется исключительно с целью клонирования всех объектов.
Сериализация в другой тип и десериализация в другой объект
Инфраструктура сериализации в .NET Framework обладает достаточно широкими возможностями. В частности, она позволяет разработчикам создавать типы, допускающие сериализацию и десериализацию в другой тип или объект. Несколько примеров ситуаций, в которых это может быть нужно:
□ Некоторые типы (например, System. DBNull и System. Ref lection. Missing)
допускают существование в домене приложений только одного экземпляра. Их часто называют одноэлементными (singleton). Если у вас имеется ссылка на DBNull, ее сериализация и десериализация не приведет к созданию в домене нового объекта. Возвращаемая после десериализации ссылка должна указывать на уже существующий в домене объект DBNull.
□ Некоторые типы (например, System.Type, System. Reflection. Assembly и другие связанные с отражениями типы, такие как Memberlnfo) допускают существование всего одного экземпляра на тип, сборку, член и т. п. Представьте массив ссылок на объекты Memberlnfo. Допустима ситуация, когда пять ссылок указывают на один объект. Это состояние должно сохраняться и после сериализации и десериализации. Более того, элементы должны ссылаться на объект Memberlnfo, существующий в домене для определенного члена. Такой подход полезен также для последовательного опроса объектов подключения к базе данных и любых других типов объектов.
□ Для объектов, контролируемых удаленно, CLR сериализует информацию о серверном объекте таким образом, что при десериализации на клиенте CLR создает объект, являющийся представителем (proxy) сервера на стороне клиента. Тип представителя отличается от типа серверного объекта, но для клиентского кода эта ситуация прозрачна. Если клиент вызывает для объекта-представителя эк- земплярные методы, код представителя переправляет вызов на сервер, который в действительности и обрабатывает запрос.
Следующий пример демонстрирует корректное выполнение сериализации и десериализации одноэлементного типа:
// Разрешен только один экземпляр типа на домен [Serializable]
public sealed class Singleton : ISerializable {
// Единственный экземпляр этого типа
private static readonly Singleton theOneObject = new SingletonQ;
// Поля экземпляра
public String Name = "left";
public DateTime Date = DateTime.Now;
// Закрытый конструктор для создания однокомпонентного типа private SingletonQ { }
// Метод, возвращающий ссылку на одноэлементный тип
public static Singleton GetSingletonQ { return theOneObject; }
// Метод, вызываемый при сериализации объекта Singleton // Рекомендую использовать явную реализацию интерфейсного метода.
[SecurityРеrmissionAttгibute(
SecurityAction.Demand, SerializationFormatter = true)] void ISerializable.GetObjectData(
Serializationlnfo info, StreamingContext context) { info.SetType(typeof(SingletonSerializationHelper));
// Добавлять другие значения не нужно
}
[Serializable]
private sealed class SingletonSerializationHelper : IObjectReference {
// Метод, вызываемый после десериализации этого объекта (без полей) public Object GetRealObject(StreamingContext context) { return Singleton.GetSingletonQ;
}
}
// ПРИМЕЧАНИЕ. Специальный конструктор HE НУЖЕН,
// потому что он нигде не вызывается
}
Класс Singleton представляет тип, для которого в домене приложений может существовать только один экземпляр. Показанный далее код тестирует процедуры сериализации и десериализации этого типа, обеспечивая выполнение данного условия:
private static void SingletonSerializationTest() {
// Создание массива с несколькими ссылками на один объект Singleton Singleton[] al = { Singleton.GetSingletonQ, Singleton.GetSingletonQ };
Console.WriteLine("Do both elements refer to the same object? "
+ (al[0] == al[l])); // "True"
using (var stream = new MemoryStream()) {
BinaryFormatter formatter = new BinaryFormatter();
// Сериализация и десериализация элементов массива
продолжение #
formatter.Serialize(stream, al); stream.Position = 0;
Singleton[] a2 = (Singleton[])formatter.Deserialize(stream); // Проверяем, что все работает, как нужно:
Console.WriteLine("Do both elements refer to the same object? + (a2[0] == a2[l])); // "True"
Console.WriteLine("Do all elements refer to the same object?
+ (al[0] == a2[0])); // "True"
}
}
Попытаемся понять, что же происходит. Для загруженного в домен типа Singleton CLR вызывает статический конструктор, создающий объект Singleton и сохраняющий ссылку на него в статическом поле sjtheOneOb ject. Класс Singleton не имеет открытых конструкторов, что не дает стороннему коду создавать его экземпляры.
В методе SingletonSerializationTest создается массив из двух элементов, каждый из которых ссылается на объект Singleton. Для инициализации этих элементов вызывается статический метод GetSingleton класса Singleton. Он возвращает ссылку на один объект Singleton. Первый вызов метода Write Line выводит "Тrue", указывая, что оба элемента массива ссылаются на один объект.
Далее метод SingletonSerializationTest вызывает метод Serialize модуля форматирования для сериализации массива и его элементов. Обнаружив, что тип Singleton реализует интерфейс ISerializable, модуль форматирования вызывает метод GetOb jectData, который передает в метод SetType тип SingletonSerializationHelper. В результате модуль форматирования сериализует объект Singleton в качестве объекта SingletonSerializationHelper. Так как метод AddValue в данном случае не вызывается, в поток ввода-вывода не записывается никакой дополнительной информации. Кроме того, модуль форматирования, обнаружив, что оба элемента массива ссылаются на один и тот же объект, сериализует только один из них.
После сериализации массива метод SingletonSerializationTest вызывает метод Deserialize модуля форматирования. При работе с потоком ввода-вывода модуль форматирования пытается десериализовать объект SingletonSerializationHelper, который с его точки зрения был им ранее сериализован (на самом деле, именно поэтому класс Singleton не имеет специального конструктора, обычно требующего реализации интерфейса ISerializable). Сконструировав объект SingletonSerializationHelper, модуль форматирования обнаруживает, что данный тип реализует интерфейс System. Runtime. Serialization. 10bjectReference. В FCL этот интерфейс определяется следующим образом:
public interface IObjectReference {
Object GetRealObject(StreamingContext context);
Для реализующих такой интерфейс типов модуль форматирования вызывает метод GetRealOb ject, возвращающий ссылку на объект, на который вы действительно хотите сослаться после завершения десериализации. В моем примере для типа SingletonSerializationHelper метод GetRealObject возвращает ссылку на уже существующий в домене объект Singleton. После возвращения управления методом Deserialize массив а2 содержит два элемента, каждый из которых ссылается на объект Singleton домена. Вспомогательный объект SingletonSerializationHelper, использовавшийся при десериализации, в данный момент недоступен и будет уничтожен при следующей сборке мусора.
Второй вызов метода WriteLine выводит значение "Тrue", подтверждая, что оба элемента массива а2 указывают на один и тот же объект. Аналогичный результат дает третий и последний вызов этого метода, так как на один и тот же объект и в самом деле указывают элементы обоих массивов.
Суррогаты сериализации
До этого момента мы обсуждали способы изменения реализации типов, позволяющие управлять сериализацией и десериализацией их экземпляров. Однако существует возможность переопределить поведение этих процессов при помощи кода, не принадлежащего реализации типа. Для чего это может быть нужно?
□ Разработчик может сериализовать типы, для которых возможность сериализации не была учтена при исходном проектировании.
□ Возможность отображения между разными версиями одного типа.
Чтобы этот механизм заработал, нужно определить «суррогатный тип», который возьмет на себя работу по сериализации и десериализации существующего типа. Затем следует зарегистрировать экземпляр суррогатного типа, сообщив модулю форматирования, за действия какого существующего типа он будет отвечать. В результате при попытке сериализовать или десериализовать экземпляр существующего типа модуль форматирования будет вызывать методы, определенные суррогатным объектом. Следующий пример показывает, как все это работает.
Тип суррогата сериализации должен реализовывать интерфейс System. Runtime. Serialization.ISerializationSurrogate, определяемый в FCL следующим образом:
public interface ISerializationSurrogate {
void GetObjectData(Object obj, Serializationlnfo info,
StreamingContext context);
Object SetObjectData(Object obj, Serializationlnfo info,
StreamingContext context, ISurrogateSelector selector);
Теперь посмотрим на пример использования этого интерфейса. Предположим, программа содержит объекты DateTime, значения которых привязаны к компьютеру пользователя. Каким образом сериализовать эти объекты в поток ввода-вывода, указав их значения, как всемирное время? Ведь только при таком подходе вы можете перенаправить поток на машину, расположенную на другом конце планеты, сохранив корректные значения даты и времени. Так как вы не можете менять тип DateTime, представленный в FCL, остается определить собственный суррогатный класс, управляющий сериализацией и десериализацией объектов DateTime. Вот как он выглядит:
internal sealed class UniversalToLocalTimeSerializationSurrogate : ISerializationSurrogate { public void GetObJectData(
Object obj, Serializationlnfo info, StreamingContext context) {
// Переход от локального к мировому времени
info. AddValue("Date", ((DateTime)obj). ToLIniversalTime(). ToString("u"));
}
public Object SetObJectData(ObJect obj, Serializationlnfo info,
StreamingContext context, ISurrogateSelector selector) {
// Переход от мирового времени к локальному return DateTime.ParseExact(
info.GetString("Date"), "u", null).ToLocalTime();
}
}
Здесь метод GetObjectData работает почти как одноименный метод интерфейса ISenializable. Отличается он всего одним параметром: ссылкой на «реальный» объект, который требуется сериализовать. В показанном варианте метода GetObjectData данный объект приводится к типу DateT ime, его значение преобразуется из локального в универсальное время, а полученная в итоге строка (отформатированная с использованием универсального шаблона полной даты/времени) добавляется в коллекцию Serializationlnfo.
Для десериализации объекта DateTime вызывается метод SetObjectData. Ему передается ссылка на объект Serializationlnfo. Метод извлекает из коллекции строковые данные, разбирает их как строку в формате универсальной полной даты/времени и преобразует полученный объект DateTime в формат локального машинного времени.
Первый параметр метода SetObjectData, объект Object, выглядит немного странно. Непосредственно перед вызовом метода модуль форматирования выделяет место (через статический метод GetUninitializedOb ject класса FormatterServices) под экземпляр типа, для которого предназначается суррогат. Все поля этого экземпляра имеют значение 0 или null, и для объекта не вызывается никаких конструкторов. Метод SetObjectData может просто инициализировать его поля, используя значения из переданного методу объекта Serializationlnfo, а затем вернуть значение null. В качестве альтернативы метод SetObjectData может создать совсем другой объект
или даже другой объектный тип и вернуть ссылку на него. В этом случае модуль форматирования проигнорирует любые изменения, которые могли произойти с объектом, переданным им в метод SetOb jectData.
В моем примере класс UniversalToLocalTimeSerializationSurrogate действует как суррогат для значимого типа DateTime. И поэтому параметр obj ссылается на упакованный экземпляр типа DateTime. Менять поля в большинстве значимых типов нельзя (так как они предполагаются неизменными), поэтому мой метод SetOb jectData игнорирует параметр obj и возвращает новый объект DateTime с нужным значением.
Как же при сериализации/десериализации объекта DateTime модуль форматирования узнает о необходимости использования типа ISerializationSurrogate? Следующий код тестирует класс UniversalToLocalTimeSerializationSurrogate:
private static void SerializationSurrogateDemo() { using (var stream = new MemoryStream()) {
// 1. Создание желаемого модуля форматирования IFormatter formatter = new SoapFormatter();
// 2. Создание объекта SurrogateSelector SurrogateSelector ss = new SurrogateSelector();
// 3. Селектор выбирает наш суррогат для объекта DateTime ss.Addsurrogate(typeof(DateTime), formatter.Context, new UniversalToLocalTimeSerializationSurrogate());
11 ПРИМЕЧАНИЕ. AddSurrogate можно вызывать более одного раза // для регистрации нескольких суррогатов
// 4. Модуль форматирования использует наш селектор formatter.SurrogateSelector = ss;
// Создание объекта DateTime с локальным временем машины // и его сериализация
DateTime localTimeBeforeSerialize = DateTime.Now; formatter.Serialize(stream, localTimeBeforeSerialize);
// Поток выводит универсальное время в виде строки,
// проверяя, что все работает stream.Position = 0;
Console .Write Line (new St reamReader( stream). ReadToEndQ);
// Десериализация универсального времени и преобразование // объекта DateTime в локальное время stream.Position = 0;
DateTime localTimeAfterDeserialize =
(DateTime)formatter.Deserialize(stream);
// Проверка корректности работы Console.WriteLine(
"LocalTimeBeforeSerialize ={0}", localTimeBeforeSerialize);
Console.WriteLine(
"LocalTimeAfterDeserialize={0}", localTimeAfterDeserialize);
}
}
После выполнения четвертого шага модуль форматирования готов к работе с суррогатными типами. При вызове метода Serialize тип каждого объекта ищется в наборе, поддерживаемом объектом SurrogateSelector. При обнаружении соответствия вызывается метод GetOb jectData класса ISerializationSurrogate, чтобы получить информацию для записи в поток ввода-вывода.
При вызове метода Deserialize тип подлежащего десериализации объекта также ищется в объекте SurrogateSelector, и при обнаружении соответствия вызывается метод SetObjectData класса ISerializationSurrogate, задающий поля десериализуемого объекта.
Объект SurrogateSelector управляет закрытой хеш-таблицей. При вызове метода AddSurrogate значения Туре и StreamingContext формируют ключ, а объект ISerializationSurrogate используется как значение. Если ключ для рассматриваемой комбинации Type/StreamingContext уже существует, метод AddSurrogate генерирует исключение ArgumentException. Присутствие в ключе объекта StreamingContext позволяет зарегистрировать два объекта суррогатного типа: один умеет сериализовать в файл объект DateTime, второй — сериализовать/ десериализовать объект DateTime в другой процесс.
ПРИМЕЧАНИЕ
В классе BinaryFormatter существует ошибка, из-за которой суррогат не может сериализовать ссылающиеся друг на друга объекты. Для ее устранения следует передать ссылку на объект ISerializationSurrogate статическому методу GetSurrog ateForCyclicalReference класса FormatterServices. Этот метод возвращает объект ISerializationSurrogate, который затем можно передать методу AddSurrogate класса SurrogateSelector. Однако при работе с методом GetSurrogateForCyclicalReference метод SetObjectData вашего суррогата должен менять значение внутри объекта, на который ссылается параметр obj, и в конце концов возвращать вызывающему методу значение null или obj. В прилагаемом к книге коде (он доступен для загрузки) демонстрируется, как отредактировать класс UniversalToLocalTimeSeriali zationSurrogate и метод SerializationSurrogateDemo, чтобы обеспечить поддержку циклических ссылок.
Цепочка селекторов суррогатов
Несколько объектов SurrogateSelector можно связать в цепочку. К примеру, у вас может быть объект SurrogateSelector с набором суррогатов сериализации, предназначенных для сериализации типов в представители с целью передачи по каналу связи или между доменами приложений. Может существовать и специальный объект SurrogateSelector с набором суррогатов сериализации, предназначенных для преобразования типов версии 1 в типы версии 2.
Чтобы модуль форматирования смог использовать все эти объекты, их нужно соединить в цепочку. Тип SunnogateSelector реализует интерфейс ISurrogateSelector, определяющий три метода. Все они связаны с созданием цепочек. Вот определение интерфейса ISurrogateSelector:
public interface ISurrogateSelector {
void ChainSelector(ISurrogateSelector selector);
ISurrogateSelector GetNextSelectorQ;
ISerializationSurrogate GetSurrogate(
Type type, StreamlngContext context, out ISurrogateSelector selector);
}
Метод ChainSelector вставляет объект ISurrogateSelector после того объекта ISurrogateSelector, с которым ведется работа (на него указывает ключевое слово this). Метод GetNextSelector возвращает ссылку на следующий объект ISurrogateSelector в цепочке, или значение null, если цепочка закончилась.
Метод GetSurrogate ищет пару Type/StreamingContext в объекте ISurrogateSelector, идентифицируемом ключевым словом this. Если паране обнаруживается, рассматривается следующий объект ISurrogateSelector и т. д. В случае же обнаружения пары метод GetSurrogate возвращает объект ISerializationSurrogate, выполняющий сериализацию/десериализацию просматриваемого типа. Кроме того, возвращается содержащий пару объект ISurrogateSelector; но необходимости в этом обычно нет, и данный результат работы метода игнорируется. Если ни один из объектов ISurrogateSelector в цепочке не имеет совпадения для пары Туре/ StreamingContext, метод GetSurrogate возвращает значение null.
ПРИМЕЧАНИЕ
В FCL определен интерфейс ISurrogateSelector и реализующий его тип Surroga- teSelector. При этом практически никто и никогда не определяет собственных типов, реализующих этот интерфейс. Ведь единственной причиной его создания может быть разве что повышенная гибкость при отображении между типами — например, если возникает необходимость сериализовать все типы, наследующие от определенного базового класса определенным образом. Превосходным образчиком такого класса является System.Runtime.Remoting.Messaging.RemotingSurrogateSelector. Сериализуя объекты для удаленной передачи, CLR форматирует их методом RemotingSurrogateSelector. Этот суррогатный селектор сериализует все производные OTSystem.MarshalByRefObject объекты таким образом, что десериализация приводит к созданию объектов-представителей на стороне клиента.
Переопределение сборки и/или типа при десериализации объекта
В процессе сериализации объекта модули форматирования выводят в поток полное имя типа и полное имя определяющей этот тип сборки. При десериализации эта
информация позволяет им точно узнать тип конструируемого и инициализируемого объекта. При обсуждении интерфейса ISenializationSunnogate я продемонстрировал механизм, позволяющий производить сериализацию и десериализацию определенного типа. Тип, реализующий интерфейс ISerializationSurrogate, привязан к определенному типу в определенной сборке.
Однако бывают ситуации, когда указанный механизм оказывается недостаточно гибким. Вот ситуации, в которых может оказаться полезным десериализация объекта в другой тип:
□ Перемещение реализации типа из одной сборки в другую. Например, номер версии сборки меняется, и новая сборка начинает отличаться от исходной.
□ Объект с сервера сериализуется в поток, отправляемый на сторону клиента. При обработке потока клиент может десериализовать объект в совершенно другой тип, код которого «знает», как удаленным методом обратиться к объектам на сервере.
□ Разработчик создает новую версию типа и именно в нее требуется десериализовать все ранее сериализованные объекты.
Десериализация объектов в другой тип легко выполняется при помощи класса System. Runtime.Serialization.SerializationBinder. Достаточно определить тип, производный от абстрактного типа SerializationBinder. В показанном далее коде предполагается, что версия 1.0.0.0 сборки определяет класс с именем Verl. И эта новая версия определяет класс VerlToVer2SerializationBinder и класс с именем Ver2:
internal sealed class VerlToVer2SerializationBinder : SerializationBinder {
public override Type BindToType(String assemblyName, String typeName) {
// Десериализация объекта Verl из версии 1.0.0.0 в объект Ver2
// Вычисление имени сборки, определяющей тип Verl
AssemblyName assemVerl = Assembly.GetExecutingAssemblyQ.GetName(); assemVerl.Version = new Version(l, 0, 0, 0);
// При десериализации объекта Verl версии vl.0.0.0 превращаем его в Ver2 if (assemblyName == assemVerl.ToStringQ && typeName == "Verl") return typeof(Ver2);
П В противном случае возвращаем запрошенный тип
return Type.GetType(String.Format("{0}, {1}", typeName, assemblyName));
}
}
После создания модуля форматирования нужно создать экземпляр Verl- ToVer2SerializationBinder и присвоить открытому для чтения и записи свойству Binder ссылку на объект привязки. После этого можно вызывать метод Deserialize. В процессе десериализации модуль форматирования обнаружит привязку и для каждого обрабатываемого объекта вызовет метод BindToType, передавая ему имя сборки и тип, которые требуется десериализовать. На этой стадии метод BindToType решает, какой тип следует сконструировать, и возвращает этот тип.
ПРИМЕЧАНИЕ
Класс SerializationBinder позволяет также в процессе сериализации менять информацию о сборке/типе путем переопределения метода BindToName. Данный метод выглядит следующим образом:
public virtual void BindToName(Type serializedType, out string assemblyName, out string typeName)
Во время сериализации модуль форматирования вызывает данный метод, передавая тип, который он собирается сериализовать. После этого вы можете передать (при помощи двух параметров out) сборку и тип, которые хотите сериализовать вы. Если же в параметрах передаются null и null (именно это происходит в заданной по умолчанию реализации), тип и сборка остаются без изменений.
Глава 25. Взаимодействие с компонентами WinRT
В Windows 8 появилась новая библиотека классов, при помощи которой приложения могут использовать функциональность операционной системы. Эта библиотека классов официально называется Windows Runtime (WinRT), а для работы с ее компонентами применяется система типов WinRT. Многие задачи, для решения которых создавалась WinRT, совпадают с задачами общеязыковой среды CLR в ее исходном воплощении — например, упрощение разработки приложений и простое взаимодействие с кодом, написанным на других языках программирования. Компания Microsoft обеспечивает поддержку использования компонентов WinRT в неуправляемом коде C/C++, в JavaScript (для виртуальной машины JavaScript «Chakra» от Microsoft), а также в C# и Visual Basic.
На рис, 25.1 представлены различные возможности, предоставляемые компонентами WinRT, и различные языки, поддерживаемые Microsoft для работы с ним и. Код приложений, написанных на неуправляемом C/C++, должен компилироваться для каждой конкретной архитектуры процессора (х86, х64 и ARM). Разработчикам Microsoft .NET Framework достаточно откомпилировать свой код в IL-код, чтобы потом среда CLR преобразовала его в машинный код для конкретного процессора. Разработчики JavaScript включают исходный код в свое приложение, а виртуальная машина «Chakra» разбирает его и преобразует в машинный код конкретного процессора. Другие компании тоже могут выпускать языки и среды, поддерживающие взаимодействие с компонентами WinRT.
Приложения Windows Store и настольные приложения могут использовать компоненты WinRT для обращения к функциональности операционной системы. Пока количество компонентов WinRT, поставляемых как составная часть Windows, относительно невелико по сравнению с размером библиотеки классов .NET Framework. Впрочем, это вполне естественно, потому что компоненты ориентированы на решение тех задач, с которыми операционная система справляется лучше всего: предоставления разработчикам абстрактного представления оборудования и средств взаимодействия между приложениями. Таким образом, большинство компонентов WinRT предоставляет такие функции, как хранение информации, сетевые операции, графика, мультимедиа, безопасность, многопоточность и т. д. Другие базовые средства (например, операции со строками) и более сложные подсистемы (например, поддержка LiNQ) операционной системой не поддерживаются, а предоставляются языком, используемым для работы с компонентами WinRT операционной системы.
DirectX или XAML | XAML | HTML & CSS | |||||
C/C++ | CRT | C#/VB | FCL | CO —Э | WinJS | ||
(исполнительная среда отсутствует; приложение строится для х86, х64, ARM) | CLR | “Chakra” | |||||
|
Windows 8 | ||||
| WinRT | |||
„ Графикам Устройства M Y мультимедиа | Хранение „ I Сети информации | Безопасность Программные потоки | ||
| ||||
Модель приложения | Пользовательский интерфейс | XAML | ||
| ||||
|
Рис. 25.1. Функциональность компонентов WinRT и различные языки, поддерживаемые Microsoft для работы с ними
|
Во внутренней реализации компоненты WinRT представляют собой компоненты COM (Component Object Model) — технологии, представленной компанией Microsoft в 1993 году. СОМ имеет репутацию излишне сложной модели с множеством запутанных правил и крайне громоздкой моделью программирования. Тем не менее в модели СОМ было заложено немало правильных идей, и за прошедшие годы разработчики Microsoft приложили значительные усилия по ее упрощению. Для компонентов WinRT компания Microsoft ввела очень значительное изменение: вместо библиотек типов для описания API компонентов СОМ теперь используются метаданные. Да, АРО компонентов WinRT описывается в формате метаданных .NET (ЕСМА-335), который был стандартизирован комитетом ЕСМА — и в том самом формате метаданных, который рассматривался в этой книге.
Метаданные обладают значительно большими возможностями, чем библиотеки типов, а их полноценная поддержка изначально заложена в CLR. Кроме того, CLR поддерживает взаимодействие с компонентами СОМ через обертки RCW (Runtime Callable Wrappers) и CCW (COM Callable Wrappers). В общем и целом это позволяет языкам (таким, как С#), работающим на базе CLR, легко взаимодействовать с типами и компонентами WinRT.
В C# ссылка на объект WinRT в действительности представляет собой ссылку на обертку RCW, которая содержит внутреннюю ссылку на объект WinRT. Аналогичным образом при передаче объекта CLR WinRT API вы в действительности передаете ссылку на обертку CCW, a CCW содержит ссылку на объект CLR.
Метаданные компонентов WinRT хранятся в файлах с расширением .winmd. У компонентов WinRT, входящих в поставку Windows, метаданные хранятся в файлах Windows.*.winmd, находящихся в каталоге %WinDir%\System32\WinMetadata. При построении приложения используется ссылка на следующий файл Windows.winmd, устанавливаемый Windows SDK:
%ProgramFiles(x86)%\Windows Kits\8.0\References\CommonConiguration\Neutral\Windows. winmd
Система типов Windows Runtime создавалась, прежде всего, для того, чтобы разработчики могли успешно писать приложения с применением всех знакомых им технологий, инструментов, приемов и соглашений. Для этого некоторые функции WinRT проецировались на соответствующие технологии разработки. Для разработчиков .NET Framework существует два вида проекций:
□ Проекции уровня CLR неявно реализуются средой CLR (и как правило, в отношении интерпретации метаданных). Следующий раздел посвящен правилам системы типов компонентов WinRT и тому, как CLR проецирует эти правила на парадигму разработки .NET Framework.
□ Проекции уровня .NET Framework реализуются явно в вашем коде посредством использования новых API, введенных в FCL. Проекции уровня .NET Framework необходимы в тех ситуациях, когда рассогласование между системой типов WinRT и системой типов CLR становится слишком значительным для неявного разрешения средствами CLR. Проекции уровня .NET Framework рассматриваются далее в этой главе.
Проекции уровня CLR и правила системы типов компонентов WinRT
Компоненты WinRT образуют систему типов, сходную с системой типов CLR. Когда среда CLR встречает тип WinRT, она обычно разрешает использование этого типа с использованием обычных технологий взаимодействия CLR. Однако в некоторых случаях CLR скрывает тип WinRT и предоставляет доступ к нему через другой тип. Во внутренней реализации CLR ищет некоторые типы (при помощи метаданных), а затем отображает их на типы FCL. Полный список типов WinRT, которые CLR неявно проецирует на типы FCL, доступен по адресу http://msdn.microsoft.com/ en-ns/library/windows/apps/hh995050.aspx.
Основные концепции системы типов WinRT
Система типов WinRT по функциональности уступает системе типов CLR. Ниже перечислены основные концепции системы типов WinRT и способы их проекции CLR.
Имена файлов и пространства имен. Имя самого файла .winmd должно совпадать с именем пространства имен, содержащего компоненты WinRT. Например, файл с именем Wintellect.WindowsStore.winmd должен содержать компоненты WinRT, определенные в пространстве имен Wintellect. WindowsStore или в одном из его подпространств. Поскольку файловая система Windows не учитывает регистр символов, пространства имен, различающиеся только регистром символов, недопустимы. Кроме того, имя компонента WinRT не может совпадать с именем пространства имен.
Общий базовый тип. Компоненты WinRT не имеют общего базового класса. Когда CLR проецирует тип WinRT, все выглядит так, словно тип WinRT является производным от System.Object; соответственно все типы WinRT наследуют такие открытые методы, как ToString, GetHashCode, Equals и GetType. При использовании объекта WinRT в C# объект кажется производным от System.Object, а объекты WinRT могут передаваться в коде. Также возможен вызов «унаследованных» методов — таких, как ToString.
Основные типы данных. Система типов WinRT поддерживает основные типы данных: логический, байтовый без знака, 16-, 32 и 64-разрядные целые числа со знаком и без, вещественные числа одинарной и двойной точности, 16-разрядные символы, строки и void1. Все остальные типы данных, как и в CLR, образуются из этих основных типов данных.
Классы. Система типов WinRT является объектно-ориентированной; это означает, что компоненты WinRT поддерживают абстракцию данных, наследование и полиморфизм[35] [36]. Однако некоторые языки (например, JavaScript) не поддерживают наследование типов, и в интересах этих языков компоненты WinRT почти не используют наследование, а это значит, что они также не используют полиморфизм. По сути наследование и полиморфизм задействованы только теми компонентами WinRT, предназначенных для других языков, помимо JavaScript. Из компонентов WinRT, включенных в поставку Windows, наследование и полиморфизм используются только компонентами XAML (для построения пользовательских интерфейсов). Приложения, написанные HaJavaScript, строят свой пользовательский интерфейс средствами HTML и CSS.
Структуры. WinRT поддерживает структуры (значимые типы), экземпляры которых продвигаются по значению через границы взаимодействий (interoperability boundary) СОМ. В отличие от значимых типов CLR, структуры WinRT могут содержать только открытые поля, которые относятся к основным типам данных (или являются другими структурами WinRT)[37]. Кроме того, структуры WinRT не могут определять конструкторы или вспомогательные методы. Для удобства CLR проецирует некоторые структуры операционной системы WinRT на собственные типы CLR, которые могут содержать конструкторы и вспомогательные методы.
Такие спроецированные типы выглядят более естественно для разработчиков CLR. В качестве примеров можно привести структуры Point, Rect, Size и TimeSpan, определенные в пространстве имен Windows. Foundation.
Null -совместимые структуры. В рамках WinRT API также могут определяться null-совместимые структуры (значимые типы). CLR проецирует интерфейс WinRT Windows.Foundation.IRefenence<T> как тип CLR System.Nullable<T>.
Перечисления. Значения перечислимых типов передаются просто в виде 32-разрядного целого числа со знаком или без. Если вы определяете перечисляемый тип в С#, он должен базироваться на типе int или uint. Кроме того, 32-раз- рядные без знака интерпретируются как флаги, которые могут объединяться операцией ИЛИ.
Интерфейсы. В типах параметров и возвращаемых значений интерфейсов WinRT могут использоваться только WinRT-совместимые типы.
Методы. В WinRT реализована ограниченная перегрузка методов. А именно, поскольку язык JavaScript использует динамическую типизацию, он не умеет различать методы, различающиеся только по типам параметров. Например, JavaScript преспокойно передаст число методу, ожидающему получить строку. Однако JavaScript отличит метод с одним параметром от метода с двумя параметрами. Кроме того, WinRT не поддерживает методы перегрузки операторов и значения аргументов по умолчанию.
Свойства. В качестве типа данных свойств WinRT могут задаваться только WinRT-совместимые типы. WinRT не поддерживает параметризованные свойства и свойства, доступные только для записи.
Делегаты. В типах параметров и возвращаемых значений делегатов WinRT могут использоваться только WinRT-совместимые типы. При передаче делегата компоненту WinRT объект делегата упаковывается в CCW и не уничтожается уборщиком мусора до тех пор, пока обертка CCW не будет освобождена использующим ее компонентом WinRT. Делегаты WinRT не имеют методов Beginlnvoke и Endlnvoke.
События. Компоненты WinRT могут определять события, используя типы делегатов WinRT. Так как многие компоненты WinRT запечатаны (не допускают наследование), в WinRT определяется делегат TypedEventHandler, у которого параметр sender относится к обобщенному типу (вместо System.Object).
public delegate void TypedEventHandlercTSender, TResult>(TSender sender,
TResult args);
Также существует тип делегата Windows. Foundation. EventHandler<T>, который CLR проецирует на знакомый тип делегата .NET Framework System. EventHandler<T>.
Исключения. Во внутренней реализации компоненты WinRT, как и компоненты СОМ, передают информацию о своем состоянии в значениях HRESULT (32-разряд - ное целое число со специальной семантикой). CLR проецирует значения WinRT типа Windows.Foundation.HResult на объекты исключений. Когда WinRT API возвращает значение HRESULT, заведомо соответствующее ошибке, CLR выдает экземпляр соответствующего класса, производного от Exception. Например, HRESULT 0х8007000е (E_0UT0FMEM0RY) отображается на System. OutOfMemoryException. Для других кодов HRESULT CLR выдает объект System. Exception, у которого свойство HResult содержит значение HRESULT. Компонент WinRT, реализованный на С#, может просто выдать исключение нужного типа, a CLR преобразует его в соответствующее значение HRESULT. Чтобы полностью контролировать значение HRESULT, создайте объект исключения, задайте соответствующее значение HRESULT в свойстве HResult объекта, после чего выдайте объект исключения.
Строки. Конечно, неизменяемые строки могут передаваться между системами типов WinRT и CLR. Тем не менее система типов WinRT не разрешает строкам принимать значение null. Если передать null в строковом параметре функции WinRT API, CLR обнаруживает этот факт и выдает исключение ArgumentNullException; вместо null для передачи пустой строки функциям WinRT API следует использовать String. Empty. Строки передаются по ссылке; возвращение строк функциями WinRT API всегда сопровождается их копированием. При передаче или получении строковых массивов CLR(String[]) от функций WinRT API создается копия массива, которая передается или возвращается на сторону вызова.
Дата и время. Структура WinRT Windows.Foundation.DateTime представляет дату/время в формате UTC. CLR проецирует структуру WinRT DateTime на структуру .NET Framework System.DateTimeOffset, которую следует использовать вместо структуры .NET Framework System.DateTime. В итоговом экземпляре DateTimeOf f set CLR преобразует дату и время UTC, возвращаемые WinRT, в локальное время. CLR передает функциям WinRT API структуру DateTimeOffet с временем UTC.
URI. CLR проецирует тип WinRT Windows . Foundation . Uri на тип .NET
Framework System.Uri. Если при передаче типа .NET Framework Uri функции WinRT API используется относительный URI-адрес, среда CLR выдает исключение ArgumentException; WinRT поддерживает только абсолютные URI. Переход через границы взаимодействий всегда сопровождается копированием URI.
IClosable/IDisposable CLR проецирует интерфейс WinRT Windows. Foundation. IClosable (состоящий из единственного метода Close) на интерфейс .NET Framework System.IDisposable (содержащий метод Dispose). Следует учесть, что все функции WinRT API, выполняющие операции ввода-вывода, реализованы асинхронно. Так как метод интерфейса IClosable называется Close, а не CloseAsync, метод Close не должен выполнять никакие операции ввода-вывода. В этом он семантически отличается от типичного поведения Dispose в .NET Framework. Для типов, реализованных в .NET Framework, метод Dispose может выполнять операции ввода-вывода; более того, часто он обеспечивает запись буферизованных данных перед фактическим закрытием устройства. Flo когда код C# вызывает Dispose для типа WinRT, операции ввода-вывода (в частности, запись буферизованных данных) выполняться не будут, что может привести к возможной потере данных. Вы должны учитывать это обстоятельство и явно вызывать методы, предотвращающие потерю
данных, для компонентов WinRT, инкапсулирующих потоки вывода. Например, при использовании объекта DataWriter всегда следует вызывать его метод StoreAsync.
Массивы. В WinRT API поддерживаются одномерные массивы с индексированием от нуля. WinRT может передавать элементы массива либо в метод, либо из него — но никогда в обоих направлениях. Соответственно вы не сможете передать массив функции WinRT API, изменить элементы массива, а затем обратиться к измененным элементам после возвращения из функции АРР. Впрочем, я описал контракт, который должен соблюдаться. Тем не менее среда не занимается активным контролем его соблюдения, поэтому некоторые проекции могут передавать содержимое массива в обоих направлениях. Обычно это делается для естественного повышения производительности.
Коллекции. При передаче коллекции WinRT API среда СLR упаковывает объект коллекции в обертку CCW и передает ссылку на CCW функции WinRT API. При вызовах через CCW вызывающий поток пересекает границу взаимодействия, что приводит к снижению производительности. С другой стороны, в отличие от массивов, при передаче коллекций WinRT API возможно выполнение операций с коллекциями «на месте» без копирования элементов. В табл. 25.1 перечислены интерфейсы коллекций WinRT и их проекции в коде приложений .NET.
Таблица 25.1. Интерфейсы коллекций WinRT и их проекции в CLR
|
|
Как показывает приведенный список, команда CLR основательно потрудилась над тем, чтобы по возможности упростить взаимодействие между системой типов [38]
WinRT и системой типов CLR, а разработчики управляемого кода могли бы использовать компоненты WinRT в своем коде1.
Проекции уровня .NET Framework
Если CLR не может неявно подобрать проекцию типа WinRT для разработчика .NET Framework, приходится использовать явные проекции. Есть три основные области, в которых необходимы проекции: асинхронное программирование, взаимодействие между потоками WinRT и .NET Framework, а также передача блоков данных между функциями CLR и WinRT API. Эти три области более подробно рассматриваются в следующих трех разделах этой главы.
Асинхронные вызовы WinRT API из кода .NET
Синхронное выполнение операции ввода-вывода в программном потоке может привести к его блокировке на неопределенный период времени. Если поток графического интерфейса ожидает завершения синхронной операции ввода/вывода, пользовательский интерфейс приложения перестает реагировать на действия пользователя (операции с сенсорным экраном, мышью и пером), а это раздражает пользователя. Чтобы предотвратить подобную блокировку, компоненты WinRT, выполняющие операции ввода-вывода, предоставляют доступ к своей функциональности через асинхронный программный интерфейс. Более того, компоненты WinRT, выполняющие вычислительные операции, также предоставляют доступ к своей функциональности через асинхронный программный интерфейс, если выполнение операции занимает более 50 миллисекунд. Проблемы построения приложений, быстро реагирующих на действия пользователя, также рассматриваются в части V этой книги.
Чтобы эффективно использовать многочисленные асинхронные функции WinRT API, необходимо понимать, как правильно работать с ними в коде С#. Рассмотрим следующий пример:
public void WinRTAsyncIntro() {
IAsyncOperation<StorageFile> asyncOp =
KnownFolders.MusicLibrary.GetFileAsync("Song.mp3"); asyncOp.Completed = OpCompleted;
// Возможно, позднее будет вызван метод asyncOp.Cancel/)
}
// ВНИМАНИЕ: метод обратного вызова выполняется в программном потоке // графического интерфейса или пула потоков:
продолжение [39]
private void OpCompleted(IAsyncOperation<StorageFile> asyncOp, AsyncStatus status)
{
switch (status) {
case AsyncStatus.Completed: // Обработка результата
StorageFile file = asyncOp.GetResultsQ; /* Завершено... */ break;
case AsyncStatus.Canceled: // Обработка отмены
/* Canceled... */ break;
case AsyncStatus.Error: // Обработка исключения
Exception exception = asyncOp.ErrorCode; /* Ошибка... */ break;
}
asyncOp.Close();
}
Метод WinRTAsyncIntro вызывает метод WinRT GetFileAsync для поиска файла в медиатеке пользователя. Все функции WinRT API, выполняющие асинхронные операции, имеют суффикс Async и возвращают объект, тип которого реализует интерфейс WinRT IAsyncXxx; в данном случае интерфейс IAsyncOperation<TResult>, где TResult — тип WinRT StorageFile. Этот объект, ссылку на который я поместил в переменную asyncOp, представляет незавершенную асинхронную операцию. Ваш код должен каким-то образом получить уведомление о завершении операции. Для этого необходимо реализовать метод обратного вызова (OpCompleted в моем примере), создать для него делегата и задать делегата свойству Completed объекта asyncOp. Теперь при завершении операции метод обратного вызова будет активизирован каким-либо потоком (необязательно потоком графического интерфейса). Если операция была завершена перед назначением делегата свойству OnCompleted, система вызовет метод обратного вызова как можно быстрее. Другими словами, здесь возникает ситуация гонки, но объект, реализующий интерфейс IAsyncXxx, разрешит ее за вас, обеспечивая правильность работы кода.
Как указано в конце метода WinRTAsyncIntro, для отмены незаверенной операции также можно вызвать метод Cancel, реализуемый всеми интерфейсами IAsyncXxx. Все асинхронные операции завершаются по одной из трех причин: успешного выполнения операции до конца, явной отмены или ошибки при выполнении операции. При завершении операции по одной из этих причин система вызывает метод обратного вызова и передает ему ссылку на объект, возвращенный исходным методом XxxAsync, и AsyncStatus. Мой метод OnCompleted проверяет параметр status и обрабатывает либо результат при успешном завершении, либо явную отмену, либо ошибку[40]. Также обратите внимание на то, что после обработки завершения операции для объекта интерфейса IAsyncXxx необходимо вызвать метод Close.
На рис. 25.2 изображены различные интерфейсы WinRT IAsyncXxx. Все четыре главных интерфейса происходят от интерфейса IAsyncInfo. Два интерфейса IAsyncAction предоставляют возможность узнать о завершении операции, но их операции завершаются без возвращаемого значения (их методы GetResults возвращают void). Два интерфейса IAsyncOpenation позволяют не только узнать о завершении операции, но и получить возвращаемое значение (их методы GetResults возвращают обобщенный тип TResult).
Два интерфейса IAsyncXxxWithProgress позволяют коду получать периодические оповещения о ходе выполнения асинхронной операции. Большинство асинхронных операций не поддерживает оповещения, но у некоторых видов операций (как, например, у фоновой загрузки и отправки данных) такая возможность предусмотрена. Для получения оповещений следует определить в коде еще один метод обратного вызова, создать для него делегата и назначить его свойству Progress объекта IAsyncXxxWithProgress. При обращении к методу обратного вызова передается аргумент, тип которого соответствует обобщенному типу TProgress.
Рис. 25.2. Интерфейсы WinRT, относящиеся к выполнению асинхронного ввода-вывода и вычислительных операций |
В .NET Framework для упрощения асинхронных операций используются типы из пространства имен System.Threading.Tasks. Типы для выполнения асинхронных вычислений и их использование рассматриваются в главе 27, а типы для выполнения асинхронного ввода-вывода — в главе 28. Кроме того, в C# имеются ключевые слова async и await, которые позволяют выполнять асинхронные операции с применением модели последовательного программирования, существенно упрощающей структуру кода.
Следующий код представляет собой переработанную версию упоминавшегося ранее метода WinRTAsyncIntro. В этой версии задействованы некоторые методы расширения .NET Framework, преобразующие модель асинхронного программирования WinRT в более удобную модель программирования С#.
using System; // Необходимо для методов расширения
// из WindowsRuntimeSystemExtensions
public async void WinRTAsyncIntro() { try {
StorageFile file = await KnownFolders.MusicLibrary.GetFileAsync("Song.mp3");
/* Завершение... */
>
catch (OperationCanceledException) { /* Отмена... */ } catch (SomeOtherException ex) { /* Ошибка... */ }
Оператор C# await заставляет компилятор провести поиск метода GetAwaiter в интерфейсе IAsyn сОре rat ion< St or age File >, возвращенном методом Get F ileAsy n c. Интерфейс не предоставляет метод GetAwaiter, поэтому компилятор ищет метод расширения. К счастью, разработчик .NET Framework включили в библиотеку System.Runtime.WindowsRuntime.dll методы расширения, вызываемые для интерфейсов WinRT IAsyn сХхх.
namespace System {
public static class WindowsRuntimeSystemExtensions {
public static TaskAwaiter GetAwaiter(this IAsyncAction source); public static TaskAwaiter GetAwaiter<TProgress>(this IAsyncActionWithProgress<TProgress> source); public static TaskAwaiter<TResult> GetAwaiter<TResult>(this IAsyncOperation<TResult> source);
public static TaskAwaiter<TResult> GetAwaitercTResult, TProgress>(
this IAsyncOperationWithProgresscTResult, TProgress> source);
}
}
Во внутренней реализации все эти методы создают объект TaskCompletionSource и приказывают объекту IAsyncXxx обратиться к методу обратного вызова, который задает финальное состояние TaskCompletionSource при завершении асинхронной операции. Объект TaskAwaiter, возвращаемый методами расширения — то, что в конечном счете должен получить С#. При завершении асинхронной операции объект TaskAwaiter следит за тем, чтобы код продолжал выполняться через объект SynchronizationContext (см. главу 28), связанный с исходным потоком. Затем поток выполняет код, сгенерированный компилятором С#, который запрашивает свойство Result объекта TaskCompletionSource.Task; это приводит к получению результата (StorageFile в моем примере), выдаче исключения OperationCanceledException в случае отмены или другого исключения в случае ошибки. Пример внутренней реализации этих методов приведен в конце раздела.
Мы рассмотрели наиболее типичный сценарий вызова асинхронной функции WinRT API и определения результата. Однако в своем коде я показал, как узнать об отмене операции, но не объяснил, как на практике производится отмена. Также остался нерассмотренным вопрос обработки оповещений о ходе выполнения операции. Чтобы правильно обработать отмену и оповещения, вместо автоматического вызова компилятором одного из методов расширения GetAwaiter следует явно вызвать один из методов расширения AsTask, также явно определяемых классом WindowsRuntimeSystemExtensions.
namespace System {
public static class WindowsRuntimeSystemExtensions { public static Task AsTask<TProgress>(this
IAsyncActionWithProgress<TProgress> source,
CancellationToken CancellationToken, IProgress<TProgress> progress);
public static Task<TResult> AsTaskcTResult, TProgress>(
this IAsyncOperationWithProgresscTResult, TProgress> source, CancellationToken CancellationToken, IProgress<TProgress> progress);
// Более простые перегруженные версии не показаны
}
}
Итак, пора рассмотреть реализацию в целом. Вот как происходит асинхронный вызов функций WinRT API с полной поддержкой отмены и оповещений о ходе выполнения в тех случаях, когда они необходимы:
using System; // Для AsTask из WindowsRuntimeSystemExtensions using System.Threading; // Для CancellationTokenSource internal sealed class MyClass {
private CancellationTokenSource m_cts = new CancellationTokenSourceQ;
// ВНИМАНИЕ: при вызове из потока графического интерфейса // весь код выполняется в этом потоке:
private async void MappingWinRTAsyncToDotNet(WinRTType someWinRTObj) { try {
// Предполагается, что XxxAsync возвращает // IAsyncOperationWithProgresscIBuffer, UInt32>
IBuffer result = await someWinRTObj.XxxAsync(...)
•AsTask(m_cts.Token, new Progress<UInt32>(ProgressReport));
/* Завершение... */
>
catch (OperationCanceledException) { /* Отмена... */ } catch (SomeOtherException) { /* Ошибка... */ }
>
private void ProgressReport(UInt32 progress) { /* Оповещение... */ } public void Cancel() { m_cts.Cancel(); } // Вызывается позднее
>
Конечно, многим читателям хотелось бы понять, как методы AsTask преобразуют объект WinRT IAsyncXxx в объект .NET Framework Task, к которому в конечном итоге применяется await. В следующем коде представлена внутренняя реализация самого сложного метода AsTask. Конечно, более простые перегруженные версии устроены проще.
public static Task<TResult> AsTaskcTResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> asyncOp,
CancellationToken ct = default(CancellationToken),
IProgress<TProgress> progress = null) {
// При отмене CancellationTokenSource отменить асинхронную операцию ct.Register(() => asyncOp.Cancel());
II Когда асинхронная операция оповещает о прогрессе,
// оповещение передается методу обратного вызова asyncOp.Progress = (asynclnfo, р) => progress.Report(p);
П Объект TaskCompletionSource наблюдает за завершением // асинхронной операции
var tcs = new TaskCompletionSource<TResult>();
П При завершении асинхронной операции оповестить TaskCompletionSource.
// Когда это происходит, управление возвращается коду,
// ожидающему завершения TaskCompletionSource. asyncOp.Completed = (async0p2, asyncStatus) => { switch (asyncStatus) {
case AsyncStatus.Completed: tcs.SetResult(async0p2.GetResults()); break;
case AsyncStatus.Canceled: tcs.SetCanceled(); break;
case AsyncStatus.Error: tcs.SetException(async0p2.ErrorCode); break;
}
};
return tcs.Task;
}
Взаимодействия между потоками WinRT и потоками .NET
Многие классы .NET Framework работают с типами, производными от System. 10. St ream — как, например, классы сериализации и LINQ. Чтобы использовать объект WinRT, реализующий интерфейсы WinRT IStorageFile или IStorageFolder, с классом .NET Framework, которому необходим тип, производный от Stream, следует задействовать методы расширения, определенные в классе System. 10.Wi ndowsRuntimeStorageExtensions.
namespace System.10 { // Определяется в System.Runtime.WindowsRuntime.dll
public static class WindowsRuntimeStorageExtensions {
public static Task<Stream> OpenStreamForReadAsync(this IStorageFile file); public static Task<Stream> OpenStreamForWriteAsync(this IStorageFile file);
public static Task<Stream> OpenStreamForReadAsync(this IStorageFolder rootDirectory,
String relativePath);
public static Task<Stream> OpenStreamForWriteAsync(this
IStorageFolder rootDirectory,
String relativePath, CreationCollisionOption creationCollisionOption);
}
}
В следующем примере один из методов расширения используется для открытия объекта WinRT StonageFile и чтения его содержимого в объект .NET Framework XElement.
async Task<XElement> FromStorageFileToXElement(StorageFile file) { using (Stream stream = await file.OpenStreamForReadAsync()) { return XElement.Load(stream);
}
}
Наконец, класс System. 10.WindowsRuntimeStreamExtensions предоставляет методы расширения, «преобразующие» потоковые интерфейсы WinRT (такие, как IRandomAccessStream, IlnputStream или IOutputStream) в тип .NET Framework Stream, и наоборот.
namespace System.10 { // Определяется в System.Runtime.WindowsRuntime.dll
public static class WindowsRuntimeStreamExtensions {
public static Stream AsStream(this IRandomAccessStream winRTStream); public static Stream AsStream(this IRandomAccessStream winRTStream,
Int32 bufferSize);
public static Stream AsStreamForRead(this IlnputStream winRTStream); public static Stream AsStreamForRead(this IlnputStream winRTStream,
Int32 bufferSize);
public static Stream AsStreamForWrite(this IOutputStream winRTStream); public static Stream AsStreamForWrite(this IOutputStream winRTStream,
Int32 bufferSize);
public static IlnputStream AsInputStream (this Stream clrStream); public static IOutputStream AsOutputStream(this Stream clrStream);
}
}
В следующем примере один из методов расширения используется для «преобразования» объекта WinRT IlnputStream в объект .NET Framework Stream.
XElement FromWinRTStreamToXElement(IInputStream winRTStream) {
Stream netStream = winRTStream.AsStreamForReadQ; return XElement.Load(netStream);
}
Обратите внимание: методы «преобразования», предоставляемые .NET Framework, не ограничиваются простым внутренним преобразованием типа. Методы, адаптирующие потоки WinRT в потоки .NET Framework, неявно создают буфер для потока WinRT в управляемой куче. В результате большинство операций осуществляет запись в буфер и не нуждается в пересечении границы взаимодействия, что способствует повышению производительности — это особенно важно в ситуациях с многочисленными мелкими операциями ввода-вывода (например, при разборе документа XML).
Одно из преимуществ использования проекций потоков .NET Framework заключается в том, что если метод используется более одного раза для AsStreamXxx для одного экземпляра потока WinRT, вам не придется беспокоиться о возможности создания нескольких разных буферов и о том, что данные, записанные в один буфер, не будут видны в другом. Функции .NET Framework API следят за тем, чтобы каждый объект потока использовал уникальный экземпляр адаптера, а все пользователи работали с одним буфером.
И хотя в большинстве случаев стандартная буферизация обеспечивает хороший компромисс между производительностью и затратами памяти, иногда требуется изменить 16-килобайтный размер буфера, используемый по умолчанию. Методы AsStreamXxx предоставляют перегруженные версии с такой возможностью. Например, если вы знаете, что будете работать с очень большим файлом в течение длительного времени, а количество одновременно используемых других буферизованных потоков будет небольшим, вы сможете обеспечить некоторый прирост производительности, выделив для своего потока очень большой буфер. И наоборот, в некоторых ситуациях с необходимостью минимальной задержки можно потребовать, чтобы из сети читалось ровно столько байтов, сколько необходимо приложению; тогда буферизацию можно вообще отключить. Если передать методу AsStreamXxx нулевой размер буфера, то объект буфера не создается.
Передача блоков данных между CLR и WinRT
Там, где это возможно, рекомендуется использовать проекции потоков из предыдущего раздела, потому что они обладают достаточно хорошими характеристиками производительности. Однако некоторые ситуации требуют передачи между CLR и компонентами WinRT физических блоков данных. Например, при использовании компонентов файловых и сокетовых потоков WinRT необходимо выполнять чтение и запись физических блоков данных. Кроме того, криптографические компоненты WinRT выполняют шифрование и дешифрование блоков данных, а пикселы растровой графики также хранятся в виде физических блоков данных.
В .NET Framework блоки данных обычно передаются в виде массива байтов (Byte [ ]) или в виде потока (например, при использовании класса MemoryStream). Конечно, массивы байтов и объекты MemoryStream не могут передаваться компонентам WinRT напрямую, поэтому WinRT определяет интерфейс IBuffer; объекты, реализующие этот интерфейс, представляют физические блоки данных, которые могут передаваться функциям WinRT API. Интерфейс WinRT IBuffer определяется следующим образом:
namespace Windows.Storage.Streams { public interface IBuffer {
UInt32 Capacity { get; } // Максимальный размер буфера (в байтах)
UInt32 Length { get; set; } // Количество используемых байтов } //в буфере
}
Как видите, объект IBuf f ег имеет максимальный размер и текущую длину; как ни странно, он не предоставляет средств для выполнения чтения или записи данных в буфер. Это объясняется, прежде всего, тем, что типы WinRT не могут выражать указатели в своих метаданных, потому что указатели плохо соответствуют правилам некоторых языков (например, JavaScript или безопасного кода С#). Таким образом, объект IBuf f ег — всего лишь способ передачи адреса памяти между CLR и WinRT API. Для обращения к байтам по адресу памяти используется внутренний интерфейс COM IBufferByteAccess. Обратите внимание: это интерфейс СОМ (потому что он возвращает указатель), а не интерфейс WinRT. Группа .NET Framework определила для этого интерфейса СОМ внутреннюю обертку RCW, которая выглядит примерно так:
namespace System.Runtime.InteropServices.WindowsRuntime {
[Guid(n905a0fefbc5311df8c49001e4fc686dan)]
[InterfaceType(ComlnterfaceType.InterfacelsIUnknown)]
[Comlmport]
internal interface IBufferByteAccess { unsafe Byte* Buffer { get; }
}
}
Во внутренней реализации CLR может взять объект IBuf f ег, запросить его интерфейс IBufferByteAccess, а затем обратиться к свойству Buffer для получения небезопасного указателя на байты, содержащиеся в буфере. По этому указателю к байтам можно обратиться напрямую.
Чтобы избавить разработчиков от написания небезопасного кода, работающего с указателями, в FCL был включен класс WindowsRuntimeBufferExtensions. Он определяет набор методов расширения, которые явно вызываются разработчиками для передачи блоков данных между массивами байтов и потоками CLR и объектами WinRT IBuffer. Для использования этих методов расширения следует включить в исходный код директиву using System.Runtime.InteropServices. WindowsRuntime;:
namespace System.Runtime.InteropServices.WindowsRuntime { public static class WindowsRuntimeBufferExtensions { public static IBuffer AsBuffer(this Byte[] source); public static IBuffer AsBuffer(this Byte[] source, Int32 offset,
Int32 length);
public static IBuffer AsBuffer(this Byte[] source, Int32 offset,
Int32 length, Int32 capacity);
public static IBuffer GetWindowsRuntimeBuffer(this MemoryStream stream); public static IBuffer GetWindowsRuntimeBuffer(this MemoryStream stream,
Int32 position, Int32 length);
}
}
Итак, если у вас имеется массив Byte[ ] и вы хотите передать его функции WinRT, получающей IBuffer, просто вызовите AsBuffer для массива Byte[ ].
По сути, ссылка на Byte [ ] упаковывается в объект, реализующий интерфейс IBuffer; содержимое массива Byte[] при этом не копируется, так что операция выполняется очень эффективно. Аналогичным образом, если у вас имеется объект MemoryStream, в котором упакован буфер Byte[ ], вы просто вызываете для него метод GetWindowsRuntimeBuffer, чтобы ссылка на буфер MemoryStream была заключена в объект, реализующий интерфейс IBuffer. И снова содержимое буфера не копируется, поэтому операция обладает высокой эффективностью. Следующий метод демонстрирует обе ситуации:
private async Task ByteArrayAndStreamToIBuffer(IRandomAccessStream winRTStream,
Int32 count) {
Byte[] bytes = new Byte[count];
await winRTStream.ReadAsync(bytes.AsBuffer()j (UInt32)bytes.Length, InputStreamOptions.None);
Int32 sum = bytes.Sum(b => b); // Обращение к прочитанным байтам
// через Byte[]
using (var ms = new MemoryStreamQ) using (var sw = new StreamWriter(ms)) {
sw.Write("This string represents data in a stream"); sw. FlushQ;
UInt32 bytesl/dritten = await
winRTStream.WriteAsync(ms.GetWindowsRuntimeBuffer());
}
}
Интерфейс WinRT IRandomAccessStream реализует интерфейс WinRT Ilnput- Stream, определяемый следующим образом:
namespace Windows.Storage.Streams {
public interface IOutputStream : IDisposable {
IAsync0perationWithProgress<UInt32, UInt32> WriteAsync(IBuffer buffer);
}
}
Когда вы вызываете методы расширения AsBuffег или GetWindowsRuntimeBuffer
в своем коде, эти методы упаковывают исходный объект в объект, класс которого реализует интерфейс IBuffer. Затем CLR создает для этого объекта обертку CCW и передает ее функции WinRT API. Когда функция WinRT API запрашивает свойство Buffer интерфейса IBufferByteAccess для получения указателя на массив байтов, массив фиксируется в памяти, а его адрес возвращается WinRT API для обращения к данным. Фиксация снимается, когда WinRT API вызывает метод СОМ Release для интерфейса IBufferByteAccess.
Если вы вызываете функцию WinRT API, возвращающую IBuffer, то, скорее всего, сами данные находятся в неуправляемой памяти, и вам нужен механизм обращения к этим данным из управляемого кода. Для решения этой задачи следует обратиться к другим методам расирения, определяемым классом WindowsRuntimeBufferExtensions.
namespace System.Runtime.InteropServices.WindowsRuntime {
public static class WindowsRuntimeBufferExtensions {
public static Stream AsStream(this IBuffer source); public static Byte[] ToArray(this IBuffer source);
public static Byte[] ToArray(this IBuffer source, UInt32 sourcelndex,
Int32 count);
// He показано: метод СоруТо для передачи байтов между IBuffer и Byte[]
//Не показано: методы GetByte, IsSameData
}
}
Метод AsStneam создает объект, производный от Stream, который служит оберткой для исходного объекта IBuffer. При наличии такого объекта можно обращаться к данным IBuffer, вызывая Read, Write и прочие подобные методы Stream. Метод ТоАггау во внутренней реализации создает Byte [ ], после чего копирует все байты из исходного объекта IBuffer в Byte[ ]; учтите, что этот метод расширения может дорого обходиться в отношении затрат памяти и процессорного времени.
Класс WindowsRuntimeBufferExtensions также содержит несколько перегруженных версий метода СоруТо, позволяющих копировать байты между IBuffer и Byte [ ]; метод GetByte, читающий данные из IBuffer по одному байту; и метод IsSameData, сравнивающий содержимое двух объектов IBuffer. Вряд ли эти методы будут часто использоваться в ваших приложениях.
Также стоит упомянуть о том, что .NET Framework определяет класс System. Runtime. InteropServices. WindowsRuntimeBuffег для создания объекта IBuffer, байты которого находятся в управляемой куче. Также существует компонент WinRT Windows.Storage.Streams.Buffer для создания объекта IBuffer, байты которого находятся в системной куче. Скорее всего, большинству разработчиков .NET Framework не придется использовать эти классы в своем коде.
Определение компонентов WinRT в коде C#
До настоящего момента рассматривались возможности использования компонентов WinRT в С#. Однако вы также можете определять компоненты WinRT в коде C# для последующего использования в C/C++, C#/Visual Basic, JavaScript и в других языках. И хотя это возможно, стоит разобраться в том, в каких ситуациях такое решение оправдано. 11апример, бессмысленно определять компонент WinRT на С#, если он будет использоваться только в других управляемых языках, работающих поверх CLR. Дело в том, что система типов WinRT обладает существенно меньшей функциональностью, ограничивающей ее возможности по сравнению с системой типов CLR.
Я также считаю, что было бы неразумно реализовать в коде C# компонент WinRT, предназначенный для использования в неуправляемом коде C/C++. Скорее всего, разработчики, пишущие свои приложения на C/C++, сильно озабочены производительностью и/или затратами памяти в своих приложениях. Вряд ли они захотят использовать компонент WinRT, реализованный на управляемом коде, потому что это потребует загрузки CLR в процесс, а следовательно, приведет к увеличению затрат памяти и снижению производительности из-за уборки мусора и JIT-компиляции кода. По этой причине многие компоненты WinRT (включая компоненты, входящие в поставку Windows) реализованы в неуправляемом коде. Конечно, в неуправляемых приложениях C++ могут быть части, не столь критичные по производительности, и в некоторых ситуациях привлечение функциональности .NET Framework для повышения продуктивности разработки оправдано. Например, сервис Bing Maps использует неуправляемый код C++ для прорисовки своего пользовательского интерфейса с применением DirectX, а в реализации бизнес-логики также применяется С#.
Итак, по моему мнению, компоненты WinRT, реализованные на С#, лучше всего подходят для разработчиков приложений Windows Store, которые строят пользовательский интерфейс средствами HTML и CSS, а затем используют JavaScript для связывания интерфейса с бизнес-логикой, реализованной в виде компонента WinRT. Другой возможный сценарий — использование функциональности существующих компонентов FCL из приложений HTML/JavaScript.
Разработчики, работающие с HTML и JavaScript, уже готовы к затратам памяти и снижению производительности, обусловленными использованием браузерного ядра. Скорее всего, дополнительные затраты памяти и снижение производительности из-за использования CLR для них не будут критичными.
Построение компонента WinRT на C# начинается с создания в Microsoft Visual Studio проекта типа Windows Runtime Component. При этом создается вполне обычный проект библиотеки классов, однако компилятор C# будет запущен с параметром командной строки /t:winmdobj для создания файла с расширением .winmdobj. При наличии этого параметра компилятор также генерирует часть IL-кода иначе, чем при обычном запуске. Например, компоненты WinRT добавляют и удаляют делегатов событий не так, как это делает CLR, поэтому компилятор включает другой код в методы add и remove событий. Позднее в этом разделе я покажу, как явно реализовать методы add и remove событий.
Когда компилятор создаст файл .winmdobj, запускается утилита экспорта WinMD (WinMDExp.exe), которой передаются созданные компилятором файлы .winmdobj, .pdb и .xml (doc). nporpaMMaWinMDExp.exe анализирует метаданные файла и убеждается в том, что типы соответствуют правилам системы типов WinRT (см. начало главы). Она также изменяет метаданные, содержащиеся в файле .winmdobj; IL-код при этом остается неизменным. Говоря конкретнее, все типы CLR отображаются на эквивалентные типы WinRT. Например, ссылка на тип .NET Framework IList<String> заменяется ссылкой на тип WinRT IVector<String>. Результат работы WinMDExp.exe представляет собой файл .winmd, который может использоваться другими языками программирования.
Содержимое файла .winmd можно просмотреть в программе ILDasm.exe. По умолчанию программа ILDasm.exe выводит необработанное содержимое файла, но с па-
раметром командной строки /project она покажет, как будут выглядеть метаданные после проецирования типов WinRT на эквивалентные типы .NET Framework.
В следующем коде продемонстрирована реализация различных компонентов WinRT на С#. В компонентах задействованы многие возможности, упоминавшиеся в этой главе, а многочисленные комментарии объясняют суть происходящеп >. Если вам потребуется написать компонент WinRT на С#, я рекомендую взять приведенный код за образец.
ВНИМАНИЕ
Когда управляемый код использует компонент WinRT, также написанный на управляемом коде, CLR рассматривает компонент WinRT так, как если бы он был обычным управляемым компонентом — то есть CLR не создает CCW и RCW, а следовательно, не вызывает через них функции WinRT API. Это приводит к заметному повышению быстродействия. Однако в процессе тестирования компонента функции API вызываются не так, как они вызывались бы из других языков (скажем, из C/C++ или JavaScript). Таким образом, помимо заниженных затрат памяти и снижения производительности, управляемый код может передать null функции WinRT API, требующей String — и это не приведет к выдаче исключения ArgumentNullException. Кроме того, функции WinRT API, реализованные в управляемом коде, могут изменять переданные массивы, и вызывающая сторона увидит измененное содержимое массива при возврате управления; обычно система типов WinRT запрещает изменение массивов, переданных функция API. На практике вы столкнетесь и с другими различиями, так что будьте внимательны.
Модуль: WinRTComponents.cs
Примечания: Copyright (с) 2012 by Jeffrey Richter
******************************************************************************/
using System;
using System.Collections.Generic; using System.Linq;
using System. Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Foundation;
using Windows.Foundation.Metadata;
// Пространство имен ДОЛЖНО соответствовать имени сборки // и быть отличным от "Windows" namespace Wintellect.WinRTComponents {
// [Flags] //He должно быть для int; обязательно для uint public enum WinRTEnum : int { // Перечисления должны базироваться
None, // на типе int или uint
NotNone
}
// Структуры могут содержать только основные типы данных,
// String и другие структуры.
// Конструкторы и методы запрещены, public struct WinRTStruct { public Int32 ANumber; public String AString;
public WinRTEnum AEnum; // В действительности просто } // 32-разрядное целое
// В сигнатуре делегатов должны содержаться WinRT-совместимые типы
// (без Beginlnvoke/Endlnvoke)
public delegate String WinRTDelegate(Int32 x);
// Интерфейсы могут содержать методы, свойства и события,
// но не могут быть обобщенными, public interface IWinRTInterface {
// Nullable<T> продвигается как IReference<T>
Int32? InterfaceProperty { get; set; }
}
// Члены без атрибута [Version(#)] по умолчанию используют версию // класса (1) и являются частью одного нижележащего интерфейса СОМ,
// создаваемого программой WinMDExp.exe.
[Version(l)]
// Классы должны быть производными от Object, запечатанными,
//не обобщенными, должны реализовать только интерфейсы WinRT,
//а открытые члены должны быть типами WinRT public sealed class WinRTClass : IWinRTInterface {
// Открытые поля запрещены
#region Класс может предоставлять статические методы, свойства и события public static String StaticMethod(String s) { return "Returning " + s; } public static WinRTStruct StaticProperty { get; set; }
// В ^JavaScript параметры 'out' возвращаются в виде объектов;
// каждый параметр становится свойством наряду с возвращаемым значением public static String OutParameters(out WinRTStruct x, out Int32 year) { x = new WinRTStruct { AEnum = WinRTEnum.NotNone, ANumber = 333, AString = "leff" }; year = DateTimeOffset.Now.Year; return "Grant";
}
#endregion
// Конструктор может получать аргументы, кроме out/ref
public WinRTClass(Int32? number) { InterfaceProperty = number; }
public Int32? InterfaceProperty { get; set; }
// Переопределяться может только метод ToString public override String ToStringQ {
return String.Format("InterfaceProperty={0}",
InterfaceProperty.HasValue ? InterfaceProperty.Value.ToStringQ : "(not set)");
}
public void ThrowingMethodQ {
throw new InvalidOperationException("My exception message");
// Чтобы выдать исключение с конкретным кодом НRESULT,
// используйте COMException
//const Int32 COR_E_INVALIDOPERATION = unchecked((Int32)0x80131509);
//throw new COMException("Invalid Operation", COREINVALIDOPERATION);
}
#region Массивы передаются, возвращаются ИЛИ заполняются; без комбинаций public Int32 PassArray([ReadOnlyArray] /* Подразумевается [In] */
Int32[] data) { return data.SumQ;
}
public Int32 FillArray([WriteOnlyArray] /* Подразумевается [Out] */
Int32[] data) {
for (Int32 n = 0; n < data.Length; n++) data[n] = n; return data.Length;
}
public Int32[] ReturnArray() { return new Int32[] { 1, 2, 3 };
}
#endregion
// Коллекции передаются по ссылке
public void PassAndModifyCollection(IDictionary<String, Object» collection) { collection["Key2"] = "Value2n; // Коллекция изменяется "на месте"
}
#region Перегрузка методов
// Перегруженные версии с одинаковым количеством параметров // 3avaScript считает идентичными public void SomeMethod(Int32 х) { }
[Windows.Foundation.Metadata.DefaultOverload] // Метод назначается public void SomeMethod(String s) { } // перегрузкой по умолчанию
#endregion
#region Автоматическая реализация события public event WinRTDelegate AutoEvent;
public String RaiseAutoEvent(Int32 number) {
WinRTDelegate d = AutoEvent;
return (d == null) ? "No callbacks registered" : d(number);
}
#endregion
#region Ручная реализация события
// Закрытое поле для отслеживания зарегистрированных делегатов события private EventRegistrationTokenTable<WinRTDelegate> mmanualEvent = null;
// Ручная реализация методов add и remove public event WinRTDelegate ManualEvent { add {
// Получение существующей таблицы (или создание новой,
// если таблица еще не инициализирована) return EventRegistгationTokenTable<WinRTDelegate>
.GetOrCreateEventRegistrationTokenTable(ref mmanualEvent)
.AddEventHandler(value);
}
remove {
EventRegistrationTokenTablecWinRTDelegate)
.GetOrCreateEventRegistrationTokenTable(ref m_manualEvent)
.RemoveEventHandler(value);
}
}
public String RaiseManualEvent(Int32 number) {
WinRTDelegate d = EventRegistrationTokenTable<WinRTDelegate>
.GetOrCreateEventRegistrationTokenTable(ref m_manualEvent).InvocationList; return (d == null) ? "No callbacks registered" : d(number);
}
#endregion
#region Asynchronous methods // Асинхронные методы ДОЛЖНЫ возвращать // IAsync[Action|0peration](WithProgress)
// ПРИМЕЧАНИЕ: другие языки видят DataTimeOffset как // Windows.Foundation.DateTime
public IAsyncOperationWithProgresscDateTimeOffset, Int32> DoSomethingAsync() {
// Используйте методы Run класса
// System.Runtime.InteropServices.WindowsRuntime.Asynclnfо // для вызова закрытого метода, написанного исключительно // на управляемом коде.
return Asynclnfo.RuncDateTimeOffset, Int32>(DoSomethingAsyncInternal)
}
// Реализация асинхронной операции на базе закрытого метода // с использованием обычных технологий .NET private async Task<DateTimeOffset> DoSomethingAsyncInternal( CancellationToken ct, IProgress<Int32> progress) {
for (Int32 x = 0; x < 10; x++) {
// Поддержка отмены и оповещений о ходе выполнения
ct.ThrowlfCancellationRequested();
if (progress != null) progress.Report(x * 10);
![]() |
await Task.Delay(1000); // Имитация асинхронных операций
return DateTimeOffset.Now; // Итоговое возвращаемое значение
}
public IAsyncOperationcDateTimeOffset> DoSomethingAsync2() {
11 Если отмена и оповещения не нужны, используйте // методы расширения AsAsync[Action|Operation]
// класса System.WindowsRuntimeSystemExtensions // (они вызывают AsyncInfo.Run в своей внутренней реализации) return DoSomethlngAsyncInternal(default(CancellationToken), null). AsAsyncOperationQ;
}
#endregion
11 После распространения версии новые члены следует помечать // атрибутом [Version(#)], чтобы программа WinMDExp.exe // помещала новые члены в другой интерфейс СОМ. Это необходимо,
// поскольку интерфейсы СОМ должны быть неизменными.
[Version(2)]
public void NewMethodAddedInV2() {}
}
}
Следующий код JavaScript демонстрирует обращения ко всем представленным
компонентам и функциям WinRT.
function () {
// Для удобства обращения к пространству имен в коде var WinRTComps = Wintellect.WinRTComponents;
// Обращение к статическому методу и свойству типа WinRT
var s = WinRTComps.WinRTClass.staticMethod(null); // JavaScript передает "null"! var struct = { anumber: 123, astring: "Jeff", aenum:
WinRTComps.WinRTEnum.notNone };
WinRTComps.WinRTClass.staticProperty = struct; s = WinRTComps.WinRTClass.staticProperty; // Обратное чтение
// Если метод имеет выходные параметры, они и возвращаемое значение
// передаются в виде свойств объекта
var s = WinRTComps.WinRTClass.outParameters();
var name = s.value; // Возвращаемое значение
var struct = s.x; // Параметр 'out'
var year = s.year; // Другой параметр 'out'
// Создание экземпляра компонента WinRT
var WinRTClass = new WinRTComps.WinRTClass(null);
s = WinRTClass .toString(); // Вызов ToStringQ
// Выдача и перехват исключений try { WinRTClass.throwingMethod(); } catch (err) { }
// Передача массива var a = [1, 2, 3, 4, 5];
var sum = winRTClass.passArray(a);
// Заполнение массива
var arrayOut = [7, 1, 7]; // ПРИМЕЧАНИЕ: fillArray видит нули!
var length = winRTClass.fillArray(arrayOut); 11 При возвращении
// arrayOut = [0, 1, 2]
// Возвращение массива
a = winRTClass. returnArrayQ; // a = [ 1, 2, 3]
// Передача коллекции с изменением элементов
var localSettings = Windows.Storage.ApplicationData.current.localSettlngs;
localSettlngs.values["Keyl"] = "Valuel";
winRTClass.passAndModifyCollectIon(localSettlngs.values);
II При возвращении localSettlngs.values содержит 2 пары "ключ/значение"
// Вызов перегруженного метода
winRTClass.someMethod(5); // Вызывает SomeMethod(String), передавая
// Использование события с автоматической реализацией var f = function (v) { return v.target; }; winRTClass.addEventListener("autoevent", f, false); s = winRTClass.ralseAutoEvent(7);
// Использование события с ручной реализацией winRTClass.addEventListener("manualevent", f, false); s = winRTClass.ralseManualEvent(8);
// Вызов асинхронного метода с поддержкой оповещений о ходе выполнения,
// отмены и обработки ошибок
var promise = winRTClass.doSomethingAsyncQ;
promise.then(
function (result) { console.log("Async op complete: " + result); }, function (error) { console.log("Async op error: " + error); }, function (progress) {
console.log("Async op progress: " + progress);
//if (progress == 30) promise.cancel(); // Проверка отмены
![]() |
»;
ЧАСТЬ V
Многопоточность
Глава 26. Потоки исполнения.................................................. 724
Глава 27. асинхронные вычислительные
операции.................................................................................. 747
Глава 28. асинхронные операции ввода-вывода... .787
Глава 29. Примитивные конструкции синхронизации потоков 820
Глава 30. Гибридные конструкции
синхронизации потоков.......................................................... 854
Глава 26. Потоки исполнения
В этой главе вы познакомитесь с потоками исполнения, или просто потоки (threads)1. Мы поговорим о том, почему в Microsoft Windows появилась концепция потоков, о тенденциях развития процессоров, о взаимоотношениях потоков общеязыковой исполняющей среды (CLR-потоков) и Windows-потоков, о дополнительных затратах ресурсов при использовании потоков, о планировании исполнения потоков в Windows, о классах .NET Framework, предоставляющих доступ к свойствам потоков, и о многом другом.
В главах пятой части книги объясняется, каким образом Windows взаимодействует с CLR для формирования архитектуры потоков. Надеюсь, после прочтения этих глав вы получите достаточно информации для эффективного применения потоков и создания надежных, расширяемых приложений и компонентов, оперативно реагирующих на действия пользователя.
Для чего Windows поддерживает потоки?
На заре компьютерной эры операционные системы не поддерживали концепцию потоков. Точнее, существовал всего один поток исполнения, обслуживающий как код операционной системы, так и код приложений. В результате задание, выполнение которого требовало времени, препятствовало выполнению других заданий. К примеру, во времена 16-разрядной системы Windows обычной была ситуация, когда распечатывающее документ приложение приостанавливало работу всей машины. Операционная система и остальные приложения просто «зависали». А если вдруг в приложении возникала ошибка, которая приводила к бесконечному циклу, она вообще порождала массу проблем.
Пользователю оставалось только перезагрузить компьютер, нажав кнопку Reset или выключатель питания. Разумеется, пользователи ненавидели такие ситуации (и продолжают ненавидеть до сих пор), потому что все запущенные приложения при этом аварийно завершались, а обрабатываемые данные стирались из памяти. В Microsoft понимали, что 1 б-разрядная платформа Windows недостаточно хороша, чтобы удержать компанию на плаву в ходе дальнейшего развития компьютерной индустрии, поэтому было решено создать новую операционную систему, удовлетворяющую потребности как корпораций, так и отдельных пользователей. Она должна была быть устойчивой, надежной, расширяемой, безопасной и избавленной
Не путать с потоками ввода-вывода (streams). — Примеч. ред.
от большинства недостатков своей предшественницы. Ядро этой операционной системы впервые было выпущено в составе Windows NT. С годами это ядро претерпело множество обновлений и приобрело дополнительные возможности. Последняя версия ядра поставляется с последними версиями операционных систем Windows для клиента и сервера.
При разработке нового ядра операционной системы было решено запускать каждый экземпляр приложения в отдельном процессе (process). Процессом называется набор ресурсов, используемый отдельным экземпляром приложения. Каждому процессу выделяется виртуальное адресное пространство; это гарантирует, что код и данные одного процесса будут недоступны для другого. Это делает приложения отказоустойчивыми, поскольку при таком подходе один процесс не может повредить код или данные другого. Код и данные ядра также недоступны для процессов; а значит, код приложений не в состоянии повредить код или данные операционной системы. Это упрощает работу конечных пользователей. Система становится также более безопасной, потому что код произвольного приложения не имеет доступа к именам пользователей, паролям, информации кредитной карты или иным конфиденциальным данным, с которыми работают другие приложения или сама операционная система.
А что с центральным процессором? Что если приложение войдет в бесконечный цикл? Если процессор всего один, приложение будет выполнять этот бесконечный цикл и не сможет уделять внимание другим операциям. Несмотря на очевидные преимущества (неповрежденные данные и более высокая степень безопасности), система, как и ее предшественницы, не сможет реагировать на действия конечного пользователя. Для решения этой проблемы и были придуманы потоки. Именно поток стал той концепцией, которая предназначена для виртуализации процессора в Windows. Каждому Windows-процессу выделяется собственный поток исполнения (который работает как виртуальный процессор). Если код приложения войдет в бесконечный цикл, то блокируется только связанный с этим кодом процесс, а остальные процессы (исполняющиеся в собственных потоках) продолжают функционировать!
Ресурсоемкость потоков
Потоки — замечательное изобретение; ведь именно благодаря им Windows реагирует на наши действия даже несмотря на то, что отдельные приложения могут быть заняты исполнением длительных заданий. Кроме того, с помощью одного приложения (например, диспетчера задач) можно принудительно прекратить работу другого приложения, если оно перестает отвечать на запросы. Однако как и любые механизмы виртуализации, потоки потребляют дополнительные ресурсы, требуя пространства (памяти) и времени (снижая производительность среды исполнения).
Рассмотрим эти проблемы более подробно. Каждый поток состоит из нескольких частей.
□ Объект ядра потока (thread kernel object). Для каждого созданного в ней потока операционная система выделяет и инициализирует одну из структур данных. Набор свойств этой структуры (о них мы поговорим чуть позже) описывает поток. Структура содержит также так называемый контекст потока, то есть блок памяти с набором регистров процессора. На машине с процессором х86, х64 и ARM контекст потока занимает около 700, 1240 и 350 байт соответственно.
□ Блок окружения потока (Thread Environment Block, ТЕВ). Это место в памяти, выделенное и инициализированное в пользовательском режиме (адресное пространство, к которому имеет быстрый доступ код приложений). Этот блок занимает одну страницу памяти (4 Кбайт для процессоров х86, х64 и ARM). Он содержит заголовок цепочки обработки исключений. Каждый блок try, в который входит поток, вставляет свой узел в начало цепочки. Когда поток выходит из блока try, узел из цепочки удаляется. Также ТЕВ содержит локальное хранилище данных для потока и некоторые структуры данных, используемые интерфейсом графических устройств (GDI) и графикой OpenGL.
□ Стек пользовательского режима (user-mode stack). Применяется для хранения
передаваемых в методы локальных переменных и аргументов. Также он содержит адрес, показывающий, откуда начнет исполнение поток после того, как текущий метод возвратит управление. По умолчанию на каждый стек пользовательского режима Windows выделяет 1 Мбайт памяти (а точнее, резервирует 1 Мбайт памяти и добавляет физическую память по мере необходимости при росте стека).
□ Стек режима ядра (kernel-mode stack). Используется, когда код приложения передает аргументы в функцию операционной системы, находящуюся в режиме ядра. Для безопасности Windows копирует все аргументы, передаваемые в ядро кодом в пользовательском режиме, из стека потока пользовательского режима в стек режима ядра. После копирования ядро проверяет значения аргументов. Так как код приложения не имеет доступа к стеку режима ядра, приложение не в состоянии изменить уже проверенные аргументы, и с ними начинает работать код ядра операционной системы. Кроме того, ядро вызывает собственные методы и использует стек режима ядра для передачи локальных аргументов, а также для сохранения локальных переменных функции и обратного адреса. В 32-разрядной версии Windows стек режима ядра занимает 12 Кбайт, а в 64-раз- рядной — 24 Кбайт.
□ Уведомления о создании и завершении потоков. Политика Windows такова, что если в процессе создается поток, то для всех загруженных в этот процесс DLL-библиотек вызывается метод DllMain и в него передается флаг DLL_THREAD_ ATTACH. Соответственно, при завершении потока этому методу передается уже флаг DLL_THREAD_DETACH. Получая уведомления об этих событиях, некоторые DLL-библиотеки выполняют специальные операции инициализации или очистки для каждого созданного/завершенного в процессе потока. К примеру, DLL-библиотека C-Runtime выделяет место под хранилище локальных состояний потока, необходимое для использования потоком функций из указанной библиотеки.
На заре развития Windows в процесс загружалось 5 или 6 библиотек, в то время как в наши дни некоторые процессы включают в себя несколько сотен библиотек. Скажем, в адресное пространство приложения Microsoft Visual Studio на моем компьютере загружено около 470 DLL-библиотек! Это означает, что созданный в данном приложении новый поток получит возможность приступить к своей работе только после вызова 470 функций из DLL. По завершении потока в Visual Studio все эти функции будут вызваны снова. Разумеется, это не может не влиять на производительность создания и завершения потоков в процессе[41].
Теперь вы видите, каких затрат времени и памяти стоит создание потока, его поддержание в системе и завершение. Но на самом деле ситуация еще хуже из-за необходимости переключения контекста (context switching). Компьютер с одним процессором может одновременно выполнять только что-то одно. Следовательно, операционная система должна распределять физический процессор между всеми своими потоками (логическими процессорами).
В произвольный момент времени Windows передает процессору на исполнение один поток. Этот поток исполняется в течение некоторого временного интервала, иногда называемого тактом (quantum). После завершения этого интервала контекст Windows переключается на другой поток. При этом обязательно происходит следующее:
1. Значения регистров процессора исполняющегося в данный момент потока сохраняются в структуре контекста, которая располагается в ядре потока.
2. Из набора имеющихся потоков выделяется тот, которому будет передано управление. Если выбранный поток принадлежит другому процессу, Windows переключает для процессора виртуальное адресное пространство. Только после этого возможно выполнение какого-либо кода или доступ к каким-либо данным.
3. Значения из выбранной структуры контекста потока загружаются в регистры
процессора.
После переключения контекста процессор исполняет выбранный поток, пока не истечет выделенное потоку время, после этого снова происходит переключение контекста. Windows делает это примерно каждые 30 мс. Никакого выигрыша в производительности или потреблении памяти переключение контекстов не дает. Оно
требуется только для того, чтобы операционная система была надежной и быстро реагировала на действия конечных пользователей.
Если поток какого-то приложения зацикливается, Windows его периодически выгружает и передает процессору другой поток для исполнения. К примеру, это может быть поток диспетчера задач, позволяющего завершить процесс, в котором исполняется зависший в бесконечном цикле поток. Процесс в результате прекращает свою работу, теряя несохраненные данные, но остальные процессы в системе продолжают функционировать, как ни в чем не бывало. Пользователю не приходится перезагружать компьютер. Как видите, переключение контекстов приводит к повышению отказоустойчивости, хотя за это приходится платить снижением производительности.
На самом деле издержки еще выше, чем можно предположить. При переключении контекста на другой поток производительность серьезно падает. Пока работа ведется с одним потоком, его код и данные находятся в кэше процессора, чтобы обращения процессора к оперативной памяти, замедляющие работу, происходили реже. Однако новый поток, скорее всего, исполняет совсем другой код и имеет доступ к другим данным, которых еще нет в кэше процессора. Значит, прежде чем вернуться к прежней скорости работы, процессор вынужден некоторое время обращаться к оперативной памяти, наполняя кэш. А примерно через 30 мс происходит очередное переключение контекста.
Время переключения контекста зависит от архитектуры процессора и его быстродействия. А время заполнения кэша зависит от запущенных в системе приложений, размера самого кэша и ряда других факторов. Поэтому оценить, с какими временными затратами связано каждое переключение контекста, невозможно. Достаточно просто запомнить, что при разработке высокопроизводительных приложений и компонентов переключения контекста нужно по возможности избегать.
ВНИМАНИЕ
Если в конце временного промежутка Windows решает продолжить исполнение уже исполняемого потока (а не переходить кдругому), переключения контекста не происходит. Это значительно повышает производительность.
ВНИМАНИЕ
Поток может самопроизвольно уступить управление до завершения такта, что происходит довольно часто, например, если поток ожидает ввода-вывода (клавиатура, мышь, файл, сеть и т. д.). Так, поток приложения Notepad обычно ничего не делает, ожидая ввода данных. При нажатии пользователем клавиши Windows пробуждает этот поток, чтобы тот обработал данное действие. Обработка занимает всего 5 мс, после чего вызывается Win32-0yHKpnfl, сообщающая Windows о готовности к обработке следующего события ввода. Если события ввода отсутствуют, поток переводится в состояние ожидания (с отказом от оставшейся части такта). В результате поток не будет планироваться на исполнение процессором до следующего события ввода. Такой подход повышает производительность системы, потому что потоки, находящиеся в состоянии ожидания, не расходуют попусту процессорное время.
В ходе процедуры уборки мусора CLR приостанавливает все потоки, просматривает их стеки в поисках корней, помечает объекты в куче, снова просматривает стеки (обновляя корни объектов, перемещенных в процессе сжатия) и возобновляет исполнение всех потоков. Таким образом, сокращение количества потоков повысит производительность уборки мусора. В процессе отладки Windows приостанавливает все потоки приложения в каждой точке останова и снова запускает их при переходе к следующему шагу или при запуске приложения. Соответственно, чем больше потоков, тем медленнее будет происходить отладка.
Из сказанного можно сделать заключение, что использования потоков нужно по возможности избегать, так как они потребляют память и требуют времени для своего создания, управления и завершения. При переключении контекста и уборке мусора время также тратится впустую. С другой стороны, без потоков тоже не обойтись, так как именно они обеспечивают в Windows приемлемые показатели надежности и времени реакции.
Не стоит забывать и о том, что компьютер с несколькими процессорами может исполнять несколько потоков одновременно, что улучшает масштабируемость системы (способность выполнения большей работы за меньшее время). Каждому ядру процессора назначается свой поток, и это ядро организует собственное переключение контекстов. Операционная система следит за тем, чтобы один поток не планировался одновременно на нескольких ядрах, так как это привело бы к хаосу. В настоящее время повсеместно встречаются компьютеры с несколькими процессорами или многоядерными процессорами. Но на заре создания Windows работать приходилось на машинах с одним процессором, и именно поэтому для повышения надежности операционной системы были введены потоки. В настоящее время потоки позволяют повысить производительность на машинах с несколькими ядрами.
В оставшихся главах этой книги рассматриваются механизмы Windows и CLR, позволяющие эффективно ответить на вопрос, как при минимальном количестве потоков сохранить работоспособность кода и каким образом масштабировать код для исполнения на машине с многоядерным процессором.
Так дальше не пойдет!
Если думать только о производительности, оптимальное число потоков на машине должно быть равно числу установленных на ней процессоров. То есть на компьютере с одним процессором должен работать всего один поток, на компьютере с двумя процессорами — два и т. д. Причины очевидны: если количество потоков превышает количество процессоров, начинается переключение контекста, и производительность падает. А при наличии одного потока на процессор контекстное переключение не требуется, и все потоки исполняются на полной скорости.
Тем не менее при разработке Windows специалисты Microsoft отдали предпочтение надежности и быстроте реакции на действия пользователя, а не скорости расчетов и производительности выполнения приложений. И с моей точки зрения это правильно. Не думаю, что кто-либо пользовался бы Windows или .NET Framework, если бы приложения по-прежнему могли блокировать работу операционной системы и других приложений. Именно поэтому в Windows каждому процессу выделяется собственный поток, что повышает надежность системы и быстроту реакции. К примеру. на рис. 26.1 показано окно диспетчера задач, открытое на вкладке Performance (Быстродействие).
Рис. 26.1. Диспетчер задач с информацией о производительности системы |
Как видите, на момент получения снимка экрана на моем компьютере было запущено .65 процессов, а значит, по меньшей мере 55 потоков. Ведь для каждого процесса существует хотя бы один поток. Однако как легко увидеть, потоков на самом деле 864! Все эти потоки выделяют память, и это при том, что ее полный <)бъем на моем компьютере составляет 4 Гбайт. Что же касается соотношения количества процессов и потоков, то в среднем на один процесс приходится 15,7 потока, тогда как в идеале на моем двухъядерном процессоре процесс должен состоять всего из двух потоков.
Более того, расположенная в левом верхнем углу диаграмма загрузки процессе >ра показывает, что в настоящий момент загрузка составляет всего 5 %. То есть 95 времени эти 864 потока в буквальном смысле ничего не делают — они просто занимают память, которая не используется, пока потоки нс начинают исполняться. Резонно спросить, нужны ли приложениям все эти ничего не делающие потоки? Разумеется, нет. Чтобы посмотреть, какой из процессов является самым «расто- читальным», перейдите на вкладку Details (Подробнее), добавьте столбец Threads (Счетчик потоков)[42] и отсортируйте его по убыванию, как показано на рис. 26.2.
Рис. 26.2. Процессы в окне диспетчера задач |
Как видите, система создала 105 потоков, но использует I % мощности процессора, приложение Explorer создало 47 потоков при 0 % нагрузке на процессор, приложение Microsoft Visual Studio (Devenv.exe) создало 36 потоков, опять же используя 0 % мощности процессора, та же самая картина с приложением ()utlook, создавшим 24 потока при 0 % и т. и. Что же происходит?
Знакомясь с Windows, разработчики узнают, что создание процессов в этой операционной системе — дорогостоящая процедура. Создание процесса занимает несколько секунд, требует выделения изрядного объема памяти, эту память требуется инициализировать, нужно загрузить с диска EXE- и DLL-файлы и т. п. По сравнению с этим с<>здать шmж д<гстаточно просто. Поэтому разрабс)тчики вместо процессов предпочитают создавать потоки, множество которых мы и видим перед собой. По в сравнении с большинством других системных ресурсов создание потока — не такая уж дешевая процедура. И применять их следует осмотрительно и только там, где они действительно уместны.
Как видите, упомянутые приложения используют потоки не эффективно. Непонятно, зачем эти потоки вообще существуют в системе. Одно дело — выделить ресурсы для приложения, и совсем другое — выделить их и не использовать. Ведь выделение памяти под стеки потоков означает, что ее останется меньше для более важных данных, например документов пользователя[43].
А теперь представьте, что процесс запущен в сеансе удаленного рабочего стола одного пользователя, но у машины на самом деле 100 пользователей. То есть запускается 100 экземпляров приложения Outlook, каждый из которых создает 24 ничего не делающих потока. Мы получаем 2400 потоков, каждый с собственными ядром, ТЕВ, стеком режима пользователя, стеком режима ядра и т. п. Огромное количество впустую потраченных ресурсов. Эту практику пора прекращать, особенно если Microsoft хочет, чтобы пользователи успешно работали с Windows на нетбуках, на большинстве которых всего 1 Гбит оперативной памяти. Именно практике эффективного проектирования приложений с минимальным количеством потоков и посвящены последние главы этой книги.
Тенденции развития процессоров
В прошлом имел место постоянный рост быстродействия процессоров, в результате даже медленно работающие приложения при переходе на более новую машину начинали работать быстрее. Однако бесконечно наращивать быстродействие невозможно. Кроме того, процессор, работающий с большой скоростью, выделяет тепло, которое нужно рассеивать. Несколько лет назад я приобрел компьютер новой модели от уважаемого производителя. Однако из-за дефекта прошивки скорость вращения вентилятора оказалась недостаточной; и через некоторое время процессор и материнская плата просто расплавились. Производитель заменил мне компьютер и «улучшил» прошивку, просто заставив вентилятор вращаться быстрее. Но из-за этого стали намного быстрее садиться батарейки, ведь вентилятор потреблял много энергии.
С подобными проблемами в наши дни приходится сталкиваться всем производителям аппаратного обеспечения. Из-за отсутствия возможности бесконечно наращивать скорость процессоров, они пытаются уменьшить транзисторы, чтобы на одной микросхеме можно было разместить их больше. Уже существуют кремниевые микросхемы, содержащие два и более процессорных ядра. А значит, скорость функционирования программного обеспечения повысится только при условии,
что оно умеет работать с несколькими ядрами. Как этого добиться? Разумно используя потоки.
В настоящее время существуют три вида многопроцессорных технологий:
□ Многопроцессорные решения. Некоторые компьютеры просто оснащают несколькими процессорами. Другими словами, на материнской плате находятся несколько гнезд, в каждом из которых располагается процессор. Это приводит к увеличению размеров материнской платы, а значит, и корпуса. В некоторых случаях приходится ставить также дополнительные источники питания из-за повышенного потребления энергии. Такие компьютеры использовались в течение нескольких десятилетий, но постепенно их популярность сходит на нет из-за большого размера и высокой стоимости.
□ Гиперпотоковые микросхемы. Эта технология (от Intel) позволяет одной микросхеме функционировать как две. Микросхема содержит два набора архитектурных состояний, таких как регистры процессора, при этом имеется всего один набор механизмов исполнения. Для Windows это выглядит как наличие в машине двух процессоров, и операционная система одновременно планирует поведение двух потоков, однако исполняется только один из них. Как только он прерывается из-за недостатка размера кэша, ошибочного прогнозирования ветви или зависимости по данным, микросхема переключается на другой поток. Все это происходит на аппаратном уровне, и Windows об этом не «знает». С точки зрения операционной системы оба потока выполняются одновременно. При наличии на одной машине нескольких гиперпотоковых процессоров операционная система сначала назначит по одному потоку на каждый процессор, в результате чего они действительно будут выполняться одновременно. Все же остальные потоки будут распределяться по уже занятым процессорам. По утверждениям Intel, такой подход повышает производительность на 10-30 %.
□ Многоядерные микросхемы. Несколько лет назад появились микросхемы, содержащие более одного процессорного ядра. На момент написания этой книги были доступны микросхемы с двумя, тремя и четырьмя ядрами. Два ядра имеет даже процессор моего ноутбука. Я уверен, что эта технология скоро распространится даже на мобильные телефоны. Компания Intel работала даже над прототипом процессора с 80 ядрами. Представляете, насколько мощным является такой компьютер! Кроме того, в Intel имеются гиперпотоковые микросхемы с несколькими ядрами.
CLR- и Windows-потоки
В настоящее время CLR использует способность Windows работать с потоками, поэтому часть V данной книги посвящена рассмотрению возможностей, которые открываются перед разработчиками, создающими код с помощью CLR. Мы поговорим о том, как исполняются потоки в Windows и как на их поведение влияет CLR. Для получения дополнительной информации о потоках исполнения рекомендую мои предыдущие книги, в частности пятое издание Windows via C/C++ (Microsoft Press, 2007).
На первых порах существования .NET Framework проектировщики CLR решили, что среда CLR должна поддерживать логические потоки, которые не обязаны однозначно соответствовать потокам Windows. В 2005 году группа CLR отказалась от этой идеи, так что в настоящее время CLR-потоки аналогичны Windows-потокам, однако в .NET Framework встречаются отдельные пережитки прежних попыток. Например, класс System. Environment предоставляет свойство CurrentManagedThreadld, которое возвращает CLR-идентификатор потока, тогда как класс System. Diagnostics. ProcessThread предоставляет свойство Id для получения Windows-идентификатора того же потока. Методы BeginThreadAf f inity и EndThreadAff inity класса System. Thread также были введены в предположении о том, что CLR-поток может не совпадать с Windows-потоком.
Для приложений Windows Store компания Microsoft исключила некоторые функции API, относящиеся к потокам, потому что эти функции приводили к нежелательным последствиям (см. раздел «Так дальше не пойдет!» этой главы) или не способствовали достижению целей, поставленных Microsoft для приложений Windows Apps. Например, класс System. Thread недоступен для приложений Windows Store из-за нежелательных функций API (таких, как Start, IsBackground, Sleep, Suspend, Resume, loin, Interrupt, Abort, BeginThreadAff inity и EndThreadAffinity). Лично я считаю, что это сделано правильно, хотя и позже, чем следовало бы. Соответственно в главах 26-30 будут рассматриваться некоторые функции APIs и возможности, доступные для настольных приложений, но не для приложений Windows Store. Во время чтения этих глав вы быстро поймете, почему некоторые функции API недоступны для приложений Windows Store.
Потоки для асинхронных вычислительных операций
В этом разделе я покажу, как создать поток и заставить его исполнить асинхронную вычислительную операцию. При этом я не рекомендую пользоваться приемами, описываемыми в этом разделе (а для приложений Windows Store они и вовсе невозможны из-за недоступности класса System. Thread). По возможности для этой цели лучше прибегать к доступному в CLR пулу потоков (thread pool). О нем мы поговорим в следующей главе.
Возможны ситуации, когда требуется явно создать поток для выполнения конкретной вычислительной операции. Обычно такая необходимость возникает при выполнении кода, приводящего поток в состояние, отличное от обычного состояния потока из пула. К примеру:
□ Поток требуется запустить с нестандартным приоритетом (все потоки пула выполняются с обычным приоритетом). Хотя изменить приоритет можно, но делать это не рекомендуется, кроме того, изменение приоритета не сохраняется между операциями с пулом потоков.
□ Чтобы приложение не закрылось до завершения потоком задания, требуется, чтобы поток исполнялся в фоновом режиме. Эта тема подробно рассмотрена в разделе «Фоновые и активные потоки» далее в этой главе. Потоки из пула всегда являются фоновыми, и существует риск, что они не успеют выполнить задание из-за того, что CLR решит завершить процесс.
□ Задания, связанные с вычислениями, обычно выполняются крайне долго; для подобных заданий я не стал бы отдавать решение о необходимости создания нового потока на откуп логике пула потоков.
□ Возможно возникнет необходимость преждевременно завершить исполняющийся поток методом Abort класса Thread, который был подробно рассмотрен в главе 22.
Для создания выделенного потока вам потребуется экземпляр класса System. Threading.Thread, для получения которого следует передать конструктору имя метода. Вот прототип такого конструктора:
public sealed class Thread : CriticalFinalizerObject, ... { public Thread(ParameterizedThreadStart start);
// Здесь не показаны редко используемые конструкторы
}
Параметр start задает метод, который будет выполняться в выделенном потоке. Сигнатура этого метода должна совпадать с сигнатурой делегата ParameterizedThreadStart:[44]
delegate void ParameterizedThreadStart(Ob]ect obJ)j
Создание объекта Thread является достаточно простой операцией, так как при этом физический поток в операционной системе не появляется. Для создания физического потока, призванного исполнить метод обратного вызова, следует воспользоваться методом Start класса Thread, передав в него объект (состояние), который вы хотите сделать аргументом метода обратного вызова. Следующий код демонстрирует процедуру создания выделенного потока, который затем асинхронно вызывает метод:
using System;
using System.Threading;
public static class Program { public static void Main() {
Console.WriteLine("Main thread: starting a dedicated thread " +
"to do an asynchronous operation");
Thread dedicatedThread = new Thread(ComputeBoundOp); dedicatedThread.Start(5);
Console.WriteLine("Main thread: Doing other work here...");
Thread.Sleep(10000); // Имитация другой работы (10 секунд)
dedicatedThread.loin(); // Ожидание завершения потока Console.WriteLine("Hit <Enter> to end this program...");
Console.Read Line();
}
// Сигнатура метода должна совпадать
// с сигнатурой делегата ParameterizedThreadStart
private static void ComputeBoundOp(Ob]ect state) {
// Метод, выполняемый выделенным потоком
Console.WriteLine("In ComputeBoundOp: state={0}", state);
Thread.Sleep(1000); // Имитация другой работы (1 секунда)
// После возвращения методом управления выделенный поток завершается
}
}
Результат компиляции и запуска такого кода:
Main thread: starting a dedicated thread to do an asynchronous operation Main thread: Doing other work here...
In ComputeBoundOp: state=5
Так как мы не можем контролировать очередность исполнения потоков в Windows, возможен и другой результат:
Main thread: starting a dedicated thread to do an asynchronous operation
In ComputeBoundOp: state=5
Main thread: Doing other work here...
Заметьте, что метод Main вызывает метод Join. Последний заставляет вызывающий поток остановить выполнение любого кода до момента, пока поток, определенный при помощи dedicatedThread, не завершится сам или не будет завершен.
Причины использования потоков
Потоки используются по двум основным причинам:
□ Улучшение времени отклика (обычно для клиентских приложений с графическим интерфейсом). Windows выделяет каждому приложению отдельный
поток, чтобы зацикливание одного приложения не мешало работе других. Аналогичным образом в клиентских приложениях с графическим интерфейсом можно выделить часть работы в отдельный поток, чтобы интерфейс продолжал реагировать на действия пользователя. Вероятно, в этом случае; количество потоков превысит количество ядер, что обернется лишними затратами системных ресурсов и снижением производительности. С другой стороны, пользовательский интерфейс быстрее реагирует на действия пользователя, улучшая его впечатления от работы с приложением.
□ Производительность (для клиентских и серверных приложений). Так как
система Windows может планировать по одному потоку на процессор, а процессоры могут исполнять потоки одновременно, параллельное выполнение нескольких операций улучшит производительность приложения. Конечно, улучшение достигается только в том случае, если приложение выполняется на машине с несколькими процессорами. Впрочем, такие компьютеры в наше время уже достаточно распространены. Вопросы создания приложений, предназначенных для работы в многопроцессорной конфигурации, рассматриваются в главах 27 и 28.
А теперь я хотел бы поделиться с вами своей теорией. Итак, каждый компьютер снабжен таким мощным инструментом, как процессор. И если вы покупаете компьютер, он должен работать все время. Другими словами, я считаю, что все процессоры в машине должны использоваться на 100 %. Впрочем, тут нужно сделать две оговорки. Во-первых, при питании от аккумулятора 100-процентное использование процессора сократит время работы с машиной. Во-вторых, в некоторых центрах обработки данных предпочитают иметь десять компьютеров с процессорами, работающими на половинной мощности, вместо пяти, процессоры которых загружены на 100 %. Дело в том, что полностью загруженный процессор выделяет тепло, а значит, требует системы охлаждения. Однако питание такой системы может оказаться более затратным делом, чем питание большего количества компьютеров, работающих на меньшей мощности. Впрочем, наличие большого количества компьютеров тоже значительно повышает издержки, ведь каждый из них требует периодического обновления аппаратного и программного обеспечения.
Теперь, если вы согласны с моей теорией, нужно определиться с тем, какие задачи должен решать процессор. Но сначала — небольшое вступление. В прошлом как разработчики, так и конечные пользователи считали, что мощность компьютеров недостаточна. И поэтому код не выполнялся, пока конечный пользователь не давал на это разрешения при помощи таких элементов интерфейса, как пункты меню, кнопки и флажки, тем самым явно показывая, что согласен предоставить приложению необходимые ресурсы процессора.
Сейчас все изменилось. Современные компьютеры достаточно мощны и в ближайшем будущем могут стать еще более мощными. Как я уже упоминал в этой главе, часто в окне Диспетчера задач можно видеть, что процессор занят 5 % времени. Если бы ядер было не два, а четыре, такая ситуация возникала бы еще чаще. Когда появится 80-ядерный процессор, вообще получится, что практически все время компьютер ничего не делает. С точки зрения потребителя получается, что за большие деньги машина выполняет меньше работы!
Именно поэтому производители аппаратного обеспечения с трудом продают пользователям многоядерные компьютеры. Программное обеспечение не может полноценно использовать предоставляемые возможности, а значит, пользователь не получает выгоды от покупки машины с дополнительным процессором. То есть в настоящее время мы имеем избыток компьютерных мощностей, поэтому разработчики могут себе позволить их активное потребление. Раньше даже помыслить было нельзя о том, чтобы приложение занималось дополнительными вычислениями, если не было полной уверенности, что конечному пользователю понадобится результат этих вычислений. Но теперь, при наличии дополнительных мощностей, это стало возможным.
Например, по завершении набора текста в редакторе Visual Studio это приложение автоматически вызывает компилятор и обрабатывает введенный код. Такой подход повышает продуктивность труда разработчиков, так как они сразу видят ошибки вводимого кода и немедленно могут их исправить. Фактически в настоящее время из последовательности редактирование-построение-отладка пропал центральный член, так как построение (компиляция) кода осуществляется непрерывно. Конечные пользователи этого даже не замечают благодаря мощному процессору. Ведь частый запуск компилятора никак не отражается на решении других задач. Я думаю, что в будущих версиях Visual Studio из меню исчезнет пункт Build, так как компиляция станет полностью автоматической. Не только упрощается пользовательский интерфейс, но и само приложение дает «ответы» на нужды конечного пользователя, повышая продуктивность его работы.
С исключением отдельных пунктов меню пользоваться приложением становится проще. Остается меньше вариантов и меньше концепций, которые следует прочитать и запомнить. Именно многоядерная конфигурация позволяет упростить пользование компьютером настолько, что в один прекрасный день с ним сможет работать даже моя бабушка. Для разработчиков удаление элементов пользовательского интерфейса означает меньший объем тестирования и упрощение основы кода. Кроме того, ослабляется острота проблемы локализации интерфейса и сопроводительной документации. Все это дает возможность экономить время и деньги.
Вот еще несколько примеров активного потребления ресурсов процессора: проверка орфографии и грамматики в документах, пересчет электронных таблиц, индексирование файлов на диске для ускорения процедуры поиска и дефрагментация жесткого диска для повышения производительности ввода-вывода.
Мне нравится мир, в котором пользовательские интерфейсы минимизируются и упрощаются, оставляя больше места для визуализации данных, а приложения сами предлагают информацию, помогающую быстро решать насущные задачи. Пришло время творчески использовать программное обеспечение.
Планирование и приоритеты потоков
Операционные системы с вытесняющей многозадачностью должны использовать некий алгоритм, определяющий порядок и продолжительность исполнения потоков. В этом разделе рассмотрен алгоритм, применяемый в Windows. Я уже упоминал о наличии в каждом ядре потока контекстной структуры, отражающей состояние регистров процессора потока во время его исполнения. 11осле каждого такта Windows просматривает все существующие ядра потоков в поисках потоков, которые не находятся в режиме ожидания, выбирает один из них и переключается на его контекст. При этом фиксируется, сколько раз каждый из потоков потребовал переключения контекста. Эту информацию можно увидеть в показанном на рис. 26.3 окне приложения Microsoft Spy++, в котором выводятся свойства всех потоков. Обратите внимание, что выбранный поток запускался 31 768 раз[45].
Итак, каждый поток исполняет код и манипулирует данными в адресном пространстве процесса. Через такт Windows переключает контекст. Переключения контекста продолжаются с момента загрузки операционной системы и до завершения ее работы.
Windows называют многопоточной операционной системой с вытесняющей многозадачностью, потому что каждый поток может быть остановлен в произвольный момент времени и вместо него выбран для исполнения другой. Как вы увидите, этим процессом в какой-то степени можно управлять, но нельзя гарантировать, что поток будет исполняться постоянно без прерывания другими потоками.
Рис. 26.3. Свойства потоков в приложении Spy++ |
ПРИМЕЧАНИЕ
Разработчики часто задают вопрос: каким образом можно гарантированно запустить поток через определенное время после какого-то события? Например, как запустить определенный поток через 1 мс после прохождения данных через последовательный порт? Я отвечаю просто: это невозможно.
Такие вещи возможны в операционных системах реального времени, но Windows к ним не относится. Операционные системы реального времени требуют досконального знания оборудования, на базе которого они работают. То есть вам должны быть известны задержки контроллеров жесткого диска, клавиатуры и других компонентов. Платформа Windows создавалась в Microsoft для работы с самым разным аппаратным обеспечением: различными процессорами, драйверами, сетями и т. п. Именно поэтому она не является операционной системой реального времени. Следует добавить, что из-за CLR управляемый код еще хуже приспособлен для работы в реальном времени. Причин этому много, в том числе динамическая загрузка библиотек, JIT-компиляция кода и уборка мусора, начало выполнения которой невозможно спрогнозировать.
Каждому потоку назначается уровень приоритета с нулевого (самого низкого) до 31 (самого высокого). При выборе потока, который будет передан процессору, сначала рассматриваются потоки с самым высоким приоритетом и ставятся в очередь в цикле. При обнаружении потока с приоритетом 31 он передается процессору. После завершения такта ищется следующий поток с аналогичным приоритетом, чтобы переключить на него контекст.
При наличии в очереди потоков с приоритетом 31 система никогда не передаст процессору поток с меньшим приоритетом. Это условие называется зависанием (starvation), а возникает оно в случае, когда потоки с высоким приоритетом потребляют практически все время процессора и не дают исполняться потокам более низкого приоритета. Зависание намного реже возникает на машинах с многопроцессорной конфигурацией, на которых потоки с приоритетами 31 и 30 могут исполняться одновременно. Система всегда старается загрузить процессор, поэтому он простаивает только при отсутствии готовых к исполнению потоков.
Потоки с высоким приоритетом всегда исполняются перед потоками с низким приоритетом вне зависимости от того, какие задания выполняют последние. Если в системе работает поток с приоритетом 5 и система определяет, что поток с более высоким приоритетом готов к работе, исполнение немедленно приостанавливается (даже если поток находится в середине такта) и процессору передается новый поток.
В процессе загрузки система создает поток обнуления страниц (zero page thread), которому назначается нулевой приоритет. Это единственный поток в системе с таким приоритетом. Его задача состоит в обнулении свободных страниц и исполняется он только при отсутствии других потоков.
Ясное дело, что с точки зрения разработчика сложно придумать рациональное объяснение назначению потокам приоритетов. Почему одному потоку присвоен приоритет 10, а другому — 23? Для решения этого вопроса Windows вводит абстрактную «прослойку» над уровнем приоритетов.
При разработке приложения следует решить, должно ли оно реагировать быстрее или медленнее, чем другие запущенные на этой же машине приложения. В соответствии с этим решением выбирается класс приоритета для процесса. В Windows поддерживаются шесть классов приоритетов: Idle (холостого хода), Below Normal (ниже обычного), Normal (обычный), Above Normal (выше обычного), High (высокий) и Realtime (реального времени). По умолчанию выбирается приоритет Normal, он же является самым распространенным.
Приоритет холостого хода подходит для приложений, которые запускаются в системе, где больше ничего не происходит (это такие приложения, как хранители экрана). Даже не используемый в интерактивном режиме компьютер может быть занят (к примеру, функционируя как файловый сервер) и не должен конкурировать за процессорное время с хранителем экрана. Приложения для сбора статистики, периодически обновляющие некоторое состояние, обычно тоже не должны становиться препятствием для более важных заданий.
Высокий приоритет следует использовать только там, где это действительно необходимо. А приоритета реального времени вообще лучше по возможности избегать. Его выбор может помешать выполнению таких системных заданий, как дисковый ввод-вывод или передача данных по сети. Поток с приоритетом реального времени может помешать обработке данных, вводимых с клавиатуры или при помощи мыши, создавая у пользователя впечатление, что система перестала работать. По большому счету для выбора такого приоритета нужно иметь веские основания, например необходимость с минимальной задержкой отвечать на события аппаратного уровня или выполнять какие-то кратковременные задания.
ПРИМЕЧАНИЕ
Чтобы система работала без сбоев, процесс невозможно запустить с приоритетом реального времени при отсутствии прав на увеличение приоритета выполнения. Эта привилегия по умолчанию имеется только у администраторов и пользователей с расширенными правами.
Выбрав класс приоритета, не нужно думать о том, как ваше приложение соотносится со всеми остальными приложениями, достаточно сосредоточиться на потоках своего приложения. В Windows поддерживаются семь относительных приоритетов потоков: Idle (холостого хода), Lowest (самый низкий), Below Normal (ниже обычного), Normal (обычный), Above Normal (выше обычного), Highest (самый высокий) и Time-Critical (требующий немедленной обработки). Эти приоритеты соотносятся с классами приоритетов процесса. По умолчанию для потоков используется обычный приоритет, соответственно, он применяется чаще всего.
Подводя итог, скажем, что процесс является членом класса приоритета и внутри него потокам назначаются связанные друг с другом приоритеты. Если вы заметили, я ничего не говорил об уровнях приоритета с нулевого по 31. Разработчики приложений никогда не имеют с ними дела напрямую. За них это делает система. Соотношение между классом приоритета процесса, относительным приоритетом потока и итоговым уровнем приоритета иллюстрирует табл. 26.1.
Таблица 26.1. Определение уровня приоритета на основе класса приоритета процесса и относительного приоритета потока
|
|
К примеру, если поток с приоритетом Normal принадлежит процессу с приоритетом Normal, ему назначается уровень приоритета 8. Так как приоритет Normal по умолчанию используется как для классов, так и для потоков, большинство потоков в системе имеют уровень приоритета 8.
Для потока с приоритетом Normal в высокоприоритетном процессе уровень приоритета равен 13. Если поменять класс приоритета на Idle, уровень приоритета потока снизится до 4. Помните, что приоритеты потоков связаны с классом приоритета процесса. При изменении последнего относительный приоритет потока остается без изменений, а вот уровень приоритета меняется.
Обратите внимание: в таблице нет комбинации, при которой поток получает нулевой уровень приоритета. Как уже упоминалось, этот приоритет зарезервирован для потока обнуления страниц, поэтому система не позволяет присвоить его какому- то другому потоку. Недоступны также следующие уровни приоритета: 17, 18, 19, 20, 21, 27, 28, 29 и 30. Они зарезервированы под драйверы устройств, работающие в режиме ядра, а потому не присваиваются пользовательским приложениям. Обратите внимание, что поток в классе приоритета Realtime не может иметь уровень приоритета ниже 16. В то же время потоки в остальных классах приоритета не могут получить уровень выше 15.
ПРИМЕЧАНИЕ
Концепция классов приоритета процесса может навести на мысль, что Windows каким-то образом управляет очередностью процессов. Но очередность операционная система определяет только для потоков. Класс приоритета процесса является абстрактным понятием, помогающим логически сопоставить относительную важность одного запущенного приложения с остальными; никаких других функций у него нет.
ВНИМАНИЕ
Лучше снизить приоритет одного потока, чем повысить приоритет другого. Обычно понижение приоритета требуется, если поток выполняет длительные вычисления, например компилирует код, проверяет орфографию, пересчитывает электронные таблицы и т. п. Повышать приоритет имеет смысл, если потокдолжен быстро отреагировать на какое-то событие, запуститься на короткий промежуток времени и вернуться в состояние ожидания. Потоки с высоким приоритетом большую часть своего существования находятся в режиме ожидания, не влияя на быстродействие всей системы. В качестве примера потока с высоким приоритетом можно упомянуть поток Проводника Windows (Windows Explorer), отслеживающий нажатие клавиши Windows пользователем. Проводник приостанавливает потоки с более низким приоритетом и немедленно выводит на экран меню. В процессе навигации поток Проводника Windows быстро отвечает на нажатия клавиш, обновляет меню и приостанавливается до следующего нажатия клавиши пользователем.
Обычно процесс получает класс приоритета в зависимости от того, каким процессом он был запущен. Большинство процессов инициируются Проводником Windows, присваивающим всем своим потомкам класс приоритета Normal. Управляемые приложения не могут владеть своими процессами, они запускаются в домене. Именно поэтому они не могут менять класс приоритета процесса, ведь это окажет влияние на весь запущенный в процессе код. К примеру, многие приложения ASP. NET выполняются в одном процессе, хотя каждое из них работает в собственном домене приложений. То же самое можно сказать о приложениях Silverlight, запускаемых в процессе интернет-браузера, или управляемых хранимых процедурах, запускаемых внутри процесса Microsoft SQL Server.
В то же время приложение может менять относительный приоритет своих потоков при помощи свойства Priority класса Thread, которому присваивается одно из пяти значений (Lowest, BelowNormal, Normal, AboveNormal или Highest), определенных в перечислении ThreadPriority. При этом точно так же, как Windows резервирует для себя нулевой уровень и уровень реального времени, CLR резервирует уровни приоритета Idle и Time-Critical. В настоящее время в CLR отсутствуют потоки с уровнем приоритета Idle, но в будущем ситуация может поменяться. При этом поток финализации, о котором шла речь в главе 21, исполняется науровне приоритета Time-Critical. Соответственно, разработчикам управляемых приложений остаются пять приоритетов потока: в табл. 26.1 это строки со второй (Highest) по шестую (Lowest).
ВНИМАНИЕ
В настоящее время редко встречаются приложения, использующие приоритеты потоков. Тем не менее хотелось бы надеяться, что в будущем, когда процессоры будут загружены на 100 %, непрерывно выполняя полезную работу, именно приоритеты потоков позволят обеспечивать быстродействие системы. К сожалению, сейчас конечные пользователи воспринимают высокую загрузку процессора как сигнал, что приложение вышло из-под контроля. В будущем мне хотелось бы, чтобы этот фактор воспринимался положительно, как знак того, что компьютер активно обрабатывает информацию для пользователя. Проблема в том, что если занять процессор обработкой потоков с уровнем приоритета 8 и выше, приложения могут начать недостаточно быстро реагировать на ввод данных пользователем. Надеюсь, что в будущей версии Диспетчера задач в отчете о загрузке процессора будет фигурировать также информация об уровнях приоритета потоков. Это гораздо лучше поможет в диагностике проблем.
Упомянем о наличии в пространстве имен System. Diagnostic s классов Process и ProcessThread (впрочем, это относится только к настольным приложениям, но не к приложениям Windows Store). Они содержат информацию о состоянии процесса и потока с точки зрения Windows. Эти классы предназначены для разработчиков, желающих написать сервисное приложение на управляемом коде или пытающихся оснастить свой код инструментами, помогающими в отладке. Поэтому данные классы попали в пространство имен System. Diagnostics. Для доступа к данным классам приложениям необходимы специальные права системы безопасности. Вы не сможете применить эти классы, к примеру, в приложениях Silverlight или ASP.NET.
С другой стороны, приложения могут воспользоваться классами AppDomain и Thread, обеспечивающими просмотр средой CLR доменов и потоков. Для работы с этими классами по большей части не требуется специальных прав системы безопасности, хотя некоторые операции доступны только при наличии определенных привилегий.
Фоновые и активные потоки
В CLR все потоки делятся на активные (foreground) и фоновые (background). При завершении активных потоков в процессе CLR принудительно завершает также все запущенные на этот момент фоновые потоки. При этом завершение фоновых потоков происходит немедленно и без появления исключений.
Следовательно, активные потоки имеет смысл использовать для исполнения заданий, которые обязательно требуется завершить например, для перемещения на диск данных из буфера в памяти. Фоновые же потоки можно оставить для таких некритичных задач, как пересчет ячеек электронных таблиц или индексирование записей. Ведь эта работа может быть продолжена и после перезагрузки приложения, а значит, нет необходимости насильно оставлять приложение работать, когда пользователь пытается его закрыть.
Концепция активных и фоновых потоков в CLR была введена для лучшей поддержки доменов приложений. Как вы знаете, в каждом домене может быть запущено отдельное приложение, при этом каждое такое приложение может иметь собственный фоновый поток. Даже если одно из приложений завершается, заставляя завершиться свой фоновый поток, среда CLR все равно должна функционировать, поддерживая остальные приложения. И только после того как все приложения со всеми своими фоновыми процессами будут завершены, можно будет уничтожить весь процесс.
Следующий код демонстрирует разницу между фоновым и активным потоками:
using System;
using System.Threading;
public static class Program { public static void Main() {
// Создание нового потока (по умолчанию активного)
Thread t = new Thread(Worker);
// Превращение потока в фоновый t.IsBackground = true;
t.Start(); // Старт потока
// В случае активного потока приложение будет работать около 10 секунд // В случае фонового потока приложение немедленно прекратит работу Console.WriteLine("Returning from Main");
}
private static void WorkerQ {
Thread.Sleep(10000); // Имитация 10 секунд работы
// Следующая строка выводится только для кода,
// исполняемого активным потоком Console.WriteLine("Returning from Worker");
}
}
Поток можно превращать из активного в фоновый и обратно. Основной поток приложения и все потоки, в явном виде созданные путем конструирования объекта Thread, по умолчанию являются активными. А вот потоки из пула по умолчанию являются фоновыми. Также потоки, создаваемые машинным кодом и попадающие в управляемую среду исполнения, помечаются как фоновые.
ВНИМАНИЕ
По возможности старайтесь избегать активных потоков. Однажды меня попросили определить, почему приложение никак не может завершить свою работу. Провозившись несколько часов, я понял, что причиной был компонент пользовательского интерфейса, в явном виде создающий активный поток. После того как компонент заставили использовать поток из пула, проблема была решена, а заодно повысилась и общая эффективность работы приложения.
Что дальше?
В этой главе мы рассмотрели основы работы с потоками. Надеюсь, вы усвоили, что поток исполнения является довольно дорогим ресурсом и использовать его следует крайне аккуратно. Лучше всего задействовать пул потоков среды CLR, который создает и уничтожает потоки автоматически. Пул предлагает набор потоков для решения различных задач, и некоторые из этих потоков вполне справятся с решением задач вашего приложения.
В главе 27 мы поговорим о том, каким образом пул потоков позволяет выполнять вычислительные операции. Глава 28 посвящена обсуждению того, как с помощью комбинации пула потоков и асинхронной модели программирования CLR выполнять операции ввода-вывода. Во многих ситуациях асинхронные вычислительные операции и операции ввода-вывода можно выполнять таким образом, что синхронизация потоков вам вообще не потребуется. Тем не менее остаются ситуации, в которых без синхронизации не обойтись. Конструкции, применяемые для синхронизации, и разница между ними рассматриваются в главах 29 и 30.
В заключение упомяну, что я интенсивно использовал потоки, начиная с первой бета-версии Windows NT 3.1, появившейся примерно в 1992 году. После выхода бета-версии .NET я начал создавать библиотеку классов, позволяющую упростить асинхронное программирование и синхронизацию потоков. Эту библиотеку (она называется Wintellect Power Threading Library) можно загрузить бесплатно. Существуют ее версии для обычной среды CLR, а также для Silverlight CLR и Compact Framework. Найти библиотеку, документацию и примеры кода можно по адресу http://Wintellect.com/PowerThreading.aspx. Там же находятся ссылки на форум поддержки и на видеоролики с примерами использования различных компонентов библиотеки.
Глава 27. Асинхронные вычислительные операции
В этой главе рассказывается о различных способах асинхронного выполнения операций, вынесенных в отдельные потоки. К вычислительным операциям, в частности, относятся компиляция кода, проверка орфографии, проверка грамматики, пересчет электронных таблиц, перекодирование аудио- и видеоданных, создание миниатюр изображений. Как видите, такие операции встречаются в финансовых и технических приложениях повсеместно.
Большинство приложений не так уж много времени уделяет обработке находящихся в памяти данных или вычислениям. Это легко проверить, открыв Диспетчер задач на вкладке Performance (Быстродействие). Загрузка процессора менее 100 % (а именно такая картина наблюдается в большинстве случаев), означает, что запущенные процессы не используют на полную мощность резервы всех ядер. Также это означает, что некоторые (если не все) потоки в процессах вообще не исполняются. Они ждут операции ввода или вывода, например срабатывания таймера, чтения данных из базы или записи данных в нее, нажатия клавиши на клавиатуре, перемещения указателя или нажатия кнопки мыши. При операциях ввода-вывода драйверы Microsoft Windows инициируют работу устройств, а сам процессор в это время не исполняет потоки, запущенные в системе. Именно поэтому диспетчер задач показывает низкую загрузку процессора.
Однако даже приложения, предназначенные для операций ввода-вывода, обрабатывают получаемые данные, поэтому распараллеливание вычислений может значительно повысить их пропускную способность. В этой главе рассказывается о пуле потоков общеязыковой исполняющей среды и основных приемах его использования. Это крайне важная информация, так как пул потоков является ключевой технологией, обеспечивающей разработку и реализацию масштабируемых, быстрореагирующих и надежных приложений и компонентов. Также в этой главе рассказывается о механизмах, позволяющих выполнять вычислительные операции посредством пула потоков. Эти операции происходят в асинхронном режиме, что позволяет, во-первых, обеспечить быструю реакцию на действия пользователей приложений с графическим интерфейсом, во-вторых, распределить занимающие много времени вычисления между различными процессорами.
Пул потоков в CLR
Как было отмечено в предыдущей главе, создание и уничтожение потока занимает изрядное время. Кроме того, при наличии множества потоков впустую расходуется память и снижается производительность, ведь операционной системе приходится планировать исполнение потоков и выполнять переключения контекста. К счастью, среда CLR способна управлять собственным пулом потоков, то есть набором готовых потоков, доступных для использования приложениями. Для каждого экземпляра CLR существует свой пул, используемый всеми доменами приложений, находящимися под управлением экземпляра CLR. Если в один процесс загружаются несколько экземпляров CLR, для каждого из них формируется собственный пул.
При инициализации CLR пул потоков пуст. В его внутренней реализации поддерживается очередь запросов на выполнение операций. Для выполнения приложением асинхронной операции вызывается метод, размещающий соответствующий запрос в очереди пула потоков. Код пула извлекает записи из очереди и распределяет их среди потоков из пула. Если пул пуст, создается новый поток. Как уже отмечалось, создание потока отрицательно сказывается на производительности. Однако по завершении исполнения своего задания поток не уничтожается, а возвращается в пул и ожидает следующего запроса. Поскольку поток не уничтожается, производительность не страдает.
Когда приложение отправляет пулу много запросов, он пытается обслужить их все с помощью одного потока. Однако если приложение создает очередь запросов быстрее, чем поток из пула их обслуживает, создаются дополнительные потоки. Такой подход позволяет обойтись при обработке запросов небольшим количеством потоков.
Когда приложение прекращает отправлять запросы в пул, появляются незанятые потоки, впустую занимающие память. Поэтому через некоторое время бездействия (различное для разных версий CLR) поток пробуждается и самоуничтожается, освобождая ресурсы. Это опять отрицательно сказывается на производительности, но в данном случае это уже не столь важно, поскольку уничтожаемый поток все равно простаивал, а значит, приложение в данный момент не было особо загружено работой.
Пул потоков позволяет найти компромисс в ситуации, когда малое количество потоков экономит ресурсы, а большое позволяет воспользоваться преимуществами многопроцессорных систем, а также многоядерных и гиперпотоковых процессоров. Пул потоков действует по эвристическому алгоритму. Если приложение должно выполнить множество заданий и при этом имеются доступные процессоры, пул создает больше потоков. При снижении загрузки приложения потоки из пула самоуничтожаются.
Простые вычислительные операции
Для добавления в очередь пула потоков асинхронных вычислительных операций обычно вызывают один из следующих методов класса ThreadPool: static Boolean QueueUserWorkItem(WaitCallback callBack);
static Boolean QueueUserWorkItem(WaitCallback callBack, Object state);
Эти методы ставят «рабочий элемент» вместе с дополнительными данными состояния в очередь пула потоков и сразу возвращают управление приложению. Рабочим элементом называется указанный в параметре callback метод, который будет вызван потоком из пула. Этому методу можно передать один параметр через аргумент state (данные состояния). Без этого параметра версия метода QueueUsenWonkltem передает методу обратного вызова значение null. Все заканчивается тем, что один из потоков пула обработает рабочий элемент, приводя к вызову указанного метода. Создаваемый метод обратного вызова должен соответствовать делегату System.Thneading.WaitCallback, который определяется так:
delegate void WaitCallback(ObJect state);
ПРИМЕЧАНИЕ
Сигнатуры делегатов WaitCallback и TimerCallback(о них мы поговорим ватой главе), а также делегата ParameterizedThreadStart (он упоминался в главе 25) совпадают. Если вы определяете метод, совпадающий с этой сигнатурой, он может быть вызван через метод ThreadPool.QueueUserWorkltem при помощи объекта System.Threading. Timer или System.Threading.Thread.
Пример процедуры асинхронного вызова метода потоком из пула:
using System;
using System.Threading;
public static class Program { public static void Main() {
Console.WriteLine("Main thread: queuing an asynchronous operation");
ThreadPool.QueueUserWorkItem(ComputeBoundOpj 5);
Console.WriteLine("Main thread: Doing other work here...");
Thread.Sleep(10000); // Имитация другой работы (10 секунд)
Console.WriteLine("Hit <Enter> to end this program...");
Console.ReadLine();
}
// Сигнатура метода совпадает с сигнатурой делегата WaitCallback private static void ComputeBoundOp(Object state) {
// Метод выполняется потоком из пула
Console.WriteLine("In ComputeBoundOp: state={0}", state);
Thread.Sleep(1000); // Имитация другой работы (1 секунда)
// После возвращения управления методом поток // возвращается в пул и ожидает следующего задания
}
Результат компиляции и запуска этого кода:
Main thread: queuing an asynchronous operation Main thread: Doing other work here...
In ComputeBoundOp: state=5
Впрочем, возможен и такой результат:
Main thread: queuing an asynchronous operation
In ComputeBoundOp: state=5
Main thread: Doing other work here...
Разный порядок следования строк в данном случае объясняется асинхронным выполнением методов. Планировщик Windows решает, какой поток должен выполняться первым, или же планирует их для одновременного выполнения на многопроцессорном компьютере.
ПРИМЕЧАНИЕ
Если метод обратного вызова генерирует необработанное исключение, CLR завершает процесс (если это не противоречит политике хоста). Необработанные исключения обсуждались в главе 20.
ПРИМЕЧАНИЕ
В приложениях Windows Store класс System.Threading.ThreadPool недоступен для открытого использования. Впрочем, он косвенно используется при использовании типов из пространства имен System.Threading.Tasks (см. раздел «Задания» далее в этой главе).
Контексты исполнения
С каждым потоком связан определенный контекст исполнения. Он включает в себя параметры безопасности (сжатый стек, свойство Principal объекта Thread и идентификационные данные Windows), параметры хоста (System. Threading.HostExecutionContextManager) и контекстные данные логического вызова (см. методы LogicalSetData и LogicalGetData класса System.Runtime. Remoting. Messaging. CallContext). Когда поток исполняет код, значения параметров контекста исполнения оказывают влияние на некоторые операции. В идеале всякий раз при использовании для выполнения заданий вспомогательного потока в этот вспомогательный поток должен копироваться контекст исполнения первого потока. Это гарантирует использование одинаковых параметров безопасности и хоста в обоих потоках, а также доступ вспомогательного потока к данным, сохраненным в контексте логического вызова исходного потока.
По умолчанию CLR автоматически копирует контекст исполнения самого первого потока во все вспомогательные потоки. Это гарантирует безопасность, но в ущерб производительности, потому что в контексте исполнения содержится много информации. Сбор всей информации и ее копирование во вспомогательные потоки занимает немало времени. Вспомогательный поток может, в свою очередь,
использовать вспомогательные потоки, при этом создаются и инициализируются дополнительные структуры данных.
Класс ExecutionContext в пространстве имен System.Threading позволяет управлять копированием контекста исполнения потока. Вот как он выглядит:
public sealed class ExecutionContext : IDisposable, ISerializable {
[SecurityCritical] public static AsyncFlowControl SuppressFlowQ;
public static void RestoreFlowQ;
public static Boolean IsFlowSuppressedQ;
11 He показаны редко применяемые методы
}
С помощью этого класса можно запретить копирование контекста исполнения, повысив производительность приложения. Для серверных приложений рост производительности в этом случае оказывается весьма значительным. Для клиентских приложений особой выгоды нет, кроме того, метод SuppressFlow помечается атрибутом [SecurityCritical], в результате становится невозможным вызов некоторых клиентских приложений (например, Microsoft Silverlight). Разумеется, запрещать копирование контекста исполнения можно, только если вспомогательному потоку не требуется содержащаяся там информация. Когда инициирующий контекст исполнения не переходит во вспомогательный поток, тот использует последний связанный с ним контекст исполнения. Поэтому при отключенном копировании контекста поток не должен исполнять код, зависящий от состояния текущего контекста исполнения (например, идентификационных данных пользователя Windows).
Следующий пример демонстрирует, как запрет на копирование контекста исполнения влияет на данные в контексте логического вызова потока при постановке рабочего элемента в очередь в CLR-пуле[46]:
public static void Main() {
// Помещаем данные в контекст логического вызова потока метода Main
CallContext.LogicalSetDataC'Name", "Jeffrey");
П Заставляем поток из пула работать
// Поток из пула имеет доступ к данным контекста логического вызова
ThreadPool.QueueUserWorkItem(
state => Console.WriteLine(nName={0}nj CallContext.LogicalGetData("Name")));
П Запрещаем копирование контекста исполнения потока метода Main
ExecutionContext. SuppressFlowQ;
// Заставляем поток из пула выполнить работу.
// Поток из пула НЕ имеет доступа к данным контекста логического вызова
ThreadPool.QueueUserWorkItem( ,
продолжение &
state => Console.WriteLine("Name={0}"j
CallContext.LoglcalGetData("Name")));
II Восстанавливаем копирование контекста исполнения потока метода Main // на случай будущей работы с другими потоками из пула ExecutionContext.RestoreFlow();
Console. ReadLineQ;
}
Результат компиляции и запуска этого кода:
Name=leffrey
Name=
Пока мы обсуждаем только запрет копирования контекста исполнения при вызове метода ThneadPool .QueueUsenWonkltem, но этот прием используется как при работе с объектами Task (см. раздел «Задания» данной главы), так и при инициировании асинхронных операций ввода-вывода (о них речь идет в главе 28).
Скоординированная отмена
Платформа .NET предлагает стандартный паттерн операций отмены. Этот паттерн является скоординированным (cooperative), то есть требует явной поддержки отмены операций. Другими словами, как код, выполняющий отменяемую операцию, так и код, пытающийся реализовать отмену, должны относиться к типам, о которых рассказывается в этом разделе. Так как необходимость отмены занимающих много времени вычислительных операций не вызывает сомнения, к вашим вычислительным операциям имеет смысл добавить возможность отмены. О том, как это сделать, мы и поговорим в этом разделе. Но начать следует с описания двух основных типов из библиотеки FCL, входящих в состав стандартного паттерна скоординированной отмены.
Для начала потребуется объект System.Threading.CancellationTokenSource.
Вот как выглядит данный класс:
public sealed class CancellationTokenSource : IDisposable { // Ссылочный тип public CancellationTokenSourceQ;
public Boolean IsCancellationRequested { get; } public CancellationToken Token { get; }
public void CancelQ; II Вызывает Cancel с аргументом false public void Cancel(Boolean throwOnFirstException);
}
Этот объект содержит все состояния, необходимые для управляемой отмены. После создания объекта CancellationTokenSource (ссылочный тип) получить
один или несколько экземпляров CancellationToken (значимый тип) можно из свойства Token. Затем они передаются операциям, поддерживающим отмену. Вот наиболее полезные члены значимого типа CancellationToken:
public struct CancellationToken { // Значимый тип
public static CancellationToken None { get; } // Очень удобно
Boolean IsCancellationRequested { get; } // Вызывается операциями,
// не связанными с Task
public void ThrowIfCancellationRequested(); // Вызван операциями,
// связанными с Task
// WaitHandle устанавливается при отмене CancellationTokenSource public WaitHandle WaitHandle { get; }
// Члены GetHashCode, Equals, == и != не показаны
public Boolean CanBeCanceled { get; } // Редко используется
public CancellationTokenRegistration Register(
Action<ObJect> callback, Object state,
Boolean useSynchronizationContext); 11 Более простые варианты
// перегрузки не показаны
![]() |
Экземпляр CancellationToken относится к упрощенному значимому типу, так как содержит всего одно закрытое поле: ссылку на свой объект CancellationTokenSource. Цикл вычислительной операции может периодически обращаться к свойству IsCancellationRequested объекта CancellationToken, чтобы узнать, не требуется ли раннее завершение его работы, то есть прерывание операции. Процессор перестает совершать операции, в результате которых вы не заинтересованы. Рассмотрим пример кода:
Internal static class CancellationDemo { public static void Main() {
CancellationTokenSource cts = new CancellationTokenSource();
// Передаем операции CancellationToken и число ThreadPool.QueueUserWorkItem(o => Count(cts.Token, 1000));
Console.WriteLine("Press <Enter> to cancel the operation."); Console. ReadLineQ;
cts.CancelQ; // Если метод Count уже вернул управления,
// Cancel не оказывает никакого эффекта
// Cancel немедленно возвращает управление, метод продолжает работу... Console.ReadLine();
![]() |
private static void Count(CancellationToken token, Int32 countTo) { for (Int32 count = 0; count ccountTo; count++) { if (token.IsCancellationRequested) {
Console.Writel_ine( "Count is cancelled"); break; // Выход их цикла для остановки операции
}
Console.WriteLine(count);
Thread.Sleep(200); // Для демонстрационных целей просто >кдем
}
Console.WriteLlne("Count is done");
}
}
ПРИМЕЧАНИЕ
Чтобы предотвратить отмену операции, ей можно передать экземпляр CancellationToken, возвращенный статическим свойством None структуры CancellationToken. Это очень удобное свойство возвращает специальный экземпляр CancellationToken, не связанный с каким-либо объектом CancellationTokenSource (его закрытое поле имеет значение null). При отсутствии объекта CancellationTokenSource отсутствует и код, который может вызвать метод Cancel. А значит, запрос к свойству IsCancellationRequested упомянутого экземпляра CancellationToken всегда будет получать в ответзначение false. Аналогичная ситуация сзапросомксвойствуСапВеСапсе1ес1. Значение true возвращается только для экземпляров CancellationToken, полученных через свойство Token перечисления CancellationTokenSource.
При желании можно зарегистрировать один или несколько методов таким образом, чтобы они вызывались при отмене объекта CancellationTokenSource. Для регистрации метода обратного вызова следует передать методу Register структуры CancellationToken делегата Action<Object> состояние, которое вы предполагаете передать через делегат в метод обратного вызова, и значение типа Boolean, указывающее, должен ли вызываться делегат с использованием контекста SynchronizationContext вызывающего потока. Если передать в параметре useSynchronizationContext значение false, поток, вызывающий метод Cancel, последовательно запустит все зарегистрированные методы. При передаче же значения true обратные вызовы отсылаются фиксированному объекту SynchronizationContext, который выбирает, какой из потоков активизирует тот или иной обратный вызов. Подробно класс SynchronizationContext рассматривается в главе 28.
ПРИМЕЧАНИЕ
Если вы регистрируете метод обратного вызова, используя уже отмененный объект CancellationTokenSource, поток, вызывающий метод Register, активизирует обратный вызов (вероятно, через SynchronizationContext вызывающего потока, если в параметре useSynchronizationContext передано значение true).
Многократный вызов метода Register приводит к многократной же активизации методов обратного вызова, причем последние могут генерировать необработанное исключение. Если вызвать метод Cancel объекта CancellationTokenSounce с параметром true, первый же метод обратного вызова, ставший источником необработанного исключения, остановит выполнение остальных методов обратного вызова, а исключение будет также сгенерировано методом Cancel. Если же передать этому методу значение false, будут вызваны все зарегистрированные методы обратного вызова. Все появляющиеся при этом необработанные исключения добавляются в коллекцию. Если после завершения всех методов обратного вызова обнаруживается наличие необработанных исключений, метод Cancel генерирует исключение AggnegateException, свойству InnenExceptions которого присваивается коллекция сгенерированных объектов исключений. При отсутствии необработанных исключений метод Cancel просто возвращает управление.
ВНИМАНИЕ
Невозможно определить, с какой операцией связан тот или иной объект из коллекции InnerExceptions исключения AggregateException. То есть вы фактически получаете только информацию о том, что некоторые операции выполнены не были, и по типу исключения можете определить, в чем была причина такого поведения. Чтобы выяснить местоположение ошибки, нужно исследовать свойство StackTrace объекта исключения и вручную проверить исходный код.
Метод Register объекта CancellationToken возвращает структуру Cancellation- TokenRegistnation, которая выглядит следующим образом:
public struct CancellationTokenRegistration :
IEquatable<CancellationTokenRegistration>, IDisposable { public void DisposeQ;
// He показаны GetHashCode, Equals, операторы == и !=
}
Метод Dispose позволяет удалить из объекта CancellationTokenSource зарегистрированный обратный вызов, с которым связан данный объект. В результате при вызове метода Cancel этот обратный вызов игнорируется. Вот код, демонстрирующий регистрацию двух обратных вызовов с одним объектом CancellationTokenSounce:
varcts = new CancellationTokenSource();
cts.Token.Reglster(() => Console.WriteLine("Canceled l"))j cts.Token.Reglster(() => Console.WriteLine("Canceled 2"));
// Для проверки отменим его и выполним оба обратных вызова cts.CancelQ;
Вот результат работы такого кода, полученный сразу после вызова метода Cancel:
Canceled 2 Canceled 1
Наконец, можно создать новый объект CancellationTokenSource, связав друг с другом другие объекты CancellationTokenSource. Отмена этого нового объекта произойдет при отмене любого из входящих в его состав объектов. Вот демонстрирующий это код:
// Создание объекта CancellationTokenSource var ctsl = new CancellationTokenSourceQ;
ctsl.Token.Register(() => Console.WriteLine("ctsl canceled"));
// Создание второго объекта CancellationTokenSource var cts2 = new CancellationTokenSourceQ;
cts2.Token.Register(() => Console.WriteLine("cts2 canceled"));
// Создание нового объекта CancellationTokenSource,
// отменяемого при отмене ctsl или ct2
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( ctsl.Token, cts2.Token);
linkedCts.Token.Register(() => Console.WriteLine("linkedCts canceled"));
// Отмена одного из объектов CancellationTokenSource (я выбрал cts2) cts2 .CancelQ;
// Показываем, какой из объектов CancellationTokenSource был отменен Console.WriteLine("ctsl canceled={0}, cts2 canceled={l}, linkedCts={2}“, ctsl.IsCancellationRequested, cts2.IsCancellationRequested, linkedCts. I sCa nee Hat ionRequested);
Результат запуска этого кода: linkedCts canceled cts2 canceled
ctsl canceled=False, cts2 canceled=True, linkedCts=True
Часто требуется отменить операцию по истечении определенного периода времени. Например, представьте, что серверное приложение начало выполнять некоторые вычисления по запросу клиента. При этом серверное приложение должно гарантированно ответить клиенту не позже двух секунд. В некоторых ситуациях лучше получить ответ с ошибкой или неполными результатами, чем дожидаться полного результата в течение долгого времени. К счастью, класс CancellationTokenSource умеет инициировать собственную отмену по истечении заданного интервала. Для этого следует либо создать объект CancellationTokenSource одним из конструкторов, получающих величину задержки, либо вызвать метод CancelAfter класса CancellationTokenSource.
public sealed class CancellationTokenSource : IDisposable { // Ссылочный тип
public CancellationTokenSource(Int32 millisecondsDelay); public CancellationTokenSource(TimeSpan delay);
public void CancelAfter(Int32 millisecondsDelay); public void CancelAfter(TimeSpan delay);
>
Задания
Вызвать метод QueueUserWorkltem класса ThreadPool для запуска асинхронных вычислительных операций очень просто. Однако этот подход имеет множество недостатков. Самой большой проблемой является отсутствие встроенного механизма, позволяющего узнать о завершении операции и получить возвращаемое значение. Для обхода этих и других ограничений специалисты Microsoft ввели понятие заданий (tasks), выполнение которых осуществляется посредством типов из пространства имен System.Threading.Tasks.
Вот каким образом при помощи заданий выполняется операция, аналогичная вызову метода QueueUserWorkltem класса ThreadPool:
ThreadPool.QueueUserWorkItem(ComputeBoundOp, 5); // Вызов QueueUserWorkltem new Task(ComputeBoundOp, 5).Start(); // Аналог предыдущей строки
Task.Run(() => ComputeBound0p(5)); // Еще один аналог
Во второй строке после создания нового объекта Task немедленно вызывается метод Start для запуска задания. Естественно, вы можете создать объект Task и вызвать Start для него позднее. Можно также представить код, передающий созданный им объект Task какому-то стороннему методу, который и будет определять момент вызова метода Start. Поскольку создание объекта Task с немедленным вызовом Start выполняется так часто, также можно воспользоваться удобным статическим методом Run класса Task, как показано в последней строке.
Для создания объекта Task следует вызвать конструктор и передать ему делегата Action или Action<Object>, указывающего, какую операцию вы хотите выполнить. При передаче метода, ожидающего тип Object, в конструктор объекта Task следует передать также аргумент, который должен быть в итоге передан операции. При вызове Run передается делегат Func<TResult> или Action, определяющий выполняемую операцию. Также конструктору можно передать еще и структуру CancellationToken, позволяющую отменить объект Task до его выполнения (эта процедура подробно рассмотрена далее).
При желании конструктору можно передавать флаги из перечисления TaskCreationOptions, управляющие способами выполнения заданий. Элементы перечисления определяют набор флагов, которые могут комбинироваться поразрядной операцией ИЛИ. Перечисление TaskCreationOptions определяется следующим образом:
[Flags, Serializable]
public enumTaskCreationOptions {
None = 0x0000, // По умолчанию
// Сообщает планировщику, что задание должно быть поставлено // на выполнение по возможности скорее PreferFairness = 0x0001,
// Сообщает планировщику, что ему следует более активно
продолжение ^
// создавать потоки в пуле потоков.
LongRunning = 0x0002;
// Всегда учитывается: присоединяет задание к его родителю AttachedToParent = 0x0004;
// Если задача пытается присоединиться к родительской задаче,
// она интерпретируется как обычная, а не как дочерняя задача. DenyChildAttach = 0x0008,
// Заставляет дочерние задачи использовать планировщик по умолчанию // вместо родительского планировщика.
HideScheduler = 0x0010
Большинство этих флагов являются рекомендациями, которые могут использоваться, а могут и игнорироваться объектом планировщика заданий TaskSchedulen; всегда принимаются к выполнению только флаги AttachedToParent, DenyChildAttach и HideScheduler, которые никак не связаны с самим объектом TaskScheduler. Более подробно про этот объект мы поговорим чуть позже.
Завершение задания и получение результата
Можно дождаться завершения задания и после этого получить результат его выполнения. Рассмотрим метод Sum, который при больших значениях переменной п требует большой вычислительной мощности:
private static Int32 Sum(Int32 n) {
Int32 sum = 0; for (; n > 0; n--)
checked { sum += n; } // при больших n выдается System.OvertlowException return sum;
}
Можно создать объект Task<TResult> (производный от объекта Task) и в качестве универсального аргумента TResult передать тип результата, возвращаемого вычислительной операцией. Затем остается дождаться завершения выполняющегося задания и получить результат при помощи следующего кода:
// Создание задания Task (оно пока не выполняется)
Task<Int32> t = new Task<Int32>(n => Sum((Int32)n), 1000000000);
// Можно начать выполнение задания через некоторое время t.Start();
// Можно ожидать завершения задания в явном виде t.Wait(); // ПРИМЕЧАНИЕ. Существует перегруженная версия,
// принимающая тайм-аут/CancellationToken
// Получение результата (свойство Result вызывает метод Wait)
Console.WriteLine("The Sum is: " + t.Result); // Значение Int32
ВНИМАНИЕ
При вызове потоком метода Wait система проверяет, началосьли выполнение задания Task, которого ожидает поток. В случае положительного результата проверки поток, вызывающий метод Wait, блокируется до завершения задания. Но если задание еще не начало выполняться, система может (в зависимости от объекта TaskScheduler) выполнить его при помощи потока, вызывающего метод Wait. В этом случае данный поток не блокируется. Он выполняет задание Task и немедленно возвращает управление. Это снижает затраты ресурсов (вам не приходится создавать поток взамен заблокированного), повышает производительность (на создание потока и переключение контекста не тратится время). Однако и это может быть не очень хорошо. Например, если перед вызовом метода Wait в рамках синхронизации потока происходит его блокирование, а затем задание пытается получить доступ к тем же запертым ресурсам, возникает взаимная блокировка (deadlock)!
Если вычислительное задание генерирует необработанное исключение, оно поглощается и сохраняется в коллекции, а потоку пула разрешается вернуться в пул. Затем при вызове метода Wait или свойства Result эти члены вбросят исключение System.AggnegateException.
Тип AggregateException инкапсулирует коллекцию исключений (которые генерируются, если родительское задание порождает многочисленные дочерние задания, приводящие к исключениям). Он содержит свойство InnerExceptions, возвращающее объект ReadOnlyCollection<Exception>. Не следует путать его со свойством InnenException, наследуемым классом AggregateException от своего базового класса System. Exception. Скажем, в показанном ранее примере элемент О свойства InnerExceptions класса AggregateException будет ссылаться на объект System.OverflowException, порождаемый вычислительным методом (Sum).
Для удобства класс AggregateException переопределяет метод GetBaseException класса Exception. Эта реализация возвращает исключение с максимальным уровнем вложенности, которое и считается источником проблемы (предполагается, что такое исключение в коллекции всего одно). Класс AggregateException также предлагает метод Flatten, создающий новый экземпляр AggregateException, свойство InnerExceptions которого содержит список исключений, вброшенных после перебора внутренней иерархии исключений первоначального объекта. Ну и наконец, данный класс содержит метод Handle, вызывающий для каждого из исключений в составе AggregateException метод обратного вызова. Этот метод выбирает способ обработки исключения. Для обрабатываемых исключений он возвращает значение true, для необрабатываемых, соответственно, — false. Если после вызова метода Handle остается хотя бы одно необработанное исключение, создается новый объект AggregateException. Впрочем, с методами Flatten и Handle мы подробно познакомимся чуть позже.
Можно ожидать завершения не только одного задания, но и массива объектов Task. Для этого в одноименном классе существует два статических метода. Метод WaitAny блокирует вызов потоков до завершения выполнения всех объектов в массиве Task. Этот метод возвращает индекс типа Int32 в массив, содержащий завершенные задания, заставляя поток продолжить исполнение. Если происходит тайм-аут, метод возвращает -1. Отмена же метода посредством структуры CancellationToken приводит к исключению OpenationCanceledException.
ВНИМАНИЕ
Если вы ни разу не вызывали методы Wait или Result и не обращались к свойству Exception класса Task, код не «узнает» о появившихся исключениях. Иначе говоря, вы не получите информации о том, что программа столкнулась с неожиданной проблемой. Для распознавания скрытых исключений можно зарегистрировать метод обратного вызова со статическим событием UnobservedTaskException класса TaskScheduler. При уничтожении задания со скрытым исключением в ходе уборки мусора это событие активизируется потоком финализации уборщика мусора CLR. После этого обработчику события передается объект UnobservedTaskExceptionEve ntArgs, содержащий скрытое исключение AggregateException.
Статический метод Wait АН класса Task блокирует вызывающий поток до завершения всех объектов Task в массиве. Метод возвращает значение true после завершения всех объектов и значение false, если истекает время ожидания. Отмена этого метода посредством структуры CancellationToken также приводит к исключению OperationCanceledException.
Отмена задания
Для отмены задания можно воспользоваться объектом CancellationTokenSource. Впрочем, сначала нам следует отредактировать метод Sum, дав ему возможность работать со структурой CancellationToken:
private static Int32 Sum(CancellationTokenct, Int32 n) {
Int32 sum = 0; for (; n > 0; n--) {
// Следующая строка приводит к исключению OperationCanceledException // при вызове метода Cancel для объекта CancellationTokenSourcej // на который ссылается маркер ct. ThrowlfCancellationRequestedQ;
checked { sum += n; } // при больших n появляется
// исключение System.OvertlowException
}
return sum;
}
В этом коде цикл вычислительной операции периодически вызывает метод ThrowlfCancellationRequested класса CancellationToken, чтобы проверить, не появился ли запрос на отмену операции. Этот метод аналогичен свойству IsCancellationRequested класса CancellationToken, рассмотренному ранее.
Однако при отмене объекта CancellationTokenSource метод генерирует исключение OperationCanceledException. Причиной исключения становится тот факт, что в отличие от рабочих элементов, запущенных методом QueueUserWorkltem класса ThneadPool, задания поддерживают концепцию выполнения и даже могут возвращать значение. Следовательно, нужен способ, позволяющий отличить завершенное задание от незавершенного. Именно для этого применяется исключение. Создадим объекты CancellationTokenSource и Task:
CancellationTokenSource cts = new CancellationTokenSource();
Task<Int32> t = new Task<Int32>(() => Sum(cts.Token, 10000), cts.Token);
t.Start();
// Позднее отменим CancellationTokenSource, чтобы отменить Task cts.CancelQ; // Это асинхронный запрос, задача уже может быть завершена
try {
// В случае отмены задания метод Result генерирует // исключение AggregateException
Console.WriteLine("The sum is: " + t.Result); // Значение Int32
}
catch (AggregateException x) {
// Считаем обработанными все объекты OperationCanceledException
// Все остальные исключения попадают в новый объект AggregateException,
// состоящий только из необработанных исключений x.Handle(e => е is OperationCanceledException);
// Строка выполняется, если все исключения уже обработаны Console.WriteLine("Sum was canceled");
}
Создаваемый объект Task можно связать с объектом CancellationToken, передав его конструктору Task (как показано ранее). Если отменить объект CancellationToken до планирования задания, задание тоже будет отменено[47]. Однако если задание уже начало выполняться (при помощи метода Start), его код должен в явном виде поддерживать отмену. К сожалению, несмотря на то что с объектом Task связан объект CancellationToken, у вас нет доступа к последнему. То есть вы должны каким-то образом поместить тот же самый объект CancellationToken, который использовался при создании объекта Task, в код задания. Проще всего при написании этого кода воспользоваться лямбда-выражением и «передать» объект CancellationToken в качестве переменной замыкания (что, собственно, и было сделано в предыдущем примере).
Автоматический запуск задания по завершении предыдущего
Для написания масштабируемого программного обеспечения следует избегать блокировки потоков. Вызов метода Wait или запрос свойства Result при незавершенном задании приведет, скорее всего, к появлению в пуле нового потока, что увеличит расход ресурсов и отрицательно скажется на расширяемости. К счастью, существует способ узнать о завершении задания. Оно может просто инициировать выполнение следующего задания. Вот как следует переписать предыдущий код, чтобы избежать блокировки потоков:
// Создание объекта Task с отложенным запуском
Task<Int32> t = Task.Run(() => Sum(CancellationToken.None, 10000));
// Метод ContinueWith возвращает объект Task, но обычно // он не используется
Task cwt = t.ContinueWith(task => Console.WriteLine(
"The sum is: " + task.Result));
Теперь, как только задание, выполняющее метод Sum, завершится, оно инициирует выполнение следующего задания (также на основе потока из пула), которое выведет результат. Исполняющий этот код поток не блокируется, ожидая завершения каждого из указанных заданий; он может в это время исполнять какой-то другой код или, если это поток из пула, вернуться в пул для решения других задач. Обратите внимание, что выполняющее метод Sum задание может завершиться до вызова метода ContinueWith. Впрочем, это не проблема, так как метод ContinueWith заметит завершение задания Sum и немедленно начнет выполнение задания, отвечающего за вывод результата.
Также следует обратить внимание на то, что метод ContinueWith возвращает ссылку на новый объект Task (в моем коде она помещена в переменную cwt). При помощи этого объекта можно вызывать различные члены (например, метод Wait, Result или даже ContinueWith), но обычно он просто игнорируется, а ссылка на него не сохраняется в переменной.
Следует также упомянуть, что во внутренней реализации объект Task содержит коллекцию ContinueWith. Это дает возможность несколько раз вызвать метод ContinueWith при помощи единственного объекта Task. Когда это задание завершится, все задания из коллекции ContinueWith окажутся в очереди в пуле потоков. Кроме того, при вызове метода ContinueWith можно установить флаги перечисления TaskContinuationOptions. Первые шесть флагов — None, PreferFairness, LongRunning,AttachedToParent, DenyChildAttach и HideScheduler аналогичны флагам показанного ранее перечисления TaskCreationOptions. Вот как выглядит тип TaskContinuationOptions:
[Flags, Serializable]
public enumTaskContinuationOptions {
None = 0x0000, // По умолчанию // Сообщает планировщику, что задание должно быть поставлено
// на выполнение по возможности скорее PreferFairness = 0x0001,
// Сообщает планировщику, что ему следует более активно // создавать потоки в пуле потоков.
LongRunning = 0x0002,
// Всегда учитывается: присоединяет задание к его родителю AttachedToParent = 0x0004,
// Если задача пытается присоединиться к родительской задаче,
// она интерпретируется как обычная, а не как дочерняя задача.
DenyChildAttach = 0x0008,
// Заставляет дочерние задачи использовать планировщик по умолчанию // вместо родительского планировщика.
HideScheduler = 0x0010,
// Запрещает отмену до завершения предшественника.
LazyCancellation = 0x0020,
// Этот флаг устанавливают, когда требуется, чтобы поток,
// выполняющий первое задание, выполнил и задание ContinueWith.
// Если первое задание уже завершено, поток, вызывающий ContinueWith,
// выполняет задание ContinueWith ExecuteSynchronously = 0x80000,
// Эти флаги указывают, когда запускать задание ContinueWith NotOnRanToCompletion = 0x10000,
NotOnFaulted = 0x20000,
NotOnCanceled = 0x40000,
// Эти флаги являются комбинацией трех предыдущих
OnlyOnCanceled = NotOnRanToCompletion | NotOnFaulted,
OnlyOnFaulted = NotOnRanToCompletion | NotOnCanceld,
OnlyOnRanToCompletion = NotOnFaulted | NotOnCanceled,
}
При вызове метода ContinueWith флаг TaskContinuationOptions .OnlyOnCanceled показывает, что новое задание должно выполняться только в случае отмены предыдущего. Аналогично, флаг TaskContinuationOptions .OnlyOnFaulted дает понять, что выполнение нового задания должно начаться только после того, как первое задание станет источником необработанного исключения. Ну а при помощи флага TaskContinuationOptions.OnlyOnRanToCompletion вы программируете запуск нового задания только при условии, что предыдущее задание не было отменено и не создало необработанного исключения, а было выполнено полностью. Без этих флагов новое задание запускается вне зависимости от того, как завершилось предыдущее. После завершения объекта Task автоматически отменяются все его вызовы с незапущенными заданиями. Вот пример, демонстрирующий сказанное:
// Создание и запуск задания с продолжением Task<Int32> t = Task.Run(() => Sum(10000));
// Метод ContinueWith возвращает объект Task, но обычно // он не используется
t.Continuel/dith(task => Console.WriteLine("The sum is: " + task.Result), TaskContinuationOptions.OnlyOnRanToCompletion);
t.ContinueWith(task => Console.WriteLine("Sum threw: " + task.Exception), TaskContinuationOptions.OnlyOnFaulted);
t.ContinueWith(task => Console.WriteLine("Sum was canceled"), TaskContinuationOptions.OnlyOnCanceled);
Дочерние задания
Как демонстрирует данный код, задания поддерживают в числе прочего и отношения предок-потомок:
Task<Int32[]> parent = new Task<Int32[]>(() => {
var results = new Int32[3]; // Создание массива для результатов
// Создание и запуск 3 дочерних заданий new Task(() => results[0] = Sum(10000),
TaskCreatIonOptions.AttachedToParent).Start(); new Task(() => results[l] = Sum(20000),
TaskCreatlonOptions.AttachedToParent).Start(); new Task(() => results[2] = Sum(30000),
TaskCreatlonOptions.AttachedToParent).Start();
// Возвращается ссылка на массив // (элементы могут быть не инициализированы) return results;
});
// Вывод результатов после завершения родительского и дочерних заданий varcwt = parent.ContinueWith(
parentTask => Array.ForEach(parentTask.Result, Console.WrlteLlne));
// Запуск родительского задания, которое запускает дочерние parent.Start();
Родительское задание создает и запускает три объекта Task. По умолчанию задания-потомки попадают на самый верхний уровень и не имеют отношения к своему предку. Однако при установке флага TaskCreationOptions .AttachedToParent родительское задание завершается только после завершения всех его потомков. Если при создании объекта Task методом ContinueWith установить флаг TaskContinuationOptions .AttachedToParent, то задание, запускаемое после завершения предыдущего, станет его потомком.
Структура задания
Каждый объект Task состоит из набора полей, определяющих состояние задания. В их число входят: идентификатор типа Int32 (предназначенное только для чтения свойство Id объекта Task), значение типа Int32, представляющее состояние выполнения задания, ссылка на родительское задание, ссылка на объект TaskScheduler, показывающий время создания задания, ссылка на метод обратного вызова, ссылка на объект, который следует передать в метод обратного вызова (этот объект доступен через предназначенное только для чтения свойство Asynestate объекта Task), ссылка на класс ExecutionContext и ссылка на объект ManualResetEventSlim. Кроме того, каждый объект Task имеет ссылку на дополнительное состояние, создаваемое по требованию. Это дополнительное состояние включает в себя объект CancellationToken, коллекцию объектов ContinueWithTask, коллекцию объектов Task для дочерних заданий, ставших источником необработанных исключений, и прочее в том же духе. За все эти возможности приходится платить, так как для хранения каждого состояния требуется выделять место в памяти. Если дополнительные возможности вам не нужны, для более эффективного расходования ресурсов рекомендуем воспользоваться методом ThreadPool .QueueUserWorkltem.
Классы Task и Task<TResult> реализуют интерфейс IDisposable, что позволяет после завершения работы с объектом Task вызвать метод Dispose. Пока что этот метод всего лишь закрывает объект ManualResetEventSlim, но можно определить классы, производные от Task и Task<TResult>, которые будут выделять свои ресурсы, освобождаемые при помощи переопределенного метода Dispose. Разумеется, разработчики практически никогда не вызывают метод Dispose для объекта Task; они просто позволяют уборщику мусора удалить освободившиеся ресурсы.
Как легко заметить, у каждого объекта Task есть поле типа Int32, содержащее уникальный идентификатор задания. При создании объекта это поле инициализируется нулем. При первом обращении к свойств Id (доступному только для чтения) в поле заносится значение типа Int32, которое и возвращается в качестве результата запроса. Нумерация идентификаторов начинается с единицы и увеличивается на единицу с каждым следующим присвоенным идентификатором. Чтобы заданию был присвоен идентификатор, достаточно открыть объект Task в отладчике Microsoft Visual Studio.
Идентификаторы были введены для того, чтобы каждому заданию соответствовал уникальный номер. В Visual Studio идентификаторы можно увидеть в окнах Parallel Tasks и Parallel Stacks. Но так как присвоение идентификаторов происходит автоматически, практически невозможно понять, какие значения к каким заданиям относятся. Тем не менее можно обратиться к статическому свойству Currentld объекта Task, которое возвращает значение типа Int32, допускающего присвоение значений null (Int32 ?). Узнать идентификатор кода, отладка которого происходит в данный момент, можно также в окнах Visual Studio Watch и Immediate. После этого остается найти это задание в окне Parallel Tasks или Parallel Stacks. Если же запросить свойство Currentld для задания, которое не выполняется, возвращается null.
Узнать, на какой стадии своего жизненного цикла находится задание, можно при помощи предназначенного только для чтения свойства Status объекта Task. Оно возвращает значение TaskStatus, которое определяется следующим образом:
public enum TaskStatus {
// Флаги, обозначающие состояние задания:
Created, // Задание создано в явном виде
//и может быть запущено вручную WaitingForActivation, // Задание создано неявно
//и запускается автоматически
WaitingToRun, // Задание запланировано, но еще не запущено
Running, // Задание выполняется
// Задание ждет завершения дочерних заданий, чтобы завершиться WaitingForChildrenToComplete,
// Возможные окончательные состояния задания:
RanToCompletion,
Canceled,
Faulted
}
Только что созданный объект Task имеет статус Created. Позднее, когда задание ставится в очередь на выполнение, его статус меняется на WaitingToRun. Запущенному заданию в потоке присваивается статус Running. Приостановленному заданию, которое ожидает завершения дочерних заданий, соответствует статус WaitingForChildrenToComplete. Полностью завершенное задание имеет одно из трех возможных состояний: RanToCompletion, Canceled или Faulted. Узнать результат выполнения задания Task<TResult> можно через его свойство Result. Если выполнение задачи Task или Task<TResult> прерывается, узнать, какое именно необработанное исключение было вброшено, можно через свойство Exception объекта Task; оно всегда возвращает объект AggregateException, коллекция которого состоит из необработанных исключений.
Для удобства объект Task предоставляет набор предназначенных только для чтения свойств типа Boolean: IsCanceled, IsFaulted и IsCompleted. Последнее свойство возвращает значение true, если объект Task находится в состоянии RanToCompleted, Canceled или Faulted. Определить, успешно ли выполнено задание, проще всего при помощи вот такого кода:
if (task.Status == TaskStatus.RanToCompletion) ...
Объект Task оказывается в состоянии WaitingForActivation, если он создается при помощи одной из следующих функций: ContinueWith, ContinueWhenAll, ContinueWhenAny или FromAsync. Задание, созданное путем конструирования объекта TaskCompletionSource<TResult>, также оказывается в состоянии WaitingForActivation. Это состояние означает, что планирование задания управляется его собственной инфраструктурой. К примеру, невозможно явным образом
запустить объект Task, созданный вызовом функции ContinueWith. Это задание запустится автоматически после завершения предыдущего.
Фабрики заданий
Иногда возникает необходимость получить набор объектов Task, находящихся в одном и том же состоянии. Для этого не нужно раз за разом передавать одни и те же параметры в конструктор каждого задания, достаточно создать фабрику заданий (task factory), инкапсулирующую нужное состояние. В пространстве имен System. Threading.Tasks определены типы TaskFactory и TaskFactory<TResult>. Оба этих типа являются производными от типа System.Object; то есть они являются равноранговыми.
Для создания группы заданий, не возвращающих значений, конструируется класс TaskFactory. Если же эти задания должны возвращать некое значение, потребуется класс TaskFactory<TResult>, которому в обобщенном аргументе TResult передается желаемый тип возвращаемого значения. При создании этих классов их конструкторам передаются параметры, которыми задания должны обладать по умолчанию. А точнее, передаются параметры CancellationToken, TaskScheduler, TaskCreationOptions и TaskContinuationOptions, наделяющие задания нужными свойствами.
Пример применения класса TaskFactory:
Task parent = new Task(() => {
varcts = new CancellationTokenSourceQ; vartf = new TaskFactory<Int32>(cts.Token,
TaskCreationOptions.AttachedToParent,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
// Задание создает и запускает 3 дочерних задания varchildTasks = new[] {
tf.StartNew(() => Sum(cts.Token, 10000)), tf.StartNew(() => Sum(cts.Token, 20000)),
tf.StartNew(() => Sum(cts.Token, Int32.MaxValue)) // Исключение
// OverflowException
H // Если дочернее задание становится источником исключения,
// отменяем все дочерние задания
for (Int32 task = 0; task cchildTasks.Length; task++) childTasks[task].ContinueWith(
t => cts.Cancel(), TaskContinuationOptions.OnlyOnFaulted);
// После завершения дочерних заданий получаем максимальное // возвращенное значение и передаем его другому заданию // для вывода tf. Continuel/\lhenAll( childTasks,
completedTasks => completedTasks.Where(
t => It.IsFaulted && !t.IsCanceled).Max(t => t.Result),
CancellationToken.None)
. Continuel/\lith(t =>Console.WriteLine("The maximum is: " + t.Result), TaskContinuationOptions.ExecuteSynchronously);
});
// После завершения дочерних заданий выводим,
// в том числе, и необработанные исключения parent.ContinueWith(p => {
// Текст помещен в StringBuilder и однократно вызван // метод Console.WriteLine просто потому, что это задание // может выполняться параллельно с предыдущим,
// и я не хочу путаницы в выводимом результате StringBuildersb = new StringBuilder(
"The following exception(s) occurred:" + Environment.NewLine);
foreach (var e in p. Exception. FlattenQ . InnerExceptions) sb.AppendLine(" "+ e.GetTypeQ . ToStringQ);
Console.WriteLine(sb. ToStringQ);
}, TaskContinuationOptions.OnlyOnFaulted);
11 Запуск родительского задания, которое может запускать дочерние parent.Start();
В этом коде создается объект TaskFactory<Int32>, при помощи которого потом создаются три объекта Task. При этом я хочу, чтобы все объекты Task обладали одним и тем же маркером CancellationTokenSounce, чтобы все они имели одного родителя, чтобы для них всех использовался один и тот же заданный по умолчанию планировщик заданий и чтобы все они выполнялись одновременно.
Поэтому из трех объектов Тask, созданных методом StantNew класса TaskFactony, формируется массив. Данный метод крайне удобен для создания и запуска дочерних заданий. В цикле каждое из дочерних заданий, ставшее источником необработанного исключения, отменяет все остальные запущенные в данный момент задания. Напоследок в классе TaskFactory вызывается метод ContinueWhenAll, создающий задание, выполняющееся после завершения всех дочерних заданий. Будучи создано в классе TaskFactory, это новое задание также считается дочерним и выполняется в синхронном режиме с помощью заданного по умолчанию планировщика. Но так как оно должно выполняться даже после отмены остальных дочерних заданий, его свойство CancellationToken переопределяется путем передачи ему значения CancellationToken. None. Это вообще исключает возможность отмены задания. Ну и после того, как обрабатывающее результаты задание завершает свою работу, создается еще одно задание, предназначенное для вывода максимального из возвращенных дочерними заданиями значения.
ПРИМЕЧАНИЕ
Вызов статических методов ContinueWhenAII и ContinueWhenAny классов TaskFactory или TaskFactory<TResult> делает недействительными следующие флаги TaskContinuationOption: NotOnRanToCompletion, NotOnFaulted и NotOnCanceled. Игнорируются и такие вспомогательные флаги, KaxOnlyOnCanceled, OnlyOnFaulted и OnlyOnRanToCompletion. То есть методы ContinueWhenAII и ContinueWhenAny запускают следующее задание вне зависимости оттого, каким оказывается результат выполнения предыдущего.
Планировщики заданий
Задания обладают очень гибкой инфраструктурой, причем не в последнюю очередь благодаря объектам TaskScheduler. Именно объект TaskScheduler отвечает за выполнение запланированных заданий и выводит информацию о них в отладчике Visual Studio. В FCL существует два производных от TaskScheduler типа: планировщик заданий в пуле потоков и планировщик заданий контекста синхронизации. По умолчанию все приложения используют первый из них, планирующий задания рабочих потоков в пуле (он подробно рассматривается чуть позже). Для получен ия ссылки на него используется статическое свойство Default класса TaskScheduler.
Планировщики заданий контекста синхронизации обычно применяются в приложениях Windows Forms, Windows Presentation Foundation (WPF), Silverlight и Windows Store. Они планируют задания в потоке графического интерфейса приложения, обеспечивая оперативное обновление таких элементов интерфейса, как кнопки, пункты меню и т. п. Этот планировщик вообще никак не использует пул потоков. Получить на него ссылку можно с помощью статического метода From- CurrentSynchronizationContext класса TaskScheduler.
Следующее простое приложение Windows Forms демонстрирует применение планировщика заданий контекста синхронизации:
internal sealed class MyForm : Form {
private readonly TaskScheduler m_syncContextTaskScheduler; public MyForm() {
m_syncContextTaskScheduler =
TaskScheduler. FromCurrentSynchronizationContextQ;
Text = "Synchronization Context Task Scheduler Demo";
Visible = true; Width = 600; Height = 100;
}
// Получение ссылки на планировщик заданий
private readonly TaskScheduler msyncContextTaskScheduler =
TaskScheduler.FromCurrentSynchronizationContext();
private CancellationTokenSource mcts;
protected override void OnMouseClick(MouseEventArgs e) {
if (mcts != null) { // Операция начата, отменяем ее m_cts.Cancel(); mcts = null;
} else { // Операция не начата, начинаем ее
Text = "Operation running"; mcts = new CancellationTokenSource();
// Задание использует планировщик по умолчанию // и выполняет поток из пула
Task<Int32> t = Task.Run(() => Sum(m_cts.Token, 20000), m_cts.Token);
// Эти задания используют планировщик контекста синхронизации //и выполняются в потоке графического интерфейса t.Continuel/dith(task => Text = "Result: " + task.Result, CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion, m_syncContextTaskScheduler);
t.ContinueWith(task => Text = "Operation canceled",
CancellationToken.None, TaskContinuationOptions.OnlyOnCanceled, m_syncContextTaskScheduler);
t.ContinueWith(task => Text = "Operation faulted",
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, m_syncContextTaskScheduler);
}
base.OnMouseClick(e);
}
}
При щелчке на клиентской области данной формы в потоке пула начинает выполняться вычислительное задание. Это хорошо, так как означает, что GUI- поток не заблокирован и может реагировать на операции с пользовательским интерфейсом. При этом исполняемый потоком из пула код не должен пытаться обновлять элементы интерфейса. В противном случае будет выдано исключение InvalidOperationException.
После завершения задания, связанного с вычислениями, начинает выполняться одно из трех следующих за ним заданий. Все эти задания обрабатываются планировщиком контекста синхронизации, причем этот планировщик ставит задания в очередь GUI-потока, позволяя коду этих заданий успешно обновлять элементы интерфейса. Обновление подписей на форме осуществляется через унаследованное свойство Text.
Так как вычислительное задание (метод Sum) запускается в потоке пула, пользователь может отменить операции при помощи элементов интерфейса. В моем примере для отмены операции достаточно щелкнуть на клиентской области формы.
Разумеется, при наличии специальных требований к планировщику можно определить собственный класс, производный от ТaskScheduler. Microsoft предлагает множество примеров кода для заданий и различных планировщиков в пакете Parallel
Extensions Extras, который можно загрузить по адресу http://code.msdn.microsoft.
com/ParExtSamples. Там вы найдете, в частности, следующие планировщики:
□ IOTaskScheduler. Ставит задания в очередь в потоках ввода-вывода пула, а не в рабочих потоках.
□ LimitedConcurrencyLevelTaskScheduler. Позволяет одновременно выполняться не более чем п заданиям, где п — параметр конструктора.
□ OrderedTaskScheduler. Разрешает выполнение только одного задания за раз. Данный класс является производным от LimitedConcurrencyLevelTaskSched uler и в качестве параметра п ему передается 1.
□ PrioritizingTaskScheduler. Ставит задания в очередь в пуле потоков среды CLR. После этого можно вызвать метод Prioritize и указать, что задание должно быть обработано раньше всех остальных заданий (если это еще не сделано). Метод Deprioritize, соответственно, позволяет выполнить задание после всех прочих.
□ ThreadPerTaskScheduler. Создает и запускает отдельный поток для каждого задания, при этом пул потоков не используется.
Методы For, ForEach и Invoke класса Parallel
Существуют стандартные ситуации, в которых теоретически возможно повышение производительности, обусловленное применением заданий. Для упрощения программирования эти сценарии инкапсулированы в статический класс System. Threading.Tasks.Parallel. Например:
// Один поток выполняет всю работу последовательно for (Int32 i = 0; i < 1000; 1++) DoWork(i);
Вместо обработки всех элементов этой коллекции можно воспользоваться методом For класса Parallel и распределить работу между несколькими потоками из пула:
// Потоки из пула выполняют работу параллельно Parallel.For(0, 1000, 1 => DoWork(i));
Аналогичным образом следующую конструкцию:
// Один поток выполняет всю работу по очереди foreach (var item in collection) DoWork(ltem);
можно заменить такой:
// Потоки из пула выполняют работу параллельно Parallel.ForEach(collection, item => DoWork(item));
Если у вас есть выбор между For и FonEach, лучше используйте цикл For, так как он работает быстрее.
Если вам нужно выполнить несколько методов, можно сделать это последовательно — например, вот так:
// Один поток выполняет методы по очереди
MethodlQ;
Method2();
Method3();
Также возможно параллельное выполнение:
// Потоки из пула выполняют методы одновременно Parallel.Invoke(
() => MethodlQj () => Method2()j () => Method3())j
Все методы класса Parallel заставляют вызывающий поток принимать участие в их обработке. Это хорошо с точки зрения расходования ресурсов, так как вызывающий поток не блокируется, ожидая выполнения работы потоками пула. Впрочем, если выполнение вызывающего потока будет закончено до того, как потоки из пула выполнят свою часть, вызывающий поток приостанавливается до их завершения, что тоже неплохо, так как обеспечивает семантику, аналогичную применению цикла for или foreach. Выполнение вызывающего потока не возобновляется, пока не будет завершена вся работа. Если какая-либо операция станет источником необработанного исключения, вызванный вами метод Parallel выдаст исключение AggregateException.
Разумеется, это не значит, что все циклы for в своем коде следует заменить вызовами Parallel. For, а циклы foreach — вызовами Parallel. ForEach. Вызовы Ра rallel базируются на предположении, что рабочие элементы без проблем смогут выполняться параллельно. Значит, для заданий, которые должны выполняться последовательно, вызов этого метода не имеет смысла. Следует также избегать рабочих элементов, вносящих изменения в любые совместно используемые данные, так как при одновременном управлении несколькими потоками эти данные могут оказаться поврежденными. Обычно эта проблема решается в рамках синхронизации потоков блокированием фрагментов кода, в которых реализуется доступ к данным. Однако так как после этого доступ к данным в каждый момент времени сможет получать только один поток, теряется преимущество одновременного обслуживания множества элементов.
Кроме того, методы класса Parallel потребляют много ресурсов — приходится выделять память под делегаты, которые вызываются по одному для каждого рабочего элемента. При наличии множества рабочих элементов, которые могут обслуживаться разными потоками, можно получить рост производительности. К тому же если каждый элемент выполняет много работы, на снижение производительности из-за вызова делегатов можно не обращать внимания. Проблемы начинаются в случае,
когда методы класса Parallel применяются к небольшому числу рабочих элементов или же к элементам, обслуживание которых происходит очень быстро.
Следует упомянуть, что для методов For, ForEach и Invoke класса Parallel существуют перегруженные версии, принимающие объект ParallelOptions. Вот как он выглядит:
public class ParallelOptions{
public ParallelOptions();
// Допускает отмену операций
public CancellationTokenCancellationToken { get; set; }
//По умолчанию CancellationToken.None
// Позволяет задать максимальное количество рабочих
// элементов, выполняемых одновременно
public Int32MaxDegreeOfParallelism { get; set; }
// По умолчанию -1 (число доступных процессоров)
// Позволяет выбрать планировщика заданий public TaskSchedulerTaskScheduler { get; set; }
// По умолчанию TaskScheduler.Default
}
Существуют перегруженные версии и для методов For и ForEach, позволяющие передавать три делегата:
□ Делегат локальной инициализации задания (locallnit) для каждой выполняемой задачи вызывается только один раз — перед получением команды на обслуживание рабочего элемента.
□ Делегат body вызывается один раз для каждого элемента, обслуживаемого участвующими в процессе потоками.
□ Делегат локального состояния каждого потока (localFinally) вызывается один раз для каждого задания, после того как оно обслужит все переданные ему рабочие элементы. Также он вызывается, если код делегата body становится источником необработанного исключения.
Следующий пример кода демонстрирует использование этих трех делегатов на примере суммирования байтов для всех файлов, содержащихся в каталоге:
private static Int64 DirectoryBytes(String path, String searchPattern,
SearchOptionsearchOption) {
var files = Directory.EnumerateFiles(path, searchPattern, searchOption);
Int64 masterTotal = 0;
ParallelLoopResult result = Parallel.ForEach<String, Int64>( files,
О => { // locallnit: вызывается в момент запуска задания
// Инициализация: задача обработала 0 байтов
return 0; // Присваивает taskLocalTotal начальное значение 0
Ь
(file, loopState, index, taskLocalTotal) => { // body: Вызывается
// один раз для каадого элемента // Получает размер файла и добавляет его к общему размеру Int64 fileLength = 0;
FileStreamfs = null; try {
fs = File.OpenRead(file); fileLength = fs.Length;
}
catch (IOException) { /* Игнорируем файлы, к которым нет доступа */ } finally { if (fs != null) fs.Dispose(); } return taskLocalTotal + fileLength;
L
taskLocalTotal => { // localFinally: Вызывается один раз в конце задания // Атомарное прибавление размера из задания к общему размеру Interlocked.Add(ref masterTotal, taskLocalTotal);
});
return masterTotal;
}
Каждое задание управляет собственной промежуточной суммой (в переменной taskLocalTotal) для данных ей файлов. После того как все задания завершатся, в безопасном в отношении потоков режиме обновляется общая сумма. Для этого используется метод Interlocked.Add (подробно он рассматривается в главе 28). Так как промежуточная сумма для каждого задания своя, во время обработки элементов не требуется синхронизация потоков, которая отрицательно сказывается на производительности. Они возникают только на последнем этапе при вызове метода Interlocked .Add. То есть снижение производительности происходит единовременно для задания, а не для рабочего элемента.
Вероятно, вы обратили внимание, что делегату body передается объект ParallelLoopState:
public class ParallelLoopState{ public void Stop(); public BooleanlsStopped { get; }
public void BreakQ;
public Int64? LowestBreakIteration{ get; }
public BooleanlsExceptional { get; }
public BooleanShouldExitCurrentlteration { get; }
}
Каждое принимающее участие в работе задание получает собственный объект ParallelLoopState и использует его для взаимодействия с другими работающими заданиями. Метод Stop останавливает цикл, и все будущие запросы к свойству IsStopped возвращают значение true. Метод Break заставляет цикл отказаться от обработки всех элементов, расположенных после выделенного. Предположим, что цикл ForEach должен обработать 100 элементов, но после обработки пятого элемента был вызван метод Break. В итоге цикл гарантированно обрабатывает первые пять элементов и возвращает управление. Впрочем, это не исключает обработки дополнительных элементов. Свойство LowestBreaklteration возвращает низшую итерацию цикла, на которой был вызван метод Break. Если этот метод вообще не вызвался, свойство возвращает значение null.
Свойство IsException возвращает true, если при обработке хотя бы одного элемента было вброшено необработанное исключение. Если обработка занимает много времени, ваш код может обратиться к свойству ShouldExitCurrentlteration, чтобы узнать, не нужно ли прервать текущую итерацию цикла. Свойство возвращает значение true, если был вызван метод Stop или Break, отменен объект CancellationTokenSource (ссылка на него дается через свойство CancellationToken класса ParallelOption) или же обработка элемента привела к необработанному исключению.
Методы For и ForEach класса Parallel возвращают экземпляр ParallelLoop- Result, который выглядит так:
public struct ParallelLoopResult{
// Возвращает false в случае преждевременного завершения операции public Boolean IsCompleted { get; } public Int64? LowestBreakIteration{ get; }
}
Результат работы цикла можно определить при помощи свойств. Если свойство IsCompleted возвращает значение true, значит, цикл пройден полностью, и все элементы обработаны. Если свойство IsCompleted возвращает значение false, а свойство LowestBreaklteration — значение null, значит, каким-то из потоков был вызван метод Stop. Если же в последнем случае значение, возвращаемое свойством LowestBreaklteration, отлично от null, значит, каким-то из потоков был вызван метод Break. При этом возвращенное свойством LowestBreaklteration значение типа Int64 указывает индекс последнего гарантированно обработанного элемента. Для корректного восстановления в случае выдачи исключения следует перехватить AggregateException.
Встроенный язык параллельных запросов
Разработанный Microsoft встроенный язык запросов (Language Integrated Query, LINQ) предлагает удобный синтаксис запросов к данным. С его помощью элементы легко фильтровать и сортировать, возвращать спроецированные наборы элементов и делать многое другое. При работе с объектами все элементы в наборе данных последовательно обрабатываются одним потоком — это называется последовательным запросом (sequential query). Повысить производительность можно при помощи языка параллельных запросов (Parallel LINQ), позволяющего последовательный запрос превратить в параллельный (parallel query). Последний во внутренней реализации задействует задания (поставленные в очередь планировщиком, используемым по умолчанию), распределяя элементы коллекции среди нескольких процессоров для обработки. Как и в случае с методами класса Parallel, максимальный выигрыш достигается при наличии множество элементов для обработки или когда обработка каждого элемента представляет собой длительную вычислительную операцию.
Вся функциональность Parallel LINQ реализована в статическом классе System. Linq.ParallelEnumerable (он определен в библиотеке System.Core.dll), поэтому в код следует импортировать пространство имен System. Linq. В частности, этот класс содержит параллельные версии всех стандартных LINQ-операторов, таких как Where, Select, SelectMany, GroupBy, loin, OrderBy, Skip, Take и т. п. Все эти методы являются методами расширения типа System. Linq. ParallelQuery<T>. Для вызова их параллельных версий следует преобразовать последовательный запрос (основанный на интерфейсе IEnumerable или IEnumerable<T>) в параллельный (основанный на классе ParallelQuery или ParallelQuerycT>), воспользовавшись методом расширения AsParallel класса ParallelEnumerable, который выглядит следующим образом[48]:
public static ParallelQuery<TSource> AsParallel<TSource>( this IEnumerable<TSource> source) public static ParallelQuery AsParallel(this IEnumerablesource)
Вот пример преобразования последовательного запроса в параллельный. Он возвращает все устаревшие методы, определенные в сборке:
private static void ObsoleteMethods(Assembly assembly) { var query =
from type in assembly .GetExportedTypes(). AsParallelQ
from method in type.GetMethods(BindingFlags.Public |
BindingFlags.Instance | BindingFlags.Static)
let obsoleteAttrType = typeof(ObsoleteAttribute)
where Attribute.IsDefined(method, obsoleteAttrType)
orderbytype.FullName
let obsoleteAttrObj = (ObsoleteAttribute)
Attribute.GetCustomAttribute(method, obsoleteAttrType)
select String.Format(nType={0}\nMethod={l}\nMessage={2}\n",
type.FullName, method.ToStringQ, obsoleteAttrObj.Message);
// Вывод результатов
foreach (var result in query) Console.WriteLlne(result);
}
Хотя подобные решения не типичны, также существует возможность в ходе операций переключиться с параллельного режима на последовательный. Это делается при помощи метода AsSequential класса ParallelEnumerable:
public static IEnumerable<TSource> AsSequential<TSource>( this ParallelQuery<TSource> source)
Этот метод преобразует ParallelQuery<T> в интерфейс IEnumerable<T>, и все операции начинают выполняться всего одним потоком.
Обычно результат LINQ-запроса вычисляется потоком, исполняющим инструкцию foreach (как было показано ранее). Это означает, что все результаты запроса просматриваются всего одним потоком. Параллельный режим обработки обеспечивает метод ForAll класса ParallelEnumerable:
static void ForAll<TSource>(
this ParallelQuery<TSource> source, Action<TSource> action)
Этот метод позволяет нескольким потокам одновременно обрабатывать результаты запросов. Мой приведенный ранее код с помощью этого метода можно
переписать следующим образом:
// вывод результатов
query.ForAll(Console.WriteLine);
Однако одновременный вызов метода Console .WriteLine несколькими потоками отрицательно сказывается на производительности, так как класс Console внутренне синхронизирует потоки, гарантируя, что к консоли в каждый момент времени имеет доступ только один поток. Именно это предотвращает смешение вывода потоков, из-за которого может появиться непонятный результат. Используйте метод ForAll в случаях, когда требуется вычисление каждого из результатов.
Так как при параллельном LINQ-запросе элементы обрабатываются несколькими потоками одновременно, результаты возвращаются в произвольном порядке. Для сохранения очередности обработки элементов применяется метод AsOrdered класса ParallelEnumerable. С его помощью потоки разбивают элементы по группам, которые впоследствии сливаются друг с другом. Однако все это отрицательно сказывается на производительности. Вот операторы, предназначенные для выполнения неупорядоченных операций:Distinct, Except, Intersect, Union, loin, GroupBy, Grouploin и ToLookup. После любого из этих операторов можно вызвать метод AsOrdered, чтобы упорядочить элементы.
Следующие операторы выполняют упорядоченные операции: OrderBy, OrderByDescending, ThenBy и ThenByDescending. Если вы хотите вернуться к неупорядоченным операциям, чтобы повысить производительность, после любого из этих операторов также можно вызвать метод AsUnordered.
Parallel LINQ также предоставляет дополнительные методы класса Parallel- Enumerable, позволяющие управлять обработкой запросов:
public static ParallelQuery<TSource> WithCancellation<TSource>(
this ParallelQuery<TSource> source, CancellationToken cancellationToken)
public static ParallelQuery<TSource> WithDegreeOfParallelism<TSource>( this ParallelQuery<TSource> source, Int32 degreeOfParallelism)
public static ParallelQuery<TSource> WithExecutionMode<TSource>(
this ParallelQuery<TSource> source, ParallelExecutionMode executionMode)
public static ParallelQuery<TSource> WithMergeOptions<TSource>(
this ParallelQuery<TSource> source, ParallelMergeOptions mergeOptions)
Очевидно, что методу WithCancellation можно передать объект Cancellation- Token, что дает возможность в любой момент остановить обработку запроса. Метод WithDegreeOf Parallelism задает максимальное количество потоков, которые могут обрабатывать запрос; при этом, если количество реально необходимых потоков меньше указанного, новые потоки не создаются. Обычно этот метод не используется, и по умолчанию запрос исполняется одним потоком на одно ядро. Однако этот метод можно вызвать, указав число ядер, меньшее реально имеющегося, оставив часть ядер для решения других задач. Если запрос выполняет синхронную операцию ввода-вывода, можно указать число ядер, превышающее реально имеющееся, так как во время таких операций потоки блокируются. При таком подходе потоки используются неэффективно, зато вы быстрее получаете результат. Это можно делать в клиентских приложениях, но я бы крайне не рекомендовал прибегать к синхронным операциям ввода-вывода в серверных приложениях.
Parallel LINQ анализирует запрос и выбирает оптимальный способ его обработки. Иногда производительность может оказаться выше при последовательных запросах. Обычно это бывает при использовании следующих операций: Concat,ElementAt(OrDefault),First(OrDefault),Last(OrDefault),Skip(While), Take(While) или Zip. Кроме того, это верно для случаев использования перегруженных версий методов Select (Many) или Where, в которых селектору передается позиционный индекс или делегат, возвращающий логическое значение. При этом запрос можно принудительно обработать в параллельном режиме, передав методу WithExecutionMode один из флагов ParallelExecutionMode:
public enum ParallelExecutionMode {
Default = 0, // Способ обработки запроса выбирается автоматически
ForceParallelism = 1 // Запрос обрабатывается в параллельном режиме
}
Как уже упоминалось, в Parallel LINQ обработкой запросов занимается целая группа потоков, а значит, возникает необходимость соединения полученных результатов в один. Для управления буферизацией и слиянием элементов используется метод WithMergeOptions, которому передается один из флагов ParallelMergeOptions: public enum ParallelMergeOptions {
Default =0, // Аналогично AutoBuffered (в будущем может измениться)
NotBuffered =1, // Результаты обрабатываются по мере готовности
AutoBuffered = 2, // Поток буферизует некоторые результаты // перед обработкой
FullyBuffered = 3 // Поток буферизует все результаты перед обработкой
}
По сути, эти параметры позволяют выбрать желаемое соотношение скорости работы и потребления памяти. Флаг NotBuffered экономит память, но обработка элементов происходит медленнее. А вот флаг FullyBuffered увеличивает потребление памяти, но результат вы получите быстрее. Компромиссом между этими вариантами является флаг AutoBuffered. Определить, какой именно вариант лучше всего подходит именно вам, проще всего экспериментальным путем. Можно также принять параметры, предлагаемые по умолчанию, что оптимально для большинства ситуаций. Дополнительную информацию о Parallel LINQ можно найти по следующим адресам:
□ http://blogs.msdn.com/pfxteam/archive/2009/05/28/9648672.aspx;
□ http://blogs.msdn.com/pfxteam/archive/2009/06/13/9741072.aspx.
Периодические вычислительные операции
В пространстве имен System. Threading определен класс Timer, который позволяет периодически вызывать методы из пула потоков. Создавая экземпляр этого класса, вы сообщаете пулу, что вам нужен метод, обратный вызов которого должен быть выполнен в заданное время. У класса Timer есть несколько очень похожих друг на друга конструкторов:
public sealed class Timer : MarshalByRefObject, IDisposable { public Timer(TimerCallback callback, Object state,
Int32 dueTime, Int32 period); public Timer(TimerCallback callback. Object state,
UInt32 dueTime, UInt32 period); public Timer(TimerCallback callback. Object state,
Int64 dueTime, Int64 period); public Timer(TimerCallback callback. Object state,
Timespan dueTime, TimeSpan period);
}
Все эти конструкторы создают объект Timer. Параметр callback указывает имя метода, обратный вызов которого должен выполниться потоком из пула. Конечно, созданный метод обратного вызова должен соответствовать типу делегата System. Threading.TimerCallback, который определяется следующим образом: delegate void TimerCallback(Object state);
Параметр state конструктора служит для передачи методу обратного вызова данных состояния; если эти данные отсутствуют, передается null. Параметр dueTime позволяет задать для CLR время ожидания (в миллисекундах) перед первым вызовом метода обратного вызова. Это время представляется 32-разрядным значением со знаком или без, 64-разрядным значением со знаком или значением TimeSpan. Чтобы метод обратного вызова активизировался немедленно, передайте в параметре dueTime значение 0. Последний параметр period указывает периодичность (в миллисекундах) последующих обращений к методу обратного вызова. Если ему передано значение Timeout.Infinite(-l), поток из пула ограничится одним обращением к методу обратного вызова.
В пуле имеется всего один поток для всех объектов Тimer. Именно он знает время активизации следующего таймера. В этот момент поток пробуждается и вызывает метод QueueUserWorkltem объекта ThreadPool, чтобы добавить в очередь пула потоков элемент, активизирующий метод обратного вызова. Если выполнение метода занимает много времени, возможно повторное срабатывание таймера. В результате один метод будет выполняться несколькими потоками пула. Решить эту проблему можно при помощи таймера, параметру period которого присвоено значение Timeout.Infinite. Такой таймер срабатывает только один раз. Затем в рамках метода обратного вызова вызывается метод Change и указывается новое время задержки, а параметру period снова присваивается значение Тimeout. Infinite. Вот как выглядят перегруженные версии метода Change:
public sealed class Timer : MarshalByRefObject, IDisposable { public Boolean Change(Int32 dueTime, Int32 period); public Boolean Change(UInt32 dueTime, UInt32 period); public Boolean Change(Int64 dueTime, Int64 period); public Boolean Change(TimeSpan dueTime, TimeSpan period);
}
Класс Timer содержит также метод Dispose, позволяющий вообще отключать таймер и при желании при помощи параметра notifyObject сообщать ядру о завершении всех ожидающих обратных вызовов. Вот как выглядят перегруженные версии метода Dispose:
public sealed class Timer : MarshalByRefObject, IDisposable { public Boolean Dispose();
public Boolean Dispose(WaitHandle notifyObject);
}
ВНИМАНИЕ
При утилизации объекта Timer уборщиком мусора поток пула останавливает таймер, чтобы он больше не срабатывал. Поэтому при работе с таймером следует проверять наличие переменной, поддерживающей его существование, иначе обращения к методу обратного вызова прекратятся. Эта ситуация подробно обсуждалась в главе 21.
В следующем коде поток из пула вызывает метод, который сначала выполняется немедленно, а затем через каждые две секунды.
internal static class TimerDemo {
private static Timer stimer;
public static void MainQ {
Console.WriteLine("Checking status every 2 seconds");
// Создание таймера, который никогда не срабатывает. Это гарантирует,
// что ссылка на него будет храниться в stimer,
// До активизации Status потоком из пула
s_timer = new Timer(Status, null, Timeout.Infinite, Timeout.Infinite);
// Теперь, когда s_timer присвоено значение, можно разрешить таймеру // срабатывать; мы знаем, что вызов Change в Status не выдаст // исключение NullReferenceException s_timer.Change(0, Timeout.Infinite);
Console. ReadLineQ; // Предотвращение завершения процесса
}
// Сигнатура этого метода должна соответствовать // сигнатуре делегата TimerCallback private static void Status(ObJect state) {
// Этот метод выполняется потоком из пула Console.WriteLine("In Status at {0}", DateTime.Now);
Thread.Sleep(1000); // Имитация другой работы (1 секунда)
// Заставляем таймер снова вызвать метод через 2 секунды s timer.Change(2000, Тimeout.Infinite);
II Когда метод возвращает управление, поток // возвращается в пул и ожидает следующего задания
}
Для периодического выполнения операций также возможен другой вариант организации кода — с использованием статического метода Delay класса Task в сочетании с ключевыми словами C# async и await (см. главу 28). Ниже приведена переработанная версия предыдущего кода:
internal static class DelayDemo {
public static void Main() {
Console.WriteLine("Checking status every 2 seconds");
Status();
Console. ReadLineQ; // Предотвращение завершения процесса
>
![]() |
// Методу можно передавать любые параметры на ваше усмотрение private static async void StatusQ { while (true) {
Console.WriteLine("Checking status at {0}", DateTime.Now);
// Здесь размещается код проверки состояния...
//В конце цикла создается 2-секундная задержка без блокировки потока await Task.Delay(2000); // await ожидает возвращения управления потоком
}
}
}
Разновидности таймеров
Библиотека FCL содержит различные таймеры, но большинство программистов даже не знают, чем они отличаются друг от друга.
□ Класс Timer из пространства имен System.Threading. Этот класс рассматривался в предыдущем разделе. Он лучик; других подходит для выполнения повторяющихся фоновых заданий с потоками пула.
□ Класс Timer из пространства имен System.Windows.Forms. Создание экземпляра этого класса указывает Windows на необходимость связать таймер с вызывающим потоком (см. Win32-функцию SetTimer). При срабатывании таймера Windows добавляет в очередь сообщений потока сообщение таймера (WM_TIMER). Поток должен извлечь эти сообщения и передать их нужному методу обратного вызова. Обратите внимание, что вся работа осуществляется одним потоком — устанавливает таймер тот же поток, который исполняет метод обратного вызова. Это предотвращает параллельное выполнение метода таймера в нескольких потоках.
□ Класс DispatcherTimer из пространства имен System.Windows.Threading.
Этот класс является эквивалентом класса Тimer из пространства имен System. Windows. Forms для приложений Silverlight и WPF.
□ Класс DispatcherTimer из пространства имен Windows.UI.XAML. Этот класс
является эквивалентом класса Timer из пространства имен System.Windows. Forms для приложений Windows Store.
□ Класс Timer из пространства имен System.Timers. Этот класс является, по
сути, оболочкой для класса Timer из пространства имен System.Threading. Он заставляет CLR по срабатыванию таймера ставить события в очередь пула потоков. Поскольку класс System .Timers .Timer является производным от класса Component из пространства имен System. ComponentModel, таймеры можно размещать в рабочей области конструктора форм приложения Visual Studio. Кроме того, он предоставляет свойства и события, упрощающие его использование в конструкторах Visual Studio. Этот класс появился в FCL в те времена, когда у Microsoft еще отсутствовала четкая концепция потоков и таймеров. Вообще говоря, его стоило бы удалить, оставив его функции классу System.Threading. Тimer. Я никогда не работаю с классом System. Тimers. Тimer и не советую этого вам (разве что вам совершенно необходимо разместить таймер в рабочей области конструктора форм).
Как пул управляет потоками
В этом разделе я хотел бы остановиться на том, каким образом пул управляет рабочими потоками и потоками ввода-вывода. Глубоко погружаться в детали мы не будем, так как внутренняя реализация этого процесса менялась при переходе от одной версии CLR к другой и наверняка изменится в будущем. Поэтому пул потоков можно представить в виде черного ящика. Вряд ли он окажется идеальным решением для какого-то конкретного приложения, так как эта технология планирования потоков общего назначения рассчитана на работу в широком спектре приложений. Для некоторых приложений она подходит лучше, чем для других. Впрочем, на сегодняшний день она прекрасно справляется со своими задачами, и я рекомендую отнестись к ней с доверием. Вряд ли вы сможете самостоятельно написать пул потоков, который будет функционировать лучше, чем поставляемый в составе CLR. Так как с течением времени внутренняя система управления потоками у пула меняется, многие приложения начинают работать лучше.
Ограничение количества потоков в пуле
CLR позволяет указать максимально возможное количество потоков, создаваемых пулом. Однако возникает ощущение, что задавать верхний предел для пула не стоит, потому что это может привести к зависанию или взаимной блокировке. Представьте очередь из 1000 рабочих элементов, заблокированную сигнальным событием элемента под номером 1001. Если верхний предел для количества потоков равен 1000, этот новый поток исполнен не будет, а значит, вся тысяча потоков навсегда окажется заблокированной. Конечному пользователю останется только завершить работу приложения, потеряв несохраненные данные. Разработчики обычно не накладывают искусственных ограничений на доступные для приложения ресурсы. Кому захочется при запуске приложения ограничивать объем используемой им памяти или пропускную способность канала связи? И все же по каким-то причинам некоторые разработчики считают возможным ограничивать максимальное количество потоков в пуле.
Из-за проблем, связанных с зависаниями и взаимными блокировками, разработчики CLR постоянно увеличивают заданное по умолчанию максимально возможное количество потоков в пуле. В настоящее время предел составляет 1000 потоков, что для 32-разрядного процесса, имеющего не менее 2 Гбайт адресного пространства, может рассматриваться как отсутствие ограничений. После загрузки библиотек Win32 и библиотек CLR, а также выделения собственной и управляемой кучи остается примерно 1,5 Гбайт адресного пространства. Так как каждый поток требует для стека в пользовательском режиме и блока окружения (ТЕВ) более 1 Мбайт памяти, в 32-разрядном процессе допустимо примерно 1360 потоков. Попытки создать большее количество потоков приведут к исключению OutOfMemoryException. 64-разрядный процесс предлагает 8 Тбайт адресного пространства, так что теоретически вы можете создавать сотни тысяч потоков. Но это будет пустая трата ресурсов, особенно с учетом того факта, что идеальное количество потоков совпадает с количеством процессоров. По идее разработчикам CLR следует убрать ограничения, но в настоящий момент это невозможно, так как в результате прекратят свою работу приложения, разработанные в предположении об ограниченном количестве потоков в пуле.
Класс System.Threading.ThneadPool предлагает несколько статических методов для управления количеством потоков в пуле: GetMaxThneads, SetMaxThreads, GetMinThneads, SetMinTh reads и GetAvailableThneads. Впрочем, я не рекомендую ими пользоваться. Попытки менять заданные по умолчанию ограничения обычно ухудшают работу приложений. Если вы считаете, что вашему приложению требуются сотни или даже тысячи потоков, скорее всего, что-то не так с архитектурой приложения или механизмом использования потоков. О том, как правильно применять потоки, мы поговорим в этой главе и в главе 28.
Управление рабочими потоками
На рис. 27.1 показаны различные структуры данных, делающие рабочие потоки частью пула. Метод Th read Pool. QueuellserWorkltem и класс Timer всегда помещают рабочие элементы в глобальную очередь. Рабочие потоки берут элементы для обработки из очереди по алгоритму «первым пришел — первым ушел». А так как при наличии нескольких потоков элементы из глобальной очереди могут удаляться одновременно, все рабочие потоки конкурируют за право на блокировку в рамках синхронизации потоков, которое гарантирует, что никакие два или более потока не смогут одновременно обрабатывать один и тот же элемент. В некоторых приложениях это право на блокировку становится узким местом, до некоторой степени ограничивающим масштабируемость и производительность.
Рассмотрим процесс планирования заданий с помощью заданного по умолчанию планировщика (его можно получить через статическое свойство Default класса TaskScheduler)[49]. При планировании задания для нерабочего потока объект Task добавляется в глобальную очередь. При этом каждый рабочий поток обладает собственной локальной очередью, в которую и добавляются планируемые задания.
Рабочий поток, готовый к обработке элементов, сначала проверяет наличие объектов Task в локальной очереди. Обнаружив такой объект, он изымает его из очереди и обрабатывает. Изъятие производится по алгоритму «последним пришел — первым ушел». Так как доступ к началу локальной очереди имеет только рабочий поток, блокировка в рамках синхронизации потоков больше не требуется, а добавление заданий в очередь и изъятие их из нее происходят очень быстро. Побочным эффектом такого поведения является то, что выполнение заданий идет в порядке, обратном порядку их постановки в очередь.
Рис. 27.1. Пул потоков в CLR |
|
ВНИМАНИЕ
Пул потоков не гарантирует определенного порядка обработки элементов из очереди, особенно с учетом того факта, что наличие нескольких потоков делает возможной одновременную обработку нескольких элементов. Проследите за тем, чтобы для вашего приложения порядок обслуживания элементов очереди не был принципиален.
Обнаружив пустую локальную очередь, рабочий поток пытается взять задание из локальной очереди другого рабочего потока. Задания, опять же, берутся с конца очереди, а значит, требуется блокировка в рамках синхронизации потоков, что несколько снижает производительность. Остается надеяться на то, что блокировка будет случаться относительно редко. Если пустыми оказываются все локальные очереди, рабочий поток извлекает (прибегая к блокировке) элемент из глобальной очереди по алгоритму «первым пришел — первым ушел». В случае пустой глобальной очереди рабочий поток переходит в режим ожидания. Если этот режим длится долго, поток просыпается и самоуничтожается, освобождая занятые ресурсы (ядро, стеки, ТЕВ).
Пул быстро создает рабочие потоки, а их количество определяется значением, переданным в метод SetMinThreads класса ThneadPool. Если вы не вызывали этот метод (а вызывать его не рекомендуется), количество потоков по умолчанию совпадает с количеством процессоров, которые может задействовать процесс. Оно определяется маской сходства (affinity mask) процесса. Обычно процессу разрешается использовать все процессоры, и пул создает рабочие потоки, количество которых быстро достигает числа процессоров. Затем пул начинает отслеживать частоту завершения рабочих элементов, и для тех из них, выполнение которых занимает много времени (с недокументированным значением), создает дополнительные потоки. При увеличении темпа завершения элементов рабочие потоки уничтожаются.
Глава 28. Асинхронные операции ввода-вывода
В предыдущей главе рассматривались возможности асинхронного выполнения вычислительных операций, когда пул потоков распределяет задания среди многочисленных ядер, обеспечивая параллельное исполнение потоков, что позволяет повысить производительность за счет более эффективного расходования ресурсов системы. В этой главе речь идет об асинхронном выполнении операций ввода-вывода, когда аппаратное обеспечение решает свои задачи вообще без участия потоков и процессора. Это, несомненно, оказывает влияние на эффективность расходования системных ресурсов, так как в этом случае эти ресурсы вообще не потребляются. Впрочем, пул потоков исполнения все равно играет важную роль, так как именно там обрабатываются результаты разнообразных операций ввода-вывода.
Операции ввода-вывода в Windows
Для начала рассмотрим, как в Microsoft Windows выполняются синхронные операции ввода-вывода. На рис, 28.1 показан компьютер с подсоединенным к нему периферийным оборудованием. Каждое из устройств снабжено собственной платой со специализированным микропроцессором. К примеру, плата жесткого диска умеет вращать диск, устанавливать головку на нужную дорожку, читать данные с диска и записывать их на него, перемещать данные в память компьютера и обратно.
Чтобы открыть дисковый файл в программе, разработчик создает объект FileStream. Далее методом Read читаются данные из файла. Вызов метода Read объекта FileStream сопровождается переходом потока от управляемого кода в машинный код/код пользовательского режима, при этом вызывается Win32^yHKH,HH ReadFile(l). Она выделяет память для небольшой структуры, называемой пакетом запросов ввода-вывода (I/O Request Packet, IRP) (2). Эта структура инициализируется дескриптором файла, смещением внутри файла, с которого начнется чтение байтов, адресом массива Byte [ ], выделенного для считываемых байтов, количеством байтов, предназначенных для передачи и т. п.
Функция ReadFile обращается к ядру Windows, переводя поток из кода пользовательского режима в код в режиме ядра и передавая в ядро IRP-структуру (3). По дескриптору устройства ядро узнает, какое устройство предназначено для конкретной операции ввода-вывода, после чего пакет запросов ставится в IRP-
![]() |
очередь нужного драйвера устройства (4). Каждый драйвер устройства управляет собственной очередью запросов ввода-вывода от всех запущенных на машине процессов. При появлении IRP-пакетов драйвер устройства передает содержащуюся в них информацию соответствующему устройству, которое, собственно, и выполняет операцию ввода-вывода (5).
Рис. 28.1. Синхронные операции ввода-вывода в Windows
Необходимо помнить одну важную подробность: в процессе выполнения устройством операции ввода-вывода поток исполнения, передавший запрос, простаивает, поэтому Windows переводит его в спящее состояние, чтобы не расходовать процессорное время впустую (6). Однако при этом поток продолжает занимать место в памяти своим стеком пользовательского режима, стеком режима ядра, блоком переменных окружения потока (Thread Environment Block, ТЕВ) и другими структурами данных, которые в этот момент не используются. Приложения с графическим интерфейсом перестают реагировать на действия пользователя на время блокировки потока. Все это, конечно, нежелательнс >.
После завершения устройством операции ввода-вывода Windows пробуждает поток, ставит его в очередь процессора и позволяет ему вернуться из режима ядра сначала в пользовательский режим, а затем и в управляемый код (7, 8 и 9). Метод Read объекта FileStream при этом возвращает значение типа Int32, содержащее количество прочитанных из файла байтов. Это дает вам информацию о количестве байтов, оказавшихся в массиве Byte [ ], ранее переданном методу Read.
Представим реализацию веб-приложения, в которой для каждого пришедшего на ваш сервер клиентского запроса формируется запрос к базе данных. При поступлении клиентского запроса поток из пула потоков обращается к вашему коду. При выдаче синхронного запроса к базе данных этот поток окажется заблокированным на неопределенное время, необходимое для получения ответа из базы. Если в это время придет еще один клиентский запрос, пул создаст еще один поток, который снова окажется заблокированным. В итоге можно оказаться с целым набором блокированных потоков, ожидающих ответа из базы данных. Получается, что веб-сервер выделяет массу ресурсов (потоков и памяти для них), которые почти не используются!
Проблема усугубляется тем, что при получении результатов запросов из базы данных блокировка с потоков будет снята одновременно и все они начнут исполняться. В ситуации, когда количество потоков значительно превосходит количество ядер процессора, операционная система прибегнет к частым переключениям контекста, что значительно снизит производительность. Так что это не тот путь, который позволил бы реализовать масштабируемое приложение.
Теперь рассмотрим процедуру выполнения асинхронных операций ввода-вывода в Windows (рис. 28.2). Я убрал со схемы все внешние устройства, кроме жесткого диска, а также добавил пул потоков среды CLR и слегка отредактировал код. Открытие файла по-прежнему выполняется путем создания объекта FileStneam, но теперь ему передается флаг FileOptions .Asynchronous, который указывает Windows, что операции чтения из файла и записи в файл следует выполнять асинхронно.
Чтение данных из файла теперь выполняется методом BeginRead, а не методом Read. ReadAsync создает объект Task<Int32>, представляющий незавершенную операцию чтения, а затем вызывает Win32^yHK4Hio ReadFile (1), которая выделяет место под IRP-пакет, инициализирует его, как и в предыдущем сценарии (2), и передает в ядро Windows (3). Windows добавляет IRP-пакет в IRP-очередь драйвера жесткого диска (4), но на этот раз поток не блокируется, а немедленно возвращает управление после вызовов метода BeginRead (5,6 и 7). Конечно, это может произойти еще до обработки IRP-пакета, поэтому после ReadAsync не может следовать код, который пытается обратиться к байтам в переданном методу массиве Byte[ ].
Может возникнуть вопрос, когда и каким образом обрабатываются считываемые данные? При вызове ReadAsync возвращается объект Task<Int32>. Используя этот объект, можно вызвать метод ContinueWith для регистрации метода обратного вызова, который должен выполняться при завершении задачи, а затем обработать данные в методе обратного вызова. Также можно использовать асинхронные функции С#, позволяющие использовать последовательную структуру кода (как при выполнении синхронного ввода-вывода).
Закончив обработку IRP-пакета (а), устройство помещает делегат в очередь CLR-пула потоков (Ь). В дальнейшем какой-то из потоков пула берет готовый IRP- пакет и активизирует метод обратного вызова (с)[50]. В результате вы узнаете о за
вершении операции, а обращения к данным массива Byte [ ] внутри метода станут
![]() |
безопасными.
Рис. 28.2. Асинхронные операции ввода-вывода в Windows
Теперь, разобравшись с основами, посмотрим на открывающиеся перед нами перспективы. Предположим, в ответ на клиентский запрос сервер выдает асинхронный запрос к базе данных. При этом наш поток не блокируется, а возвращается в пул, получая возможность заняться обработкой других клиентских запросов. Таким образом, получается, что для обработки всех входящих запросов достаточно всего одного потока. Полученный от базы данных ответ также окажется в очереди пула потоков, то есть наш поток сможет тут же его обработать и отправить данные клиенту. Таким образом, единственный поток обрабатывает не только клиентские запросы, но и все ответы базы данных. В итоге сервер практически не потребляет системных ресурсов, но работает с максимально возможной скоростью, так как переключения контекста не происходит!
Если элементы появляются в пуле быстрее, чем поток может их обработать, пул может создать дополнительные потоки. Пул быстро создаст по одному потоку на каждый процессор. Соответственно, на машине с четырьмя процессорами четыре клиентских запроса к базе данных и ответа базы данных (в любой комби
нации) будут обрабатываться в четырех потоках без какого-либо переключения контекста[51].
Однако при блокировке потока (выполнении синхронной операции ввода-вывода, вызове метода Thread. Sleep или ожидании, связанном с блокировкой потока в рамках синхронизации потоков) Windows уведомляет пул о том, что один из его потоков прекратил работу. Пул для восполнения недостаточной загрузки процессора создает новый поток взамен заблокированного. К сожалению, такой выход из положения не идеален, потому что создание нового потока обходится довольно дорого с точки зрения затрат времени и памяти.
Кроме того, позднее поток может быть разблокирован, и в итоге процессор окажется перегруженным, что приведет к переключению контекста и снижению производительности. Впрочем, эта проблема решается средствами пула. Завершившим свою работу потокам, которые вернулись в пул, не дают обрабатывать новые элементы, пока загрузка процессора не достигнет определенного уровня. Таким способом уменьшается количество переключений контекста и повышается производительность. Если впоследствии пул обнаружит, что потоков больше, чем необходимо, он просто позволит лишним потокам самоуничтожиться, освободив ресурсы.
Для реализации описанного поведения CLR-пул потоков использует такой ресурс Windows, как порт завершения ввода-вывода (I/O Completion Port). Он создается при инициализации CLR. Затем с этим портом можно связать подсоединяемые устройства, чтобы в результате их драйверы «знали», куда поставить в очередь IRP-пакет. Подробнее этот механизм описан в моей книге «Windows via C/C++» (Microsoft Press, 2007).
Асинхронный ввод-вывод кроме минимального использования ресурсов и уменьшения количества переключений контекста предоставляет и другие преимущества. Скажем, в начале сборки мусора CLR приостанавливает все потоки в процессе. Получается, чем меньше у нас потоков, тем быстрее произойдет уборка мусора. Кроме того, при уборке мусора CLR просматривает в поисках корней все стеки потоков. Соответственно, чем меньше у нас потоков, тем меньше стеков приходится просматривать и тем быстрее работает уборщик мусора. Плюс ко всему, если в процессе обработки потоки не были заблокированы, большую часть времени они будут проводить в пуле в режиме ожидания. А значит, в начале уборки мусора потоки окажутся наверху стека, и поиск корней не займет много времени.
При достижении отлаживаемым приложением точки останова Windows приостанавливает все его потоки. После возвращения к отладке следует возобновить все потоки, а значит, при наличии большого количества потоков пошаговая отладка будет выполняться крайне медленно. Асинхронный ввод-вывод позволяет обойтись всего несколькими потоками, повышая тем самым производительность отладки.
Выгоды этим не исчерпываются. Предположим, ваше приложение должно загрузить с различных сайтов 10 изображений. Загрузка каждого из них занимает 5 секунд. В синхронном режиме выполнения (загрузка одного изображения за другим) вам потребуется 50 секунд. Однако при помощи всего одного потока можно начать 10 асинхронных операций загрузки и получить все изображения всего за 5 секунд! То есть время выполнения нескольких синхронных операций ввода-вывода получается путем суммирования времени, которое занимает каждая отдельная операция, в то время как в случае; набора асинхронных операций ввода-вывода время их завершения определяется самой медленной из выполняемых операций.
Для приложений с графическим интерфейсом асинхронные операции открывают еще одно преимущество: их интерфейс всегда реагирует на действия конечного пользователя. В приложениях Silverlight и Windows Store вообще все операции ввода-вывода выполняются только асинхронно, потому что библиотеки классов операций ввода-вывода предоставляют только асинхронные версии своих операций; методы выполнения синхронных операций просто отсутствуют. Это было сделано намеренно, чтобы приложение не переставало реагировать на действия конечного пользователя.
Асинхронные функции C#
Асинхронные операции являются ключом к созданию высокопроизводительных масштабируемых приложений, выполняющих множество операций при помощи небольшого количества потоков. Вместе с пулом потоков они дают возможность эффективно задействовать все процессоры в системе. Осознавая этот огромный потенциал, разработчики CLR разработали модель программирования, призванную сделать его доступным для всех программистов[52]. Эта модель использует объекты
Task (см. главу 27) и асинхронные функции языка С#. В следующем примере кода асинхронные функции используются для выполнения двух асинхронных операций.
private static async Task<String> IssueClientRequestAsync(String serverName,
String message)
{
using (var pipe = new NamedPipeClientStream(serverName, "PipeName", PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.WriteThrough))
{
pipe.Connect(); // Преаде чем задавать ReadMode, необходимо pipe.ReadMode = PipeTransmissionMode.Message; // вызвать Connect
// Асинхронная отправка данных серверу Byte[] request = Encoding.UTF8.GetBytes(message); await pipe.WriteAsync(request, 0, request.Length);
// Асинхронное чтение ответа сервера Byte[] response = new Byte[1000];
Int32 bytesRead = await pipe.ReadAsync(response, 0, response.Length); return Encoding.UTF8.GetString(response, 0, bytesRead);
} // Закрытие канала
}
В приведенном коде сразу видно, что IssueClientRequestAsync является асинхронной функцией, потому что ключевое слово async следует в первой строке сразу же после static. Когда метод помечается ключевым словом async, компилятор преобразует код метода в тип, реализующий конечный автомат (более подробное описание приводится в следующем разделе). Это позволяет потоку выполнить часть кода в конечном автомате, а затем вернуть управление без выполнения всего метода до завершения. Таким образом, при вызове IssueClientRequestAsync поток конструирует NamedPipeClientStream, вызывает Connect, задает значение свойства ReadMode, преобразует переданное сообщение в Byte [ ] и вызывает WriteAsync. Внутренняя реализация WriteAsync создает объект Task и возвращает его IssueClientRequestAsync. На этой стадии оператор C# await вызывает ContinueWith для объекта Task с передачей метода, возобновляющего выполнение конечного автомата, после чего поток возвращает управление из IssueClientRequestAsync.
В будущем драйвер сетевого устройства завершит запись данных в канал. Поток из пула оповестит объект Task, что приведет к активизации метода обратного вызова ContinueWith, заставляющего поток возобновить выполнение конечного автомата. А если конкретнее, поток заново входит в метод IssueClientRequestAsync, но в точке оператора await. Теперь наш метод выполнит сгенерированный компилятором код, запрашивающий состояние объекта Task. В случае ошибки выдается представляющее ее исключение. Если операция завершается успешно, оператор await возвращает результат. В нашем случае WriteAsync возвращает Task вместо Task<TResult>, так что возвращаемое значение отсутствует.
Далее выполнение нашего метода продолжается созданием объекта Byte [ ] и последующим вызовом асинхронного метода ReadAsync для NamedPipeClientStream. Внутренняя реализаци ReadAsync создает объект Task<Int32> и возвращает его. И снова оператор await вызывает ContinueWith для объекта Task<Int32> с передачей метода, возобновляющего выполнение конечного автомата, и снова поток возвращает управление из IssueClientRequestAsync.
В будущем сервер вернет ответ клиентской машине, драйвер сетевого устройства получит этот ответ, а поток из пула уведомит объект Task<Int32>, который возобновит выполнение конечного автомата. Оператор await заставляет компилятор сгенерировать код, который запрашивает свойство Result объекта Task (Int32) и присваивает результат локальной переменной bytesRead, или выдает исключение в случае ошибки. Затем выполняется оставшаяся часть кода IssueClientRequestAsync, которая возвращает строку результата и закрывает канал. На этой стадии конечный автомат отработал до завершения, а уборщик мусора при необходимости освободит память.
Так как асинхронные функции возвращают управление до того, как их конечный автомат отработает до завершения, выполнение метода, вызвавшего IssueClientRequestAsync, продолжится сразу же после того, как IssueClientRequestAsync выполнит свой первый оператор await. Но как вызывающая сторона узнает, что выполнение конечного автомата IssueClientRequestAsync завершилось? Когда вы помечаете метод ключевым словом async, компилятор автоматически генерирует код, создающий объект Task в начале выполнения конечного автомата; этот объект Task завершается автоматически при завершении конечного автомата. Заметьте, что типом возвращаемого значения IssueClientRequestAsync является Task<String>. Фактически возвращается объект Task<String>, который создается кодом, сгенерированным компилятором, а свойство Result объекта Task в данном случае имеет тип String. Ближе к концу IssueClientRequestAsync я возвращаю строку. Это заставляет код, сгенерированный компилятором, завершить созданный им объект Task<String> и задать его свойству Result возвращенную строку.
Для асинхронных функций действует ряд ограничений:
□ Метод Main приложения не может быть преобразован в асинхронную функцию. Кроме того, конструкторы, методы доступа свойств и методы доступа событий не могут быть преобразованы в асинхронные функции.