Pull to refresh

Comments 29

Тут разбираются различные модели однопотоxой многозадачности на примере NodeJS, но те же выводы применимы для любой платформы. async functions, generators, promises, callbacks — теряют стек вызова, из-за чего их сложнее отлаживать, имеют пенальти по производительности и требуют переписывания всего кода приложения "в своём стиле".

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

Каким образом он может сохраняться? Дампится в момент создания замыкания? Тогда это ещё более медленно. Не набросаете пример?

Вообще в java, а котлин больше в эту сторону, можно получить стектрейс текущего потока как-то так
Thread.currentThread().getStackTrace().

Получить-то его везде можно. Он автоматически приклеивается после возвращаения в сопрограмму? Кстати, "сопрограмма" — вполне устоявшийся термин, зря вы его не используете.


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

Да, там будет честный стректрейс, но другого и ожидать глупо. Или есть какие-то более хорошие идеи, что за стректрейс там должен быть?

Опять же, вместо того, чтобы "минусовать рекламу" вы бы лучше почитали. Я зря старался что ли? Там есть примеры:


Честный стектрейс:


TypeError: Cannot read property 'name' of null
    at Object.<anonymous> (./user.js:18:33)
    at next (native)
    at onFulfilled (./node_modules/co/index.js:65:19)

Полезный стектрейс:


TypeError: Cannot read property 'name' of null
    at Object.module.exports.getName (./user.js:14:23)
    at Object.module.exports.say (./greeter.js:2:41)
    at Future.task.error (./index.js:11:17)
    at ./node_modules/fibers/future.js:467:21
Это же абсолютно разные вещи. Для «fibers» есть решения типа quasar, который по своему неплох, но требует инструментирования байткода и решает свои специфичные проблемы. В котлине же на уровне языка сделали корутины, которые лишь позволяют передавать управление определенным образом. Так вот, если мы передали управление, то нужно ожидать, что стектрейс будет согласован с тем как мы передаем управление. И я не думаю, что можно придумать более удачный стектрейс для такого решения.

А какие там проблемы?


Не очень понял какой стектрейс вы называете "согласованным с тем, как передаём управление". Который от старта приложения или который от асинхронного события?

А какие там проблемы?

Выполнение кода в нитях.
Который от старта приложения или который от асинхронного события?

Еще раз: никакой асинхронности в корутинах нет. Это только способ передачи управления. Компилятор компилирует корутину специальным образом, чтобы можно было передать управление в ключевые точки функции. Если внутри корутины запустить поток (например), то конечно можно сделать асинхронный вызов типа async/await, но в своей сути это просто передача управления. Можно реализовать, например, генерацию последовательностей аля-yield, которая никак с асинхронностью не связана. И с этой точки зрения стектрейс это последовательность передачи управления. Стектрейс это всегда активный стек выполняющегося потока, поэтому даже если мы перешли обратно в корутину, но уже в другом потоке, то стек будет конечно текущего потока, потому что так работает jvm и именно это ожидает увидеть разработчик.

Любой код выполняется в нитях. Так в чём проблемы fibers?


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

Любой код выполняется в нитях. Так в чём проблемы fibers?

Тут путаница некоторая в терминологии произошла. Когда я говорил «нити», я имел ввиду легковесные потоки (fibers). Так вот выполнение кода в легковесном потоке требует достаточно большой работы по управлению этой инфраструктурой, почитайте хотя бы о том же quasar-е, которая пытается реализовать модель акторов из эрланга. Конечно, если выполнять всё в одном потоке, как это делает нода, то проблем минимум, но есть и другие языки, которые пытаются утилизировать все ядра или даже работать распределенно. И для таких решений уже легковесные нити работающие все в одном потоке не подойдут, там делается система акторов, которая обменивается сообщениями через очереди, имеет достаточно сложное управление потоками, инструментирует байткод для создания корутин и имеет ряд ограничений — всё это не подойдет для каждого первого приложения. Поэтому в котлине сделали из коробки корутины, на базе которых можно делать те решения, которые нужно.

Вы можете ответить прямо — первый или второй?

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

