Русский
Русский
English
Статистика
Реклама

Асинхронность

Из песочницы Асинхронность в C и F. Подводные камни асинхронности в C

18.07.2020 16:04:37 | Автор: admin
Привет, Хабр! Представляю вашему вниманию перевод статьи Async in C# and F# Asynchronous gotchas in C# автора Tomas Petricek.

Еще в феврале я присутствовал на ежегодном саммите MVP мероприятии, организованном Microsoft для MVP. Я воспользовался этой возможностью, чтобы посетить также Бостон и Нью-Йорк, сделать два выступления про F# и записать лекцию Channel 9 о провайдерах типов. Несмотря на другие мероприятия (такие как посещения пабов, общение с другими людьми про F# и долгий сон по утрам), мне также удалось провести несколько обсуждений.

image

Одним обсуждением (из тех, что не под NDA) была беседа Async Clinic о новых ключевых словах в C# 5.0 async и await. Люциан (Lucian) и Стивен (Stephen) говорили о распространенных проблемах, с которыми сталкиваются разработчики C# при написании асинхронных программ. В этом посте я рассмотрю некоторые проблемы с точки зрения F#. Разговор был довольно оживленным, и кто-то описал реакцию аудитории F # следующим образом:

image
(Когда MVP, пишущие на F#, видят примеры кода C#, они хихикают, как девочки)

Почему так происходит? Оказывается, что многие из распространенных ошибок невозможны (или гораздо менее вероятны) при использовании асинхронной модели F# (которая появилась в версии F# 1.9.2.7, выпущенной в 2007 году и поставлявшейся с Visual Studio 2008).

Подводный камень #1: Async не работает асинхронно


Давайте сразу перейдем к первому сложному аспекту модели асинхронного программирования на C #. Посмотрите на следующий пример и попытайтесь представить, в каком порядке будут напечатаны строки (я не смог найти точный код, показанный на выступлении, но я помню, как Люциан демонстрировал нечто подобное):

  async Task WorkThenWait()  {      Thread.Sleep(1000);      Console.WriteLine("work");      await Task.Delay(1000);  }   void Demo()   {      var child = WorkThenWait();      Console.WriteLine("started");      child.Wait();      Console.WriteLine("completed");  }

Если вы думаете, что будет напечатано started, work и completed, вы ошибаетесь. Код печатает work, started и completed, попробуйте сами! Автор хотел начать работу (вызвав WorkThenWait), а затем дождаться выполнения задачи. Проблема в том, что WorkThenWait начинается с выполнения каких-либо тяжелых вычислений (здесь Thread.Sleep), и только после этого использует await.

В C# первая часть кода в async-методе выполняется синхронно (в потоке вызывающей стороны). Вы можете исправить это, например, добавив в начале await Task.Yield().

Соответствующий код F#


В F# это не проблема. При написании асинхронного кода на F# весь код внутри блока async { }отложен и запускается позже (когда вы явно запускаете его). Приведенный выше код C# соответствует следующему в F#:

let workThenWait() =     Thread.Sleep(1000)    printfn "work done"    async { do! Async.Sleep(1000) } let demo() =     let work = workThenWait() |> Async.StartAsTask    printfn "started"    work.Wait()    printfn "completed"  

Очевидно, что функция workThenWait не выполняет работу ( Thread.Sleep) как часть асинхронных вычислений, и что она будет выполняться при вызове функции (а не при запуске асинхронного рабочего процесса). Обычным шаблоном в F# является обёртывание всего тела функции в async. В F# вы бы написали следующее, что и работает, как ожидалось:

let workThenWait() = async{     Thread.Sleep(1000)    printfn "work done"    do! Async.Sleep(1000) }  

Подводный камень #2: Игнорирование результатов


Вот еще одна проблема в модели асинхронного программирования на C# (эта статья взята непосредственно из слайдов Люциана). Угадайте, что произойдёт, когда вы запустите следующий асинхронный метод:

async Task Handler() {   Console.WriteLine("Before");   Task.Delay(1000);   Console.WriteLine("After");} 

Вы ожидаете, что он напечатает Before, подождёт 1 секунду, а затем напечатает After? Неправильно! Будут напечатаны оба сообщения сразу, без промежуточной задержки. Проблема состоит в том, что Task.Delay возвращает Task, а мы забыли подождать, пока она не завершится (используя await).

Соответствующий код F#


Опять-таки, вероятно, вы не столкнулись бы с этим в F#. Вы вполне можете написать код, который вызывает Async.Sleep и игнорирует возвращаемый Async:

let handler() = async{    printfn "Before"    Async.Sleep(1000)    printfn "After" } 

Если вы вставите этот код в Visual Studio, MonoDevelop или Try F #, вы тут же получите предупреждение:

warning FS0020: This expression should have type unit, but has type Asyncunit. Use ignore to discard the result of the expression, or let to bind the result to a name.

предупреждение FS0020: Это выражение должно иметь тип unit, но имеет тип Asyncunit. Используйте, ignore, чтобы отбросить результат выражения или let, чтобы связать результат с именем.


Вы по-прежнему можете скомпилировать код и запустить его, но, если вы прочитаете предупреждение, то увидите, что выражение возвращает Async и вам нужно дождаться его результата, используя do!:

let handler() = async {   printfn "Before"   do! Async.Sleep(1000)   printfn "After" } 

Подводный камень #3: Асинхронные методы, которые возвращают void


Довольно много времени в разговоре было посвящено асинхронным void-методам. Если вы пишете async void Foo() { }, то компилятор C# генерирует метод, который возвращает void. Но под капотом он создает и запускает задачу. Это означает, что вы не можете предугадать, когда работа действительно будет выполнена.

В выступлении прозвучала такая рекомендация по использованию шаблона async void:

image
(Ради всего святого, прекратите использовать async void!)

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

Позвольте мне продемонстрировать проблему с помощью фрагмента из статьи MSDN Magazine об асинхронном программировании на C#:

async void ThrowExceptionAsync() {    throw new InvalidOperationException();}public void CallThrowExceptionAsync() {    try     {        ThrowExceptionAsync();    }     catch (Exception)     {        Console.WriteLine("Failed");    }} 

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

Соответствующий код F#


Так что, если вам не нужно использовать функции языка программирования, то, вероятно, лучше не включать эту функцию в первую очередь. F# не позволяет вам писать функции async void если вы переносите тело функции в блок async { }, тип возвращаемого значения будет Async. Если вы используете аннотации типов и требуете unit, вы получите несоответствие типов (type mismatch).

Вы можете написать код, который соответствует вышеупомянутому коду C#, используя Async.Start:

let throwExceptionAsync() = async {    raise <| new InvalidOperationException()  }let callThrowExceptionAsync() =   try     throwExceptionAsync()     |> Async.Start   with e ->     printfn "Failed"

Здесь исключение также не будет обработано. Но происходящее более очевидно, потому что мы должны написать Async.Start явно. Если мы этого не сделаем, мы получим предупреждение о том, что функция возвращает Async и мы игнорируем результат (так же, как в предыдущем разделе Игнорирование результатов).

Подводный камень #4: Асинхронные лямбда-функции, которые возвращают void


Ситуация ещё более усложняется, когда вы передаете асинхронную лямбда-функцию какому-либо методу в качестве делегата. В этом случае компилятор C # выводит тип метода из типа делегата. Если вы используете делегат Action (или аналогичный), то компилятор создает асинхронную void-функцию, которая запускает работу и возвращает void. Если вы используете делегат Func, компилятор генерирует функцию, которая возвращает Task.

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

Parallel.For(0, 10, async i => {    await Task.Delay(1000);});

Вы не сможете ответить на этот вопрос, если вы не знаете, что для For есть только такие перегрузки, которые принимают делегаты Action и, таким образом, лямбда-функция всегда будет компилироваться как async void. Это также означает, что добавление какой-то (возможно, полезной) нагрузки будет ломающим изменением (breaking change).

Соответствующий код F#


Язык F# не имеет специальных асинхронных лямбда-функций, но вы вполне можете написать лямбда-функцию, которая возвращает асинхронные вычисления. Такая функция будет возвращать тип Async, поэтому она не может быть передана в качестве аргумента методам, которые ожидают возвращающий void делегат. Следующий код не компилируется:

Parallel.For(0, 10, fun i -> async {  do! Async.Sleep(1000) })

Сообщение об ошибке просто говорит о том, что тип функции int -> Asyncне совместим с делегатом Action(в F# должно быть int -> unit):

error FS0041: No overloads match for method For. The available overloads are shown below (or in the Error List window).

ошибка FS0041: не найдены перегрузки для метода For. Доступные перегрузки показаны ниже (или в окне списка ошибок).


Чтобы получить то же поведение, что и в приведенном выше коде C#, мы должны явно начать работу. Если вы хотите запустить асинхронную последовательность в фоновом режиме, это легко делается с помощью Async.Start (который принимает асинхронное вычисление, возвращающее unit, планирует его и возвращает unit):

Parallel.For(0, 10, fun i -> Async.Start(async {  do! Async.Sleep(1000) }))

Вы, конечно, можете написать это, но увидеть, что происходит, довольно легко. Также нетрудно заметить, что мы тратим ресурсы впустую, так как особенность Parallel.For в том, что он выполняет вычисления с интенсивным использованием процессора (которые обычно являются синхронными функциями) параллельно.

Подводный камень #5: Вложенность задач


Я думаю, что Лукиан включил этот камень просто чтобы проверить умственные способности людей в аудитории, но вот он. Вопрос в том, подождёт ли следующий код 1 секунду между двумя выводами на консоль?

Console.WriteLine("Before");await Task.Factory.StartNew(    async () => { await Task.Delay(1000); });Console.WriteLine("After");

Совершенно неожиданно, но между этими выводами нет задержки. Как это возможно? Метод StartNew принимает делегат и возвращает Task где T тип, возвращаемый делегатом. В нашем случае делегат возвращает Task, поэтому в результате мы получаем Task. await ожидает только завершения внешней задачи (которая немедленно возвращает внутреннюю задачу), при этом внутренняя задача игнорируется.

В C# это можно исправить, используя Task.Run вместо StartNew (или удалив async/await в лямбда-функции).

Можно ли написать что-то подобное в F #? Мы можем создать задачу, которая будет возвращать Async, используя функцию Task.Factory.StartNew и лямбда-функцию, которая возвращает асинхронный блок. Чтобы дождаться выполнения задачи, нам нужно будет преобразовать ее в асинхронное выполнение, используя Async.AwaitTask. Это означает, что мы получим Async<Async>:

async {  do! Task.Factory.StartNew(fun () -> async {     do! Async.Sleep(1000) }) |> Async.AwaitTask }

Опять-таки, этот код не компилируется. Проблема в том, что ключевое слово do! требует с правой стороны Async, но в действительности получает Async<Async>. Другими словами, мы не можем просто игнорировать результат. Нам нужно что-то с этим сделать явно
(для воспроизведения поведения C# можно использовать Async.Ignore). Сообщение об ошибке, возможно, не такое понятное, как предыдущие, но даёт общее представление:

error FS0001: This expression was expected to have type Asyncunit but here has type unit

ошибка FS0001: Ожидается выражение типа Asyncunit, присутствует тип unit

Подводный камень #6: Асинхронность не работает


Вот еще один проблемный фрагмент кода со слайда Люциана. На этот раз проблема довольно проста. Следующий фрагмент определяет асинхронный метод FooAsync и вызывает его из Handler, но код не выполняется асинхронно:

async Task FooAsync() {    await Task.Delay(1000);}void Handler() {    FooAsync().Wait();}

Определить проблему несложно мы вызываем FooAsync().Wait(). Это означает, что мы создаем задачу, а затем, используя Wait, блокируем программу до её завершения. Проблему решает простое удаление Wait, потому что мы просто хотим запустить задачу.

Этот же код можно написать на F#, но асинхронные рабочие процессы не используют задачи .NET (изначально предназначенные для вычислений с привязкой к ЦП), а вместо этого используют тип F# Async, который не укомплектован Wait. Это означает, что вы должны написать:

let fooAsync() = async {    do! Async.Sleep(1000) }let handler() =     fooAsync() |> Async.RunSynchronously

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

Резюме


В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в C# ведёт себя неожиданным образом. Большинство из них основавыются на беседе Люциана и Стивена на саммите MVP, поэтому спасибо им обоим за интересный список распространённых ловушек!

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

Наконец, эту статью не следует понимать как разрушительную критику асинхронности в C# :-). Я полностью понимаю, почему дизайн C# следует тем принципам, которым он следует для C# имеет смысл использовать Task (вместо отдельных Async), что влечёт за собой ряд последствий. И я могу понять причины других решений это, вероятно, лучший способ интеграции асинхронного программирования в C#. Но в то же время я думаю, что F# справляется лучше отчасти из-за способности к компоновке, но, что более важно, из-за крутых дополнений, таких как агенты F#. Кроме того, у асинхронности в F# тоже есть свои проблемы (самая распространенная ошибка хвостовые рекурсивные функции должны использоваться return! вместо do!, чтобы избегать утечек), но это тема отдельной статьи для блога.

P.S. От переводчика. Статья написана в 2013 году, но она показалась мне достаточно интересной и актуальной, чтобы перевести её на русский. Это мой первый пост на Хабре, поэтому не пинайте сильно.
Подробнее..

Работа с асинхронностью в Dart

27.01.2021 14:17:26 | Автор: admin

Всем привет! Меня зовут Дмитрий Репин, я Flutter-разработчик в Surf.

В этой статье я расскажу, как работать с асинхронностью в Dart: всё о самых важных классах библиотеки dart:async с примерами под катом. Поговорим о том, как в однопоточном языке сходить в сеть или базу данных и при этом не затормозить приложение.

Эта статья написана по материалам моего ролика на YouTube. Посмотрите видео, если больше любите слушать, чем читать.

Dart однопоточный язык программирования, который выполняется в одном процессе. Что это значит по факту: если мы будем в одном потоке выполнять любую операцию, требующую времени, то приложение просто подвиснет. И всё время, пока мы, например, ждём ответа от сервера или выполнения запроса в БД, пользователь будет страдать и смотреть на лагающий интерфейс.

К счастью, Dart хитрый однопоточный язык, который предоставляет механизм Event Loop он даёт возможность откладывать какие-то операции на потом, когда поток будет посвободнее.Такие отложенные операции мы будем называть асинхронными.

Все операции в Dart можно разделить на два типа:

  1. Синхронные те, что блокируют другие операции до своего выполнения.

  2. Асинхронные, которые позволяют другим операциям выполняться, пока текущая не закончится.

Вместе с операциями, функции тоже можно поделить на синхронные и асинхронные. И синхронные, и асинхронные функции могут содержать синхронные операции. Асинхронной функция начнёт считаться в тот момент, когда в ней появляется хотя бы одна асинхронная операция.

Создание асинхронной функции с помощью класса Future

Чтобы было нагляднее, давайте напишем простую асинхронную функцию. Пусть она будет принимать строку и смотреть на её длину: если она больше 10 символов, функция вернёт число 42, в ином случае ошибку. Представим, что вычисление длины строки достаточно тяжеловесная операция чтобы сделать её асинхронной.

Чтобы сделать код асинхронным, нам понадобится класс Future. В конструктор он принимает функцию, которую необходимо выполнить асинхронно, а также предоставляет два обработчика: then и catchError. Первый обрабатывает успешное выполнение функции, а второй выполнение функции с ошибкой. В итоге у нас должен получиться такой код:

runSimpleFutureExample({    String question = 'В чем смысл жизни и всего такого?',  }) {    print('Start of future example');    Future(() {      print('Вопрос: $question');      if (question.length > 10) {         return 42;      } else {        throw Exception('Вы задали недостаточно сложный вопрос.');      }    }).then((result) {      print('Ответ: $result');    }).catchError((error) {      print('Ошибка. $error');    });    print('Finish of future example');  }

В начале и конце метода выводятся строки о том, что он начал и закончил работу, а между ними создаётся сам future. В конструктор он принимает метод, который вычисляет длину строки, а затем к Future добавляются обработчики then и catchError, которые обрабатывают выполнение Future. Попробуем запустить этот пример и посмотрим, в каком порядке выводится на консоль результат:

Start of future example

Finish of future example

Вопрос: В чем смысл жизни и всего такого?

Ответ: 42

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

Работать с Future через обработчики then и catchError не самая хорошая идея, особенно если нужно передать результат одного Future во второй, а второго в третий и т. д., порождая callback hell.

Для таких случаев Dart предоставляет ключевые слова async и await. Словом async помечается функция, исполняющая асинхронные операции через await. Словом await помечаются сами асинхронные операции. К сожалению, при таком подходе мы теряем обработчик catchError, однако никто не мешает нам обрабатывать ошибки через стандартный try/catch. Чтобы понять, как это работает, перепишем наш предыдущий пример с использованием async/await и для удобства вынесем Future в отдельный метод:

 runFutureWithAwaitExample({    String question = 'В чем смысл жизни и всего такого?',  }) async {    print('Start of future example');    try {      final result = await _getAnswerForQuestion(        'В чем смысл жизни и всего такого?',      );      print('Ответ: $result');    } catch (error) {      print('Ошибка. $error');    }    print('Finish of future example');  }   Future<int> _getAnswerForQuestion(String question) => Future(() {        print('Вопрос: $question');        if (question.length > 10) {          return 42;        } else {          throw Exception('Вы задали недостаточно сложный вопрос.');        }      });

Здесь мы объявили метод, возвращающий Future. Вызываем его с использованием слова await, передавая в переменную result, с которой мы можем работать дальше, как будто в синхронном коде. Выведем на консоль результат работы метода:

Start of future example

Вопрос: В чем смысл жизни и всего такого?

Ответ: 42

Finish of future example

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

Обработка последовательности асинхронных событий с помощью Stream

Помимо Future, есть ещё один важный класс для работы с асинхронностью Stream. В отличие от Future, Stream может обрабатывать целый набор асинхронных событий. Давайте сразу разберём на примере:

void runStreamSimpleExample() {    print('Simple stream example started');    final stream = Stream.fromIterable([1, 2, 3, 4, 5]);    stream.listen((number) {      print('listener: $number');    });    print('Simple stream example finished');  }

У стрима много разных конструкторов, посмотрите их в документации. Здесь используем fromIterable. Создаём в массиве пять чисел, подписываемся на стрим через метод listen и выводим на консоль:

Simple stream example started

Simple stream example finished

listener: 1

listener: 2

listener: 3

listener: 4

listener: 5

Как и в случае с Future, сначала выполняется синхронный код: сообщения о старте и завершения примера. Только потом обрабатывается стрим и выводятся на консоль числа. В этом примере подписка на стрим обрабатывается асинхронно через listen, однако есть способ обрабатывать стрим через знакомые нам ключевые слова async и await. Перепишем пример с их использованием:

void runStreamAwaitedSimpleExample() async {    print('Simple stream example with await started');    final stream = Stream.fromIterable([1, 2, 3, 4, 5]);    await for (final number in stream) {      print('Number: $number');    }    print('Simple stream example with await finished');  }

Теперь обработка стрима похожа на обычный цикл. Но не стоит обрабатывать слишком большие стримы таким образом: это может затормозить работу программы. Выведем результат на консоль:

Simple stream example started

listener: 1

listener: 2

listener: 3

listener: 4

listener: 5

Simple stream example finished

Тут всё так же, как и с async/await во future. Сама функция стала асинхронной, поэтому теперь обработка Stream происходит до завершения метода.

Single-subscription и broadcast стримы. Основы StreamController

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

print('Simple stream example started');    final stream = Stream.fromIterable([1, 2, 3, 4, 5]);    stream.listen((number) {      print('listener 1: $number');    });    stream.listen((number) {      print('listener 2: $number');    });    print('Simple stream example finished');

И результат:

The following StateError was thrown while handling a gesture.

Bad state: Stream has already been listened to.

Мы получаем ошибку: на стрим уже произведена подписка и подписаться второй раз не выйдет.

Здесь стоит рассказать о том, что существует два типа стримов: single-subscription и broadcast стримы. Мы создали single-subscription стрим. Такие стримы поставляют все данные подписчику разом и только после самой подписки. Так и происходит в нашем примере.

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

Чтобы увидеть разницу, создадим broadcast стрим. В этом примере для создания стрима мы не будем использовать непосредственно сам Stream. Мы будем работать с классом StreamController. Он предоставляет доступ к самому стриму, даёт возможность управлять им и добавлять в него события.

///пример broadcast стрима  void runBroadcastStreamExample() {    print('Broadcast stream example started');    final streamController = StreamController.broadcast();    streamController.stream.listen((number) {      print('Listener 1: $number');    });    streamController.stream.listen((number) {      print('Listener 2: $number');    });    streamController.sink.add(1);    streamController.sink.add(2);    streamController.sink.add(3);    streamController.sink.add(4);    streamController.sink.add(5);    streamController.close();    print('Broadcast stream example finished');  }

Мы создали StreamController с broadcast стримом, дважды подписались на него и добавили через sink пять чисел, после чего закрыли стрим. Смотрим, что ушло на консоль:

Broadcast stream example started

Broadcast stream example finished

Listener 1: 1

Listener 2: 1

Listener 1: 2

Listener 2: 2

Listener 1: 3

Listener 2: 3

Listener 1: 4

Listener 2: 4

Listener 1: 5

Listener 2: 5

Проблем не возникло: два подписчика отработали, как и ожидалось. На самом деле у StreamController достаточно много функций, рекомендую почитать документацию к нему.

Отписка от стрима с помощью StreamSubscription

Помимо управления самим стримом часто возникает потребность управлять подпиской на него для этого в dart:async добавлен StreamSubscription. Кстати, мы уже неявно работали с ним в наших предыдущих примерах, когда подписывались на стрим: на самом деле метод listen возвращает его, и таким образом мы можем выделить подписку в переменную. Модифицируем предыдущий пример так, чтобы вторая подписка отменялась после числа 3:

///пример broadcast стрима  void runBroadcastStreamExample() {    print('Broadcast stream example started');    final streamController = StreamController.broadcast();    streamController.stream.listen((number) {      print('Listener 1: $number');    });    StreamSubscription sub2;    sub2 = streamController.stream.listen((number) {      print('Listener 2: $number');      if (number == 3) {        sub2.cancel();      }    });    streamController.sink.add(1);    streamController.sink.add(2);    streamController.sink.add(3);    streamController.sink.add(4);    streamController.sink.add(5);    streamController.close();    print('Broadcast stream example finished');  }

Тут без сюрпризов, на консоль выводится то, что мы ожидали:

Broadcast stream example started

Broadcast stream example finished

Listener 1: 1

Listener 2: 1

Listener 1: 2

Listener 2: 2

Listener 1: 3

Listener 2: 3

Listener 1: 4

Listener 1: 5

Стоит отметить, что StreamSubscription не только даёт возможность отменять подписку полностью, но и приостанавливать и возобновлять её, а также предоставляет колбэки на события подписки.

Как упростить работу с асинхронностью

Мы разобрали все самые важные классы для dart:async, но осталось ещё три, о которых я бы хотел рассказать. Возможно, они используются не так часто, но могут быть очень полезны в определённых ситуациях.

Completer

Completer позволяет поставлять Future, отправлять событие о выполнении или событие об ошибке. Это может быть полезно, когда нужно сделать цепочку Future и вернуть результат.

Разберём на примере. Пусть у нас будет completer, который складывает два числа с трёхсекундной задержкой. Это очень простой пример, но важно понимать, что тут можно сделать большую вложенность из Future и компактно уместить в один класс.

class CompleterTester {  void runCompleterInitTest() async {    print('Completer example started');    var sumCompleter = SumCompleter();    var sum = await sumCompleter.sum(20, 22);    print('Completer result: ' + sum.toString());    print('Completer example finished');  }} class SumCompleter {  Completer<int> completer = Completer();   Future<int> sum(int a, int b) {    _sumAsync(a, b);    return completer.future;  }   void _sumAsync(int a, int b) {    Future.delayed(Duration(seconds: 3), () {      return a + b;    }).then((value) {      completer.complete(value);    });  }}

Разберём, что тут произошло. Мы создали класс SumCompleter и создали в нём completer. Затем объявили метод sum, который вызывает приватный метод на сложение и возвращает Future этого completer. Затем этот приватный метод выполняет операцию и вызывает метод complete у комплитера. После этого пользователь метода получает результат.

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

StreamIterator

Представьте, что вам нужно управлять переходом к следующему айтему стрима и делать это именно тогда, когда вам нужно. Именно это и делает StreamIterator. Он предоставляет метод moveNext для перехода к следующему элементу стрима. moveNext вернет true, если элемент пришел, и false, если стрим был закрыт. Также StreamIterator предоставляет свойство current для получения текущего элемента. Ну и конечно, метод cancel для отмены подписки. Небольшой пример по работе класса:

void runStreamIteratorExample() async {    print('StreamIteratorExample started');    var stream = Stream.fromIterable([1, 2, 3]);    var iterator = StreamIterator(stream);    bool moveResult;    do {      moveResult = await iterator.moveNext();      print('number: ${iterator.current}');    } while (moveResult);    print('StreamIteratorExample finished');  }

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

StreamTransformer

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

void runStreamTransformerExample() async {    print('StreamTransformer example started');    StreamTransformer doubleTransformer =        new StreamTransformer.fromHandlers(handleData: (data, EventSink sink) {      sink.add(data * 2);    });     StreamController controller = StreamController();    controller.stream.transform(doubleTransformer).listen((data) {      print('data: $data');    });     controller.add(1);    controller.add(2);    controller.add(3);    print('StreamTransformer example finished');  }

В нашем случае он будет принимать на вход данные, которые нужно трансформировать, и sink, в который мы должны передать трансформированные данные. В теле метода просто удваиваем приходящее значение. Затем остаётся только трансформировать стрим с помощью метода transform. И вуаля: в методе listen значение удваивается. Обратите внимание: трансформер может и не отдать данные в синк, а может отдать два раза. То есть он работает не только как map или where, а наделён гораздо большей функциональностью.


Рекомендую поиграться с этими примерами они есть на нашем GitHub. Так вы точно поймете, как устроена асинхронность в Dart. Если что-то непонятно, задавайте вопросы я отвечу в комментариях.

Подробнее..

От монолита к микросервисам ускорили банковские релизы в 15 раз

24.07.2020 12:09:40 | Автор: admin
Бывает, что компания использует устаревшую монолитную IT-систему, с которой сложно быстро выпускать обновления и решать свои бизнес-задачи. Как правило, рано или поздно владелец продукта начинает проектировать новое, более гибкое архитектурное решение.

Недавно мы писали о том, как работают IT-архитекторы, а теперь расскажем подробности об одном из наших кейсов и покажем схему работы системы. В этом проекте мы помогли заменить коробочное банковское приложение на микросервисное ДБО, при этом наладив быстрый выпуск релизов в среднем 1 раз в неделю.



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

Проблемы коробочного монолита


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

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

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

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

  1. Вендор вносил желаемые изменения неприемлемо долго, а переписать мобильный интерфейс в конкретной коробке и вовсе было практически невозможно.
  2. Из-за сбоев в шине данных или коробке пользователи зачастую не могли войти в онлайн-банк.
  3. Работать с приложением пользователи могли только в том случае, если у них была установлена самая свежая версия.
  4. Для распределения нагрузки инстансы монолита были развернуты на нескольких серверах, каждый из которых обслуживал свою группу абонентов. При падении одного из серверов пропадал доступ у всех абонентов соответствующей группы.
  5. Вся экспертиза хранилась у производителя коробочного решения, а не в банке.
  6. Из-за того, что банк не мог оперативно внести изменения по просьбам пользователей, а функциональность была недостаточной по сравнению с конкурентами, появился риск оттока клиентов.

В результате банк принял решение постепенно отказаться от коробки и разработать собственное ДБО, с микросервисной архитектурой, чтобы ускорить разработку функций и обеспечить удобство и безопасность для пользователей.

С чего мы начинали


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

На старте наша команда Backend-разработчиков занималась реализацией отдельных базовых функций: например, денежными переводами. Однако, у нас был достаточно большой опыт работы с онлайн-банками, один из наших проектов к этому моменту вошел в отраслевой рейтинг Markswebb, поэтому мы предложили банку помощь в проектировании архитектуры и получили зеленый свет.

Архитектура

Вместе с владельцем продукта мы приняли решение использовать Spring Cloud, который предоставляет все необходимые функции для реализации микросервисной архитектуры: это и Service discovery Eureka, Api Gateway Zuul, Config server и многое другое. В качестве системы контейнеров для Docker-образов выбрали OpenShift, потому что инфраструктура банка была заточена под этот инструмент.

Также мы проанализировали, какие особенности старой коробки могут осложнять работу пользователей. Один из основных недостатков заключался в том, что система синхронно работала через шину данных, и каждое действие пользователей вызывало обращение к шине. Из-за больших нагрузок часто происходил отказ шины, при этом переставало работать все приложение. Кроме того, как и во многих старых банковских продуктах, накопилось легаси наследство в виде старого и тяжеловесного CORE АБС, переписывать которое было бы сложно и дорого.

Мы предложили ряд улучшений:

  • Версионирование

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

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

  • Асинхронность

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

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

  • Кэширование

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

Что изменилось в системе


Как было отмечено выше, в старом онлайн-банке backend был выполнен в монолитной архитектуре, приложение разворачивалось на отдельной машине. При увеличении нагрузки приходилось разворачивать новые сервера. При падении сервера у некоторых пользователей полностью не работало мобильное приложение.





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



Давайте рассмотрим пример получения данных по счетам пользователя в новой архитектуре. Запрос пользователя из мобильного приложения попадает через балансировщик на Gateway, который понимает, на какой из сервисов направить запрос. Далее попадаем на API сервис счетов. Сервис сперва проверяет, есть ли актуальные данные пользователя в Cache. При успешном исходе возвращает данные, в ином случае отправляет запрос в Middle сервис счетов.

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

Среди наиболее важных изменений можно отметить следующие:

  • Гибкость масштабирования

Для увеличения отказоустойчивости системы сервисы распределены по разным серверам. Это позволяет сохранять систему в работоспособном состоянии в случае падения одного из них. Для своевременного реагирования на нестандартные ситуации мы внедрили систему мониторинга, это помогло при необходимости вовремя масштабировать сервисы. Подключили DevOps для настройки с нуля CI/CD на серверах клиента, выстроили процессы развертывания и поддержки будущего приложения на нескольких серверах.

  • Ускорение релизов за счет версионирования

Ранее при обновлении мобильной версии одни пользователи уже применяли новую версию, а другие нет, но число последних было минимально. При разработке нового ДБО мы реализовали версионирование и возможность выпускать релизы без риска, что приложение перестанет работать у большой части клиентов. Сейчас при реализации отдельных функциональностей в новых версиях мы не ломаем старые версии, а значит, нет необходимости проводить регресс. Это помогло ускорить частоту релизов минимум в 15 раз теперь релизы выходят в среднем 1 раз в неделю. Команды Backend и Mobile могут одновременно и независимо работать над новыми функциональностями.

Подводя итоги


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

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

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

Спасибо за внимание!
Подробнее..

Перевод Мифы об асинхронном PHP он не по-настоящему асинхронный

20.04.2021 18:08:31 | Автор: admin

В последнее время появляется достаточно много обсуждений проблемы производительности в PHP. И даже несмотря на то, что у нас есть PHP8, JIT и куча других улучшений, многие по-прежнему продолжают жаловаться на то, что PHP "недостаточно производительный". Что PHP - это язык, подходящий только для модели запрос-ответ. Что PHP слишком медленный и его не нужно использовать для высоконагруженных систем. С одной стороны от части всё это правда. Если мы строим какую-то систему, для которой вопрос производительности критичен, то использовать классический блокирующий PHP явно не стОит. Большая часть функций и библиотек PHP созданы для работы в традиционном блокирующем окружении, что уже подразумевает собой не самую высокую производительность. Однако PHP может работать быстро, более того, он может работать очень быстро. Как? Обычно у нас может быть две причины, из-за чего будет проседать производительность: мы либо совершаем какие-то сложные вычисления, либо у нас есть блокирующй ввод-вывод. Первое к сожалению (или к счастью) мы не можем решить в PHP. Но блокирующий ввод-вывод для PHP совсем не проблема. В PHP-сообществе есть люди, которые пишут асинхронный код уже на протяжении несколько лет. Конечно одновременно с этим бОльшая часть сообщества по-прежнему считает асинхронный PHP - дикостью. Я часто слышал: "Ты наверно совсем отчаянный, если собираешься писать что-то асинхронное на PHP". По правде говоря, у нас у всех есть это предубеждение, что PHP не подходит для подобного рода задач. И в большинстве случаев это предубеждение основано на неверных представлениях о самой "асинхронности". Неверные предубеждения в свою очередь ведут к неправильным ожиданиям, что в свою очередь приводит к разочарованию и обвинениям в том, что PHP "не по-настоящему асинхронный".

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

Конкурентность и параллелизм

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

  • Отправляем первый запрос.

  • Ждём ответа.

  • Отправляем второй запрос.

  • Ждём ответа.

Здесь каждая операция блокирует поток выполнения. В большинстве случаев для PHP это нормально. Проблемы могут возникнуть только тогда, когда таких блокирующих вызовов много, а производительность для этой программы критична. Такая программа не будет использовать все доступные ей ресурсы, и бОльшую часть времени будет простаивать. Пока выполняются сетевые запросы, CPU ничем не занят. И наоборот, когда CPU вычисляет что-то сложное вся программа "замирает" и не отвечает на ввод.

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

Основная причина всех споров вокруг асинхронного PHP - это непонимание того, что значит конкурентность. Очень часто мы путаем асинхронное выполнение с параллельным. Я много раз слышал довод: "PHP не по-настоящему асинхронный потому что в нём нельзя выполнять несколько задач параллельно". Здесь важно понять, что "асинхронность" - это гораздо более широкое понятие, чем "параллелизм".

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

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

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

Хорошо, надеюсь что теперь разница между конкурентность и параллелизмом более-менее понятна. Но вернёмся к PHP, который является однопоточным языком программирования. Это означает, что в один момент времени может выполняться только одна строчка PHP кода. Является ли это ограничением, которое не позволит выполнять асинхронные задачи на PHP? Или для этого нам и не нужно иметь несколько потоков? Чтобы ответить на эти вопросы, нужно снов немного погрузиться в теорию и понять разницу между процессами и потоками.

Процессы и потоки

Программисты пишут исходный код, который впоследствии будет выполнен компьютером. Неважно на каком языке мы пишем свою программу: C, Lisp или PHP. В конце концов, наш код компилируется или интерпретируется в двоичный файл. Во время выполнения этого бинарного кода программе нужно получить от операционной системе некоторые ресурсы: адресное пространство в памяти, PID (идентификатор процесса) и другие. Может быть запущено несколько инстансов одной программы, каждый при этом будет отдельным процессом внутри операционной системы. Переключение между процессами требует некоторого времени на сохранение/загрузку состояния регистров CPU и памяти. Все процессы изолированы друг от друга. Каждый процесс считает себя единственным запущенным в операционной системе, и что больше никаких программ нет. Часто можно увидеть ситуацию, когда одна программа "зависает", но при этом из неё можно выйти, не оказывая влияния на другие запущенные программы.

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

Однопоточная конкурентность

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

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

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

  • CPU-bound операции, которые требуют тяжелых вычислений. Они требуют процессорного времени.

  • I/O-bound операции, которые зависят от сети/оборудования/взаимодействия с пользователем. Они требуют просто времени: нужно дождаться определенного события.

При выполнении CPU-bound задачи поток выполнения блокируется из-за того, что он слишком активно используется. Например, когда нам нужно сделать какие-то сложные вычисления или отрендерить 3д модель. Для таких операций лучше всего подходит многопоточность. На многопроцессорных системах несколько потоков на самом деле могут параллельно проводить какие-то вычисления. Таким образом достигается более высокая общая производительность.

С другой стороны при выполнении I/O-bound операции поток выполнения блокируется потому что ему приходится ждать данные из источника ввода/вывода (сеть, жесткий диск и др.). Когда операционная система видит, что сейчас для этого потока нет никаких данных, то он переводится в "спящий режим". В таком состоянии поток не выполняется, он просто ждёт. И в данной ситуации многопоточность нам ничем не поможет. Ну создадим мы много потоков, которые будут ждать выполнения некоторого события. От этого само событие быстрее не произойдет.

А теперь поговори и PHP. В большинстве случаев это язык для веб-приложений, где у нас очень много разного I/O: ходим в файловую систему, делаем сетевые запросы или обрабатываем команды в терминале. Исходя из этого однопоточный PHP не так уж и плох для реализации конкурентности, и что однопоточность в этом случае - не ограничение, а наоборот возможность.

Неблокирующий I/O

Сам по себе один поток выполнения конечно не делает PHP программу асинхронной. Более того, когда мы говорим про I/O в PHP, то сразу бросается в глаза, что PHP создавался с намерением всегда выполняться синхронно и быть блокирующим. Все нативные функции для работы с I/O в PHP блокируют поток выполнения.

  • Читаем файл с помощью fopen()? Приложение будет заблокировано.

  • Делаем запрос в базу с PDO? Приложение заблокировано.

  • Читаем что-то с file_get_contents()? Я думаю, что ответ вы уже знаете.

Но сам по себе блокирующий I/O не является чем-то плохим. Да и в PHP мы даже и не задумываемся особо над тем, как выполняется наше приложение: блокируется там поток выполнения или нет. Да и неблокирующий I/O в PHP - очень редкая штука. В модели request-response нам нужно, чтобы поток выполнения блокировался, потому что это единственный способ узнать, когда операция завершена и есть результат. Например, мы получили запрос, сходили в базу данных, как-то обработали результат, отрендерили HTML или собрали JSON, и вернули ответ. По сути здесь нечему выполняться асинхронно. На каждом этапе нам нужно дождаться и получить результаты предыдущего. Неблокирующий I/O больше всего нужен в серверном коде, когда нужно обрабатывать сразу тысячи клиентских запросов. Конечно PHP - это тоже серверный код, но перед ним всегда обычно есть Nginx или Apache. Что и позволяет нам спокойно писать блокирующий синхронный PHP-код. В традиционном PHP мы всегда имеем дело с одним единственным HTTP-запросом и нам в принципе неважно будет заблокирован поток выполнения или нет.

А что если мы хотим реализовать HTTP сервер на чистом PHP? Или сервер, слушающий сокет? Что если нам нужно реализовать сервис на PHP, который должен будет обрабатывать тысячи конкурентных запросов? Я имею в виду, что асинхронный PHP открывает возможности для создания целого класса приложений, которые раньше в принципе невозможно было написать на PHP.

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

Решение в том, чтобы вместо нативных блокирующих PHP функций для работы с I/O (вроде file_get_contents()), мы можем использовать библиотеки (ReactPHP и Amp). Эти библиотеки предоставляют высокоуровневые абстракции для реализации неблокирующего I/O в PHP. С неблокирующим асинхронным I/O нам и не нужно иметь много потоков для реализации конкурентности. Операционная система сама параллельно выполняет весь I/O для нас. Когда наш код вызывает какой-либо неблокирующий API, то он не ждёт ответа. Поток выполнения PHP может сразу продолжить выполнение кода, который находится после этого I/O-вызова. Операционная система сама уведомит наш PHP-код, когда данные будут готовы и доступны для чтения. Конечно звучит немного странно. Особенно когда мы привыкли к модели request-response. Каким образом операционная система свяжется с пользователем неблокирующего API? Какой-нибудь сигнал? Или может есть какой-то механизм, который постоянно проверяет, не пришли ли новые данные? Когда мы рассматриваем CPU, который последовательно выполняет инструкции в нашем коде, то как нам заставить программу слушать события? Обычно это реализуется через колбэки, которые имеют доступ к ожидаемым данным. Большинство операционных систем, на которых мы привыкли работать (Windows, Linux, Mac OS) умеют в такие асинхронные обработчики. То есть мы можем попросить их что-то сделать, а они предоставят итоговый результат через колбэк. Конечно существует много различных способов выразить неблокирующие вызовы - промисы, корутины и прочее. Но под капотом все они основаны на рутине (функции), которая будет вызвана после получения данных для I/O. У операционной системы много потоков, благодаря которым она может иметь доступ к различным системным ресурсам. Операционная система может обращаться к файловой системе или выполнять сетевой запрос в разных потоках. Таким образом наш PHP-скрипт только делегирует выполнение I/O-bound задач операционной системе и затем работает уже с результатами, полученными через колбэки.

Здесь проблема в том, что традиционный последовательный PHP-скрипт скорее всего не сможет обработать эти колбэки. Например, нам нужно выполнить два конкурентных HTTP-запроса:

$client = new Browser();$result1 = $client->get('http://google.com/');$result2 = $client->get('https://github.com/reactphp');

Представим такой код, где мы хотим сделать два конкурентных HTTP-запроса. Сетевые запросы являются I/O-bound операциями, так что их можно смело делегировать операционной системе. Начинаем один запрос, и не дожидаясь пока он выполнится, сразу стартуем следующий. Как только операционная система выполнит эти запросы, она сообщит об этом нашему скрипту. Но... Вы уже видите здесь проблему? В однопоточном PHP код выполняется строчка за строчкой. Велика вероятность, что к тому моменту как сетевые запросы будут выполнены, сам скрипт уже закончит выполнение. Ему же больше просто нечего делать. Мы же не ждём HTTP-ответов, а только лишь стартует запросы. Поэтому если мы хотим получить и обработать ответы, то нам нужно две вещи: - Иметь возможность слушать I/O события. - Продолжать выполнение скрипта до тех пор, пока в фоне выполняются какие-либо I/O задачи. Оба этих условия решаются с помощью цикла событий. Предыдущий пример можно переписать следующим образом:

use React\Http\Browser;use Psr\Http\Message\ResponseInterface;$loop = React\EventLoop\Factory::create();$client = new Browser($loop);$result1 = $client->get('http://google.com/');$result2 = $client->get('https://github.com/reactphp');$loop->run();

Мы добавили новый объект - цикл событий. Здесь я использовал реализацию ReactPHP. В самом начале скрипта мы создаём цикл событий, а в конце скрипта вызываем метод run(). Можно сказать, что это и делает PHP-скрипт асинхронным. На самой последней строчке скрипт не заканчивает выполнение, а начинает слушать события. Мы отправили два конкурентных сетевых запроса, так что нам нужно дождаться ответов. Более того, эта строчка на самом деле не отправляет ещё никаких запросов:

$result1 = $client->get('http://google.com/');

Здесь мы всего лишь описываем наше намерение отправить запрос. А он в свою очередь будет отправлен, как только запустится цикл событий. Но если на самом деле запрос ещё не отправлен, то что же тогда хранится в переменных $result1 и $result2? Они обе установлены в null? В асинхронном (по крайней мере ReactPHP) мире, когда нам нужно оперировать результатами, которые будут получены в будущем, то мы используем промисы. Промис можно рассматривать как плэйсхолдер для будущего значения. Этот промис будет разрешен в реальный сетевой ответ, как только запрос будет выполнен.

$printResponse = fn (ResponseInterface $response) => var_dump((string)$response->getBody());$promise1 = $client->get('http://google.com/');$promise2 = $client->get('https://github.com/reactphp');$promise1->then($printResponse);$promise2->then($printResponse);

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

Заключение

Всё вместе: однопоточный PHP, неблокирующий I/O вместе с событийной архитектурой легко превращают классический PHP в асинхронный. Да, к сожалению в языке сейчас нет нативной поддержки для асинхронности. Но есть библиотеки, которые могут помочь. Более того, сам PHP может быть асинхронным сразу из коробки без установки каких-либо расширений (однако расширения помогают улучшить асинхронность). На данный момент основная проблема - отсутствие нативной поддержки для высокоуровневых абстракций (цикл событий, промисы) и I/O-функции. PHP существует в модели request-response уже на протяжении многих лет. Поэтому бОльшая часть библиотек, которые у нас есть, предполагают выполнение в традиционном блокирующем окружении. С другой стороны в последнее время язык стремительно развивается. Возможно очень скоро мы увидим первые шаги в поддержке асинхронности в PHP (например, fiber'ы).

Целью этой статьи не было показать вам, что на PHP можно решить любую задачу. Конечно же не существует серебреной пули и для каждой задачи есть подходящий инструмент. Выбор того, подходит ли PHP для вашей задачи или нужен другой язык - полностью на вашей совести. Моя задача была показать как работает асинхронный PHP. Что на самом деле внутри там нет никакой магии, и что однопоточный PHP действительно может быть асинхронным. Необязательно иметь несколько потоков, чтобы выполнять код конкурентно. Более того, если мы говорим о PHP, то его однопоточность здесь будет скорее преимуществом, чем ограничением.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru