В прошлый раз мы разобрали пример, когда асинхронная операция использует дополнительный поток. Этот пример многим показался провокационным и даже вредным, что для меня выглядит достаточно странным. Насколько я понял основной претензией является то, что этот пример для многих как бы отрицает «экономное использование потоков», как это сформулировано например здесь-«metanit: Асинхронное программирование» .

Конечно, многие обиделись на меня за то, что я посмел возражать признанному авторитету, который вынес в заголовок своей очень известной работы фразу There is no thread (Там нет потока) ведь хорошо известно, что: «нет пророка в своем отечестве», и, видимо, быть не должно, но это все эмоции.

Давайте я покажу, как преобразовать мой пример, чтобы он продемонстрировал не только то самое «экономное использование потоков», но и откуда оно берется.


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

 Можно, например вспомнить определения синхронного и асинхронного методов, которое дал Стивен Тоуб (например):

this method is “synchronous” because a caller will not be able to do anything else until this whole operation completes and control is returned back to the caller

this method is “asynchronous” because control is expected to be returned back to its caller very quickly and possibly before the work associated with the whole operation has completed

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

 Рассмотрим, например, синхронную операцию, например вывод в консоль и соответствующий ей синхронный метод:

Console.WriteLine(string);

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

 Здесь можно почитать про неблокирующую, то есть асинхронную запись в консоль (If it does block is there a method of writing asynchronous output to the Console?):

https://stackoverflow.com/questions/3670057/does-console-writeline-block

Сложность понятия асинхронности, рассуждения для гурманов с уклоном в философию

Пару недель назад на Хабре прошла статья

 https://habr.com/ru/articles/799145/  

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

Начать надо с того, что сложность асинхронности как термина очевидно связана с тем, что,

Во-первых, это противоположность синхронности, а, во-вторых, синхронность не существует сама по себе, синхронность всегда существует между двумя (и более) сущностями, между двумя объектами, обычно объектами действия, которые в свою очередь существуют во времени. Синхронность можно определить как: соответствие поведения объектов действия по параметрам времени.

Тут мы вводим еще одно понятие «объект действия». Применительно к программированию это вполне интуитивное обобщение для всего, что каким-то образом определяет действие. Это в первую очередь методы или функции, которые инкапсулируют вычисления или логику преобразования данных/обмена данными/логику управления чем-то, в том числе преобразованием-обменом данными во времени, то есть действия по отношению к данным или в общем по управлению чем-то. Иногда в качестве объектов действия в программировании рассматриваются задачи, процедуры, абстрактная работа, потоки исполнения, запросы…

Объекты действия и просто объекты из ООП

Я всегда хотел поделиться с аудиторией своим пониманием объекта для Объектно-Ориентированного Программирования (ООП). Мне нравится представлять себе объект как сокращение выражения «объект нашего внимания». По-моему, очень последовательно-логично получается, когда мы выделяем при проектировании то, что требует нашего внимания как объект, который мы должны каким-то образом идентифицировать-изолировать в коде. В этом смысле любой тип программирования можно рассматривать как спецификацию ООП, например процедурное или функциональное программирование это способ программирования, когда основным и базовым объектом проектирования-реализации алгоритмов/логики являются процедуры/функции. Когда мы сосредоточились на объектах действия таких как методы, они же функции или процедуры, мы в какой-то степени попали в зону функционального-процедурного программирования. Здесь важно отметить, что вряд ли хороший дизайн может обойтись объектами только одного типа, например только процедурами или только объектами действия, такое сокращение типов объектов при построении системы вряд ли позволит создать жизнеспособную систему. Предлагаю считать это просто упражнением по риторике, которое позволяет укрепить и стабилизировать понимание отношений между терминами и определениями, которые нам придется использовать далее.

 Подведем промежуточный итог. В чем сложность асинхронности? Ответ: мы должны рассматривать взаимодействие двух и более объектов во времени, фактически это всегда анализ некоторой системы не статических во времени объектов. Мы должны выяснить тип объектов, мы должны выяснить параметры, которые влияют/участвуют во взаимодействии, определить способ их согласования-синхронизации.

Модифицированный пример, есть ли теперь дополнительный поток

Я думаю, теперь вы готовы анализировать примеры. Наша цель понять как же async/await «экономят потоки», в этот раз мы доберемся до примера, в котором что-то подобное происходит.