Я читал как это реализовано в Go и реализовывал в D. Не заметил там особых сложностей. Возможно проблема именно в JVM, который не даёт полного контроля. Но неужели никто не запилил какого-нибудь JNI расширения, как это сделали для NodeJS?


Вообще говоря, есть два перпендикулярных понятия: concurency и parallelism. так вот, раскидывание задач по ядрам — это paralellism. А выполнение нескольких задач на одном ядре — concurrency. У вас может быть одно, но не быть другого и наоборот. Например, в D fibers реализуют исключительно concurency — поток крутится в event-loop-е и выполняет задачи, беря их из очереди. А вот paralellism реализуется с помощью threads — вы можете одну задачу запустить на множестве потоков. В частности, вы можете запустить задачу "крутить event-loop" на "thread-pooll-е", который по умолчанию создаёт число "worker-thread" на 1 меньше числа ядер. Стандартный механизм коммуникации через посылку сообщений не очень удобен, поэтому я и реализовал "wait-free channels" — с ними производить синхронизацию задач — одно удовольствие. Правда типобезопасность не докрутил ещё.


Наоборот, я так понял первый ("от старта приложения до точки вызова функции") в JVM и невозможен, что очень печально. Переключение fibers, вообще говоря — плёвая операция — просто меняется указатель на стек и fiber просыпается, словно и не засыпал. Когда fiber решает, что ему делать нечего, то вызывает специальную функцию, которая находит следующую задачу на исполнение и передаёт управление ей или же сам напрямую передаёт ей управление, если он на ней "блокируется". А поскольку для этого даже не нужно переходить в режим ядра, то пенальти тут совсем незначительное. По идее, даже если в JVM такое не поддерживает — это должно быть не сложно прикрутить через нативный интерфейс.

Возможно проблема именно в JVM, который не даёт полного контроля.

Я же выше присылал ссылку на реализацию легковесных потоков для jvm. Сложности как обычно в деталях.
А выполнение нескольких задач на одном ядре — concurrency.

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

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

Первый стектрейс был тот, что назван «честным» — так вот именно он будет. Тот, что назван «полезным» его можно несложно получить вручную при реализации того же async/await, но не думаю, что это хорошая идея для производительных систем, потому что каждый раз, когда мы будем передавать управление, нам придется строить «полезный» стектрейс, что может сказаться на производительности — хотя не буду наверняка это утверждать.
Переключение fibers, вообще говоря — плёвая операция — просто меняется указатель на стек и fiber просыпается, словно и не засыпал.

Ну да, это просто последовательная передача управления в это место, чего тут долгого? К чему вы это?
По идее, даже если в JVM такое не поддерживает — это должно быть не сложно прикрутить через нативный интерфейс.

JVM-у всё равно, он будет выполнять тот байткод, который ему дадут. Для реализации лековесных потоков просто нужна возможность этот байткод сделать. Чтобы сделать полноценные fibers quasar очень серьезно инструментирует обычный последовательный код, что приводит к «особенностям» его использования, потому что не любой код можно таким образом преобразовать. Котлин же предлагает способ создавать подобный код с помощью корутин. Это не позволяет перекраить весь байткод под свои нужды, но позволяет возвращаться к выполнению какого-то метода — что по сути и делают леговесные потоки. Ведь async/await это такая простая реализация того же подхода. Да, вероятно в коде на котлине придется «помечать» какой именно код требует саспенда, но это даже может и не плохо, здесь мы хотя бы обходимся без уличной магии, которая в сложных случаях или не работает или дает непонятную логику. На базе того инструментария, который сейчас есть в котлине, можно реализовать код, который мы внизу смотрели:

fun greeting() = async {

    val config by httpResourceJson("./config.json")
    // тут запустился первый асинхронный запрос.

    val profile by httpResourceJson("./profile.json")
    // тут запустился второй асинхронный запрос.

    val greeting = config.replace("{name}", profile)
    // а вот тут данные ответа потребовались и произошла остановка задачи

    // сюда управление уже дойдёт лишь, когда все асинхронные запросы отработают.
    return greeting
}

