Первоначальная архитектура Интернета подразумевала однородное адресное пространство, в котором каждый узел имел глобальный и уникальный IP адрес, и мог напрямую общаться с другими узлами. Сейчас Интернет, по факту, имеет другую архитектуру – одну область глобальных IP адресов и множество областей с частным адресами, скрытых за NAT устройствами.В такой архитектуре, только устройства находящиеся в глобальном адресном пространстве могут с легкостью взаимодействовать с кем-либо в сети, поскольку имеют уникальный, глобальный маршрутизируемый IP адрес. Узел, находящийся в частной сети, может соединяться с другими узлами в этой же сети, а также соединяться с другими, хорошо известными узлами в глобальном адресном пространстве. Такое взаимодействие достигается во многом благодаря механизму преобразования сетевых адресов. NAT устройства, например, Wi-Fi маршрутизаторы, создают специальные записи в таблицах трансляций для исходящих соединений и модифицируют IP адреса и номера портов в пакетах. Это позволяет устанавливать из частной сети исходящее соединение с узлами в глобальном адресном пространстве. Но в то же время, NAT устройства обычно блокируют весь входящий трафик, если не установлены отдельные правила для входящих соединений.
Такая архитектура Интернета достаточно правильна для клиент-серверного взаимодействия, когда клиенты могут находиться в частных сетях, а серверы имею глобальный адрес. Но она создает трудности для прямого соединения двух узлов между различными частными сетями. Прямое соединение двух узлов важно для «peer-to-peer» приложений, таких как передача голоса (Skype), получение удаленного доступа к компьютеру (TeamViewer), или онлайн игры.
Один из наиболее эффективных методов для установления peer-to-peer соединения между устройствами находящимися в различных частных сетях называется «hole punching». Этот техника чаще всего используется с приложениями на основе UDP протокола.
Но если вашему приложению требуется гарантированная доставка данных, например, вы передаете файлы между компьютерами, то при использовании UDP появится множество трудностей, связанных с тем, что UDP не является протоколом гарантированной доставки и не обеспечивает доставку пакетов по порядку, в отличие от TCP протокола.
В таком случае, для обеспечения гарантированной доставки пакетов, требуется реализовать протокол прикладного уровня, обеспечивающий необходимую функциональность и работающий поверх UDP.
Сразу хочу заметить, что существует техника TCP hole punching, для установления TCP соединений между узлами в разных частных сетях, но ввиду отсутствия поддержки её многими NAT устройствами она обычно не рассматривается как основной способ соединения таких узлов.
Далее в этой статье я буду рассматривать только реализацию протокола гарантированной доставки. Реализация техники UDP hole punching будет описана в следующих статьях.
Требования к протоколу
Надежная доставка пакетов, реализованная через механизм положительной обратной связи (так называемый positive acknowledgment )
Необходимость эффективной передачи больших данных, т.е. протокол должен избегать лишних ретрансляций пакетов
Должна быть возможность отмены механизма подтверждения доставки ( возможность функционировать как «чистый» UDP протокол)
Возможность реализации командного режима, с подтверждением каждого сообщения
Базовой единицей передачи данных по протоколу должно быть сообщение
Эти требования во многом совпадают с требованиями к Reliable Data Protocol, описанными в rfc 908 и rfc 1151, и я основывался на этих стандартах при разработке данного протокола.
Для понимания данных требований, давайте рассмотрим временные диаграммы передачи данных между двумя узлами сети по протоколам TCP и UDP. Пусть в обоих случаях у нас будет потерян один пакет. Передача неинтерактивных данных по TCP:
Как видно из диаграммы, в случае потери пакетов, TCP обнаружит потерянный пакет и сообщит об этом отправителю, запросив номер потерянного сегмента. Передача данных по протоколу UDP:
UDP не предпринимает никаких шагов по обнаружению потерь. Контроль ошибок передачи в UDP протоколе полностью возлагается на приложение.
Обнаружение ошибок в TCP протоколе достигается благодаря установке соединения с конечным узлом, сохранению состояния этого соединения, указанию номера отправленных байт в каждом заголовке пакета, и уведомлениях о получениях с помощью номера подтверждения «acknowledge number».
Дополнительно, для повышения производительности (т.е. отправки более одного сегмента без получения подтверждения) TCP протокол использует так называемое окно передачи — число байт данных которые отправитель сегмента ожидает принять.
Более подробно с TCP протоколом можно ознакомиться в rfc 793, с UDP в rfc 768, где они, собственно говоря, и определены.
Из вышеописанного, понятно, что для создания надежного протокола доставки сообщений поверх UDP (в дальнейшем будем называть Reliable UDP), требуется реализовать схожие с TCP механизмы передачи данных. А именно:
сохранять состояние соединения
использовать нумерацию сегментов
использовать специальные пакеты подтверждения
использовать упрощенный механизм окна, для увеличения пропускной способности протокола
Дополнительно, требуется:
сигнализировать о начале сообщения, для выделения ресурсов под соединение
сигнализировать об окончании сообщения, для передачи полученного сообщения вышестоящему приложению и высвобождения ресурсов протокола
позволить протоколу для конкретных соединений отключать механизм подтверждений доставки, чтобы функционировать как «чистый» UDP
Заголовок Reliable UDP
Вспомним, что UDP дейтаграмма инкапсулируется в IP дейтаграмму. Пакет Reliable UDP соответственно «заворачивается» в UDP дейтаграмму. Инкапсуляция заголовка Reliable UDP:
Структура заголовка Reliable UDP достаточно простая:
Flags – управляющие флаги пакета
MessageType – тип сообщения, используется вышестоящими приложениями, для подписки на определенные сообщения
TransmissionId — номер передачи, вместе с адресом и портом получателя уникально определяет соединение
PacketNumber – номер пакета
Options – дополнительные опции протокола. В случае первого пакета используется для указания размера сообщения
Флаги бывают следующие:
FirstPacket — первый пакет сообщения
NoAsk — сообщение не требует включения механизма подтверждения
LastPacket — последний пакет сообщения
RequestForPacket — пакет подтверждения или запрос на потерянный пакет
Общие принципы работы протокола
Так как Reliable UDP ориентирован на гарантированную передачу сообщения между двумя узлами, он должен уметь устанавливать соединение с другой стороной. Для установки соединения сторона-отправитель посылает пакет с флагом FirstPacket, ответ на который будет означать установку соединения. Все ответные пакеты, или, по-другому, пакеты подтверждения, всегда выставляют значение поля PacketNumber на единицу больше, чем самое большое значение PacketNumber у успешно пришедших пакетов. В поле Options для первого отправленного пакета записывается размер сообщения.
Для завершения соединения используется похожий механизм. В последнем пакете сообщения устанавливается флаг LastPacket. В ответном пакете указывается номер последнего пакета + 1, что для приёмной стороны означает успешную доставку сообщения. Диаграмма установление и завершение соединения:
Когда соединение установлено, начинается передача данных. Данные передаются блоками пакетов. Каждый блок, кроме последнего, содержит фиксированное количество пакетов. Оно равно размеру окна приема/передачи. Последний блок данных может иметь меньшее количество пакетов. После отправки каждого блока, сторона-отправитель ожидает подтверждения о доставке, либо запроса на повторную доставку потерянных пакетов, оставляя открытым окно приема/передачи для получения ответов. После получения подтверждения о доставке блока, окно прием/передачи сдвигается и отправляется следующий блок данных.
Сторона-получатель принимает пакеты. Каждый пакет проверяется на попадание в окно передачи. Не попадающие в окно пакеты и дубликаты отсеиваются. Т.к. размер окна сторого фиксирован и одинаков у получателя и у отправителя, то в случае доставки блока пакетов без потерь, окно сдвигается для приема пакетов следующего блока данных и отправляется подтверждение о доставке. Если окно не заполнится за установленный рабочим таймером период, то будет запущена проверка на то, какие пакеты не были доставлены и будут отправлены запросы на повторную доставку. Диаграмма повторной передачи:
Тайм-ауты и таймеры протокола
Существует несколько причин, по которым не может быть установлено соединение. Например, если принимающая сторона вне сети. В таком случае, при попытке установить соединение, соединение будет закрыто по тайм-ауту. В реализации Reliable UDP используются два таймера для установки тайм-аутов. Первый, рабочий таймер, служит для ожидания ответа от удаленного хоста. Если он срабатывает на стороне-отправителе, то выполняется повторная отправка последнего отправленного пакета. Если же таймер срабатывает у получателя, то выполняется проверка на потерянные пакеты и отправляются запросы на повторную доставку.
Второй таймер – необходим для закрытия соединения в случае отсутствия связи между узлами. Для стороны-отправителя он запускается сразу после срабатывания рабочего таймера, и ожидает ответа от удаленного узла. В случае отсутствия ответа за установленный период – соединение завершается и ресурсы освобождаются. Для стороны-получателя, таймер закрытия соединения запускается после двойного срабатывания рабочего таймера. Это необходимо для страховки от потери пакета подтверждения. При срабатывании таймера, также завершается соединение и высвобождаются ресурсы.
Диаграмма состояний передачи Reliable UDP
Принципы работы протокола реализованы в конечном автомате, каждое состояние которого отвечает за определенную логику обработки пакетов.
Диаграмма состояний Reliable UDP:
Closed – в действительности не является состоянием, это стартовая и конечная точка для автомата. За состояние Closed принимается блок управления передачей, который, реализуя асинхронный UDP сервер, перенаправляет пакеты в соответствующие соединения и запускает обработку состояний.
FirstPacketSending – начальное состояние, в котором находится исходящее соединение при отправке сообщения.
В этом состоянии отправляется первый пакет для обычных сообщений. Для сообщений без подтверждения отправки, это единственное состояние – в нем происходит отправка всего сообщения.
SendingCycle – основное состояния для передачи пакетов сообщения.
Переход в него из состояния FirstPacketSending осуществляется после отправки первого пакета сообщения. Именно в это состояние приходят все подтверждения и запросы на повторные передачи. Выход из него возможен в двух случаях – в случае успешной доставки сообщения или по тайм-ауту.
FirstPacketReceived – начальное состояние для получателя сообщения.
В нем проверяется корректность начала передачи, создаются необходимые структуры, и отправляется подтверждение о приеме первого пакета.
Для сообщения, состоящего из единственного пакета и отправленного без использования подтверждения доставки – это единственное состояние. После обработки такого сообщения соединение закрывается.
Assembling – основное состояние для приема пакетов сообщения.
В нем производится запись пакетов во временное хранилище, проверка на отсутствие потерь пакетов, отправка подтверждений о доставке блока пакетов и сообщения целиком, и отправка запросов на повторную доставку потерянных пакетов. В случае успешного получения всего сообщения – соединение переходит в состояние Completed, иначе выполняется выход по тайм-ауту.
Completed – закрытие соединения в случае успешного получения всего сообщения.
Данное состояние необходимо для сборки сообщения и для случая, когда подтверждение о доставке сообщения было потеряно по пути к отправителю. Выход из этого состояния производится по тайм-ауту, но соединение считается успешно закрытым.
Глубже в код. Блок управления передачей
Один из ключевых элементов Reliable UDP – блок управления передачей. Задача данного блока – хранение текущих соединений и вспомогательных элементов, распределение пришедших пакетов по соответствующим соединениям, предоставление интерфейса для отправки пакетов соединению и реализация API протокола. Блок управления передачей принимает пакеты от UDP уровня и перенаправляет их на обработку в конечный автомат. Для приема пакетов в нем реализован асинхронный UDP сервер. Некоторые члены класса ReliableUdpConnectionControlBlock:
internal class ReliableUdpConnectionControlBlock : IDisposable
{
// массив байт для указанного ключа. Используется для сборки входящих сообщений
public ConcurrentDictionary<Tuple<EndPoint, Int32>, byte[]> IncomingStreams { get; private set;}
// массив байт для указанного ключа. Используется для отправки исходящих сообщений.
public ConcurrentDictionary<Tuple<EndPoint, Int32>, byte[]> OutcomingStreams { get; private set; }
// connection record для указанного ключа.
private readonly ConcurrentDictionary<Tuple<EndPoint, Int32>, ReliableUdpConnectionRecord> m_listOfHandlers;
// список подписчиков на сообщения.
private readonly List<ReliableUdpSubscribeObject> m_subscribers;
// локальный сокет
private Socket m_socketIn;
// порт для входящих сообщений
private int m_port;
// локальный IP адрес
private IPAddress m_ipAddress;
// локальная конечная точка
public IPEndPoint LocalEndpoint { get; private set; }
// коллекция предварительно инициализированных
// состояний конечного автомата
public StatesCollection States { get; private set; }
// генератор случайных чисел. Используется для создания TransmissionId
private readonly RNGCryptoServiceProvider m_randomCrypto;
//...
}
Реализация асинхронного UDP сервера:
private void Receive()
{
EndPoint connectedClient = new IPEndPoint(IPAddress.Any, 0);
// создаем новый буфер, для каждого socket.BeginReceiveFrom
byte[] buffer = new byte[DefaultMaxPacketSize + ReliableUdpHeader.Length];
// передаем буфер в качестве параметра для асинхронного метода
this.m_socketIn.BeginReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref connectedClient, EndReceive, buffer);
}
private void EndReceive(IAsyncResult ar)
{
EndPoint connectedClient = new IPEndPoint(IPAddress.Any, 0);
int bytesRead = this.m_socketIn.EndReceiveFrom(ar, ref connectedClient);
//пакет получен, готовы принимать следующий
Receive();
// т.к. простейший способ решить вопрос с буфером - получить ссылку на него
// из IAsyncResult.AsyncState
byte[] bytes = ((byte[]) ar.AsyncState).Slice(0, bytesRead);
// получаем заголовок пакета
ReliableUdpHeader header;
if (!ReliableUdpStateTools.ReadReliableUdpHeader(bytes, out header))
{
// пришел некорректный пакет - отбрасываем его
return;
}
// конструируем ключ для определения connection record’а для пакета
Tuple<EndPoint, Int32> key = new Tuple<EndPoint, Int32>(connectedClient, header.TransmissionId);
// получаем существующую connection record или создаем новую
ReliableUdpConnectionRecord record = m_listOfHandlers.GetOrAdd(key, new ReliableUdpConnectionRecord(key, this, header.ReliableUdpMessageType));
// запускаем пакет в обработку в конечный автомат
record.State.ReceivePacket(record, header, bytes);
}
Для каждой передачи сообщения создается структура, содержащая сведения о соединении. Такая структура называется connection record. Некоторые члены класса ReliableUdpConnectionRecord:
internal class ReliableUdpConnectionRecord : IDisposable
{
// массив байт с сообщением
public byte[] IncomingStream { get; set; }
// ссылка на состояние конечного автомата
public ReliableUdpState State { get; set; }
// пара, однозначно определяющая connection record
// в блоке управления передачей
public Tuple<EndPoint, Int32> Key { get; private set;}
// нижняя граница приемного окна
public int WindowLowerBound;
// размер окна передачи
public readonly int WindowSize;
// номер пакета для отправки
public int SndNext;
// количество пакетов для отправки
public int NumberOfPackets;
// номер передачи (именно он и есть вторая часть Tuple)
// для каждого сообщения свой
public readonly Int32 TransmissionId;
// удаленный IP endpoint – собственно получатель сообщения
public readonly IPEndPoint RemoteClient;
// размер пакета, во избежание фрагментации на IP уровне
// не должен превышать MTU – (IP.Header + UDP.Header + RelaibleUDP.Header)
public readonly int BufferSize;
// блок управления передачей
public readonly ReliableUdpConnectionControlBlock Tcb;
// инкапсулирует результаты асинхронной операции для BeginSendMessage/EndSendMessage
public readonly AsyncResultSendMessage AsyncResult;
// не отправлять пакеты подтверждения
public bool IsNoAnswerNeeded;
// последний корректно полученный пакет (всегда устанавливается в наибольший номер)
public int RcvCurrent;
// массив с номерами потерянных пакетов
public int[] LostPackets { get; private set; }
// пришел ли последний пакет. Используется как bool.
public int IsLastPacketReceived = 0;
//...
}
Глубже в код. Состояния
Состояния реализуют конечный автомат протокола Reliable UDP, в котором происходит основная обработка пакетов. Абстрактный класс ReliableUdpState предоставляет интерфейс для состояния:
Всю логику работы протокола реализуют представленные выше классы, совместно со вспомогательным классом, предоставляющим статические методы, такие как, например, построения заголовка ReliableUdp из connection record.
Далее будут рассмотрены в подробностях реализации методов интерфейса, определяющих основные алгоритмы работы протокола.
Метод DisposeByTimeout
Метод DisposeByTimeout отвечает за высвобождение ресурсов соединения по истечению тайм-аута и для сигнализации об успешной/неуспешной доставки сообщения. ReliableUdpState.DisposeByTimeout:
Он переопределен только в состоянии Completed. Completed.DisposeByTimeout:
protected override void DisposeByTimeout(object record)
{
ReliableUdpConnectionRecord connectionRecord = (ReliableUdpConnectionRecord) record;
// сообщаем об успешном получении сообщения
SetAsCompleted(connectionRecord);
}
Метод ProcessPackets
Метод ProcessPackets отвечает за дополнительную обработку пакета или пакетов. Вызывается напрямую, либо через таймер ожидания пакетов.
В состоянии Assembling метод переопределен и отвечает за проверку потерянных пакетов и переход в состояние Completed, в случае получения последнего пакета и прохождения успешной проверки Assembling.ProcessPackets:
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
if (connectionRecord.IsDone != 0)
return;
if (!ReliableUdpStateTools.CheckForNoPacketLoss(connectionRecord, connectionRecord.IsLastPacketReceived != 0))
{
// есть потерянные пакеты, отсылаем запросы на них
foreach (int seqNum in connectionRecord.LostPackets)
{
if (seqNum != 0)
{
ReliableUdpStateTools.SendAskForLostPacket(connectionRecord, seqNum);
}
}
// устанавливаем таймер во второй раз, для повторной попытки передачи
if (!connectionRecord.TimerSecondTry)
{
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
connectionRecord.TimerSecondTry = true;
return;
}
// если после двух попыток срабатываний WaitForPacketTimer
// не удалось получить пакеты - запускаем таймер завершения соединения
StartCloseWaitTimer(connectionRecord);
}
else if (connectionRecord.IsLastPacketReceived != 0)
// успешная проверка
{
// высылаем подтверждение о получении блока данных
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
connectionRecord.State = connectionRecord.Tcb.States.Completed;
connectionRecord.State.ProcessPackets(connectionRecord);
// вместо моментальной реализации ресурсов
// запускаем таймер, на случай, если
// если последний ack не дойдет до отправителя и он запросит его снова.
// по срабатыванию таймера - реализуем ресурсы
// в состоянии Completed метод таймера переопределен
StartCloseWaitTimer(connectionRecord);
}
// это случай, когда ack на блок пакетов был потерян
else
{
if (!connectionRecord.TimerSecondTry)
{
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
connectionRecord.TimerSecondTry = true;
return;
}
// запускаем таймер завершения соединения
StartCloseWaitTimer(connectionRecord);
}
}
В состоянии SendingCycle этот метод вызывается только по таймеру, и отвечает за повторную отправку последнего сообщения, а также за включение таймера закрытия соединения. SendingCycle.ProcessPackets:
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
if (connectionRecord.IsDone != 0)
return;
// отправляем повторно последний пакет
// ( в случае восстановления соединения узел-приемник заново отправит запросы, которые до него не дошли)
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.RetransmissionCreateUdpPayload(connectionRecord, connectionRecord.SndNext - 1));
// включаем таймер CloseWait – для ожидания восстановления соединения или его завершения
StartCloseWaitTimer(connectionRecord);
}
В состоянии Completed метод останавливает рабочий таймер и передает сообщение подписчикам. Completed.ProcessPackets:
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
if (connectionRecord.WaitForPacketsTimer != null)
connectionRecord.WaitForPacketsTimer.Dispose();
// собираем сообщение и передаем его подписчикам
ReliableUdpStateTools.CreateMessageFromMemoryStream(connectionRecord);
}
Метод ReceivePacket
В состоянии FirstPacketReceived основная задача метода — определить действительно ли первый пакет сообщения пришел на интерфейс, а также собрать сообщение состоящее из единственного пакета. FirstPacketReceived.ReceivePacket:
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
if (!header.Flags.HasFlag(ReliableUdpHeaderFlags.FirstPacket))
// отбрасываем пакет
return;
// комбинация двух флагов - FirstPacket и LastPacket - говорит что у нас единственное сообщение
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.FirstPacket) &
header.Flags.HasFlag(ReliableUdpHeaderFlags.LastPacket))
{
ReliableUdpStateTools.CreateMessageFromSinglePacket(connectionRecord, header, payload.Slice(ReliableUdpHeader.Length, payload.Length));
if (!header.Flags.HasFlag(ReliableUdpHeaderFlags.NoAsk))
{
// отправляем пакет подтверждение
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
}
SetAsCompleted(connectionRecord);
return;
}
// by design все packet numbers начинаются с 0;
if (header.PacketNumber != 0)
return;
ReliableUdpStateTools.InitIncomingBytesStorage(connectionRecord, header);
ReliableUdpStateTools.WritePacketData(connectionRecord, header, payload);
// считаем кол-во пакетов, которые должны прийти
connectionRecord.NumberOfPackets = (int)Math.Ceiling((double) ((double) connectionRecord.IncomingStream.Length/(double) connectionRecord.BufferSize));
// записываем номер последнего полученного пакета (0)
connectionRecord.RcvCurrent = header.PacketNumber;
// после сдвинули окно приема на 1
connectionRecord.WindowLowerBound++;
// переключаем состояние
connectionRecord.State = connectionRecord.Tcb.States.Assembling;
// если не требуется механизм подтверждение
// запускаем таймер который высвободит все структуры
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.NoAsk))
{
connectionRecord.CloseWaitTimer = new Timer(DisposeByTimeout, connectionRecord, connectionRecord.ShortTimerPeriod, -1);
}
else
{
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
connectionRecord.WaitForPacketsTimer = new Timer(CheckByTimer, connectionRecord, connectionRecord.ShortTimerPeriod, -1);
}
}
В состоянии SendingCycle этот метод переопределен для приема подтверждений о доставке и запросов повторной передачи. SendingCycle.ReceivePacket:
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
if (connectionRecord.IsDone != 0)
return;
if (!header.Flags.HasFlag(ReliableUdpHeaderFlags.RequestForPacket))
return;
// расчет конечной границы окна
// берется граница окна + 1, для получения подтверждений доставки
int windowHighestBound = Math.Min((connectionRecord.WindowLowerBound + connectionRecord.WindowSize), (connectionRecord.NumberOfPackets));
// проверка на попадание в окно
if (header.PacketNumber < connectionRecord.WindowLowerBound || header.PacketNumber > windowHighestBound)
return;
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
if (connectionRecord.CloseWaitTimer != null)
connectionRecord.CloseWaitTimer.Change(-1, -1);
// проверить на последний пакет:
if (header.PacketNumber == connectionRecord.NumberOfPackets)
{
// передача завершена
Interlocked.Increment(ref connectionRecord.IsDone);
SetAsCompleted(connectionRecord);
return;
}
// это ответ на первый пакет c подтверждением
if ((header.Flags.HasFlag(ReliableUdpHeaderFlags.FirstPacket) && header.PacketNumber == 1))
{
// без сдвига окна
SendPacket(connectionRecord);
}
// пришло подтверждение о получении блока данных
else if (header.PacketNumber == windowHighestBound)
{
// сдвигаем окно прием/передачи
connectionRecord.WindowLowerBound += connectionRecord.WindowSize;
// обнуляем массив контроля передачи
connectionRecord.WindowControlArray.Nullify();
// отправляем блок пакетов
SendPacket(connectionRecord);
}
// это запрос на повторную передачу – отправляем требуемый пакет
else
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.RetransmissionCreateUdpPayload(connectionRecord, header.PacketNumber));
}
В состоянии Assembling в методе ReceivePacket происходит основная работа по сборке сообщения из поступающих пакетов. Assembling.ReceivePacket:
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
if (connectionRecord.IsDone != 0)
return;
// обработка пакетов с отключенным механизмом подтверждения доставки
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.NoAsk))
{
// сбрасываем таймер
connectionRecord.CloseWaitTimer.Change(connectionRecord.LongTimerPeriod, -1);
// записываем данные
ReliableUdpStateTools.WritePacketData(connectionRecord, header, payload);
// если получили пакет с последним флагом - делаем завершаем
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.LastPacket))
{
connectionRecord.State = connectionRecord.Tcb.States.Completed;
connectionRecord.State.ProcessPackets(connectionRecord);
}
return;
}
// расчет конечной границы окна
int windowHighestBound = Math.Min((connectionRecord.WindowLowerBound + connectionRecord.WindowSize - 1), (connectionRecord.NumberOfPackets - 1));
// отбрасываем не попадающие в окно пакеты
if (header.PacketNumber < connectionRecord.WindowLowerBound || header.PacketNumber > (windowHighestBound))
return;
// отбрасываем дубликаты
if (connectionRecord.WindowControlArray.Contains(header.PacketNumber))
return;
// записываем данные
ReliableUdpStateTools.WritePacketData(connectionRecord, header, payload);
// увеличиваем счетчик пакетов
connectionRecord.PacketCounter++;
// записываем в массив управления окном текущий номер пакета
connectionRecord.WindowControlArray[header.PacketNumber - connectionRecord.WindowLowerBound] = header.PacketNumber;
// устанавливаем наибольший пришедший пакет
if (header.PacketNumber > connectionRecord.RcvCurrent)
connectionRecord.RcvCurrent = header.PacketNumber;
// перезапускам таймеры
connectionRecord.TimerSecondTry = false;
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
if (connectionRecord.CloseWaitTimer != null)
connectionRecord.CloseWaitTimer.Change(-1, -1);
// если пришел последний пакет
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.LastPacket))
{
Interlocked.Increment(ref connectionRecord.IsLastPacketReceived);
}
// если нам пришли все пакеты окна, то сбрасываем счетчик
// и высылаем пакет подтверждение
else if (connectionRecord.PacketCounter == connectionRecord.WindowSize)
{
// сбрасываем счетчик.
connectionRecord.PacketCounter = 0;
// сдвинули окно передачи
connectionRecord.WindowLowerBound += connectionRecord.WindowSize;
// обнуление массива управления передачей
connectionRecord.WindowControlArray.Nullify();
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
}
// если последний пакет уже имеется
if (Thread.VolatileRead(ref connectionRecord.IsLastPacketReceived) != 0)
{
// проверяем пакеты
ProcessPackets(connectionRecord);
}
}
В состоянии Completed единственная задача метода — отослать повторное подтверждение об успешной доставке сообщения. Completed.ReceivePacket:
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
// повторная отправка последнего пакета в связи с тем,
// что последний ack не дошел до отправителя
if (header.Flags.HasFlag(ReliableUdpHeaderFlags.LastPacket))
{
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
}
}
Метод SendPacket
В состоянии FirstPacketSending этот метод осуществляет отправку первого пакета данных, или, если сообщение не требует подтверждение доставки — все сообщение. FirstPacketSending.SendPacket:
public override void SendPacket(ReliableUdpConnectionRecord connectionRecord)
{
connectionRecord.PacketCounter = 0;
connectionRecord.SndNext = 0;
connectionRecord.WindowLowerBound = 0;
// если подтверждения не требуется - отправляем все пакеты
// и высвобождаем ресурсы
if (connectionRecord.IsNoAnswerNeeded)
{
// Здесь происходит отправка As Is
do
{
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.CreateUdpPayload(connectionRecord, ReliableUdpStateTools. CreateReliableUdpHeader(connectionRecord)));
connectionRecord.SndNext++;
} while (connectionRecord.SndNext < connectionRecord.NumberOfPackets);
SetAsCompleted(connectionRecord);
return;
}
// создаем заголовок пакета и отправляем его
ReliableUdpHeader header = ReliableUdpStateTools.CreateReliableUdpHeader(connectionRecord);
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.CreateUdpPayload(connectionRecord, header));
// увеличиваем счетчик
connectionRecord.SndNext++;
// сдвигаем окно
connectionRecord.WindowLowerBound++;
connectionRecord.State = connectionRecord.Tcb.States.SendingCycle;
// Запускаем таймер
connectionRecord.WaitForPacketsTimer = new Timer(CheckByTimer, connectionRecord, connectionRecord.ShortTimerPeriod, -1);
}
В состоянии SendingCycle в этом методе происходит отправка блока пакетов. SendingCycle.SendPacket:
public override void SendPacket(ReliableUdpConnectionRecord connectionRecord)
{
// отправляем блок пакетов
for (connectionRecord.PacketCounter = 0;
connectionRecord.PacketCounter < connectionRecord.WindowSize &&
connectionRecord.SndNext < connectionRecord.NumberOfPackets;
connectionRecord.PacketCounter++)
{
ReliableUdpHeader header = ReliableUdpStateTools.CreateReliableUdpHeader(connectionRecord);
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.CreateUdpPayload(connectionRecord, header));
connectionRecord.SndNext++;
}
// на случай большого окна передачи, перезапускаем таймер после отправки
connectionRecord.WaitForPacketsTimer.Change( connectionRecord.ShortTimerPeriod, -1 );
if ( connectionRecord.CloseWaitTimer != null )
{
connectionRecord.CloseWaitTimer.Change( -1, -1 );
}
}
Глубже в код. Создание и установление соединений
Теперь, когда мы познакомились с основными состояниями и методами, используемыми для обработки состояний, можно разобрать немного подробнее несколько примеров работы протокола. Диаграмма передачи данных в нормальных условиях:
Рассмотрим подробно создание connection record для соединения и отправку первого пакета. Инициатором передачи всегда выступает приложение, вызывающее API-метод отправки сообщения. Далее задействуется метод StartTransmission блока управления передачей, запускающий передачу данных для нового сообщения. Создание исходящего соединения:
private void StartTransmission(ReliableUdpMessage reliableUdpMessage, EndPoint endPoint, AsyncResultSendMessage asyncResult)
{
if (m_isListenerStarted == 0)
{
if (this.LocalEndpoint == null)
{
throw new ArgumentNullException( "", "You must use constructor with parameters or start listener before sending message" );
}
// запускаем обработку входящих пакетов
StartListener(LocalEndpoint);
}
// создаем ключ для словаря, на основе EndPoint и ReliableUdpHeader.TransmissionId
byte[] transmissionId = new byte[4];
// создаем случайный номер transmissionId
m_randomCrypto.GetBytes(transmissionId);
Tuple<EndPoint, Int32> key = new Tuple<EndPoint, Int32>(endPoint, BitConverter.ToInt32(transmissionId, 0));
// создаем новую запись для соединения и проверяем,
// существует ли уже такой номер в наших словарях
if (!m_listOfHandlers.TryAdd(key, new ReliableUdpConnectionRecord(key, this, reliableUdpMessage, asyncResult)))
{
// если существует – то повторно генерируем случайный номер
m_randomCrypto.GetBytes(transmissionId);
key = new Tuple<EndPoint, Int32>(endPoint, BitConverter.ToInt32(transmissionId, 0));
if (!m_listOfHandlers.TryAdd(key, new ReliableUdpConnectionRecord(key, this, reliableUdpMessage, asyncResult)))
// если снова не удалось – генерируем исключение
throw new ArgumentException("Pair TransmissionId & EndPoint is already exists in the dictionary");
}
// запустили состояние в обработку
m_listOfHandlers[key].State.SendPacket(m_listOfHandlers[key]);
}
Отправка первого пакета (состояние FirstPacketSending):
public override void SendPacket(ReliableUdpConnectionRecord connectionRecord)
{
connectionRecord.PacketCounter = 0;
connectionRecord.SndNext = 0;
connectionRecord.WindowLowerBound = 0;
// ...
// создаем заголовок пакета и отправляем его
ReliableUdpHeader header = ReliableUdpStateTools.CreateReliableUdpHeader(connectionRecord);
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.CreateUdpPayload(connectionRecord, header));
// увеличиваем счетчик
connectionRecord.SndNext++;
// сдвигаем окно
connectionRecord.WindowLowerBound++;
// переходим в состояние SendingCycle
connectionRecord.State = connectionRecord.Tcb.States.SendingCycle;
// Запускаем таймер
connectionRecord.WaitForPacketsTimer = new Timer(CheckByTimer, connectionRecord, connectionRecord.ShortTimerPeriod, -1);
}
После отправки первого пакета отправитель переходит в состояние SendingCycle – ожидать подтверждения о доставке пакета.
Сторона-получатель, с помощью метода EndReceive, принимает отправленный пакет, создает новую connection record и передает данный пакет, с предварительно распарсенным заголовком, в обработку методу ReceivePacket состояния FirstPacketReceived Создание соединения на принимающей стороне:
private void EndReceive(IAsyncResult ar)
{
// ...
// пакет получен
// парсим заголовок пакета
ReliableUdpHeader header;
if (!ReliableUdpStateTools.ReadReliableUdpHeader(bytes, out header))
{
// пришел некорректный пакет - отбрасываем его
return;
}
// конструируем ключ для определения connection record’а для пакета
Tuple<EndPoint, Int32> key = new Tuple<EndPoint, Int32>(connectedClient, header.TransmissionId);
// получаем существующую connection record или создаем новую
ReliableUdpConnectionRecord record = m_listOfHandlers.GetOrAdd(key, new ReliableUdpConnectionRecord(key, this, header. ReliableUdpMessageType));
// запускаем пакет в обработку в конечный автомат
record.State.ReceivePacket(record, header, bytes);
}
Прием первого пакета и отправка подтверждения (состояние FirstPacketReceived):
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
if (!header.Flags.HasFlag(ReliableUdpHeaderFlags.FirstPacket))
// отбрасываем пакет
return;
// ...
// by design все packet numbers начинаются с 0;
if (header.PacketNumber != 0)
return;
// инициализируем массив для хранения частей сообщения
ReliableUdpStateTools.InitIncomingBytesStorage(connectionRecord, header);
// записываем данные пакет в массив
ReliableUdpStateTools.WritePacketData(connectionRecord, header, payload);
// считаем кол-во пакетов, которые должны прийти
connectionRecord.NumberOfPackets = (int)Math.Ceiling((double) ((double) connectionRecord.IncomingStream.Length/(double) connectionRecord.BufferSize));
// записываем номер последнего полученного пакета (0)
connectionRecord.RcvCurrent = header.PacketNumber;
// после сдвинули окно приема на 1
connectionRecord.WindowLowerBound++;
// переключаем состояние
connectionRecord.State = connectionRecord.Tcb.States.Assembling;
if (/*если не требуется механизм подтверждение*/)
// ...
else
{
// отправляем подтверждение
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
connectionRecord.WaitForPacketsTimer = new Timer(CheckByTimer, connectionRecord, connectionRecord.ShortTimerPeriod, -1);
}
}
Глубже в код. Закрытие соединения по тайм-ауту
Отработка тайм-аутов важная часть Reliable UDP. Рассмотрим пример, в котором на промежуточным узле произошел сбой и доставка данных в обоих направления стала невозможной. Диаграмма закрытия соединения по тайму-ауту:
Как видно из диаграммы, рабочий таймер у отправителя включается сразу после отправки блока пакетов. Это происходит в методе SendPacket состояния SendingCycle. Включение рабочего таймера (состояние SendingCycle):
public override void SendPacket(ReliableUdpConnectionRecord connectionRecord)
{
// отправляем блок пакетов
// ...
// перезапускаем таймер после отправки
connectionRecord.WaitForPacketsTimer.Change( connectionRecord.ShortTimerPeriod, -1 );
if ( connectionRecord.CloseWaitTimer != null )
connectionRecord.CloseWaitTimer.Change( -1, -1 );
}
Периоды таймера задаются при создании соединения. По умолчанию ShortTimerPeriod равен 5 секундам. В примере он установлен в 1,5 секунды.
У входящего соединения таймер запускается после получения последнего дошедшего пакета данных, это происходит в методе ReceivePacket состояния Assembling Включение рабочего таймера (состояние Assembling):
Во входящем соединении за время ожидания рабочего таймера не пришло больше пакетов. Таймер сработал и вызывал метод ProcessPackets, в котором были обнаружены потерянные пакеты и первый раз отправлены запросы на повторную доставку. Отправка запросов на повторную доставку (состояние Assembling):
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
// ...
if (/*проверка на потерянные пакеты */)
{
// отправляем запросы на повторную доставку
// устанавливаем таймер во второй раз, для повторной попытки передачи
if (!connectionRecord.TimerSecondTry)
{
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
connectionRecord.TimerSecondTry = true;
return;
}
// если после двух попыток срабатываний WaitForPacketTimer
// не удалось получить пакеты - запускаем таймер завершения соединения
StartCloseWaitTimer(connectionRecord);
}
else if (/*пришел последний пакет и успешная проверка */)
{
// ...
StartCloseWaitTimer(connectionRecord);
}
// если ack на блок пакетов был потерян
else
{
if (!connectionRecord.TimerSecondTry)
{
// повторно отсылаем ack
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
connectionRecord.TimerSecondTry = true;
return;
}
// запускаем таймер завершения соединения
StartCloseWaitTimer(connectionRecord);
}
}
Переменная TimerSecondTry установилась в true. Данная переменная отвечает за повторный перезапуск рабочего таймер.
Со стороны отправителя тоже срабатывает рабочий таймер и повторно отсылается последний отправленный пакет. Включение таймера закрытия соединения (состояние SendingCycle):
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
// ...
// отправляем повторно последний пакет
// ...
// включаем таймер CloseWait – для ожидания восстановления соединения или его завершения
StartCloseWaitTimer(connectionRecord);
}
После чего в исходящем соединении запускается таймер закрытия соединения. ReliableUdpState.StartCloseWaitTimer:
protected void StartCloseWaitTimer(ReliableUdpConnectionRecord connectionRecord)
{
if (connectionRecord.CloseWaitTimer != null)
connectionRecord.CloseWaitTimer.Change(connectionRecord.LongTimerPeriod, -1);
else
connectionRecord.CloseWaitTimer = new Timer(DisposeByTimeout, connectionRecord, connectionRecord.LongTimerPeriod, -1);
}
Период ожидания таймера закрытия соединения равен 30 секундам по-умолчанию.
Через непродолжительное время, повторно срабатывает рабочий таймер на стороне получателя, вновь производится отправка запросов, после чего запускается таймер закрытия соединения у входящего соединения
По срабатыванию таймеров закрытия все ресурсы обоих connection record освобождаются. Отправитель сообщает о неудачной доставке вышестоящему приложению (см. API Reliable UDP). Освобождение ресурсов connection record'a:
public void Dispose()
{
try
{
System.Threading.Monitor.Enter(this.LockerReceive);
}
finally
{
Interlocked.Increment(ref this.IsDone);
if (WaitForPacketsTimer != null)
{
WaitForPacketsTimer.Dispose();
}
if (CloseWaitTimer != null)
{
CloseWaitTimer.Dispose();
}
byte[] stream;
Tcb.IncomingStreams.TryRemove(Key, out stream);
stream = null;
Tcb.OutcomingStreams.TryRemove(Key, out stream);
stream = null;
System.Threading.Monitor.Exit(this.LockerReceive);
}
}
Глубже в код. Восстановление передачи данных
Диаграмма восстановления передачи данных при потере пакета:
Как уже обсуждалось в закрытии соединения по тайм-ауту, по истечению рабочего таймера у получателя произойдет проверка на потерянные пакеты. В случае наличия потерь пакетов будет составлен список номер пакетов, не дошедших до получателя. Данные номера заносятся в массив LostPackets конкретного соединения и выполняется отправка запросов на повторную доставку. Отправка запросов на повторную доставку пакетов (состояние Assembling):
public override void ProcessPackets(ReliableUdpConnectionRecord connectionRecord)
{
//...
if (!ReliableUdpStateTools.CheckForNoPacketLoss(connectionRecord, connectionRecord.IsLastPacketReceived != 0))
{
// есть потерянные пакеты, отсылаем запросы на них
foreach (int seqNum in connectionRecord.LostPackets)
{
if (seqNum != 0)
{
ReliableUdpStateTools.SendAskForLostPacket(connectionRecord, seqNum);
}
}
// ...
}
}
Отправитель примет запрос на повторную доставку и вышлет недостающие пакеты. Стоит заметить, что в этот момент у отправителя уже запущен таймер закрытия соединения и, при получении запроса, он сбрасывается. Повторная отправка потерянных пакетов (состояние SendingCycle):
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
// ...
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
// сброс таймера закрытия соединения
if (connectionRecord.CloseWaitTimer != null)
connectionRecord.CloseWaitTimer.Change(-1, -1);
// ...
// это запрос на повторную передачу – отправляем требуемый пакет
else
ReliableUdpStateTools.SendPacket(connectionRecord, ReliableUdpStateTools.RetransmissionCreateUdpPayload(connectionRecord, header.PacketNumber));
}
Повторно отправленный пакет (packet#3 на диаграмме) принимается входящим соединением. Выполняется проверка на заполнение окна приема и обычная передача данных восстанавливается. Проверка на попадание в окно приема (состояние Assembling):
public override void ReceivePacket(ReliableUdpConnectionRecord connectionRecord, ReliableUdpHeader header, byte[] payload)
{
// ...
// увеличиваем счетчик пакетов
connectionRecord.PacketCounter++;
// записываем в массив управления окном текущий номер пакета
connectionRecord.WindowControlArray[header.PacketNumber - connectionRecord.WindowLowerBound] = header.PacketNumber;
// устанавливаем наибольший пришедший пакет
if (header.PacketNumber > connectionRecord.RcvCurrent)
connectionRecord.RcvCurrent = header.PacketNumber;
// перезапускам таймеры
connectionRecord.TimerSecondTry = false;
connectionRecord.WaitForPacketsTimer.Change(connectionRecord.ShortTimerPeriod, -1);
if (connectionRecord.CloseWaitTimer != null)
connectionRecord.CloseWaitTimer.Change(-1, -1);
// ...
// если нам пришли все пакеты окна, то сбрасываем счетчик
// и высылаем пакет подтверждение
else if (connectionRecord.PacketCounter == connectionRecord.WindowSize)
{
// сбрасываем счетчик.
connectionRecord.PacketCounter = 0;
// сдвинули окно передачи
connectionRecord.WindowLowerBound += connectionRecord.WindowSize;
// обнуление массива управления передачей
connectionRecord.WindowControlArray.Nullify();
ReliableUdpStateTools.SendAcknowledgePacket(connectionRecord);
}
// ...
}
API Reliable UDP
Для взаимодействия с протоколом передачи данных имеется открытый класс Reliable Udp, являющийся оберткой над блоком управления передачей. Вот наиболее важные члены класса:
public sealed class ReliableUdp : IDisposable
{
// получает локальную конечную точку
public IPEndPoint LocalEndpoint
// создает экземпляр ReliableUdp и запускает
// прослушивание входящих пакетов на указанном IP адресе
// и порту. Значение 0 для порта означает использование
// динамически выделенного порта
public ReliableUdp(IPAddress localAddress, int port = 0)
// подписка на получение входящих сообщений
public ReliableUdpSubscribeObject SubscribeOnMessages(ReliableUdpMessageCallback callback, ReliableUdpMessageTypes messageType = ReliableUdpMessageTypes.Any, IPEndPoint ipEndPoint = null)
// отписка от получения сообщений
public void Unsubscribe(ReliableUdpSubscribeObject subscribeObject)
// асинхронно отправить сообщение
// Примечание: совместимость с XP и Server 2003 не теряется, т.к. используется .NET Framework 4.0
public Task<bool> SendMessageAsync(ReliableUdpMessage reliableUdpMessage, IPEndPoint remoteEndPoint, CancellationToken cToken)
// начать асинхронную отправку сообщения
public IAsyncResult BeginSendMessage(ReliableUdpMessage reliableUdpMessage, IPEndPoint remoteEndPoint, AsyncCallback asyncCallback, Object state)
// получить результат асинхронной отправки
public bool EndSendMessage(IAsyncResult asyncResult)
// очистить ресурсы
public void Dispose()
}
Получение сообщения осуществляется по подписке. Сигнатура делегата для метода обратного вызова:
public delegate void ReliableUdpMessageCallback( ReliableUdpMessage reliableUdpMessage, IPEndPoint remoteClient );
Сообщение:
public class ReliableUdpMessage
{
// тип сообщения, простое перечисление
public ReliableUdpMessageTypes Type { get; private set; }
// данные сообщения
public byte[] Body { get; private set; }
// если установлено в true – механизм подтверждения доставки будет отключен
// для передачи конкретного сообщения
public bool NoAsk { get; private set; }
}
Для подписки на конкретный тип сообщений и/или на конкретного отправителя используются два необязательных параметра: ReliableUdpMessageTypes messageType и IPEndPoint ipEndPoint.
Типы сообщений:
public enum ReliableUdpMessageTypes : short
{
// Любое
Any = 0,
// Запрос к STUN server
StunRequest = 1,
// Ответ от STUN server
StunResponse = 2,
// Передача файла
FileTransfer =3,
// ...
}
Отправка сообщения осуществляется асинхронного, для этого в протоколе реализована асинхронная модель программирования:
public IAsyncResult BeginSendMessage(ReliableUdpMessage reliableUdpMessage, IPEndPoint remoteEndPoint, AsyncCallback asyncCallback, Object state)
Результат отправки сообщения будет true – если сообщение успешно дошло до получателя и false – если соединение было закрыто по тайм-ауту:
public bool EndSendMessage(IAsyncResult asyncResult)
Заключение
Многое не было описано в рамках данной статьи. Механизмы согласования потоков, обработка исключений и ошибок, реализация асинхронных методов отправки сообщения. Но ядро протокола, описание логики обработки пакетов, установка соединения и отработка тайм-аутов, должны проясниться для Вас.
Продемонстрированная версия протокола надежной доставки достаточно устойчива и гибка, и соответствует определенным ранее требованиям. Но я хочу добавить, что описанная реализация может быть усовершенстована. К примеру, для увеличения пропускной способности и динамического изменения периодов таймеров в протокол можно добавить такие механизмы как sliding window и RTT, также будет полезным реализация механизма определения MTU между узлами соединения (но только в случае отправки больших сообщений).
Спасибо за внимание, жду Ваших комментариев и замечаний.
P.S. Для тех, кто интересуется подробностями или просто хочет протестировать протокол, ссылка на проект на GitHube: Проект Reliable UDP
Update: Спасибо mayorovp и sidristij за идею добавления task’а к интерфейсу. Совместимость библиотеки со старыми ОС не нарушается, т.к. 4-ый фреймворк поддерживает и XP и 2003 server.