Pull to refresh

Семантические ловушки асинхронности: Ключи к разгадке и эффективному освоению тем Task, Синхронность, Асинхронность

Level of difficultyMedium
Reading time11 min
Views9.3K

При освоении новых знаний мы, так или иначе, стараемся связать их с уже освоенными знаниями, в связи с чем при изучении и без того непростых абстракций, в терминах которых заложен свой особый смысл, нередко заходим в тупик.

Такая ситуация имеет место когда начинающий программист знакомится с темами Task,Синхронность, Асинхронность. Ситуация усугубляется тем что зачастую, более опытные коллеги применяют в жаргоне выражения из разряда “Синхронное/Асинхронное выполнение задачи”.

Для того, чтобы распутать этот «Гордиев узел», давайте подойдем к проблеме издалека: рассмотрим ее на примере работы симфонического оркестра. Сравним при этом значения проблемных терминов с точки зрения людей, обладающих различной профессиональной ориентацией.

Для проведения анализа привлечем двух экспертов. 

Знакомьтесь: это Валентин. Валентин – филолог по образованию. Он хорошо ориентируется в терминах.

А это Алексей. Он уже не помнит, на кого учился, потому что курсу ко 2-му он стал сеньором, у него отросла борода, и он забыл про университет. Он тоже хорошо ориентируется в терминах.

E:\Доклад DeX\Материал для статьи\Джун.bmp

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

Приступим к анализу.

Параллельное выполнение

С точки зрения Валентина, параллельное выполнение каких-либо операций на примере работы оркестра, выглядит следующим образом: дирижер дает указание двум исполнителям (Скрипач 1 и Скрипач 2) начать исполнение отрывка из пьесы. Каждый из них начинает игру, при этом вовсе не обязательно, что они начнут играть одновременно, как не обязательно и то, что они завершат отрывок в одно и то же время. Даже не обязательно, что они будут попадать в такт. Термин «параллельно» подразумевает лишь то, что в данный момент времени оба скрипача играют на скрипке. Не исключено, что один из них и вовсе ноты перепутал, параллелизм этого не отменяет. (Рис. 1) 

Рис. 1
Рис. 1

Если мы спросим Алексея, что он думает по этому поводу, он скажет, что два метода могут выполняться параллельно, если мы запустим их в разных потоках (два скрипача). При этом мы не можем гарантировать, что выполнение этих методов закончится одновременно. Это наглядно видно при выводе на консоль логов выполнения работы скрипачами (см. Рис 2). Из-за того, что потоки могут быть нагружены и другой работой помимо той, которую мы на них возлагаем, наши методы выводят данные на экран в непредсказуемом порядке. Так, словно оба скрипача – совсем новички, им очень сложно держать темп и никак не удается работать, как одна команда (см. Рис. 1). Мы можем утверждать только то, что каждый из них сейчас играет. 

Рис. 2
Рис. 2

Что ж, тут взгляды экспертов совпали. Лингвисты и программисты понимают одно и тоже под термином «параллельно».

Понятие синхронности и асинхронности, о которых пойдет речь дальше, чаще всего употребляют относительно задачи, а не потока, поэтому, прежде чем переходить к анализу следующего термина, давайте введем новую абстракцию – Task (задача). Task относится к потоку так же, как игра на скрипке относится к скрипачу. Поток – музыкант, скрипач в нашем случае (не забудьте про дирижера – он тоже музыкант). Задача, которую он выполняет (игра на скрипке) и есть тот самый Task (см. рис. 3). 

Рис. 3
Рис. 3

Не может выполняться задача без потока, как не может скрипка играть без скрипача. Но благодаря тому, что вся работа по выделению потока для выполнения новой задачи спрятана под капотом абстракции Task, мы можем оперировать задачами как самодостаточными сущностями, не думая о потоках. Так, как это делает дирижер в оркестре. Ему безразлично, кто сегодня на сцене – скрипач Петров или скрипач Иванов, для него это «скрипка», он ей говорит: «Играй», – а не Петрову. 

Синхронное выполнение

Рис. 4
Рис. 4

Валентин утверждает, что синхронно – это когда дирижер махнул палочкой, и обе скрипки начали играть одновременно, играют профессионально, и в ноты попадают, и темп игры у обеих одинаковый. Если встать между ними, возникнет стереоскопический эффект, будто одна и та же мелодия доносится из двух наушников. Скрипки-профи (см. рис 4).

Однако, если мы попросим Алексея рассказать нам про синхронное выполнение задач, он нас спросит: «А вы про какую, параллельную или последовательную?» О_о

