Pull to refresh

О структуре параллельных вычислений или доводы против оператора «Go»

Reading time 23 min
Views 10K
Original author: Nathaniel J. Smith


Каждый язык, поддерживающий параллельные (конкурентные, асинхронные) вычисления, нуждается в способе запуска кода параллельно. Вот примеры из разных API:


go myfunc();                                // Golang

pthread_create(&thread_id, NULL, &myfunc);  /* C with POSIX threads */

spawn(modulename, myfuncname, [])           % Erlang

threading.Thread(target=myfunc).start()     # Python with threads

asyncio.create_task(myfunc())               # Python with asyncio

Есть много вариантов нотации и терминологии, но одна семантика — запустить myfunc параллельно основной программе и продолжить родительский поток выполнения (англ. "Control Flow")


Другой вариант — Коллбэки:


QObject::connect(&emitter, SIGNAL(event()),        // C++ with Qt
                 &receiver, SLOT(myfunc()))

g_signal_connect(emitter, "event", myfunc, NULL)   /* C with GObject */

document.getElementById("myid").onclick = myfunc;  // Javascript

promise.then(myfunc, errorhandler)                 // Javascript with Promises

deferred.addCallback(myfunc)                       # Python with Twisted

future.add_done_callback(myfunc)                   # Python with asyncio

И снова нотация меняется, но все примеры делают так, что, начиная с текущего момента, если и когда случится определенное событие, тогда запустится myfunc. Как только callback установлен, управление возвращается и вызывающая функция продолжает работу. (Иногда коллбэки обернуты в удобные комбинирующие функции или протоколы в стиле Twisted, но базовая идея неизменна.)


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


Но не моя новая странная библиотека Trio. Она не использует эти подходы. Вместо них, если мы хотим запустить myfunc и anotherfunc параллельно, мы пишем примерно так:


async with trio.open_nursery() as nursery:
    nursery.start_soon(myfunc)
    nursery.start_soon(anotherfunc)

nursery — ясли, питомник

Впервые столкнувшись с конструкцией "питомник", люди теряются. Зачем тут менеджер контекста (with-блок)? Что это за питомник, и зачем он нужен для запуска задачи? Потом люди понимают, что питомник мешает использовать привычные в других фреймворках подходы и злятся. Всё кажется причудливым, специфичным и слишком высокоуровневым, чтобы быть базовым примитивом. Все это понятные реакции! Но потерпите немного.


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


Звучит слишком смело? Но подобное уже случалось: некогда goto широко использовался для управления поведением программы. Теперь же это повод посмеяться:



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


Оглавление


  • Что такое goto?
  • Что такое go?
  • Что случилось с goto?
    • goto разрушает абстракции
    • Дивный новый мир без goto
    • Больше никаких goto
  • О вреде выражений типа “Go”
    • go-выражения ломают абстракции.
    • go-выражения ломают автоочистку открытых ресурсов.
    • go-выражения ломают обработку ошибок.
    • Больше никаких go
  • Питомник как структурная замена go
    • Питомник сохраняет абстракцию функций.
    • Питомник поддерживают динамическое добавление задач.
    • Из питомника всё ещё можно выйти.
    • Вы можете определить новые типы, которые крякают как питомник.
    • Нет, правда, питомники всегда ждут окончания всех задач внутри.
    • Работает автоматическая очистка ресурсов.
    • Работает поднятие ошибок.
    • Дивный новый мир без go
  • Питомники на практике
  • Выводы
  • Комментарии
  • Благодарности
  • Сноски
  • Об авторе
  • Продолжение

Что такое goto?


Первые компьютеры программировались с помощью ассемблера, или даже более примитивно. Это не очень удобно. Так что в 1950-х люди типа Джона Бэкуса из IBM и Грейс Хоппер из Remington Rand начали разрабатывать языки типа ФОРТРАН и FLOW-MATIC (более известный его прямым потомком КОБОЛ ).


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



Заметьте, что в отличие от современных языков, тут нет условных блоков if, циклов или вызовов функций — по факту тут нет блоков или отступов вообще. Это просто последовательный список выражений. Не потому, что данная программа слишком коротка, чтобы понадобились операторы управления (кроме JUMP TO) — просто такой синтаксис ещё не был изобретен!



