1

Уже спросил на англоязычном форуме, ответы меня не устраивают.

На самом деле я изучаю async/wait и пытаюсь увидеть для себя преимущество await Task.WhenAll по сравнению с Task.WaitAll в операциях, связанных с процессором. Поскольку все пишут, что Task.WaitAll обеспечивает блокирующее ожидание, а await Task.WhenAll обеспечивает неблокирующее ожидание.

Я создал пример, в котором хотел заменить Task.WaitAll на await Task.WhenAll и своими глазами увидеть, что появился еще один свободный поток. Но я вижу, что даже Task.WaitAll не блокирует поток. И мой вопрос связан с этим. В случае с Task.WaitAll я вижу, что в том же потоке, в котором выполняется Task.WaitAll, выполняется другая задача. Но если я включу Thread.Sleep или while (true) вместо Task.WaitAll, то поведение программы станет ожидаемым.

Я думал, что метод Main создаст задачу MyTask (-1 рабочий поток), которая создаст 16 задач условно B1-B16 (-15 рабочих потоков, так как 1 рабочий поток занят задачей MyTask, а всего рабочих потоков 16), задача MyTask будет иметь блокирующее ожидание Task.WaitAll, и я увижу 15 из 16 запущенных задач. Но я вижу все 16 запущенных задач, и одна из них выполняется в том же потоке, что и задача MyTask.

Вопрос. Почему в этом примереTask.WaitAll не блокирует поток, в котором он выполняется, в отличие от Thread.Sleep или while (true)? Может ли кто-нибудь объяснить пошагово, как работает код двух задач в потоке 4 в случае использования Task.WaitAll? Почему поток, в котором выполняется задача MyTask, также используется условно задачей B16?

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApp1 { class Program {
static void Main(string[] args) { Console.WriteLine($"Main Thread: {Thread.CurrentThread.ManagedThreadId}");

        int ProcessorCount = Environment.ProcessorCount;
        ThreadPool.SetMaxThreads(ProcessorCount, ProcessorCount);
        int Counter = 0;
        List<Task> MyListForTask = new List<Task>();

        void MyMethod()
        {
            lock (MyListForTask)
            {
                Counter++;
                Console.WriteLine($"Counter: {Counter}        Thread: {Thread.CurrentThread.ManagedThreadId}");
            }

            //Thread.Sleep(int.MaxValue);
            while (true) { };
        }

        Task MyTask = Task.Run(() =>
        {
            Console.WriteLine($"MyTask            Thread: {Thread.CurrentThread.ManagedThreadId}\n");

            for (int i = 0; i < ProcessorCount; i++)
            {
                MyListForTask.Add(Task.Run(MyMethod));
            }

            //Thread.Sleep(int.MaxValue);
            //while (true) { };
            Task.WaitAll(MyListForTask.ToArray());                
        });

        MyTask.Wait();
    }
}

}

enter image description here

enter image description here

enter image description here

enter image description here

2 Answers2

2

Отличное наблюдение. Task.WaitAll действительно каким-то образом позволяет шедьюлеру взять его поток и использовать в дочерних задачах. Ну а почему нет, если он не может завершиться, пока все что внутри не завершится. Браво, майкрософт, отличная работа с потоками, ни потока мимо.

С другой стороны, этот поток, который заблочен такой синхронной ожидалкой как WaitAll, вы все равно никак не сможете использовать. Например, запустите WaitAll из UI потока Windows Forms. Если бы ваше подозрение было верным, форма бы не зависла. А всё потому, что диспетчер UI потока и шедьюлер не могут и не должны договориться.

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

С другой стороны, когда пишешь асинхронный код, о потоках вообще не думаешь, потому что работа с потоками - проблемы контекста синхронизации. Есть разница - синхронный вызов и асинхронный вызов. Асинхронный возвращает в вызывающий поток Task сразу как только внутри себя сам начал асинхронную работу, например наткнулся на await.

Вот в чем главная разница:

Task task = Task.WhenAll(tasks);
Console.WriteLine("привет, я свободный поток");
task.Wait();

То есть даже при отсутствии контекста синхронизации и без использования async/await выгода от использования асинхронных операций очевидна.

Второй аспект, который я расписывать не буду, это возможность отмены. Подавляющее большинство асинхронных методов а .NET принимают CancellatoionToken в качестве аргумента. Вы не можете отменить тот же Thread.Sleep пока он не выспится полностью, а Task.Delay сможете в любой момент.

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

Может ли кто-нибудь объяснить пошагово, как работает код двух задач в потоке 4 в случае использования Task.WaitAll?

Магия оптимизаций .NET! :) В этом нет никакого секрета, истина где-то в открытых исходниках самого дотнета. Я покопался, раскопки привели меня в недра ManualResetEventSlim, и там я потерялся в низкоуровневой работе с ресурсами операционной системы Windows, хотя с очень большой вероятностью она тут не при чем и я просто заблудился.

На самом деле я изучаю async/await и пытаюсь увидеть для себя преимущество await Task.WhenAll по сравнению с Task.WaitAll

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

aepot
  • 49,560
  • Спасибо, значит как и ожидалось - подкапотная муть .NET. Не впервые. Нужно было подтверждение более умных людей. –  Nov 24 '22 at 10:27
  • В некоторых местах документации MIcrosoft прямо говорится, что некоторые задачи могут запускаться на основном потоке. link (и последний раздел) – Alexander Petrov Nov 24 '22 at 12:05