Как это не парадоксально для лингвиста, в обиходном сленге программиста мы можем встретить две вариации термина “Синхронное выполнение” применительно к последовательному и параллельному запуску задач. Однако, утверждение “Задачи выполняются синхронно…” не корректно, оно не отражает смысл происходящего и вводит в заблуждение новичков , которые только знакомятся с рассматриваемой абстракцией.

Применительно к выполнению программы никакой синхронности не существует. Задачи могут выполняться либо последовательно, либо параллельно, но никак не синхронно. 

Рассмотрим пример - пусть дирижер (поток, в котором выполняется наш метод Main) запустил в работу 2 скрипки. После чего мы прописали вызов метода Wait для ожидания завершения каждой из запущенных задач. Скрипки запустились и работают параллельно, а дирижер при этом заблокирован на строчке с вызовом Wait и ожидает завершения работы скрипок.

 static void Main(string[] args)
        {
            Console.WriteLine("Дирижер дал указания скрипкам");

            Task violin1 = Task.Run(() => Print("Скрипка 1 закончила игру"));
            Task violin2 = Task.Run(() => Print("Скрипка 2 закончила игру"));

            violin1.Wait(); //блокирующее ожидание завершения задачи violin1
            violin2.Wait(); //блокирующее ожидание завершения задачи violin2

            Console.WriteLine("Дирижер готов раздавать новые указания");
        }

        static void Print(string text)
        {            
            Thread.Sleep(5000);
            Console.WriteLine(text);
        }
Рис. 5
Рис. 5

Сложившаяся ситуация может быть описана как “Синхронное ожидание выполнения задач”. Обратите внимание, тут есть очень важный момент, мы говорим о синхронности применительно к процессу ожидания выполнения (иными словами - завершения), а не к самому процессу выполнения. Выполняются задачи в данном случае параллельно, как и при рассмотрении параллелизма. Именно параллельно, со всем присущим параллелизму хаосу в порядке вывода данных на монитор (см. рис. 5 и рис. 2). То есть, снова два неопытных скрипача мучают скрипки, каждый в своем ритме, как на рисунке 1, никакой синхронности в их работе нет.

Мы можем расположить команду ожидания завершения задачи после запуска каждой задачи, при этом дирижер будет ожидать завершения игры одной скрипки, прежде чем дать команду играть второй скрипке. В этом случае работа будет выполняться последовательно, один Task за другим (см. рис. 6).

  static void Main(string[] args)
        {
            Console.WriteLine("Дирижер дал указания скрипке 1");

            Task violin1 = Task.Run(() => Print("Скрипка 1 закончила игру"));       
            violin1.Wait(); //блокирующее ожидание завершения задачи violin1

            Console.WriteLine("Дирижер дал указания скрипке 2");

            Task violin1 = Task.Run(() => Print("Скрипка 2 закончила игру"));      
            violin2.Wait(); //блокирующее ожидание завершения задачи violin2

            Console.WriteLine("Дирижер готов раздавать указания");
            Console.ReadLine();
        }
Рис. 6
Рис. 6

В обиходе, можно услышать, что это “последовательное, синхронное выполнение”, но тут тоже ошибка. Поскольку термин “синхронно” не применим к выполнению программы, корректно будет сказать что это “синхронное ожидание выполнения..” или “синхронное ожидание завершения…”, “блокирующее ожидание”. То есть под синхронностью, подразумевается именно факт блокировки дирижера (потока запустившего задачи). 

«Ничего не понятно, но очень интересно. Ну ладно , а где же красивая игра двух скрипок?»

Парадокс, но ее не будет. Вышеописанный хаос и прячется за ширмой термина «синхронно» применительно к выполнению программного кода. Термин «синхронно» и «с блокировкой запускающего потока», тут применяют как синонимы, хотя это и не очевидно. Это надо четко понимать, когда пробуешь погружаться в эту тему. Ожидание обратного может помешать освоению данного инструмента.

Во что теперь верить? Как опираться на жизненный опыт? И что же тогда с асинхронностью? Куда уж асинхронней того, что описано в этом пункте?

Асинхронное выполнение

Если мы спросим Валентина, что он думает по поводу термина «асинхронно», он скажет:

Рис. 7
Рис. 7

Ну представьте себе две скрипки, которые решили подшутить над дирижером. Они начали одновременно, придерживались одного темпа игры, но при этом в их игре не совпала ни одна нота. Словно они разные произведения играли (см. рис. 7). 

Когда первая скрипка играет А, вторая играет B. Когда первая играет B, вторая играет А. И так на протяжении всей пьесы.

