[Перевод] Параллельные вычисления — Все дело в контексте-синхронизации (SynchronizationContext)

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

Независимо от платформы — будь то ASP.NET, Windows Forms, Windows Presentation Foundation (WPF), Silverlight или другие — все .NET программы включают концепцию SynchronizationContext, и все программисты многопоточных решений могут извлечь выгоду из ее понимания и применения.

Необходимость в SynchronizationContext

Многопоточные программы существовали задолго до появления .NET Framework. Этим программам часто требовалось, чтобы один поток передавал «единицу работы»* другому потоку. Программы Windows были построены на основе концепции «цикл обработки сообщений», поэтому многие программисты использовали эту встроенную очередь сообщений для передачи единиц работы в цикл обработки. Каждая многопоточная программа, которая хотела использовать очередь сообщений Windows таким образом, должна была определить свое собственное пользовательское сообщение Windows и соглашение для его обработки.

<ред:* «единица работы» в исходном тексте «a unit of work» очень интересный термин который обозначает не только именованные и неименованные-лямбда функции, это может быть просто некоторый кусок кода-логики выделенный компилятором для передачи на исполнение в некотором контексте>

Когда .NET Framework был впервые опубликован, этот хорошо известный шаблон был стандартизирован. В то время единственным типом приложения с графическим интерфейсом, который поддерживался .NET, был Windows Forms. Однако разработчики платформы предвидели другие модели и разработали универсальное решение. Родился ISynchronizeInvoke.

Идея, лежащая в основе ISynchronizeInvoke, заключается в том, что «исходный» поток может поставить делегат* в очередь к «целевому» потоку, необязательно ожидая завершения выполнения этого делегата. ISynchronizeInvoke также предоставлял свойство для определения того, был ли текущий код сразу запущен на исполнение в целевом потоке; в этом случае постановка делегата в очередь была бы ненужной. Windows Forms предоставила единственную реализацию ISynchronizeInvoke, и был разработан шаблон для проектирования асинхронных компонентов, так что все были довольны.

<ред:* делегат – указатель на функцию именованную или анонимную или сформированную компилятором из какого-то куска кода или делегат в терминах C#>

Версия 2.0 .NET Framework содержала множество радикальных изменений. Одним из основных улучшений было внедрение асинхронных страниц в архитектуру ASP.NET. До .NET Framework 2.0 для каждого ASP.NET запроса требовался поток, пока запрос не будет завершен. Это было неэффективное использование потоков, поскольку создание веб-страницы часто зависит от запросов к базе данных и вызовов веб-служб, и потоку, обрабатывающему этот запрос, пришлось бы ждать завершения каждой из этих операций. С асинхронными страницами поток, обрабатывающий запрос, мог начинать каждую из операций <ред: из списка операций, составляющих запрос>*, а затем возвращаться обратно в пул потоков ASP.NET, когда операция завершались, другой поток из пула потоков ASP.NET <ред: выполнял следующую операцию из списка и в конце концов> завершал запрос.

<ред:* здесь дана настолько краткая формулировка принципа, можно сказать скомканная, что ее приходится уточнять>

Однако ISynchronizeInvoke плохо подходил для этой новой архитектуры ASP.NET асинхронных страниц. Асинхронные компоненты, разработанные с использованием шаблона ISynchronizeInvoke, не будут корректно работать в рамках ASP.NET страниц, поскольку ASP.NET асинхронные страницы связаны не с одним (не с единственным) потоком. Вместо постановки работы в очередь для исходного потока асинхронным страницам нужно только поддерживать количество незавершенных операций, чтобы определить, когда запрос страницы может быть завершен. После долгих размышлений и тщательного проектирования интерфейс ISynchronizeInvoke был заменен интерфейсом SynchronizationContext.

Концепция SynchronizationContext

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

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

Другим аспектом SynchronizationContext является то, что каждый поток имеет «текущий» контекст. Контекст потока не обязательно уникален; контекст конкретного потока может быть общим с другими потоками. Поток может изменить свой текущий контекст, но это довольно редкое явление или необходимость. <ред: то есть каждый поток принадлежит некоторому контексту, и одному контексту могут принадлежать несколько потоков. Таким образом, когда вы отдаете задание на выполнение в какой-то контекст, вы, в общем случае не знаете какой поток этого контекста будет исполнять это задание>

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

