Модульное тестирование асинхронного кода: три решения для улучшения тестов

OSzone.net » Microsoft » Разработка приложений » Другое » Модульное тестирование асинхронного кода: три решения для улучшения тестов
Автор: Свен Гранд
Иcточник: msdn.microsoft.com
Опубликована: 14.10.2015
За последнее десятилетие асинхронное программирование становится все важнее. Асинхронность помогает разработчикам выжимать максимум возможного из доступных ресурсов и в конечном счете делать больше с меньшими затратами. Она же позволяет создавать более «отзывчивые» клиентские приложения и более масштабируемые серверные приложения.

Разработчики ПО освоили множество проектировочных шаблонов для эффективного создания синхронной функциональности, но рекомендации по проектированию асинхронного ПО все еще сравнительно новы, хотя поддержка, предоставляемая языками и библиотеками для параллельного программирования кардинально улучшилась с выпуском Microsoft .NET Framework 4 и 4.5. При обилии хороших материалов для использования новых методов (см. «Best Practices in Asynchronous Programming» по ссылке bit.ly/1ulDCiI и «Talk: Async Best Practices» по ссылке bit.ly/1DsFuMi) рекомендации по проектированию внутренних и внешних API для приложений и библиотек с использованием языковых средств вроде async и await, а также Task Parallel Library (TPL) до сих пор неизвестны многим разработчикам.

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

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

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

Хороший вопрос. Допустим, я должен протестировать такой код:

public void StartAsynchronousOperation()
{
  Message = "Init";
  Task.Run(() =>
    {
      Thread.Sleep(1900);
      Message += " Work";
    });
}
public string Message { get; private set; }

Моя первая попытка написать тест для этого кода была не слишком удачной:

[Test]
public void FragileAndSlowTest()
{
  var sut = new SystemUnderTest();
  // Тестируемая операция будет выполняться в другом потоке
  sut. StartAsynchronousOperation();
  // Плохая идея: рассчитываем, что другой поток
  // завершит выполнение через две секунды
  Thread.Sleep(2000);
  // Результат проверки потока
  Assert.AreEqual("Init Work", sut.Message);
}

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

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

private System.Threading.Timer timer;
private readonly Object lockObject = new Object();

public void StartRecurring()
{
  Message = "Init";
  // Устанавливаем таймер на задержку в одну секунду
  // и интервал в одну секунду
  timer = new Timer(o => { lock(lockObject){ Message +=
    " Poll";} }, null, new TimeSpan(0, 0, 0, 1),
    new TimeSpan(0, 0, 0, 1));
}
public string Message { get; private set; }

И этот тест скорее всего будет иметь те же проблемы:

[Test]
public void FragileAndSlowTestWithTimer()
{
  var sut = new SystemUnderTest();
  // Выполняем код для установки таймера на задержку
  // и интервал в одну секунду
  sut.StartRecurring();
  // Плохая идея: ожидаем трехкратного срабатывания таймера
  Thread.Sleep(3100);
  // Результат проверки
  Assert.AreEqual("Init Poll Poll Poll", sut.Message);
}

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

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

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

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

Модульные тесты для асинхронного кода имеют смысл, когда этот код содержит блоки функциональности, выполняемой в одном потоке, и модульные тесты должны проверять, что эти блоки работают так, как ожидается. Когда модульные тесты показывают, что функциональность выполняется корректно, имеет смысл использовать дополнительные стратегии тестирования, чтобы вскрыть проблемы с параллельной обработкой. Существует несколько подходов к тестированию и анализу многопоточного кода, позволяющих обнаруживать проблемы такого рода (см., например, «Tools and Techniques to Identify Concurrency Issues» по ссылке bit.ly/1tVjpll). В частности, стресс-тесты способны нагрузить всю систему или ее большую часть. Эти стратегии дополняют модульные тесты, но выходят за рамки данной статьи. Решения в этой статье покажут, как исключить многопоточные части, в то же время изолированно тестируя функциональность с помощью модульных тестов.

