Многопоточное vs асинхронное сетевое программирование на практике

1,00
р.
Как-то в комментариях VladD поделился информацией, что один из его коллег, сетевой программист, перешел от многопоточного к асинхронному сетевому программированию. Хотелось бы на примере конкретной задачи разобраться, насколько асинхронность выиграет у многопоточности.
Задача: возьмем один из простых сетевых протоколов - RFB. Нам нужно одновременно подключиться к 10 000 серверов с RFB на борту, и узнать версию RFB.
Как это реализовать многопоточно - я знаю, но как это реализовать асинхронно? И на сколько, в данной задаче, асинхронность выиграет? Сама реализация RFB - не нужна, нужен пример выполнения 10 000 одновременных асинхронных запросов.

Протестировал 3 варианта кода:
Многопоточный (поменял разбивку списка на несколько - на потокобезопасную очередь, чтобы уровнять шансы) Асинхронный Асинхронный (паттерн Throttling)
Результаты (проверка 300 000 IP адресов):
Многопоточный: 3 минуты 18 секунд Асинхронный: 1 минута 27 секунд Асинхронный (паттерн Throttling): когда перевалило за 6 минут - закрыл программу и не стал измерять дальше. Скорости можно добиться только использовав в уровне параллелизма - размер всего списка, но тогда теряется смысл самого использования паттерна. Т.е. реализация от andreycha, если использовать уровень параллелизма меньше размера списка - работает дольше чем даже многопоточная версия. Возможно это просто моя ошибка, либо ошибка andreycha.
Вывод:
Стандартная асинхронная реализация работает более чем в 2 раза быстрее чем многопоточная.

Ответ
Про асинхронность и ее преимущества тут. Вкратце -- в то время, пока запрос ушел в сеть и не вернулся обратно, мы не блокируем потоки на нашем компьютере. Т.о. 10000 адресов можно вполне обработать, например, несколькими потоками.
Запускать 10000 одновременных запросов это, конечно, перебор. Но запускать, скажем, по полсотни-сотне одновременных запросов -- вполне нормально. Такой шаблон называется троттлингом -- throttling (или в автомобильных терминах -- дросселированием :D). Т.е. пропускаем весь объем заданий по-немногу. Такой подход позволяет несильно загружать канал при отправке запросов и машину при получении и разборе ответов. Примерный код может выглядеть так:
public async Task CheckServers() { var servers = new List(10000) { ... }
const int ConcurrencyLevel = 100 // запускаем первые 100 запросов var tasks = servers.Take(ConcurrencyLevel).Select(GetVersion).ToList() int nextIndex = ConcurrencyLevel
while (tasks.Count > 0) { // дожидаемся завершения любого запроса var completedTask = await Task.WhenAny(tasks) // удаляем его из списка tasks.Remove(completedTask)
// добавляем новый запрос, если таковые остались if (nextIndex < servers.Count) { tasks.Add(GetVersion(servers[nextIndex++])) }
string rfbVersion = await completedTask // работаем с версией } }
private async Task GetVersion(string server) { // тут асинхронная реализация обращения к серверу по RFB и возвращение версии }
Важный вопрос состоит в правильной асинхронной реализации обращения к серверу по протоколу RFB. Если вы используете библиотеку -- она должна поддерживать асинхронность. Если вы реализуете эту функциональность сами (например, на сокетах) -- значит нужно пользоваться асинхронными функциями сокетов.
UPD
Выяснилось, что запросы/ответы у ТС настолько легковесные, что в данном случае троттлинг работает медленнее, чем если отправить сразу все запросы. Однако этот паттерн может быть по-прежнему полезен, когда необходимо ограничить количество исходящих запросов и/или количество обрабатываемых ответов (например, если разбор ответов сильно загружает процессор/требует много памяти).