Существуют и другие аспекты SynchronizationContext, но они менее важны для большинства программистов. Наиболее важные аспекты проиллюстрированы на рисунке 1. (в исходнике используется слово рисунок, пусть будет рисунок)

Рисунок 1 Аспекты API SynchronizationContext

// The important aspects of the SynchronizationContext APIclass SynchronizationContext

{
// Dispatch work to the context.
void Post(..); // (asynchronously)
void Send(..); // (synchronously)
// Keep track of the number of asynchronous operations.
void OperationStarted();
void OperationCompleted();
// Each thread has a current context.
// If "Current" is null, then the thread's current context is
// "new SynchronizationContext()", by convention.
static SynchronizationContext Current { get; }
static void SetSynchronizationContext(SynchronizationContext);
}

Реализации SynchronizationContext

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

1.WindowsFormsSynchronizationContext(System.Windows.Forms.dll: System.Windows.Forms) Приложения Windows Forms создают и задают WindowsFormsSynchronizationContext в качестве текущего контекста для любого потока, который создает элементы управления пользовательским интерфейсом. Этот SynchronizationContext использует методы ISynchronizeInvoke в элементе управления пользовательского интерфейса, который передает делегаты главному циклу сообщений Win32. Контекст для WindowsFormsSynchronizationContext определяет единственный поток пользовательского интерфейса (UI поток).

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

2. DispatcherSynchronizationContext (WindowsBase.dll: System.Windows.Threading) Приложения WPF и Silverlight используют DispatcherSynchronizationContext, который ставит делегатов в очередь к диспетчеру UI потока с «нормальным» приоритетом. Этот SynchronizationContext устанавливается в качестве текущего контекста, когда поток начинает свой цикл диспетчеризации, вызывая Dispatcher.Run. Контекст для DispatcherSynchronizationContext определяет единственный UI поток.

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

3.Default SynchronizationContext (он же ThreadPool) (mscorlib.dll: System.Threading) DefaultSynchronizationContext — это созданный по умолчанию объект SynchronizationContext <ред: который всегда создается при запуске приложения, насколько я понимаю, соответственно, можно сказать, что это изначальный синх-контекст приложения>. По соглашению, если текущий SynchronizationContext потока равен null, то он неявно имеет SynchronizationContext по умолчанию.

DefaultSynchronizationContext помещает свои асинхронные делегаты в очередь ThreadPool, но выполняет свои синхронные делегаты непосредственно в вызывающем потоке. Следовательно, его контекст охватывает все потоки ThreadPool <ред: и текущий активный и любой который потенциально может быть задействован для выполнения асинхронного вызова, насколько я понимаю>, а также любой поток, который вызывает Send. Контекст «заимствует» потоки, вызывающие Send, помещая их в свой контекст до завершения выполнения делегата. В этом смысле, контекст по умолчанию может включать любой поток в процессе.

DefaultSynchronizationContext применяется к потокам ThreadPool, если код не размещен в ASP.NET. DefaultSynchronizationContext также неявно применяется к явным дочерним потокам (экземплярам класса Thread), если дочерний поток не устанавливает свой собственный SynchronizationContext. Таким образом, приложения пользовательского интерфейса обычно имеют два контекста синхронизации: UI SynchronizationContext, включающий поток пользовательского интерфейса, и SynchronizationContext по умолчанию, включающий потоки ThreadPool.

Многие асинхронные компоненты, основанные на событиях, не работают должным образом с SynchronizationContext по умолчанию. Печально известным примером является приложение пользовательского интерфейса, в котором один BackgroundWorker запускает другой BackgroundWorker. Каждый BackgroundWorker захватывает и использует SynchronizationContext потока, который вызывает RunWorkerAsync и позже выполняет свое событие RunWorkerCompleted в этом контексте. В случае одного BackgroundWorker обычно это SynchronizationContext на основе пользовательского интерфейса, поэтому RunWorkerCompleted выполняется в контексте пользовательского интерфейса, захваченном RunWorkerAsync (см. рисунок 2).

Рисунок 2. Одиночный BackgroundWorker в контексте пользовательского интерфейса

Рисунок 2. Одиночный BackgroundWorker в контексте пользовательского интерфейса

Однако, если BackgroundWorker запускает другой BackgroundWorker из своего обработчика DoWork, то вложенный BackgroundWorker не захватывает UI SynchronizationContext. DoWork выполняется потоком ThreadPool с SynchronizationContext по умолчанию. В этом случае вложенный RunWorkerAsync захватит SynchronizationContext по умолчанию, поэтому он выполнит свой RunWorkerCompleted в потоке ThreadPool вместо потока пользовательского интерфейса (см. рисунок 3).

Рисунок 3 Вложенные BackgroundWorkers в контексте пользовательского интерфейса

Рисунок 3 Вложенные BackgroundWorkers в контексте пользовательского интерфейса

По умолчанию все потоки в консольных приложениях и службах Windows имеют только DefaultSynchronizationContext. Это приводит к тому, что некоторые асинхронные компоненты, основанные на событиях, не могут работать в этой концепции. Одним из решений этой проблемы является создание явного дочернего потока и установка SynchronizationContext в этом потоке, который затем может предоставить контекст для таких проблемных компонент. Реализация SynchronizationContext выходит за рамки этой статьи, но класс ActionThread из библиотеки Nito.Async (nitoasync.codeplex.com) может использоваться в качестве реализации SynchronizationContext общего назначения.

AspNetSynchronizationContext (System.Web.dll: System.Web [internal class]) ASP.NET SynchronizationContext назначается потокам Тред-пула при выполнения ими кода страницы. Когда делегат помещается в очередь в контексте AspNetSynchronizationContext, он восстанавливает идентичность и культуру исходной страницы, а затем выполняет делегат напрямую. Делегат вызывается напрямую, даже если он «асинхронно» поставлен в очередь при вызове Post.

Концептуально контекст AspNetSynchronizationContext является сложным*<ред: сноска через три абзаца>. В течение жизненного цикла асинхронной страницы этот контекст применяется (подменяет исходный DefaultSynchronizationContext) к каждому потоку из ASP.NET пула потоков. После запуска асинхронных запросов контекст не включает никаких дополнительных потоков. По мере завершения асинхронных запросов потоки Тред-пула, выполняющие свои процедуры завершения, возвращаются в контекст: DefaultSynchronizationContext. Это могут быть те же потоки, которые инициировали запросы, но, скорее всего, это будут любые потоки, которые окажутся свободными на момент завершения операций.

Если несколько операций выполняются одновременно для одного и того же приложения, AspNetSynchronizationContext гарантирует, что они выполняются по одной за раз. Они могут выполняться в любом потоке, но этот поток будет иметь идентификатор и язык интерфейса исходной страницы. <ред: если что, я тоже не смог понять смысл этого абзаца, хочу отметить что абзац очень короткий.>

Одним из распространенных примеров является WebClient, используемый внутри асинхронной веб-страницы. DownloadDataAsync перехватит текущий SynchronizationContext и позже выполнит свое событие DownloadDataCompleted в этом контексте. Когда страница начнет выполняться, ASP.NET выделит один из своих потоков для выполнения кода на этой странице. Страница может вызвать DownloadDataAsync, который сразу выполнится и завершится; ASP.NET ведет подсчет незавершенных асинхронных операций, поэтому понимает, что страница не завершена. Когда объект WebClient загрузит запрошенные данные, он получит уведомление в потоке Тред-пула. Этот поток вызовет DownloadDataCompleted в перехваченном контексте. Контекст останется в том же потоке, но обеспечит запуск обработчика событий с правильной идентификацией и культурой.

<ред:* действительно как-то очень сложно, я бы даже сказал неоднозначно написано про реализацию контекста AspNetSynchronizationContext, скорее всего это тоже по причине того, что через чур много информации попытались передать всего в трех абзацах, надо искать более подробные пояснения, по моему.>

Замечания по реализациям SynchronizationContext

SynchronizationContext предоставляет средства для написания компонент, которые могут работать в разных фреймворках. BackgroundWorker и WebClient — это два примера, которые одинаково хорошо работают в Windows Forms, WPF, Silverlight, консоли и ASP.NET приложениях. Однако есть некоторые моменты, которые необходимо иметь в виду при проектировании таких повторно используемых компонентов.

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

Не все реализации SynchronizationContext гарантируют порядок выполнения делегатов или синхронизацию делегатов. Реализации SynchronizationContext на основе пользовательского интерфейса удовлетворяют этим условиям, но ASP.NET SynchronizationContext обеспечивает только синхронизацию. SynchronizationContext по умолчанию не гарантирует ни порядок выполнения, ни синхронизацию.

