ВходНаше всё Теги codebook 无线电组件 Поиск Опросы Закон Вторник
7 мая
339089
bialix (06.07.2012 04:21 - 05:19, просмотров: 64767)
Изобретаю велосипед на тему асинхронных функций. Предлагаю вам начинать кидаться гнилыми помидорами и тухлыми яйками, либо дополнить своими дельными соображениями если таковые имеются. Вобщем, чем плохая тема для тяпницы? Если у меня это таки получится сделать красиво, то оно будет доступно в опен-сорсе. Может кому то еще пригодится. Но пока что давайте это обсудим. Дальше будет много букв. Я предупредил. Кто не сможет осилить, это не страшно. Кидать гнилыми помидорами можно даже если "не читал, но осуждаешь". Ключевые слова для обсуждения: callbacks, coroutines, protothreads, asynchronous calls, asynchronous programming. Зачем мне это надо? Лирика Чем больше в лес, тем становишься старше, ленивее и тупее. Я уже с десяток лет пишу код на автоматах, наелся я с автоматами и event-driven programming достаточно, чтобы начать это дело слегка недолюбливать. Плюс к тому же хочется как всегда, чтобы писать кода поменьше, но при этом чтобы он делал как можно больше, правильнее и без ошибок. И чтобы было понятно. И чтобы писать тесты легко. И чтобы было с блекджеком и шлюхами. И чтобы мир во всем мире, и пусть никто не уйдёт обиженным. И чтобы... ась? Забыл, склероз. Язык наш Си - это конечно пичалька, но ничего лучшего чтобы писать под микрожелезки почему то никак не придумают. Так что будем продолжать жрать кактус. Посему изобретаем велосипед с расчетом на Си. Апологеты плюсов могут начинать кидать помидоры уже сейчас. Однако я уверен, что плюсы тут ничем не помогут. А если реализовать то, что я хочу, для сей, то и на плюсах это уж как-то поедет. Что конкретно я хочу достичь и почему? Хочется научиться писать код на Си либо на псевдо-Си, который будет выглядеть как будто мы вызываем обычную (блокирующую) функцию и получаем от нее результат, но на самом деле в момент вызова асинхронной функции мы только запускаем выполнение долгой операции и не блокируем выполнение других параллельно выполняющихся функций в нашей программе. А когда долгая операция будет всё-таки выполнена, то наша основная функция вернется к исполнению ровно в той точке, где был сделан вызов асинхронной функции и получит результат выполнения. Аналогом этого является реализация coroutines и protothreads, с которыми многие из вас уже знакомы. Возникает законный вопрос: нафига изобретать велосипед, если все уже украдено до нас? Наиболее близкой реализацией такого, как я хочу, является библиотека макросов protothreads от Адама Дюнкеля (или как-то так его зовут). Макросы protothreads я уже пытался раньще использовать с более-менее переменным успехом. Однако столкнулся с определенными ограничениями, вызванными использованной идеологией этих самых protothreads. Особо злопамятные могут вспомнить, что я уже тут на сахаре пару лет назад уже об этом спрашивал. Но тогда коллективный разум меня или не понял или не захотел понять, вобщем тогда я на это слегка забил. Однако заноза у меня в мозгу осталась, и вот на очередном витке истории аналогичная проблема всплывает опять. Чем мне не нравится подход protothreads? protothreads мне предлагает использовать концепцию легковесных потоков, а я хочу имитировать работу функции, включая и возможность возвращать значение из асинхронной функции. protothreads навязывает мне использование поллинга ресурсов, а я хочу, чтобы оно могло работать с коллбэками. Фактически прототреды - это путь в сторону кооперативной ОС, а мне туда не надо. Прототред нельзя вызвать как функцию. Его можно только запустить как поток в ОС и потом как-нибудь странно с ним взаимодействовать методами, которые обычно применяются для обмена данными между потоками в нормальных больших ОС, или методами IPC. Примерно на этой точке прошлый раз мне указали, что я на самом деле хочу использовать coroutines. На самом деле: нет, я не хочу coroutines. Потому что мне не надо сопрограмму, которую я могу вызывать несколько раз для получения нескольких значений. Мне нужно именно вызвать асинхронную функцию один раз и получать от нее результат. Либо получить от нее дулю с маком, если все пошло на перекосяк. Как раз в protothreads для этого есть механизм SPAWN, это близко к тому, что мне надо... но читай пред-предыдущий абзац про прототреды. Сопрограммы используют тот же механизм (машина Даффа с неявным switch-case) по сути что и прототреды, только на более примитивном уровне. Те же яйца, только в профиль и меньше размером. Плюс я все-таки хочу сделать асинхронный вызов функции, которая потенциально выполняется долго из-за ожидания железа или протокола, причем тут сопрограмма? Поэтому мой велосипед отталкивается все-таки от прототредов, как более навороченного решения, которое допускает даже некоторую реентерантность и меньшую завязку на глобальные переменные. Это очень важно для построения юнит-тестов. Что есть хорошего в прототредах? Поскольку мои доводы насчет сопрограмм скорее всего непонятны, то я должен указать на одну фишку прототредов, которая мне кажется ужасно правильной. Возвращаемое значение из функции-прототреда. Это особое значение, настолько особое и магическое, что оно тщательно прячется от посторонних глаз. Функция-прототред возвращает текущее состояние исполнения протопотока. Если вы в это никогда не вникали, то может быть стоит посмотреть код или почитать, что про это пишут на сайте. Я буду дальше использовать эту идею в рассуждениях о своем велосипеде. Откуда вообще появилось такое странное и навязчивое желание и почему я думаю, что готовые решения не работают? Пару лет назад мы писали софт (типа драйвера) для работы с железкой, которая по сути своей представляла хаб на 8 каналов 1-Wire, и предполагала работу с iButton с внутренней памятью. Эти iButton использовались как электронные кошельки, т.е. нам нужно было в момент касания ибутона считать внутреннюю память, чтобы получить текущее значение кошелька. И дополнительно потом либо перевести деньги с кошелька, либо на кошелек. Либо пока что не переводить. Вот такая вобщем-то простая задача. На первый взгляд. Правда в итоге получилось не очень просто. Главная причина это конечно потому что мы писали код как могли, своими кривыми ручонками. Другие причины: это было из-за того, что нам нужно было работать с несколькими каналами одновременно, т.е. параллельно обслуживать несколько ибутонов. А еще ибутон мог прикоснуться считывателя любого канала в любой момент, а потом точно так же в любой момент и потерять контакт. Это очень важный момент. Из-за того, что ибутон может потерять контакт в любой момент, то: 1) любая операция с ним должна работать максимально быстро. 2) любая операция с ним на любой стадии может прерваться и выдать ошибку. Плюс мы делали параноидальный софт, который следит за тем, что все работает корректно, и что даже если во время работы голодная крыса перегрызет кабель, которым наша машина соединена с железкой, и мы перестанем получать от нее ответы (либо железка из-за плохого контакта в разъеме питания ребутнется и потеряет связь на время), то даже в этом случае наше ПО должно отработать такую внештатную ситуацию и доложить наверх о возникшей проблеме согласно протокола и регламента. Потому что тут у нас какбы деньги и все должно быть учтено и должны быть предусмотрены все мыслимые способы выхода из нештатных ситуаций. Вобщем, с самого начала мы заложились на использование event-driven, соответственно все делали на автоматах. Плюс мы сразу разделили реализацию на несколько уровней-слоев, каждый слой делал свою часть работы и тестировался отдельно от других слоев. Такое себе модульное программирование. Фактически в результате мы получили такой себе черный ящик с двумя каналами: один канал был подключен к бизнес-логике, которая оперировала понятием кошелек и сумма денег, а второй канал был подключен к COM-порту, через который осуществлялась связь с устройством. Тип порта тут не принципиален, принципиально лишь то, что обмен данными с железкой занимает определенное время, и что связь может быть, а может и не быть. Но вернемся к нашему черному ящику. Что же было у него внутри? Там был адский ад. Конечно изначально мы хотели как лучше, но получилось как получилось. Мы изобрели библиотеку для обмена сообщениями между отдельными слоями реализации. Сообщения передавались через fifo между слоями, каждый слой представлял собой опять же черный ящичек, внутри каждого черного ящичка сидел конечный автомат и обрабатывал приходящие сообщения. Конечный автомат вызывался как callback функция. Всё это было именно так изобретено не с перепою, а потому что уже раньше у нас были подобные наработки и имелся положительный опыт в использовании подобного подхода. А тут как раз новый проект, вот и решили реализовать чуть лучше чем раньше, и чтобы универсально. Наша изобретенная библиотека называется Chained Fifo (cfifo), лежит в открытом доступе на лончпаде. Однако, я сомневаюсь что после того, что я написал кто-то захочет на нее смотреть. Сама по себе библиотека неплохая, но она слишком низкоуровневая. А я хочу более высокоуровневый интерфейс. Что такого плохого в event-driven и автоматах? Сами по себе они не плохи. Однако мы использовали события (сообщения) для передачи между автоматами трех принципиально различных вещей: 1) асинхронные уведомления типа: появился контакт с ибутоном, пропал контакт с ибутоном, стало плохо железке, и т.п.. 2) команды на выполнение определенных операций, часто команды формировались и начинали выполняться после получения уведомления "появился контакт с ибутоном" ибо без контакта делать что-то бессмысленно 3) результаты выполнения этих команд (как успешного выполнения, так и ошибки в работе, либо даже таймаута) Если класс сообщений под номером 1 вполне логично вписывается в event-driven и тут у меня возражений нет, даже более того я считаю, что это очень правильно. То с 2 и 3 всё плохо. На самом деле мне нужно было реализовать вместо них асинхронные функции, о которых я всё пытаюсь рассказать. Однако, понимание этого пришло после того, как почти вся работа была закончена и стал виден весь ужас того, что мы создали. И уже было поздно что-то менять, заказчик сказал, что и так работает и что времени на переделку он не выделит. Бывало ли с вами такое? Как часто? Как должны тогда работать асинхронные функции? По моему мнению, независимо от низкоуровневой реализации, для программиста асинхронные функции должны выглядеть почти как обычные (блокирующие) функции, только они не должны блокировать работу всего остального, пока мы ждем выполнения долгой операции. Например, посылаем пакеты в СОМ-порт и затем считываем ответ от железки. Или еще что. Почему я уточнил: "независимо от низкоуровневой реализации"? Потому что я уверен в том, что такую абстракцию вполне можно было реализовать и на сообщениях, как было сделано у нас, только это все спрятать за лаконичным фасадом. Т.е. вызвали асинхронную функцию (запустили выполнение долгой операции) -> текущая функция стала на паузу -> но всё остальное работает -> нет блокировок. Долгая операция отработала успешно (или завершилась с ошибкой) -> текущая функция снялась с паузы и продолжила работу. Почему я считаю, что сделать на одних прототредах нельзя? Потому что у нас в системе есть не только команда-результат, но и асинхронные события (пункт 1 в списке событий выше), которые надо обрабатывать вне потока, и лучше всего внутри callback функции. Вот тут Миро Самек хитро пиарит свой фреймворк для разработки на основе конечных автоматов, тонко поливая грязью прототреды: http://embeddedgur …versus-state-machines/ Я с ним и согласен и не согласен. Ибо часто нужны линейные алгоритмы, которые легко и красиво делаются на прототредах, но выглядят отвратительно скучно на автоматах. И никакой фреймворк дело не спасёт. Давайте уже наконец изобретать велосипед! Надеюсь я изложил свои предпосылки и мотивы, и теперь станет яснее чего я хочу. Уточним терминологию: * основная функция - это функция которая вызвала асинхронную функцию и ждет ее выполнения. * асинхронная функция - это функция, которую вызвала основная функция, и которая выполняется долгое время и затем возвращая результат в основную функцию. Итак, что я хочу от велосипеда под названием "асинхронные функции" 1) Хороший велосипед должен решать как проблему текущего дня, так и то, что я узнал в прошлом. 2) Я хочу иметь механизм асинхронных функций, который прячет от меня, как программиста, всю низкоуровневую работу с реализацией запуска долгой операции и ожидания ее завершения. Т.е. никаких явных автоматов. Насколько можно используем прототреды 3) Прототреды хотят явного шедулера, а я не хочу этого. Шедулер -- это плохо, потому что мне не нужны явные долгоиграющие потоки. Мне нужна простая реализация для долгой операции: запустил операцию - дождался завершения - вернулся в точку откуда запускал. 4) Поэтому я считаю, что самым правильным будет использование колбэков для реализации "вернулся в точку откуда запускал": асинхронная функция после своего завершения возвращает выполнение программы к основной функции через обратный вызов этой самой основной функции. Это самый хитрый момент. Именно этот момент нам позволит выкинуть нафик явный шедулер. 5) Любая асинхронная функция может завершиться как успешно, так и с ошибкой. Ошибки могут быть разными. Кроме ошибок, в реальном мире случаются таймауты. 6) Асинхронная операция должна уметь завершаться по таймауту, возвращая выполнение программы к основной функции. 7) Если асинхронная функция вернула ошибку или завершилась по таймауту, то основная функция может попытаться запустить операцию еще раз с теми же параметрами, что и раньше, чтобы попытаться все-таки выполнить свою работу невзирая на помехи и ошибки. Часто в таком случае делают три попытки выполнить операцию. Знакомо? Поскольку это выглядит достаточно тривиально, то мы можем заложить в механизм асинхронных функций возможность делать несколько попыток, пряча внутри явный цикл типа такого for(i=0; i<3; i++) if (async_call_foo() == OK) goto _ok; _not_ok: // fatal error! _ok: // do next step Посмотрите примеры из доки к прототредам, там такое есть в явном виде. И такое часто надо. Мне такое явно писать лень каждый раз, это должно быть спрятано за фасадом. 8) Поскольку у нас может быть несколько попыток и все неудачные, то появляется еще одна дополнительная ошибка: все попытки исчерпаны. 9) Потенциально ресурс, к которому захочет обратиться асинхронная функция для выполнения долгой операции, может быть заблокирован другой асинхронной функцией. Мы ведь не блокируем выполнение, и что-то будет работать параллельно с нашей основной функцией. И другая функция может начать асинхронную операцию раньше нас. В такой ситуации мы либо вываливаемся по ошибке, либо будем ждать пока ресурс не освободится. Выбор варианта зависит от программиста, а наш асинхронный механизм может нам тут помочь и спрятать за фасадом низкоуровневые детали. 10) А теперь начинается пичалька: после вызова асинхронной функции у нас может быть несколько исходов. В случае все ОК: мы получаем результат выполнения и идём дальше. В случае ошибок выполнения и таймаутов можем либо как-то отреагировать на это и прервать основную функцию с ошибкой, либо автоматически сделать еще одну попытку до исчерпания лимита попыток. Опять же решение за программистом, что ему надо. Пичалька в том, что наш язык Си никак не приспособлен для решения такой нетривиальной задачи. Потому что частью завершения асинхронной операции как раз и является обработка как результата ОК, так и ошибок/таймаутов. И это надо сделать красиво и наглядно, и спрятать за фасадом все низкоуровневые детали. 11) Наш механизм должен позволять вызывать из одной асинхронной функции другую асинхронную функцию (и так далее по цепочке если нужно) и при этом все вышесказанное должно продолжать работать точно так же. Почему надо прятать за фасадом низкоуровневые детали? Потому что иначе нет смысла изобретать велосипед. А надо просто тупо делать закат солнца вручную, пока не станет тошнить от этого. Давайте попробуем представить как будет выглядеть пример такого асинхронного вызова 1) В самом примитивном своем случае асинхронный вызов, который всегда возвращает только ОК и никогда не вывалится по ошибке или таймауту, и возвращает нам некие данные, мог бы выглядеть так: ... data = ASYNC_CALL(async_func(param,taram)); ... Здесь ASYNC_CALL -- это наша асинхронная магия. async_func - имя асинхронной функции, param,taram - параметры, которые передаются в асинхронную функцию. 2) Поскольку мы хотим спрятать за фасадом обратный вызов по окончанию асинхронной операции, то для Си нам придется написать ... data = ASYNC_CALL(async_func, param,taram); ... Это потому, что мы должны будем неявно передать в async_func еще и коллбэк (указатель на основную функцию) и плюс к нему некий контекст (как структура pt в прототредах). 3) Поскольку в асинхронную функцию неявно будет передаваться дополнительные параметры, то мы должны по-хитрому объявлять такую функцию в .c/.h файлах, например a.h ASYNC_DEF(async_func, int param, int taram); a.c ASYNC_FUNC(async_func, int param, int taram) { ... ASYNC_RETURN(); } Здесь под ASYNC_RETURN(); прячется возврат выполнения в основную функцию. 3) Поскольку мы говорили о том, что основная функция будет запускаться как callback и должна иметь контекст, как в прототредах, то ее тоже надо объявлять по-хитрому main.c #include "a.h" ASYNC_MASTER(main_func) { ... data = ASYNC_CALL(async_func, param,taram); ... } Здесь main_func - имя нашей основной функции. 4) Поскольку мы можем захотеть из одной асинхронной функции вызвать другую асинхронную функцию, то макросы-фасады ASYNC_FUNC и ASYNC_MASTER должны иметь внутри какую-то очень похожую магию, чтобы они вели себя одинаково, но в то же время иметь какое-то различие, чтобы из ASYNC_FUNC не вызвать по ошибке ASYNC_MASTER (это не разрешается). 5) Мы говорили про то, чтобы автоматически сделать несколько попыток. Укажем при вызове асинхронной функции сколько попыток нам надо: ... data = TRY(3, ASYNC_CALL(async_func, param,taram)); ... Здесь TRY(3,...) прячет за фасадом цикл попыток. 6) Попытаемся представить, как нам обработать нехорошие завершения асинхронной операции: ... TRY_ASYNC(3, ASYNC_CALL(async_func, param,taram)) AND_AFTER_THAT_DO ON Ok: data = ASYNC_RESULT(async_func); ASYNC_DONE; ON Timeout: ASYNC_REPEAT; ON Error: ASYNC_DONE; return -1; /* error code of main func */ ON ExhaustedTries: return -2; /* error code of main func */ END_ASYNC ... Здесь всё что большими буквами, это какие-то макросы, которые прячут за фасадом низкоуровневое нечто. Например этот кусок ON Ok: ... ON Error: ... ON Timeout: ... Очень смахивает на switch (...) { case Ok: ... case Error: ... case Timeout: } 7) А что если мы таки хотим делать повторы на таймауты и ошибки и это тоже сделать покомпактнее: ... TRY_ASYNC(3, ASYNC_CALL(async_func, param,taram)) REPEAT_ON(Timeout,Error) AND_AFTER_THAT_DO ON Ok: data = ASYNC_RESULT(async_func); ASYNC_DONE; ON ExhaustedTries: return -2; /* error code of main func */ END_ASYNC ... Вот как-то так я себе вижу такой асинхронный механизм. Приведем еще раз нечто похожее на окончательный вариант. ASYNC_DEF(async_func, int param, int taram); ASYNC_FUNC(async_func, int param, int taram) { ... if (...) ASYNC_RETURN_OK(new_data); else ASYNC_ERROR(error_code); } ASYNC_MASTER(main_func) { ... TRY_ASYNC(3, ASYNC_CALL(async_func, param,taram)) REPEAT_ON(Timeout) AND_AFTER_THAT_DO ON Ok: data = ASYNC_RESULT(async_func); ASYNC_DONE; ON Error: ASYNC_DONE; return -1; /* error code of main func */ ON ExhaustedTries: return -2; /* error code of main func */ END_ASYNC ... } И еще мы где-то должны указать величину таймаута. Я про это немного забыл. На этом первую теоретическую часть я хочу завершить и буду надеяться, что хоть кто-то дочитал это до конца и захочет что-то прокомментировать.