Программирование на Python 3. Руководство издательство СимволПлюс
![]()
|
Упражнения Ни одно из упражнений, которые приводятся здесь, не требует созда ния большого объема программного кода, но ни одно из них нельзя на звать легким! 1. Скопируйте программу magicnumbers.py и удалите ее функции get_function() и все функции load_modules(), за исключением какой нибудь одной. Добавьте класс функтора GetFunction с двумя кэша ми: один – для хранения найденных функций и другой – для хране 466 Глава 8. Усовершенствованные приемы программирования ния функций, которые не были найдены (чтобы избежать повторно го поиска функций в модулях, где эти функции отсутствуют). Единственное изменение в функции main() заключается в добавле нии строки get_function = GetFunction() перед циклом и в использо вании инструкции with, чтобы можно было отказаться от блока fi nally . Кроме того, проверьте, что функции в модуле являются вы зываемыми объектами, но не с помощью функции hasattr(), а с по мощью проверки на принадлежность абстрактному базовому классу collections.Callable. Определение класса можно уместить примерно в двенадцать строк программного кода. Решение приво дится в файле magicnumbers_ans.py. 2. Создайте новый файл модуля и определите в нем три функции: is_ascii() , которая возвращает True, если все символы в заданной строке имеют числовые коды меньше 127; is_ascii_punctuation(), которая возвращает True, если все символы в заданной строке содер жатся и в строке string.punctuation; is_ascii_printable(), которая возвращает True, если все символы в заданной строке содержатся и в строке string.printable. Последние две функции структурно идентичны друг другу. Каждая функция должна быть создана с ис пользованием инструкции lambda, может занимать одну или две строки и должна быть написана в функциональном стиле. Обяза тельно добавьте для каждой функции строки документирования с доктестами и предусмотрите запуск доктестов при попытке запус тить модуль. Для реализации каждой функции потребуется от трех до пяти строк программного кода, а с учетом доктестов общий раз мер модуля не должен превышать 25 строк. Решение приводится в файле Ascii.py. 3. Создайте новый файл модуля и определите в нем класс Atomic ме неджера контекста. Этот класс должен работать подобно классу AtomicList , демонстрировавшемуся в этой главе, за исключением того, что он должен работать не только со списками, но и с любыми типами изменяемых коллекций. Метод __init__() должен прове рять тип контейнера, и, вместо того чтобы хранить флаг выбора ме жду поверхностной и глубокой копиями, он должен в зависимости от значения флага присваивать соответствующую функцию атрибу ту self.copy и вызывать функцию копирования в методе __enter__(). Метод __exit__() имеет немного более сложную реализацию, потому что замена содержимого списка выполняется иначе, чем замена со держимого словарей и множеств, и здесь нельзя использовать инст рукцию присваивания, потому что она никак не отразится на ори гинальном контейнере. Определение самого класса можно уместить примерно в тридцать строк, однако вам необходимо также добавить доктесты. Решение приводится в файле Atomic.py, длина которого составляет около ста пятидесяти строк, включая доктесты. 9 Процессы и потоки С тех пор, как многоядерные процессоры получили широкое распро странение, еще более важной и более практически значимой стала проблема распределения вычислительной нагрузки таким образом, чтобы получить максимальную отдачу от всех имеющихся ядер. На практике используются два основных подхода к распределению на грузки. Один из них заключается в одновременном выполнении не скольких процессов, а другой – в одновременном выполнении не скольких потоков управления. В этой главе будет продемонстрирова но, как использовать оба подхода. Преимущество выполнения нескольких процессов, то есть запуск ав тономных программ, заключается в том, что каждый процесс работает независимо от других. Тем самым все бремя разрешения конфликтов ложится на операционную систему. Недостаток такого подхода заклю чается в неудобстве организации взаимодействий и совместного ис пользования данных между вызывающей программой и отдельными процессами. В системах UNIX запуск отдельных процессов может вы полняться с использованием парадигмы ветвления процессов, но для кроссплатформенных программ должно использоваться другое реше ние. Простейшее решение, которое будет показано здесь, заключается в том, что вызывающая программа передает данные запускаемым ею процессам и оставляет за ними возможность самостоятельно воспроиз вести результаты. Наиболее гибкое решение, существенно упрощаю щее двусторонний обмен данными, заключается в использовании ме ханизмов сетевых взаимодействий. Конечно, во многих ситуациях в таком взаимодействии нет никакой необходимости, когда вполне достаточно запустить одну или более программ с помощью управляю щей программы. Альтернативой выполнению работы независимыми процессами явля ется создание многопоточных программ, которые распределяют рабо • Делегирование работы процессам • Делегирование работы потокам 468 Глава 9. Процессы и потоки ту между независимыми потоками выполнения. Преимуществом та кого подхода является простота совместного использования данных (если при этом гарантируется, что в каждый конкретный момент вре мени доступ к данным имеет только один поток выполнения), но в этом случае все бремя разрешения конфликтов ложится на плечи программиста. В языке Python имеется отличная поддержка создания многопоточных программ, позволяющая свести к минимуму работу, которую нам необходимо выполнить самостоятельно. Тем не менее многопоточные программы намного сложнее однопоточных программ и требуют значительно большей аккуратности при их создании и со провождении. В первом разделе этой главы мы создадим две маленькие программы. Первая программа будет запускаться пользователем, а вторая – пер вой программой, причем вторая программа будет запускаться в виде отдельного процесса. Второй раздел начнется с введения в многопоточ ное программирование. После этого мы создадим многопоточную про грамму, реализующую ту же функциональность, что и две программы из первого раздела, с целью продемонстрировать различия между ре шениями, основанными на использовании нескольких процессов и не скольких потоков выполнения. А затем мы рассмотрим еще одну мно гопоточную программу, более сложную, чем первую, которая выпол няет работу несколькими потоками и собирает воедино полученные результаты. Делегирование работы процессам В определенных ситуациях программы с необходимой функциональ ностью уже имеются, и требуется автоматизировать их использова ние. Сделать это можно с помощью модуля subprocess, который предос тавляет средства запуска других программ, передачи им любых пара метров командной строки и в случае необходимости – возможность об мена данными с ними с помощью каналов. Один очень простой пример такой программы мы уже видели в главе 5, когда использовали функ цию subprocess.call() для очистки консоли способом, зависящим от типа платформы. Однако эти средства могут также использоваться для создания пар программ «родительпотомок», в которых родитель ская программа запускается пользователем, а она в свою очередь за пускает столько экземпляров дочерней программы, сколько потребу ется, причем каждой выдается отдельное задание. Именно этот прием мы рассмотрим в данном разделе. В главе 3 мы рассматривали очень простую программу grepword.py, ко торая отыскивает слово, указанное в командной строке, в файлах, имена которых перечисляются вслед за словом. В этом разделе мы раз работаем более сложную версию, способную рекурсивно отыскивать файлы во вложенных подкаталогах и делегировать работу дочерним процессам, число которых зависит от наших потребностей. На экран Делегирование работы процессам 469 будет выводиться простой список имен файлов (с путями), в которых будет обнаружено искомое слово. Родительская программа находится в файле grepwordp.py, а дочерняя – в файле grepwordpchild.py. Взаимоотношения между этими двумя программами во время работы схематически изображены на рис. 9.1. Основу программы grepwordp.py составляет функция main(), которую мы рассмотрим, разделив ее на три части: def main(): child = os.path.join(os.path.dirname(__file__), "grepwordpchild.py") opts, word, args = parse_options() filelist = get_files(args, opts.recurse) files_per_process = len(filelist) // opts.count start, end = 0, files_per_process + (len(filelist) % opts.count) number = 1 Функция начинается с определения имени дочерней про граммы. Затем сохраняются параметры командной стро ки, полученные от пользователя. Функция parse_opti ons() в своей работе использует модуль optparse. Она воз вращает именованный кортеж opts, который определя ет, должна ли программа рекурсивно выполнять поиск во вложенных подкаталогах, и количество используе мых процессов (значение по умолчанию 7), максималь ное количество которых было выбрано нами произволь но и равно 20. Кроме того, функция возвращает искомое слово и список имен (файлов и каталогов), полученных из командной строки. Функция get_files() возвращает список файлов, которые необходимо прочитать. Получив все сведения, необходимые для решения задачи, мы опреде ляем, сколько файлов должно быть обработано каждым из процессов. Переменные start и end будут использоваться, чтобы определить часть списка filelist, которая будет передаваться очередному дочернему процессу для обработки. Едва ли стоит ожидать, что число файлов grepword p.py grepword p child.py grepword p child.py … Рис. 9.1. Родительская и дочерние программы Функция get_files() , стр. 399 470 Глава 9. Процессы и потоки будет кратным числу процессов, поэтому для первого дочернего про цесса число файлов увеличивается на величину остатка. Переменная number используется исключительно для нужд отладки – чтобы при вы воде результатов можно было видеть, какая строка какому процессу принадлежит. pipes = [] while start < len(filelist): command = [sys.executable, child] if opts.debug: command.append(str(number)) pipe = subprocess.Popen(command, stdin=subprocess.PIPE) pipes.append(pipe) pipe.stdin.write(word.encode("utf8") + b"\n") for filename in filelist[start:end]: pipe.stdin.write(filename.encode("utf8") + b"\n") pipe.stdin.close() number += 1 start, end = end, end + files_per_process Для каждой части start:end списка filelist создается командная стро ка, состоящая из имени выполняемого файла интерпретатора Python (которое хранится в атрибуте sys.executable), имени файла дочерней программы, которая должна быть запущена, и параметров командной строки – в данном случае просто номер дочернего процесса, при усло вии, что выполнение идет в отладочном режиме. Если файл дочерней программы содержит корректную строку «shebang» или в операцион ной системе настроена взаимосвязь между расширением имени файла и открывающей его программой, можно было бы сразу учесть эту ин формацию и не беспокоиться о включении имени выполняемого файла интерпретатора. Но мы предпочитаем приведенный подход, потому что он гарантирует, что дочерняя программа будет выполняться той же версией интерпретатора Python, что и родительская. Получив строку с командой, мы создаем объект subprocess.Popen, пере даем ему эту команду для выполнения (в виде списка строк) и в дан ном случае задаем возможность записывать данные в поток стандарт ного ввода процесса. (Точно так же возможно читать данные из потока стандартного вывода, определив похожий именованный аргумент.) После этого мы записываем в поток стандартного ввода процесса иско мое слово, сопровождая его символом перевода строки, а затем все имена файлов из соответствующей части списка filelist. Модуль sub process читает и записывает байты, а не строки, поэтому мы должны кодировать строки при записи (и декодировать байты при чтении), ис пользуя соответствующую кодировку, и в данном случае мы выбрали UTF8. Закончив передачу списка файлов дочернему процессу, мы за крываем поток стандартного ввода и идем дальше. Нет никакой необходимости сохранять ссылку на каждый дочерний процесс (на каждой новой итерации цикла в переменную pipe будет за Делегирование работы процессам 471 писываться новая ссылка на объект subprocess.Popen), потому что каж дый процесс выполняется независимо, но мы сохраняем их в списке, чтобы иметь возможность прерывать их работу. Кроме того, мы не вы полняем сбор информации, которая выводится дочерними процесса ми; вместо этого мы позволяем им выполнять вывод результатов на консоль. Это означает, что результаты, выводимые несколькими про цессами, могут выводиться вперемешку. (В упражнениях вам будет предоставлена возможность предотвратить такое перемешивание.) while pipes: pipe = pipes.pop() pipe.wait() После запуска всех дочерних процессов мы ожидаем, пока каждый из них завершит свою работу. В UNIXсистемах это гарантирует такую, может быть, не очень существенную особенность: после того как все процессы завершатся, управление передается командной строке (в про тивном случае нам было бы необходимо нажать клавишу Enter после завершения всех процессов). Другое преимущество такого подхода со стоит в следующем: если работа программы прерывается (например, нажатием комбинации клавиш Ctrl+C), то все дочерние процессы, кото рые к этому моменту продолжают работу, будут прерваны и заверше ны с исключением KeyboardInterrupt, которое нельзя перехватить. В противном случае главная программа завершится (потеряв возмож ность прерывать работу дочерних процессов) и дочерние процессы про должат работу (пока их не остановит команда kill или менеджер за дач). Ниже приводится полный программный код (за исключением коммен тариев и инструкций import) дочерней программы grepwordpchild.py, разбитый на две части: BLOCK_SIZE = 8000 number = "{0}: ".format(sys.argv[1]) if len(sys.argv) == 2 else "" word = sys.stdin.readline().rstrip() Программа начинается с того, что запоминает свой номер или пустую строку, если она выполняется не в отладочном режиме. После этого она читает первую строку, содержащую искомое слово. Эта и вся по следующая информация читается как строки. for filename in sys.stdin: filename = filename.rstrip() previous = "" try: with open(filename, "rb") as fh: while True: current = fh.read(BLOCK_SIZE) if not current: break 472 Глава 9. Процессы и потоки current = current.decode("utf8", "ignore") if (word in current or word in previous[len(word):] + current[:len(word)]): print("{0}{1}".format(number, filename)) break if len(current) != BLOCK_SIZE: break previous = current except EnvironmentError as err: print("{0}{1}".format(number, err)) Все строки, кроме первой, являются именами файлов (с путями). Для каждого имени программа открывает соответствующий файл, читает его содержимое и выводит имя файла, если в нем обнаруживается ис комое слово. Вполне возможно, что некоторые файлы могут иметь очень большой размер, что может привести к проблеме нехватки памя ти, особенно, когда параллельно выполняются 20 дочерних процессов и все они читают большие файлы. Мы ликвидируем эту проблему, чи тая каждый файл блоками, всякий раз сохраняя предыдущий прочи танный блок, чтобы гарантировать, что учтен случай попадания един ственного вхождения искомого слова на границу двух блоков. Еще од но преимущество чтения файлов блоками состоит в том, что если иско мое слово находится в начале файла, мы можем завершить его чтение, не читая весь файл целиком, поскольку нам достаточно знать, что сло во присутствует в файле, и не важно, в каком месте внутри файла оно встречается. Чтение файлов выполняется в двоичном режиме, поэто му мы должны преобразовывать каждый блок в строку, прежде чем можно будет выполнять поиск, так как ис комое слово является строкой. Мы исходим из предполо жения, что во всех файлах содержится текст в кодиров ке UTF8, но в некоторых случаях это предположение может оказаться неверным. Более сложная версия про граммы могла бы сначала пытаться определить фактиче скую кодировку, затем закрывать файл и повторно от крывать его уже с корректной кодировкой. Как уже от мечалось в главе 2, существует по меньшей мере два па кета автоматического определения кодировки файлов, которые доступны в каталоге пакетов Python Package In dex, pypi.python.org/pypi. (Может показаться заманчи вым декодировать искомое слово в объект bytes и сравни вать объект bytes с объектом bytes, но такой прием не да ет полной надежности, так как некоторые символы могут иметь более одного допустимого представления в коди ровке UTF8.) Kодировки символов, стр. 112 Делегирование работы потокам выполнения 473 Модуль subprocess предлагает гораздо более широкие возможности, чем было использовано здесь, включая эквиваленты обратным апост рофам и конвейерам командной оболочки, а также функциям os.sys tem() и функциям порождения дочерних процессов. В следующем разделе мы рассмотрим многопоточную версию програм мы grepwordp.py, благодаря чему мы сможем сравнить ее с версией, которая запускает дочерние процессы. Мы также увидим более слож ную многопоточную программу, которая распределяет работу между потоками выполнения и собирает результаты воедино, что дает боль ший контроль над тем, как они будут выводиться. Делегирование работы потокам выполнения Создание двух или более потоков выполнения в языке Python произво дится достаточно просто. Сложности появляются, когда возникает не обходимость использовать одни и те же данные в разных потоках. Представьте, что два потока совместно пользуются одним и тем же списком. Один поток может приступить к выполнению итераций по списку, используя инструкцию for x in L, а второй поток в это же вре мя удаляет несколько элементов гденибудь в середине списка. В луч шем случае это будет приводить к неожиданному аварийному завер шению программы, а в худшем – к получению неверных результатов. Одно из типичных решений этой проблемы заключается в использова нии механизма блокировки. Например, один поток может сначала по лучить блокировку и только потом начинать итерации по списку – в это время любой другой поток будет заблокирован, ожидая снятия блокировки. В действительности все не так просто. Связь между бло кировкой и данными, которые она запирает, существует только в на шем сознании. Если один поток уже получил блокировку и ее попыта ется запросить второй поток, он окажется заблокированным, пока первый поток не освободит блокировку. Реализуя доступ к данным че рез получение блокировки, мы можем гарантировать, что в каждый конкретный момент доступ к данным будет иметь только один поток, хотя сама защита носит косвенный характер. Одна из проблем, связанных с блокировками, заключается в том, что существует риск появления ситуации взаимоблокировки. Предполо жим, что поток №1 получает блокировку A, получая доступ к элемен ту совместно используемых данных a, а затем пытается получить бло кировку B, чтобы получить возможность доступа к элементу совмест но используемых данных b, но не может этого сделать, потому что в это же время поток №2 уже имеет блокировку B для доступа к элементу совместно используемых данных b и в свою очередь пытается получить блокировку A, чтобы получить доступ к данным a. То есть поток №1, имея блокировку A, пытается получить блокировку B, в то время как поток №2 , имея блокировку B, пытается получить блокировку A. |