Пример из прошлой статьи очевидно запускает дополнительный поток для вызванной из функции main() консольного приложения асинхронной операции:

SomeMethodAsync1();

Против фактов не попрешь, как говорится, но что, если мы поставим await перед этим вызовом?

static async Task Main()
        {
//ДОБАВЛЕН AWAIT!!!!!!!!!!!
            await SomeMethodAsync1();//ТУТ ДОБАВЛЕН AWAIT!!!!!!!!!!!
            Console.WriteLine($"ThrID={Thread.CurrentThread.ManagedThreadId}");
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine($"MAIN Iter={i}");
                Thread.Sleep(100);
            }
        }

        static async Task SomeMethodAsync1()
        {
            await Task.Yield();
            Console.WriteLine($"TrID={Thread.CurrentThread.ManagedThreadId}");
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine($"MethodIter={i}");
                Thread.Sleep(100);
            }
        }

Нет ничего проще чем это проверить, спасибо всем, кто комментировал мою предыдущую статью, потому что именно ваши комментарии заставили меня пойти на этот ужасный эксперимент :)))!!!

Кстати, обратите внимание, у нашей функции майн уже стоит необходимый async, который нужен чтобы скомпилировать функцию с добавленным await-ом.

Мы получим в этом случае вот такой замечательный вывод:

вывод для вызова под AWAIT-ом
вывод для вызова под AWAIT-ом

Как видите теперь все исполнение происходит в единственном потоке с TrID=5. Сравните с тем, что было без единственного добавленного слова await:

вывод для вызова без AWAIT-а (из предыдущей статьи)
вывод для вызова без AWAIT-а (из предыдущей статьи)

Преобразование асинхронного вызова в синхронный

Вы можете видеть, что добавление ключевого слова await перед вызовом Асинхронной функции превратило асинхронный вызов в синхронный. В соответствии с определением синхронности/асинхронности, которое мы разобрали в начале, вывод, который производит функция SomeMethodAsync1() при а-синхронном вызове происходит уже после самого вызова и поэтому он смешивается с выводом, который формирует вызывающая функция после вызова метода SomeMethodAsync1(). Я думаю, не надо пояснять что порядок следования записей сформированных в двух этих функциях при асинхронном вызове никогда точно не определен, в этом смысле асинхронность порождает хаос (хотя при ближайшем рассмотрении мы бы увидели, что это не совсем хаос, это все-таки порядок, с элементами хаоса). Просто первое, что пришло мне в голову при сравнении этих двух выводов, прокомментировать это сравнение броским: «Порядок против Хаоса», это конечно преувеличение, лишь с маленькой долей истины.

Когда мы добавили AWAIT перед вызываемой SomeMethodAsync1() функцией мы сделали вызов этой асинхронной функции снова синхронной, мы вернули порядок и в коде, и в выводе (хотя наверно только в выводе, в коде, по сути, ничего не изменилось), который он генерирует. Await блокирует выполнение вызывающей функции до окончания асинхронной функции, соответственно функция у нас осталась асинхронной, но вызов мы организовали для нее синхронный. Мне кажется вполне можно назвать такое преобразование асинхронного метода в синхронный инверсией асинхронности, романтично звучит :), а главное коротко.

 Кажется, что получилась какая-то бессмыслица, зачем писать асинхронную функцию, а потом прилагать какие-то дополнительные усилия чтобы вызвать ее в синхронном режиме?

На самом деле это очень востребованное преобразование на практике. Практически все примеры для async/await по сути демонстрируют необходимость такого преобразования. Только почему то никто не решается называть Синхронный вызов а-синхронной функции Синхронным, наверно именно потому, что на первый взгляд это звучиит как бессмыслица.

Но если разобрать что же тут произошло внимательно, как подобает профессиональному разработчику и аналитику мы увидим ожидаемую экономию потока.

Для начала посмотрим схему потоков, которая получается при вызове нашей асинхронной функции SomeMethodAsync1 без AWAIT-а:

схема генерации записей при честном асинхронном вызове
схема генерации записей при честном асинхронном вызове