Вместо этого, FLOW-MATIC имел две возможности управлять потоком выполнения. Обычно поток был последовательным — начать сверху и двигаться вниз, одно выражение за раз. Но если выполнить специальное выражение JUMP TO, оно могло перенести управление куда-то ещё. Например, выражение (13) перепрыгивает к выражению (2):



Так же, как с примитивами параллельности из начала статьи, тогда не было согласия, как назвать эту "сделай прыжок в одну сторону" операцию. В листинге это JUMP TO, но исторически прижилось goto (как "иди туда"), который я тут использую.


Вот полный набор прыжков goto в этой маленькой программе:



Это кажется запутанным не только вам! Такой стиль программирования, основанный на прыжках, FLOW-MATIC унаследовал прямо из ассемблера. Он мощный, хорошо приближен к тому, как на самом деле работает компьютерное "железо", но с ним очень трудно работать напрямую. Этот клубок стрелок — причина изобретения термина "спагетти-код".


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


Что такое go?


Давайте отвлечемся от нашей истории. Все знают, что goto был плохим, но какое отношение это имеет к асинхронности? Посмотрите на известное выражение go из Golang, которое используется чтобы породить новую "горутину" (легковесный поток):


// Golang
go myfunc();

Можно ли нарисовать диаграмму ее потока выполнения? Она немного отличается от диаграммы выше, потому что тут поток разделяется. Нарисуем её вот так:



Цвета тут призваны показать, что оба пути выбраны. С точки зрения родительской горутины (зеленая линия) — поток управления выполняется последовательно: он начинается сверху и потом немедленно идет вниз. Тем временем, с точки зрения функции-потомка (сиреневая линия) — поток приходит сверху и потом прыгает в тело myfunc. В отличие от обычного вызова функции, тут происходит прыжок в одну сторону — запуская myfunc мы переключаемся в совершенно новый стек и среда выполнения сразу забывает, откуда мы пришли.


видимо имеется в виду стек вызовов

Но это применимо не только к Golang. Это диаграмма верна для всех примитивов (управления), перечисленных в начале статьи:


  • Библиотеки управления потоками (threading libraries) обычно возвращают некоторый контрольный объект, который позволит присоединиться к потоку позже — но это независимая операция, о которой сам язык не знает ничего. Примитив для создания нового потока имеет диаграмму, показанную выше.
  • Регистрация коллбэка семантически эквивалентна созданию фонового потока (хотя очевидно, что реализация отличается), который:
    а) блокируется, пока не случится какое-либо событие, и потом
    б) запускает коллбэк-функцию
    Так что, в терминах высокоуровневых операторов управления, регистрация коллбэка — выражение, идентичное go.
  • С Futures и Promises то же самое — когда вы запускаете функцию и она возвращает Promise, это значит что она запланировала работу в фоне, и возвращает контрольный объект, чтобы достать результат позже (если вы захотите). С точки зрения семантики управления, это то же самое, что породить поток. После этого вы передаете промису коллбэк и далее как в предыдущем пункте.

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


И всё же меня раздражает, что для этой категории операторов управления нет стандартного имени. Я использую выражение "go" чтобы называть их, так же как "goto" стало обобщающим термином для всех похожих на goto выражений. Почему go? Одна причина в том, что Golang дает нам очень чистый пример такого синтаксиса. А другая вот:



Заметили схожесть? Все верно — go это одна из форм goto.


Асинхронные программы печально известны трудностью написания и анализа. Так же как и программы, основанные на goto. Проблемы, вызванные goto, в современных языках в основном решены. Если мы изучим, как починили goto, поможет ли это создать более удобные асинхронные API? Давайте выясним!


Что случилось с goto?


Так что же не так с goto, что вызывает так много проблем? В поздних 60-х Эдсгер Вибе Дейкстра написал пару известных теперь работ, которые помогли понять это гораздо яснее: Доводы против оператора goto и Заметки по структурному программированию.


goto разрушает абстракции


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


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

