Мы всё-таки смогли дойти до третьей части и добрались до самого интересного — организации асинхронных вычислений.


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


Теперь посмотрим, как можно управлять потоком исполнения (control flow) в случае обработки асинхронных задач.



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


Синхронные операции — операции, при которых мы получаем результат в результате блокирования потока выполнения. Для простых вычислительных операций (сложение/умножение чисел) — это единственный вариант их совершения, для операций ввода/вывода — один из, при этом мы говорим, к примеру, «попытайся прочитать из файла что-нибудь за 100мс», и если для чтения ничего нет — поток выполнения будет заблокирован на эти 100мс.


В некоторых случаях это допустимо (например, если мы делаем простое консольное приложение, либо какую-либо утилиту, цель которой — отработать и всё), но в некоторых — нет. К примеру, если мы так застрянем в потоке, в котором обрабатывается UI — наше приложение зависнет. За примерами далеко ходить не нужно — если javascript на сайте сделает while(true);, то перестанут вызываться какие-либо другие обработчики событий страницы и её придётся закрыть. Те же дела, если начать что-нибудь вычислять под Android'ом в обработчиках UI-событий (код которых вызывается в UI-потоке), это приведёт к появлению окна «приложение не отвечает, закрыть?» (подобные окна вызываются по watchdog-таймеру, который сбрасывается, когда выполнение возвращается обратно к системе UI).


Асинхронные операции — операции, при которых мы просим совершить некоторую операцию и можем каким-либо образом отслеживать процесс/результат её выполнения. Когда она будет выполнена — неизвестно, но мы можем продолжить заниматься другими делами.


Event loop


Event loop — это бесконечный цикл, который берёт события из очереди и как-то их обрабатывает. А в некоторых промежутках — смотрит, не произошло ли каких-нибудь IO-событий, либо не просрочились ли какие-либо таймеры — тогда добавляет в очередь событие об этом, чтобы потом обработать.


Вернёмся к примеру с браузером. Вся страница работает в одном event loop'е, загруженный страницей javascript добавляется в очередь, чтобы выполниться. Если на странице происходят какие-либо UI-события (клик по кнопке, перемещение мыши, прочее) — код их обработчиков добавляется в очередь. Обработчики выполняются последовательно, нет никакой параллельности, пока работает какой-либо код — все остальные ждут. Если какой-нибудь код вызовет какую-нибудь специальную функцию, вроде setTimeout(function() { alert(42) }, 5000) — то это создаст где-то вне цикла таймер, по истечению которого в очередь будет добавлен код функции с alert(42).


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


Вторая фишка: даже если мы попросим, например, 1 миллисекунду ожидания, может пройти куда больше, т.к. реализация event loop'а может посмотреть: «ага, очередь пуста, ближайший таймер через 1мс, будем ждать IO-событий 1мс», а когда мы вызовем select, реализация операционной системы может посмотреть: «ага, событий вроде нет, на твоё время мне всё равно, я делаю context switch, пока есть возможность», а там все остальные потоки заиспользовали всё доступное им время и мы пролетели.


select


Асинхронные IO-события на низком уровне реализованы при помощи вариаций select'а. У нас есть некие файловые дескрипторы (которые могут быть либо файлами, либо сетевыми сокетами, либо чем-то ещё (по сути, в Linux что угодно может являться файлом (или наоборот, файл может являться чем угодно))).


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


  1. Один или несколько переданных нами дескрипторов не станут готовы к совершению желаемой нами операции.
  2. Не истекло время ожидания (если оно было задано).

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


Callbacks


Самый простой способ получить результаты выполнения асинхронной операции — при её создании передать ссылки на функции, которые будут вызваны при каком-либо прогрессе выполнения/готовности результата.


Это довольно низкоуровневый подход, и часто неумение банально писать функции «в столбик» вместе со злоупотреблением анонимных функций приводит к «callback hell» (ситуация, когда мы имеем четыре-десять уровней вложенности функций, чтобы обработать последовательные операции):


// Вкладываем
function someAsync(a, callback) {
  anotherAsync(a, function(b) {
    asyncAgain(b, function(c) {
      andAgain(b, c, function(d) {
        lastAsync(d, callback);
      });
    });
  });
}