Решение 1: отделение функциональности от многопоточности

Простейшее решение для модульного тестирования функциональности, задействованной в асинхронных операциях, — ее отделение от многопоточности. Джерард Мезарос (Gerard Mezaros) описывал этот подход в шаблоне Humble Object в своей книге «xUnit Test Patterns» (Addison-Wesley, 2007). Тестируемая функциональность извлекается в новый класс, а многопоточная часть остается в Humble Object, который вызывает новый класс (рис. 1).

*

Рис. 1. Шаблон Humble Object

BeforeДо
ClientКлиент
TestТест
SistemUnderTestSistemUnderTest
FunctionalityFunctionality
AfterПосле
HumbleObjectHumbleObject


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

public class Functionality
{
  public void Init()
  {
    Message = "Init";
  }
  public void Do()
  {
    Message += " Work";
  }
  public string Message { get; private set; }
}

До рефакторинга функциональность была смешана с асинхронным кодом, но я перенес ее в класс Functionality. Этот класс теперь можно тестировать с помощью простых модульных тестов, поскольку он больше не содержит многопоточного кода. Заметьте, что такой рефакторинг важен не только в модульном тестировании: компоненты не должны предоставлять асинхронные оболочки для синхронных по своей природе операций, и вместо этого им следует возлагать на вызывающий код определение того, надо ли освобождать их от запуска подобных операций. В случае модульного теста я предпочел этого не делать, но в приложении-потребителе могут решить иначе по причинам повышения «отзывчивости» или параллельного выполнения. Подробнее на эту тему см. в блоге Стефена Тауба (Stephen Toub) статью «Should I Expose Asynchronous Wrappers for Synchronous Methods?» (bit.ly/1shQPfn).

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

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

Решение 2: синхронизация тестов

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

В лучшем случае тестируемый метод возвращает экземпляр типа, который будет уведомляться о завершении операции. Тип Task, доступный в .NET Framework с версии 4, отлично подходит для этой цели, а языковые средства async/await, появившиеся с выпуском .NET Framework 4.5, упрощают композицию объектов Task:

public async Task DoWorkAsync()
{
  Message = "Init";
  await Task.Run( async() =>
  {
    await Task.Delay(1900);
    Message += " Work";
  });
}
public string Message { get; private set; }

Рефакторинг — универсальная рекомендация, которая помогает как в случае модульного тестирования, так и при использовании предоставляемой асинхронной функциональности. За счет возврата Task, который представляет асинхронную операцию, пользователь кода может легко определить, когда асинхронная операция завершилась — неудачно с исключением или вернула результат.

TЭто упрощает модульное тестирование асинхронного метода до модульного тестирования синхронного метода. Теперь не сложно синхронизировать тест с тестируемым кодом, вызывая целевой метод и ожидая завершения возвращенного Task. Это ожидание может быть синхронным (с блокированием вызывающего потока) через Wait-методы Task или асинхронным (используя так называемые продолжения [continuations], чтобы предотвратить блокирование вызывающего потока) с помощью ключевого слова await, прежде чем проверять результат асинхронной операции (рис. 2).

*

Рис. 2. Синхронизация через async и await

TestТест
CallВызов
AwaitОжидание по await
SistemUnderTestSistemUnderTest


Чтобы использовать await в методе модульного теста, сам тест должен быть объявлен с ключевым словом async в своей сигнатуре. Никаких выражений sleep больше не нужно:

[Test]
public async Task SynchronizeTestWithCodeViaAwait()
{
  var sut = new SystemUnderTest();
  // Планируем асинхронное выполнение операции
  // и ожидаем ее завершения
  await sut.StartAsync();
  // Проверяем результат операции
  Assert.AreEqual("Init Work", sut.Message);
}