Алексей же утверждает, что, с точки зрения скрипки, асинхронное поведение мало чем отличается от случая, который мы описывали при рассмотрении синхронной работы. 

В случае последовательного запуска задач первая скрипка отыграла, вторая получает указание приступать к работе и т. д. (см. рис. 8). Но есть одно но! Дирижер при этом освобождается сразу, как раздал указания первой скрипке, и свободен до тех пор, пока скрипка не завершит работу. После этого его просят вернуться к дирижированию, он раздает указания и снова может быть свободен, пока выполняется новая задача.

 async static Task Main(string[] args)
        {
            Console.WriteLine("Дирижер дал указания скрипке 1");
            Task violin1 = PrintAsynс("violin1");
            Console.WriteLine("Дирижер пошел по своим делам");
            await violin1; //не блокирующее ожидание завершения задачи violin1


            Console.WriteLine("Дирижер дал указания скрипке 2");
            Task violin2 = PrintAsynс("violin2");
            Console.WriteLine("Дирижер пошел по своим делам");
            await violin2; //не блокирующее ожидание завершения задачи violin2

            Console.WriteLine("Дирижер готов раздавать новые указания");
        }

        static async Task PrintAsynс(string text)
        {
            Console.WriteLine($"Скрипка {text} играет");
            await Task.Delay(10000);
            Console.WriteLine($"Скрипка {text} закончила игру");
        }
Рис. 8
Рис. 8

Можно сказать, что для скрипки не существует ни синхронности, ни асинхронности. Термины синхронно/асинхронно применимы только к дирижеру.

То есть как? Зачем? Почему? Причем тут асинхронность? Вы все врете! Это все не имеет смысла! В программировании так не бывает! Там везде царит математический порядок!

А вот так. Хоть это и не имеет ничего общего с тем, что под асинхронностью подразумевает Валентин, такой подход называют асинхронным. Зачастую ошибочно применяя фразу “Асинхронное выполнение задач”. Корректней всего этот процесс можно описать фразой “Асинхронное ожидание выполнения задачи” а лучше “не блокирующее ожидание выполнения задачи”.  

Такой режим работы дает большие возможности с точки зрения эффективности использования ресурсов. Предположим, что в нашем распоряжении всего один поток. Этакий человек-оркестр в лице дирижера. Он выходит к публике, кланяется, поворачивается к пюпитру, делает взмах палочкой, указывая на скрипку, и… бросает палочку, берет в руки скрипку. Отыгрывает партию скрипки, бежит снова за пюпитр, дает указание фортепиано начать игру и сам же эти указания выполняет (см. рис. 9).

Рис. 9

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

Стоит уяснить: несмотря на то, что после запуска асинхронного метода поток освобождается, выполнение метода, в котором производился запуск асинхронной задачи (в нашем случае метод Main), при этом не продолжается, оно останавливается на операторе await и ждет результата выполнения задачи. Да, поток в это время может быть привлечен к другой работе, но программный код в методе Main замер на строчке с await и ждет результата! Когда результат придет, какой-нибудь поток будет привлечен к дальнейшему выполнению метода (не обязательно, тот же, что выполнял метод Main до этого, для простоты объяснения мы сознательно опускаем этот момент). 

При изучении литературы к данной теме вы можете столкнуться с такой фразой: «Пока выполняется асинхронная задача, выполнение кода возвращается в вызывающий метод - то есть, в нашем случае, в метод Main». Не допускайте ложного представления о том, что это приведет к дальнейшему выполнению метода Main! В следующем примере, работа метода Main продолжиться только после того, как он – метод, дождется результата выполнения всех асинхронных задач. 

Здесь, применение выражения “не блокирующее ожидание” вместо “асинхронно” как нельзя лучше передает смысл происходящего - мы ждем результат, код не выполняется! 

Все равно, что дирижер, которому разрешено пойти выпить чаю, пока скрипка отыгрывает партию, но не дозволено раздавать указания другим инструментам в это время. Рано еще, согласно нотам, не положено. 

Непоняяяятно. >_> Если дирижер свободен, почему он не может раздать указания другим инструментам?

А никто не говорил, что он не может. Просто в описанном случае так стояла задача. В нотах пьесы было написано: сначала отыгрывает партия скрипки, после этого вступает виолончель. Вот бедный дирижер и маялся без дела, вроде и не занят, а и размахивать палочкой пока нельзя. Оператор await сказал ему: «Займись чем-то другим, я тебя позову, когда скрипка отыграет.»