Как же изменилась эта схема при добавлении ключевого слова AWAIT перед вызовом? Но это не единственный вопрос, на который надо ответить, чтобы понять, а в чем же заключается «экономия потоков». Поэтому я нарисовал сразу две схемы того, как можно превратить асинхронный вызов обратно в синхронный. Первый вариант, который я назвал гипотетическим, на самом деле вполне практический, поскольку я уверен он достаточно широко использовался и наверняка где-то продолжает работать, а второй вариант – тот который нам предлагает применение async/await:

два варианта преобразования асинхронного вызова в синхронный
два варианта преобразования асинхронного вызова в синхронный

Я думаю вы догадались что PAsync на второй картинке означает Преобразованный Асинхронный вызов, потому что его явно нельзя в этом случае назвать, например, псевдо-асинхронным вызовом, он очевидно является синхронным.

Надеюсь из сравнения этих двух картинок становится совершенно понятно в чем заключается пресловутое "экономное использование потоков", и просто более эффективное построение работы с методами которые изначально реализованы как а-синхронные. AWAIT позволяет нам использовать активный поток в котором происходит вызов асинхронной функции чтобы выполнить эту функцию когда мы хотим просто дождаться чтобы эта асинхронная функция выполнила свою работу до конца. Как видите добавленный AWAIT (который нельзя добавить без ASYNC) позволил нам исключить-сэкономить дополнительный поток для асинхронной операции, когда мы решили вызвать ее синхронно.

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

С уважением,

Сёргий

PS:

Сколько противоречий и недосказанности содержится в таком определении отсюда:

Асинхронность позволяет вынести отдельные задачи из основного потока в специальные асинхронные методы и при этом более экономно использовать потоки.

?

Попробуем перечислить:

1.     Разве основной поток не определяет метод, который выполняется в этом потоке?

2.     В чем разница между методами основного потока и специальными асинхронными методами?

3.     Специальные асинхронные методы будут выполняться на каком потоке?

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

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

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

Все говорят, что мы вместе
Все говорят, но немногие знают в каком,
А из наших труб идёт необычный дым
Стой, опасная зона, работа мозга

Бошетунмай