Сделав кастомизацию только для CompletableFuture как local delegates — при этом всё будет работать точно так как вы этого ожидаете — т.е. в этом методе поток будет находиться только когда для него есть дело. А если так, то нужно ли сложное решение с большим числом возможных проблем из за обобщенности решения, если частное решение работает не хуже и из коробки без дополнительного инструментирования байткода?

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


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


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

Такая неявная реклама своей статьи, да.

Сразу говорю, буду говорить применительно к C#, но это, считай, уже reference реализация async/await для всех остальных.

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

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

Пенальти по производительности — async/await можно использовать по-разному (в том числе для выноса в фоновые потоки задач), но чаще всего используют этот механизм там, где на оверхед пофиг. То же сетевое взаимодействие существенно упрощается, а иначе все равно пришлось бы в callback-hell возвращаться, что не сильно быстрее в любом случае.

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

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


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

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


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

Это касается лишь stackfull coroutines, которые обычно реализуются без этих ваших async-await. В MS извратнулись и по началу сделали stackfull coroutines через async-await, а недавно переделали на stackless со всеми вытекающими.


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

Вы всё же почитайте ту "рекламируемую статью". Там в конце приводится пример использования stackfull coroutines без async/await/yield/callbacks. Помимо V8, она используется в таких языках как Go, D, Python.


Переписывание кода — когда библиотека уже вся на async-await, то ничего страшного не происходит.

Происходит как минимум следующее:


  1. Приходится захламлять код всеми этими async/await.
  2. Приходится лепить костыли и дублировать код, для поддержки и того и того варианта использования в обобщённом коде (чтобы можно было передать асинхронный колбэк, нужно функцию делать асинхронной, но тогда её нельзя вызвать из синхронного кода, передав синхронный колбэк — нужно дублировать реализацию или ещё как костылять).
При том, что безопасно и эффективно они выполняются только в одном потоке. Собственно, это их основное назначение — уменьшить число нативных потоков, выполняя по несколько задач одновременно в каждом.

Это ваши личные выдумки. Задача async/await — упростить асинхронный код. К асинхронному коду относятся и один поток, и несколько. Вот вам обыденный пример — сокеты. Они не однопоточны, все вызовы уходят на completion порты и прочие механизмы, а это значит использование пула потоков ОС. async/await здесь нужен для скрытия всех этих деталей и возвращения управления в тот же поток, откуда произошел вызов, создавая иллюзию синхронного кода. async/await ортогональны каким-то там потокам.

Это касается лишь stackfull coroutines, которые обычно реализуются без этих ваших async-await. В MS извратнулись и по началу сделали stackfull coroutines через async-await, а недавно переделали на stackless со всеми вытекающими.

Замечательно, только стектрейс исключения в C# все так же содержит все вызовы моего кода + вспомогательные вызовы, которые вставил компилятор для реализации async/await, но их несложно фильтровать глазами. Это полезный стектрейс. Скомпилируйте hello world в студии и сами все увидите.

Происходит как минимум следующее:

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

Задача async — транслировать обычную функцию в машину состояний с соответствующими пенальти по производительности.
Задача await — остановить текущую задачу, увеличив счётчик, и передать управление другой задаче в том же потоке. Иначе можно было бы выполнить thread_join безо всяких await и получить то же самое "упрощение асинхронного кода", но без необходимости транслировать все функции в машины состояний.


Это полезный стектрейс. Скомпилируйте hello world в студии и сами все увидите.

Я не C# разработчик. Можете сделать пример и указать в какой студии компилировали?


Насчет костылей вообще непонятно откуда вы это все выдумали.

Из практики я всё это выдумал. Говнокод был только у тех, кто не знал про fibers. Теперь они прикрутили костыли в виде async functions и хвастаются как теперь стало классно, сравнивая не с fibers, а со своим же говнокодом на колбэках.

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


@ $mol_mem()
greeting() {

    const config = $mol_http_resource_json.item( './config.json' ).json()
    // тут запустился первый асинхронный запрос.

    const profile = $mol_http_resource_json.item( './profile.json' ).json()
    // тут запустился второй асинхронный запрос.

    const greeting = config.greeting.replace( '{name}' , profile.name )
    // а вот тут данные ответа потребовались и произошла остановка задачи

    // сюда управление уже дойдёт лишь, когда все асинхронные запросы отработают.
    return greeting
}