Да, это из Заметок по структурному программированию. Но его главной заботой была абстракция. Он хотел писать программы, слишком большие, чтобы держать их в голове целиком. Чтобы делать это, вы должны относиться к частям программы, как к черным ящикам — как например, вы видите эту программу на Python:


print("Hello World!")

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


К этому моменту был изобретен блочный синтаксис и языки типа ALGOL аккумулировали ~5 разных типов операторов управления: они все еще имели последовательный поток выполнения и goto:



А также приобрели условия, циклы и вызовы функций:



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


  • приходит сверху => [чтото случается] => поток выходит снизу

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


print("Hello World!")

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


Но если у вас есть язык с goto — язык, где функции и все остальное построено на основе goto, и goto может прыгнуть куда угодно, в любое время — тогда эти структуры совсем не являются черными ящиками! Если у вас есть функция с циклом, внутри которого условие, а внутри него есть goto… тогда этот goto может передать выполнение куда угодно. Возможно, управление внезапно полностью вернется из другой функции, которую вы еще даже не вызвали! Вы не знаете!


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


И как только Дейкстра понял проблему — он смог решить её. Вот его революционное предположение — мы должны думать об условиях/циклах/вызовах функций не как о сокращениях для goto, но как о фундаментальных примитивах со своими правами — и мы должны полностью удалить goto из наших языков.


Из 2018 года это выглядит вполне очевидным. Но как реагируют программисты, когда вы пытаетесь забрать их небезопасные игрушки? В 1969-м, предложение Дейкстры казалось невероятно сомнительным. Дональд Кнут защищал goto. Люди, которые стали экспертами по написанию кода с goto, вполне обоснованно негодовали против необходимости заново учиться тому, как выражать свои идеи в новых, более ограничивающих выражениях. И конечно, потребовалось создать совершенно новые языки.


В итоге современные языки немного менее строги, чем оригинальная формулировка Дейкстры.



Слева: традиционный goto. Справа: Одомашненный goto, как в C, C#, Golang, и т.д. Неспособность пересечь границы функции значит, что он все еще может пописать на ваши ботинки, но вряд ли сможет вас разорвать.

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