Комментарии (23)


  1. slepmog
    25.03.2024 01:59
    +19

    Нет, async придумали не для того, чтобы "экономить потоки", а для того, чтобы их не блокировать.

    Нет, добавление await не превращает вызов в "синхронный". Поэтому его так никто и не называет. Если вы хотите увидеть синхронный вызов асинхронной функции, смотрите тут.

    Нет, добавление await и его эффект - не таинственное знание и не "Преобразованный Асинхронный вызов", а наиболее обыденный (если угодно, "непреобразованный") способ использования асинхронных функций.

    Нет, добавление await не приводит к выполнению всего кода на одном потоке. Распределением Task по потокам занимается контекст синхронизации. Самый простой способ увидеть это в вашем неправильном, подогнанном под ответ примере - добавить строчку Console.WriteLine($"TrID={Thread.CurrentThread.ManagedThreadId}"); в SomeMethodAsync1, прямо перед await Task.Yield();, что даст вам:

    TrID=1
    TrID=4
    MethodIter=0
    MethodIter=1
    MethodIter=2
    ...
    


    1. rukhi7 Автор
      25.03.2024 01:59

      Да, я тоже заметил что пропал первый поток (ID=1), мне как раз было интересно услышать комментарий по этому поводу, вам в любом случае плюс за профессиональную внимательность.

      Но исходя из определения асинхронности которое я привел, наличие дополнительного потока не определяет является вызов метода асинхронным или синхронным:

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

      Потом вы явно что то здесь:

      Нет, async придумали не для того, чтобы "экономить потоки", а для того, чтобы их не блокировать.

      не договариваете, как минимум! Вообще-то ASYNC придумали чтобы можно было писать AWAIT. Зачем нужен AWAIT, вы почему-то скромно умолчали. А у меня речь идет именно про AWAIT. Поэтому мне кажется вы в какой-то степени исказили содержание статьи таким образом и пишите притензии к этому искаженному содержанию. Надеюсь не намеренно.


      1. slepmog
        25.03.2024 01:59
        +4

        я тоже заметил что пропал первый поток (ID=1), мне как раз было интересно услышать комментарий по этому поводу

        Как вы, конечно, знаете, вызов любой асинхронной функции начинается синхронно (а значит, совершенно точно на том же потоке, что делает вызов). Выполнение становится асинхронным, когда в цепочке вызовов встречается первый незавершённый await.

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

        Когда у вас не было await перед SomeMethodAsync1, всё работало точно так же относительно первого незавершённого await, смена потока, выполняющего продолжение, происходила на том же await Task.Yield. Разница только в том, что без await ваши Task Main и Task SomeMethodAsync1 выполняются одновременно, а не последовательно, что вам чаще всего не надо (Fire and Forget по типу 1, плюс с неожиданным параллелизмом в силу характера работы консольного контекста синхронизации).

        Вообще-то ASYNC придумали чтобы можно было писать AWAIT. Зачем нужен AWAIT, вы почему-то скромно умолчали. А у меня речь идет именно про AWAIT

        Под словами "async придумали" я имел в виду "придумали механизм асинхронных вызовов в .NET", а не "придумали ключевое слово async в C#".

        Ключевые слова async и await в C# (Async и Await в VB) - это часть одного синтаксического пакета, и говорить о том, что их придумали как самоцель, некорректно. Их придумали, чтобы обеспечить удобное использование механизма асинхронных вызовов из языка. Указывать на различия между ними - примерно как указывать на различия между операторами + и =.

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

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

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


        1. rukhi7 Автор
          25.03.2024 01:59

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

          Так из того что я в этой и прошлой статьях нарисовал (а вы проверили) действительно видно что поток не блокируется в обоих случаях и когда мои

           Task Main и Task SomeMethodAsync1 выполняются одновременно

          и когда они выполняются последовательно, кто же с этим спорит-то?

          Вопрос то в том, за счет чего и каким образом исключается блокировка. И вроде как из картинок очевидно, что есть более эффективный способ избежать бокировки с asyn/await, и менее эффективный гипотетический или, можно сказать старомодный.

          Получается что вы статью не прочитали или не внимательно прочитали и отвечаете на какие-то вами придуманные заблуждения.


          1. Gromilo
            25.03.2024 01:59
            +3

            Вопрос то в том, за счет чего и каким образом исключается блокировка. 

            За счёт того, что таск - это объект. В какой-то момент управление возвращается потоку туда, где создавался таск, а таск остаётся жить сам по себе, пока какой-то поток не продолжит его выполнение. Цепочка из авейтов как раз таки позволяет сделать return из функции на любом уровне вложенности и позволить треду заниматься своими делами.

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

            Всё в точном соответствии с работой примеров.

            З.Ы. На самом деле я так не понял в чём именно вопрос, а статьи мне не помогли. Типа где-то кто-то не правильно написал как таски работают?


          1. slepmog
            25.03.2024 01:59
            +1

            Tак из того что я в этой и прошлой статьях нарисовал (а вы проверили) действительно видно что поток не блокируется в обоих случаях и когда мои

            Task Main и Task SomeMethodAsync1 выполняются одновременно

            и когда они выполняются последовательно, кто же с этим спорит-то?

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

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


            1. rukhi7 Автор
              25.03.2024 01:59

              Вы не то чтобы с этим спорите, вы (и в прошлой статье, и в этой) сначала говорите "проверим, блокируется ли поток"

              Я специально поиск запустил по предыдущей статье, там нет таких слов: "проверим, блокируется ли поток", там даже просто слов "блокирует" "блок" нет.

              Там ответ на вопрос который мне задали:

              Может ли магия async/await сама создавать потоки или это все делается явно тем, кто асинхронные функции пишет?

              Вы нервный срыв мне хотите устроить?

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


  1. Gromilo
    25.03.2024 01:59
    +4

    Сложна!
    В смысле сложно написано. Я знаю как работает асинхронщина, но не так и не понял, что хотел сказать автор. Сорвать какие-то покровы?

    Вот пример, когда всё вообще синхронно выполняется. Достаточно выкинуть Yield

    Всё держится на том, что код всегда выполняется синхронно и есть некие "истинно асинхронные операции", на которых выполнение асинк кода прекращается, а поток может заняться как-то другой работой: уйти в пул или выполнять мейн дальше. Кооперативная многозадачность, типа. Такие операции и руками можно написать, если надо, асинк нужен ради сахара. Когда "истинно синхронные операция" завершится, дальнейшее выполнение подхватит какой-то поток.

    Прикол в том, что для ожидания завершения "истинно асинхронной операции" не нужен никакой поток, вот вся экономия.


    1. alexalok
      25.03.2024 01:59

      А как же Task.Run? ;)


      1. Gromilo
        25.03.2024 01:59

        А с ним что не так? Мы его не авейтим.


    1. rukhi7 Автор
      25.03.2024 01:59

      Вот пример, когда всё вообще синхронно выполняется. Достаточно выкинуть Yield

      Любая функция выполняется синхронно, чтобы сотворить пример асинхронной функции достаточно подписать функцию async и добавить в начале await Task.Yield()

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


      1. Gromilo
        25.03.2024 01:59

        Вот до этого места со всем согласен:

        Надо хорошо понимать параметры этого когда-то и желательно уметь этими параметрами управлять. Для того чтобы разобрать эти параметры и как они управляются в зависимости от контекста достаточно даже функции со слипами (sleep - ами) для примера.

        А каких параметрах идёт речь?


        1. rukhi7 Автор
          25.03.2024 01:59

          я же написал:

          То есть тело функции будет выполняться когда-то после завершения вызова такой функции.


          1. Gromilo
            25.03.2024 01:59

            И чего?


            1. rukhi7 Автор
              25.03.2024 01:59

              А каких параметрах идёт речь?

              то есть вы все таки хотите поподробнее, хорошо! Это хороший вопрос чтобы ответить на него в следующей статье.


  1. Pardych
    25.03.2024 01:59
    +3

    еще бы неплохо научиться использовать Task.Delay вместо Thread.Sleep в асинхронном контексте выполнения

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

    это собственно и было значением фразы "тут нет тредов"

    про них тут неизвестно, они вынесены за рамки этого кода, так что не надо их трогать

    блокирующие тред операции должны быть заботливо упакованы в асинк-методы, которые не заблочат тред их вызывающий (скажем есть foo которая слипит тред на секунду - она же его в самом примитивном виде и должна породить, вернуть управление и по завершению работы в треде - вернуть результат в таск), потому что тот в свою очередь может быть например тредом ui или еще каким синглтредовым контекстом обвязан и там все от блокирующей операции колом встанет

    я второй раз прошу автора заняться изучением теории асинхронщины

    посмотреть статьи про сами реализацию асинк-эвейт например, а не шлепать непонятные примеры чтобы доказать что асинхронный код является причиной продуцирования тредов

    он является, но это никак не противоречит фразе there is no thread потому что в реализации асинк-эвейтов, корутин, фьючей и прочей асинхронной дребедени нет продуцирования тредов, этим занимаются тредпулы в обвязке - контекст выполнения собственно стейт-машин асинхронщиной занимающихся, в котлин-корутинах, например их еще и наглядно можно задавать, а сама асинхронщина ближе всего к понятию "плоские коллбеки", которые и есть этот ваш тру-асинк


  1. rukhi7 Автор
    25.03.2024 01:59

    еще бы неплохо научиться использовать Task.Delay вместо Thread.Sleep в асинхронном контексте выполнения

    это хорошее замечание, но я собираюсь эту тему разобрать в визуальном контексте, там будет очень хорошо видно на что оно влияет, мне кажется. Нельзя же все засунуть в одну статью.

    а не шлепать непонятные примеры

    мне кажется непонятные примеры интереснее всего разбирать.


  1. cstrike
    25.03.2024 01:59
    +3

    Хватит. То что вы перевели статью от Тауба про async/await не делает вас экспертом в этой теме. Хватить писать эту псевдонаучную ересь.


    1. ryanl
      25.03.2024 01:59
      +1

      Ахах, черт возьми, у статьи еще и тег "туториал". )


    1. rukhi7 Автор
      25.03.2024 01:59

      неужели даже такие физиономии получают приглашение от @habrahabr ?


      1. cstrike
        25.03.2024 01:59

        На личность перешли? А еще ниже можете?


        1. rukhi7 Автор
          25.03.2024 01:59

          На личность перешли? А еще ниже можете?

          Так это был старт соревнования в низости! Не знал!

          Тогда вы сразу победили с этой личностью, которую вы тут вывалили.


          1. cstrike
            25.03.2024 01:59

            Какое соревнование? Вы о чем вообще? Этот коментарий такой же бессмысленный как и предыдущий. Как и ваши последние статьи, к слову.