К счастью, последние версии основных инфраструктур модульного тестирования — MSTest, xUnit.net и NUnit — поддерживают тесты с async и await (см. блог Стивена Клири [Stephen Cleary] по ссылке bit.ly/1x18mta). Их средства выполнения тестов умеют работать с тестами async Task и ожидают (через await) завершения потока до того, как начнут оценивать выражения Assert. Если средство выполнения инфраструктуры модульного тестирования не умеет работать с сигнатурами методов тестов async Task, тест может, по крайней мере, вызвать метод Wait объекта Task, возвращенного тестируемой системой.

Кроме того, функциональность на основе таймеров можно улучшить с помощью класса TaskCompletionSource (детали см. в пакете исходного кода, сопутствующем этой статье). Затем тест может ожидать (через await) завершения конкретных повторяющихся операций:

[Test]
public async Task
  SynchronizeTestWithRecurringOperationViaAwait()
{
  var sut = new SystemUnderTest();
  // Выполняем код для настройки таймера на задержку
  // и интервал по одной секунде
  var firstNotification = sut.StartRecurring();
  // Ждем, когда эта операция закончится два раза
  var secondNotification = await firstNotification.GetNext();
  await secondNotification.GetNext();
  // Проверяем результат
  Assert.AreEqual("Init Poll Poll", sut.Message);
}

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

private readonly ISomeInterface dependent;
public void StartAsynchronousOperation()
{
  Task.Run(()=>
  {
    Message += " Work";
    // Асинхронная операция завершена
    dependent.DoMore()
  });
}

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

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

*
Увеличить


Рис. 3. Синхронизация через заглушку зависимого объекта

TestТест
SistemUnderTestSistemUnderTest
NotifyУведомление
StubЗаглушка
DependentObjectDependentObject


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

// Подготовка
var finishedEvent = new ManualResetEventSlim();
var dependentStub = MockRepository.GenerateStub
  <ISomeInterface>();
dependentStub.Stub(x => x.DoMore()).
  WhenCalled(x => finishedEvent.Set());
var sut = new SystemUnderTest(dependentStub);

Теперь тест может выполнить асинхронную операцию и ждать уведомления:

// Планируем асинхронное выполнение операции
sut.StartAsynchronousOperation();
// Ждем завершения операции
finishedEvent.Wait();
// Проверяем результат операции
Assert.AreEqual("Init Work", sut.Message);

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

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

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

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

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

Решение 3: тест в одном потоке

В этом решении тестируемый код нужно подготовить так, чтобы тест мог впоследствии напрямую инициировать выполнение операций в том же потоке, где выполняется сам тест. Это адаптация подхода группы jMock для Java-кода (см. «Testing Multithreaded Code» на jmock.org/threads.html).

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