// Линейно
function someAsync2(a, callback) {
  var b;

  anotherAsync(a, handleAnother);

  function handleAnother(_b) {
    b = _b;
    asyncAgain(b, handleAgain);
  }

  function handleAgain(c) {
    andAgain(b, c, handleAnd);
  }

  function handleAnd(d) {
    lastAsync(d, callback);
  }
}

Async Monad


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


Что такое «вычисление»? Это процесс преобразования A в B. Будем записывать синхронные вычисления как A > B.


Что такое «асинхронное значение»? Это обещание предоставить нам в будущем некоторое значение T (которое может быть успешным результатом, либо ошибкой). Будем обозначать это как Async[T].


Тогда «асинхронная операция» будет выглядеть как A > Async[T], где A — какие-то аргументы, необходимые для старта операции (например, это может быть URL, к которому мы хотим совершить GET-запрос).


Как работать с Async[T]? Пусть у него будет метод run, который примет коллбэк, который будет вызван тогда, когда данные станут доступны: Async[T].run : (T > ()) > () (принимает функцию, принимающую T, ничего не возвращает).


Хорошо, а теперь добавим самое главное — возможность продолжить асинхронную операцию. Если у нас есть Async[A], то, очевидно, когда A станет доступно, мы можем создать Async[B] и ждать уже его результата. Функция для такого продолжения будет выглядеть так:


Async[A].then : (A > Async[B]) > Async[B]


Т.е. если мы можем создать Async[B] из некого A, а так же имеем Async[A], который когда-нибудь предоставит нам A, нет никаких проблем предоставить Async[B] сразу, ибо B мы сможем всё-таки получить через какое-то время и в итоге всё сойдётся.


Реализация этого добра
function Async(starter) {
  this.run = function(callback) {
    starter(callback);
  };
  var runParent = this.run;

  this.then = function(f) {
    return new Async(function(callback) {
      runParent(function(x) {
        f(x).run(callback);
      });
    });
  };
}

И тогда тот наш синтетический пример выше становится:


function someAsync(a) {
  return anotherAsync(a).then(function(b) {
    return asyncAgain(b).then(function(c) {
      return andAgain(b, c);
    }).then(function(d) {
      return lastAsync(d);
    });
  });
}

Но дальше интереснее. Явно разграничим тип асинхронного значения на ошибку/результат. Теперь у нас всегда Async[E + R] (плюс это тип-сумма, одно из двух). И тогда мы можем, к примеру, ввести метод Async[E + R].success : (R > Async[E + N]) > Async[E + N]. Обратите внимание, что E осталось нетронутым.


Мы можем реализовать этот метод только так, чтобы он выполнял переданную ему функцию только в случае получения успешного результата (т.е. получению R, а не E) и запускал следующую асинхронную операцию, иначе — результат асинхронной операции продолжает оставаться «ошибочным».


this.success = function(f) {
  return new Async(function(callback) {
    runParent(function(x) {
      if (x.isError()) callback(x);
      else f(x).run(callback);
    });
  });
};

Теперь если мы будем chain'ить асинхронные операции при помощи метода success, мы будем обрабатывать только успешную ветку развития событий, а любая ошибка проскочит все последующие обработчики и попадёт сразу в коллбэк, переданный в run.


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


Промисы (promises, обещания)


Есть стандарт, описывающий интерфейс Thenable. Он работает практически идентично тому, что было описано выше, но в Promises/A+ нет понятия старта асинхронной операции. Если мы имеем на руках Thenable, то уже где-то что-то выполняется и всё что мы можем — подписаться на результат выполнения. И там один метод then, принимающий две опциональных функции для обработки успешной/провальной ветки, а не разные методы.


Здесь уж на вкус и цвет, у обоих подходов есть и плюсы и минусы.


async/await — промисы + корутины


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


Есть.


У нас есть корутины, у которых может быть множество точек входа. И это то, что нам нужно. Пусть у нас будет корутина, которая выдаёт наружу Async[E + R], а внутрь неё подаётся получившееся R, либо возбуждается исключение E. И тогда начинается дзен:


function someAsync*(a) {
  var b = yield anotherAsync(a),
      c = yield asyncAgain(b),
      d = yield andAgain(b, c);

  yield lastAsync(d);
}

Затем нам нужен «executor» такого добра, который будет принимать эту корутину, доставать из неё выходы, если они являются Async'ами — выполнять их, если другими корутинами — рекурсивно execute'ить их, считая результатом последний yield.