Не существует соответствия 1:1 между экземплярами SynchronizationContext и потоками. WindowsFormsSynchronizationContext имеет сопоставление 1:1 с потоком (при условии, что SynchronizationContext.CreateCopy не вызывается), но это не относится ни к одной из других реализаций. В общем, лучше не предполагать, что какой-либо экземпляр контекста будет запущен в каком-либо конкретном потоке.

Наконец, SynchronizationContext.Post метод не обязательно является асинхронным. Большинство реализаций реализуют его асинхронно, но AspNetSynchronizationContext является выдающимся исключением. Этот факт может вызвать неожиданные проблемы с повторным входом <ред: повторный вход = "stack dives” или «рекурсия» в других работах>. Краткое описание этих различных реализаций можно увидеть на рисунке 4.

Рисунок 4 Краткое описание реализаций SynchronizationContext

Рисунок 4 Краткое описание реализаций SynchronizationContext

AsyncOperationManager и AsyncOperation

Классы AsyncOperationManager и AsyncOperation в .NET Framework являются облегченными оболочками вокруг абстракции SynchronizationContext. AsyncOperationManager фиксирует текущий SynchronizationContext при первом создании AsyncOperation, заменяя SynchronizationContext по умолчанию, если текущее значение равно null. AsyncOperation асинхронно отправляет делегаты в захваченный SynchronizationContext.

Большинство асинхронных компонентов, основанных на событиях, используют в своей реализации AsyncOperationManager и AsyncOperation. Они хорошо работают для асинхронных операций, которые имеют определенную точку завершения — то есть асинхронная операция начинается в одной точке и заканчивается событием в другой. Другие асинхронные уведомления могут не иметь определенной точки завершения; это может быть тип подписки, который начинается в какой-то момент и затем продолжается бесконечно. Для этих типов операций SynchronizationContext может быть захвачен и использован напрямую.

Новые компоненты не должны использовать асинхронный шаблон на основе событий. The Visual Studio asynchronous Community Technology Preview (CTP) включает документ, описывающий шаблон асинхронных решений на основе задач, в котором компоненты возвращают объекты Task и Task вместо создания событий через SynchronizationContext. API-интерфейсы, основанные на задачах, — это будущее асинхронного программирования в .NET.

Примеры поддержки SynchronizationContext из библиотек

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

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

Windows Communication Foundation (WCF): UseSynchronizationContext WCF имеет два атрибута, которые используются для настройки поведения сервера и клиента: ServiceBehaviorAttribute и CallbackBehaviorAttribute. Оба этих атрибута имеют свойство типа Bool: UseSynchronizationContext. Значение этого атрибута по умолчанию равно true, что означает, что текущий SynchronizationContext захватывается при создании канала связи, и этот захваченный SynchronizationContext используется для постановки в очередь методов контракта.

Обычно такое поведение является именно тем, что необходимо: серверы используют SynchronizationContext по умолчанию, а обратные вызовы клиента используют соответствующий SynchronizationContext пользовательского интерфейса. Однако это может вызвать проблемы, когда требуется повторный вход (ограниченная рекурсия), например, когда клиент вызывает серверный метод, который вызывает обратный вызов клиента. В этом и подобных случаях автоматическое использование WCF SynchronizationContext может быть отключено путем установки UseSynchronizationContext в значение false.

Это всего лишь краткое описание того, как WCF использует SynchronizationContext. Более подробную информацию смотрите в статье «Контексты синхронизации в WCF» (msdn.microsoft.com/magazine/cc163321) в ноябрьском номере журнала MSDN за 2007 год.

Windows Workflow Foundation (WF): WorkflowInstance.SynchronizationContext Хосты WF изначально использовали WorkflowSchedulerService и производные типы для управления планированием действий рабочего процесса в потоках. Часть обновления .NET Framework 4 включала свойство SynchronizationContext для класса WorkflowInstance и его производного класса WorkflowApplication.

SynchronizationContext может быть установлен напрямую, если процесс хостинга создает свой собственный WorkflowInstance. SynchronizationContext также используется WorkflowInvoker.InvokeAsync, который захватывает текущий SynchronizationContext и передает его своему внутреннему WorkflowApplication. Этот SynchronizationContext затем используется для публикации события завершения рабочего процесса, а также для отложенного вызова workflow активностей.

