Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
ACID-транзакции с использованием STM.NET
Тэд Ньюард
Хотя эта рубрика посвящена в основном языкам программирования, интересно посмотреть, как иногда идеи из одних языков перетекают в другие без явной их модификации.
Один из таких примеров — язык C-Omega от Microsoft Research (его название еще писали как Cw, так как греческая буква "омега" сильно напоминает букву "w" в английском языке). Помимо введения ряда концепций унификации данных и кода, которые в конечном счете проникли в языки C# и Visual Basic в виде LINQ, C-Omega также предложил новые средства параллельной обработки, названные хордами (chords), которые позднее превратились в библиотеку Joins. Хотя на момент написания этой статьи Joins еще не стала продуктом, тот факт, что концепция хорд в целом может быть представлена в виде библиотеки, означает, что ее могла бы использовать любая программа, написанная на C#, Visual Basic или другом .NET-языке.
Другой пример — механизм Code Contracts (контрактов кода), доступный на веб-сайте Microsoft DevLabs и обсуждавшийся в августовском номере MSDN Magazine за 2009 г. Проектирование по контракту (design-by-contract) — языковое средство, характерное для языков наподобие Eiffel и изначально попавшее в .NET через язык Spec# от Microsoft Research. Похожие системы гарантий на основе контрактов тоже были созданы Microsoft Research, в том числе одна из моих любимых — Fugue, которая использовала собственные атрибуты и статический анализ для проверки корректности клиентского кода.
И вновь, хотя контракты кода не поставляются как официальный продукт или с лицензией, разрешающей их применение в коммерческом ПО, тот факт, что они существуют в виде библиотеки, а не какого-то автономного языка, подразумевает две вещи. Во-первых, что их мог бы написать в виде библиотеки теоретически любой достаточно квалифицированный .NET-разработчик, жаждущий использования функциональности такого рода. И во-вторых, что эта функциональность могла бы быть доступна во множестве языков, включая C# и Visual Basic.
Если вы уже догадались, о чем будет моя статья, вы правы. На этот раз я хочу сосредоточиться на еще одной недавно объявленной библиотеке для программистов-полиглотов: STM (Software Transactional Memory). Библиотеку STM.NET можно скачать с сайта DevLabs, но в противоположность некоторым другим реализациям, о которых я упоминал, это не автономная библиотека, связываемая с вашей программой или запускаемая как инструмент статического анализа, а замена и расширение библиотеки базовых классов (.NET Base Class Library) в целом.
Однако заметьте, что текущая реализация STM.NET не особо совместима с текущими бета-версиями Visual Studio 2010, так что будьте вдвойне осторожны при ее установке на компьютеры со всякими незаконченными, предварительными и бета-версиями ПО. Ее следует устанавливать для Visual Studio 2008. И здесь очень пригодится Virtual PC.
Начала
Лингвистические корни STM.NET берут свое начало из самых разных областей, но концептуальная идея STM гениально проста и знакома: вместо того чтобы заставлять разработчиков придумывать средства распараллеливания (блокировки и все такое), дать им возможность помечать, какие части кода должны выполняться с теми или иными характеристиками дружественности к параллельной обработке, и разрешить инструментальным средствам языка (компилятору или интерпретатору) при необходимости самостоятельно управлять блокировками. Другими словами, разработчики подобно администраторам и пользователям баз данных помечают код атрибутами транзакционной семантики в стиле ACID и оставляют все черную работу по управлению блокировками нижележащей среде.
Хотя STM.NET может показаться всего лишь еще одной попыткой управления параллельной обработкой, она отражает нечто более глубокое — поиск путей переноса всех четырех характеристик ACID-транзакций баз данных в модель программирования, размещенную в памяти. Помимо управления блокировками в интересах программиста, модель STM также обеспечивает атомарность (atomicity), согласованность (consistency), изоляцию (isolation) и надежность (durability), которые сами по себе могут значительно упростить программирование независимо от наличия нескольких потоков выполнения.
В качестве примера рассмотрим такой псевдокод (согласен, он используется слишком часто):
BankTransfer(Account from, Account to, int amount) {
from.Debit(amount);
to.Credit(amount);
}
Что будет, если Credit завершится неудачей и вызовет исключение? Ясное дело, пользователь вряд ли обрадуется, если деньги со счета from будут списаны, а на счет to они не поступят, следовательно, в этом случае программисту нужно проделать дополнительную работу:
BankTransfer(Account from, Account to, int amount) {
int originalFromAmount = from.Amount;
int originalToAmount = to.Amount;
try {
from.Debit(amount);
to.Credit(amount);
}
catch (Exception x) {
from.Amount = originalFromAmount;
to.Amount = originalToAmount;
}
}
На первый взгляд это может показаться лишним. Но вспомните, что в зависимости от конкретной реализации методов Debit и Credit исключения могут возникать до завершения операции Debit или после операции Credit (которая в этом случае заканчивается ничем). То есть метод BankTransfer должен гарантировать, что все данные, так или иначе вовлеченные в эту операцию, вернутся точно в то же состояние, в котором они были до начала операции.
Если вам понадобится еще большее усложнение метода BankTransfer (например, операции над тремя или четырьмя элементами данных одновременно), то код восстановления в блоке catch очень быстро превратится в нечто ужасающее. И этот шаблон проявляется гораздо чаще, чем мне хотелось бы.
Стоит отметить еще один момент — изоляцию. В исходном коде другой поток мог бы получить неправильный баланс в процессе его расчета и, как минимум, повредить один из счетов. Более того, если вы просто поставите в это место блокировку, вы можете получить взаимоблокировку потоков, если пары "from-to" не всегда упорядочены. STM просто берет на себя все эти заботы без всяких блокировок.
Если бы вместо этого язык предлагал некую разновидность транзакционной операции, например в виде ключевого слова atomic, за которым скрыта логика блокировки и отката при неудачном завершении по аналогии с BEGIN TRANSACTION/COMMIT для базы данных, то код примера с BankTransfer упростился бы до такого варианта:
BankTransfer(Account from, Account to, int amount) {
atomic {
from.Debit(amount);
to.Credit(amount);
}
}
Согласитесь, в таком коде у вас будет намного меньше забот.
Однако подход STM.NET, основанный на применении библиотеки, так далеко не заходит, поскольку язык C# не обладает нужной степенью синтаксической гибкости. Вместо этого вы будете иметь дело с чем-то, близким к показанному ниже:
public static void Transfer(
BankAccount from, BankAccount to, int amount) {
Atomic.Do(() => {
// Be optimistic, credit the beneficiary first
to.ModifyBalance(amount);
from.ModifyBalance(-amount);
});
}
Взгляд изнутри: это кардинальное изменение в языке
Рецензируя статью Ньюарда, я заметил одну вещь, которая, к сожалению, толкуется в статье неправильно. Ньюард пытается разделить языковые расширения на те, которые требуют изменений в самом языке, и на чисто библиотечные. При этом он пытается классифицировать STM.NET как относящуюся ко второй категории — чисто библиотечной, тогда как это определенно не так.
Расширение на основе только библиотеки реализуется исключительно в рамках существующего языка. Системы STM на основе библиотеки действительно имеются; они, как правило, требуют, чтобы данные, у которых должна быть транзакционная семантика, объявлялись со специальным типом вроде "TransactionalInt". STM.NET отнюдь не такова — она прозрачно предоставляет транзакционную семантику для обычных данных, просто в силу обращения к ней в рамках (динамической) области транзакции.
Это требует модификации каждой операции чтения и записи, выполняемой в коде в рамках транзакции, — введения дополнительных вызовов, которые запрашивают необходимые блокировки, создают и заполняют теневые копии и т. д. В нашей реализации мы основательно модифицировали JIT-компилятор в CLR, чтобы генерировать совершенно другой код, выполняемый в рамках транзакций. Ключевое слово atomic (даже если бы мы предоставили его через API на основе делегата) меняет языковую семантику на фундаментальном уровне.
Таким образом, я заявляю, что мы действительно изменили сам язык. В .NET-языке вроде C# языковая семантика реализуется комбинацией компилятора исходного кода и его допущений в отношении семантики генерируемого им MSIL-кода — того, как исполняющая среда CLR будет выполнять этот IL-код. Мы радикально изменили интерпретацию байтовых кодов (bytecodes) исполняющей средой CLR, и поэтому я бы сказал, что это меняет язык.
В частности, рассмотрим, что происходит, когда JIT-компилятор встречает такой код:
try {<br xmlns="http://www.w3.org/1999/xhtml" /> <body> <br xmlns="http://www.w3.org/1999/xhtml" />} <br xmlns="http://www.w3.org/1999/xhtml" />catch (AtomicMarkerException) {}
Код внутри <body> (и рекурсивно внутри методов, вызываемых из него) динамически модифицируется для обеспечения транзакционной семантики. Должен подчеркнуть, что это не имеет абсолютно ничего общего с обработкой исключений; это просто "хакерский" прием для идентификации атомарного блока, так как конструкция try/catch — единственный механизм, доступный в IL, для определения лексически отграниченного блока. В перспективе мы предпочли бы увидеть в языке IL нечто более похожее на явный блок "atomic". Интерфейс на основе делегата реализуется в терминах этого эрзаца атомарного блока.
В целом, атомарный блок уровня IL, каким бы образом он ни выражался, действительно фундаментальным образом изменяет семантику кода, который выполняется в нем. Вот почему STM.NET содержит новую, значительно модифицированную исполняющую среду CLR, а не просто вносит изменения в BCL. Если вы возьмете обычную CLR и запустите ее с BCL из STM.NET, вы не получите никакой транзакционной семантики (я даже сомневаюсь, что она вообще будет работать).
—Дэйв Детлефс (Dave Detlefs), архитектор из группы Microsoft CLR.
Этот синтаксис не столь элегантен, каким могло бы быть ключевое слово atomic, но C# обладает мощью анонимных методов для отграничения блока кода, который мог бы образовать тело нужного нам атомарного блока, и тем самым его можно выполнить с использованием сходной семантики. (Увы, на момент написания статьи проект STM.NET поддерживает только C#. Никаких технических препятствий для распространения этого проекта на любые другие языки нет, но в первой версии STM.NET его разработчики решили ограничиться C#.)
Приступаем к работе с STM.NET
Первым делом скачайте Microsoft .NET Framework 4 beta 1 с поддержкой использования Software Transactional Memory V1.0 (длиннющее название, которое я сокращу до STM.NET BCL, или просто STM.NET) с веб-сайта DevLabs. Находясь на этом сайте, также скачайте STM.NET Documentation and Samples. Первый комплект содержит саму BCL, средства STM.NET и сопутствующие сборки, а второй — включает, помимо документации и проектов-примеров, шаблон Visual Studio 2008 для создания приложений STM.NET.
Создание приложения с поддержкой STM.NET начинается так же, как и любого другого, — в диалоге New Project (рис. 1). Выбор шаблона TMConsoleApplication влечет за собой ряд операций, некоторые из которых не слишком понятны на интуитивном уровне. Например, на момент написания статьи запуск с использованием библиотек STM.NET требует вот такого фокуса с версиями в файле app.config .NET-приложения:
Рис. 1 Создание нового проекта с помощью шаблона TMConsoleApplication
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<requiredRuntime version="v4.0.20506"/>
</startup>
...
</configuration>
Появится окно с другими настройками шаблона, но значение requiredRuntime необходимо, чтобы загрузчик CLR осуществил связывание с STM.NET-версией исполняющей среды. Кроме того, шаблон TMConsoleApplication связывает данную сборку с версиями сборок mscorlib и System.Transactions, установленными в каталоге, где установлена STM.NET, а не с версиями, поставляемыми с обычной CLR из .NET Framework 3.0 или 3.5. Это требуется потому, что если STM.NET будет обеспечивать транзакционный доступ не только для вашего кода, ей понадобится собственная копия mscorlib. Вдобавок, для корректного взаимодействия с другими формами транзакций, например с облегченными транзакциями, предоставляемыми Lightweight Transaction Manager (LTM), ей нужна и своя версия System.Transactions.
В остальном STM.NET-приложение будет традиционной .NET-программой, написанной на C# и скомпилированной в IL-код, который связывается с оставшейся частью не модифицированных .NET-сборок и т. д. STM.NET-сборки компонентов COM+ и EnterpriseServices получат несколько расширений, описывающих транзакционные поведения для методов, которые взаимодействуют с транзакционным поведением STM.NET, но об этом я расскажу в свое время.
Hello, STM.NET
Как и в примере на Axum в сентябрьском номере MSDN Magazine за 2009 г., написание традиционной программы Hello World в качестве отправной точки в изучении STM.NET на самом деле труднее, чем можно было бы подумать поначалу, хотя по большей части, если вы пишете ее без использования транзакций, она идентична Hello World на C#. А если вы хотите использовать преимущества транзакционного поведения STM.NET, вам придется задуматься над тем, что вывод текста в консоль фактически является действием, которое нельзя отменить (по крайней мере в рамках STM.NET), а значит, попытка отката выражения Console.WriteLine весьма проблематична.
Так что вместо этого возьмем простой пример из STM.NET User Guide. У объекта (с именем MyObject) есть две закрытые строки и метод, который присваивает этим строкам какие-либо пары значений:
class MyObject {
private string m_string1 = "1";
private string m_string2 = "2";
public bool Validate() {
return (m_string1.Equals(m_string2) == false);
}
public void SetStrings(string s1, string s2) {
m_string1 = s1;
Thread.Sleep(1); // simulates some work
m_string2 = s2;
}
}
Поскольку присваивание параметра полю само по себе является атомарной операцией, беспокоиться о проблемах параллельной обработки здесь не приходится. Но, как и в примере с BankAccount, нужно, чтобы значения были присвоены обеим строкам или ни одной из них — частичное обновление не приемлемо. Два потока тупо присваивают значения этим строкам снова и снова, а третий поток проверяет содержимое экземпляра MyObject, сообщая о нарушении в событии Validate, которое в этом случае возвращает false (рис. 2).
Рис. 2 Проверка атомарных обновлений объекта MyObject вручную
[AtomicNotSupported]
static void Main(string[] args) {
MyObject obj = new MyObject();
int completionCounter = 0; int iterations = 1000;
bool violations = false;
Thread t1 = new Thread(new ThreadStart(delegate {
for (int i = 0; i < iterations; i++)
obj.SetStrings("Hello", "World");
completionCounter++;
}));
Thread t2 = new Thread(new ThreadStart(delegate {
for (int i = 0; i < iterations; i++)
obj.SetStrings("World", "Hello");
completionCounter++;
}));
Thread t3 = new Thread(new ThreadStart(delegate {
while (completionCounter < 2) {
if (!obj.Validate()) {
Console.WriteLine("Violation!");
violations = true;
}
}
}));
t1.Start(); t2.Start(); t3.Start();
while (completionCounter < 2)
Thread.Sleep(1000);
Console.WriteLine("Violations: " + violations);
...
Заметьте: благодаря структуре этого примера проверка заканчивается неудачей, если двум строкам в obj присвоены одинаковые значения, а это указывает на то, что SetStrings("Hello", "World") из потока t1 выполнил частичное обновление (оставив первый "Hello" совпадающим со вторым "Hello", присвоенным потоком t2).
Внимательно посмотрев на реализацию SetStrings, вы заметите, что этот код вряд ли можно назвать безопасным в многопоточной среде. Если происходит переключение потока в середине операции, другой поток может легко вновь попасть в середину SetStrings, переведя экземпляр MyObject в недопустимое состояние. Запустите пример и по прошествии достаточного числа итераций начнут появляться сообщения о нарушениях. (На моем лэптопе хватило двух циклов, чтобы получить нарушение, так что, если программа какое-то время работает без ошибок, это еще не означает, что в ее коде нет ошибок параллельной обработки.)
Модификация этого примера под STM.NET требует лишь небольших изменений в классе MyObject (рис. 3).
Рис. 3 Проверка MyObject с помощью STM.NET
class MyObject {
private string m_string1 = "1";
private string m_string2 = "2";
public bool Validate() {
bool result = false;
Atomic.Do(() => {
result = (m_string1.Equals(m_string2) == false);
});
return result;
}
public void SetStrings(string s1, string s2) {
Atomic.Do(() => {
m_string1 = s1;
Thread.Sleep(1); // simulates some work
m_string2 = s2;
});
}
}
Как видите, нам понадобилось лишь обернуть тела Validate и SetStrings в атомарные методы с использованием операции Atomic.Do. Теперь, сколько бы вы ни выполняли пример, никаких нарушений не будет.
Транзакционное поведение
Наблюдательные читатели наверняка заметили атрибут [AtomicNotSupported] в начале метода Main на рис. 2 и, вероятно, их заинтересовало его предназначение или даже возник вопрос, а не идентичен ли он аналогичному атрибуту времен COM+. Оказывается, все именно так: среде STM.NET нужна помощь в определении того, какие методы, вызываемые в блоке Atomic, дружественны к транзакциям; тогда она сможет предоставить необходимую поддержку этим методам.
В текущей версии STM.NET доступны три атрибута:
- AtomicSupported — сборка, метод, поле или делегат поддерживает транзакционное поведение и может успешно использоваться внутри или вне атомарных блоков;
- AtomicNotSupported — сборка, метод или делегат не поддерживает транзакционное поведение и поэтому не может использоваться внутри атомарных блоков;
- AtomicRequired — сборка, метод, поле или делегат не только поддерживает транзакционное поведение, но и может использоваться лишь внутри атомарных блоков (тем самым гарантируя, что применение этого элемента всегда будет происходить в транзакционной семантике).
С технической точки зрения, существует и четвертый атрибут, AtomicUnchecked, который указывает STM.NET, что данный элемент не подлежит проверке, и точка. Он используется как удобная возможность увильнуть от проверки всего кода.
Именно наличие атрибута AtomicNotSupported заставляет систему STM.NET генерировать исключение AtomicContractViolationException, когда предпринимается попытка выполнить следующий (наивный) код:
[AtomicNotSupported]
static void Main(string[] args) {
Atomic.Do( () => {
Console.WriteLine("Howdy, world!");
});
System.Console.WriteLine("Simulation done");
}
Поскольку метод System.Console.WriteLine не помечен атрибутом AtomicSupported, метод Atomic.Do генерирует исключение, когда видит вызов в атомарном блоке. Такая мера безопасности гарантирует выполнение внутри атомарного блока лишь дружественных к транзакциям методов.
Hello, STM.NET (часть 2)
А как быть, если вы истово хотите написать традиционную программу Hello World? И что делать, если вам действительно нужно вывести строку в консоль (либо записать в файл или выполнить какую-то операцию, не имеющую отношения к транзакциям) в дополнение к двум другим транзакционным операциям, но вывести ее только в случае успешного завершения обеих операций? В STM.NET предусмотрено три способа обработки такой ситуации.
Во-первых, вы можете выполнить нетранзакционную операцию вне транзакции (и только после фиксации этой транзакции), поместив код в блок, передаваемый Atomic.DoAfterCommit. Так как коду внутри этого блока обычно нужно использовать данные, сгенерированные или измененные в рамках транзакции, DoAfterCommit принимает контекст, который передается из транзакции блоку кода в качестве единственного параметра.
Во-вторых, вы можете создать компенсационную операцию (compensating action), которая, если транзакция в конечном счете закончится неудачей, будет выполняться вызовом Atomic.DoWithCompensation — метода, принимающего контекст (для маршалинга данных из транзакции в блок кода фиксации или компенсации (что больше подходит).
В-третьих, вы можете создать Transactional Resource Manager (RM), который понимает, как работать совместно с транзакционной системой STM.NET. На деле это не так трудно, как кажется: просто наследуйте от STM.NET-класса TransactionalOperation, в котором переопределите методы OnCommit и OnAbort, чтобы они вели себя соответствующим образом. Используя этот новый тип RM, вызывайте OnOperation в самом начале работы с ним (что приводит к включению ресурса в транзакцию STM.NET). Затем вызывайте его FailOperation, если окружающие операции закончатся неудачей.
Итак, если вы хотите транзакционно записывать данные в некий текстовый поток, то можете создать диспетчер ресурсов, добавляющий текст, как это делается на рис. 4. Это позволит вам с помощью атрибута [AtomicRequired] потребовать от самого себя записи неких данных в текстовый поток через TxAppender, пока вы находитесь внутри атомарного блока (рис. 5).
Рис. 4 Transactional Resource Manager
public class TxAppender : TransactionalOperation {
private TextWriter m_tw;
private List<string> m_lines;
public TxAppender(TextWriter tw) : base() {
m_tw = tw;
m_lines = new List<string>();
}
// This is the only supported public method
[AtomicRequired]
public void Append(string line) {
OnOperation();
try {
m_lines.Add(line);
}
catch (Exception e) {
FailOperation();
throw e;
}
}
protected override void OnCommit() {
foreach (string line in m_lines) {
m_tw.WriteLine(line);
}
m_lines = new List<string>();
}
protected override void OnAbort() {
m_lines.Clear();
}
}
Рис. 5 Использование TxAppender
public static void Test13() {
TxAppender tracer =
new TxAppender(Console.Out);
Console.WriteLine(
"Before transactions. m_balance= " +
m_balance);
Atomic.Do(delegate() {
tracer.Append("Append 1: " + m_balance);
m_balance = m_balance + 1;
tracer.Append("Append 2: " + m_balance);
});
Console.WriteLine(
"After transactions. m_balance= "
+ m_balance);
Atomic.Do(delegate() {
tracer.Append("Append 1: " + m_balance);
m_balance = m_balance + 1;
tracer.Append("Append 2: " + m_balance);
});
Console.WriteLine(
"After transactions. m_balance= "
+ m_balance);
}
Очевидно, что это более длинный путь, применимый только в ряде случаев. Он может не подойти для некоторых типов данных, но в общем и целом (и при условии, что все фактическое необратимое поведение отложено до метода OnCommit) этого вам хватит для большинства операций с внутрипроцессными транзакциями.
Подготовка к работе с STM.NET
Для работы с системой STM к ней нужно немного привыкнуть, но, как только вы акклиматизируетесь, все пойдет как по маслу. Подумайте о частях приложения, где применение STM.NET поможет упростить кодирование.
При работе с другими транзакционными ресурсами STM.NET можно легко и быстро подключить к существующим транзакционным системам, сделав Atomic.Do единственным источником транзакционного кода в своем приложении. В документации STM.NET есть пример TraditionalTransactions, демонстрирующий как раз такой вариант; он асинхронно отправляет сообщения в закрытую очередь MSMQ, а в случае неудачи блока Atomic никакое сообщение в очередь не посылается. По-видимому, это самый очевидный случай применения.
В диалоговых окнах — особенно в мастерах или диалогах настройки — возможность отката изменений к исходному состоянию при нажатии кнопки Cancel просто неоценима.
В средствах модульного тестирования вроде NUnit, MSTest и других системах прилагают огромные усилия, чтобы при правильном написании тестов результаты одного теста не могли утекать в другой. Если STM.NET выйдет из стадии экспериментального продукта, в NUnit и MSTest можно будет переработать код, выполняющий тесты, под использование транзакций STM, чтобы изолировать тесты друг от друга, осуществляя откат в конце каждого метода теста и тем самым исключая любое воздействие одного теста на другой. Более того, тест, вызывающий метод AtomicUnsupported, будет помечаться в период выполнения этого теста как ошибочный, что предотвратит автоматическую утечку результатов теста в какое-либо хранилище за пределами тестовой среды (например, на диск или в базу данных).
STM.NET также можно использовать в реализации свойств объектов предметной области (domain objects). Хотя большинство объектов предметной области имеют сравнительно простые свойства, через которые присваиваются значения полям или возвращаются значения этих полей, в более сложных свойствах могут выполняться многошаговые алгоритмы, создающие риск того, что в многопоточной среде могут происходить частичные обновления (если поток вызывает свойство при его установке другим потоком) или фантомные (когда поток вызывает свойство при его установке другим потоком и исходное обновление в конечном счете отбрасывается из-за какого-либо нарушения).
Еще любопытнее тот факт, что исследователи, работающие вне Microsoft, ищут способы вывода транзакций на аппаратный уровень, чтобы в недалеком будущем обновление поля объекта или локальной переменной происходило как транзакция с аппаратным контролем в самом чипе памяти. Такие транзакции будут работать радикально быстрее в сравнении с нынешними методами.
Однако, как и в случае с Axum, Microsoft полагается на ваши отзывы, чтобы определиться, стоит ли продолжать работы над этой технологией, поэтому, если вас заинтересовала эта идея, обязательно дайте знать об этом Microsoft.
Тэд Ньюард (Ted Neward) — глава компании Neward and Associates, специализирующейся на гибких и надежных корпоративных системах с применением .NET и Java. Автор многочисленных книг, лектор INETA, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области архитектуры. С ним можно связаться по адресу ted@tedneward.com или через блог <blogs.tedneward.com>.
Выражаю благодарность за рецензирование этой статьи экспертамДэйву Детлефу (Dave Detlefs) и Дане Грофф (Dana Groff).