private readonly TaskScheduler taskScheduler;
public void StartAsynchronousOperation()
{
  Message = "Init";
  Task task1 = Task.Factory.StartNew(()=>
    {Message += " Work1";},
    CancellationToken.None,
    TaskCreationOptions.None,
    taskScheduler);
  task1.ContinueWith(((t)=>{Message += " Work2";},
    taskScheduler);
}

Тестируемая система модифицируется под использование отдельного TaskScheduler. Во время теста «обычный» TaskScheduler заменяется DeterministicTaskScheduler, который позволяет синхронно запускать асинхронные операции (рис. 4).

*
Увеличить


Рис. 4. Применение отдельного TaskScheduler в SystemUnderTest

TestТест
Start operations synchronouslyСинхронный запуск операций
SistemUnderTestSistemUnderTest
DeterministicTaskSchedulerDeterministicTas1kScheduler
TaskSchedulerTaskScheduler


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

[Test]
public void TestCodeSynchronously()
{
  var dts = new DeterministicTaskScheduler();
  var sut = new SystemUnderTest(dts);
  // Выполняем код, чтобы запланировать первую операцию
  // и немедленно вернуть управление
  sut.StartAsynchronousOperation();
  // Выполняем все операции в текущем потоке
  dts.RunTasksUntilIdle();
  // Проверяем результат двух операций
  Assert.AreEqual("Init Work1 Work2", sut.Message);
}

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

Код, который использует таймеры, проблематичен не только потому, что тесты становятся хрупкими и медленными. Модульные тесты усложняются, когда код работает с таймерами, выполняемыми вне рабочего потока. В библиотеке классом инфраструктуры .NET есть таймеры, специально предназначенные для применения в UI-приложениях, например System.Windows.Forms.Timer для Windows Forms или System.Windows.Threading.DispatcherTimer для Windows Presentation Foundation (WPF) (см. «Comparing the Timer Classes in the .NET Framework Class Library» по ссылке bit.ly/1r0SVic). Они используют очередь сообщений UI, которая не доступна напрямую при модульном тестировании. Тест, показанный в начале этой статьи, не будет работать с такими таймерами. Тест должен раскрутить цикл прокачки сообщений (message pump), например, используя WPF DispatcherFrame (см. пример в сопутствующем коде). Чтобы сохранить простоту и четкость модульных тестов при развертывании таймеров на основе UI, вы должны заменять эти таймеры при тестировании. Я ввожу интерфейс для таймеров, чтобы разрешить замену «реальных» таймеров на реализация специально для тестирования. То же самое я делаю для таймеров на основе «потоков» вроде System.Timers.Timer или System.Threading.Timer, поскольку тогда я могу улучшить модульные тесты во всех случаях. Тестируемую систему нужно модифицировать под использование этого интерфейса ITimer:

private readonly ITimer timer;
private readonly Object lockObject = new Object();

public void StartRecurring()
{
  Message = "Init";
  // Настраиваем таймер на задержку и интервал по одной секунде
  timer.StartTimer(() => { lock(lockObject)
    {Message += " Poll";} }, new TimeSpan(0,0,0,1));
}

Введя интерфейс ITimer, я могу заменить поведение таймера при тестировании, как показано на рис. 5.

*
Увеличить


Рис. 5. Применение ITimer в SystemUnderTest

TestТест
Start operations synchronouslyСинхронный запуск операций
SistemUnderTestSistemUnderTest
ITimerITimer
DeterministicTimerDeterministicTimer
TimerAdapterTimerAdapter
TimerTimer


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

[Test]
public void VeryFastAndReliableTestWithTimer()
{
  var dt = new DeterministicTimer();
  var sut = new SystemUnderTest(dt);
  // Выполняем код, настраивающий таймер
  // на секундные задержку и интервал
  sut.StartRecurring();
  // Сообщаем таймеру об истечение некоего времени
  dt.ElapseSeconds(3);
  // Проверяем, что с результатом трех исключений
  // повторяющейся операции все в порядке
  Assert.AreEqual("Init Poll Poll Poll", sut.Message);
}

DeterministicTimer написан специально для целей тестирования. Он позволяет тесту контролировать момент времени, когда выполняется операция по таймеру, и при этом избежать ожидания. Операция выполняется в том же потоке, где работает и сам тест (детали реализации DeterministicTimer см. в сопутствующем коде). Для выполнения тестируемого кода вне «контекста тестирования» я должен реализовать адаптер ITimer для существующего таймера. В сопутствующем коде есть примеры адаптеров для нескольких таймеров из библиотеки классов инфраструктуры. Интерфейс ITimer можно адаптировать под требования конкретной ситуации, и он может содержать лишь подмножество полной функциональности определенных таймеров.

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

Заключение

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

Первое решение, отделяющее функциональность от аспектов асинхронности программы через Humble Object, является наиболее универсальным. Оно применимо во всех ситуациях независимо от того, как запускаются потоки. Советую использовать это решение для очень сложных асинхронных систем, сложной функциональности или их комбинации. Это хороший пример принципа разделения обязанностей (bit.ly/1lB8iHD).

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

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

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


Ссылка: http://www.oszone.net/28049/