Task Parallel Library (TPL): TaskScheduler.FromCurrentSynchronizationContext and CancellationToken.Register TPL использует объекты task в качестве своих единиц работы
и выполняет их через TaskScheduler. Дефолтный TaskScheduler действует как default
SynchronizationContext по умолчанию, помещая задачи в очередь ThreadPool. Существует другой TaskScheduler, предоставляемый TPL, который помещает задачи в очередь SynchronizationContext. Нотификация об изменениях в пользовательском интерфейсе может быть реализована с помощью вложенной задачи, как показано на рисунке 5.

Рисунок 5 Нотификация об изменениях в пользовательском интерфейсе

private void button1_Click(object sender, EventArgs e)
{
    // This TaskScheduler captures SynchronizationContext.Current.
    TaskScheduler taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
    // Start a new task (this uses the default TaskScheduler, 
    // so it will run on a ThreadPool thread).
    Task.Factory.StartNew(() =>
    {
        // We are running on a ThreadPool thread here.
        ; // Do some work.
        // Report progress to the UI.
        Task reportProgressTask = Task.Factory.StartNew(() =>
        {
            // We are running on the UI thread here.
            ; // Update the UI with our progress.
        },
          CancellationToken.None,
          TaskCreationOptions.None,
          taskScheduler);
        reportProgressTask.Wait();
        ; // Do more work.
    });
}

Класс CancellationToken используется для любого типа принудительного завершения (далее «прерывания») в .NET Framework 4. Для интеграции с существующими способами прерывания этот класс позволяет зарегистрировать делегат для вызова при запросе прерывания. Когда делегат зарегистрирован, SynchronizationContext может быть передан. Когда запрашивается прерывание, CancellationToken ставит делегат в очередь SynchronizationContext вместо того, чтобы выполнять его напрямую.

Microsoft Reactive Extensions (Rx): observeOn, subscribeOn и SynchronizationContextScheduler Rx — это библиотека, которая обрабатывает события как потоки данных. Оператор ObserveOn ставит события в очередь через SynchronizationContext, а оператор SubscribeOn ставит в очередь подписки (subscriptions) на эти события через SynchronizationContext. ObserveOn обычно используется для обновления пользовательского интерфейса входящими событиями, а SubscribeOn используется для обработки событий из объектов пользовательского интерфейса.

Rx также имеет свой собственный способ постановки единиц работы в очередь: интерфейс IScheduler. Rx включает SynchronizationContextScheduler, реализацию IScheduler, которая занимается формированием очереди для SynchronizationContext.

 Visual Studio Async CTP: await, ConfigureAwait, switchTo и EventProgress Поддержка Visual Studio асинхронных преобразований кода была анонсирована на конференции Microsoft Professional Developers Conference 2010. По умолчанию текущий SynchronizationContext захватывается в точке с await , и этот SynchronizationContext используется для возобновления после await  (точнее, он захватывает текущий SynchronizationContext, если он не равен null, и в этом случае он захватывает текущий TaskScheduler):

private async void button1_Click(object sender, EventArgs e)
{
    // SynchronizationContext.Current is implicitly captured by await.
    var data = await webClient.DownloadStringTaskAsync(uri);
    // At this point, the captured SynchronizationContext was used to resume
    // execution, so we can freely update UI objects.
}

ConfigureAwait предоставляет средство избежать поведения с захватом default SynchronizationContext; передача значения false для параметра flowContext предотвращает использование SynchronizationContext чтобы можно было возобновить выполнение после инструкции await. Существует также метод расширения для экземпляров SynchronizationContext, называемый switchTo; это позволяет любому асинхронному методу переключаться на другой SynchronizationContext, вызывая switchTo и ожидая результата.

Асинхронный CTP вводит общий шаблон для получения информации о ходе выполнения асинхронных операций: интерфейс IProgress и его реализация EventProgress. Этот класс захватывает текущий SynchronizationContext при его создании и вызывает событие ProgressChanged в этом контексте.

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

Ограничения и гарантии

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

<ред: в исходном тексте в этом месте вы найдете абзац информации об авторе - Stephen Cleary, и, в конце, строчку благодарности Eric Eilebrecht-у за ревью>

<ред: С Наступающим Новым Годом! Счастья, удачи, интересных проектов!

Сёргий>

© Habrahabr.ru