четверг, 21 января 2010 г.

Input/Output Completion Threads в .Net

Несколько лет назад мои поиски оптимальных методов работы с сетью завершились тем, что был написан собственный Native Windows IOCP модуль на C++. Его мы используем и в RunServer, и в собственных игровых проектах; он достаточно гибкий и быстрый.
Но вот недавно появилась задача сделать для RunServer режим "Pure .Net" - возможность работать без привязки к каким-либо системным функциям, чтобы можно было выполняться и на Mono и на гипотетическом Azure.
Различные части ядра - генератор случайных чисел, собственный Thread Pool на том же IOCP - были переделаны без каких-либо заметных потерь и все это "счастье" включается одним дополнительным ключом при компиляции. С сетевой частью все оказалось заметно сложнее. Были реанимированы старые наработки по .Net Sockets и после некоторый доработки запущены в производство.

Я когда-то хотел написать статью об особенностях .Net Sockets для масштабных серверов, но все откладывал "на потом". Сразу хочу предупредить, что все описанное касается TCP.
Первый и самый важный нюанс этих сокетов в том, что асинхронные вызовы (BeginSend/BeginReceive), не смотря на распространенные заблуждения, не используют IOCP. Вместо этого создается событие (аналог WaitHandle), которое ждет появления данных на сокете и затем вызывает коллбек с помощью ThreadPool. Что интересно, если данные в сокете уже есть, то коллбек вызывается сразу же и в том же потоке, что и BeginReceive, но в документации об этом нет ни слова. Зачастую при тестах в локальной сети именно так и происходит и технически можно никогда и не увидеть спауна потоков ThreadPool, а ведь их по-умолчанию разрешено до тысячи (!) на один процессор:
The thread pool has a default size of 250 worker threads per available processor, and 1000 I/O completion threads

Второй нюанс связан с какой-то внутренней особенностью взаимодействия сокетов и ThreadPool. Он достаточно нетривиален и в предварительных тестах его не увидишь. Обычно код коллбека для BeginReceive выглядит примерно так:

private void OnReceive(IAsyncResult asyncResult)
{
SocketError errorCode;
int size = m_socket.EndReceive(asyncResult, out errorCode);

if (errorCode != SocketError.Success)
{
OnError(new SocketException((int) errorCode));
return;
}

OnData(m_buffer, size);
m_socket.BeginReceive(m_buffer, 0, m_buffer.Length, SocketFlags.None, OnReceive, null);
}
Все логично, все корректно. Но на практике возможны интереснейшие казусы. Пока в буфере есть данные, коллбек будет вызываться в том же потоке, что и BeginReceive. Это может быть поток программы или даже один из I/O Completion Threads, ничего криминального в этом нет. Криминал начинается, если данных в буфере нет и мы уже не в потоке программы, а в каком-то из временных. Событие ожидания привязывается к текущему потоку и не отпускает его назад в пул. Для следующего вызова выделяется новый поток из пула и лишь после EndReceive предыдущий поток уходит в пул. На практике при нескольких сотнях соединений происходит постоянное выделение десятков (а иногда и сотен) потоков с возвращением их назад в пул. При использовании gcServer=false это еще и чревато сборкой мусора прямо в теле временного потока, что в .Net 1.1 и 2.0 (до SP 1) вызывало зависание этого потока на длительное время.
Я нашел лишь один метод бороться с этой напастью: не вызывать BeginReceive во временном потоке. Для каждого соединения держится флаг Receiving если идет прием; отдельный поток регулярно просматривает коллекцию сокетов, вызывает BeginReceive для тех, у кого нет флага. Недостаток этого подхода очевиден - чем больше соединений, тем больше может быть время между EndReceive и следующим BeginReceive. Частично это лечится проверкой значения asyncResult.CompletedSynchronously. Если оно true, то этот вызов был синхронным и можно спокойно вызывать BeginReceive прямо из колбека.

Третий нюанс актуален далеко не всегда и не для всех. Суть его в том, что передавая методам BeginSend/BeginReceive какой-то байтовый буфер мы обрекаем его на тяжкую долю: для вызова системных методов (WSASend/WSARecv на Windows) буферы будут зафиксированы в памяти, чему соответствует термин pinned. Такие объекты выпадают из поля зрения GC на некоторое время, не могут быть перемещены и ведут нас прямым путем к фрагментации памяти. Это актуально как для малых объемов, так и для больших. Наилучшим методом борьбы с этим является пулинг. Об этом же говорит и Maoni Stephens:
if you are using the raw socket I/O you do have control over the buffers so you can have a buffer pool and reuse buffers

К сожалению, пул универсальным быть не может - в один момент времени нам надо 10000 буферов, а в другой - всего 500 и хранить все 10000, выделенные в прошлый раз, нет смысла. Размеры пула, минимальные и максимальные размеры буферов и пр. надо подбирать в зависимости от задач и нагрузки, или реализовывать автоматическую балансировку.
В наших тестовых проектах использование .Net Sockets показывает повышенную нагрузку на GC, которая даже при сбалансированном пуле заметно выше, чем с использованием Native IOCP. При использовании gcServer = true эта нагрузка достаточно незначительна (4-5% при 1000 соединений на нашем тестовом сервере), к тому же она приходится на выделенные потоки и не влияет на общий workflow.

Из плюсов использования .Net Sockets я хотел бы выделить достаточно удобные методы работы с ними и меньшую латентность, чем при вызове C++ методов из внешней DLL.
Для клиентов RunServer начиная с версии 2.2 будет возможность проверить и сравнить эти две технологии без каких-либо серьезных изменений в высокоуровневой части.