Ладно. А если мы возьмем другую пьесу, в которой надо запустить в работу сразу 2 скрипки?

В этом случае мы бы переписали код следующим образом:

 async static Task Main(string[] args)
        {
            Console.WriteLine("Дирижер дал указания скрипке 1");

            Task violin1 = PrintAsynс("violin1");
            Task violin2 = PrintAsynс("violin2");

            await violin1; //не блокирующее ожидание завершения задачи violin1
            await violin2; //не блокирующее ожидание завершения задачи violin2

            Console.WriteLine("Дирижер готов раздавать новые указания");
        }

        static async Task PrintAsynс(string text)
        {
            Console.WriteLine($"Скрипка {text} играет");
            await Task.Delay(10000);
            Console.WriteLine($"Скрипка {text} закончила игру");
        }

При этом наш дирижер сначала запустит работу двух скрипок и пойдет по своим делам, пока скрипки не завершат работу. Скрипками манипулируют два независимых скрипача (два отдельных потока), поэтому дирижер свободен. Ну и, конечно же, мы не можем угадать порядок их завершения, как и в ранее рассмотренных случаях параллельного выполнения, мы лишь можем утверждать, что дождемся сначала пока доиграет первая скрипка, а после, если не доиграла вторая, будем ждать и ее (см. порядок операторов await). 

Погодите-ка, вы снова меня обманываете! Мы это уже проходили! Чем же это отличается от синхронно-параллельного выполнения? Там же было тоже самое!

Пожалуй, с точки зрения скрипок и скрипачей, положение дел очень похоже, и это может создать почву для путаницы. Но есть одно но: в синхронном варианте для того, чтобы дождаться окончания работы скрипок, мы заставляли дирижера ждать (см. рис. 5, рис. 7). Он не мог отвлечься от процесса работы скрипок. В асинхронном же режиме мы говорим ему: «Ты пока можешь сходить попить чай, скрипки все равно не скоро закончат. А как закончат, мы сами тебя позовем.» 

Помните прошлый пример, когда дирижер работал за весь оркестр? Если в оркестре есть и другие исполнители помимо дирижера (другие свободные потоки), то ему вовсе не обязательно отыгрывать партии всех инструментов. Он раздал указания двум скрипачам, дирижировать дальше ему запрещает оператор await, но в оркестре полно другой работы. Он по-прежнему может сесть за фортепиано.

Так и с потоками и задачами. Стоит только уточнить, что мы не знаем, какой работой займется наш поток. Распределением работы в данном случае занимаемся не мы. Как и не можем быть уверены, что именно поток-дирижер занят выполнением задачи-фортепьяно. Применяя асинхронный подход, мы лишь гарантируем, что дирижер не будет зря занимать сцену. Он получит статус «свободен» и станет доступен для другой работы. Но чаще всего нас и не волнует, кто держит скрипку в руках – Петров, Иванов или сам дирижер.

И кстати, вы вновь допускаете ошибку применив фразу “..синхронно-параллельного выполнения”, корректно будет “синхронного ожидания выполнения” и не важно какую работу мы ожидаем -  параллельную или последовательную. 

Помимо описанных выше примеров могут иметь место случаи, которые на первый взгляд трудно классифицировать как синхронный или асинхронный.

Например, если мы просто напишем в методе Main запуск нескольких Task, это будет равнозначно тому, что дирижер раздал указания скрипачам, посмотрел на часы, увидел, что рабочий день окончен, развернулся и ушел, не дослушав игру или, техническим языком, метод Main завершит свою работу раньше, чем запущенные им задачи. Следовательно, результат их выполнения мы не увидим (см. рис. 10).

async static Task Main(string[] args)
        {
            Console.WriteLine("Дирижер дал указания скрипкам");

            Task violin1 = Task.Run(() => Print("Скрипка 1 закончила игру"));
            Task violin2 = Task.Run(() => Print("Скрипка 2 закончила игру"));

            Console.WriteLine("Дирижер готов раздавать новые указания");
        }

        static void Print(string text)
        {
            Thread.Sleep(5000);
            Console.WriteLine(text);
        }
Рис. 10
Рис. 10

Блокировки запускающего потока вроде бы нет - следовательно можно предположить, что это асинхронный случай. Но и ожидания посредством оператора await тоже нет, следовательно, это не асинхронность. Данный пример стоит классифицировать просто как параллельное выполнение программы. Концепция синхронности и асинхронности, к нему не применима.

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

Tags:
Hubs:
Total votes 14: ↑11 and ↓3+9
Comments17

Articles