А async/await — это когда мы yield переименовываем в await, а перед декларацией функции пишем async. Ну и иногда (в случае Python, например), можно увидеть асинхронные генераторы, в которых в наличии одновременно и yield, и await. Тогда они ведут себя как те же корутины, но операции с ней становятся асинхронными, ибо между возвратом/принятием она ждёт результатов своих внутренних асинхронных операций.




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

Поделиться с друзьями
-->

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


  1. beduin01
    06.02.2017 07:38

    Я никак не могу взять в толк. Ведь фоновая операция (к примеру обращение к БД) должна тоже в каком-то потоке обрабатываться? Где она происходит? В каком-то отдельном потоке? Но кто его создает? И каково количество этих потоков? Ведь у них же тоже должно быть переключение контекста, а значит те же сами блокировки.

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


    1. Rulexec
      06.02.2017 08:12

      Первое зависит от реализации. Платформа, предоставляющая event loop и асинхронное API действительно может создать сколько угодно других потоков и просто перекидывать данные оттуда в «наш главный». Но по факту новые потоки нужны только если что-то действительно вычисляем, опросить состояние IO (пример с БД) можно и в том же самом. А как там ОС это проворачивает — неизвестно, может у неё отдельный поток тоже для всего IO, я не знаю.


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


    1. Rulexec
      06.02.2017 08:23

      Тут важно ещё понимать, что «обращение к БД» в основном… не происходит, мы почти всё время просто ждём. Записать/прочитать данные в буффер и отправить его сетевой карте — это относительно быстрая операция, относительно времени, которое проходит между такими операциями. И если у нас времени ожидания достаточно, чтобы ещё повычислять чего-нибудь другого — нам не нужен ещё один поток.


    1. mayorovp
      06.02.2017 10:21

      Базовая "фоновая" IO-операция, такая как чтение или запись в сокет либо в файл, фонового потока не требует*. Она выполняется с использованием неблокироющего сокета или асинхронного API.


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


      Если же задача была написана с использованием асинхронных операций — то нет никаких причин существовать дополнительному фоновому потоку во время выполнения задачи.




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


    1. tumikosha
      06.02.2017 11:03

      во всяких там FLex'ах обычно есть еще один поток, параллельный главному, долгие операции (БД, Http) туда перекидываются.
      Разработчики Python просто клепают библы на C++ к.е. запускают поток параллельный главному и перекидывают таски туда.
      Но, в итоге, все равно же получается очередь сообщений между потоками, этакая микро- RabbitMQ


    1. Vadem
      07.02.2017 19:47

      Может эта статья вам поможет: There Is No Thread.
      Она про async/await из C#, но, думаю, принципиальной разницы тут нет.


  1. DmitryKoterov
    06.02.2017 10:21
    +1

    Самое главное-то не написали.

    try {
      let a = await something();
      let b = await xxxxx(a);
      // или еще что угодно - хоть последовательно, хоть параллельно
    } catch (e) {
      console.log(e);
      throw e;
      // или еще что-нибудь
    }
    


    Вот это можно запихнуть в async-функцию, и оно будет работать, как ожидается. И стектрейс будет внятным!

    Async-await возвращают исключения в JS, с которыми у него было туго с момента самого появления. (А также возвращают исключения в асинхронное программирование в любом другом языке.)


    1. mayorovp
      06.02.2017 10:25

      Внятные стектрейсы зависят только от используемой реализации обещаний (Promise). Ключевые слова async и await тут ни при чем. Нативные обещания в том же Chrome — да, дают внятные стектрейсы (в других браузерах не проверял). А вот всяческие полифилы могут и порушить все.


    1. Rulexec
      06.02.2017 11:07

      Написал «а внутрь неё подаётся получившееся R, либо возбуждается исключение E», но не уточнил, что исключение прямо внутри корутины возникает, ну и примера нет, спасибо.


  1. avigmati
    06.02.2017 18:00

    получается асинхронность = кооперативная многозадачность решенная через event loop?


    1. Rulexec
      06.02.2017 20:20

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


      Кооперативная многозадачность решённая через event loop просто порождает асинхронность, а не является ей. Асинхронность — просто абстрактное понятие, описывающее процессы, которые происходят не мгновенно.


  1. vba
    06.02.2017 19:33

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


    1. mayorovp
      07.02.2017 08:53

      А что мешает использовать подход с event loop на той же jvm? Почему многопоточность вдруг оказалась обязательной?