Сопрограмма (coroutine) – это нечто вроде квазипотока программы. Используя сопрограммы мы можем разделить выполнение программы на несколько квазипотоков и, по мере надобности, переключаться между ними. Переключиться из одной сопрограммы в другую можно в любом месте процедуры. Когда эта другая сопрограмма отдаст управление назад, то выполнение нашей процедуры начнется в точности с того места, где произошло переключение. Причем мы можем переключиться на другую сопрограмму не только из основной процедуры, но и из любой вложенной – т.е. каждая сопрограмма имеет свой стек выполнения.
Сопрограммы – это не потоки. Например, важные отличия сопрограмм от потоков:
С другой стороны у сопрограмм и потоков есть некоторые общие черты. Например, и у сопрограммы, и у потока свой стек вызовов. Также в различных операционных системах многозадачность может быть кооперативной, а не вытесняющей (Windows 3.1), что означает, что переключение потоков происходит явным образом по указанию самого потока, что роднит такие потоки с сопрограммами.
Использование сопрограмм дает, например, такие возможности:
Хорошая статья про сопрограммы есть в Википедии: http://en.wikipedia.org/wiki/Coroutine Там же ссылки на другие полезные ресурсы.
В реальном мире идеи сопрограмм используются достаточно часто.
Например, в языке C# для итерации по коллекциям используется конструкция yield return, с которой для итерации фактически создается сопрограмма: http://msdn.microsoft.com/ru-ru/library/9k7k7cf0.aspx
Эмулятор терминала PuTTY внутри использует сопрограммы, реализованные на макросах языка C. Ссылка на обсуждение на RSDN.RU: http://www.rsdn.ru/forum/flame.comp/2960478.flat.1.aspx
Реализация генераторов, использующая идею сопрограмм: http://www.rsdn.ru/forum/cpp/2965247.flat.1.aspx
Не так давно один из пользователей Йокселя сообщил о проблеме: не открывались большие файлы MXL. После совместного с пользователем изучения проблемы было установлено следующее. В Йокселе конвертер MXL-файла на вход получает два адреса памяти: адрес начала блока памяти, где находится документ MXL, и адрес конца этого блока. Конвертер последовательно проходит по этому блоку памяти, считывая данные и создавая документ Йокселя. Подобный подход (с блоком памяти) достаточно универсален. Он позволяет работать как с файлами на диске (через отображение файлов в память), так и документами в ресурсах программы или в блоках памяти хипа. Однако в данном случае этот подход не сработал. Оказалось, что функция Windows MapViewOfFile, которая используется для отображения файлов в память, для больших документов выдает ошибку.
Мне всегда казалось, что для системы никогда не составит труда отобразить документ в память целиком. В самом деле, документ MXL 80–100Мб – это ОГРОМНЫЙ документ (около 100 тыс. строк и миллионы ячеек). По сути подобный размер можно было бы считать предельным для MXL-документов. (Правда, учитывая, что 1С, загружая MXL-документ, расходует где-то в 4 раза больше оперативной памяти, чем объем документа на диске, НАСТОЯЩИМ пределом для MXL-документов является объем, примерно, 400–500Мб). С другой стороны при наличии 2 гигабайт адресного пространства 100Мб является очень небольшим объемом. Однако, в некоторых случаях отображение файлов в память все-таки не работает...
Итак, стало ясно, что подход с единым блоком памяти себя не оправдывает и нужно искать другое решение, которое, с одной стороны, решило бы проблему с отображением файлов в память, а с другой стороны, сохранило бы текущую гибкость при работе с различными источниками данных.
Наиболее гибким решением я посчитал чтение документов через виртуальный интерфейс. Был реализован интерфейс "Utils::ISequentialStream" с двумя методами: Read и Skip. Были созданы две реализации этого интерфейса: для чтения документа с диска и для чтения документа из блока памяти.
При реализации чтения документа из файла я, как водится, наступил на грабли функции ReadFile Windows. Эта функция не является буферизированной, поэтому исходный вариант замедлил загрузку MXL-файлов в 2 раза (на моей системе). Поэтому пришлось срочно добавить простейшую буферизацию с буфером в 4 килобайта и скорость загрузки вернулась практически к прежним значениям.
Не так давно поступило подтверждение от автора сообщения об ошибке, что новый метод загрузки документов решает проблему.
Т.к. загрузка MXL-документов происходит теперь через виртуальный интерфейс и не требует единого непрерывного блока памяти, возникла мысль, что загрузку документа из существующего объекта 1С можно серьезно улучшить.
Как работает метод «ЗагрузитьИзТаблицы»? Создается объект MFC CMemFile, создается объект CArchive, подключенный к этому CMemFile. Происходит подключение к объекту CSheetDoc 1С и вызов метода Serialize, в который передается наш CArchive. В результате происходит совершенно обычная для 1С выгрузка документа, но только в качестве приемника данных выступает не файл на диске, а блок памяти. Далее этот блок памяти отцепляется от CMemFile и подается на вход конвертеру из MXL.
Каковы недостатки этой реализации? Основная проблема – это работа объекта CMemFile. Сначала он выделяет буфер в 1 мегабайт. 1С пишет в этот буфер, пока он не заполнится. Если буфер заполнился, то CMemFile увеличивает буфер на 1 Мб. Иногда это увеличение может пройти успешно без необходимости создавать новый буфер, но иногда увеличить буфер возможности нет и нужно создать новый буфер, скопировать туда содержимого старого и удалить старый буфер. При выгрузке документа в 100Мб в худшем случае может потребоваться скопировать, примерно, 5000 мегабайт данных (да-да, примерно, 5 гигабайт): 1 + 2 + 3 + 4 + ... + 99 = 99 * 100 / 2 мегабайт. Это серьезно замедляет процедуру загрузки документа и фрагментирует хип процесса.
Какова могла бы быть улучшенная версия метода? Например, можно было бы задействовать то, что при выгрузке 1С косвенно использует виртуальный интерфейс CFile, а Йоксель для загрузки теперь использует виртуальный интерфейс "Utils::ISequentialStream«. Почему бы не «скрестить» вместе эти интерфейсы по принципу: получить немного данных 1С и загрузить их в Йоксель, потом получить еще немного данных и опять их обработать, и так до полной загрузки документа? К сожалению напрямую это сделать непросто. В тупой прямой реализации пришлось бы тотально переделать конвертер: его пришлось бы сделать наследником CFile и все операции по заполнению документа Йокселя делать внутри метода Write. В результате пришлось бы сделать два конвертера: специальный вариант для метода «ЗагрузитьИзТаблицы» и еще один вариант – для всех остальных случаев. Естественно, это привело бы либо к дублированию кода, либо к серьезному усложнению конвертера и появлению множества багов. Этот путь, естественно, неприемлем.
Хорошо, но как обойтись теми средствами, что есть, и не переделывать конвертер? И тут приходит мысль о сопрограммах, о которых я писал ранее. Как было бы здорово: разместить загрузку данных в основной сопрограмме, а выгрузку данных из 1С во вспомогательной. Тогда 1С во вспомогательной сопрограмме могла бы тихонько писать себе во внутренний буфер до его заполнения. При заполнении буфера она бы переключала поток выполнения на основную сопрограмму и загрузка документа возобновлялась бы с прерванного места. Загруженные в буфер данные использовались бы для формирования документа до исчерпания буфера. При исчерпании буфера происходило бы переключение на вспомогательную сопрограмму и так до конца. Как же можно реализовать сопрограммы в 1С?
В первую очередь на ум приходят файберы (fibers). Файберы – это средство аналогичное пользовательским потокам в UNIX и было добавлено в Windows Microsoft для облегчения портирования под Windows серверных приложений UNIX.
На первый взгляд файберы – идеальное средство для организации сопрограмм. Все файберы выполняются внутри одного потока (соответственно, отсутствуют все проблемы многопоточной работы). Каждый файбер имеет свой собственный стек. Файберы включают средства для явного переключения потока выполнения между различными файберами. Почитать о файберах можно у Рихтера или в MSDN: http://msdn.microsoft.com/en-us/library/ms682661(VS.85).aspx
Хорошая статья о файберах от, так сказать, первоисточника: http://blogs.msdn.com/oldnewthing/archive/2004/12/31/344799.aspx. Это третья часть трилогии о файберах – при желании можно найти первые две. Статья хорошая, но она сразу вызывает серьезные сомнения в возможности использовать файберы для моих целей. Проблема вот в чем:
Более подробно можно посмотреть по ссылке в разделе Dire warnings about fibers.
Итак, файберы отпадают. Требуется нечто другое, обладающее следующими свойствами:
Хочешь, не хочешь, а остаются только потоки.
Но ведь 1С – однопоточное приложение и многопоточная работа в ней может приводить к серьезным ошибкам? Особенно учитывая, что мы собираемся выполнять не только свой код, но и код 1С (выгрузка документа в CArchive)?
Хорошо, давайте разберемся. Почему могут возникать ошибки в 1С при многопоточной работе? Например, когда два разных потока ОДНОВРЕМЕННО модифицируют некоторый объект. Соответственно, если нет синхронизации доступа, то объект может быть разрушен и возникнет ошибка либо при модификации объекта, либо при последущем доступе к нему. Но ведь нам не нужна ОДНОВРЕМЕННАЯ работа потоков, нам требуется сэмулировать сопрограммы, которые просто таки обязаны работать строго последовательно и ситуация ОДНОВРЕМЕННОЙ работы должна быть полностью исключена. Если нет одновременной работы потоков, то полностью отсутствуют какие-либо последствия от неправильной реализации многопоточной работы – доступ к ЛЮБОМУ объекту программы ВСЕГДА происходит только из одного потока выполнения. Таким образом, просто необходимо корректно реализовать переключение потоков, чтобы добиться нужной цели и избежать любых проблем.
Итак, что же получилось. Создано два класса: один реализует интерфейс "Utils::ISequentialStream", а второй – интерфейс CFile (MFC). Назовем первый класс – «Поток», а второй – «Файл». «Поток» выполняется в основном потоке процесса 1С. «Файл» выполняется в дополнительном потоке. Переключение между потоками происходит при помощи двух объектов-событий с автосбросом. Когда одному потоку нужно переключить выполнение на другой, то он включает одно событие и засыпает на другом – т.е. останавливается, переходя в режим ожидания, пока другое событие не будет включено. Переключение происходит при помощи Windows-функции SignalObjectAndWait. Благодаря этой функции включение события и вход в режим ожидания происходит АТОМАРНО – т.е. ситуация одновременной работы потоков полностью исключается.
Выгрузка данных из 1С инициируется при создании объекта «Файл». Поэтому код 1С, выгружающий данные, работает в дополнительном потоке.
Работа с данными происходит следующим образом. При создании объекта «Поток» этот объект выделяет фиксированный буфер в 1 мегабайт. Код конвертера из MXL-документа вызывает функцию Read объекта «Поток». При этом объект «Поток» смотрит содержимое буфера. Если в буфере данных достаточно для выполнения запроса чтения, то он просто копирует данные из буфера во указанный буфер конвертера. Если данных не достаточно, то объект «Поток» при помощи функции SignalObjectAndWait засыпает, переключив управление на объект «Файл». В результате в дополнительном потоке возобновляется выгрузка данных кодом 1С. Код 1С, выгружая документ, последовательно вызывает функцию Write объекта «Файл». Эта функция последовательно пишет все записываемые 1С данные в наш фиксированный буфер в 1 мегабайт. Со временем наступает ситуация, когда буфер переполняется. Обнаружив такую ситуацию, объект «Файл» опять таки при помощи функции SignalObjectAndWait переключает управление на основной поток. Получив обратно управление объект «Поток» видит, что в буфере опять полно данных и возобновляет нормальную работу конвертера из MXL, пока буфер опять не будет исчерпан. И так далее – пока документ не будет полностью выгружен из 1С и не загружен во внутренний документ Йокселя.
Реализовав новый способ загрузки документа из существующего документа 1С, мне было необходимо протестировать его работу в сложных условиях, например, при исчерпании памяти. Напомню, что при прежнем способе загрузки («CMemFile» + CArchive) при возникновении исключения в случае нехватки памяти в коде 1С генерировалось еще одно исключение, которое в соответствии со стандартом C++ (нельзя генерировать исключения пока активно другое исключение) приводило к вызову terminate и принудительному завершению процесса. Оказалось, что при новом способе поведение системы не изменилось. Внимательнее изучив ситуацию, я обнаружил одну странность.
Дело в том, что при старом способе дело было так: генерилось CMemoryException, потом еще CMemoryException, потом вызывался terminate. При новом способе стало по-другому: CMemoryException, потом два раза "std::runtime_error", потом terminate. При этом префикс std на самом деле был видоизмененным – как это делает STLport. Т.е. "std::runtime_error" вылетало, фактически, из кода Йокселя. Стало очень интересно, плюс забрезжила надежда устранить проблему «двойного проброса исключения». Изучив проблему, я установил, что все дело в CArchive. И теперь я располагаю исчерпывающим объяснением того, что происходило при использовании старого способа загрузки. Сообразительный читатель в этой точке документа может попробовать самостоятельно, изучив код CArchive, установить, в чем проблема. :)
Итак, у нас используется CMemFile + CArchive. У CArchive внутри расположен временный буфер, куда он складывает данные и который он сбрасывает в CFile при переполнении. Как-то раз при выгрузке огроменного документа CArchive обнаруживает, что буфер переполнился. Он вызывает CFile::Write, чтобы записать этот буфер. Наш CMemFile видит, что его многомегабайтный буфер уже переполнился и просит у системы выделить еще один буфер – на мегабайт больше. Т.к. доступная память уже закончилась, возбуждается исключение CMemoryException. Как полагается при вылете исключения начинается раскрутка стека с вызовом деструкторов локальных объектов функций. В итоге дело доходит до функции, где находится наш CArchive, и для объекта CArchive тоже вызывается деструктор. Код деструктора смотрит на временный буфер CArchive и, обнаружив, что в буфере есть несохраненные данные, опять вызывает функцию CFile::Write. Естественно, с точно такими же параметрами. Естественно, эта функция точно так же обламывается и повторно возбуждается CMemoryException. Наступает смерть приложения.
Что происходило при новом способе загрузки? Т.к. мне было необходимо учитывать возможность возникновения ошибочных ситуаций, то мне пришлось реализовать механизм корректного завершения потока выгрузки данных из 1С. Для этого был заведен специальный флаг. Объект «Файл» внутри функции Write, увидев, что флаг взведен, должен выкинуть исключение ("std::runtime_error"). Это исключение перехватывалось основной функцией потока и приводило к корректному выходу из этой функции и нормальному завершению потока.
Что происходило в условиях нехватки памяти? Конвертер из MXL пытался выделить память. Например, под новую ячейку, или объект-формат, или что-то другое. Запрос на выделения памяти проваливался и возбуждалось исключение CMemoryException. Объект «Поток» взводил флаг ошибки и переключал выполнение на объект «Файл». Объект «Файл», который в это время находится в процессе выполнения функции Write, выкидывает исключение "std::runtime_error«. При вылете исключения начинается раскрутка стека для дополнительного потока и дело доходит до объекта CArchive. Он видит незаписанные данные в своем внутреннем буфере и вызывает функцию Write объекта «Файл». Эта функция опять видит флаг ошибки и опять выкидывает исключение, которое убивает процесс.
Таким образом, проблема заключается в некорректном дизайне класса CArchive, который не выполняет одно из важнейших правил программирования на C++: в деструкторе класса нельзя возбуждать исключения. В принципе, вряд ли стоит обвинять Microsoft и MFC в этой ошибке. MFC – очень древняя библиотека. Не исключено, что она должна была работать на совсем древнем компиляторе, который вообще не поддерживал исключения. В конце концов были времена, когда в C++ вообще не было исключений. Потом исключения появились, но тотально переписать и перепроектировать весь код MFC уже не было возможности.
Я решил эту проблему введением дополнительного флага – по которому функция Write вообще ничего не делает – просто сразу возвращает управление в 1С. Таким образом исключение повторно не возбуждается и все дальше работает полностью корректно. Однако существует более правильный способ решения.
Давайте посмотрим на описание конструктора CArchive в MSDN: http://msdn.microsoft.com/en-us/library/e031sa8s(VS.80).aspx. Как можно видеть у конструктора есть такой параметр как nMode, через который в CArchive передается набор управляющих флагов. Среди этих флагов есть такой не особо приметный флаг под названием "CArchive::bNoFlushOnDelete". Вот описание этого флага:
Prevents the archive from automatically calling Flush when the archive destructor is called. If you set this flag, you are responsible for explicitly calling Close before the destructor is called. If you do not, your data will be corrupted.
Как можно видеть, догадаться ДЛЯ ЧЕГО этот флаг нужен и к каким отрицательным последствиям приводит его отсутствие, очень сложно. Однако этот флаг делает четко то, что нам требуется: запрещает вызов операций записи в деструкторе CArchive.
Ссылок на эту страницу нет