А языки, сохранившие оператор goto (С, С#, Golang, ...), сильно его ограничили. Как минимум, они не позволяют вам прыгать из тела одной функции в другую. Если вы не используете Assembler [2], классический, неограниченный goto ушёл в прошлое. Дейкстра выиграл.


Дивный новый мир без goto


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


Например, в Питоне есть классный синтаксис для автоматической очистки ресурсов — менеджер контекста. Вы можете написать:


# Python
with open("my-file") as file_handle:
    some code

и это гарантирует, что файл будет открыт во время выполнения some code но после — немедленно закрыт. Большинство современных языков имеют эквиваленты (RAII, using, try-with-resource, defer, ...). И все они предполагают, что поток управления идет по порядку. А что случится, если мы запрыгнем в блок with используя goto? Файл открыт или нет? А если мы выпрыгнем оттуда вместо того, чтобы выйти как обычно?


после того, как код внутри блока завершен, with запускает метод __exit__() который и закрывает открытые ресурсы, такие как файлы и соединения.

Закроется ли файл? В языке с goto менеджеры контекста просто не работают чётким образом.


Та же проблема с обработкой ошибок — когда что-то идет не так, что должен сделать код? Часто — послать описание ошибки выше по стеку (вызовов) к вызывающему коду и позволить ему решать, что делать. Современные языки имеют конструкции специально для этого, такие как Исключения (Exceptions) или другие формы автоматического поднятия ошибок. Но эта помощь доступна только если в языке есть стек вызовов и надежная концепция "вызова". Вспомните спагетти потока выполнения в примере на FLOW-MATIC и представьте исключение, выброшенное в середине. Куда оно вообще может прийти?


Больше никаких goto


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


Но даже сама возможность goto в языке делает всё сложнее. Сторонние библиотеки не могут считаться чёрным ящиком — не зная исходники, не разобраться, какие функции обычные, а какие непредсказуемо управляют потоком выполнения. Это серьезное препятствие для предсказания локального поведения кода. Также теряются мощные возможности, такие как менеджеры контекста и автоматическое всплытие ошибок. Лучше удалить goto совсем, в пользу операторов управления, которые поддерживают правило "черного ящика".


О вреде выражений типа "Go"


Итак, мы рассмотрели историю goto. Но применима ли она к оператору go? Ну… в общем, полностью! Аналогия получилась шокирующе точной.


go-выражения ломают абстракции.


Помните, как мы сказали, что если язык позволяет goto, то любая функция может скрывать в себе goto? В большинстве асинхронных фреймворков, go-выражения приводят к той же проблеме — любая функция может (а может и нет) запустить задачу в фоне. Выглядит, как будто функция вернула управление, но работает ли она все еще в фоне? И нет способа это узнать, не прочитав исходники функции и всего, что она вызывает. И когда она завершится? Трудно сказать. Если у вас есть go и его аналоги, тогда функции больше не чёрные ящики, которые уважают поток выполнения. В моей первой статье про асинхронные API, я назвал это "нарушением причинности", и нашел, что это первопричина многих распространённых, реальных проблем в программах, использующих asyncio и Twisted, таких как проблемы контроля потока, проблемы правильного выключения и т.д.


имеется в виду контроль потока данных, входящих и выходящих из программы. Например, в программу приходят данные со скоростью 3МБ/с, а уходят со скоростью 1МБ/с, и соответственно программа потребляет все больше и больше памяти, см. другую статью автора

go-выражения ломают автоочистку открытых ресурсов.


Давайте снова взглянем на пример выражения with:


# Python
with open("my-file") as file_handle:
    some code

Ранее мы сказали, что нам "гарантированно", что файл будет открыт, пока some code работает, и закрыт после. Но что если some code запускает фоновую задачу? Тогда наша гарантия потеряна: операции, которые выглядят, как будто они внутри with, на самом деле могут работать после завершения блока with, и вызвать ошибку, потому что файл закрылся, пока он всё ещё им нужен. И снова, вы не можете судить об этом локально; чтобы исключить подобное, вам придется прочитать все исходники всех функций, вызванных внутри some code.


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


Переводчик статьи сам столкнулся с такой задачей, используя в Python модуль threading — пришлось создать глобальную для моего модуля структуру, в которой хранились объекты управления всеми фоновыми задачами, и при завершении программы закрывать их все в цикле — значительно больше кода, чем удобная конструкция with

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


go-выражения ломают обработку ошибок.


Как мы обсудили выше, современные языки предоставляют мощные инструменты, такие как Исключения (exceptions), для гарантий обнаружения ошибок и передачи их в нужное место. Но эти инструменты полагаются на надежную концепцию "вызывающего кода". Как только вы запускаете новую задачу или регистрируете коллбэк, эта концепция сломана. В результате, все известные мне популярные асинхронные фреймворки, просто сдаются. Если в фоновой задаче случилась ошибка, и вы не обработали ее на месте, тогда среда выполнения… выводит ошибку в консоль и надеется, что она была не слишком важна. Если вам повезло, она напечатает что-нибудь в терминал. (Единственный софт из тех, что я использовал, который считает "напечатай что-нибудь и работай дальше" хорошей стратегией обработки ошибок — это старые безобразные библиотеки Фортрана; но асинхронные библиотеки делают так же.) Даже Rust — язык, известный, как самый помешанный на корректности многопоточного программирования из-за своей высокой инженерной культуры — виновен в этом грехе. Если фоновый поток выполнения (thread) сломался, Rust отбрасывает ошибку и надеется на лучшее.


Конечно, вы можете обрабатывать ошибки правильно в таких системах, вызывая join для каждого потока или создавая собственный механизм поднятия ошибок, такой как errbacks в Twisted или Promise.catch в Javascript. Но теперь вы воссоздаёте локальную, хрупкую реализацию возможностей, которые ваш язык уже имеет. И теряете такие полезные вещи, как Traceback и отладку. И стоит всего лишь забыть вызов Promise.catch и серьёзные ошибки могут быть не замечены.


Даже ухитрившись решить все эти проблемы, теперь мы имеем две системы обработки ошибок.


Больше никаких go


Также, как goto был очевидным примитивом для первых практических высокоуровневых языков, go-выражения стали очевидным примитивом для первых практических конкурентных фреймворков — они совпадают с тем, как работают нижележащие планировщики, и достаточно мощны, чтобы реализовать любые паттерны конкурентных потоков. Но также, как и goto, они ломают абстракции потока выполнения, поэтому даже возможность наличия go в языке делает всё остальное трудным для использования.


Хорошие новости в том, что Дейкстра уже показал нам, как решать все эти проблемы! Нам нужно:


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

И это то, что сделал Trio.


Питомник как структурная замена go


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



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


Как же превратить эту зарисовку в конкретную конструкцию языка? Существует несколько подходящих под "чёрный ящик" конструкций, но


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


Здесь я фокусируюсь на объяснении моего решения. Я не заявляю, что типа изобрел идею параллельных вычислений или чего-то подобного. Идея черпает вдохновение из многих источников, я стою на плечах гигантов и т.д. [3]


Вот как мы это сделаем: первое, мы декларируем, что родительская задача не может запускать никаких потомков, пока вначале не создаст "питомник", где они будут жить. В Trio мы делаем это, используя питоновский синтаксис async with:



Открытие питомника автоматически создаёт представляющий его объект, и синтаксис as nursery назначает этот объект переменной nursery. Теперь мы используем метод питомника nursery.start_soon(), чтобы запускать фоновые (параллельные) задачи: в нашем случае это функции myfunc и anotherfunc. Концептуально эти задачи выполняются внутри питомника. Часто удобно думать о коде, написанном внутри питомника, как о начальной (родительской) задаче, которая автоматически стартует, когда питомник создан.



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


вероятно, имеются в виду временные рамки.

Вот диаграмма потока выполнения и она совпадает с базовой моделью из начала этого раздела:



Такой дизайн имеет много последствий, не все из которых очевидны. Вот некоторые из них:


Питомник сохраняет абстракцию функций.


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


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


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


run_concurrently([myfunc, anotherfunc])

async.gather в Python, Promise.all в Javascript, и т.п.

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


async with trio.open_nursery() as nursery:
    while True:
        incoming_connection = await server_socket.accept()
        nursery.start_soon(connection_handler, incoming_connection)

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


Из питомника всё ещё можно выйти.


Из питомника есть аварийный выход. Что если вам нужно написать функцию, которая порождает фоновую задачу, и эта задача должна пережить родителя? Легко: передайте объект питомника в эту функцию. Нет правила, что только блок async with open_nursery() может запускать nursery.start_soon(), — пока питомник открыт [4], любой, кто получил ссылку на питомник, получает возможность запускать в нем задачи. Вы можете передать ссылку как аргумент функции, передать её через очередь, что угодно.


На практике это означает, что вы можете писать функции, которые "ломают правила", но внутри определенных границ:


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

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


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


Вы можете определить новые типы, которые крякают как питомник.


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


async with my_supervisor_library.open_supervisor() as nursery_alike:
    nursery_alike.start_soon(...)

Если ваша функция принимает питомник в аргументах, можете передать ей ваш тип вместо питомника, для контроля стратегии обработки ошибок. Довольно удобно.


Правда Trio следует не таким условным соглашениям, как asyncio и некоторые другие библиотеки: start_soon() должен принимать функцию, а не корутину или Future (вы можете вызвать функцию множество раз, но нет способа перезапустить корутину или Future). Я думаю, что такое соглашение лучше по множеству причин (особенно потому, что у Trio вообще нет Future!), но все таки стоило об этом упомянуть.


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


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


В Trio, код может получить запрос на отмену выполнения в любое время. После того как отмена запрошена, в следующий раз, когда код проходит "контрольную точку" (детали), вызывается исключение Cancelled. Это значит, что есть задержка между запросом отмены и тем, когда она действительно случится — может пройти какое-то время, прежде чем задача дойдет до "контрольной точки", и после этого исключение должно отмотать стек вызовов, запустить очистку и т.д. Когда такое случается, питомник всегда ждет, пока очистка полностью завершится. Мы никогда не убиваем задачу, не дав ей шанс запустить обработчики очистки, и мы никогда не оставляем задачу работать без присмотра снаружи питомника, даже если она в процессе отмены.


Работает автоматическая очистка ресурсов.


Так как питомники следуют "правилу чёрного ящика", блоки with снова работают. Например, закрытие файла в конце блока with не сломает внезапно фоновую задачу, которая всё ещё использует этот файл.


Работает поднятие ошибок.


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


В Trio, все фоновые задачи живут питомнике, а родительская задача обязана ждать окончания задач в питомнике… поэтому мы можем кое-что сделать с необработанными исключениями. Если фоновая задача завершается с исключением, мы можем перебросить исключение в родительскую. Идея в том, что питомник — это как бы примитив "параллельного вызова" — можно думать о нашем примере, как об одновременном вызове myfunc и anotherfunc, так что наш стек вызовов становится деревом. А исключения поднимаются по дереву вызовов в сторону корня, так же, как они поднимаются по обычному стеку вызовов.


Врочем, тут есть одна тонкость: если мы перезапустим (re-raise) исключение в родительской задаче, оно поднимется ещё выше. В общем это значит, что родительская задача выйдет из питомника


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

Но мы же сказали, что родительская задача не может покинуть питомник, пока там ещё остались работающие потомки. Что же нам делать?


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


Это значит впрочем, что если вы хотите реализовать питомники в своем языке, вам может понадобиться какая-либо интеграция между кодом питомников и вашей системой остановки задач (task cancellation). Это может быть сложным в языках типа C# или Golang, где остановка задач обычно управляется через ручную передачу объектов и специальные соглашения или даже хуже — язык вообще не имеет общего механизма отмены.


Дивный новый мир без go


Устранение goto позволило прежним создателям языков создать более сильные допущения о структуре программ, что разрешило новые возможности типа менеджеров контекста with и исключений; устранение go-выражений имеет схожий эффект. Например:


  • система остановки задач в Трио надежней и проще, чем у соперников, потому что предполагается, что задачи вложены в правильную древовидную структуру. (полная дискуссия: Таймауты и завершение задач по-человечески)
  • Трио — единственная Python библиотека для параллельных вычислений, где ctrl-C работает ожидаемым образом (детали). Это было бы невозможно, если бы питомники не предоставили надежный механизм поднятия исключений.

Питомники на практике


Итак, это была теория. А как это работает на практике?


Ну… это эмпирический вопрос: вам стоит попробовать и узнать! Серьезно, мы просто не узнаем наверняка, пока множество людей не попробует. Сейчас я вполне уверен что основа хороша, но может оказаться что нам нужно будет добавить некоторые правки, также, как ранние теоретики структурного программирования в итоге оставили break и continue.


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


Но в том то и дело. Как написал Кнут (Knuth, 1974, стр.275):


Вероятно худшая ошибка, которую любой может сделать в отношении темы оператора goto, это считать, что "структурное программирование" достигается написанием программы как мы обычно делали и потом устранить все goto. Большинство goto там изначально лишние! Что мы действительно хотим, так это задумывать наши программы таким образом, что мы даже не думаем про выражения goto, потому что реальная надобность в них почти никогда не возникает. Язык, которым мы выражаем наши идеи, сильно влияет на наши мыслительные процессы. Следовательно, Дейкстра требует новых языковых возможностей — структур, которые помогают ясному мышлению — чтобы избежать соблазна "goto" усложнений.

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


Например, посмотрите на алгоритм Happy Eyeballs (RFC 8305), простой асинхронный алгоритм для ускорения установки TCP соединений. Концептуально, алгоритм несложный — вы запускаете гонку нескольких соединений, с отложенным стартом, чтобы избежать нагрузки на сеть. Но если посмотреть на лучшую Twisted реализацию — это почти 600 строк кода на Python и все еще имеет как минимум одну логическую ошибку. Эквивалент в Трио короче в 15 раз. Еще важнее, используя Трио я написал код за несколько минут, вместо месяцев, и логика верно работала с первой попытки. Я бы никогда не смог это сделать с любым другим фреймворком, даже с теми, в которых я имею намного больше опыта. Если хотите больше деталей, посмотрите моё выступление. Всегда ли будет так? Время покажет. Но выглядит перспективно.


Выводы


Популярные примитивы параллельного программирования — выражения go, функции порождающие потоки, коллбэки, Futures, Promises,… они все — вариации над goto, в теории и практике. И даже не современного одомашненного goto, но старозаветного с огнем-и-молниями goto, который игнорирует границы функций. Эти примитивы опасны, даже если их не использовать прямо, потому что они подрывают нашу способность рассуждать о потоке выполнения и составлять сложные системы из абстрактных модулей; также они мешают полезным возможностям языка, таким как автоматическая очистка ресурсов и всплытие ошибок. Поэтому, как и goto, им не место в современном высокоуровневом языке.


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


К сожалению, чтобы сполна воспользоватся этими преимуществами, необходимо полностью удалить старые примитивы, и это, вероятно потребует создания с нуля новых фреймворков — также же, как удаление goto потребовало создания новых языков. Но как бы впечатляюще ни выглядел FLOW-MATIC в свое время, большинство из нас рады, что мы перешли на что-то лучшее. Я не думаю, что мы пожалеем о переходе на питомники, и Trio показывает, что это жизнеспособный проект для практических, универсальных асинхронных фреймворков.


Комментарии


Вы можете обсудить этот пост на форуме Trio.


по ссылке из статьи всего два сообщения:


А основной Trio форум тут: https://trio.discourse.group/

Благодарности


Большое спасибо Graydon Hoare, Quentin Pradet, and Hynek Schlawack за комментарии к черновику этой статьи. Любые оставшиеся ошибки, конечно же, остаются на моей совести.


большое спасибо пользователю berez за найденные в переводе ошибки.

Использованные материалы: Пример FLOW-MATIC кода из этой брошюры (PDF), из музея компьютерной истории.


Wolves in Action, фотограф Martin Pannier, под лицензией CC-BY-SA 2.0, обрезано.
Французский бульдог, автор Daniel Borker, под лицензией CC0 public domain dedication.


Сноски


[2] А WebAssembly даже показывает, что возможно и желательно иметь низкоуровневый ассемблерный язык без goto: ссылка, обоснование


[3] Для тех, кто вероятно не обратит внимания на этот текст, не удостоверившись заранее, что я в курсе их любимых статей, вот мой текущий список тем для обзора:
The "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Статья Martin Sústrik's о структурной параллельности и работа над libdill, и crossbeam::scope / rayon::scope в Rust. Мне также указали на крайне связанные с темой golang.org/x/sync/errgroup и github.com/oklog/run в Golang.
Дайте мне знать, если я пропустил что-либо важное.


[4] Если вызвать start_soon() после того, как питомник закрылся, тогда start_soon вызовет исключение, а если исключения нет, значит питомник гарантировано открыт, пока все задачи не завершатся. Если вы создаете вашу собственную систему питомников, вы должны будете внимательно управлять синхронизацией в этом месте.


Об авторе


Nathaniel J. Smith, Ph.D., работал в UC Berkeley над улучшением numpy, на данный момент состоит в команде разработчиков Python и выступает на конференциях. Идея Nathaniel о структурированнии параллельных вычислений может стать революцией и в корне перевернуть наш способ написания асинхронных программ.


Update (August 2022)


В рамках разработки Python 3.11 Гвидо ван Россум имплементирует описанный в статье примитив в классе asyncio.TaskGroup.

Only registered users can participate in poll. Log in, please.
Как лучше назвать новую концепцию «Nursery» из оригинальной статьи?
38.2% Питомник 34
24.72% Ясли 22
11.24% Детский сад 10
1.12% Детская 1
33.71% Н(о|ю)рсери, да простит меня великий и могучий, но раз уж он стерпел дженерики, треды и промисы… 30
89 users voted. 50 users abstained.
Tags:
Hubs:
+8
Comments 50
Comments Comments 50

Articles