«Mango» — внутреннее кодовое название Windows Phone SDK 7.1 и, конечно же, название изысканного тропического фрукта. Плоды манго можно употреблять в пищу самыми разными способами, например добавлять в пироги, салаты, коктейли. Говорят также, что манго очень полезен для здоровья, и у него интересная история. В этой статье я рассмотрю Mangolicious — приложение Windows Phone SDK 7.1, посвященное плодам манго. В приложении содержатся рецепты с использованием манго, составы коктейлей и различные факты о манго, но настоящей целью этого приложения является исследование некоторых из важнейших новых возможностей в выпуске SDK 7.1, а именно:
локальная база данных и LINQ to SQL;вторичные тайлы (secondary tiles) и глубокое связывание (deep linking);интеграция Silverlight/XNA.
Пользовательский интерфейс этого приложения прост: основная страницы предлагает панораму с меню в первом элементе панорамы, динамическим выбором рецептов и коктейлей текущего сезона во втором элементе и некоторые сведения о плодах манго в третьем элементе, как показано на рис. 1.
Рис. 1. Основная панорамная страница Mangolicious
И меню, и элементы в разделе Seasonal Highlights действуют как ссылки для перехода на другие страницы приложения. Большая часть страниц является Silverlight-страницами, а одна выделена под интегрированную XNA-игру. Вот сводка задач, которые нужно выполнить для создания этого приложения, — от начала и до конца:
Создать базовое решение в Visual Studio.Независимо создать базу данных для рецептов, коктейлей и интересных фактов.Обновить приложение так, чтобы оно использовало эту базу данных и предоставляло доступ к ней для связывания с данными.Создать различные UI-страницы и связать с ними данные.Установить функцию Secondary Tiles, чтобы пользователи могли прикреплять элементы Recipe к странице Start устройства под управлением Phone.Интегрировать в приложение XNA-игру.Создание решения
Для этого приложения я воспользуюсь шаблоном Windows Phone Silverlight and XNA Application в Visual Studio. Это приведет к генерации решения с тремя проектами; они представлены в табл. 1 после переименования.
Табл. 1. Проекты в решении Windows Phone Silverlight and XNA
ПроектОписаниеMangoAppСодержит само приложение Phone со страницами по умолчанию: MainPage и вторичной GamePageGameLibraryПо большей части пустой проект, в котором содержатся все необходимые ссылки, но нет никакого кода. Очень важно, что он включает Content Reference на проект Content.GameContentПустой проект Content, который будет содержать все ресурсы игры (изображения, звуковые файлы и т. д.)Создание базы данных и класса DataContext
В Windows Phone SDK 7.1 введена поддержка локальных баз данных. То есть приложение может хранить данные в локальном файле базы данных (SDF) на устройстве Phone. Рекомендуемый подход — создать базу данных в коде либо самого приложения, либо отдельной вспомогательной программы, предназначенной только для этой цели. Создавать базу данных в вашем приложении имеет смысл, когда вы будете создавать все данные (или большую часть) только при выполнении этого приложения. В случае приложения Mangolicious у меня есть лишь статические данные, и я могу заполнить базу данных заранее.
Для этого я создам отдельное вспомогательное приложение на основе простого шаблона Windows Phone Application. Чтобы создать базу данных программным способом, нужен класс, производный от DataContext, который определен в Phone-версии сборки System.Data.Linq. Этот класс DataContext можно использовать как во вспомогательном приложении, создающем базу данных, так и в основном приложении, которое просто работает с базой данных. Во вспомогательном приложении я должен указать размещение базы данных в изолированном хранилище, так как это единственное место, в которое можно что-то записывать из приложения Phone. Этот класс также содержит набор полей Table для каждой таблицы базы данных:
public class MangoDataContext : DataContext{ public MangoDataContext() : base("Data Source=isostore:/Mangolicious.sdf") { } public Table<Recipe> Recipes; public Table<Fact> Facts; public Table<Cocktail> Cocktails;}
Между классами Table и таблицами в базе данных существует сопоставление один в один. Свойства Column сопоставляются с полями (столбцами) таблицы в базе данных и включают такие свойства схемы базы данных, как тип данных и размер (INT, NVARCHAR и др.), может ли поле содержать null-значения, является ли оно полем ключа и т. д. Я определяю классы Table для остальных таблиц в базе данных точно так же, как показано на рис. 2.
Рис. 2. Определение классов Table
[Table]public class Recipe{ private int id; [Column( IsPrimaryKey = true, IsDbGenerated = true, DbType = "INT NOT NULL Identity", CanBeNull = false, AutoSync = AutoSync.OnInsert)] public int ID { get { return id; } set { if (id != value) { id = value; } } } private string name; [Column(DbType = "NVARCHAR(32)")] public string Name { get { return name; } set { if (name != value) { name = value; } } } … // остальные определения полей (столбцов) // опущены для краткости}
Тем не менее во вспомогательном приложении — при использовании стандартного подхода Model-View-ViewModel (MVVM) — мне теперь нужен класс ViewModel, который будет выступать в роли посредника между View (UI) и Model (данными), используя класс DataContext. В ViewModel есть поле DataContext и ряд наборов для табличных данных (Recipes, Facts и Cocktails). Эти данные статичны, поэтому в данном случае будет достаточно простых наборов List<T>. По той же причине мне нужны лишь аксессоры get свойств, но не модификаторы set (рис. 3).
Рис. 3. Определение свойств-наборов для табличных данных в ViewModel
public class MainViewModel{ private MangoDataContext mangoDb; private List<Recipe> recipes; public List<Recipe> Recipes { get { if (recipes == null) { recipes = new List<Recipe>(); } return recipes; } } … additional table collections omitted for brevity}
Я также предоставляю открытый метод (который можно вызывать из UI) для реального создания базы данных и всех ее данных. В этом методе я создаю саму базу данных, если ее нет, а затем создаю каждую таблицу по очереди, заполняя их статическими данными. Например, чтобы создать таблицу Recipe, я создаю несколько экземпляров класса Recipe, соответствующих записям в таблице, добавляю все записи в наборе к DataContext, а затем фиксирую данные в базе данных. Тот же шаблон используется для таблиц Facts и Cocktails (рис. 4).
Рис. 4. Создание базы данных
public void CreateDatabase(){ mangoDb = new MangoDataContext(); if (!mangoDb.DatabaseExists()) { mangoDb.CreateDatabase(); CreateRecipes(); CreateFacts(); CreateCocktails(); }} private void CreateRecipes(){ Recipes.Add(new Recipe { ID = 1, Name = "key mango pie", Photo = "Images/Recipes/MangoPie.jpg", Ingredients = "2 cans sweetened condensed milk, ¾ cup fresh key lime juice, ¼ cup mango purée, 2 eggs, ¾ cup chopped mango.", Instructions = "Mix graham cracker crumbs, sugar and butter until well distributed. Press into a 9-inch pie pan. Bake for 20 minutes. Make filling by whisking condensed milk, lime juice, mango purée and egg together until blended well. Stir in fresh mango. Pour filling into cooled crust and bake for 15 minutes.", Season = "summer" }); … additional Recipe instances omitted for brevity mangoDb.Recipes.InsertAllOnSubmit<Recipe>(Recipes); mangoDb.SubmitChanges();}
После этого в подходящем месте вспомогательного приложения (например, в обработчике щелчка кнопки) я могу вызвать этот метод CreateDatabase. Когда я запускаю вспомогательное приложение (либо в эмуляторе, либо на физическом устройстве), в изолированном хранилище приложения создается файл базы данных. Последняя задача — извлечь этот файл в настольную систему, чтобы им можно было пользоваться в основном приложении. Для этого я использую утилиту командной строки Isolated Storage Explorer, поставляемую с Windows Phone SDK 7.1. Вот команда, которая делает снимок изолированного хранилища из эмулятора и передает его в настольную систему:
"C:\Program Files\Microsoft SDKs\Windows Phone\v7.1\Tools\IsolatedStorageExplorerTool\ISETool" ts xd {e0e7e3d7-c24b-498e-b88d-d7c2d4077a3b} C:\Temp\IsoDump
В этой команде предполагается, что данная утилита установлена по стандартному пути. Параметры поясняются в табл. 2.
Табл. 2. Параметры командной строки Isolated Storage Explorer
ПараметрОписаниеtsСокращение от «take snapshot» (создать снимок) (команда загрузки из изолированного хранилища в настольную систему)xdСокращение от XDE (эмулятор){e0e7e3d7-c24b-498e-b88d-d7c2d4077a3b}ProductID для вспомогательного приложения. Он указывается в WMAppManifest.xml и отличается для каждого приложенияC:\Temp\IsoDumpЛюбой допустимый путь в настольной системе, куда вы хотите скопировать снимок
После извлечения SDF-файла в настольную систему вспомогательное приложение больше не требуется и можно переключить свое внимание на приложение Mangolicious, использующее эту базу данных.
Использование базы данных
В приложении Mangolicious я добавляю SDF-файл в проект, а также включаю в решение тот же собственный класс DataContext, но с парой небольших изменений. В Mangolicious мне не требуется записб в базу данных, поэтому я могу использовать ее напрямую из папки установки приложения. Таким образом, строка подключения немного отличается от той, которая была во вспомогательном приложении. Кроме того, в коде Mangolicious определяется таблица SeasonalHighlights. Соответствующей таблицы в базе данных нет. Вместо этого ее код извлекает данные из двух таблиц нижележащей базы данных (Recipes и Cocktails) и использует их для заполнения элемента панорамы Seasonal Highlights. Вот и все, чем отличаются классы DataContext во вспомогательном и основном приложениях:
public class MangoDataContext : DataContext{ public MangoDataContext() : base("Data Source=appdata:/Mangolicious.sdf;File Mode=read only;") { } public Table<Recipe> Recipes; public Table<Fact> Facts; public Table<Cocktail> Cocktails; public Table<SeasonalHighlight> SeasonalHighlights;}
В приложении Mangolicious также нужен класс ViewModel, и в качестве отправной точки можно задействовать класс ViewModel из вспомогательного приложения. Мне необходимы поле DataContext и группа свойств-наборов List<T> для табличных данных. И еще я добавлю строковое свойство, которое будет хранить название текущего сезона, вычисляемого в конструкторе:
public MainViewModel(){ season = String.Empty; int currentMonth = DateTime.Now.Month; if (currentMonth >= 3 && currentMonth <= 5) season = "spring"; else if (currentMonth >= 6 && currentMonth <=
season = "summer"; else if (currentMonth >= 9 && currentMonth <= 11) season = "autumn"; else if (currentMonth == 12 || currentMonth == 1 || currentMonth == 2) season = "winter";}
В ViewModel критически важен метод LoadData. Здесь я инициализирую базу данных и выполняю запросы LINQ to SQL для загрузки данных через DataContext в свои наборы в памяти. Я мог бы в этот момент заранее загружать все три таблицы, но хочу оптимизировать время запуска приложения, отложив загрузку данных до открытия соответствующей страницы. Единственное, что я должен загрузить при запуске — это данные для таблицы SeasonalHighlight, потому что она показывается на основной странице. Для этого с помощью двух запросов я извлекаю записи из таблиц Recipes и Cocktails, соответствующие текущему сезону, и добавляю в набор скомбинированные группы записей, как показано на рис. 5.
Рис. 5. Загрузка данных при запуске
public void LoadData(){ mangoDb = new MangoDataContext(); if (!mangoDb.DatabaseExists()) { mangoDb.CreateDatabase(); } var seasonalRecipes = from r in mangoDb.Recipes where r.Season == season select new { r.ID, r.Name, r.Photo }; var seasonalCocktails = from c in mangoDb.Cocktails where c.Season == season select new { c.ID, c.Name, c.Photo }; seasonalHighlights = new List<SeasonalHighlight>(); foreach (var v in seasonalRecipes) { seasonalHighlights.Add(new SeasonalHighlight { ID = v.ID, Name = v.Name, Photo = v.Photo, SourceTable="Recipes" }); } foreach (var v in seasonalCocktails) { seasonalHighlights.Add(new SeasonalHighlight { ID = v.ID, Name = v.Name, Photo = v.Photo, SourceTable = "Cocktails" }); } isDataLoaded = true;}
Я могу использовать аналогичные запросы LINQ to SQL для создания отдельных методов LoadFacts, LoadRecipes и LoadCocktails, которые можно вызывать после запуска для загрузки соответствующих данных по требованию.
Создание UI
Основная страница состоит из Panorama с тремя PanoramaItem. Первый PanoramaItem содержит ListBox, предлагающий главное меню приложения. Когда пользователь выбирает один из элементов в ListBox, происходит переход к соответствующей странице, т. е. либо к страницам наборов для Recipes, Facts и Cocktails, либо к странице Game. Перед самым переходом я загружаю необходимые данные в наборы Recipes, Facts или Cocktails:
switch (CategoryList.SelectedIndex){ case 0: App.ViewModel.LoadRecipes(); NavigationService.Navigate( new Uri("/RecipesPage.xaml", UriKind.Relative)); break; … additional cases omitted for brevity}
Когда пользователь выбирает элемент из списка Seasonal Highlights в UI, я анализирую выбранный элемент, чтобы выяснить, что это — Recipe или Cocktail, а затем выполняю переход на индивидуальную страницу Recipe или Cocktail, передавая идентификатор этого элемента как часть строки запроса навигации (рис. 6).
Неявное применение стилей — еще одно новшество, появившееся в Windows Phone SDK 7.1 как часть Silverlight 4.
Рис. 6. Выбор из списка Seasonal Highlights
SeasonalHighlight selectedItem = (SeasonalHighlight)SeasonalList.SelectedItem;String navigationString = String.Empty;if (selectedItem.SourceTable == "Recipes"){ App.ViewModel.LoadRecipes(); navigationString = String.Format("/RecipePage.xaml?ID={0}", selectedItem.ID);}else if (selectedItem.SourceTable == "Cocktails"){ App.ViewModel.LoadCocktails(); navigationString = String.Format("/CocktailPage.xaml?ID={0}", selectedItem.ID);}NavigationService.Navigate( new System.Uri(navigationString, UriKind.Relative));
Пользователь может перейти из меню на основной странице к одной из трех других страниц. В каждой из них данные связываются с одним из наборов в ViewModel для отображения списка элементов: Recipes, Facts или Cocktails. На каждой из этих страниц есть простой ListBox, где каждый элемент в списке содержит элемент управления Image для картинки и TextBlock для названия элемента. На рис. 7, к примеру, показана страница FactsPage.
Рис. 7. Интересные факты, одна из страниц списков-наборов
Когда пользователь выбирает индивидуальный элемент из списка Recipes, Facts или Cocktails, осуществляется переход к соответствующей странице (Recipe, Fact или Cocktail) и передается идентификатор этого элемента в строке запроса навигации. И вновь эти страницы почти идентичны, каждая из них предлагает Image и некоторый текст под ним. Заметьте, что я не определяю явный стиль для связанных с данными элементами TextBlock, но все они, тем не менее, используют TextWrapping=Wrap. Это делается объявлением стиля TextBlock в App.xaml.cs:
<Style TargetType="TextBlock" BasedOn="{StaticResource PhoneTextNormalStyle}"> <Setter Property="TextWrapping" Value="Wrap"/></Style>
Результат заключается в том, что любой TextBlock в этом решении, для которого не указан собственный стиль явным образом, будет неявно использовать объявленный в App.xaml.cs. Неявное применение стилей — еще одно новшество, появившееся в Windows Phone SDK 7.1 как часть Silverlight 4.
Хотя механизм «возврата домой» можно реализовать, из-за его поведения следует хорошенько подумать, а стоит ли его вводить.
Отделенный код для каждой из этих страниц весьма прост. В переопределенной версии OnNavigatedTo я извлекаю идентификатор элемента из строки запроса, нахожу этот элемент в наборе ViewModel и связываю его с данными. Код для RecipePage немного посложнее, чем для остальных: весь дополнительный код в этой странице относится к HyperlinkButton, размещаемой в правом верхнем углу страницы (рис. 8).
Рис. 8. Страница рецептов с кнопкой-заколкой
Функция Secondary Tiles
Когда пользователь щелкает «заколку» HyperlinkButton на странице индивидуального рецепта, я закрепляю этот элемент как тайл (картинку) на странице Start устройства Phone. Эта операция приводит к переходу пользователя на страницу Start и деактивирует приложения. Когда тайл закрепляется таким образом, он периодически анимируется, поворачиваясь то передней, то обратной стороной, как показано на рис. 9 и 10.
Рис. 9. Прикрепленный тайл рецепта (передняя сторона)
Рис. 10. Прикрепленный тайл рецепта (обратная сторона)
В последующем пользователь сможет стукнуть пальцем по этому закрепленному тайлу и перейти прямо к нужному элементу в приложении. После открытия соответствующей страницы на кнопке-заколке изображение сменится с утопленной заколки на поднятую заколку. Если он открепит страницу, она будет удалена со страницы Start и приложение продолжит свою работу.
Вот как это работает. В переопределенной версии OnNavigatedTo для RecipePage — после выполнения стандартный операций для определения того, какой Recipe следует связать через механизм привязки данных, — я формирую строку, которую впоследствии смогу использовать как URI для этой страницы:
thisPageUri = String.Format("/RecipePage.xaml?ID={0}", recipeID);
В обработчике щелчка кнопки-заколки я сначала проверяю, существует ли тайл для этой страницы, и, если нет, создаю его. Для этого используются данные текущего Recipe (рецепта): изображение и название. Я также задаю единственное статическое изображение и статический текст для обратной стороны тайла. В то же время я задействую способность кнопки перерисовывать себя, используя изображение открепленной заколки. С другой стороны, если тайл уже есть, я оказываюсь в обработчике щелчка, так как пользователь выбрал открепление тайла. В этом случае я удаляю тайл и перерисовываю кнопку, используя изображение заколки, как показано на рис. 11.
Рис. 11. Прикрепление и открепление страниц
private void PinUnpin_Click(object sender, RoutedEventArgs e){ tile = ShellTile.ActiveTiles.FirstOrDefault( x => x.NavigationUri.ToString().Contains(thisPageUri)); if (tile == null) { StandardTileData tileData = new StandardTileData { BackgroundImage = new Uri( thisRecipe.Photo, UriKind.RelativeOrAbsolute), Title = thisRecipe.Name, BackTitle = "Lovely Mangoes!", BackBackgroundImage = new Uri("Images/BackTile.png", UriKind.Relative) }; ImageBrush brush = (ImageBrush)PinUnpin.Background; brush.ImageSource = new BitmapImage(new Uri("Images/Unpin.png", UriKind.Relative)); PinUnpin.Background = brush; ShellTile.Create( new Uri(thisPageUri, UriKind.Relative), tileData); } else { tile.Delete(); ImageBrush brush = (ImageBrush)PinUnpin.Background; brush.ImageSource = new BitmapImage(new Uri("Images/Pin.png", UriKind.Relative)); PinUnpin.Background = brush; }}
Заметьте: если пользователь касается пальцем закрепленного тайла, чтобы получить страницу этого рецепта, а затем нажимает аппаратную кнопку Back, то выходит из приложения. Потенциально это может запутывать, потому что пользователь ожидает выхода из приложения только при нажатии кнопки Back, находясь на основной странице, но не на любой другой. В качестве альтернативы можно было бы предоставить некую разновидность кнопки Home (домой) на странице Recipe и разрешить пользователю последовательно вернуться на основную страницу приложения. Увы, это тоже может ввести в замешательство, потому что пользователь, перейдя на основную страницу и нажав Back, он вернулся бы на прикрепленную страницу Recipe, а не вышел бы из приложения. Из-за такого поведения механизма «возврата домой» следует хорошенько подумать, а стоит ли его вводить.
Интеграция XNA-игры
Вспомните, что изначально я создал приложение как решение Windows Phone Silverlight and XNA Application. Тем самым я получил три проекта. Для создания функциональности, не имеющей отношения к игре, я работал над основным проектом MangoApp. Проект GameLibrary выступает в роли мостика между Silverlight MangoApp и XNA GameContent. На него ссылается как проект MangoApp, так и проект GameContent. Дополнительных усилий не требуется. Чтобы интегрировать игру в мое Phone-приложение, нужно решить две главные задачи:
расширить класс GamePage в проекте MangoApp для включения всей игровой логики;асширить проект GameContent, чтобы он предоставлял изображения и звуки для игры (никаких изменений в коде не нужно).
Рассмотрим вкратце расширения, генерируемые Visual Studio для проекта, который интегрирует Silverlight и XNA. Первым делом обратите внимание на то, что в App.xaml объявляется SharedGraphicsDeviceManager. Он управляет совместным использованием экрана исполняющими средами Silverlight и XNA. Этот объект также является единственной причиной для наличия в проекте дополнительного класса AppServiceProvider. Данный класс используется для кеширования диспетчера устройства общей графики (shared graphics device manager), поэтому он доступен всем, кому это нужно в приложении, — как Silverlight, так и XNA. В классе App есть поле AppServiceProvider, и он также предоставляет некоторые дополнительные свойства для интеграции XNA: ContentManager и GameTimer. Все они инициализируются в новом методе InitializeXnaApplication, вместе с GameTimer, которое используется для «прокачки» очереди сообщений XNA.
Интересная работа заключается в том, как интегрировать XNA-игру в Phone-приложение Silverlight.
Интересная работа заключается в том, как интегрировать XNA-игру в Phone-приложение Silverlight. Сама по себе игра представляет меньший интерес. Поэтому в данном упражнении, вместо того чтобы тратить силы на написание полноценной игры с нуля, я адаптирую существующую, а именно: пример из учебного пособия по разработке игр на платформе XNA с сайта AppHub (bit.ly/h0ZM4o).
В моей адаптации имеется шейкер (для приготовления коктейлей), представленный в коде классом Player; он стреляет метательным предметом в приближающиеся плоды манго (это враги). Когда я попадаю в манго, он раскрывается и преобразуется в коктейль mangotini. Каждое попадание в манго увеличивает счет на 100 очков. Всякий раз, когда манго сталкивается с шейкером, его сила уменьшается на 10. Когда сила падает до 0, игра заканчивается. Кроме того, пользователь может закончить игру в любой момент, нажав кнопку аппаратную Back. Игра в действии показана на рис. 12.
Рис. 12. XNA-игра в действии
Мне не нужно вносить какие-либо изменения в почти пустой GamePage.xaml. Вместо этого вся работа пойдет в отделенном коде. Visual Studio генерирует стартовый код для этого класса GamePage, как описано в табл. 3.
Табл. 3. Стартовый код для GamePage
Поле/методОписаниеНеобходимые измененияContentManagerЗагружает/управляет временем жизни контента из конвейера контента (content pipeline)Добавить код, чтобы использовать этот метод для загрузки изображений и звуковGameTimerВ модели XNA игра выполняет действия, когда срабатывают события Update и Draw, и эти события управляются таймеромБез измененийSpriteBatchИспользуется для прорисовки текстур в XNAДобавить код, чтобы использовать этот метод в методе Draw для прорисовки игровых объектов (игрока, врагов, летящих предметов, взрывов и т. д.)GamePage ConstructorСоздает таймер и подключает его события Update и Draw к методам OnUpdate и OnDrawСохранить код таймера и дополнительно инициализировать игровые объектыOnNavigatedToНастраивает совместное использование графики между Silverlight и XNA, а потом запускает таймерСохранить код совместного использования и таймера; дополнительно загружать контент в игру, в том числе любое предыдущее состояние из изолированного хранилищаOnNavigatedFromОстанавливает таймер и отключает совместное использование графики XNAСохранить код совместного использования и таймера; дополнительно сохранять игровой счет и здоровье игрока в изолированном хранилищеOnUpdate(Пустой), обрабатывает событие GameTimer.UpdateДобавить код для расчета изменений игровых объектов (позиции игрока, количества и позиций врагов, летящих предметов и взрывов)OnDraw(Пустой), обрабатывает событие GameTimer.DrawДобавить код для прорисовки игровых объектов, вывода счета и здоровья игрока
Эта игра является прямой адаптацией примера из учебного пособия AppHub, в котором содержатся два проекта: проект игры Shooter и проект контента ShooterContent. Контент включает файлы изображений и звуков. Хотя это не влияет на код приложения, я могу изменить эти файлы для подстройки под тематику «манго» своего приложения, и для этого достаточно заменить PNG- и WAV-файлы. Все необходимые изменения кода вносятся в проект игры Shooter. Руководство по переносу из Game Class в Silverlight/XNA вы найдете на сайте AppHub по ссылке bit.ly/iHl3jz.
Для начала я должен скопировать файлы проекта игры Shooter в свой проект MangoApp. Кроме того, я копирую файлы контента ShooterContent в проект GameContent. В табл. 4 дано сводное описание существующих классов в проекте игры Shooter.
Табл. 4. Классы игры Shooter
КлассОписаниеНеобходимые измененияAnimationАнимирует различные спрайты в игре: игрока, вражеские объекты, летящие предметы и взрывыИсключить GameTimeEnemyСпрайт, представляющий вражеские объекты, по которым стреляет игрок. В моей адаптации это плоды мангоИсключить GameTimeGame1Управляющий класс для игрыОбъединить с классом GamePageParallaxingBackgroundАнимирует фоновые изображения облаков, создавая за счет параллакса кажущуюся объемностьНетPlayerСпрайт, представляющий игровой персонаж. В моей адаптации это шейкер для коктейлейИсключить GameTimeProgramПрименяется только для игр, ориентированных на Windows или XboxНе используется; можно удалитьProjectileСпрайт, представляющий летящие предметы, которыми игрок стреляет по врагамНет
Чтобы интегрировать эту игру в мое Phone-приложение, в класс GamePage нужно внести следующие изменения.
Скопировать все поля из класса Game1 в класс GamePage. Также скопировать поле инициализации в методе Game1.Initialize в конструктор GamePage.Скопировать метод LoadContent и все методы для добавления и обновления врагов, летящих предметов и взрывов. Никаких изменений в этих методах не требуется.Перевести код на использование вместо GraphicsDeviceManager свойства GraphicsDevice.Извлечь код в методах Game1.Update и Draw в обработчики событий таймера GamePage.OnUpdate и OnDraw.
Стандартная XNA-игра создает новый GraphicsDeviceManager, тогда как в Phone-приложении у меня уже есть SharedGraphicsDeviceManager, который предоставляет свойство GraphicsDevice, и этого более чем достаточно. Чтобы упростить картину, я буду кешировать ссылку на GraphicsDevice как поле в своем классе GamePage.
В стандартной XNA-игре методы Update и Draw являются переопределенными версиями виртуальных методов базового класса Microsoft.Xna.Framework.Game. Однако в интегрированном приложении Silverlight/XNA класс GamePage не наследует от XNA-класса Game, поэтому я должен абстрагировать код от методов Update и Draw и вставить их содержимое в обработчики событий OnUpdate и OnDraw. Заметьте, что некоторые классы игровых объектов (например, Animation, Enemy и Player), методы Update и Draw, а также ряд вспомогательных методов, вызываемых Update, принимают параметр GameTime. Он определен в Microsoft.Xna.Framework.Game.dll, и, если приложение Silverlight содержит любые ссылки на эту сборку, это, как правило, следует считать ошибкой. Параметр GameTime можно полностью заменить двумя свойствами Timespan — TotalTime и ElapsedTime, — предоставляемыми объектом GameTimerEventArgs, который передается в обработчики событий таймера OnUpdate и OnDraw. Ну а в остальном содержимое метода Draw можно перенести без изменений.
Исходный метод Update проверяет состояние GamePad и вызывает Game.Exit по условию. В интегрированном приложении Silverlight/XNA это не используется, поэтому переносить такую проверку в новый метод незачем:
//if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)//{// // this.Exit();//}
Новый метод Update теперь не просто обвязка, откуда вызываются другие методы для обновления игровых объектов. Я обновляю фон с эффектом параллакса даже по окончании игры, но игровой персонаж, враги, соударения, летящие предметы и взрывы обновляются, только если игровой персонаж жив. Соответствующие вспомогательные методы вычисляют количество и позиции различных игровых объектов. После исключения использования GameTime всех их можно перенести без изменений за одним исключением:
private void OnUpdate(object sender, GameTimerEventArgs e){ backgroundLayer1.Update(); backgroundLayer2.Update(); if (isPlayerAlive) { UpdatePlayer(e.TotalTime, e.ElapsedTime); UpdateEnemies(e.TotalTime, e.ElapsedTime); UpdateCollision(); UpdateProjectiles(); UpdateExplosions(e.TotalTime, e.ElapsedTime); }}
Метод UpdatePlayer нужно в небольшой оптимизации. В исходной версии игры, когда здоровье игрока падало до 0, оно восстанавливалось до 100, т. е. игра продолжалась вечно. В моей адаптации, когда здоровье игрока падает до 0, я устанавливаю флаг в false. Затем в методах OnUpdate и OnDraw проверяю значение этого флага. В OnUpdate значение флага определяет, надо ли далее вычислять изменения в объектах, а в OnDraw он указывает, что именно следует рисовать — игровые объекты или экран «game over» с финальным счетом:
private void UpdatePlayer(TimeSpan totalTime, TimeSpan elapsedTime){…unchanged code omitted for brevity. if (player.Health <= 0) { //player.Health = 100; //score = 0; gameOverSound.Play(); isPlayerAlive = false; }}Заключение
В этой статье мы рассмотрели, как разрабатывать приложения с применением нескольких новых средств в Windows Phone SDK 7.1: локальных баз данных, LINQ to SQL, вторичных тайлов и глубокого связывания, а также интеграцию Silverlight и XNA. В выпуске SDK 7.1 содержится гораздо больше новых средств и усовершенствований в существующих. Подробнее на эту тему см. следующие ссылки.
Что нового в Windows Phone SDK: bit.ly/c2RmNrТайлы: bit.ly/oQlu15Объединение Silverlight и XNA: bit.ly/p4RncQОбзор локальных баз данных для Windows Phone: bit.ly/l23UQM
Финальная версия приложения Mangolicious доступна в Windows Phone Marketplace по ссылке bit.ly/nuJcTA (для доступа потребуется программное обеспечение Zune). Обратите внимание, что пример использует Silverlight for Windows Phone Toolkit (можно бесплатно скачать по ссылке bit.ly/qiHnTT).
Асинхронное программирование долго было уделом лишь самых квалифицированных разработчиков с наклонностями мазохистов, причем тех, у которых было время, желание и умственные способности выстраивать обратные вызовы один за другим в нелинейном потоке управления. С появлением Microsoft .NET Framework 4.5 языки C# и Visual Basic откроют асинхронность для всех остальных, и теперь даже простые смертные смогут писать асинхронные методы почти так же легко, как синхронные. Никаких обратных вызовов. Никакого явного маршалинга кода из одного контекста синхронизации в другой. Никаких забот о передаче результатов или исключений. Никаких трюков, искажавших существующие языковые средства ради упрощения асинхронного программирования. Короче говоря, никакой мороки.
Конечно, хотя теперь легко приступить к написанию асинхронных методов (см. статьи Эрика Липперта и Мэдса Торгерсена в этом номере), делать это по-настоящему хорошо все равно можно только при понимании того, что происходит «за кулисами». Всякий раз, когда в языке или инфраструктуре создается более высокий уровень абстракции, на котором может программировать средний разработчик, этот уровень неизбежно влечет за собой скрытые издержки, влияющие на производительность. Во многих случаях такие издержки пренебрежимо малы, и огромное число разработчиков в большинстве ситуаций может и должно их игнорировать. Однако более продвинутым разработчикам по-прежнему полезно хорошо понимать, каковы эти издержки, чтобы предпринимать необходимые меры и избегать этих издержек, если они становятся заметными. Так обстоит дело и с асинхронными методами в C# и Visual Basic.
В этой статье я рассмотрю все тонкости асинхронных методов, чтобы вы хорошо понимали, как эти методы реализованы на внутреннем уровне. Заметьте: эта информация дается вовсе не для того, чтобы вы ради микроскопических оптимизаций и малозаметного выигрыша в производительности перелопатили свой читаемый код в нечто, что не подлежит сопровождению. Она просто поможет вам диагностировать любые проблемы, с которыми вы можете столкнуться; кроме того, я поясню, каким набором инструментов можно воспользоваться для преодоления таких потенциальных проблем. Также обратите внимание, что эта статья основана на предварительной версии .NET Framework 4.5 и скорее всего специфические детали реализации будут изменены перед финальным выпуском.
Выработка правильной умозрительной модели
Десятилетиями разработчики использовали высокоуровневые языки вроде C#, Visual Basic, F# и C++ для создания эффективных приложений. Из этого опыта у разработчиков сложилось четкое представление об издержках различных операций, и это знание было оформлено в соответствующие рекомендации по разработке. Например, в большинстве случаев использования вызов синхронного метода обходится сравнительно недорого и еще дешевле, если компилятор может встроить вызываемого (callee) по месту вызова (call site). Таким образом, разработчики научились разбивать код на небольшие, простые в сопровождении методы, которые, в целом, не требуют беспокоиться о каких либо отрицательных последствиях при увеличении частоты их вызова. У таких разработчиков сложилась умозрительная модель того, что подразумевается под вызовом метода.
С появлением асинхронных методов придется выстраивать новую умозрительную модель. Хотя языки C# и Visual Basic и их компиляторы способны создавать иллюзию того, что асинхронный метод ведет себя так же, как его синхронный эквивалент, на внутреннем уровне этот совсем не так. В конечном счете компилятор генерирует массу кода в интересах разработчика; по объему он подобен стереотипному коду, который разработчики, реализовавшие асинхронность в былые времена, вынуждены были писать и поддерживать вручную. Более того, код, сгенерированный компилятором, вызывает библиотечный код в .NET Framework, что опять же увеличивает объемы работы, выполняемой в интересах разработчика. Чтобы прийти к правильной умозрительной модели, а потом использовать ее при принятии соответствующих решений в процессе разработки, важно понимать, что именно компилятор генерирует в ваших интересах.
«Не болтайте лишнего»
При работе с синхронным кодом методы с пустыми телами обходятся практически бесплатно. В случае асинхронных методов это не так. Рассмотрим следующий асинхронный метод, в теле которого присутствует единственное выражение (и из-за отсутствия await-выражений оно в конечном счете будет выполняться синхронно):
public static async Task SimpleBodyAsync() { Console.WriteLine(”Hello, Async World!”);}
Декомпилятор промежуточного языка (IL) раскроет истинную природу этой функции после компиляции, сгенерировав вывод, аналогичный тому, что показано на рис. 1. Однострочное выражение превратилось в два метода, один из которых существует во вспомогательном классе конечного автомата. У интерфейсного метода (stub method) та же базовая сигнатура, что и у написанного разработчиком метода (этот метод называется так же, имеет ту же область видимости, принимает те же параметры и сохраняет тот же возвращаемый тип), но он не содержит никакого кода, написанного разработчиком. Вместо этого он содержит стереотипный подготовительный код (setup boilerplate). Этот код инициализирует конечный автомат, используемый для представления асинхронного метода, и запускает его вызовом дополнительного метода MoveNext в конечном автомате. Этот тип конечного автомата хранит состояние асинхронного метода, позволяя при необходимости сохранять его между await-точками. Кроме того, он содержит тело метода, написанное разработчиком, но перестроенное так, чтобы результаты и исключения можно было передавать в возвращаемый Task, запоминать текущую позицию в методе для возобновления выполнения в этом месте после await и т. д.
Рис. 1. Стереотипный код асинхронного метода
[DebuggerStepThrough]public static Task SimpleBodyAsync() { <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0(); d__.<>t__builder = AsyncTaskMethodBuilder.Create(); d__.MoveNext(); return d__.<>t__builder.Task;}[CompilerGenerated][StructLayout(LayoutKind.Sequential)]private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public void MoveNext() { try { if (this.<>1__state == -1) return; Console.WriteLine(”Hello, Async World!”); } catch (Exception e) { this.<>1__state = -1; this.<>t__builder.SetException(e); return; } this.<>1__state = -1; this.<>t__builder.SetResult(); } …}
Размышляя над тем, во что обходятся вызовы асинхронных методов, не забывайте об этом стереотипном коде. Блок try/catch в методе MoveNext скорее всего не позволит JIT-компилятору подставить его в место вызова, поэтому мы теперь имеем, как минимум, издержки вызова метода там, где в случае синхронности их не было бы (при таком малом теле метода). Мы также получаем множество вызовов процедур из инфраструктуры .NET Framework (например, SetResult). И еще массу операций записи в поля типа конечного автомата. Конечно, все это нужно взвесить против издержек Console.WriteLine, которые, по-видимому, будут доминировать над всеми остальными издержками (он захватывает блокировки, выполняет ввод-вывод и др.). Более того, обратите внимание на оптимизации, выполняемые за вас инфраструктурой. Например, тип конечного автомата является структурой (struct). Эта struct будет упаковываться и передаваться (boxed) в кучу, только если этому методу когда-либо потребуется приостанавливать свое исполнение из-за ожидания выполнения экземпляра, а в этом простом методе такого никогда не случится. Сам по себе стереотипный код данного асинхронного метода не вызовет никаких операций создания объектов. Ну а компилятор и исполняющая среда в тесном взаимодействии минимизируют количество таких операций в инфраструктуре.
.NET Framework пытается генерировать эффективные асинхронные реализации асинхронных методов.
Когда не следует использовать async
.NET Framework пытается генерировать эффективные асинхронные реализации асинхронных методов, применяя множество оптимизаций. Однако у разработчиков зачастую есть знания конкретной предметной области, которые могут подтолкнуть их к оптимизациям, весьма рискованным для автоматического применения компилятором и исполняющей средой, учитывая универсальность, на которую они ориентируются. Памятуя об этом, разработчик на практике может выиграть, отказавшись от асинхронных методов в определенных случаях, особенно в случае библиотечных методов, доступ к которым требует большей гибкости. Как правило, метод может выполняться синхронно, когда известно, что нужные ему данные уже доступны.
Проектируя асинхронные методы, разработчики .NET Framework потратили много времени на минимизацию операций создания объектов. Дело в том, что такие операции влекут за собой самые большие издержки в инфраструктуре асинхронных методов. Сам по себе акт создания объекта обычно обходится недорого. Создание объектов аналогично наполнению товарами корзины покупателя в том смысле, что вы не тратите особых усилий, помещая их в корзину; но когда дело доходит до оплаты, вы должны достать кошелек и выложить за товары приличную сумму. Хотя операции создания обычно вызывают небольшие издержки, инициируемый ими сбор мусора может оказаться стопором для производительности приложения. Сбор мусора включает сканирование некоторой части объектов, созданных на данный момент, и поиск тех из них, на которые больше нет ссылок. Чем больше объектов создано, тем дольше будет проходить эта процедура. Более того, чем крупнее созданные объекты и чем больше их количество, тем чаще сбор мусора. Таким образом, операции создания объектов оказывают глобальное воздействие на систему: чем больше мусора генерируется асинхронными методами, тем медленнее выполняется программа в целом, даже если микротесты самих асинхронных методов не показывают значимых издержек.
В случае асинхронных методов, которые действительно передают управление (из-за ожидания объекта, еще не завершившего свою работу), инфраструктуре приходится создавать объект Task для возврата из такого метода, поскольку Task служит уникальной ссылкой для данного конкретного вызова. Однако многие вызовы асинхронных методов могут завершаться безо всякой передачи управления. Тогда инфраструктура асинхронного метода может вернуть кешированный, уже выполненный Task — тот, который можно использовать снова и снова, избегая создания ненужных объектов Task. Однако такое возможно лишь в ограниченных случаях, например когда асинхронный метод является не обобщенным Task, Task<Boolean> или Task<TResult>, где TResult — ссылочный тип и результат асинхронного метода равен null. Хотя в будущем этот набор случаев может быть расширен, нередко большего эффекта можно добиться, если вы знаете предметную область, к которой относится реализуемая операция.
Рассмотрим реализацию типа вроде MemoryStream. MemoryStream наследует от Stream, а значит, может переопределять новые методы ReadAsync, WriteAsync и FlushAsync класса Streams в .NET 4.5, чтобы предоставить реализации, оптимизированные именно для MemoryStream. Поскольку операция чтения выполняется применительно к буферу в памяти, а значит, является простой операцией копирования памяти, максимальная производительность достигается, когда ReadAsync выполняется синхронно. Реализация такого варианта с помощью асинхронного метода выглядела бы примерно так:
public override async Task<int> ReadAsync( byte [] buffer, int offset, int count, CancellationToken cancellationToken){ cancellationToken.ThrowIfCancellationRequested(); return this.Read(buffer, offset, count);}
Ничего сложного. А поскольку Read — синхронный вызов и в этом методе нет await-выражений, которые передавали бы управление, все вызовы ReadAsync будут на самом деле выполняться синхронно. Теперь посмотрим на стандартный шаблон использования потоков данных (streams), например на операцию копирования:
byte [] buffer = new byte[0x1000];int numRead;while((numRead = await source.ReadAsync( buffer, 0, buffer.Length)) > 0){ await source.WriteAsync(buffer, 0, numRead);}
Заметьте, что здесь ReadAsync потока-источника в данной серии вызовов всегда запускается с одним и тем же параметром count (длина буфера), и поэтому очень высока вероятность, что возвращаемое значение (число считанных байтов) тоже будет повторяться. Кроме некоторых редких случаев, крайне маловероятно, что асинхронная реализация метода ReadAsync сможет использовать кешированный Task для своего возвращаемого значения, но вы — сможете.
Разработчики .NET Framework потратили много времени на минимизацию операций создания объектов.
Подумайте о том, чтобы переписать этот метод так, как показано на рис. 2. Используя преимущества специфических аспектов этого метода в наиболее частых сценариях применения, мы теперь сократили операции создания объектов до минимума — ожидать этого от нижележащей инфраструктуры не следует. Благодаря тому, что при каждом вызове ReadAsync получает одно и то же число байтов, мы полностью избавляемся от связанных с методом ReadAsync издержек создания объектов, возвращая тот же Task, что и в предыдущем вызове. А для такой низкоуровневой операции, которая часто повторяется и должна выполняться очень быстро, подобная оптимизация дает весьма ощутимую разницу, особенно в частоте процедур сбора мусора.
Рис. 2. Минимизация операций создания Task
private Task<int> m_lastTask;public override Task<int> ReadAsync( byte [] buffer, int offset, int count, CancellationToken cancellationToken){ if (cancellationToken.IsCancellationRequested) { var tcs = new TaskCompletionSource<int>(); tcs.SetCanceled(); return tcs.Task; } try { int numRead = this.Read(buffer, offset, count); return m_lastTask != null && numRead == m_lastTask.Result ? m_lastTask : (m_lastTask = Task.FromResult(numRead)); } catch(Exception e) { var tcs = new TaskCompletionSource<int>(); tcs.SetException(e); return tcs.Task; }}
Соответствующая оптимизация для предотвращения операций создания объектов возможна, когда сценарий применения диктует необходимость кеширования. Рассмотрим метод, задача которого — скачивать содержимое определенной веб-страницы, а затем кешировать успешно загруженный контент для последующих обращений. Такую функциональность можно написать с использованием следующего асинхронного метода (с применением новой библиотеки System.Net.Http.dll в .NET 4.5):
private static ConcurrentDictionary<string,string> s_urlToContents;public static async Task<string> GetContentsAsync(string url){ string contents; if (!s_urlToContents.TryGetValue(url, out contents)) { var response = await new HttpClient().GetAsync(url); contents = response.EnsureSuccessStatusCode(). Content.ReadAsString(); s_urlToContents.TryAdd(url, contents); } return contents;}
Это прямолинейная реализация. А для вызовов GetContentsAsync, которые нельзя обслужить из кеша, издержки конструирования нового Task<string>, представляющего эту операцию скачивания, будут пренебрежимо малы в сравнении с издержками, относящимися к сети. Однако в случаях, где вызовы могут быть обслужены из кеша, эти издержки будут не столь малыми — объект создается только для того, чтобы обернуть доступные данные и вернуть их вызвавшему.
Чтобы избежать таких издержек (если это необходимо для соответствия вашим требованиям к производительности), вы могли бы переписать этот метод, как показано на рис. 3. Теперь у нас два метода: синхронный открытый и асинхронный закрытый, которому осуществляется делегирование от открытого метода. Словарь кеширует сгенерированные задачи, а не их содержимое, поэтому будущие попытки скачать ранее загруженную страницу могут обслуживаться простым обращением к словарю и возвратом уже существующей задачи. На внутреннем уровне мы также используем преимущества методов ContinueWith в Task, позволяющих нам сохранять задачу в словарь, как только Task завершается, но только при условии успешного скачивания. Конечно, этот код сложнее и требует большей продуманности при написании и сопровождении, поэтому, как и в случае любых других оптимизаций производительности, не тратьте на них время, пока тестирование производительности не докажет их необходимость. Будет ли заметна разница от подобных оптимизаций, зависит в основном от сценариев применения. Вам следует провести набор тестов, отражающих типичные шаблоны применения, и после анализа результатов решить, дают ли эти оптимизации ощутимый эффект для производительности вашего кода.
Рис. 3. Самостоятельное кеширование объектов Task
private static ConcurrentDictionary<string,Task<string>> s_urlToContents;public static Task<string> GetContentsAsync(string url) { Task<string> contents; if (!s_urlToContents.TryGetValue(url, out contents)) { contents = GetContentsAsync(url); contents.ContinueWith(delegate { s_urlToContents.TryAdd(url, contents); }, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuatOptions.ExecuteSynchronously, TaskScheduler.Default); } return contents;}private static async Task<string> GetContentsAsync(string url){ var response = await new HttpClient().GetAsync(url); return response.EnsureSuccessStatusCode(). Content.ReadAsString();}
Другая оптимизация, связанная с задачей, о которой стоит поразмыслить: а нужно ли вам вообще возвращать Task из асинхронного метода? C# и Visual Basic поддерживают создание асинхронных методов, возвращающих void, и в этом случае для метода не создается Task. Асинхронные методы, предоставляемые из библиотек для общего пользования, всегда следует писать так, чтобы они возвращали Task или Task<TResult>, потому что разработчик библиотеки не знает, будет ее пользователь ждать завершения работы конкретного метода или нет. Однако в некоторых сценариях внутреннего использования асинхронные методы, возвращающие void, могут оказаться полезными. Основная причина, по которой введены асинхронные методы, возвращающие void, — поддержка существующих сред, управляемых событиями, например ASP.NET и Windows Presentation Foundation (WPF). Они упрощают реализацию обработчиков кнопок, событий загрузки страниц и тому подобного через использование async и await. Если вы решились на использование асинхронного метода, возвращающего void, будьте очень осторожны в обработке исключений: исключения, выходящие за рамки асинхронного void-метода, попадают в тот SynchronizationContext, который был текущим на момент вызова этого асинхронного void-метода.
Вам следует провести набор тестов, отражающих типичные шаблоны применения.
Учитывайте контекст
Разновидностей контекста в .NET Framework много: LogicalCallContext, SynchronizationContext, HostExecutionContext, SecurityContext, ExecutionContext и др. (Исходя из их количества вы могли подумать, будто разработчики .NET Framework материально заинтересованы во введении новых контекстов, но уверяю вас, это не так.) Некоторые из этих контекстов очень подходят для асинхронных методов — не только по функциональности, но и по своему влиянию на их производительность.
SynchronizationContext Играет большую роль в асинхронных методах. Контекст синхронизации — это просто абстракция поверх возможности выполнять маршалинг вызова делегата способом, специфическим для конкретной библиотеки или инфраструктуры. Например, в WPF имеется DispatcherSynchronizationContext, представляющий UI-поток для Dispatcher: передача делегата в этот контекст синхронизации приводит к тому, что данный делегат ставится Dispatcher в очередь на выполнение в своем потоке. В ASP.NET содержится AspNetSynchronizationContext, гарантирующий, что асинхронные операции, встретившиеся в процессе обработки ASP.NET-запроса, выполняются последовательно и сопоставляются с корректным состоянием HttpContext. И так далее. Итого в .NET Framework около 10 конкретных реализаций SynchronizationContext — некоторые из них общедоступны, другие являются внутренними.
При ожидании на объектах Task и других ожидаемых (awaitable) типах, предоставляемых .NET Framework, ждущие (awaiters) возврата этих типов (например, TaskAwaiter) захватывают текущий SynchronizationContext на момент инициации ожидания через await. Если текущий SynchronizationContext был захвачен, по завершении выполнения ожидаемого объекта в этот контекст передается продолжение (continuation), которое представляет оставшуюся часть асинхронного метода. Благодаря этому разработчику, пишущему асинхронный метод, который вызывается из UI-потока, не нужно вручную выполнять маршалинг вызовов обратно в UI-поток, чтобы модифицировать содержимое UI-элементов: маршалинг осуществляется автоматически самой инфраструктурой .NET Framework.
Увы, этот маршалинг тоже создает издержки. Для разработчиков приложений, использующих await при реализации своего потока управления, этот автоматический маршалинг почти всегда является правильным решением. Однако библиотеки — это другая история. Разработчикам приложений автоматический маршалинг обычно нужен потому, что их код чувствителен к контексту, в котором он выполняется, например чтобы у него была возможность обращаться к UI-элементам или к HttpContext, соответствующему данному ASP.NET-запросу. Но в большинстве библиотек этого ограничения нет. В результате автоматический маршалинг зачастую приводит к совершенно ненужным издержкам. Рассмотрим вновь показанный ранее код для копирования байтов из одного потока данных в другой:
byte [] buffer = new byte[0x1000];int numRead;while((numRead = await source.ReadAsync( buffer, 0, buffer.Length)) > 0){ await source.WriteAsync(buffer, 0, numRead);}
Если эта операция копирования запускается из UI-потока, то каждая ожидаемая операция чтения и записи будет принудительно возвращать выполнение обратно в UI-поток. В случае мегабайта исходных данных и объектов Stream, выполняющих операции чтения и записи асинхронно (что делается чаще всего), это означает свыше 500 переходов из фоновых потоков в UI-поток. Для решения этой проблемы типы Task и Task<TResult> предоставляют метод ConfigureAwait. Он принимает булев параметр continueOnCapturedContext, управляющий поведением маршалинга. Если он равен true (по умолчанию), await-выражение будет автоматически завершаться в захваченном SynchronizationContext. А если указывается false, то SynchronizationContext игнорируется и инфраструктура пытается продолжить выполнение с того места, где оно было прервано предыдущей асинхронной операцией. Включив этот код копирования потоков данных, вы получите более эффективную версию:
byte [] buffer = new byte[0x1000];int numRead;while((numRead = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait( false)) > 0){ await source.WriteAsync(buffer, 0, numRead). ConfigureAwait(false);}
Для разработчиков библиотек одного этого фактора достаточно для того, чтобы всегда использовать ConfigureAwait, кроме редких случаев, когда библиотеке известна предметная область среды, где она будет применяться, и действительно нужно выполнять тело метода с доступом к корректному контексту.
Помимо производительности, есть и другая причина использовать ConfigureAwait в библиотечном коде. Допустим, предыдущий код без ConfigureAwait находился в методе CopyStreamToStreamAsync, который вызвали из WPF UI-потока следующим образом:
private void button1_Click(object sender, EventArgs args) { Stream src = …, dst = …; Task t = CopyStreamToStreamAsync(src, dst); t.Wait(); // взаимоблокировка!}
Здесь разработчик должен был бы написать button1_Click как асинхронный метод, а затем ожидать Task вместо использования его синхронного метода Wait. У этого метода есть важные области применения, но он почти никогда не годится для такого ожидания в UI-потоке. Wait не возвращает управление, пока не завершится Task. В случае CopyStreamToStreamAsync включенные await-выражения пытаются возвращаться (через Post) в захваченный SynchronizationContext, и этот метод не может завершиться, пока не закончится выполнение Post (поскольку объекты Post используются для обработки оставшейся части метода). Но эти объекты Post не завершаются из-за того, что UI-поток, который должен был бы их обработать, блокирован в вызове Wait. Получается круговая зависимость, приводящая к взаимоблокировке. Если бы CopyStreamToStreamAsync был написан с использованием ConfigureAwait(false), круговой зависимости не возникло бы и соответственно не было бы взаимоблокировки.
У метода Wait есть важные области применения, но он почти никогда не годится для ожидания в UI-потоке.
ExecutionContext Является неотъемлемой частью .NET Framework, хотя большинство разработчиков пребывает в блаженном неведении его существования. ExecutionContext — прадедушка контекстов, которые инкапсулирует несколько других контекстов вроде SecurityContext и LogicalCallContext, и представляет все, что должно происходить автоматически между асинхронными точками в коде. Всякий раз, когда вы использовали ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync или любую другую асинхронную операцию в .NET Framework, «за кулисами» по возможности захватывался ExecutionContext (через ExecutionContext.Capture), и этот контекст потом применялся для обработки предоставленного делегата (через ExecutionContext.Run). Например, если бы код, вызывающий ThreadPool.QueueUserWorkItem, подменял в этот момент некую Windows-идентификацию, та же идентификация использовалась бы и при выполнении переданного делегата WaitCallback. А если бы код, вызывающий Task.Run, впервые сохранял данные в LogicalCallContext, те же данные были бы доступны через LogicalCallContext внутри переданного делегата Action. ExecutionContext также используется между ожиданиями на задачах.
Вместо этого в .NET Framework включено множество оптимизаций, предотвращающих захват и выполнение в захваченном контексте ExecutionContext в отсутствие необходимости, потому что подобные операции обходятся весьма дорого. Однако операции наподобие подмены (олицетворения) некоей Windows-идентификации или сохранение данных в LogicalCallContext помешают этим оптимизациям. Таким образом, если вы будете избегать операций, манипулирующих ExecutionContext, таких как WindowsIdentity.Impersonate и CallContext.LogicalSetData, вы добьетесь более высокой производительности при использовании как асинхронных методов, так и асинхронности в целом.
Минимизируем частоту сбора мусора
Асинхронные методы создают отличную иллюзию, когда дело доходит до локальных переменных. В синхронных методах, написанных на C# и Visual Basic, локальные переменные размещаются в стеке, поэтому выделять память в куче под эти переменные не нужно. Однако в асинхронных методах стек для метода исчезает, когда асинхронный метод приостанавливается в точке ожидания. Чтобы данные были доступны методу после возобновления, их надо где-то хранить. В связи с этим компиляторы C# и Visual Basic «поднимают» локальные переменные в структуру конечного автомата, которая потом упаковывается и помещается в кучу, как только первое await-выражение вызывает ожидание. Делается это для того, чтобы локальные переменные могли сохраниться между точками ожидания.
Чем крупнее создаваемые объекты, тем чаще требуется сбор мусора.
Ранее в этой статье мы обсуждали, какое влияние на издержки и частоту сбора мусора оказывает количество создаваемых объектов, а также их размер. Чем крупнее создаваемые объекты, тем чаще требуется сбор мусора. То же самое относится и к количеству локальных переменных в асинхронном методе, которые нужно переместить в кучу.
На момент написания этой статьи компиляторы C# и Visual Basic иногда «поднимали» больше, чем реально требовалось. Рассмотрим, к примеру, такой фрагмент кода:
public static async Task FooAsync() { var dto = DateTimeOffset.Now; var dt = dto.DateTime; await Task.Yield(); Console.WriteLine(dt);}
Переменная dto вообще не читается после точки ожидания, поэтому значение, записанное в нее до await-выражения, не нужно сохранять между точками ожидания. Однако тип конечного автомата, генерируемый компилятором для хранения локальных переменных, все равно содержит ссылку на dto, как показано на рис. 4.
Рис. 4. Подъем локальных переменных
[StructLayout(LayoutKind.Sequential), CompilerGenerated]private struct <FooAsync>d__0 : <>t__IStateMachine { private int <>1__state; public AsyncTaskMethodBuilder <>t__builder; public Action <>t__MoveNextDelegate; public DateTimeOffset <dto>5__1; public DateTime <dt>5__2; private object <>t__stack; private object <>t__awaiter; public void MoveNext(); [DebuggerHidden] public void <>t__SetMoveNextDelegate(Action param0);}
Это увеличивает размер объекта в куче чуть больше реально необходимого. Если вы обнаружите, что сбор мусора выполняется чаще, чем ожидалось, проанализируйте, действительно ли вам требуются все временные переменные, которые вы включили в свой асинхронный метод. Этот пример можно было бы переписать следующим образом, чтобы исключить появления лишнего поля в классе конечного автомата:
public static async Task FooAsync() { var dt = DateTimeOffset.Now.DateTime; await Task.Yield(); Console.WriteLine(dt);}
Более того, в .NET сборщик мусора (garbage collector, GC) учитывает поколения объектов (generational collector), а это означает, что он разделяет объекты на группы, называемые поколениями: на верхнем уровне новые объекты создаются в поколении 0, а затем все объекты, пережившие сбор мусора, переводятся в другое поколение (.NET GC в настоящее время использует поколения 0, 1 и 2). Это ускоряет операции сбора мусора, позволяя GC часто собирать только некое подмножество известного пространства объектов. В основе лежит концепция того, что надобность в только что созданных объектах быстро отпадает, а объекты, существующие длительное время, пока останутся. То есть, если объект переживает поколение 0, он скорее всего будет существовать еще какое-то время, продолжая в течение этого срока расходовать системные ресурсы. А значит, на самом деле требуется обеспечить, чтобы объекты становились доступными сбору мусора, как только необходимость в них исчезает.
При вышеупомянутом подъеме локальные переменные передаются в поля класса, который существует на время выполнения асинхронного метода (пока ожидаемый объект должным образом поддерживает ссылку на делегат, запускаемый по окончании ожидаемой операции). В синхронных методах JIT-компилятор может отслеживать моменты, когда к локальным переменным больше никогда не будет обращений, и в такие моменты может помочь GC игнорировать эти переменные как корневые (roots), тем самым делая объекты, на которые они ссылались, доступными для сбора мусора, если на них нет ссылок где-то еще. Однако в асинхронных методах ссылки на эти локальные переменные остаются, а значит, объекты, на которые они ссылаются, могут прожить гораздо дольше, чем в том случае, если бы они были настоящими локальными переменными. Если вы обнаруживаете, что объекты остаются существовать в течение более длительного времени, чем используются, подумайте о присваивании null локальным переменным, ссылающимся на такие объекты, когда вы заканчиваете работу с ними. И вновь делать это стоит лишь в том случае, если они действительно создают проблему для производительности, а иначе усложнять код не имеет смысла. Более того, компиляторы C# и Visual Basic в финальной версии или в ближайшей перспективе могут быть модифицированы для обработки большего круга таких сценариев в интересах разработчика, а значит, любой такой код, написанный сегодня, в будущем скорее всего устареет.
Избегайте усложнения
Компиляторы C# и Visual Basic весьма впечатляют в том плане, где можно использовать await-выражения: почти везде. Await-выражения можно включать в более сложные выражения, что позволяет ожидать экземпляры Task<TResult> в тех местах, где у вас могут быть любые другие выражения, возвращающие значения. Например, рассмотрим код, который возвращает сумму результатов трех задач:
public static async Task<int> SumAsync( Task<int> a, Task<int> b, Task<int> c){ return Sum(await a, await b, await c);}private static int Sum(int a, int b, int c){ return a + b + c;}
Компилятор C# позволяет использовать выражение «await b» как аргумент функции Sum. Однако здесь в Sum передаются как параметры результаты нескольких await-выражений; из-за правил оценки и того, как async реализовано в компиляторе, конкретно этот пример требует от компилятора временно сохранять результаты первых двух await-выражений. Как вы уже видели, локальные переменные сохраняются между точками ожидания поднятием в поля класса конечного автомата. Но в случаях, подобных этому, значения находятся в CLR-стеке оценки (CLR evaluation stack), откуда они не поднимаются в конечный автомат; вместо этого они записываются в один временный объект, а затем конечный автомат ссылается на них. Когда ожидание на первой задаче завершается и происходит переход ко второй, компилятор генерирует код, упаковывающий первый результат и сохраняющий упакованный объект в единое поле <>t__stack конечного автомата. По окончании ожидания на второй задаче и переходе к ожиданию на третьей компилятор генерирует код, который создает Tuple<int,int> из первых двух значений, сохраняя этот Tuple в том же поле <>__stack. Все это означает, что — в зависимости от того, как вы пишете свой код, — вы можете получить совершенно разные шаблоны создания объектов. Поэтому лучше написать SumAsync так:
public static async Task<int> SumAsync( Task<int> a, Task<int> b, Task<int> c){ int ra = await a; int rb = await b; int rc = await c; return Sum(ra, rb, rc);}
Чем меньше await-выражений вам приходится обрабатывать, тем лучше.
При этом изменении компилятор сгенерирует в классе конечного автомата три дополнительных поля для хранения ra, rb и rc, и никакой разбивки не будет. Соответственно возникает дилемма: больший класс конечного автомата с меньших количеством операций создания объектов или меньший класс конечного автомата с большим количеством операций создания объектов. Общее количество занимаемой памяти будет больше во втором случае, так как под каждый создаваемый объект нужно выделять свою память, но тестирование конечной производительности может показать, что даже в этом случае она все равно выше. В целом, как уже упоминалось, не следует тратить время на микро-оптимизации такого рода, если только вы не обнаружите, что операции создания объектов действительно создают проблемы для производительности. Однако знать, откуда исходят эти операции, полезно в любом случае.
Конечно, в предыдущих примерах кроются куда большие издержки, о которых вы должны знать и которые нужно учитывать. Этот код не может вызвать Sum, пока не завершатся все три await-выражения, и между этими await-выражениями никакой работы не выполняется. Каждое из этих await-выражений требует приличного объема закулисной работы, поэтому чем меньше await-выражений вам приходится обрабатывать, тем лучше. А раз так, то лучше скомбинировать все три await-выражения в одно и ждать сразу все задачи с помощью Task.WhenAll:
public static async Task<int> SumAsync( Task<int> a, Task<int> b, Task<int> c){ int [] results = await Task.WhenAll(a, b, c); return Sum(results[0], results[1], results[2]);}
Здесь метод Task.WhenAll возвращает Task<TResult[]>, который не завершается, пока не будут завершены все переданные задачи, и он работает гораздо эффективнее, чем просто ждет на каждой индивидуальной задаче. Он также собирает результаты ото всех задач и сохраняет их в массиве. Если вы хотите избежать использования массива, то можете указать привязку к необобщенному методу WhenAll, работающему с Task вместо Task<TResult>. Для максимальной производительности вы могли бы также использовать гибридный подход, где сначала проверяется, все ли задачи успешно завершились, и, если да, их результаты получаются индивидуально; но в ином случае вы ждали бы с помощью WhenAll еще не завершенные задачи. Это избавило бы вас от любых операций создания, связанных с вызовом WhenAll, когда в них нет нужды, например от создания массива params, передаваемого в метод. И как уже говорилось, желательно, чтобы эта библиотечная функция также подавляла маршалинг контекста. Такое решение показано на рис. 5.
Рис. 5. Применение нескольких оптимизаций
public static Task<int> SumAsync( Task<int> a, Task<int> b, Task<int> c){ return (a.Status == TaskStatus.RanToCompletion && b.Status == TaskStatus.RanToCompletion && c.Status == TaskStatus.RanToCompletion) ? Task.FromResult(Sum(a.Result, b.Result, c.Result)) : SumAsyncInternal(a, b, c);}private static async Task<int> SumAsyncInternal( Task<int> a, Task<int> b, Task<int> c){ await Task.WhenAll((Task)a, b, c).ConfigureAwait(false); return Sum(a.Result, b.Result, c.Result);}Асинхронность и производительность
Асинхронные методы — мощный инструмент повышения эффективности труда разработчиков, упрощающий написание масштабируемых и быстро отвечающих библиотек и приложений. Однако важно учитывать, что асинхронность не является оптимизацией производительности для индивидуальной операции. Если вы просто возьмете синхронную операцию и превратите ее в асинхронную, то производительность этой отдельно взятой операции неизбежно упадет, так как ей по-прежнему придется выполнять все, что синхронная операция, но теперь с дополнительными ограничениями и издержками.
Асинхронность ориентирована на повышение совокупной производительности: как работает ваша система в целом, когда вы записываете все асинхронно, чтобы, например, система занималась другой работой, пока осуществляется ввод-вывод. Это позволяет повысить эффективность использования системы, расходуя ценные ресурсы, лишь когда они действительно нужны для выполнения. Реализация асинхронных методов в .NET Framework тщательно оптимизирована и зачастую дает не меньшую и даже более высокую производительность, чем индивидуальные реализации асинхронных операций, использующие существующие шаблоны и требующие гораздо больше кода. С этого момента, планируя разработку асинхронного кода в .NET Framework, вы должны выбирать асинхронные методы. Тем не менее, как разработчику вам будет полезно понимать все, что делает .NET Framework в ваших интересах в этих асинхронных методах, чтобы при необходимости вы могли добиться максимально эффективного результата.
Все большее количество компаний внедряет решения Microsoft Dynamics CRM 4.0 и обнаруживает необходимость в создании внешних приложений, которые можно интегрировать с существующим API на основе веб-сервисов.
Создание приложений Silverlight для взаимодействия с Microsoft Dynamics CRM 4.0 (далее CRM 4.0 для краткости) может оказаться непростой задачей из-за асинхронной природы вызовов Silverlight и ее неспособности напрямую вызывать веб-сервисы CRM 4.0. В этой статье подробно поясняется, как создать приложение Silverlight, которое может читать и записывать данные через CRM 4.0 Web service API.
Обзор решения
Silverlight 4 и CRM 4.0 — мощные технологии, но их не так-то просто интегрировать. Я расскажу о том, что стоит за этой интеграцией, исследовав асинхронное взаимодействие между Silverlight и CRM 4.0 Web service API.
В типичных приложениях, не имеющих отношения к Silverlight, вызов веб-сервиса осуществляется синхронно: он вызывается, и приложение ждет, пока не будет получен ответ. В течение этого времени пользователь не может взаимодействовать с приложением и должен ждать окончания вызова. В асинхронном приложении, например на основе Silverlight, вызов сервиса выдается, но приложение остается полностью функциональным даже до получения ответа. Это значительно улучшает удобство использования приложения, но предъявляет более высокие требования к разработчику.
Чтобы понять, как осуществляется это взаимодействие, я представлю несколько примеров. Для начала мы посмотрим, как настроить приложение Silverlight, и пошагово пройдем все операции, необходимые для взаимодействия с веб-сервисом, который будет действовать как оболочка CRM 4.0 API. Затем мы изучим детали работы с CRM 4.0 API и обсудим, как читать и записывать данные с использованием оболочки — веб-сервиса. Мы поработаем с базовой сущностью System User в CRM 4.0, а также рассмотрим работу с динамическими сущностями. Наконец, вы узнаете, как обращаться с наборами результатов, возвращаемых в Silverlight. В итоге вы сможете легко интегрировать свои приложения Silverlight с CRM 4.0.
Создание приложения Silverlight 4
Чтобы сконфигурировать приложение Silverlight на взаимодействие с CRM 4.0, потребуется выполнить ряд операций. Хотя можно ссылаться на CRM 4.0 SDK или Web service API прямо из проекта Silverlight, большинство интерфейсов и методов на самом деле из кода вызываться не будет. Для взаимодействия с CRM 4.0 API нужно создать оболочку в виде веб-сервиса. Этот веб-сервис станет посредником при вызовах между приложением Silverlight и CRM 4.0 API в формате, который будет понятен Silverlight. Оболочку в виде веб-сервиса можно добавить прямо в приложение SilverlightCRMDemo.Web.
Начните с создания нового решения Silverlight в Visual Studio 2010 и назовите его CRM40SilverlightDemo. При создании приложения Silverlight в Visual Studio всегда генерируются два проекта. Один является базовым приложением Silverlight, а второй — приложением ASP.NET, встраивающим приложение Silverlight в веб-страницу. Это приложение ASP.NET также будет хостом для веб-сервиса, который будет взаимодействовать с CRM 4.0 API. Приложение Silverlight будет ссылаться на этот веб-сервис через Service Reference.
Чтобы создать оболочку в виде веб-сервиса, добавьте новый веб-сервис в приложение ASP.NET и назовите его CrmServiceWrapper. В этом примере нужно добавить в сервис два веб-метода: один — для получения CRM-данных, а второй — для их отправки. На рис. 1 показано, как на данном этапе должны выглядеть эти методы. Когда вы добьетесь успешного взаимодействия приложения Silverlight с этим сервисом-оболочкой, вы модифицируете эти методы для реального вызова CRM 4.0 API.
Рис. 1. Заглушки веб-методов
public class CrmServiceWrapper : System.Web.Services.WebService{ [WebMethod] public string GetCRMData() { return “This is the stubbed return for retrieving”; } [WebMethod] public string PostCRMData() { return “This is the stubbed return for posting data”; }}
После включения веб-сервиса в приложение ASP.NET самый простой способ добавить ссылку на него из приложения Silverlight — запустить его в отладочном режиме и получить URL, с которого отладчик запускает приложение ASP.NET (вы можете скопировать URL из всплывающего окна браузера). Теперь вы можете добавить новую Service Reference с именем CrmServiceReference в приложение Silverlight и вставить этот URL. Все релевантные конфигурационные файлы и код будут автоматически скорректированы. Если вы решите не делать этого, вам придется иметь дело с исключениями ссылок между доменами и немного повозиться с настройкой, чтобы успешно отладить свое приложение.
Теперь, когда у вас есть ссылка, можно приступать к реальному кодированию в приложении Silverlight. Код должен подключать обработчик событий для каждого веб-метода и создавать два метода для обработки данных по завершении вызовов этих веб-методов. Код, показанный на рис. 2, можно напрямую вставить в файл MainPage.xaml.cs приложения Silverlight. Запуск этого файла приведет к одновременному выполнению обоих методов.
Рис. 2. Добавление кода в MainPage.xaml.cs
using CRM40SilverlightDemo.CrmServiceReference; public partial class MainPage : UserControl{ public MainPage() { InitializeComponent(); // Call the GetCRMData Web method. CrmServiceWrapperSoapClient proxyGet = new CrmServiceWrapperSoapClient(); proxyGet.GetCRMDataCompleted += new EventHandler<GetCRMDataCompletedEventArgs>(proxy_GetCRMDataCompleted); proxyGet.GetCRMDataAsync(); // Call the PostCRMData Web method. CrmServiceWrapperSoapClient proxyPost = new CrmServiceWrapperSoapClient(); proxyPost.PostCRMDataCompleted += new EventHandler<PostCRMDataCompletedEventArgs>(proxy_PostCRMDataCompleted); proxyPost.PostCRMDataAsync(); } // Called asynchronously when the GetCRMData Web method returns data. void proxy_GetCRMDataCompleted(object sender, GetCRMDataCompletedEventArgs e) { // Do something with the data returned. string result = e.Result.ToString(); } // Called asynchronously when the PostCRMData Web method returns data. void proxy_PostCRMDataCompleted(object sender, PostCRMDataCompletedEventArgs e) { // Do something with the data returned. string result = e.Result.ToString(); }}
Убедившись, что ваше приложение Silverlight выполняется без ошибок и получает данные от веб-сервиса, вы можете переключиться на вызовы CRM 4.0 API. Все эти вызовы будет исходить из уже созданных веб-методов GetCRMData и PostCRMData оболочки веб-сервиса.
Взаимодействие с CRM 4.0 API
Через CRM 4.0 доступны два основных веб-сервиса: CRMService и MetadataService. На эти веб-сервисы, в целом, можно ссылаться из любого проекта (но они не дают особой функциональности, если вы ссылаетесь на них из приложения Silverlight). Самый распространенный и эффективный способ работы с этим API — использование Microsoft Dynamics CRM SDK (можно скачать по ссылке bit.ly/6M3PvV). SDK содержит множество классов и методов и упрощает взаимодействие между .NET-кодом и веб-сервисами CRM 4.0. В этом разделе вы научитесь взаимодействовать с API из оболочки веб-сервиса, используя SDK.
Первый шаг — добавление ссылок на соответствующие сборки CRM SDK. В приложении ASP.NET, где размещается оболочка в виде веб-сервиса, добавьте ссылки на две сборки SDK: microsoft.crm.sdk.dll и microsoft.crm.sdktypeproxy.dll. После этого включите необходимые директивы в начало страницы CrmServiceWrapper.asmx:
using Microsoft.Crm.Sdk;using Microsoft.Crm.SdkTypeProxy;using Microsoft.Crm.Sdk.Query;
Следующий шаг — напишите код для создания экземпляра CRM-сервиса, который позволит вам подключаться к экземпляру CRM 4.0. Этот код будет использоваться веб-методами GetCRMData и PostCRMData, поэтому его нужно выделить в собственный метод. Этот метод (рис. 3) требует двух ключевых полей: названия организации для вашего экземпляра CRM 4.0 и URL основного CRM-сервиса (находится по адресу /MSCRMServices/2007/crmservice.asmx). Заметьте, что эти поля лучше всего размещать в конфигурационном файле для упрощения их модификации после компиляции кода.
Рис. 3. Метод GetCRMService
static public CrmService GetCRMService(){ CrmService service = new CrmService(); CrmAuthenticationToken token = new Microsoft.Crm.Sdk.CrmAuthenticationToken(); token.OrganizationName = “Contoso”; service.Url = “http://localhost:5555/MSCRMServices/2007/crmservice.asmx”; service.Credentials = System.Net.CredentialCache.DefaultCredentials; service.CrmAuthenticationTokenValue = token; return service;}Запрос CRM-данных
Теперь вы можете переключить свое внимание на запрос данных от CRM 4.0. Важно знать, что в CRM 4.0 есть два типа сущностей: базовые системные и собственные. С базовыми системными сущностями работать немного легче, чем с собственными сущностями. По умолчанию все свойства базовых сущностей можно получать через строго типизированные объекты в C#. Собственные сущности, как правило, запрашиваются в виде Dynamic Entities, хотя в качестве альтернативы их тоже можно обрабатывать как строго типизированные объекты. Я продемонстрирую запрос данных от двух типов сущностей.
Пример запроса базовой системной сущности (System User) показан на рис. 4. Этот код заменяет веб-метод с тем же именем, который был в виде заглушки ранее в этой статье. В новом коде, который запрашивает от CRM 4.0 всех системных пользователей, вы заметите несколько важных вещей. Во-первых, тип объекта, с которым мы имеем дело, — systemuser. Все базовые сущности имеют свои типы. Во-вторых, возвращаемый результат является строковым представлением XML-документа.
Рис. 4. Запрос сущности System User
[WebMethod]public string GetCRMData(){ // This will return all users in CRM in a single XML structure. StringBuilder xml = new StringBuilder(); CrmService service = GetCRMService(); QueryExpression query = new QueryExpression(); query.EntityName = “systemuser”; query.ColumnSet = new AllColumns(); RetrieveMultipleRequest retrieve = new RetrieveMultipleRequest(); retrieve.Query = query; retrieve.ReturnDynamicEntities = false; RetrieveMultipleResponse retrieved = (RetrieveMultipleResponse)service.Execute(retrieve); xml.Append(”<Users>”); for (int i = 0; i < retrieved.BusinessEntityCollection.BusinessEntities.Count; i++) { systemuser user = (systemuser)retrieved.BusinessEntityCollection.BusinessEntities[i]; // Create a string represenation to return to Silverlight app. xml.Append(”<User”); xml.Append(” FirstName ='” + user.firstname + “'”); xml.Append(” LastName = '” + user.lastname + “'”); xml.Append(” SystemUserId = '” + user.systemuserid.ToString() + “'”); xml.Append(” JobTitle = '” + user.jobtitle + “'”); xml.Append(”/>”); } xml.Append(”</Users>”); return xml.ToString();}
Вы обнаружите, что варианты возврата данных в Silverlight весьма ограничены. Например, вы не можете получить BusinessEntityCollection, так как Silverlight не позволяет напрямую работать с этим API. Также есть ограничения на передачу XML в Silverlight через веб-сервис. Поэтому в итоге лучший вариант — простая строка.
Запрос собственных сущностей может оказаться посложнее. Наиболее распространенный способ получения данных — использование Dynamic Entity для извлечения результатов (пример такого рода показан на рис. 5). Проблема с этим подходом в том, как работать со специфическими атрибутами в фильтрах внутри выражений запроса. И хотя все это возможно, особой помощи от IntelliSense не ждите.
Рис. 5. Получение данных с использованием Dynamic Entity
public static DynamicEntity GetCRMEntity( CrmService tmpService, String entityId, String entityName){ DynamicEntity crmEntity = null; TargetRetrieveDynamic targetRetrieve = new TargetRetrieveDynamic(); // Set the properties of the target. targetRetrieve.EntityName = entityName; targetRetrieve.EntityId = new Guid(entityId); // Create the request object. RetrieveRequest retrieve = new RetrieveRequest(); // Set the properties of the request object. retrieve.Target = targetRetrieve; retrieve.ColumnSet = new AllColumns(); // Retrieve as a DynamicEntity. retrieve.ReturnDynamicEntities = true; // Execute the request. RetrieveResponse retrieved = (RetrieveResponse)tmpService.Execute(retrieve); // Extract the DynamicEntity from the request. DynamicEntity entity = (DynamicEntity)retrieved.BusinessEntity; crmEntity = entity; return crmEntity;}
Подход с Dynamic Entity, вероятно, самый распространенный, но, если вы хотите полностью видеть структуру своей сущности в Visual Studio и взаимодействовать с ней так же, как и со стандартной сущностью, вы можете создать прокси для CrmService. Во многих случаях это может дать более широкие возможности в разработке и обеспечить большую гибкость в том, как пишется код. Прокси — это не более чем C#-файл, сгенерированный на основе самого нового экземпляра CrmService WSDL. Чтобы создать класс прокси для основного сервиса CrmService, откройте командную строку Visual Studio и введите следующее, заменив URL ссылкой на свою страницу crmservice.asmx:
wsdl.exe /out:CrmSdk.cs /namespace:CRM40SilverlightDemo.WebReferences.CrmSdkhttp://localhost:5555/mscrmservices/2007/crmservice.asmx?wsdl
Эта команда создаст C#-файл CrmSdk.cs в каталоге, откуда вы запустили wsdl.exe. Этот файл нужно добавить в ваш проект. После этого вы можете работать с любой собственной сущностью точно так же, как с базовыми системными сущностями. Если сущность изменилась, просто обновите свой C#-файл прокси, и новые атрибуты (или другие модификации) станут доступными. В нашем текущем упражнении C#-файл прокси не используется.
Обновление данных в CRM 4.0
Разобравшись в том, как получать данные от CRM 4.0, вы сможете понять и то, как их передавать. Код на рис. 6 показывает, как обновить запись системного пользователя. Для этого нужно передать два свойства: уникальный идентификатор записи CRM 4.0 и свой ник. Чтобы отправить эти строки, в файле MainPage.xaml.cs нужно модифицировать одну строку кода:
proxyPost.PostCRMDataAsync(”f04b02d9-ad5f-e011-a513-000c29330bd5″,”My Nickname”);
Заметьте, что этот идентификатор «зашит» в вызов метода PostCRMData. На практике вы предпочтете создать механизм для динамического получения идентификаторов.
Рис. 6. Отправка данных в CRM 4.0
[WebMethod]public string PostCRMData(string userId, string nickname){ CrmService service = GetCRMService(); Key kid = new Key(); kid.Value = new Guid(userId); systemuser entity = new systemuser(); entity.systemuserid = kid; entity.nickname = nickname; service.Update(entity); return “success”;}Обработка результатов в Silverlight
К этому моменту решение должно извлекать данные из CRM 4.0 и отправлять их. Однако мы пока ничего не делали со строковыми результатами, возвращаемыми в Silverlight. Метод GetCRMData вызовращает строку данных, содержащую XML-документ со всеми пользовательскими записями, но что делать с этими данными? В зависимости от конкретного элемента управления вы можете напрямую связать его с XML или предварительно разобрать возвращаемый XML и работать с индивидуальными элементами данных.
Пример разбора результатов в цикле показан на рис. 7. Этот код демонстрирует, как загрузить строку в XML-документ и перебрать данные. Для работы с XML-документами в Silverlight самая универсальная функциональность заключена в классе XDocument. Для его использования нужно добавить в свой проект Silverlight ссылку на System.Xml.Linq.
Рис. 7. Работа с XDocument
void proxy_GetCRMDataCompleted(object sender, GetCRMDataCompletedEventArgs e){ XDocument xDoc = XDocument.Parse(e.Result); string firstName; string lastName; string ID; string title; // Loop through the results. foreach (XElement element in xDoc.Descendants(”User”)) { firstName = GetAttributeValue(element, “FirstName”); lastName = GetAttributeValue(element, “LastName”); ID = GetAttributeValue(element, “SystemUserId”); title = GetAttributeValue(element, “JobTitle”); }} private string GetAttributeValue(XElement element, string strAttributeName){ if (element.Attribute(strAttributeName) != null) { return element.Attribute(strAttributeName).Value; } return string.Empty;}Безграничные возможности
При интеграции эти двух технологий вы получаете безграничные возможности. Некоторые шаги, которые вы наверняка захотите предпринять, — разобраться в подходах к обработке исключений (многие элементы управления Silverlight скрывают исключения, поэтому в каждом случае потребуется индивидуальный подход) и интеграции с различными элементами управления. Независимо от выбранного вами направления дальнейшей работы у вас теперь есть все, что нужно для создания решений Silverlight, способных читать и записывать данные CRM 4.0.
Асинхронные методы в предстоящих версиях Visual Basic и C# — отличный способ избавиться от обратных вызовов при использовании асинхронного программирования. В этой статье я подробно расскажу, что именно делает новое ключевое слово await, начиная от концептуального уровня и заканчивая уровнем «железа».
Последовательная композиция
Visual Basic и C# являются языками императивного программирования — и гордятся этим! Они превосходно позволяют выражать логику программирования в виде последовательности дискретных стадий, которые обрабатываются одна за другой. Большинство языковых конструкций уровня выражений являются управляющими структурами, дающими возможность самыми разнообразными способами указывать порядок выполнения дискретных стадий данного блока кода.
Условные выражения вроде if и switch позволяют выбирать различные последовательные операции в зависимости от текущего состояния.Выражения циклов наподобие for, foreach и while позволяют многократно повторять выполнение определенного набора стадий.Такие выражения, как continue, throw и goto, позволяют передавать управление другим частям программы (вне локальной области видимости).
Формирование логики с помощью управляющих структур приводит к последовательной композиции, и они являются кровеносной системой императивного программирования. Это объясняет, почему вам предоставляется так много управляющих структур: последовательная композиция должна быть по-настоящему удобной и тщательно структурированной.
Непрерывное выполнение
В большинстве императивных языков, в том числе в текущих версиях Visual Basic и C#, выполнение методов (или функций, или процедур — обзовите их, как хотите) осуществляется непрерывно. Я подразумеваю под этим следующее. Как только поток начинает выполнять данный метод, он будет постоянно занят этим, пока метод не завершится. Да, иногда поток будет выполнять выражения в методах, вызываемых вашим блоком кода, но это просто часть процесса выполнения данного метода. Поток никогда не переключится на что-то другое, если только ваш метод не укажет ему сделать это.
Эта непрерывность иногда создает проблемы. Временами метод не может продолжить, пока не произойдет некое событие: завершится скачивание, доступ к файлу, вычисления в другом потоке или сработает таймер. В таких ситуациях полностью занят ничегонеделанием. Обычно в таких случаях говорят, что поток блокирован; метод, послуживший причиной этому, называют блокирующим.
Вот пример метода, вызывающего весьма серьезную блокировку:
static byte[] TryFetch(string url){ var client = new WebClient(); try { return client.DownloadData(url); } catch (WebException) { } return null;}
Поток, выполняющий этот метод, будет простаивать большую часть времени вызова client.DownloadData, фактически ничего не делая, а просто находясь в ожидании.
Это плохо, особенно когда потоки являются драгоценным ресурсом, а именно так зачастую и есть. В типичном промежуточном уровне обслуживание каждого запроса требует взаимодействия с внутренним сервером или другим сервисом. Если каждый запрос обрабатывается в своем потоке и эти потоки большую часть времени блокируются в ожидании промежуточных результатов, то одно лишь количество потоков, выделяемых для промежуточного уровня, может легко стать бутылочным горлышком.
Вероятно, самый драгоценный вид потоков — UI-поток: он всего один такой. Практически все UI-инфраструктуры являются однопоточными и требуют, чтобы все, связанное с UI (события, обновления, логика манипуляции UI), выполнялось в одном выделенном потоке. Если одна из UI-операций (например, обработчик события запускает скачивание по URL) входит в состояние ожидания, замораживается весь UI, потому что его поток занят ничегонеделанием.
Таким образом, нам нужно, чтобы несколько последовательных операций могли совместно использовать потоки. Для этого они должны время от времени «делать паузы», т. е. оставлять дыры (временные окна) в своем выполнении, в течение которых другие могли бы делать что-то в том же потоке. Другими словами, иногда они должны быть «прерываемыми». Это особенно удобно, если последовательные операции делают паузу, когда ничем не занимаются. И тут нас спасет асинхронное программирование!
Асинхронное программирование
В настоящее время, поскольку методы всегда непрерывные, вы должны разбивать прерываемые операции на несколько методов (например, до и после скачивания). Чтобы отыскать дыру в середине процесса выполнения метода, вы должны разъединить его на непрерывные части. Различные API могут помочь в этом деле, предлагая асинхронные (неблокирующие) версии длительно выполняемых методов, которые инициируют операцию (например, запускают скачивание), сохраняют передаваемый обратный вызов для возобновления выполнения по окончании, а затем немедленно возвращают управление вызвавшему коду. Но, чтобы вызывающий код мог предоставить обратный вызов, операции «после» нужно переработать в отдельные методы.
Вот как это делается для предыдущего метода TryFetch:
static void TryFetchAsync(string url, Action<byte[], Exception> callback){ var client = new WebClient(); client.DownloadDataCompleted += (_, args) => { if (args.Error == null) callback(args.Result, null); else if (args.Error is WebException) callback(null, null); else callback(null, args.Error); }; client.DownloadDataAsync(new Uri(url));}
Здесь вы видите несколько способов передачи обратных вызовов: метод DownloadDataAsync ожидает, что обработчик событий подписывается на событие DownloadDataCompleted, и именно так вы передаете часть метода «после». TryFetchAsync тоже приходится иметь дело с обратными вызовами вызвавших. Вместо того чтобы заниматься всем этим самостоятельно, вы используете более простой подход — обратный вызов принимается как параметр. Очень хорошо, что с помощью лямбда-выражения обработчик событий может просто получать параметр callback и напрямую использовать его; если бы вы попробовали применять именованный метод, вам пришлось бы придумать какой-то способ передачи делегата обратного вызова в обработчик событий. Сделайте паузу на секунду и представьте, что вам потребовалось бы писать без лямбда-выражений.
Но главное, на что здесь нужно обратить внимание, — насколько сильно изменяется поток управления. Вместо использования управляющих структур языка для выражения потока управления, вы их эмулируете:
выражение return эмулируется вызовом обратного вызова;неявное распространение исключений эмулируется тоже вызовом обратного вызова;обработка исключений эмулируется с помощью проверки типа (type check).
Конечно, это очень простой пример. Когда применяются более сложные управляющие структуры, эмуляция усложняется в еще большей степени.
Подведем итог. Мы добились прерываемости, а значит, выполняющий поток может делать что-то другое, пока «ждет» завершения скачивания. Но потеряли простоту использования управляющих структур для выражения потока управления. И мы отказались от структурного императивного языка.
Асинхронные методы
Когда мы смотрим на проблему под этим углом, становится очевидным, чем вам помогут асинхронные методы в следующих версиях Visual Basic и C#: они позволят выражать прерываемый последовательный код.
Взгляните на асинхронную версию TryFetch с новым синтаксисом:
static async Task<byte[]> TryFetchAsync(string url){ var client = new WebClient(); try { return await client.DownloadDataTaskAsync(url); } catch (WebException) { } return null;}
Асинхронные методы позволяют делать паузу посреди вашего кода: вы можете не только использовать свои любимые управляющие структуры для выражения последовательной композиции, но и натыкать дыр в процессе выполнения с помощью выражений await — в этих дырах выполняющий поток волен делать другие вещи.
Хорошая аналогия, позволяющая лучше понять суть асинхронных методов, — кнопки «пауза» и «воспроизведение». Когда выполняющий поток добирается до выражения await, он нажимает кнопку «пауза» и выполнение метода приостанавливается. А когда задача, на которой вы ждали, завершается, он нажимает кнопку «воспроизведение» и выполнение метода возобновляется.
Перестройка кода компилятором
Когда нечто сложное выглядит простым, это обычно означает, что все самое интересное происходит «за кулисами», и именно так обстоит дело с асинхронными методами. Простота достигается за счет отличной абстракции, которая намного облегчает и написание, и чтение асинхронного кода. Пониманиетого, что происходит на внутреннем уровне не обязательно. Но если у вас есть такое понимание, это безусловно поможет вам стать более эффективным асинхронным программистом и полнее использовать соответствующие средства. И если вы читаете эту статью, то наверняка вам как минимум просто интересно. Поэтому нырнем поглубже: что именно делают async-методы (и await-выражения в них)?
Когда компилятор Visual Basic или C# встречает асинхронный метод, он весьма основательно перелопачивает его при компиляции: прерываемость метода не поддерживается нижележащей исполняющей средой напрямую и должна эмулироваться компилятором. Поэтому разбиением метода на части занимается сам компилятор. Однако он делает это иначе, чем это делали бы вы вручную.
Компилятор превращает ваши асинхронные методы в конечный автомат (state machine). Этот конечный автомат отслеживает, где вы находитесь в процессе исполнения и каково ваше локальное состояние. Оно может быть «выполняется» или «приостановлено». В состоянии «выполняется» процесс исполнения может достигнуть await, которое нажимает кнопку «пауза» и приостанавливает выполнение. В состоянии «приостановлено» что-то может нажать кнопку «воспроизведение», чтобы вернуться и возобновить выполнение.
Выражение await отвечает за такую настройку, чтобы кнопка «воспроизведение» нажималась, когда завершается задача, ожидаемая через await. Однако, прежде чем обсуждать это, давайте рассмотрим, что представляет собой сам конечный автомат и чем на самом деле являются кнопки паузы и воспроизведения.
Формирователи Task
Асинхронные методы создают задачи (tasks). Конкретнее, асинхронный метод возвращает экземпляр типа Task или Task<T> из пространства имен System.Threading.Tasks, и этот экземпляр генерируется автоматически. Пользовательский код не должен (и не может) предоставлять такие экземпляры. (Тут я немного покривил душой: асинхронные методы могут возвращать void, но мы пока проигнорируем этот факт.)
С точки зрения компилятора, создание задач — одна из простых частей его работы. В этом деле он опирается на предоставляемую инфраструктурой концепцию формирователя Task (Task builder), который находится в System.Runtime.CompilerServices (потому что не предназначен для прямого использования человеком). Например, существует тип вроде такого:
public class AsyncTaskMethodBuilder<TResult>{ public Task<TResult> Task { get; } public void SetResult(TResult result); public void SetException(Exception exception);}
Формирователь позволяет компилятору получить Task, а затем завершить Task с result или Exception. На рис. 1 схематично показано, как выглядит этот механизм для TryFetchAsync.
Рис. 1. Формирование Task
static Task<byte[]> TryFetchAsync(string url){ var __builder = new AsyncTaskMethodBuilder<byte[]>(); … Action __moveNext = delegate { try { … return; … __builder.SetResult(…); … } catch (Exception exception) { __builder.SetException(exception); } }; __moveNext(); return __builder.Task;}
Следите внимательно:
сначала создается формирователь;затем создается делегат __moveNext. Этот делегат является кнопкой «воспроизведение». Назовем его делегатом возобновления (resumption delegate), и он содержит:оригинальный код из вашего async-метода (впросем, на данный момент мы это опускаем);выражения return, которые нажимают кнопку «пауза»;вызовы, завершающие работу формирователя с успешным результатом и соответствующие выражениям return оригинального кода;обертывающие блоки try/catch, которые завершают работу формирователя с любыми необрабатываемыми (escaped) исключениями;теперь нажимается кнопка «воспроизведение» — вызывается делегат возобновления. Он выполняется, пока не будет нажата кнопка «пауза»;вызвавшему коду возвращается Task.
Формирователи Task — особые вспомогательные типы, предназначенные для использования только компилятором. Однако их поведение несильно отличается от того, что происходит, когда вы напрямую используете типы TaskCompletionSource из Task Parallel Library (TPL).
До сих пор я создавал Task для возврата и кнопку «воспроизведение» (делегат возобновления) для вызова кем-либо, когда придет время возобновить выполнение. Нам все еще надо разобраться в том, как возобновляется выполнение и как await-выражение настраивается для решения этой задачи. Но, прежде чем собирать все воедино, рассмотрим, как используются задачи.
Ожидаемые и ждущие
Как вы убедились, на объектах Task можно ждать. Однако Visual Basic и C# за милую душу позволяют ждать и другие объекты, если они ожидаемые (awaitable), т. е. если они имеют определенную форму (shape), с применением которой может быть скомпилировано await-выражение. Чтобы быть ожидаемым, у объекта должен быть метод GetAwaiter, который в свою очередь возвращает ждущего (awaiter). В качестве примера Task<TResult> имеет метод GetAwaiter, возвращающий этот тип:
public struct TaskAwaiter<TResult>{ public bool IsCompleted { get; } public void OnCompleted(Action continuation); public TResult GetResult();}
Члены ждущего позволяют компилятору проверять, завершен ли ожидающий, регистрировать обратный вызов для него, если еще не завершен, и получать результат (или исключение), когда завершен.
Теперь мы начинаем понимать, что должно делать await, чтобы обеспечить приостановку и возобновление выполнения кода вокруг ожидаемого объекта. Например, await внутри нашего примера TryFetchAsync превратилось бы в нечто вроде:
__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter(); if (!__awaiter1.IsCompleted) { … // готовимся к возобновлению в Resume1 __awaiter1.OnCompleted(__moveNext); return; // нажимаем кнопку "пауза" }Resume1: … __awaiter1.GetResult()) …
И вновь следите за тем, что происходит:
получаем ждущего для задачи, возвращенной из DownloadDataTaskAsync;если ждущий не завершен, ему передается кнопка «пауза» (делегат возобновления) в качестве обратного вызова;когда ждущий возобновляет выполнение (в Resume1), мы получаем результат и используем его в коде, который расположен за ним.
Очевидно, что общий случай заключается в том, что ожидаемым является Task или Task<T>. Действительно, эти типы (уже присутствующие в Microsoft .NET Framework 4) тонко оптимизированы для такой роли. Однако есть веские причины на то, чтобы поддерживать в качестве ожидаемых и другие типы.
Взаимодействие с другими технологиями Например, в F# есть тип Async<T>, приблизительно соответствующий Func<Task<T>>. Возможность ожидания Async<T> прямо из Visual Basic или C# помогает наводить мосты между асинхронным кодом, написанным на двух языках. Аналогично F# предоставляет функциональность, позволяющую сделать обратное: использовать Task непосредственно в асинхронном коде на F#.Реализация специальной семантикиСама TPL добавляет несколько простых примеров этого. В частности, вспомогательный статический метод Task.Yield возвращает ожидаемый объект, который заявляет (через IsCompleted), что он еще не завершен, но к выполнению немедленно планируется обратный вызов, передаваемый в его метод OnCompleted так, будто он фактически завершен. Это позволяет вам вводить принудительное планирование к выполнению и обходить оптимизацию компилятором, который пропускает эту стадию, если результат уже доступен. Это можно использовать для поиска «дыр» в выполняемом на данный момент коде и улучшать отзывчивость не простаивающего кода. Сами объекты Task не могут представлять завершенные задачи, для которых заявляется обратное, поэтому используется специальный ожидаемый тип (awaitable type).
Прежде чем углубиться в реализацию ожидаемого Task, давайте закончим с тем, как компилятор перестраивает асинхронный метод, и изучим подсистему, которая отслеживает состояние выполнения метода.
Конечный автомат
Чтобы сшить все вместе, нужно построить конечный автомат вокруг создания и использования типов Task. Вся пользовательская логика из оригинального метода в основном помещается в делегат возобновления, но локальные объявления повышаются до более высокого уровня, чтобы они смогли пережить несколько запусков. Более того, вводится переменная состояния для отслеживания того, как идут дела, а пользовательская логика в делегате возобновления обертывается в большой блок switch, где проверяется состояние и выполняется переход к соответствующей метке. Поэтому, когда бы ни было вызвано возобновление, возврат произойдет прямо туда, откуда было передано управление в прошлый раз. Все это показано на рис. 2.
Рис. 2. Создание конечного автомата
static Task<byte[]> TryFetchAsync(string url){ var __builder = new AsyncTaskMethodBuilder<byte[]>(); int __state = 0; Action __moveNext = null; TaskAwaiter<byte[]> __awaiter1; WebClient client = null; __moveNext = delegate { try { if (__state == 1) goto Resume1; client = new WebClient(); try { __awaiter1 = client.DownloadDataTaskAsync( url).GetAwaiter(); if (!__awaiter1.IsCompleted) { __state = 1; __awaiter1.OnCompleted(__moveNext); return; } Resume1: __builder.SetResult(__awaiter1.GetResult()); } catch (WebException) { } __builder.SetResult(null); } catch (Exception exception) { __builder.SetException(exception); } }; __moveNext(); return __builder.Task;}
Совсем непросто! Уверен, вы спрашиваете себя, почему этот код намного объемнее и детальнее, чем вручную «асинхронизированная» версия. показанная ранее. На то есть несколько веских причин, включая эффективность (меньше операций выделения памяти в общем случае) и универсальность (этот код применим не только к типам Task, но и к пользовательским ожидаемым объектам). Однако главная причина такова: разбивать пользовательскую логику больше не надо — она просто дополняется переходами, возвратами и т. д.
Хотя пример слишком примитивен, чтобы по-настоящему оценить это, перестройка логики метода в семантически эквивалентный набор дискретных методов для каждой из его непрерывных частей логики между await-выражениями — штука очень хитроумная. Чем больше количество управляющих структур, в которые вложены await-выражения, тем хуже результат. Когда await-выражения окружены не только циклами с операторами continue и break, но и блоками try-finally и даже goto, добиться перестройки с высокой точностью становится гораздо труднее, если вообще возможно.
Вместо этого куда разумнее просто накладывать на оригинальный код пользователя другой уровень управляющих структур, перебрасывая управление (с помощью условных переходов) и возвращая (с помощью return), как того требует ситуация. Воспроизведение и пауза. В Microsoft систематически тестировали тождественность асинхронных методов их синхронным эквивалентам, и мы убедились, что это очень надежный подход. Нет лучшего способа оставить в целости синхронную семантику в мире асинхронного кода, чем в первую очередь сохранить код, описывающий эту семантику.
Детали
Представленное описание слегка идеализированное — в перестройке применяется больше трюков, чем вы могли бы предполагать. Ниже перечислено еще несколько подвохов, с которыми приходится иметь дело компилятору.
Выражения goto Перестройка в варианте на рис. 2 на самом деле не подлежит компиляции, так как выражения goto (по крайней мере вC#) не позволяют перейти на метки, захороненные во вложенных структурах. Сама по себе это не проблема, так как компилятор генерирует промежуточный IL-код, а не исходный код, и не обременяется вложением. Но даже в IL-коде нельзя перейти в середину блока try, как это делается в моем примере. На самом деле происходит переход к началу блока try, осуществляется нормальное вхождение в него, а затем переключение и вновь переход.
Блоки finally При возврате из делегата возобновления из-за await выполнять блоки finally пока нельзя. Их нужно сохранять до той поры, когда будут выполняться оригинальные выражения return из пользовательского кода. Для управления этим генерируется булев флаг, уведомляющий, надо ли выполнять блоки finally, и они дополняются его проверкой.
Порядок оценки Await-выражение необязательно является первым аргументом метода или оператора; оно может оказаться в середине. Чтобы сохранить порядок оценки, все предшествующие аргументы нужно оценивать и сохранять до await, а после await — восстанавливать.
Помимо всего этого, существует несколько ограничений, обойти которые нельзя. Например, await-выражения не могут находиться внутри блока catch или finally, так как нам не известен хороший способ восстановления корректного контекста исключения после await.
Ждущие выполнения задач
Ждущий (awaiter), используемый в сгенерированном компилятором коде для реализации await-выражения, имеет весьма большую свободу в том, как он планирует делегат возобновления, т. е. выполнение оставшейся части асинхронного метода. Однако потребность в реализации собственного ждущего может быть вызвана только в очень сложных и специфических случаях. Типы Task и так обладают высокой гибкостью в планировании, потому что подчиняются концепции контекста планирования, который сам является подключаемым.
Контекст планирования (scheduling context) — одна из тех концепций, которые, вероятно, выглядели бы получше, если бы мы разрабатывали их с самого начала. На данном этапе это сплав нескольких существующих концепций, и мы решили не вносить еще большую мешанину, пытаясь ввести поверх них новую, унифицирующую концепцию. Рассмотрим эту идею на концептуальном уровне, а затем обсудим ее реализацию.
Смысл планирования асинхронных обратных вызовов для ожидаемых задач заключается в том, что вы хотите продолжить выполнение «с того места, где были». Это «где» я и называю контекстом планирования. Этот контекст связан с концепцией потоков; в каждом потоке (в большинстве случаев) есть один такой контекст. При выполнении в потоке вы можете запросить контекст планирования, в котором он работает, а получив его, можете планировать выполнение в нем чего-либо.
Поэтому асинхронный метод должен делать следующее, когда он ждет выполнения задачи.
В приостановленном состоянии Запрашивать контекст планирования потока, в котором он выполняется.При возобновлении Планировать выполнение делегата возобновления в этом контексте планирования.
Почему это важно? Рассмотрим UI-поток. У него есть свой контекст планирования, который планирует новую работу, передавая ее через очередь сообщений обратно UI-потоку. Это означает, что, если ваш код выполняется в UI-потоке и ждет завершения задачи, то, когда будет готов результат задачи, оставшаяся часть асинхронного метода будет снова выполняться в UI-потоке. Таким образом, все то, что можно делать только в UI-потоке (манипулировать UI), вы по-прежнему можете делать после await-выражения; у вас не будет странного переключения потоков посреди вашего кода.
Другие контексты планирования являются многопоточными; в частности, стандартный пул потоков представлен одним контекстом планирования. Когда в нем планируется новая работа, она может быть выполнена в любом потоке из пула. Таким образом, асинхронный метод, начавший работать в пуле потоков, там же ее и продолжит, хотя при этом не исключена его «переброска» между разными потоками.
На практике единой концепции для контекста планирования нет. Грубо говоря, SynchronizationContext потока действует как его контекст планирования. Поэтому, если у потока есть один из SynchronizationContext (существующая концепция, которая может быть реализована пользователем), он и будет использоваться. А если нет, тогда применяется TaskScheduler потока (аналогичная концепция, введенная TPL). Если у него нет ни того, ни другого, будет задействован TaskScheduler по умолчанию — он планирует возобновления в стандартном пуле потоков.
Конечно, все это планирование создает издержки и влияет на производительность. Обычно в пользовательских сценариях ими можно пренебречь. Но иногда — особенно в библиотечном коде — это может вызвать проблемы. Взгляните:
async Task<int> GetAreaAsync(){ return await GetXAsync() * await GetYAsync();}
Это приводит к планированию обратно в контекст планирования дважды — после каждого await — только для того, что выполнить операцию в «правильном» потоке. Но кого волнует, в каком потоке вы выполняете умножения? В итоге вы попусту тратите время (чаще всего), и есть приемы, позволяющие избежать этого: вы можете обернуть Task, на котором вы ждете, в ожидаемый объект, отличный от Task, которому известно, как отключить поведение «планирования обратно» (schedule-back behavior) и просто осуществить возобновление в том потоке, который выполнил задачу, избежав переключения контекста и задержки, связанной с планированием:
async Task<int> GetAreaAsync(){ return await GetXAsync().ConfigureAwait( continueOnCapturedContext: false) * await GetYAsync().ConfigureAwait( continueOnCapturedContext: false);}
Конечно, это выглядит не столь изящно, но этот прием весьма неплох в библиотечном коде, который иначе пострадал бы от издержек планирования.
Приступайте к «асинхронизации»
Теперь у вас должно быть некоторое понимание внутреннего устройства асинхронных методов. Вероятно, самое полезное для вас заключается в следующем:
компилятор сохраняет смысл ваших управляющих структур, сохраняя сами структуры;асинхронные методы не планируют новые потоки — они позволяют выполнять вычисления в существующих потоках;когда задачи, выполнения которых вы ждали, завершаются, они возвращают вас туда, «где вы были».
Отмена и очистка являются общеизвестными проблемами, весьма трудными в решении, когда дело доходит до многопоточных приложений. Когда безопасно закрывать описатель? Имеет ли значение, какой поток отменяет операцию? Что еще хуже, некоторые многопоточные API не реентерабельны, потенциально способные повысить производительность, но создающие для разработчика дополнительные сложности.
В прошлой статье я ознакомил вас со средой пула потоков (msdn.microsoft.com/magazine/hh394144). Одна важнейшая особенность этой среды дает возможность поддерживать группы очистки (cleanup groups), и именно на этом я сосредоточусь в этой статье. Группы очистки вовсе не решают все проблемы отмены и очистки. Они просто делают объекты и обратные вызовы пула потоков более управляемыми, а это косвенно помогает облегчить отмену и очистку других API и ресурсов.
До сих пор я показал лишь то, как с помощью шаблона класса unique_handle автоматически закрывать объекты работы через функцию CloseThreadpoolWork. (Подробности см. в моей статье за август 2011 г. по ссылке msdn.microsoft.com/magazine/hh335066.) Однако в этом подходе есть некоторые ограничения. Если вы хотите узнать, отменены или нет еще не обработанные обратные вызовы, вы должны вызвать сначала WaitForThreadpoolWorkCallbacks. Это приводит к двум вызовам, умноженным на количество объектов, генерирующих обратные вызовы и используемых в вашем приложении на данный момент. Если вы предпочтете использовать TrySubmitThreadpoolCallback, то не получите возможность сделать даже это, и вам останется гадать, как отменить или ждать конечного обратного вызова. Конечно, в реальном приложении скорее всего будут не только объекты работы. В следующей статье я начну знакомить вас с другими объектами пула потоков, которые генерируют обратные вызовы, — от таймеров до ввода-вывода и ожидаемых объектов. Координация отмены и очистки всего этого может быстро превратиться в сплошной кошмар. К счастью, группы очистки решают эти и некоторые другие проблемы.
Функция CreateThreadpoolCleanupGroup создает объект группы очистки. Если функция выполняется успешно, она возвращает непрозрачный указатель, представляющий объект группы очистки. В ином случае возвращается null, а дополнительная информация доступна через функцию GetLastError. Функция CloseThreadpoolCleanupGroup, получив объект группы очистки, указывает пулу потоков, что этот объект может быть освобожден. Я уже упоминал об этом мимоходом, но повторить не помешает: API пула потоков не переносит недопустимых аргументов. Вызов CloseThreadpoolCleanupGroup или любой другой API-функции пула потоков с недопустимым, ранее закрытым или null-значением указателя приведет ваше приложение к краху. Это дефекты, допущенные программистом, и они не требуют дополнительных проверок в период выполнения. Шаблон класса unique_handle, который я представил вам в своей статье за июль 2011 г. (msdn.microsoft.com/magazine/hh288076), берет на себя эти детали с помощью класса traits, специфичного для группы очистки:
struct cleanup_group_traits{ static PTP_CLEANUP_GROUP invalid() throw() { return nullptr; } static void close(PTP_CLEANUP_GROUP value) throw() { CloseThreadpoolCleanupGroup(value); }};typedef unique_handle<PTP_CLEANUP_GROUP, cleanup_group_traits> cleanup_group;
Теперь я могу использовать удобный typedef и создать объект группы очистки следующим образом:
cleanup_group cg(CreateThreadpoolCleanupGroup());check_bool(cg);
Группа очистки сопоставляется с различными объектами, генерирующими обратные вызовы, посредством объекта среды (environment). Сначала обновите среду, чтобы указать группу очистки, которая будет управлять сроком жизни объектов и их обратных вызовов, например:
environment e;SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr);
После этого вы можете добавлять объекты в группу очистки, на которые потом будете ссылаться как на члены группы очистки. Эти объекты можно также индивидуально удалять из группы очистки, но чаще все члены закрывают одной операцией.
Объект работы может стать членом группы очистки в момент создания простой передачей обновленной среды в функцию CreateThreadpoolWork:
auto w = CreateThreadpoolWork(work_callback, nullptr, e.get());check_bool(nullptr != w);
Заметьте, что на этот раз я не использовал unique_handle. Только что созданный объект работы теперь является членом группы очистки в environment, и его срок жизни не требуется отслеживать напрямую с помощью RAII.
Вы можете отменить членство объекта работы в группе очистки только его закрытием, что можно сделать на индивидуальной основе с помощью функции CloseThreadpoolWork. Пулу потоков известно, что данный объект работы является членом группы очистки и отменяет это членство перед его закрытием. Это гарантирует, что приложение не рухнет, когда группа очистки позднее попытается закрыть все свои члены. Но обратное недопустимо: если вы сначала указываете группе очистки закрыть все члены, а потом вызываете CloseThreadpoolWork применительно к уже недействительному объекту работу, ваше приложение рухнет.
Конечно, весь смысл группы очистки — освободить приложение от необходимости индивидуально закрывать все генерирующие обратные вызовы объекты, которые оно использовало. Еще важнее то, что это позволяет приложению ждать выполнения и при необходимости отменять любые незавершенные обратные вызовы в операции единого ожидания, избавляя поток приложения от периодического ожидания и возобновления. Все эти и другие сервисы предоставляет функция CloseThreadpoolCleanupGroupMembers:
bool cancel = …CloseThreadpoolCleanupGroupMembers(cg.get(), cancel, nullptr);
Эта функция может показаться простой, но на самом деле она выполняет ряд важных операций над всеми своими членами. Сначала в зависимости от значения второго параметра она отменяет любые ожидающие обратные вызовы, обработка которых еще не началась. Затем она ждет любые обратные вызовы, выполнение которых уже началось, и при необходимости любые невыполненные обратные вызовы, если вы предпочтете не отменять их. Наконец, она закрывает все свои объекты-члены.
Некоторые находят сходство между группами очистки и сбором мусора, но я считаю это неправильной аналогией. Если уж на то пошло, группа очистки больше похожа на STL-контейнер объектов, генерирующих обратные вызовы. Объекты, добавленные в группу, не будут автоматически закрыты по любой причине. Если вы забудете вызвать CloseThreadpoolCleanupGroupMembers, в вашем приложении начнется утечка памяти. Даже вызов CloseThreadpoolCleanupGroup для закрытия самой группы не поможет. Вместо этого просто рассматривайте группу очистки как средство управления сроком жизни и параллельной обработки группы объектов. И, конечно, вы можете создать несколько групп очистки в своем приложении, чтобы по-разному управлять каждой группой объектов. Эта абстракция невероятно удобна, но никакой магии в ней нет, и вам придется позаботиться о ее корректном использовании. Возьмем следующий псевдокод:
environment e;SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr); while (app is running){ SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, nullptr, e.get())); // Rest of application.} CloseThreadpoolCleanupGroupMembers(cg.get(), true, nullptr);
Вполне предсказуемо этот код будет использовать неограниченные объемы памяти, и скорость его работы будет постоянно замедляться по мере исчерпания системных ресурсов.
В своей статье за август 2011 г. я продемонстрировал, что использование обманчиво простой функции TrySubmitThreadpoolCallback весьма проблематично, так как простого способа ожидания завершения ее обратного вызова нет. Это связано с тем, что она на самом деле не предоставляет доступа к объекту работы. Однако сам пул потоков от такого ограничения не страдает. Так как TrySubmitThreadpoolCallback принимает указатель на environment, вы можете неявным образом сделать объект работы членом группы очистки. Тогда вы сможете использовать CloseThreadpoolCleanupGroupMembers для ожидания выполнения или отмены соответствующего обратного вызова. Рассмотрим следующий псевдокод:
environment e;SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr); while (app is running){ TrySubmitThreadpoolCallback(simple_callback, nullptr, e.get()); // Rest of application.} CloseThreadpoolCleanupGroupMembers(cg.get(), true, nullptr);
Я почти готов простить тех, кто думает о схожести со сбором мусора, поскольку пул потоков автоматически закрывает объект работы, созданный TrySubmitThreadpoolCallback. Конечно, это не имеет ничего общего с группами очистки. Такое поведение я описывал в своей статье за июль 2011 г. Функция CloseThreadpoolCleanupGroupMembers в этом случае изначально не отвечает за закрытие объекта работы, а лишь ожидает и, возможно, отменяет обратные вызовы. В этом примере (в отличие от предыдущего) код будет выполняться неопределенно долго без использования лишних ресурсов и обеспечивать предсказуемые отмену и очистку. С помощью групп обратных вызовов (callback groups) функция TrySubmitThreadpoolCallback сама выполняет очистку, давая нам надежную и удобную альтернативу. В высокоструктурированном приложении, где один и тот же обратный вызов повторно ставится в очередь, все равно было бы эффективнее повторно использовать явный объект работы, но удобство этой функции трудно отрицать.
Группы очистки обладают еще одной особенностью, которая облегчает очистку в вашем приложении. Зачастую недостаточно просто ожидать выполнения пока необработанных обратных вызовов. Вам может понадобиться выполнить некую задачу очистки для каждого генерирующего обратный вызов объекта, как только у вас появится уверенность в том, что никаких обратных вызовов выполняться больше не будет. Управление сроком жизни этих объектов через группу очистки также означает, что пул потоков будет в курсе, когда должны выполняться такие задачи очистки.
Когда вы сопоставляете группу очистки с объектом environment через SetThreadpoolCallbackCleanupGroup, вы также предоставляете обратный вызов, который должен выполняться для каждого члена этой группы в процессе закрытия этих объектов функцией CloseThreadpoolCleanupGroupMembers. Поскольку это атрибут объекта environment, вы можете даже применять разные обратные вызовы к разным объектам, относящимся к одной группе очистки. В следующем примере я создаю environment для группы очистки и обратный вызов очистки:
void CALLBACK cleanup_callback(void * context, void * cleanup){ printf(”cleanup_callback: context=%s cleanup=%s\n”, context, cleanup);} environment e;SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), cleanup_callback);
Первый параметр обратного вызова очистки — значение context для объекта, генерирующего обратный вызов. Это значение вы указываете, например, при вызове функции CreateThreadpoolWork или TrySubmitThreadpoolCallback, и именно благодаря ему становится известно, для какого объекта вызывается обратный вызов очистки. Второй параметр — это значение, передаваемое как последний параметр при вызове функции CloseThreadpoolCleanupGroupMembers.
Теперь рассмотрим следующие объекты работы и обратные вызовы:
void CALLBACK work_callback(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK){ printf(”work_callback: context=%s\n”, context);} void CALLBACK simple_callback(PTP_CALLBACK_INSTANCE, void * context){ printf(”simple_callback: context=%s\n”, context);} SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, “Cheetah”, e.get()));SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, “Leopard”, e.get()));check_bool(TrySubmitThreadpoolCallback(simple_callback, “Meerkat”, e.get()));
Какие из них отличаются от других? Как бы крошечный сурикат не хотел походить на больших кошек в Южной Африке, он просто никогда не будет одним из них. Что будет при закрытии членов группы очистки таким образом?
CloseThreadpoolCleanupGroupMembers(cg.get(), true, “Cleanup”);
В многопоточном коде мало что может быть преопределенным. Если обратные вызовы успеют завершиться до их отмены и закрытия, мы получим следующий вывод:
work_callback: context=Cheetahwork_callback: context=Leopardsimple_callback: context=Meerkatcleanup_callback: context=Cheetah cleanup=Cleanupcleanup_callback: context=Leopard cleanup=Cleanup
Очень часто ошибочно полагают, будто обратный вызов очистки вызывается только для объектов, чьи обратные вызовы не получили шанса на выполнение. Windows API слегка запутывает программистов, потому что иногда обратный вызов очистки называется в нем обратным вызовом отмены, а это вовсе не так. Обратный вызов очистки вызывается для каждого текущего члена группы очистки. Вы могли бы рассматривать это как деструктор членов группы очистки, но эта аналогия приемлема ровно до того момента, пока вы не попадаете в функцию TrySubmitThreadpoolCallback, которая вновь усложняет картину. Вспомните, что пул потоков автоматически закрывает нижележащий объект работы, создаваемый этой функцией в момент выполнения его обратного вызова. То есть, будет ли выполняться обратный вызов очистки для этого неявного объекта работы, зависит от того, началось ли выполнение его обратного вызова к моменту обращения к функции CloseThreadpoolCleanupGroupMembers. Обратный вызов очистки будет выполняться для этого объекта лишь в том случае, если его обратный вызов все еще находится в очереди и вы указываете функции CloseThreadpoolCleanupGroupMembers отменить любые обратные вызовы в очереди. Все это весьма непредсказуемо, и поэтому я не советую использовать TrySubmitThreadpoolCallback с обратным вызовом очистки.
В заключение стоит упомянуть, что, даже когда CloseThreadpoolCleanupGroupMembers блокируется, она не тратит время впустую. Любые объекты, готовые к очистке, получат возможность выполнить свои обратные вызовы очистки в вызвавшем потоке, пока он ждет завершения остальных необработанных обратных вызовов. Возможности, предоставляемые группами очистки и, в частности, функцией CloseThreadpoolCleanupGroupMembers просто бесценны для корректной и эффективной очистки всех частей вашего приложения.
Я уже говорил раньше, что блокирующие операции губительны для параллельной обработки. Однако зачастую приходится ждать, когда станет доступным некий ресурс, или, возможно, вы реализуете протокол, в котором предусматривается определенный период ожидания до повторной отправки сетевого пакета. Что же тогда делать? Вы могли бы задействовать критическую секцию (critical section), вызывать функции вроде Sleep и WaitForSingleObject и т. д. Конечно, это означает, что ваши потоки снова будут простаивать на блокировке. Здесь нужен какой-то способ переложить ожидание на пул потоков, не затрагивая его лимиты параллельных потоков, о чем шла речь в моей статье за сентябрь (msdn.microsoft.com/magazine/hh394144). Тогда пул потоков может поставить в очередь обратный вызов, как только ресурс станет доступным или истечет время ожидания.
Сегодня я покажу, как реализовать именно такой вариант. Наряду с объектами работы, о которых я рассказывал в своей статье за август (msdn.microsoft.com/magazine/hh335066), API пула потоков предоставляет ряд других объектов, генерирующих обратные вызовы, и мы рассмотрим, как использовать объекты ожидания.
Объекты ожидания
Объект ожидания (wait object) пула потоков используется для синхронизации. Вместо блокировки на критической секции вы можете ждать, когда синхронизирующий объект ядра (обычно событие или семафор) перейдет в свободное состояние (become signaled).
Хотя вы можете использовать WaitForSingleObject и ее свиту, объект ожидания отлично интегрируется с остальной частью API пула потоков. Это делается весьма эффективно, группируя вместе любые переданные вами объекты ожидания, что уменьшает количество необходимых потоков и объем кода, который вам придется писать и отлаживать. Благодаря этому вы можете использовать среду пула потоков и группы очистки и избавиться от нужды выделять один или более потоков под ожидание перехода объектов в свободное состояние. В связи с рядом усовершенствований в той части пула потоков, которая относится к ядру, в некоторых случаях этого можно добиваться даже без использования потоков.
Объект ожидания создается функцией CreateThreadpoolWait. Если функция завершается успешно, она возвращает непрозрачный указатель, представляющий объект ожидания. А если ее выполнение заканчивается неудачей, функция возвращает нулевое значение указателя и предоставляет дополнительную информацию через функцию GetLastError. Функция CloseThreadpoolWait сообщает пулу потоков, что объект ожидания может быть освобожден. Эта функция не возвращает значение и для большей эффективности предполагает, что объект ожидания действителен.
Все эти детали берет на себя шаблон класса unique_handle, который я представил в своей статье за июль (msdn.microsoft.com/magazine/hh288076).
Вот класс traits, который можно использовать совместно с unique_handle, а также как typedef для удобства:
struct wait_traits{ static PTP_WAIT invalid() throw() { return nullptr; } static void close(PTP_WAIT value) throw() { CloseThreadpoolWait(value); }};typedef unique_handle<PTP_WAIT, wait_traits> wait;
Теперь я могу использовать typedef и создать объект wait следующим образом:
void * context = …wait w(CreateThreadpoolWait(callback, context, nullptr));check_bool(w);
Как обычно, последний (необязательный) параметр принимает указатель на среду, чтобы вы могли сопоставить объект wait со средой (environment), как я описывал в статье за сентябрь. Первый параметр принимает функцию обратного вызова, которая будет поставлена в очередь пула потоков, как только ожидание завершится. Она объявляется так:
void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * context, PTP_WAIT, TP_WAIT_RESULT);
Аргумент TP_WAIT_RESULT обратного вызова — целое значение без знака, сообщающее о причине, по которой завершилось ожидание. Значение WAIT_OBJECT_0 указывает, что ожидание завершилось успешно, так как синхронизирующий объект перешел в свободное состояние. Значение WAIT_TIMEOUT говорит о том, что период ожидания истек до того, как синхронизирующий объект перешел в свободное состояние. Как можно задать период ожидания и конкретный синхронизирующий объект, на котором вы будете ждать? Это работа на удивление сложной функции SetThreadpoolWait. Точнее, она достаточно проста, пока вы не пытаетесь указать период ожидания. Рассмотрим пример:
handle e(CreateEvent( … ));check_bool(e);SetThreadpoolWait(w.get(), e.get(), nullptr);
Сначала я создаю объект события, используя unique_handle typedef из своей статьи за июль. Функция SetThreadpoolWait задает синхронизирующий объект, на котором должен ждать объект wait. Последний (необязательный) параметр определяет период ожидания, но в этом примере я передаю нулевое значение указателя (nullptr), указывая, что пул потоков должен ждать неопределенно долго.
Структура FILETIME
Как же указать конкретный период ожидания? В этом-то и заключается вся сложность. Такие функции, как WaitForSingleObject, позволяют задавать период ожидания в миллисекундах как целое значение без знака. Однако функция SetThreadpoolWait ожидает указатель на структуру FILETIME, которая ставит перед разработчиком несколько трудных задач. Структура FILETIME является 64-битным значением, представляющим абсолютные дату и время от начала 1601 года в виде 100-наносекундных интервалов (на основе Coordinated Universal Time).
Для принятия относительных временных интервалов SetThreadpoolWait интерпретирует структуру FILETIME как знаковое 64-битное значение. Если вы передаете отрицательное значение, она воспринимает беззнаковую часть как период, относительный текущему времени, — опять же в 100-наносекундных интервалах. Стоит отметить, что относительный таймер (relative timer) прекращает отсчет при переходе компьютера в ждущий или спящий режим. Абсолютные значения периода ожидания, очевидно, не затрагиваются такими событиями. В любом случае использование FILETIME неудобно ни с абсолютными, ни с относительными значениями интервалов ожидания.
По-видимому, самый простой способ выражения интервалов ожидания в абсолютных значениях — заполнение структуры SYSTEMTIME и последующая подготовка структуры FILETIME без вашего участия — вызовом функции SystemTimeToFileTime:
SYSTEMTIME st = {};st.wYear = …st.wMonth = …st.wDay = …st.wHour = …// и т. д.FILETIME ft;check_bool(SystemTimeToFileTime(&st, &ft));SetThreadpoolWait(w.get(), e.get(), &ft);
В случае относительных значений тайм-аута придется попотеть немного больше. Сначала вы должны преобразовать относительное время в 100-наносекундные интервалы, а затем преобразовать их отрицательное 64-битное значение. Последнее сложнее, чем кажется. Вспомните, что компьютеры представляют знаковые целые, используя систему поразрядного дополнения до двух, а это означает, что у отрицательного числа должен быть установлен самый старший бит. Теперь добавьте к этому тот факт, что FILETIME на самом деле состоит из двух 32-битных значений. То есть вам придется позаботиться о корректном выравнивании при их обработке как 64-битного значения, а иначе произойдет сбой из-за ошибки выравнивания. Кроме того, нельзя просто использовать младшие 32 бита для хранения значения, так как самый старший бит находится в старших 32 битах.
Преобразование относительных значений периода ожидания
Обычно относительные периоды ожидания выражают в миллисекундах, поэтому позвольте мне продемонстрировать это преобразование здесь. Вспомните, что миллисекунда — это одна тысячная секунды, а наносекунда — одна миллиардная часть секунды. Можно взглянуть на это и по-другому: миллисекунда — это 1000 микросекунд, а микросекунда — 1000 наносекунд. В таком случае миллисекунда равна 10 000 100-наносекундных интервалов (такую единицу измерения ожидает функция SetThreadpoolWait). Выразить это можно разными способами, но вот один из подходов, который хорошо работает:
DWORD milliseconds = …auto ft64 = -static_cast<INT64>(milliseconds) * 10000;FILETIME ft;memcpy(&ft, &ft64, sizeof(INT64));SetThreadpoolWait(w.get(), e.get(), &ft);
Заметьте, что я для надежности преобразую DWORD до умножения, чтобы избежать переполнения целого значения. Я также использую memcpy, поскольку reinterpret_cast потребовал бы выравнивания FILETIME по границе, кратной восьми байтам. Конечно, вы могли бы прибегнуть к этому варианту, но мой вариант немного изящнее. Еще более простой подход базируется на том факте, что компилятор Visual C++ выравнивает объединение (union) по самым большим требованиям к выравниванию любого из членов объединения. По сути, если вы корректно упорядочиваете члены объединения, то можете сделать это всего одной строкой:
union FILETIME64{ INT64 quad; FILETIME ft;};FILETIME64 ft = { -static_cast<INT64>(milliseconds) * 10000 };SetThreadpoolWait(w.get(), e.get(), &ft.ft);
Но хватит о трюках с компилятором. Вернемся к пулу потоков. У вас может возникнуть соблазн попытаться использовать нулевой период ожидания. Обычно это делается в случае с WaitForSingleObject, чтобы определить, перешел ли синхронизирующий объект в свободное состояние, без реальной блокировки и ожидания. Однако эта процедура не поддерживается пулом потоков, так что лучше выбросьте это из головы.
Если вы хотите, чтобы конкретный объект работы прекратил ожидание своего синхронизирующего объекта, просто вызовите SetThreadpoolWait с нулевым значением указателя в ее втором параметре. Остерегайтесь создания явных условий для конкуренции потоков.
Последняя функция, относящаяся к объектам ожидания, — WaitForThreadpoolWaitCallbacks. Поначалу она может показаться похожей на функцию WaitForThreadpoolWorkCallbacks, используемую с объектами работы (я рассказывал о ней в статье за август). Но не дайте себя обмануть. Функция WaitForThreadpoolWaitCallbacks делает буквально то, что предполагает ее имя. Она ожидает любые обратные вызовы от конкретного объекта wait.
Подвох в том, что объект wait поставит обратный вызов в очередь, только когда соответствующий синхронизирующий объект перейдет в свободное состояние или истечет период ожидания. А до тех пор никакие обратные вызовы в очередь не ставятся, и функции Wait нечего ждать. Решение заключается в том, чтобы сначала вызывать SetThreadpoolWait с нулевыми значениями указателей, сообщая объекту wait о прекращении ожидания, а затем вызывать WaitForThreadpoolWaitCallbacks, чтобы избежать появления конкуренции между потоками:
SetThreadpoolWait(w.get(), nullptr, nullptr);WaitForThreadpoolWaitCallbacks(w.get(), TRUE);
Как и следовало ожидать, второй параметр определяет, будут ли отменены любые необработанные обратные вызовы, которые могли поступить в очередь, но их выполнение пока не начато. Естественно, объекты ожидания нормально работают с группами очистки (cleanup groups). О том, как использовать группы очистки, см. в моей статье за октябрь (msdn.microsoft.com/magazine/hh456398). В более крупных проектах они реально помогают упростить множество трудных операций отмены и очистки.
Забавный казус произошёл с антивирусной программой Avira после очередного обновления антивирусных баз. Avira признал сам себя трояном, то есть шпионской программой. Точнее, к зловредным компонентам был отнесён файл AESCRIPT.dll, являющийся частью необходимых для функционирования программы ресурсом. Вредоносный файл получил идентификационный номер TR/Spy.463227.
Сейчас, если посмотреть в базе вирусных угроз на официальном сайте Avira, то по данному номеру можно увидеть следующую надпись:
«This is a known false alarm which was fixed with VDF version 7.11.16.146 Please run an update of your Avira product.»
В ней говорится о том, что ошибочное срабатывание антивируса было исправлено в новой версии антивирусных баз, и пользователю необходимо запустить обновление Avira для установки обновления за номером 7.11.16.146.
Ошибка была диагностирована и исправлена в тот же день, когда и проявилась на компьютерах пользователей – 26 октября.
30 сентября тоже из-за ошибки в очередном обновлении антивирусных баз в неудобную ситуацию попала компания Microsoft. Тогда фирменные антивирус компании Microsoft Security Essentials и Forefront Client Security, работающие на одном движке, за вредоносную программу признали веб-браузер Google Chrome. Они увидели в браузере троян PWS:Win32/Zbot, ворующий пароли. Microsoft также оперативно выпустил исправляющее ошибку обновление и извинилась перед пользователями.
В прошлом месяце я рассмотрел некоторые наиболее популярные сложности с отказоустойчивыми кластерами в Windows Server 2008 R2 и рассказал о способах корректного устранения этих неполадок.
Вспомните, что текущая политика поддержки предполагает, что решение отказоустойчивой кластеризации в Windows Server 2008 и Windows Server 2008 R2 может считаться официально поддерживаемым службой поддержки потребителей Microsoft (Customer Support Services, CSS), только если удовлетворяет следующим критериям:
Все компоненты, включая оборудование и программное обеспечение, должны удовлетворять требованиям для получения логотипа "Certified for Windows Server 2008 R2".Окончательно настроенное решение должно пройти проверочный тест в оснастке "Failover Cluster Management" (Управление отказоустойчивыми кластерами).
Есть несколько сценариев устранения неполадок. Они представляют самые популярные неполадки отказоустойчивых кластеров Windows Server 2008 R2 и шаги по их устранению.
Сценарий 1. В процессе ежемесячной очистки объектов Active Directory по неосторожности был удален Cluster Name Object. Вновь созданный объект не переходит в интерактивный режим.
Объект CNO (Cluster Name Object) это общий идентификатор кластера, и поэтому он очень важен. Он создается автоматически мастером создания кластера (Create cluster) и носит то же имя, что и кластер. По мере настройки новых служб и приложений в этой учетной записи, CNO создает другие виртуальные объекты. При удалении CNO или его разрешений он не может создавать другие необходимые кластеру объекты до тех пор, пока не будет восстановлен или ему не будут назначены соответствующие разрешения.
Как и в случае с другими объектами Active Directory, у CNO есть связанный идентификатор objectGUID. По нему отказоустойчивый кластер узнает, что имеет дело с правильным объектом. Если просто создать новый объект, то у него будет новый objectGUID. Нужно восстановить правильный объект, чтобы отказоустойчивый кластер мог продолжить нормальную работу.
При устранении этой неполадки нужно найти две вещи, относящиеся к ресурсу кластера. В Windows PowerShell выполните команду:
Get-ClusterResource "Cluster Name" | Get-ClusterParameterCreatingDC,objectGUID
Эта команда сообщает необходимые значения. Первый параметр — CreatingDC. Когда отказоустойчивый кластер создает CNO, отмечается контроллер домена, в котором он был создан. При любой выполняемой с кластером операции (создание виртуального объекта, перевод в интерактивное состояние и т.п.) к этому контроллеру обращаются за объектом CNO и информацией безопасности. Если контроллер домена найти не удается или он недоступен, тогда уже выполняется поиск любого другого ответственного контроллера.
Второй параметр – objectGUID – отвечает за то, чтобы можно было быть уверенным, что получен правильный объект. В нашем случае имя кластера —CLUSTER1, контроллер, на котором он был создан, — DC1, а значение objectGUID — 1a3cf049cf79614ebd94670560da6f04:
Object Name Value
—— —- —–
Cluster Name CreatingDC \\DC1.domain.com
Cluster Name ObjectGUID1a3cf049cf79614ebd94670560da6f04
Надо войти в систему DC1 и открыть консоль Active Directory Users and Computers (Active Directory — пользователи и компьютеры). Если в ней есть текущий объект CLUSTER1, можно проверить наличие в нем правильных атрибутов. Редактор атрибутов Active Directory не показывает GUID, поскольку он не отражается в шестнадцатеричном формате.
В противном случае мы увидели бы такое значение 49f03c1a-79cf-4e61-bd94-670560da6f04. Шестнадцатеричный формат преобразуется и работает в парах, что немного смущает. Если взять первые восемь пар и выполнить преобразование, то 49f03c1a станет 1a3cf049. В следующих двух парах 79cf станет cf79, а 4e61 — 614e. Оставшиеся пары останутся такими же.
Чтобы увидеть в шестнадцатеричном формате то, что видит отказоустойчивый кластер, свойства объекта objectGUID нужно передать в редактор атрибутов. Поскольку это неправильный объект, сначала надо удалить объект, чтобы было возможно восстановить правильный объект.
Восстановить объект можно несколькими способами. Можно воспользоваться Active Directory Restore, утилитой, подобной ADRESTORE, или новой корзиной Active Directory (если это контроллер домена Windows 2008 R2 с обновленной схемой). Новая корзина значительно упрощает процесс и делает его самым удобным для восстановления объектов Active Directory.
С помощью корзины Active Directory можно найти объект восстановления, выполнив команду Windows PowerShell:
Get-ADObject –filter 'isdeleted –eq $true –and samAccountName –eq "CLUSTER1$"' –includeDelectedObjects –property * | FormatListsamAccountName,objectGUID
Эта команда выполняет поиск всех удаленных объектов с именем CLUSTER1 в корзине Active Directory. В результате получаем имя учетной записи и objectGUID. Если найдены несколько элементов, все они будут показаны. При просмотре нужного элемента мы увидим:
samAccountName : CLUSTER1$objectGUID:49f03c1a-79cf-4e61-bd94-670560da6f04
Теперь его надо восстановить. После удаления неправильного объекта для восстановления следует использовать такую команду Windows PowerShell:
Restore-ADObject –identity 49f03c1a-79cf-4e61-bd94-670560da6f04
В результате объект будет восстановлен в том же месте (подразделении) с теми же разрешениями и паролем, известным Active Directory.
Это одно из преимуществ корзины Active Directory по сравнению с аналогами утилиты ADRESTORE. При восстановлении эти утилиты сбрасывают пароль, перемещают объект в соответствующий контейнер, восстанавливают объект в отказоустойчивом кластере и т.д.
Используя корзину, мы просто переводим ресурс в интерактивный режим. Это более удачный вариант, чем восстановление Active Directory, особенно если позже были созданы новые объекты пользователя или компьютера, удалены старые объекты и т. п.
Сценарий 2. В оснастке «Управление отказоустойчивым кластером» общие тома кластера отмечены как Redirected Access (перенаправленный доступ). Как исправить ситуацию?
Во-первых, уточним определение общих томов кластера. Они упрощают настройку и управление виртуальными машинами Hyper-V в отказоустойчивых кластерах. Благодаря наличию общих томов в отказоустойчивом кластере, работающие под управлением Hyper-V, множество виртуальных машин могут использовать один LUN и при этом перемещаться между узлами независимо друг от друга. Общий том кластера обеспечивает повышение гибкости томов в кластерном хранилище. Например, для оптимизации дисковой производительности можно хранить системные файлы отдельно от данных, даже если системные файлы и данные размещены в файлах виртуального жесткого диска.
Надо позаботиться, чтобы в параметрах всех сетевых адаптеров, обеспечивающих связь кластеров, были установлены компоненты Client for Microsoft Networks (Клиент для сетей Microsoft) и File and Printer Sharing for Microsoft Networks (Общий доступ к файлам и принтерам в сетях Microsoft), для поддержки протокола SMB (Server Message Block). Это необходимо для общего тома. Сервер работает под управлением Windows Server 2008 R2, что автоматически обеспечивает версию SMB, необходимую для общего тома, а именно SMB2.Используется только одна предпочтительная коммуникационная сеть для общего тома, но включение этих параметров во множестве сетей поможет кластеру противостоять отказам.
Перенаправление доступа подразумевает, что все операции ввода-вывода «перенаправляются» по сети на другой узел, имеющий доступ к диску. Могут быть три причины для перехода в режим перенаправления доступа:
Режим настроен вручную.Идет процесс резервного копирования.Имеются неполадки с оборудованием, и узел не может напрямую получить доступ к диску.
В нашем сценарии мы исключили первые два варианта. Остается третий. Если заглянуть в журнал системных событий System Event Log, можно увидеть событие отказоустойчивой кластеризации "Event ID: 5121".
Определение этой записи журнала: Общий том кластера CSV «Cluster Disk x» более не доступен напрямую с этого узла кластера. Ввод-вывод будет перенаправлен на устройство хранения по сети через узел-владелец, которому принадлежит этот том. Это может привести к снижению производительности. Если перенаправление доступа к этому тому включено, отключите его. Если перенаправление доступа отключено, устраните неполадки связи этого узла с устройством хранения; после восстановления связи с устройством хранения работоспособность ввода-вывода будет также восстановлена.
В такой ситуации надо тщательно изучить все предшествующие этому события, связанные с оборудованием. Это значит, что надо искать такие события, как 9, 11, 15, которые указывают на неполадки оборудования или связи. Стоит провести физическую проверку диску в панели Disk Management (Управление диском). В большинстве случаев так удается обнаружить другие ошибки. Решив проблему, можно вывести диск из этого режима.
Следует помнить, что общий том остается включенным на протяжении всего времени, пока есть хотя бы один узел, подключенный с сети хранения. Именно поэтому будет установлен режим перенаправления. Все операции записи на диск отправляются в узел, который поддерживает связь, и виртуальные машины Hyper-V будут продолжать работу. Это может повлиять на их производительность, но они останутся работоспособными. В результате производственные серверы никогда не отключаются, а это уже хорошо.
Сценарий 3. Я только что создал новый отказоустойчивый кластер Windows 2008 R2 для виртуальных машин с высоким уровнем доступности. Диски настроены для работы с общим томом, но при попытке доступа к ним с помощью проводника или панели «Управление диском» происходит зависание. Я не могу скопировать на том файлы виртуального диска.
Существует только один «истинный» владелец диска и он называется узел-координатор (Coordinator Node). Запись любого типа метаданных выполняется только этим узлом.
Проводник или панель управления диском открывает диск с возможностью записи любых метаданных (если такое предполагается). По этой причине любой диск, не находящийся в собственности, перенаправляется по сети на узел-координатор. Это не совсем то же, что «перенаправление доступа».
В процессе устранения этой неполадки в оснастке «Управление отказоустойчивым кластером» можно увидеть, что диск находится в интерактивном режиме. Поэтому сначала надо изучить события в журнале. В журнале системных событий можно увидеть события отказоустойчивой кластеризации:
Событие ID: 5120
Общий том кластера «Cluster Disk x» больше не доступен на этом узле из-за «STATUS_BAD_NETWORK_PATH(c00000be)». Все операции ввода-вывода будут временно поставлены в очередь, пока путь к тому не будет восстановлен. (Cluster Shared Volume ‘Cluster Disk x’ is no longer available on this node because of ‘STATUS_BAD_NETWORK_PATH(c00000be).’ All I/O will temporarily be queued until a path to the volume is reestablished.)
Событие ID: 5142
С данного узла кластера больше нельзя получить доступ к общему тому кластера 'Cluster Disk x' из-за ошибки 'ERROR_TIMEOUT(1460)'. Проверьте подключение данного узла к устройству хранения и сети.
Эти события говорят о попытках подключения к узлу-координатору, которые завершаются таймаутом. Поэтому загляните в журнал системных событий и проверьте, нет ли там других ошибок, указывающих на связь узлов по сети. При наличии таковых необходимо устранить их. Ошибки могут быть вызваны поломкой или отключением сетевой карты.
Далее надо проверить возможность связь узлов по сети. Первым делом нужно проверить сеть, по которой передается трафик общего тома. В отказоустойчивом кластере сеть для общего тома выбирается, исходя из максимального значения метрики. Это отличается от того, как Windows идентифицирует сети.
Сетевой отказоустойчивый адаптер для отказоустойчивого кластера (Failover Cluster Network Fault Tolerance, NETFT) имеет свою собственную внутреннюю систему метрик. Все выявленные им сети имеют шлюз по умолчанию и получают метрики 10000, 10100 и т. д. Метрики всех сетей без шлюза по умолчанию начинаются с 1000, 1100 и т. д. Чтобы узнать, как адаптер NETFT определил их, можно использовать команду Windows PowerShell:
Get-ClusterNetwork | FT Name, Metric, Role
Вы увидите примерно следующее:
Name Metric
——————-
Management 10100
CSV Traffic 1000
LAN-WAN 10000
Private 1100
Среди этих сетей сеть, которую я определил как сеть общего тома, — CSV Traffic. Для узла Node1 я использовал IP-адрес 1.1.1.1 и 1.1.1.2 для узла Node2, поэтому я проверяю сетевое соединение с помощью команды PING.
Следующим шагом будет попытка соединения по протоколу SMB к указанным IP-адресам. Это то, что делает служба кластеризации. Чтобы получить ответ, достаточно выполнить простую команду:
NET VIEW \\1.1.1.1
В ответ будет получен или список общих ресурсов или сообщение "There are no entries in the list" (то есть список пуст).
Это указывает на то, что можно было бы создать соединение с этим общим ресурсом. Однако сообщение "System error 53 has occurred. The network path was not found" (Ошибка 53. Сетевой путь не найден) указывает на проблему в настройке TCP/IP сетевой карты.
Для работы с общим томом необходимы компоненты "Client for Microsoft Networks" и "File and Printer Sharing for Microsoft Networks". Если они отсутствуют, то возникает проблема с зависанием Проводника.
В Windows 2003 Server Cluster и ниже рекомендовалось отключать эти компоненты. Сейчас в этом нет необходимости.
Другие факторы
Есть несколько факторов, которые следует принять во внимание. Если узлы кластера вызывают отказ подсистемы размещения ресурсов (Resource Host Subsystem, RHS), первым делом нужно вспомнить о природе RHS и ее функциях. RHS — это компонент отказоустойчивого кластера, выполняющий проверку работоспособности множества ресурсов для обеспечения работоспособности. Что касается IP-адресов, то этот компонент проверяет, находится ли адрес в сетевом стеке и реагирует ли на запросы. Проверяя диски, он пытается соединиться и выполнить команду DIR.
Если сбоит RHS, проверьте журнал системных событий на наличие записей с идентификаторами 1230 и 1146. В событии 1230 фактически указывается ресурс и используемые библиотеки DLL. Если произошел сбой, это означает, что ресурс не отвечает должным образом и возможна взаимная блокировка. Если сбой произошел на дисковом ресурсе, следует просмотреть наличие ошибок, связанных с диском или слишком большим временем отклика диска. Неплохо начать с использования монитора производительности (Performance Monitor). Также приветствуется обновление драйверов и микропрограммы карт или компонентов сети.
Помимо этого следует сделать некоторые наблюдения. Служба кластеризации проводит проверку работоспособности пользовательских процессов из режима ядра, чтобы выявить, когда пользовательский режим перестает отвечать или происходит зависание. Для вывода из этого состояния служба кластеризации проводит проверку ошибкой. Если это происходит, будет получена ошибка Stop 0×0000009E. Чтобы устранить неполадку, надо изучить файл дампа, созданный для поиска причин зависания. Также можно запустить монитор производительности и посмотреть, будут ли возникать зависания, утечки памяти и т. п.
Служба кластеризации зависит от инструментария управления Windows (Windows Management Instrumentation, WMI). Если возникают проблемы с WMI, это ведет к проблемам с кластеризацией (с созданием и добавлением узлов, миграцией и т. п.). Проверьте WMI, например, WBEMTEST.EXE или даже удаленные сценарии WMI.
Один сценарий можно попытаться выполнить в консоли Windows PowerShell (здесь NODE1 — имя узла):
get-wmiobjectmscluster_resourcegroup -computer NODE1 -namespace "ROOT\MSCluster"
Он создает соединение WMI с кластером и предоставляет информацию о группах.
Неудачное завершение сценария указывает на проблемы с WMI. Перезапустите службу WMI, если она остановлена. Может быть повреждена база данных WMI (для проверки целостности воспользуйтесь командой Windows PowerShell: winmgmt /salvagerepository) и т. д.
Помните о ряде правил устранения неполадок:
Проверяйте, проверяйте и еще раз проверяйте. При устранении неполадок используйте тесты для проверки кластера. Используйте их при изменении системы.Все больше систем переводятся на Windows PowerShell, поэтому начните изучать эту технологию, если вы ее еще не освоили.Если вы зависите от объектов Active Directory, защитите их. Разрешите использование корзины Active Directory и защитите объекты от случайного удаления.При устранении неполадок общего тома не забывайте, что проблема может быть не только в неполадках оборудования.При устранении неполадки сделайте шаг назад и внимательно проанализируйте все, на что она может оказывать влияние, и постепенно сужайте поле поиска.
Служба отказоустойчивой кластеризации создана для выявления, восстановления и предоставления отчетов об ошибках. Когда кластер «говорит», что есть или была неполадка, это не значит, что он вызвал ее. Как говорится, "не убивайте гонца, принесшего дурную весть".
Технология развертывания настольных систем с годами сильно изменилась. С первых дней существования Windows 2000 и Windows XP для обеспечения правильного развертывания необходимо было следовать довольно жестким действиям и выполнять настройку вручную. Затем необходимо было подключить образ, используя инструменты сторонних производителей. Сегодня это полностью гибкий процесс, в который было внесено множество улучшений.
Корпорация Майкрософт представила пакеты инструментов развертывания вместе с Windows Vista, и они были значительно улучшены в Windows 7. Эти инструменты предоставляют инфраструктуру для создания образов настольных систем, которые можно настраивать, обновлять, автоматизировать и выполнять развертывание любыми способами в соответствие с потребностями организации. Процесса, выполняемого вручную и бессистемно, больше нет. Теперь можно использовать инструменты, обеспечивающие значительную гибкость и эффективность.
Эти новые инструменты и методы развертывания упрощают и ускоряют процесс развертывания настольных систем. Можно создавать, обновлять и управлять образами Windows 7; сохранять и переносить данные пользователей; устранять проблемы совместимости приложений; и предоставлять более крупную инфраструктуру для объединения всего этого.
Пакет автоматической установки Windows
Начнем с основных стандартных блоков развертывания. Большинство из них входят в состав пакета автоматической установки Windows или Windows AIK. Инструменты, включенные в Windows AIK, обеспечивают большинство функций, необходимых для создания, настройки и развертывания образов Windows (см. рис. 1).
ИнструментОписаниеДиспетчер образов систем WindowsОткрытие образов Windows, создание файлов ответов и управление дистрибутивными общими ресурсами и наборами конфигураций.ImageXЗапись, создание, изменение и применение образов Windows.Средства управления развертыванием образа и обслуживания (DISM)Установка обновлений, драйверов и языковых пакетов для образов Windows. Средства DISM доступны во всех установках Windows 7 и Windows Server 2008 R2.Среда предустановки Windows (Windows PE)Минимальная среда ОС, используемая для развертывания Windows. Windows AIK включает в себя несколько инструментов для создания и настройки сред Windows PE.Средство миграции пользовательской среды (USMT)Перенос данных пользователя из предыдущих ОС Windows в Windows 7.
Рис. 1. Инструменты и служебные программы, входящие в состав Windows AIK.
Если ранее вы использовали некоторые инструменты пакета Windows AIK для Windows Vista, вы заметите, что в Windows AIK для Windows 7 появилось средство USMT 4.0. В состав USMT входит ряд новых функций, включая хранилище миграции жестких ссылок, автономное сохранение данных пользовательской среды и, что самое важное, тесную интеграцию с System Center Configuration Manager и набор средств развертывания Microsoft Deployment Toolkit (MDT). Дополнительные сведения о новых возможностях USMT см. в заметках о выпуске USMT 4.0.
Пакет Windows AIK требуется для большинства описанных в данной статье методов развертывания. Загрузите Windows AIK для Windows 7 здесь.
Виртуализация и совместимость приложений
Одна из самых распространенных проблем, которая может встретиться при выполнении развертывания настольных систем — это совместимость приложений, особенно устаревших приложений (которые могут больше не поддерживаться). Они по-прежнему могут быть критически важны для бизнеса, поэтому их потребуется определить и разместить. Перед тем, как приступить к действительному развертыванию, набор средств для обеспечения совместимости приложений (ACT) поможет устранить возможные проблемы совместимости приложений.
ACT помогает рационализировать существующие приложения благодаря определению возможного дублирования, конфликтующих версий и т.д. Помогая стандартизировать набор приложений в организации вы можете уменьшить число приложений, которые следует протестировать до развертывания.
По завершении процесса рационализации ACT поможет выполнить тестирование каждого приложения на совместимость с Windows 7. Это может быть простым представлением сведений, данных разработчиком приложения с указаниями о совместимости приложения. Однако в некоторых случаях вы столкнетесь с собственными приложениями, для которых требуется более тщательное тестирование. Вы также можете встретиться с известными несовместимым приложениями, для правильной работы которых в Windows 7 требуется перенос.
Для некоторых приложений можно применить исправления совместимости, также называемые оболочками, для обеспечения их правильной работы в Windows 7. Оболочки также можно использовать с большим числом ранее несовместимых приложений, чтобы быстро и просто заставить их работать. Например, оболочки совместимости позволяют запускать приложения якобы с правами администратора или якобы в Windows XP, хотя в действительности приложение выполняется в Windows 7.
Для приложений, проблему несовместимости которых нельзя устранить с помощью оболочек совместимости и ACT, может потребоваться использовать технологию виртуализации для запуска приложений в режиме Windows XP. Также можно использовать Microsoft Enterprise Desktop Virtualization (MED-V) для эмуляции предыдущих версий Windows.
MED-V входит в состав пакета оптимизации рабочей среды Майкрософт (MDOP). Она позволяет запускать приложения на виртуальной машине под управлением более старых ОС совершенно прозрачно и безболезненно для пользователей. Приложения работают так, как если бы они были установлены на настольной системе. Пользователи могут даже прикреплять их к панели задач.
Набор средств развертывания Microsoft (MDT)
После установки Windows AIK и использования ACT для подготовки приложений к переносу в Windows 7 необходимо приступить к созданию и развертыванию образов Windows 7. MDT — основа процесса развертывания настольных систем. Этот набор средств развертывания предоставляет исчерпывающую платформу для настройки, автоматизации и развертывания новых настольных систем Windows 7. Он также поддерживает развертывание Windows Server 2008 R2, Windows Server 2008 и Windows Server 2003.
Последняя версия, MDT 2010 Update 1, предлагает ряд улучшений. Теперь она поддерживает Office 2010, позволяя пользователям инициировать и настраивать собственные развертывания с помощью Configuration Manager и имеет улучшенную поддержку драйвера Windows 7.
Благодаря централизованной панели управления под названием Deployment Workbench (см. рис. 2) MDT полностью автоматизирует процесс развертывания новой ОС. MDT поддерживает три основные сценария развертывания. Установка с незначительным участием пользователей (LTI), установка без участия пользователя (ZTI) и установка с участием пользователей (UDI). Каждый сценарий обеспечивает различные уровни автоматизации и взаимодействия с пользователем на основе потребностей и возможностей. Более подробно о выборе наилучшего сценария для вашей ситуации можно в документе «Использование набора Microsoft Deployment Toolkit», который включен в загрузку MDT.
Рис. 2. Средство Deployment Workbench в MDT 2010 Update 1.
Также существует ряд подходов к созданию образов. Можно создать «толстый» образ, содержащий всю настольную среду, включая ОС, драйверы, приложения и т.д.
И наоборот, «тонкие» образы представляют минималистский подход и содержат только самое необходимое для создания настольной вычислительной среды. Этот подход позволяет добавлять приложения и параметры позднее.
Наконец, название «гибридный образ» говорит само за себя: это «компромиссный» образ, включающий в себя основные приложения и настройки, относящиеся ко всем пользователям. Позднее можно применить дополнительные настройки.
После выбора способа развертывания и типа образа необходимо использовать MDT для создания общего ресурса развертывания (см. рис. 3). В нем будут храниться образы, и из него будет выполняться их развертывание.
Рис. 3. Создание нового общего ресурса развертывания.
В новый общий ресурс развертывания можно добавлять операционные системы (см. рис. 4), приложения, пакеты (включая системные обновления, исправления и т.д.) и драйверы.
Рис. 4. Добавление ОС к общему ресурсу развертывания.
После добавления всех компонентов необходимо создать последовательность задач. Эта последовательность контролирует все ключевые этапы выполнения основных сценариев развертывания. MDT включает ряд шаблонов последовательностей задач, помогающих приступить к работе, включая Standard Client deployment task sequence (см. рис. 5).
Рис. 5. Последовательность Standard Client deployment task sequence.
В шаблоне последовательности задач можно добавлять, удалять и настраивать все этапы развертывания в соответствие с собственными потребностям. На вкладке «OS Info» (Информация ОС) шаблона можно открыть Windows SIM, входящий в состав Windows AIK. Windows SIM (см. рис. 6) позволяет изменять атрибуты ОС, включая информацию о регистрации и активации, внешний вид, членство в домене и т.д.
Рис. 6. Изменение атрибутов образа Windows в Windows SIM.
Это далеко не все. Как упоминалось ранее, платформа MDT включает ряд сценариев развертывания, включая LTI, ZTI и UDI. Они используют различные технологии развертывания, включая Windows Deployment Services (WDS) и nd System Center Configuration Manager. В файлы справки MDT включена полная документация и пошаговые инструкции для этих сценариев.
Можно загрузить последний выпуск MDT вместе с полной документацией процессов и инструментов MDT. Также доступна готовая к печати документация. Кроме того, обязательно ознакомьтесь сблогом Майкла Нихауса (Michael Niehaus) и блогом Deployment Guys, в которых содержатся дополнительные советы, видео и пошаговые руководства по развертыванию Windows 7 вместе с MDT.
Как давнего программиста на Python меня заинтриговало интервью с Доном Сайми (Don Syme), архитектором языка F#. В этом интервью Дон упомянул, что «некоторые рассматривают [F#] как строго типизированную разновидность Python, вплоть до различий в синтаксисе». Это поразило меня, и я решил, что этот вопрос стоит изучить глубже.
Как оказалось, F# — совершенно новый и очень интересный язык программирования, который до сих пор остается загадкой для многих разработчиков. F# предлагает те же преимущества в эффективности труда программистов, что и Ruby/Python в последние годы. F# — подобно Ruby и Python — высокоуровневый язык с минимальным синтаксисом и элегантностью выражения. Но настоящая уникальность F# заключается в том, что он сочетает в себе эти практичные средства с изощренной системой логического распознавания типов (type-inference system) и многими достижениями из мира функционального программирования. Это размещает F# в классе с несколькими узлами.
Но новый высокопродуктивный язык программирования — не единственная на сегодня новая интересная технология.
Широкое распространение облачных платформ вроде Windows Azure делают доступными распределенные хранилища и компьютерные ресурсы как крупным компаниям, так и разработчикам-одиночкам. Наряду с облачными хранилищами появились полезные инструменты наподобие горизонтально масштабируемого алгоритма MapReduce, которые позволяют быстро писать код, способный эффективно анализировать и сортировать потенциально гигантские наборы данных.
Этот подход удобен для отладки на скорую руку F#-программ. Такие инструменты дают возможность, написав несколько строк кода и развернув их в облаке, манипулировать гигабайтами данных. Просто замечательно.
В этой статье я поделюсь с вами некоторыми из своих находок в области F#, Windows Azure и MapReduce. Я покажу, как можно использовать F# и алгоритм MapReduce для разбора файлов журналов в Windows Azure. Сначала мы рассмотрим некоторые методики создания прототипов (prototyping), чтобы уменьшить сложность программирования MapReduce, а затем перенесем результаты нашего труда в… облако.
Азы работы с F#
Один из новых стилей работы, ставших доступными .NET-программистам с появлением F#, — рабочий процесс Interactive, привычный многим программистам на Perl, Python и Ruby. При таком стиле программирования часто используют среду интерактивного кодирования вроде оболочки самого Python или отдельный инструмент, например IPython. Это позволяет разработчику импортировать класс из модуля, создать его экземпляр, а затем, используя командную строку, изучить его методы и данные.
Распространенный способ интерактивной разработки, который станет привычным .NET-программистам, — простое написание кода в Visual Studio и передача его фрагментов в окно F# Interactive для выполнения. Фрагменты отправляются нажатием комбинации клавиш Alt+Enter применительно к текущему выделенному тексту. Кроме того, нажатие Alt+' позволяет отправить в это окно единственную строку. Пример использования этой методики показан на рис. 1.
Рис. 1. Использование окна F# Interactive
Этот подход удобен для отладки на скорую руку F#-программ; кроме того, в разрабатываемом вами скрипте на F# доступны и IntelliSense, и выполнение из командной строки.
Второй подход — вы вновь пишете код в Visual Studio, но потом копируете разделы кода из Visual Studio и напрямую вставляете их в автономную F# Interactive Console (рис. 2). В этом случае вы должны добавлять в конец вставленного кода две точки с запятыми. Это позволяет интерактивно взаимодействовать с кодом и дополнительно использовать преимущества выполнения из командной строки. Возможно, вы перейдете на этот вариант, когда привыкнете программировать в более интерактивном стиле.
Рис. 2. F# Interactive Console
Вы также можете вести интерактивную разработку на F#, запуская свой код непосредственно из Windows PowerShell; иначе говоря, передавая скрипт в fsi.exe (исполняемый файл F# Interactive Console) Одно из преимуществ такого подхода в том, что это позволяет быстро создавать прототипы скриптов и передавать результаты на стандартный вывод. Кроме того, можно итеративно изменять код в «облегченном» текстовом редакторе, например в Notepad++. На рис. 3 показан пример вывода от скрипта MapReduce, запускаемого из Windows PowerShell; этот скрипт я буду часто использовать в данной статье.
Рис. 3. Выполнение скрипта на F# в Windows PowerShell
PS C:\Users\Administrator\Desktop> & 'C:\Program Files (x86)\FSharp-2.0.0.0\bin\fsi.exe' mapreduce.fsscript192.168.1.1, 11192.168.1.2, 9192.168.1.3, 8192.168.1.4, 7192.168.1.5, 6192.168.1.6, 5192.168.1.7, 5
Все эти разные способы написания кода помогают справляться со сложными алгоритмами, программированием для сетей и облака. Вы можете быстро писать черновые варианты скриптов и запускать их из командной строки, чтобы увидеть, дают ли они ожидаемые вами результаты. После этого вы сможете вернуться к созданию более крупных проектов в Visual Studio.
Теперь, когда вы знаете азы работы в F#, давайте углубимся в какой-нибудь реальный код.
Разбор журналов в стиле MapReduce
В дополнение к ранее упомянутым преимуществам интерактивного программирования код на F# является четким и эффективным. Пример на рис. 4 состоит менее чем из 50 строк кода, но содержит все важные части алгоритма MapReduce для вычисления десяти наиболее часто встречающихся IP-адресов в наборе файлов журналов.
Рис. 4.Алгоритм MapReduce для разбора файла журнала
open System.IOopen System.Collections.Generic// Map Phaselet inputFile = @”web.log”let mapLogFileIpAddr logFile = let fileReader logFile = seq { use fileReader = new StreamReader(File.OpenRead(logFile)) while not fileReader.EndOfStream do yield fileReader.ReadLine() } // Takes lines and extracts IP Address Out, // filter invalid lines out first let cutIp = let line = fileReader inputFile line |> Seq.filter (fun line -> not (line.StartsWith(”#”))) |> Seq.map (fun line -> line.Split [|' '|]) |> Seq.map (fun line -> line.[8],1) |> Seq.toArray cutIp// Reduce Phaselet ipMatches = mapLogFileIpAddr inputFilelet reduceFileIpAddr = Array.fold (fun (acc : Map<string, int>) ((ipAddr, num) : string * int) -> if Map.containsKey ipAddr acc then let ipFreq = acc.[ipAddr] Map.add ipAddr (ipFreq + num) acc else Map.add ipAddr 1 acc) Map.empty ipMatches// Display Top 10 Ip Addresseslet topIpAddressOutput reduceOutput = let sortedResults = reduceFileIpAddr |> Map.toSeq |> Seq.sortBy (fun (ip, ipFreq) -> -ipFreq) |> Seq.take 10 sortedResults |> Seq.iter(fun (ip, ipFreq) -> printfn “%s, %d” ip ipFreq);;reduceFileIpAddr |> topIpAddressOutput
Эту автономную версию, которая впоследствии станет сетевой, можно разбить на три фазы: сопоставление (map phase), сокращение (reduce phase) и отображение (display phase).
Первой фазой является сопоставление. Функция mapLogFileIpAddr принимает файл журнала как параметр. В этой функции определена другая функция, fileReader, в которой используется методика функционального программирования для отложенного получения строки текста из файла (впрочем, языки вроде C# и Python тоже позволяют делать это). Далее функция cutIp разбирает каждую строку ввода, отбрасывает строки комментариев и возвращает IP-адрес и целое число (1).
Это эффективная методика обработки данных. Выделите весь блок кода, отвечающего за сопоставление, и выполните его в окне F# Interactive вместе со строкой:
let ipMatches = mapLogFileIpAddr inputFile
Вы увидите следующий вывод:
val ipMatches : seq<string * int>
Заметьте, что на данный момент никаких операций не выполнялось и что файл журнала не считывался. Единственное, что было сделано, — оценено выражение. Благодаря этому выполнение откладывается до тех пор, пока в нем не появится реальная потребность, и данные не извлекаются в память только ради того, чтобы оценить выражение. Это эффективная методика обработки данных, и ее эффективность станет особенно заметной, когда вам понадобится разбор гигантских файлов журналов, размеры которых исчисляются гигабайтами или терабайтами.
Если вы хотите прочувствовать разницу, просто добавьте line в функцию cutIp, чтобы она выглядела так (Примечание. строка |> Seq.toArray для конструкции функции совсем необязательна; в нашем примере она специально используется для убыстрения работы функции, если бы ее не было, функция просто бы работала медленно, как, например, функция mapLogFileIpAddr.):
let cutIp = let line = fileReader inputFile line |> Seq.filter (fun line -> not (line.StartsWith(”#”))) |> Seq.map (fun line -> line.Split [|' '|]) |> Seq.map (fun line -> line.[8],1) |> Seq.toArraycutIp
Если вы отправите этот код интерпретатору F# и предоставите большой файл журнала, содержащий несколько гигабайт данных, то можете прогуляться и выпить чашечку кофе, так как ваш компьютер будет занят чтением всего файла и генерацией для него сопоставлений «ключ-значение» в памяти.
В следующей части конвейера данных я принимаю вывод от предыдущей и отправляю результаты в анонимную функцию, которая подсчитывает число вхождений IP-адресов в последовательности. Все значения добавляются в структуру данных Map через рекурсию. Этот стиль программирования может оказаться трудным в понимании для разработчиков — новичков в функциональном программировании, поэтому вы, возможно, захотите вставить выражения print внутрь анонимной функции, чтобы видеть, что именно она делает.
При императивном программировании вы могли бы добиться того же, обновляя изменяемый словарь, в котором каждый IP-адрес хранится как ключ, перебирая последовательность IP-адресов в цикле, а потом обновляя счетчик для каждого адреса.
Последняя фаза не имеет ничего общего с алгоритмом MapReduce, но полезна при изучении скриптов на этапе создания прототипов. Результаты фазы сопоставления передаются из структуры данных Map в Seq, сортируются, а затем выводятся 10 наиболее часто встречающихся. Заметьте, что при таком стиле конвейеризации данных результаты одной операции перетекают в другую безо всякого цикла for.
MapReduce и Windows Azure
Закончив проверку прототипа, пора заняться его переносом в среду, напоминающую производственную. В качестве примера я перенес программу из настольной системы в Windows Azure.
Для начала будет полезно посмотреть на примеры F#-кода для Windows Azure по ссылке code.msdn.microsoft.com/fsharpazure и установить шаблоны Windows Azure. Особенно интересен пример webcrawler, где рабочая роль F# использует хранилище двоичных объектов и очереди. Этот проект стоит тщательно изучить, если вас интересует более «продвинутое» применение F# в сочетании Windows Azure.
Я не стану детально рассматривать конфигурирование фермы MapReduce со множеством узлов. Вместо этого я представлю общий обзор. Вся специфика изложена в статье Джоша Твиста (Josh Twist) в журнале MSDNMagazine «Synchronizing Multiple Nodes in Windows Azure» (msdn.microsoft.com/magazine/gg309174).
Сконфигурировать ферму MapReduce в Windows Azure можно несколькими способами. Один из примеров использования рабочих ролей F#, которые равномерно распределяются между рабочими ролями сопоставления (Map Workers) и сокращения (Reduce Workers), показан на рис. 5. Сделать это очень легко: вы просто копируете и вставляете функцию сопоставления в Map Worker, а функцию сокращения — в Reduce Worker.
Рис. 5. Ферма MapReduce в Windows Azure
Детальное объяснение распределенного алгоритма и его возможных реализаций отлично представлено в презентации MapReduce, подготовленной Джеффом Дином (Jeff Dean) и Санжаем Гемаватом (Sanjay Ghemawat) (labs.google.com/papers/mapreduce-osdi04-slides/). Однако в примере на рис. 5 показано, что рабочие роли F# параллельно используют несколько файлов журналов. Потом они возвращают свой вывод, состоящий из ключей IP-адресов со значением 1, через Windows Azure AppFabric Service Bus рабочим ролям сокращения или с помощью записи на диск.
Далее рабочие роли сокращения считывают эти промежуточные данные и выдают суммарный счетчик пар «ключ-значение», записывая их в хранилище двоичных объектов. Каждая такая роль создает собственный отчет, и все эти отчеты надо объединить перед сортировкой и отображением главной рабочей ролью (Master Worker).
Создание и публикация рабочей роли
Закончив прототип и планирование высокоуровневой архитектуры, создайте необходимый проект в Visual Studio 2010 и опубликуйте его в Windows Azure.
Создание рабочей роли F# — не столь простой процесс, как хотелось бы, поэтому давайте разберем все необходимые этапы. Сначала вам потребуется скачать ранее упомянутые шаблоны Windows Azure F#. Затем вы должны создать проект Visual F# для Windows Azure. Я присвоил проекту имя AzureFSharpProject.
После этого у вас появится возможность создать рабочую роль F#, как показано на рис. 6.
Рис. 6. Создание рабочей роли F#
В этот момент вы можете поместить свою функцию сопоставления в роль Map Worker, а функцию сокращения в роль Reduce Worker. Затем создайте дополнительные рабочие роли для других ролей Map Worker и Reduce Worker в зависимости от масштаба ваших потребностей в обработке данных. Канонический справочник — документ Google MapReduce по ссылке labs.google.com/papers/-mapreduce.html. В нем подробно описываются архитектура MapReduce, возможные проблемы и сценарии применения.
Когда вы будете готовы к публикации в Windows Azure, щелкните правой кнопкой мыши свой проект и выберите Publish | Create Service Package Only, как показано на рис. 7.
Рис. 7. Публикация в Windows Azure
Наконец, вам потребуется зарегистрироваться на портале управления Windows Azure и через его интерфейс создать рабочую роль (рис. 8).
Рис. 8. Конфигурирование новой рабочей роли
В этот момент вы можете подключать свои узлы так, как считаете нужным, и обрабатывать алгоритмом MapReduce журналы в облаке. Конечно, этот же подход легко применим к источникам данных, отличных от простых файлов журналов. Универсальный F#-алгоритм MapReduce — наряду с интерактивными средствами, которые я продемонстрировал при его кодировании, — можно использовать практически для любой работы, связанной с разбором, сопоставлением и сокращением.
Дальнейшие шаги
F# — мощный язык программирования, который позволяет решать многие задачи с помощью как небольших блоков кода, так и более сложных программ, составляемых из этих блоков. В этой статье я продемонстрировал, как, написав всего 50 строк кода на F#, можно превратить алгоритм MapReduce в анализатор журналов на основе Windows Azure.
Что касается реализации MapReduce в Windows Azure, то, вероятно, вам стоит прочитать еще пару интересных статей по этой тематике. Сначала обратите внимание на статью «Building a Scalable, Multi-Tenant Application for Windows Azure» в MSDN, где обсуждаются рабочие роли и MapReduce (msdn.microsoft.com/library/ff966483). Потом прочтите статью Хуана Диаз (Juan Diaz) «Comparison of the use of Amazon’s EC2 and Windows Azure, cloud computing and implementation of MapReduce», в блоге по ссылке (bit.ly/hBQFSt).
Если вы еще не освоили F#, то, надеюсь, эта статья подтолкнет вас к тому, чтобы попробовать поработать на этом языке. А если вам интересно прослушать все интервью Дона Сайми (Don Syme), отправляйтесь в блог Simple-Talk (bit.ly/eI74iO).

Рубрики
Облако тегов
Записи RSS
Комментарии RSS
Последние 50 записей
Назад
Пустота « По умолчанию
Жизнь
Земля
Ветер
Вода
Огонь
Свет 