К сожалению fibers в браузере нет, так что то же самое реализуется несколько по другому, но суть та же — синхронизация происходит не в момент, когда мы делаем запрос, а в момент, когда без ответа не можем продолжать исполнение. Это позволяет эффективно распараллеливать задачи, не шаманя вручную над агрегированием ответов в один await, что в общем случае всё-равно не даст распараллелить всё.

Не совсем понимаю, чтобы такой код написать и корутин никаких не нужно:

// посылает запрос асинхронно, для простоты возвращает просто String
fun httpResourceJson(url: String): CompletableFuture<String> {
// ...
}

fun greeting(): String {

    val config = httpResourceJson("./config.json")
    // тут запустился первый асинхронный запрос.

    val profile = httpResourceJson("./profile.json")
    // тут запустился второй асинхронный запрос.

    val greeting = config.get().replace("{name}", profile.get())
    // а вот тут данные ответа потребовались и произошла остановка задачи

    // сюда управление уже дойдёт лишь, когда все асинхронные запросы отработают.
    return greeting
}
С локальными делегатами, которые тоже скоро появятся в языке, код будет вообще почти идентичным, только не нужна никакая магия:
fun greeting(): String {

    val config by httpResourceJson("./config.json")
    // тут запустился первый асинхронный запрос.

    val profile by httpResourceJson("./profile.json")
    // тут запустился второй асинхронный запрос.

    val greeting = config.replace("{name}", profile)
    // а вот тут данные ответа потребовались и произошла остановка задачи

    // сюда управление уже дойдёт лишь, когда все асинхронные запросы отработают.
    return greeting
}
Конечно, если мы хотим освободить поток до получения обоих значений, то нужно использовать что-то вроде await/async:
fun greeting() = async {

    val configFuture = httpResourceJson("./config.json")
    // тут запустился первый асинхронный запрос.

    val profileFuture = httpResourceJson("./profile.json")
    // тут запустился второй асинхронный запрос.
    
    val (config, profile) = awaitPair(configFuture, profileFuture)
    // нам нужны результаты обоих заначений, ждем, пока поток отдали под другие нужды
    
    // сюда управление уже дойдёт лишь, когда все асинхронные запросы отработают.
    val greeting = config.replace("{name}", profile)

    return greeting
}

Фишка вся в том, что $mol_http_resource_json может сходить за данными на сервер, а может сразу вернуть их из кеша. И использующему их коду не нужно знать возможен ли там где-то в глубине асинхронный запрос и соответственно беспокоиться о том, чтобы вызвать get() непосредственно перед использованием, но не раньше.


Очень уж заманчиво написать так:


fun greeting(): String {

    val config = httpResourceJson("./config.json").get()
    val profile = httpResourceJson("./profile.json").get()

    val greeting = config.greeting.replace( config.namePlaceHolder , profile[ config.nameField ] )

    return greeting
}

Вместо правильного:


fun greeting(): String {

    val config = httpResourceJson("./config.json")
    val profile = httpResourceJson("./profile.json")

    val greeting = config.get().greeting.replace( config.get().namePlaceHolder , profile.get()[ config.get().nameField ] )

    return greeting
}

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

Фишка вся в том, что $mol_http_resource_json может сходить за данными на сервер, а может сразу вернуть их из кеша.

Это вообще никак не связано с темой нашего обсуждения — если данные уже получены, то их можно вернуть. Это логика функции httpResourceJson.
Вместо правильного:

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

В общем случае, если нам нужен результат, то это точка для await-а — т.е. если для каждого get-а мы будем саспендиться и освобождать поток (если готового значения еще нет), то это будет вполне рабочее поведение по-умолчанию.
Есть еще https://github.com/Kotlin/anko/blob/master/doc/ADVANCED.md#asynchronous-tasks
да, но это немного другое.
Еще в попилку https://github.com/metalabdesign/AsyncAwait
Библиотека, которую мы начали разрабатывать немного раньше описанной в статье. Предоставляет много больше возможностей как `awaitWithProgress`, избавляет от утечек памяти при уничтожении активити, обработку ошибок и пр.
Sign up to leave a comment.

Articles