0

Могу ошибаться в деталях, наверняка aepot напишет лучше ответ. И тем не менее.

  1. Насчёт переиспользования потока - вопрос тонкий, я не готов ответить. Но в общем случае асинхронному Task-у вообще не обязателен отдельный поток. Вам нужно подразобраться в чём разница между многопоточностью и асинхронностью. Асинхронное выполнение нескольких задач возможно и в одном потоке, просто периодически переключается контекст. Для начала вам лучше по отдельности эти концепции поизучать и покрутить по-всякому. Здесь вы их смешиваете, вас это только запутает. У вас тут нет ни одного await, но есть много Task-ов, это только запутывает ситуацию.

  2. Разницу между Task.WaitAll и Task.WhenAll вы не видите потому, что у вас нет await-ов снаружи MyTask. Вот если бы MyTask был полноценным асинхронным методом, ожидавшимся через await (или через другой Task.WhenAll) где-то снаружи, то поток должен был бы освободиться, а на Task.WaitAll он должен был бы висеть. Но из-за MyTask.Wait(); что внутри не напиши, а эта инструкция всё-равно должна по идее заблокировать текущий поток.

Я вижу как-то так, хотя наверняка могу ошибаться.

P.S. Про ThreadPool убрал, был не прав.

CrazyElf
  • 71,194
  • 1
    Task.Run по умолчанию делает ThreadPool.QueueUserWorkItem – aepot Nov 24 '22 at 09:11
  • Спасибо за ответ. 1) Вижу своими глазами что влияет. Докажите. Запустите задач, которые будут работать до упора, больше, чем указанный лимит в ThreadPool.SetMaxThreads. 2) и 3) Вопрос не про теорию. Вопрос максимально конкретный про практику. Есть пример. Как это работает? Почему в MyTask - while(true) или Thread.Sleep не дают запуститься условно 16-ой задаче, а якобы блокирующее ожидание Task.WaitAll дает, при этом в том же потоке. Это именно то, что меня интересует. –  Nov 24 '22 at 09:16
  • @aepot Понятно, тут у меня значит пробел. Был ) – CrazyElf Nov 24 '22 at 09:20
  • @NikVladi А эффект устойчивый, вы много раз запускали и всё время картина была одинаковая? – CrazyElf Nov 24 '22 at 09:21
  • @CrazyElf, да, одинаковая. Я видел разные глюки в .NET и на git писал куда надо. Допускаю как то, что я не понимаю что-то, так и то, что это глюк. Разобраться хочется. –  Nov 24 '22 at 09:21
  • 1
    @NikVladi Функции while(true) и Thread.Sleep это обычные синхронные функции и они разумеется тормозят текущих поток. А WaitAll() это асинхронная функция. А суть асинронности вообще то как раз в том, что бы такая задача не занимала поток. И она его не занимает. Любая потенциально асинхронная функция в тот момент когда ей надо реально чего то ждать подает сигнал планировщику [асинхронных] задач, тот переключает стек на другую область и выполняет с этим стеком другую асинхронную задачу в том же потоке, до тех пор пока та не завершиться или не решит сама чего то подождать. – Mike Nov 24 '22 at 10:22
  • @Mike Да вот теоретически вроде бы всё понятно, у товарища вопрос, почему практика с теорией не бьётся ) – CrazyElf Nov 24 '22 at 10:33
  • @Mike, запустите код и скажите занимает ли WaitAll поток? Занимает. `int ProcessorCount = Environment.ProcessorCount; ThreadPool.SetMaxThreads(ProcessorCount, ProcessorCount);

    Task t = Task.Delay(int.MaxValue);

    for (int i = 0; i < ProcessorCount * 2; i++) { Task.Run(() => { Console.WriteLine(Task.CurrentId); Task.WaitAll(t); }); }

    Task.WaitAll(t);`

    –  Nov 24 '22 at 10:37
  • 1
    @NikVladi Если задача назначена на поток, это не значит что она в этом потоке занимает процессор. Вы сами показали, что у вас 2 задачи находятся одновременно в потоке 4. И это правильно, просто одна из них (ну фактически конечно обе, просто третьей задачи на тот же поток не нашлось) в состоянии ожидания, ее стек отложен в сторону и поток команд передан другой задаче – Mike Nov 24 '22 at 10:48
  • 2
    @NikVladi - Запустите задач, которые будут работать до упора, больше, чем указанный лимит в ThreadPool.SetMaxThreads - легко, если внутри этих задач будет await внутри цикла. – Alexander Petrov Nov 24 '22 at 12:07
  • 1
    @AlexanderPetrov ну как бы спорно, ведь если контекст null, то континуация встыкается в тот же пул потоков. А если не null, то он сам решает. (см. реализацию SynchronizationContext) – aepot Nov 24 '22 at 12:48
  • @AlexanderPetrov, выше @Mike писал о WaitAll, я на это и реагировал и привел конкретный пример. await к Task.WaitAll не применить, к Task.Run можно, но станет только хуже, дальше первой задачи дело не пойдет. Если Task.WaitAll заменить на await Task.WhenAll, то да, все ок. Но речь шла о - я цитирую @Mike - "А WaitAll() это асинхронная функция. А суть асинронности вообще то как раз в том, что бы такая задача не занимала поток. И она его не занимает.". Но WaitAll как раз занимает. Но иногда, как мы убедились, занимает "с перерывами". –  Nov 24 '22 at 14:53