Объяснение задержки вывода консоли в параллельном C#
Узнайте, почему параллельные выводы в консоли C# задерживаются, даже при NoBuffering и NotBuffered. Поймите влияние AsOrdered() и получите решения.
Почему вывод в консоль задерживается при параллельной обработке в C#, несмотря на использование опций NoBuffering и NotBuffered?
У меня возникло неожиданное поведение в коде параллельной обработки на C#, где вывод в консоль задерживается, хотя я использую опции, которые, как я думал, должны отключить буферизацию. Вот мой код:
var rand = new Random(1);
var range = Enumerable.Range(1, 8);
var partition = Partitioner.Create(range, EnumerablePartitionerOptions.NoBuffering);
foreach (var x in partition
.AsParallel()
.AsOrdered()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.WithDegreeOfParallelism(4)
.Select(DoSomething))
{
Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
}
int DoSomething(int x)
{
Console.WriteLine($"WAIT {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
int random;
lock (rand) { random = rand.Next(2000); }
Thread.Sleep(random); // имитация работы
Console.WriteLine($"DONE {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
return x;
}
Вывод показывает задержку в 1,3 секунды между тем, как элемент 1 обрабатывается, и тем, как он выводится в консоль:
WAIT 2 13:51:08.8398170 10
WAIT 1 13:51:08.8398197 9
WAIT 3 13:51:08.8398132 11
WAIT 4 13:51:08.8398108 4
DONE 1 13:51:09.0805471 9 <-- начало
WAIT 5 13:51:09.0808715 9
DONE 2 13:51:09.3504889 10
WAIT 6 13:51:09.3505787 10
DONE 3 13:51:09.7937364 11
WAIT 7 13:51:09.7939256 11
DONE 6 13:51:10.2208844 10
WAIT 8 13:51:10.2209660 10
DONE 4 13:51:10.3948195 4
---- 1 13:51:10.3951458 2 <-- конец
DONE 5 13:51:10.4109264 9
---- 2 13:51:10.4112009 2
---- 3 13:51:10.4112443 2
DONE 7 13:51:10.5068458 11
---- 4 13:51:10.5068961 2
---- 5 13:51:10.5071686 2
---- 6 13:51:10.5072167 2
DONE 8 13:51:12.1163565 10
---- 7 13:51:12.1164506 2
---- 8 13:51:12.1165087 2
Несмотря на использование EnumerablePartitionerOptions.NoBuffering и ParallelMergeOptions.NotBuffered, я ожидал, что элемент 1 будет выведен сразу после его обработки в методе DoSomething. Это ожидаемое поведение, или я упускаю какие‑то настройки, которые позволили бы добиться немедленного вывода в консоль при параллельной обработке?
Консольный вывод в C# при параллельной обработке может всё ещё задерживаться, даже если использовать опции NoBuffering и NotBuffered, потому что внутренние механизмы синхронизации PLINQ и необходимость поддерживать порядок при использовании AsOrdered() создают задержки.
Опции буферизации, которые вы применяете, работают корректно, но они не решают фундаментальную проблему: упорядоченные параллельные запросы должны ждать завершения предыдущих элементов, прежде чем выдавать результаты.
Содержание
- Понимание механизма буферизации
- Почему вывод в консоль всё ещё задерживается
- Роль AsOrdered() в задержках
- Проблемы синхронизации потоков
- Практические решения для немедленного вывода
- Альтернативные подходы
- Проблемы производительности
Понимание механизма буферизации
Опции буферизации, которые вы используете, работают так, как задумано, но служат разным целям:
-
EnumerablePartitionerOptions.NoBuffering: предотвращает буферизацию на входе параллельного запроса, т.е. данные обрабатываются по мере их появления, а не загружаются заранее в буферы. -
ParallelMergeOptions.NotBuffered: предотвращает буферизацию на выходе, уменьшая задержку между обработкой элементов и их доступностью для потребителей.
Согласно документации Microsoft, «Используйте NotBuffered для запросов, которые будут потребляться и выводиться как потоки; это обеспечивает минимальную задержку между началом выполнения запроса и появлением элементов».
Проблема не в этих опциях, а в том, как PLINQ обрабатывает упорядоченное выполнение и слияние результатов.
Почему вывод в консоль всё ещё задерживается
В вашем выводе видно, что сообщения «WAIT» и «DONE» появляются сразу на своих рабочих потоках (потоки 9, 10, 11, 4), но сообщения «----» в цикле foreach все выводятся на потоке 2 с заметной задержкой.
Это происходит из‑за:
-
Слияния результатов: При использовании
AsOrdered()PLINQ должен сохранять исходный порядок. Хотя элементы обрабатываются параллельно, они могут быть выданы потребителю только в правильном порядке. -
Потребление одним потоком: Ваш цикл
foreachвыполняется в одном потоке (поток 2), который должен дождаться завершения всех предыдущих элементов, прежде чем обработать следующий.
Как объясняет Microsoft: «foreach сам по себе не выполняется параллельно, поэтому он требует, чтобы вывод из всех параллельных задач был объединён обратно в поток, в котором выполняется цикл».
Задержка в 1,3 секунды — это время, необходимое для завершения обработки элемента 1 и достаточного завершения последующих элементов, чтобы PLINQ мог безопасно выдать элемент 1 в правильном порядке.
Роль AsOrdered() в задержках
Использование AsOrdered() является основным фактором задержки. При сохранении порядка PLINQ реализует дополнительную синхронизацию:
// Это заставляет PLINQ выполнять дополнительную синхронизацию в конце
.AsOrdered()
Исследование на Stack Overflow подтверждает: «Используя AsOrdered() вы заставляете PLINQ выполнять дополнительную синхронизацию и буферизацию в конце».
Даже с NotBuffered PLINQ всё равно должен убедиться, что при запросе элемента 1 он действительно завершён и что все предыдущие элементы (в исходной последовательности) тоже завершены. Это создаёт естественную задержку в упорядоченных сценариях.
Проблемы синхронизации потоков
Несколько факторов синхронизации способствуют задержке:
-
Синхронизация вывода в консоль: Хотя класс
Consoleобрабатывает синхронизацию потоков, это всё равно создаёт некоторую нагрузку, поскольку несколько потоков конкурируют за доступ к буферу консоли. -
Блокировка объекта
Random: Ваш блокlockна объектеrandсоздаёт конкуренцию между потоками:csharplock (rand) { random = rand.Next(2000); }Это заставляет потоки ждать доступа к общему экземпляру
Random. -
Управление ThreadPool: Механизм PLINQ использует потоки из ThreadPool и текущий поток как рабочие потоки, что может вызывать задержки планирования.
Документация Essential C# отмечает, что «синхронизация потоков, которая предотвращает гонки данных, но избегает взаимных блокировок» является сложным аспектом параллельного программирования.
Практические решения для немедленного вывода
Решение 1: Удалить AsOrdered() для истинного потокового вывода
Если порядок не критичен, уберите AsOrdered():
foreach (var x in partition
.AsParallel()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.WithDegreeOfParallelism(4)
.Select(DoSomething))
{
Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
}
Это позволит потреблять результаты по мере их завершения, независимо от исходного порядка.
Решение 2: Использовать ForAll вместо foreach
Для максимальной производительности без сохранения порядка:
partition.AsParallel()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.WithDegreeOfParallelism(4)
.Select(DoSomething)
.ForAll(x => Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}"));
Согласно документации Microsoft, ForAll «обрабатывает результаты в последовательном порядке, например, когда вы вызываете Console.WriteLine для каждого элемента».
Решение 3: Разделить обработку и вывод
Создайте отдельный поток для вывода в консоль:
var results = partition.AsParallel()
.AsOrdered()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.WithDegreeOfParallelism(4)
.Select(DoSomething);
// Обрабатываем результаты сразу по мере их завершения
var outputTask = Task.Run(() =>
{
foreach (var x in results)
{
Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
}
});
// При необходимости дождаться завершения
outputTask.Wait();
Альтернативные подходы
Использование Channels для истинного потокового вывода
Для более продвинутых сценариев рассмотрите System.Threading.Channels:
var channel = Channel.CreateUnbounded<int>();
// Задача-производитель
Task.Run(async () =>
{
await foreach (var x in partition.AsParallel()
.AsOrdered()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.Select(DoSomething))
{
await channel.Writer.WriteAsync(x);
}
channel.Writer.Complete();
});
// Задача-потребитель
await foreach (var x in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
}
Использование BlockingCollection для немедленной обработки
var blockingCollection = new BlockingCollection<int>();
// Параллельный производитель
var producer = Task.Run(() =>
{
partition.AsParallel()
.AsOrdered()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.ForAll(x => blockingCollection.Add(DoSomething(x)));
blockingCollection.CompleteAdding();
});
// Потребитель
foreach (var x in blockingCollection.GetConsumingEnumerable())
{
Console.WriteLine($"---- {x} {DateTime.Now.TimeOfDay} " +
$"{Thread.CurrentThread.ManagedThreadId}");
}
Проблемы производительности
Хотя приведённые решения обеспечивают немедленный вывод, стоит учитывать компромиссы:
- Сохранение порядка: Удаление
AsOrdered()повышает пропускную способность, но теряет гарантии исходного порядка. - Нагрузка: Дополнительные механизмы синхронизации увеличивают нагрузку на CPU.
- Использование ресурсов: Истинные потоковые подходы могут потреблять больше памяти и потоков.
Согласно рекомендациям Microsoft: «Опция FullyBuffered заставляет вывод всего запроса быть буферизованным до того, как какие-либо элементы будут выданы. Это может занять больше времени, прежде чем первый элемент станет доступен, но полный набор результатов может быть сгенерирован быстрее, чем при использовании других опций».
Для вашего конкретного случая, если приоритетом является немедленный вывод в консоль, а порядок не критичен, удаление AsOrdered() и использование ForAll обеспечит наиболее быстрый отклик.
Вывод
Задержка вывода в консоль, которую вы наблюдаете, является ожидаемым поведением при использовании AsOrdered() с PLINQ, даже при применении опций NoBuffering и NotBuffered. Ключевые выводы:
-
Опции буферизации работают корректно:
EnumerablePartitionerOptions.NoBufferingиParallelMergeOptions.NotBufferedфункционируют как задумано, но не устраняют фундаментальную необходимость ожидания завершения предыдущих элементов в упорядоченных запросах. -
AsOrdered()является основным фактором задержки: Требование сохранения порядка заставляет PLINQ задерживать выдачу результатов до завершения всех предыдущих элементов. -
Существует несколько решений: Для немедленного вывода можно удалить
AsOrdered(), использоватьForAllвместоforeach, либо реализовать отдельные потоки для обработки и вывода. -
Компромиссы: Каждое решение имеет свои плюсы и минусы — сохранение порядка против мгновенного отклика, пропускная способность против задержки, использование памяти и потоков.
Для истинного потокового поведения без ограничений порядка уберите AsOrdered() и рассмотрите использование ForAll или паттернов производителя/потребителя. Если порядок обязателен, примите, что некоторая задержка неизбежна в модели упорядоченного параллельного выполнения.