Работа с упакованными двоичными данными с помощью модуля struct
Используя символ b в аргументе режима функции open, вы получаете возможность открывать двоичные файлы с данными платформонезависимым способом, а также читать и записывать их содержимое с помощью обычных методов объекта файла. Но как обрабатывать двоичные данные после того, как они будут прочитаны? Эти данные будут возвращены сценарию в виде простой строки байтов, большая часть из которых наверняка будет соответствовать непечатаемым символам.
Если нужно лишь переписать двоичные данные в другой файл или передать другой программе, тогда все просто - достаточно записать строку байтов в другой файл, открытый в двоичном режиме. Если потребуется извлечь некоторое количество байтов из определенной позиции, можно воспользоваться операцией извлечения среза строки. При необходимости можно даже использовать битовые операции. Кроме того, имеется мощный инструмент, позволяющий получить двоичные данные в структурированном виде или сконструировать их, - модуль struct из стандартной библиотеки.
В модуле struct имеются функции для упаковывания и распаковывания двоичных данных, как если бы данные были созданы с помощью объявления struct языка C. Имеется возможность при упаковывании и распаковывании данных учитывать прямой или обратный порядок следования байтов (порядок следования байтов определяет, где будут находиться старшие значимые биты в двоичном представлении чисел, - слева или справа). Создание двоичного файла с данными, например, - достаточно простая задача: нужно упаковать значения языка Python в строку байтов и записать ее в файл. Строка формата в вызове pack ниже определяет: прямой порядок следования байтов (>), целое число, 4-символьную строку, короткое целое число и вещественное число:
>>> import struct
>>> data = struct.pack('>i4shf', 2, 'spam’, 3, 1.234)
>>> data
b’\x00\x00\x00\x02spam\x00\x03?\x9d\xf3\xb6’
>>> file = open('data.bin’, 'wb’)
>>> file.write(data)
14
>>> file.close()
Обратите внимание, что модуль struct возвращает строку байтов: сейчас мы находимся в царстве двоичных данных, а не текста, и для сохранения должны использовать двоичные файлы. Как обычно, интерпретатор отображает большую часть байтов с упакованными двоичными данными, которые не соответствуют печатаемым символам, в виде шестнадцатеричных экранированных последовательностей \xNN. Чтобы выполнить обратное преобразование этих данных, нужно прочитать их из файла и передать модулю struct с той же строкой формата, как и при создании, - в результате получится кортеж значений, полученных в результате анализа строки байтов и преобразованных в объекты языка Python:
>>> import struct
>>> file = open('data.bin’, 'rb’)
>>> data = file.read()
>>> values = struct.unpack('>i4shf’, data)
>>> values
(2, b’spam’, 3, 1.2339999675750732)
Анализируемые строки - это также строки байтов, и к ним допускается применять строковые и битовые операции для более глубокого анализа:
>>> bin(values[0] | 0b1) # доступ к битам и байтам
‘0b11’
>>> values[1], list(values[1]), values[1][0]
(b’spam’, [115, 112, 97, 109], 115)
Обратите также внимание, что здесь может пригодиться операция извлечения среза. 4-символьную строку из середины только что прочитанных упакованных двоичных данных легко получить, используя операцию извлечения среза. Числовые значения также можно извлекать подобным способом и передавать функции struct.unpack для преобразования:
>>> data
b’\x00\x00\x00\x02spam\x00\x03?\x9d\xf3\xb6’
>>> data[4:8]
b’spam’
>>> number = data[8:10]
>>> number
b’\x00\x03’
>>> struct.unpack('>h’, number)
(3,)
Упакованные двоичные данные бывают получены из самых разных контекстов, включая некоторые виды сетевых взаимодействий и представление данных другими языками программирования. Однако все это не относится к разряду повседневных задач программирования, поэтому оставим описание подробностей за разделом с описанием модуля struct в руководстве по стандартной библиотеке Python.
Произвольный доступ к данным в файлах
При работе с двоичными файлами часто также применяется операция произвольного доступа. Ранее упоминалось, что добавление символа + в строку режима открытия файла позволяет выполнять обе операции, чтения и записи. Этот режим обычно используется вместе с методом seek объектов файлов, позволяющим выполнять чтение/запись произвольных участков файла. Такие гибкие режимы обработки файлов позволяют читать байты из одного места, записывать в другое и так далее. При объединении этих режимов с двоичным режимом появляется возможность извлекать и изменять произвольные байты в файле.
Выше для перехода в начало файла вместо операций закрытия файла и повторного его открытия использовался метод seek. Как уже упоминалось, операции чтения и записи всегда выполняются в текущей позиции в файле. При открытии файлов текущая позиция обычно устанавливается в смещение 0 от начала файла и перемещается вперед по мере чтения/записи данных. Метод seek позволяет переместить текущую позицию для следующей операции чтения/записи в другое место, для чего ему достаточно передать величину смещения в байтах.
Метод seek в языке Python принимает также второй необязательный аргумент, который определяет физический смысл первого аргумента и может принимать одно из трех значений: 0 - абсолютная позиция в файле (по умолчанию), 1 - смещение относительно текущей позиции и 2 - смещение относительно конца файла. Когда методу seek передается только аргумент смещения 0, это соответствует операции перемотки файла в начало (rewind): текущая позиция перемещается в начало файла. Вообще, метод seek поддерживает произвольный доступ на уровне смещения в байтах. Используя в качестве множителя размер записи в двоичном файле, можно организовать доступ к записям по их относительным позициям.
Метод seek можно использовать и без спецификатора + в строке режима для функции open (например, чтобы просто обеспечить произвольное чтение данных), но наибольшая гибкость достигается при работе с файлами, открытыми для чтения и записи. Возможность произвольного доступа поддерживается и для файлов, открытых в текстовом режиме. Но выполняющиеся в текстовом режиме операции кодирования/деко-дирования Юникода и преобразования символов конца строки сильно осложняют вычисление абсолютных смещений в байтах и длин, необходимых методам позиционирования и чтения, - представление ваших данных может значительно измениться при сохранении в файл. Кроме того, применение текстового режима может также ухудшить переносимость данных между платформами, где по умолчанию используются различные кодировки, если только вы не предполагаете всегда явно указывать кодировку файлов. Метод seek лучше подходит для работы с двоичными файлами; исключение составляет простой некодируемый текст ASCII, в котором отсутствуют символы конца строки.
Для демонстрации создадим файл в режиме “w+b” (эквивалент режима ‘wb+’) и запишем в него некоторые данные - этот режим позволяет читать из файла и писать в него и создает новый пустой файл, если он существовал прежде (это относится ко всем режимам “w”). После записи данных мы вернемся в начало файла и прочитаем его содержимое (несколько целочисленных значений, возвращаемых вызовами методов в этом примере, было опущено ради экономии места):
>>> records = [bytes([char] * 8) for char in b’spam’]
>>> records
[b’ssssssss’, b’pppppppp’, b’aaaaaaaa’, b’mmmmmmmm’]
>>> file = open('random.bin’, 'w+b’)
>>> for rec in records: # запиcать четыре записи
... size = file.write(rec) # bytes означает двоичный режим
>>> file.flush()
>>> pos = file.seek(0) # прочитать файл целиком
>>> print(file.read())
b’ssssssssppppppppaaaaaaaammmmmmmm’
Теперь повторно откроем файл в режиме “r+b” - он также позволяет читать из файла и писать в него, но не очищает файл при открытии. На этот раз мы будем выполнять позиционирование и чтение с учетом размеров элементов данных («записей»), чтобы показать возможность получения и изменения записей в произвольном порядке:
c:\temp> python
>>> file = open('random.bin’, 'r+b’)
>>> print(file.read()) # прочитать файл целиком
b’ssssssssppppppppaaaaaaaammmmmmmm’
>>> record = b’X’ * 8
>>> file.seek(0) # изменить первую запись
>>> file.write(record)
>>> file.seek(len(record) * 2) # изменить третью запись
>>> file.write(b’Y’ * 8)
>>> file.seek(8)
>>> file.read(len(record)) # извлечь вторую запись
b’pppppppp’
>>> file.read(len(record)) # извлечь следующую (третью) запись
b’YYYYYYYY’
>>> file.seek(0) # прочитать файл целиком
>>> file.read()
b’XXXXXXXXppppppppYYYYYYYYmmmmmmmm’
c:\temp> type random.bin # посмотреть файл за пределами Python
XXXXXXXXppppppppYYYYYYYYmmmmmmmm
Наконец, имейте в виду, что метод seek можно использовать, даже если файл открыт только для чтения. Следующий пример демонстрирует возможность чтения произвольных записей фиксированной длины. Обратите внимание, что при этом используется текстовый режим “г”: поскольку данные представляют собой простой текст ASCII, где каждый символ представлен одним байтом, и текст не содержит символов конца строки, на данной платформе текстовый и двоичный режимы действуют одинаково:
c:\temp> python
>>> file = open('random.bin’, 'r’) # текстовый режим можно использовать, если
# не выполняется кодирование и отсутствуют
# символы конца строки
>>> reclen = 8
>>> file.seek(reclen * 3) # извлечь четвертую запись
>>> file.read(reclen)
‘mmmmmmmm’
>>> file.seek(reclen * 1) # извлечь вторую запись
>>> file.read(reclen)
‘pppppppp’
>>> file = open('random.bin’, 'rb’) # в данном случае двоичный режим действует
# точно так же
>>> file.seek(reclen * 2) # извлечь третью запись
>>> file.read(reclen) # вернет строку байтов
b’YYYYYYYY’
Но в общем случае текстовый режим не следует использовать, если вам требуется произвольный доступ к записям (за исключением файлов с простым некодируемым текстом, подобным ASCII, не содержащим символов конца строки). Символы конца строки могут преобразовываться в Windows, а применение кодировок Юникода может вносить различные искажения - оба эти преобразования существенно осложняют возможность позиционирования по абсолютному смещению. Например, в следующем фрагменте соответствие между строкой Python и ее кодированным представлением в файле нарушается сразу же за первым не-ASCII символом:
>>> data = 'sp\xe4m’ # данные в сценарии
>>> data, len(data) # 4 символа Юникода,
(‘spam’, 4) # 1 символ не-ASCII
>>> data.encode('utf8’), len(data.encode('utf8’)) # байты для записи в файл (b’sp\xc3\xa4m’, 5)
>>> f = open('test’, mode=’w+’, encoding=’utf8’) # текст. режим, кодирование >>> f.write(data)
>>> f.flush()
>>> f.seek(0); f.read(1) # работает для байтов ascii
‘s’
>>> f.seek(2); f.read(1) # 2-байтовый не-ASCII
‘ a’
>>> data[3] # а в смещении 3 - не ‘m’ !
‘m’
>>> f.seek(3); f.read(1)
UnicodeDecodeError: ‘utf8’ codec can’t decode byte 0xa4 in position 0: unexpected code byte
(UnicodeDecodeError: кодек ‘utf8’ не может преобразовать байт 0xa4 в позиции 0: неопознанный код)
Как видите, режимы открытия файлов в Python обеспечивают необходимую гибкость при работе с файлами в программах. А модуль os предлагает еще более широкие возможности для обработки файлов, которые представлены в следующем разделе.
Низкоуровневые инструменты в модуле os для работы с файлами
Модуль os содержит дополнительный набор функций для работы с файлами, отличных от инструментов, которыми располагают встроенные объекты файлов, демонстрировавшиеся в предыдущих примерах. Например, ниже приводится неполный список функций в модуле os, имеющих отношение к файлам:
os.open(path, flags, mode)
Открывает файл, возвращает его дескриптор
os.read(descriptor N)
Читает не более N байтов и возвращает строку байтов
os.write(descriptor, string)
Записывает в файл байты из строки байтов string os.lseek(descriptor, position, how)
Перемещается в позицию position в файле
С технической точки зрения, функции из модуля os обрабатывают файлы по их дескрипторам, которые представляют собой целочисленные коды или «описатели» (handles), идентифицирующие файлы в операционной системе. Файлы, представленные дескрипторами, интерпретируются как обычные двоичные файлы, к которым не применяются ни преобразование символов конца строки, ни кодирование текста, о которых рассказывалось в предыдущем разделе. Фактически, за исключением отдельных особенностей, таких как буферизация, операции с файлами, представленными дескрипторами, мало чем отличаются от операций, поддерживаемых объектами файлов для двоичного режима. При работе с такими файлами мы также читаем и пишем строки типа bytes, а не str. Однако так как инструменты для работы с файлами с использованием дескрипторов, представленные в модуле os, - более низкого уровня и более сложны в применении, чем встроенные объекты файлов, создаваемые с помощью встроенной функции open, то следует использовать последние во всех ситуациях, за исключением отдельных случаев специальной обработки файлов.11
Использование файлов, возвращаемых os.open
Чтобы дать вам общее представление об этом наборе инструментов, проведем несколько интерактивных экспериментов. Встроенные объекты файлов и файловые дескрипторы модуля os обрабатываются различными наборами инструментов, но в реальности они связаны между собой - объекты файлов просто добавляют дополнительную логику поверх дескрипторов файлов.
Метод fileno объекта файла возвращает целочисленный дескриптор, ассоциированный со встроенным объектом файла. Например, объекты файлов стандартных потоков ввода-вывода имеют дескрипторы 0, 1 и 2; вызов функции os.write для отправки данных в stdout по дескриптору дает тот же эффект, что и вызов метода sys.stdout.write:
>>> import sys
>>> for stream in (sys.stdin, sys.stdout, sys.stderr):
... print(stream.fileno())
0
1
2
>>> sys.stdout.write('Hello stdio world\n’) # записать с помощью метода Hello stdio world # объекта файла
18
>>> import os
>>> os.write(1, b’Hello descriptor world\n’) # записать с помощью модуля os Hello descriptor world 23
Поскольку объекты файлов, открываемые явно, ведут себя точно так же, с одинаковым успехом для обработки конкретного внешнего файла на компьютере можно использовать встроенную функцию open, инструменты из модуля os или и то и другое вместе:
>>> file = open(r’C:\temp\spam.txt’, 'w’) # создать внешний файл, объект
>>> file.write('Hello stdio file\n’) # записать с помощью объекта файла
>>> file.flush() # или сразу - функции os.write
>>> fd = file.fileno() # получить дескриптор из объекта
>>> fd 3
>>> import os
>>> os.write(fd, b’Hello descriptor file\n’) # записать с помощью модуля os >>> file.close()
C:\temp> type spam.txt # строки, записанные
Hello stdio file # двумя способами
Hello descriptor file
Флаги режима os.open
Зачем же нужны дополнительные файловые средства в модуле os? Если вкратце, то они обеспечивают более низкоуровневое управление обработкой файлов. Встроенная функция open проста в использовании, но она ограничена возможностями файловой системы, которую использует, и добавляет некоторые дополнительные особенности, которые могут быть нежелательны. Модуль os позволяет сценариям быть более точными; например, следующий фрагмент открывает дескриптор файла в двоичном режиме для чтения-записи, выполняя битовую операцию «ИЛИ» над двумя флагами режима, экспортируемыми модулем os:
>>> fdfile = os.open(r’C:\temp\spam.txt’, (os.O_RDWR | os.O_BINARY))
>>> os.read(fdfile, 20)
b’Hello stdio file\r\nHe’
>>> os.lseek(fdfile, 0, 0) # вернуться в начало файла
>>> os.read(fdfile, 100) # в двоичном режиме сохраняются “\r\n”
b’Hello stdio file\r\nHello descriptor file\n’
>>> os.lseek(fdfile, 0, 0)
>>> os.write(fdfile, b’HELLO’) # перезаписать первые 5 байтов 5
C:\temp> type spam.txt
HELLO stdio file Hello descriptor file
В данном случае эквивалентный режим открытия с помощью встроенной функции open определяется строками “rb+” и “r+b”:
>>> file = open(r’C:\temp\spam.txt’, 'rb+’) # то же самое, но с помощью open >>> file.read(20) # и объектов файлов
b’HELLO stdio file\r\nHe’
>>> file.seek(0)
>>> file.read(100)
b’HELLO stdio file\r\nHello descriptor file\n’
>>> file.seek(0)
>>> file.write(b’Jello’)
5
>>> file.seek(0)
>>> file.read()
b’Jello stdio file\r\nHello descriptor file\n’
В некоторых системах флаги для функции os.open позволяют указывать более сложные режимы - например, исключительный доступ (O_EXCL) и неблокирующий режим (O_NONBLOCK). Некоторые из этих флагов не переносимы между платформами (еще одна причина в пользу встроенных объектов файлов). Найти полный список других флагов открытия можно в руководстве по библиотеке или вызвав на своем компьютере функцию dir(os).
И последнее замечание: в Python использование функции os.open с флагом O_EXCL на сегодняшний день является наиболее переносимым способом исключить возможность параллельного изменения файла или обеспечить синхронизацию с другими процессами. Где может использоваться эта особенность, мы увидим в следующей главе, когда приступим к исследованию инструментов параллельной обработки данных. Программам, параллельно выполняющимся на сервере, к примеру, может потребоваться устанавливать блокировку на файлы, прежде чем изменять их, если подобные изменения могут одновременно запрашиваться несколькими потоками выполнения или процессами.
Обертывание дескрипторов объектами файлов
Ранее было показано, как перейти от использования объекта файла к использованию дескриптора с помощью метода объекта файла fileno, - получив дескриптор, мы можем использовать инструменты из модуля os для выполнения низкоуровневых операций с файлом. Но можно пойти и обратным путем - функция os.fdopen обертывает дескриптор файла объектом файла. Поскольку преобразования могут выполняться в обоих направлениях, мы можем выбирать любой набор инструментов -объект файла или модуль os:
>>> fdfile = os.open(r’C:\temp\spam.txt’, (os.O_RDWR | os.O_BINARY))
>>> fdfile
3
>>> objfile = os.fdopen(fdfile, 'rb’)
>>> objfile.read()
b’Jello stdio file\r\nHello descriptor file\n’
Фактически мы можем обернуть дескриптор файла любым объектом файла, открытым в текстовом или в двоичном режиме. В текстовом режиме операции чтения и записи будут производить кодирование/деко-дирование Юникода и преобразование символов конца строки, с которыми мы познакомились выше, и для работы с ними необходимо будет использовать строки типа str, а не bytes:
C:\...\PP4E\System> python >>> import os
>>> fdfile = os.open(r’C:\temp\spam.txt’, (os.O_RDWR | os.O_BINARY))
>>> objfile = os.fdopen(fdfile, 'r’)
>>> objfile.read()
‘Jello stdio file\nHello descriptor file\n’
Встроенная функция open в Python 3.X также может принимать дескриптор файла вместо строки с его именем. В этом режиме она действует практически так же, как функция os.fdopen, но обеспечивает более полный контроль. Например, можно использовать дополнительные аргументы, чтобы определить кодировку для текста и подавить операцию закрытия дескриптора, которая выполняется по умолчанию. Однако на практике функция os.fdopen в версии 3.X принимает те же дополнительные аргументы, потому что она была переопределена и теперь вызывает встроенную функцию open (смотрите файл os.py в стандартной библиотеке):
C:\...\PP4E\System> python >>> import os
>>> fdfile = os.open(r’C:\temp\spam.txt’, (os.O_RDWR | os.O_BINARY))
>>> fdfile
3
>>> objfile = open(fdfile, 'r’, encoding=’latin1’, closefd=False)
>>> objfile.read()
‘Jello stdio file\nHello descriptor file\n’
>>> objfile = os.fdopen(fdfile, 'r’, encoding=’latin1’, closefd=True)
>>> objfile.seek(0)
>>> objfile.read()
‘Jello stdio file\nHello descriptor file\n’
Далее в книге мы будем использовать этот прием обертывания в объекты файлов, чтобы упростить работу в текстовом режиме с каналами и другими объектами на основе дескрипторов (например, сокеты обладают методом makefile, позволяющим добиться похожего эффекта).
Другие инструменты для работы с файлами в модуле os
В модуле os имеется также ряд инструментов для работы с файлами, которые принимают строку пути к файлу и выполняют ряд операций, связанных с файлами, таких как переименование (os.rename), удаление (os.remove) и изменение владельца файла и прав доступа к нему (os. chown, os.chmod). Рассмотрим несколько примеров использования этих инструментов:
>>> os.chmod('spam.txt’, 0o777) # разрешить доступ всем пользователям
Функции os.chmod установки прав доступа к файлу передается строка из девяти битов, состоящая из трех групп, по три бита в каждой. Эти три группы определяют права доступа, слева направо, для пользователя-владельца файла, для группы пользователей, которой принадлежит файл, и для всех остальных. Три бита внутри каждой группы отражают право на чтение, на запись и на выполнение. Если какой-то бит в этой строке равен «1», это означает разрешение на выполнение соответствующей операции. Например, восьмеричное число 0777 является строкой из девяти единичных битов в двоичном представлении и разрешает все три вида доступа для всех трех групп пользователей; восьмеричное число 0600 означает возможность только чтения и записи для пользователя, который владеет файлом (восьмеричное число 0600 в двоичной записи дает 110 000 000).
Эта схема ведет свое происхождение от системы прав доступа в Unix, но работает также в Windows. Если она вас озадачила, посмотрите описание команды chmod в документации по вашей системе (например, в страницах руководства Unix). Идем дальше:
>>> os.rename(r’C:\temp\spam.txt’, r’C:\temp\eggs.txt’) # откуда, куда
>>> os.remove(r’C:\temp\spam.txt’) # удалить файл?
WindowsError: [Error 2] The system cannot find the file specified: ‘C:\\ temp\\...’
(WindowsError: [Error 2] Системе не удается найти указанный путь: C:\\ temp\\...)
>>> os.remove(r’C:\temp\eggs.txt’)
Использованная здесь функция os.rename изменяет имя файла; функция os.remove удаляет файл, она синонимична функции os.unlink (имя последней - имя, которое имеет эта функция в Unix, но оно не знакомо пользователям других платформ)12. Модуль os также экспортирует системный вызов stat:
>>> open('spam.txt’, 'w’).write('Hello stat world\n’) # +1 для символа \r 17
>>> import os
>>> info = os.stat(r’C:\temp\spam.txt’)
>>> info
nt.stat_result(st_mode=33206, st_ino=0, st_dev=0, st_nlink=0, st_uid=0, st_gid=0, st_size=18, st_atime=1267645806, st_mtime=1267646072, st_ ctime=1267645806)
>>> info.st_mode, info.st_size # через атрибуты именованного кортежа (33206, 18)
>>> import stat
>>> info[stat.ST_MODE], info[stat.ST_SIZE] # через константы в модуле stat
(33206, 18)
>>> stat.S_ISDIR(info.st_mode), stat.S_ISREG(info.st_mode)
(False, True)
Функция os.stat возвращает кортеж величин (в версии 3.X это особая разновидность кортежа, элементы которого имеют имена), представляющих низкоуровневую информацию о файле с указанным именем, а модуль stat экспортирует константы и функции для получения этой информации переносимым способом. Например, значение, получаемое из результата функции os.stat по индексу stat.ST_SIZE, соответствует размеру файла, а вызов функции stat.S_ISDIR с параметром «режим», полученным из результата функции os.stat, позволяет проверить, является ли файл каталогом. Однако, как было показано выше, обе эти операции доступны и в модуле os.path, поэтому на практике редко возникает необходимость использовать функцию os.stat; исключение составляют низкоуровневые запросы:
>>> path = r’C:\temp\spam.txt’
>>> os.path.isdir(path), os.path.isfile(path), os.path.getsize(path)
(False, True, 18)
Сканеры файлов
Прежде чем закончить обзор инструментов для работы с файлами, реализуем более практичную задачу и проиллюстрируем кое-что из того, что мы уже видели. В отличие от некоторых языков командной оболочки, в Python нет неявной процедуры циклического сканирования файла, но написать такую универсальную процедуру, пригодную для многократного использования, несложно. Модуль в примере 4.1 определяет универсальную процедуру сканирования файлов, которая просто применяет переданную в нее функцию к каждой строке внешнего файла.
Пример 4.1. PP4E\System\Filetools\scanfile.py
def scanner(name, function):
file = open(name, ‘r’) # создать объект файла
while True:
line = file.readline() # вызов методов файла if not line: break # до конца файла function(line) # вызвать объект функции
file.close()
Функции scanner безразлично, какая функция обработки строк в нее передана, чем и определяется ее универсальность: она готова применить любую функцию одного аргумента, уже существующую или которая может появиться в будущем, ко всем строкам в текстовом файле. Если реализацию этого модуля поместить в каталог, входящий в путь поиска модулей, им можно будет воспользоваться всякий раз, когда потребуется выполнить построчный обход файл. В примере 4.2 приводится клиентский сценарий, выполняющий простое преобразование строк.
Пример 4.2. PP4E\System\Filetools\commands.py
#!/usr/local/bin/python
from sys import argv
from scanfile import scanner
class UnknownCommand(Exception): pass
def processLine(line): # определить функцию,
if line[0] == ‘*’: # применяемую к каждой строке
print(“Ms.”, line[1:-1]) elif line[0] == ‘+’:
print(“Mr.”, line[1:-1]) # отбросить первый и последний символы
else:
raise UnknownCommand(line) # возбудить исключение
filename = ‘data.txt’
if len(argv) == 2: filename = argv[1] # аргумент командной строки с именем
scanner(filename, processLine) # файла запускает сканер
Для текстового файла hillbillies.txt:
*Granny +Jethro *Elly May +”Uncle Jed”
наш сценарий commands.py вернет следующие результаты:
C:\...\PP4E\System\Filetools> python commands.py hillbillies.txt
Ms. Granny Mr. Jethro Ms. Elly May Mr. “Uncle Jed”
Все работает, тем не менее существует множество альтернативных способов реализации обоих примеров, и какие-то из них могут предлагать более удачные решения. Например, процессор команд, представленный в примере 4.2, можно было бы реализовать, как показано ниже. Преимущества этой реализации становятся более очевидными с ростом обрабатываемых вариантов - управляемый данными подход может оказаться короче и проще в сопровождении, чем длинная инструкция if с избыточными, по сути, действиями (если вам когда-нибудь потребуется изменить способ вывода строк, в следующей реализации вы сможете сделать это, изменив всего одну строку):
commands = {‘*’: ‘Ms.’, ‘+’: ‘Mr.’} # данные изменять проще, чем код?
def processLine(line): try:
print(commands[line[0]], line[1:-1]) except KeyError:
raise UnknownCommand(line)
Сканер также можно было бы улучшить. Как правило, перемещение обработки из программного кода Python во встроенные инструменты приводит к увеличению скорости. Например, если скорость имеет большое значение, сканер файлов можно было бы сделать быстрее, заменив в примере 4.1 вызов функции readline итератором объекта файла (в эффективности которого вы уже имели возможность убедиться):
def scanner(name, function):
for line in open(name, ‘r’): # построчное сканирование
function(line) # вызов объекта функции
Еще больших чудес в примере 4.1 можно достичь с помощью таких инструментов итераций, как встроенная функция map, генераторы списков и выражения-генераторы. Ниже приводится минималистская версия. Цикл for замещается вызовом функции map или генератором, и Python сам закрывает файл на этапе сборки мусора или при выходе из сценария (в процессе обработки во всех реализациях создается список результатов, однако такое неэкономное расходование ресурсов вполне допустимо, за исключением очень больших файлов):
def scanner(name, function):
list(map(function, open(name, ‘r’)))
def scanner(name, function):
[function(line) for line in open(name, ‘r’)]
def scanner(name, function):
list(function(line) for line in open(name, ‘r’))
Фильтры файлов
Предыдущий пример работает, как предполагалось, но как быть, если во время сканирования файла нам потребуется файл изменить? В примере 4.3 показаны два подхода: в одном используются явные файлы, а в другом стандартные потоки ввода-вывода, которые можно перенаправить в командной строке.
Пример 4.3. PP4E\System\Filetools\filters.py
import sys
def filter_files(name, function): # фильтрация файлов через функцию
input = open(name, ‘r’) # создать объекты файлов
output = open(name + ‘.out’, ‘w’) # выходной файл
for line in input:
output.write(function(line)) # записать измененную строку input.close()
output.close() # выходной файл имеет расширение ‘.out’
def filter_stream(function): # отсутствуют явные файлы
while True: # использовать стандартные потоки
line = sys.stdin.readline() # или: input() if not line: break
print(function(line), end=’’) # или: sys.stdout.write()
if __name__ == ‘__main__’:
filter_stream(lambda line: line) # копировать stdin в stdout, если
# запущен как самостоятельный сценарий
Обратите внимание, что применение такой новейшей особенности, как менеджеры, контекста, обсуждавшейся выше, позволило бы сэкономить несколько строк программного кода в реализации фильтра из примера 4.3, опирающегося на использование файлов, и гарантировало бы немедленное закрытие файлов в случае появления исключения в функции обработки:
def filter_files(name, function):
with open(name, ‘r’) as input, open(name + ‘.out’, ‘w’) as output: for line in input:
output.write(function(line)) # записать измененную строку
И снова, применение итераторов объектов файлов позволило бы упростить реализацию фильтра на основе потоков ввода-вывода:
def filter_stream(function):
for line in sys.stdin: # автоматически выполняет построчное чтение
print(function(line), end=’’)
Поскольку стандартные потоки ввода-вывода открываются автоматически, они обычно проще в использовании. Если запустить этот пример, как самостоятельный сценарий, он просто скопирует stdin в stdout:
C:\...\PP4E\System\Filetools> filters.py < hillbillies.txt
*Granny +Jethro *Elly May +”Uncle Jed”
Однако этот модуль более полезен, когда он импортируется как библиотека (клиент предоставляет функцию обработки строк):
>>> from filters import filter_files
>>> filter_filesChillbillies.txt’, str.upper)
>>> print(open('hillbillies.txt.out’).read())
*GRANNY +JETHRO *ELLY MAY +”UNCLE JED”
В оставшейся части книги мы часто будем видеть примеры использования файлов, особенно в наиболее полных и практичных примерах системных программ в главе 6. Однако сначала познакомимся с инструментами обработки жилища наших файлов.
Инструменты для работы с каталогами
Одной из наиболее частых задач для утилит командной оболочки является применение операций к множеству файлов, находящихся в каталоге - «папке» на языке Windows. Сценарии, способные выполнять операции над группой файлов, позволяют автоматизировать (то есть программировать) задачи, которые в противном случае пришлось бы многократно повторять вручную.
Например, допустим, что нужно найти во всех файлах с программным кодом Python из каталога разработки имя глобальной переменной (вы могли забыть, где оно используется). Для каждой платформы существует множество способов решить эту задачу (например, команды find и grep в Unix), но сценарии Python, выполняющие такие задачи, будут работать на любой платформе, где работает Python, - в Windows, Unix, Linux, Macintosh и практически на любой другой распространенной платформе. Достаточно просто скопировать сценарий на любой компьютер, где предполагается его использовать, и он будет работать независимо от имеющихся на нем утилит, - для этого необходимо иметь лишь интерпретатор Python. Кроме того, программирование таких задач на языке Python позволяет по ходу дела выполнять любые действия - замену, удаление и любые другие, какие только можно реализовать на языке Python.
Обход одного каталога
Чаще всего при написании таких инструментов сначала получают список имен файлов, которые нужно обработать, а затем пошагово обходят его в цикле for, поочередно обрабатывая каждый файл. Весь фокус состоит в том, чтобы научиться получать в сценариях такой список содержимого каталога. Существует по меньшей мере три способа сделать это: выполнить команды оболочки для получения списка с помощью os.рорen, отыскать файлы по шаблону имени с помощью glob.glob и получить перечень содержимого каталога с помощью os.listdir. Эти способы различаются по интерфейсу, формату результата и переносимости.
Запуск команд получения списка содержимого каталога с помощью os.popen
Скажите-ка, как вы получали списки файлов в каталоге до того, как услышали о Python? Если у вас нет опыта работы с инструментами командной строки, ответ может быть следующим: «Ну, я запускал в Windows проводник и щелкал, куда нужно». Но здесь у нас речь идет о механизмах, менее ориентированных на графический интерфейс, то есть о механизмах командной строки.
Для получения списков файлов в Unix обычно используется команда ls; в Windows списки можно создавать вводом dir в окне консоли MS-DOS.
Поскольку сценарии Python могут выполнить любую команду оболочки с помощью os.popen, они являются самым универсальным способом получения содержимого каталога из программ на языке Python. Мы уже встречались с функцией os.popen в предыдущей главе - она выполняет команду оболочки и возвращает объект файла, из которого можно прочесть вывод команды. Для иллюстрации допустим сначала, что имеется следующая структура каталогов - на моем ноутбуке с Windows есть обе команды, dir и Unix-подобная ls из Cygwin:
c:\temp> dir /B
parts
PP3E
random.bin
spam.txt
temp.bin
temp.txt
c:\temp> c:\cygwin\bin\ls
PP3E parts random.bin spam.txt temp.bin temp.txt
c:\temp> c:\cygwin\bin\ls parts
part0001 part0002 part0003 part0004
Имена parts и PP3E являются здесь подкаталогами, вложенным в каталог C:\temp (последний из них является копией дерева каталогов с примерами для предыдущего издания книги, часть из которых я использовал в этом издании). Теперь мы знаем, что сценарии могут получать списки имен файлов и каталогов на этом уровне, просто запуская специфическую для платформы команду и читая полученный вывод (текст, обычно выводимый в окно консоли):
C:\temp> python >>> import os
>>> os.popen('dir /B').readlines()
[‘parts\n’, ‘PP3E\n’, ‘random.bin\n’, ‘spam.txt\n’, ‘temp.bin\n’, ‘temp.txt\n’]
Строки, возвращаемые командой оболочки, содержат замыкающий символ конца строки, но его легко можно отсечь. Кроме того, функция os.popen возвращает итератор, точно такой же, как итератор объектов файлов:
>>> for line in os.popen('dir /B'):
... print(line[:-1])
parts
PP3E
random.bin
spam.txt
temp.bin
temp.txt >>> lines = [line[:-1] for line in os.popen('dir /B')]
>>> lines
[‘parts’, ‘PP3E’, ‘random.bin’, ‘spam.txt’, ‘temp.bin’, ‘temp.txt’]
В случае объектов каналов действие итераторов может иметь еще более значимый эффект, чем просто уход от одновременной загрузки всех результатов в память: метод readlines всегда блокирует вызывающий процесс, пока не завершится порожденная программа, тогда как при использовании итераторов этого не происходит.
Обе команды, dir и ls, позволяют задавать требуемые образцы имен файлов и имен каталогов, список содержимого которых должен быть получен, с помощью шаблонов имен. В следующем примере мы снова просто выполняем команды оболочки, поэтому годится все, что можно ввести в командной строке:
>>> os.popen('dir *.bin /B').readlines()
[‘random.bin\n’, ‘temp.bin\n’]
>>> os.popen(r'c:\cygwin\bin\ls *.bin').readlines()
[‘random.bin\n’, ‘temp.bin\n’]
>>> list(os.popen(r'dir parts /B'))
[‘part0001\n’, ‘part0002\n’, ‘part0003\n’, ‘part0004\n’]
>>> [fname for fname in os.popen(r'c:\cygwin\bin\ls parts')]
[‘part0001\n’, ‘part0002\n’, ‘part0003\n’, ‘part0004\n’]
Эти вызовы используют универсальные инструменты и все действуют, как было заявлено. Однако выше отмечалось, что недостатками os.popen являются необходимость использования команд оболочки, специфических для платформы, и потеря производительности при запуске независимых программ. На практике различные инструменты могут возвращать различные результаты:
>>> list(os.popen(r'dir parts\part* /B'))
[‘part0001\n’, ‘part0002\n’, ‘part0003\n’, ‘part0004\n’]
>>>
>>> list(os.popen(r'c:\cygwin\bin\ls parts/part*'))
[‘parts/part0001\n’, ‘parts/part0002\n’, ‘parts/part0003\n’, ‘parts/part0004\n’]
Следующие два альтернативных приема проявляют себя лучше в обоих отношениях.
Модуль glob
Термин globbing (глобальный поиск по шаблону) происходит от группового символа *, используемого в шаблонах имен файлов. На компьютерном сленге символ * трактуется, как «glob» (группа символов). Более приземленно, глобальный поиск по шаблону просто означает получение имен всех элементов в каталоге - файлов и подкаталогов, имена которых соответствуют заданному шаблону. В командных оболочках Unix при глобальном поиске шаблоны имен файлов, указанные в командной строке, расширяются до всех совпадающих имен еще перед выполнением команды. В Python можно делать нечто похожее, вызывая встроенную функцию glob.glob, - инструмент, принимающий шаблон имени файла и возвращающий список (не генератор) имен файлов, соответствующих этому шаблону:
>>> import glob
>>> glob.glob('*')
[‘parts’, ‘PP3E’, ‘random.bin’, ‘spam.txt’, ‘temp.bin’, ‘temp.txt’]
>>> glob.glob('*.bin')
[‘random.bin’, ‘temp.bin’]
>>> glob.glob('parts')
[‘parts’]
>>> glob.glob('parts/*')
[‘parts\\part0001’, ‘parts\\part0002’, ‘parts\\part0003’, ‘parts\\part0004’]
>>> glob.glob('parts\part*')
[‘parts\\part0001’, ‘parts\\part0002’, ‘parts\\part0003’, ‘parts\\part0004’]
Для определения шаблонов в функции glob используется обычный синтаксис шаблонов имен файлов, используемый в командных оболочках: ? означает один любой символ, * означает любое число символов, а [] означает множество символов, доступных для выбора.13 Если поиск нужно осуществлять в каталоге, отличном от текущего рабочего каталога, в шаблон нужно включить путь к каталогу. Кроме того, модуль принимает разделители имен каталогов в стиле Unix или DOS (/ или \). Эта функция реализована так, что не вызывает команды оболочки (она использует функцию os.listdir, описываемую в следующем разделе) и потому должна выполняться быстрее и лучше переноситься на все платформы Python, чем показанные выше приемы с применением функции os.popen.
Вообще функция glob несколько мощнее, чем здесь описано. Получение списка файлов в каталоге является лишь одной из ее возможностей поиска по шаблону. Например, ее можно использовать для получения списка имен из нескольких каталогов, так как каждый уровень в передаваемом пути к каталогу также можно определить в виде шаблона:
>>> for path in glob.glob(r'PP3E\Examples\PP3E\*\s*.py'): print(path)
PP3E\Examples\PP3E\Lang\summer-alt.py
PP3E\Examples\PP3E\Lang\summer.py
PP3E\Examples\PP3E\PyTools\search_all.py
Здесь мы получили список имен файлов, соответствующих шаблону s*py, из двух разных каталогов. Так как в качестве имени предшествующего каталога был использован групповой символ *, Python перебрал все возможные пути к файлам. Запуская команды оболочки с помощью функции os.рорen, такого же результата можно добиться, только если подобная возможность поддерживается самой командной оболочкой или командой вывода списка файлов.
Функция os.listdir
Функция listdir из модуля os является еще одним способом получить список имен файлов. Но она принимает не шаблон имени файла, а простую строку с именем каталога и возвращает список, содержащий имена всех файлов в каталоге - как просто файлов, так и вложенных подкаталогов, - для использования в вызывающем сценарии:
>>> import os >>> os.listdir('.')
[‘parts’, ‘PP3E’, ‘random.bin’, ‘spam.txt’, ‘temp.bin’, ‘temp.txt’]
>>>
>>> os.listdir(os.curdir)
[‘parts’, ‘PP3E’, ‘random.bin’, ‘spam.txt’, ‘temp.bin’, ‘temp.txt’]
>>>
>>> os.listdir('parts')
[‘part0001’, ‘part0002’, ‘part0003’, ‘part0004’]
Эта функция также не привлекает к работе команды оболочки, и поэтому данный способ является не только быстрым, но и переносимым на все основные платформы Python. Результат функции не упорядочен никаким образом (но может быть отсортирован методом списков sort или функцией sorted); возвращает базовые имена файлов без путей к каталогам; не включает имена каталогов «.» или «..» и содержит имена файлов и подкаталогов для данного уровня.
Чтобы сравнить все три способа, запустим их друг за другом для явно заданного каталога. Они отличаются некоторыми деталями, но в целом являются вариациями на одну и ту же тему: функция os.рорen возвращает символы конца строки и способна сортировать имена файлов на некоторых платформах, функция glob.glob принимает шаблоны и возвращает полные имена файлов с путями, а функция os.listdir принимает обычное имя каталога и возвращает имена файлов без путей к каталогам:
>>> os.popen('dir /b parts').readlines()
[‘part0001\n’, ‘part0002\n’, ‘part0003\n’, ‘part0004\n’]
>>> glob.glob(r'parts\*')
[‘parts\\part0001’, ‘parts\\part0002’, ‘parts\\part0003’, ‘parts\\part0004’]
>>> os.listdir('parts')
[‘part0001’, ‘part0002’, ‘part0003’, ‘part0004’]
Из этих трех способов лучшими вариантами являются функции glob и listdir, если важна переносимость сценария и единообразие результатов, при этом функция listdir в последних версиях Python выглядит самой быстрой (тем не менее советую замеры производительности произвести самостоятельно - реализация может со временем измениться).
Разбиение и объединение результатов вывода
В предыдущем примере отмечалось, что функция glob возвращает полные имена файлов с путями, а функция listdir возвращает простые базовые имена файлов. В сценариях для удобства обработки часто требуется разбивать результаты функции glob, чтобы получить базовые имена, либо добавлять полные пути в результаты функции listdir. Такие преобразования легко реализуются, если позволить модулю os.path выполнить всю работу. Например, сценарию, который должен скопировать все файлы в какое-то место, обычно нужно сначала выделить базовые имена файлов из результатов, полученных с помощью функции glob, и затем добавить впереди них другие имена каталогов:
>>> dirname = r'C:\temp\parts'
>>>
>>> import glob
>>> for file in glob.glob(dirname + '/*'):
... head, tail = os.path.split(file)
... print(head, tail, '=>', ('C:\\Other\\' + tail))
C:\temp\parts part0001 => C:\Other\part0001 C:\temp\parts part0002 => C:\Other\part0002 C:\temp\parts part0003 => C:\Other\part0003 C:\temp\parts part0004 => C:\Other\part0004
Здесь после => показаны полные имена файлов, которые получатся после перемещения. Напротив, сценарию, который должен обработать все файлы в каталоге, отличном от того, в котором он выполняется, вероятно, потребуется добавить к результатам функции listdir имя целевого каталога, прежде чем предавать имена файлов другим инструментам:
>>> import os
>>> for file in os.listdir(dirname):
... print(dirname, file, '=>', os.path.join(dirname, file))
C:\temp\parts part0001 => C:\temp\parts\part0001 C:\temp\parts part0002 => C:\temp\parts\part0002 C:\temp\parts part0003 => C:\temp\parts\part0003 C:\temp\parts part0004 => C:\temp\parts\part0004
Когда вы начнете писать действующие инструменты для работы с каталогами, похожие на те, что мы будем разрабатывать в главе 6, пользование этими функциями войдет у вас в привычку.
Обход деревьев каталогов
Возможно, вы обратили внимание, что все предшествующие приемы в этом разделе возвращают имена файлов только из одного каталога (единственным исключением является глобальный поиск по шаблону). Такой подход годится в большинстве случаев, но что если потребуется применить операцию к каждому файлу в каждом каталоге и во всех подкаталогах дерева каталогов?
Например, допустим, что требуется найти в сценариях на языке Python все вхождения некоторого глобального имени. Однако на этот раз наши сценарии организованы в виде пакета модулей - каталога с вложенными подкаталогами, которые могут содержать собственные подкаталоги. Можно вручную запускать наш гипотетический поисковый механизм для одного каталога в каждом из подкаталогов в дереве, но это утомительно, чревато ошибками и точно не доставит удовольствия.
К счастью, реализовать обработку дерева каталогов на языке Python почти так же просто, как и просканировать единственный каталог. Можно написать рекурсивную процедуру обхода дерева или использовать утилиту перемещения по дереву, встроенную в модуль os. Такие инструменты можно использовать для поиска, копирования, сравнения и выполнения любых других операций над произвольными деревьями каталогов на любой платформе, где выполняется Python (то есть почти всюду).
Функция обхода дерева os.walk
Чтобы облегчить применение операции ко всем файлам в дереве каталогов, в составе Python поставляется утилита, выполняющая обход дерева и запускающая в каждом каталоге указанную функцию. Функции os.walk передается имя корневого каталога, и она автоматически обходит все дерево от корня и ниже.
По своей сути функция os.walk является функцией-генератором - для каждого каталога в дереве она возвращает кортеж из трех элементов, содержащий имя текущего каталога, а также списки всех файлов и всех подкаталогов в текущем каталоге. Так как это функция-генератор, обход дерева обычно реализуется с помощью цикла for (или другого инструмента итераций). В каждой итерации функция перемещается к следующему подкаталогу, а инструкция цикла выполняет свое тело для следующего уровня в дереве (например, открывает все файлы в этом подкаталоге и производит поиск по их содержимому).
На первый взгляд, такое описание может показаться ужасно сложным, но когда вы привыкнете к функции os.walk, все окажется довольно простым. В следующем фрагменте, например, тело цикла выполняется для каждого каталога в дереве с корнем в текущем рабочем каталоге. Цикл просто выводит имя каталога и имена всех файлов в нем, добавляя к ним имя каталога. Описать это на языке Python проще, чем на обычном языке (перед тем как запускать этот пример, я удалил каталог PP3E, чтобы сократить вывод):
>>> import os
>>> for (dirname, subshere, fileshere) in os.walk('.'):
... print('[' + dirname + ']')
... for fname in fileshere:
... print(os.path.join(dirname, fname)) # обработка одного файла
[.]
.\random.bin
.\spam.txt
.\temp.bin
.\temp.txt
[.\parts]
.\parts\part0001
.\parts\part0002
.\parts\part0003
.\parts\part0004
Иными словами, мы реализовали наш собственный, легко изменяемый инструмент рекурсивного вывода содержимого каталога на языке Python, Поскольку нам может потребоваться подправить его и использовать где-нибудь еще, давайте сделаем его постоянно доступным в виде файла модуля, как показано в примере 4.4, - теперь, когда мы проработали детали в интерактивном режиме.
Пример 4.4. PP4E\System\Filetools\lister_walk.py
"выводит список файлов в дереве каталогов с помощью os.walk”
import sys, os
def lister(root): # для корневого каталога
for (thisdir, subshere, fileshere) in os.walk(root): # перечисляет
print(‘[‘ + thisdir + ‘]’) # каталоги в дереве
for fname in fileshere: # вывод файлов в каталоге
path = os.path.join(thisdir, fname) # добавить имя каталога print(path)
if __name__ == ‘__main__’:
lister(sys.argv[1]) # имя каталога в
# командной строке
При таком оформлении данный программный код можно также выполнять из командной строки. Ниже приводится пример запуска его для получения списка содержимого другого корневого каталога, который передается в аргументе командной строки:
C:\...\PP4E\System\Filetools> python lister_walk.py C:\temp\test
[C:\temp\test]
C:\temp\test\random.bin
C:\temp\test\spam.txt
C:\temp\test\temp.bin
C:\temp\test\temp.txt
[C:\temp\test\parts]
C:\temp\test\parts\part0001
C:\temp\test\parts\part0002
C:\temp\test\parts\part0003
C:\temp\test\parts\part0004
Ниже приводится более сложный пример использования функции os.walk. Предположим, что имеется дерево каталогов с файлами, и вам необходимо отыскать в нем все файлы с программным кодом на языке Python, которые ссылаются на модуль mimetypes (с этим модулем мы познакомимся в главе 6). Ниже демонстрируется один из способов (хотя и слишком специфичный и не универсальный) решения поставленной задачи:
>>> import os >>> matches = []
>>> for (dirname, dirshere, fileshere) in os.walk(r'C:\temp\PP3E\Examples'):
... for filename in fileshere:
... if filename.endswith('.py'):
... pathname = os.path.join(dirname, filename)
... if 'mimetypes' in open(pathname).read():
... matches.append(pathname)
>>> for name in matches: print(name)
C:\temp\PP3E\Examples\PP3E\Internet\Email\mailtools\mailParser.py
C:\temp\PP3E\Examples\PP3E\Internet\Email\mailtools\mailSender.py
C:\temp\PP3E\Examples\PP3E\Internet\Ftp\mirror\downloadflat.py
C:\temp\PP3E\Examples\PP3E\Internet\Ftp\mirror\downloadflat_modular.py
C:\temp\PP3E\Examples\PP3E\Internet\Ftp\mirror\ftptools.py
C:\temp\PP3E\Examples\PP3E\Internet\Ftp\mirror\uploadflat.py
C:\temp\PP3E\Examples\PP3E\System\Media\playfile.py
Данная реализация в цикле обходит все файлы в каждом из подкаталогов, отыскивает файлы с расширением .py, содержащие искомую строку. Если совпадение найдено, полное имя файла добавляется в объект списка с результатами. Как вариант, мы могли бы просто создать список всех файлов с расширением .py и организовать поиск требуемой строки в цикле for уже после обхода дерева. Так как в главе 6 мы представим более универсальное решение для этого типа задач, то оставим пока все, как есть.
Если вам будет интересно узнать, что в действительности происходит внутри генератора os.walk, попробуйте несколько раз вызвать его метод __next__(или передать его встроенной функции next), как это автоматически делается циклом for, - каждый раз вы будете перемещаться к очередному подкаталогу в дереве:
>>> gen = os.walk(r'C:\temp\test')
>>> gen.__next__()
(‘C:\\temp\\test’, [‘parts’], [‘random.bin’, ‘spam.txt’, ‘temp.bin’, ‘temp. txt’])
>>> gen.__next__()
(‘C:\\temp\\test\\parts’, [], [‘part0001’, ‘part0002’, ‘part0003’, ‘ part0004’]) >>> gen.__next__()
Traceback (most recent call last):
File “
StopIteration
Описание функции os.walk в руководстве по библиотеке содержит более подробную информацию. Например, эта функция поддерживает порядок обхода не только в направлении сверху вниз, но и снизу вверх - достаточно передать функции необязательный аргумент topdown=False, и вызывающий программный код получит возможность сократить количество посещаемых ветвей дерева, удаляя имена из списка подкаталогов в возвращаемых кортежах.
Для создания списков имен на каждом уровне в дереве каталогов функция os.walk использует функцию os.listdir, с которой мы встречались выше, возвращающую имена файлов и каталогов без определенного порядка и без путей к каталогам. Прежде чем вернуть очередной результат, функция os.walk делит этот список на списки каталогов и файлов (точнее, некаталогов). Обратите также внимание, что функция os.walk использует тот же список подкаталогов, который она возвращает вызывающему программному коду, чтобы затем спуститься в подкаталоги. Списки являются изменяемыми объектами, которые можно изменять непосредственно, поэтому, изменяя содержимое полученного списка подкаталогов, вызывающий программный код может оказывать влияние на дальнейшую работу os.walk. Например, удаляя имена каталогов, можно сократить число посещаемых ветвей, а отсортировав список, можно определить очередность обхода подкаталогов.
Рекурсивный обход с помощью os.listdir
Функция os.walk сама осуществляет обход дерева - нам остается лишь реализовать тело цикла, выполняющее необходимые действия. Но иногда большей гибкости можно достичь, реализовав обход дерева самостоятельно, при этом почти не приложив лишних усилий. В следующем сценарии представлена другая реализация вывода содержимого каталога с использованием рекурсивной функции обхода (функция, которая вызывает саму себя, чтобы повторить операции). Функция mylister в примере 4.5 очень похожа на функцию lister из примера 4.4, но создает списки имен файлов с помощью os.listdir и вызывает саму себя рекурсивно, чтобы спуститься в подкаталоги.
Пример 4.5. PP4E\System\Filetools\lister_recur.py
# выводит список файлов в дереве каталогов с применением рекурсии
import sys, os
def mylister(currdir):
print(‘[‘ + currdir + ‘]’)
for file in os.listdir(currdir): # генерирует список файлов
path = os.path.join(currdir, file) # добавить путь к каталогу if not os.path.isdir(path): print(path) else:
mylister(path) # рекурсивный спуск в подкаталоги
if __name__ == ‘__main__’:
mylister(sys.argv[1]) # имя каталога в командной строке
Как обычно, этот файл можно импортировать или запускать как самостоятельный сценарий. Тот факт, что результатом его работы является печать текста, можно отнести к его недостаткам при его использовании в качестве импортируемого инструмента, если только его стандартный поток вывода не перехватывается в другой программе.
Когда этот файл запускается как самостоятельный сценарий, он воспроизводит почти те же результаты, что и пример 4.4; почти, но не полностью - в отличие от версии на основе функции os.walk, рекурсивная версия не обязует пройти все файлы на текущем уровне, прежде чем спуститься в подкаталоги. Можно было бы обойти список имен файлов дважды (чтобы сначала отобрать файлы), но в данной реализации порядок обхода определяется результатами, возвращаемыми функцией os.listdir. Для многих случаев такой порядок обхода может оказаться неприемлемым:
C:\...\PP4E\System\Filetools> python lister_recur.py C:\temp\test
[C:\temp\test]
[C:\temp\test\parts]
C:\temp\test\parts\part0001
C:\temp\test\parts\part0002
C:\temp\test\parts\part0003
C:\temp\test\parts\part0004
C:\temp\test\random.bin
C:\temp\test\spam.txt
C:\temp\test\temp.bin
C:\temp\test\temp.txt
Мы еще воспользуемся большей частью приемов, приведенных в этом разделе, в главе 6 и далее в книге. Например, приведенные выше приемы обхода деревьев будут использованы в сценариях копирования и сравнения деревьев каталогов. По ходу изложения вы увидите эти инструменты в действии. Кроме того, в главе 6 мы реализуем утилиту find, объединяющую в себе обход дерева каталогов с помощью os.walk и поиск имен файлов по шаблону с помощью glob.glob.
Обработка имен файлов в Юникоде в версии 3.X: listdir, walk, glob
Поскольку в Python 3.X все обычные строки состоят из символов Юникода, имена каталогов и файлов, возвращаемые функциями os.listdir, os.walk и glob.glob, в действительности являются строками Юникода. Это может иметь некоторые последствия, если каталоги содержат необычные имена, не поддающиеся декодированию.
Формально имена файлов могут содержать любые символы, поэтому в версии 3.X функция os.listdir может работать в двух режимах: если ей передать аргумент типа bytes, она будет возвращать кодированные имена файлов в виде строк байтов; если ей передать аргумент типа str, она будет возвращать имена файлов в виде строк Юникода, декодированных в соответствии с кодировкой, используемой файловой системой:
C:\...\PP4E\System\Filetools> python
>>> import os
>>> os.listdir('.')[:4]
[‘bigext-tree.py’, ‘bigpy-dir.py’, ‘bigpy-path.py’, ‘bigpy-tree.py’]
>>> os.listdir(b'.')[:4]
[b’bigext-tree.py’, b’bigpy-dir.py’, b’bigpy-path.py’, b’bigpy-tree.py’]
Версия, основанная на использовании строк байтов, может применяться для файлов с недекодируемыми именами. Функции os.walk и glob. glob за кулисами обращаются к функции os.listdir, от которой наследуют то же самое поведение. Функция os.walk обхода деревьев, например, вызывает os.listdir для каждого подкаталога - передача строки байтов в аргументе подавляет декодирование, вследствие чего в результате возвращается строка байтов:
>>> for (dir, subs, files) in os.walk('..'): print(dir)
..\Environment ..\Filetools ..\Processes
>>> for (dir, subs, files) in os.walk(b'..'): print(dir)
b’..’
b’..\\Environment’
b’..\\Filetools’ b’..\\Processes’
Функция glob.glob также вызывает функцию os.listdir перед применением шаблонов имен, и поэтому тоже возвращает имена в виде недекодированных строк байтов, когда получает строку байтов в аргументе:
>>> glob.glob('.\*')[:3]
[‘.\\bigext-out.txt’, ‘.\\bigext-tree.py’, ‘.\\bigpy-dir.py’]
>>>
>>> glob.glob(b'.\*')[:3]
[b’.\\bigext-out.txt’, b’.\\bigext-tree.py’, b’.\\bigpy-dir.py’]
Передавая имена в виде обычных строк (например, посредством аргумента командной строки), вы можете столкнуться с необходимостью преобразовывать обычные строки в строки байтов, с целью подавить декодирование:
>>> name = '.'
>>> os.listdir(name.encode())[:4]
[b’bigext-out.txt’, b’bigext-tree.py’, b’bigpy-dir.py’, b’bigpy-path.py’]
Таким образом, если каталоги могут содержать имена, не поддающиеся декодированию с использованием кодировки, используемой по умолчанию, вам может потребоваться передавать этим инструментам строки байтов, чтобы избежать ошибок, связанных с кодированием Юникода. В ответ вы будете получать строки байтов, которые могут оказаться менее читаемыми при выводе, но это убережет вас от ошибок при обходе каталогов и файлов.
Такой подход может оказаться особенно полезным в системах, где используются простейшие кодировки, такие как ASCII или Latin-1, но могут иметься файлы с именами в произвольных кодировках, скопированными с других компьютеров, из Интернета и так далее. В зависимости от ситуации для подавления некоторых ошибок кодирования можно использовать также обработчики исключений.
Пример того, какое это может иметь значение, мы увидим в первом разделе главы 6, где недекодируемое имя каталога вызывает появление ошибки при выводе во время полного сканирования диска (хотя данная ошибка относится скорее к функции вывода, чем к операции декодирования).
Обратите внимание, что встроенная функция open также может принимать имена открываемых файлов как в виде строк str Юникода, так и в виде строк байтов bytes, однако она использует этот аргумент, только чтобы дать начальное имя файлу, - порядок же обработки содержимого файла определяется дополнительным аргументом режима. Возможность передавать строку байтов в качестве имени файла позволяет использовать произвольные кодированные имена.
Правила использования Юникода: содержимое файлов и имена файлов
Важно помнить, что Юникод может выполнять применительно к файлам две различные задачи: кодирование содержимого файлов и кодирование имен файлов. Интерпретатор Python определяет настройки по умолчанию для этих двух операций в двух различных атрибутах; для Windows 7:
>>> import sys
>>> sys.getdefaultencoding() # кодировка для содержимого файлов
‘utf-8’
>>> sys.getfilesystemencoding() # кодировка для имен файлов
‘mbcs’
Эти настройки позволяют явно указывать используемые кодировки - кодировка для содержимого используется операциями чтения из файлов и записи в файлы, а кодировка для имен файлов используется при работе с именами файлов, до передачи данных. Кроме того, использование строк байтов bytes для передачи имен файлов различным инструментам позволяет обойти проблему несовместимости со схемой кодирования, используемой файловой системой, а открытие файлов в двоичном режиме позволяет подавить ошибки декодирования их содержимого.
Однако, как мы уже видели выше, открывая текстовые файлы в двоичном режиме, мы можем столкнуться с проблемой несовпадения кодированного текста с искомой строкой в операциях поиска: строки поиска в этом случае также должны быть строками байтов, закодированными с применением определенной кодировки, возможно несовместимой с кодировкой содержимого файла. Фактически данный подход в значительной степени воспроизводит поведение текстовых файлов в Python 2.X и подчеркивает важность использования Юникода в версии 3.X - при работе с такими файлами иногда может сложиться ложное впечатление, что все работает прекрасно. С другой стороны, возможность открывать текстовые файлы в двоичном режиме, чтобы подавить декодирование содержимого файлов и избежать появления связанных с этим ошибок, все еще может быть полезной, если вы не желаете пропустить недекодируемые файлы, содержимое которых не имеет большого значения.
Как правило, необходимо всегда указывать имя кодировки для содержимого текстовых файлов, если она может не совпадать с кодировкой по умолчанию, и в большинстве случаев вам следует опираться на интерфейсы, принимающие имена файлов в виде строк Юникода. Опять же, полную информацию по использованию Юникода в именах файлов вы найдете в руководствах по языку Python, так как здесь недостаточно места, чтобы дать полный охват этой темы, а за информацией о Юникоде вообще обращайтесь к четвертому изданию книги «Изучаем Python»1.
В главе 6 мы собираемся задействовать инструменты, с которыми встретились в этой главе, в реальной задаче. Например, мы применим инструменты для работы с файлами и каталогами при реализации делителей файлов, систем тестирования, инструментов копирования и сравнения каталогов, а также других утилит, опирающихся на использование процедуры обхода деревьев. Мы увидим, что инструменты для работы с каталогами, с которыми мы встретились здесь, обладают качествами, позволяющими автоматизировать огромный круг задач. Однако перед этим прочитаем главу 5, завершающую обзор основных инструментов исследованием еще одной темы системного программирования, которая тесно переплетается с различными прикладными областями, - реализацией параллельной обработки данных на языке Python.
Марк Лутц «Изучаем Python», 4 издание, СПб.: Символ-Плюс, 2010.
Системные инструменты параллельного выполнения
«Расскажите обезьянам, что им делать»
Большинство компьютеров тратит массу времени, ничего не делая. Если запустить системный монитор и посмотреть на уровень загрузки процессора, вы поймете, что я имею в виду: он очень редко достигает 100%, даже если выполняется несколько программ одновременно.14 Просто в программном обеспечении существует очень много задержек - доступ к диску, сетевой трафик, запросы к базам данных, ожидание нажатия клавиши пользователем и тому подобное. Фактически большая часть мощности современных процессоров большую часть времени не используется: более быстрые процессоры дают ускорение во время пиков потребности в производительности, но значительная часть их мощности в целом может оказаться невостребованной.
Еще на заре эпохи компьютеров программисты поняли, что могут воспользоваться такой неиспользуемой вычислительной мощностью, выполняя одновременно несколько программ. Если распределить процессорное время среди множества задач, его мощность не будет тратиться
впустую, пока некоторая конкретная задача ждет осуществления внешнего события. Такая технология обычно называется параллельной обработкой (или, иногда, «мультиобработкой» или даже «многозадачностью»), потому что возникает впечатление одновременного выполнения нескольких заданий параллельно во времени. Это одна из центральных идей современных операционных систем, на основе которой возникло представление о компьютерных интерфейсах с несколькими активными окнами, воспринимаемое нами теперь, как нечто само собой разумеющееся. Даже внутри одной программы разделение обработки на ряд параллельно выполняющихся заданий может увеличить быстродействие системы в целом, во всяком случае по меркам внешних часов.
Столь же важно для современных систем обладание быстрой реакцией на действия пользователя, независимо от объема работы, выполняемой за кулисами. Обычно недопустимо, чтобы программа зависала при выполнении запроса. Взгляните, например, на пользовательский интерфейс клиента электронной почты: обрабатывая запрос на получение почты с сервера, программа должна загрузить электронные письма с сервера через сеть. Если почты достаточно много, а соединение с Интернетом достаточно медленное, для завершения этого этапа может потребоваться несколько минут. Но по ходу выполнения задачи загрузки программа в целом не должна останавливаться - она по-прежнему должна реагировать на запросы обновления экрана, щелчки мышью и так далее.
И здесь на помощь приходит параллельная обработка. Выполняя такие долговыполняющиеся задачи параллельно с остальной частью программы, система в целом может сохранить способность реагировать на действия пользователя независимо от того, насколько занятыми оказываются отдельные ее части. Более того, модель параллельной обработки является вполне естественной для структурирования таких и некоторых иных программ - некоторые задачи легче проще проектировать и реализовывать как набор программных компонентов, действующих независимо и параллельно.
Существует два основных способа реализации одновременного выполнения задач в Python - ветвление процессов (forks) и порожденные потоки (threads) выполнения. Функционально для организации параллельного выполнения программного кода на языке Python оба способа используют службы операционной системы. Процедурно они существенно отличаются в смысле интерфейсов, переносимости и организации взаимодействий между заданиями. Например, на момент написания данной книги возможность прямого ветвления процессов не поддерживалась стандартной реализацией Python для Windows (однако такая поддержка присутствует в версии Python для Cygwin).
Напротив, поддержка потоков выполнения в Python реализована на всех основных платформах. Кроме того, семейство функций os.spawn обеспечивает дополнительные способы запуска способом, не зависящим от типа платформы, - напоминающим ветвление процессов. Для запуска программ переносимым способом, с помощью команд оболочки, также можно использовать функции os.popen, os.system и модуль subprocess, с которыми мы познакомились в главах 2 и 3. Новейший пакет multiprocessing предоставляет дополнительные переносимые способы запуска процессов.
В данной главе мы продолжим рассмотрение системных интерфейсов, доступных программистам на языке Python, исследуем встроенные инструменты для параллельного запуска заданий и обмена информацией с этими заданиями. В некотором смысле мы приступили к этому раньше - функции os.system, os. popen и модуль subprocess, которые мы изучали и использовали в предыдущих трех главах, обеспечивают переносимый способ порождения программ командной строки и обмена информацией с ними. Однако здесь мы не собираемся повторять полное описание этих инструментов.
Вместо этого мы сделаем упор на знакомстве с более прямо относящимися к теме приемами, такими как ветвление процессов, потоки, каналы, сигналы, сокеты и другими, и на использовании встроенных инструментов языка Python, поддерживающими их, такими как функция os.fork и модули threading, queue и multiprocessing. В следующей главе (и в оставшейся части книги) мы будем использовать эти приемы в примерах действующих программ, поэтому, прежде чем двигаться вперед, необходимо усвоить основы.
Одно предварительное замечание: процессы, потоки и механизмы взаимодействий между процессами, которые мы будем исследовать в этой главе, являются основными инструментами организации параллельной обработки в сценариях на языке Python, однако существует множество сторонних инструментов, предлагающих дополнительные возможности, способные обслуживать расширенные или углубленные потребности. В качестве примера приведу систему MPI для Python, позволяющую в сценариях на языке Python использовать стандартный интерфейс передачи сообщений (Message Passing Interface, MPI), дающий возможность организовать взаимодействие между процессами различными способами (подробности ищите в Интернете). Изучение подобных расширений выходит далеко за рамки этой книги, тем не менее большинство расширенных техник, с которыми вы можете встретиться в будущем, также опираются на основы параллельной обработки, которые мы будем исследовать здесь.
Ветвление процессов
Ветвление процессов является традиционным способом организации параллельных вычислений и представляет собой фундаментальную часть инструментального набора Unix. Ветвление процессов - это самый простой способ запуска независимых программ, как отличных, так и не отличных от вызывающей программы. Прием ветвления основан на понятии копирования программ: когда программа вызывает процедуру ветвления, операционная система создает в памяти новую копию этой программы и запускает ее параллельно оригиналу. В некоторых системах исходная программа в действительности не копируется (это слишком дорогостоящая операция), но новая копия работает так, как если бы она действительно была подлинной копией.
После операции ветвления исходный экземпляр программы называется родительским процессом, а копия, созданная с помощью функции os.fork, называется дочерним процессом. Вообще говоря, родитель может воспроизвести любое число потомков, а потомки могут создать собственные дочерние процессы - все ответвленные процессы выполняются независимо и параллельно под управлением операционной системы, и дочерние процессы могут продолжать выполняться даже после завершения родительского процесса.
Возможно, это проще понять на примере, чем в теории. Сценарий Python в примере 5.1 продолжает ответвлять новые дочерние процессы, пока в консоли не будет нажата клавиша q.
Пример 5.1. PP4E\System\Processes\fork1.py
"ответвляет дочерние процессы, пока не будет нажата клавиша ‘q’”
import os
def child():
print(‘Hello from child’, os.getpid())
os._exit(0) # иначе произойдет возврат в родительский цикл
def parent(): while True:
newpid = os.fork() if newpid == 0: child() else:
print(‘Hello from parent’, os.getpid(), newpid) if input() == ‘q’: break
parent()
Инструменты ветвления процессов в Python, находящиеся в модуле os, - это просто тонкие обертки вокруг стандартных средств ветвления из системной библиотеки, используемой также программами на языке C. Запуск нового параллельного процесса осуществляется вызовом функции os.fork. Поскольку эта функция создает копию вызывающей программы, она возвращает различные значения в каждой копии: ноль - в дочернем процессе и числовой идентификатор ID процесса нового потомка - в родительском процессе.
Обычно программы проверяют этот результат, чтобы приступить к выполнению каких-то операций только в дочернем процессе. В этом сценарии, например, функция child вызывается только в дочерних процессах.15
Поскольку ветвление процессов исходно является частью модели программирования в Unix, этот сценарий замечательно будет функционировать в Unix, Linux и в современных версиях Mac OS. К сожалению, этот сценарий не будет работать под управлением стандартной версии Python в Windows, потому что функция fork не стыкуется с моделью Windows. Тем не менее в Windows сценарии на языке Python всегда могут порождать потоки выполнения, а также использовать пакет multiprocessing, описываемый ниже в этой главе. Этот модуль обеспечивает альтернативный и переносимый способ запуска процессов, который позволяет отказаться от приема ветвления процессов в Windows в контекстах, согласующихся с его ограничениями (хотя и за счет необходимости выполнения некоторых низкоуровневых операций).
Однако сценарий из примера 5.1 будет работать в Windows, если использовать версию Python, распространяемую вместе с системой Cygwin (или собранную вами из исходных текстов вместе с библиотеками Cygwin). Cygwin - это бесплатная и открытая система, обеспечивающая полную Unix-подобную функциональность для Windows (описывается ниже, во врезке «Подробнее о Cygwin Python для Windows»). Используя Python для Cygwin в операционной системе Windows, можно использовать прием ветвления процессов, хотя он не полностью соответствует приему ветвления процессов в Unix. Однако, поскольку эта версия Python достаточно близка к рассматриваемым в данной книге, давайте воспользуемся ею, чтобы запустить сценарий:
[C:\...\PP4E\System\Processes]$ python fork1.py Hello from parent 7296 7920 Hello from child 7920
Hello from parent 7296 3988 Hello from child 3988
Hello from parent 7296 6796 Hello from child 6796
q
Эти сообщения представляют три ответвленных дочерних процесса -уникальные идентификаторы всех участвующих процессов получены и выведены с помощью функции os.getpid. Важно отметить, что вызов функции child в дочернем процессе явно завершает его выполнение вызовом функции os._exit. Эту функцию мы более подробно обсудим далее в этой главе, но если ее не вызвать, дочерний процесс продолжит существование после возврата из функции child (не забывайте, что это лишь копия исходного процесса). В этом случае дочерний процесс возвратится в цикл, находящийся в функции parent, и начнет плодить собственных потомков (то есть у родителя появятся внуки). Если удалить вызов выхода и перезапустить сценарий, то для его остановки может понадобиться несколько раз нажать клавишу q, поскольку несколько процессов будут выполнять функцию parent.
В примере 5.1 каждый процесс завершается вскоре после запуска, поэтому перекрытие по времени незначительно. Попробуем сделать нечто более сложное, чтобы лучше продемонстрировать параллельное выполнение нескольких ответвленных процессов. Пример 5.2 запускает 5 копий себя самого, при этом каждая копия считает до 5 с односекундной задержкой между итерациями. Функция time.sleep из стандартной библиотеки просто приостанавливает работу вызывающего процесса на указанное количество секунд (допускается указывать значение с плавающей точкой, чтобы приостановить процесс на дробную часть секунды).
Пример 5.2. PP4E\System\Processes\fork-count.py
Основы ветвления: запустить 5 копий этой программы параллельно оригиналу; каждая копия считает до 5 и выводит счетчик в тот же поток stdout -- при ветвлении копируется память процесса, в том числе дескрипторы файлов; в настоящее время ветвление не действует в Windows без Cygwin: запускайте программы в Windows с помощью функции os.spawnv или пакета multiprocessing; функция spawnv примерно соответствует комбинации функций fork+exec;
import os, time
def counter(count): # вызывается в новом процессе
for i in range(count):
time.sleep(1) # имитировать работу
print(‘[%s] => %s’ % (os.getpid(), i))
for i in range(5): pid = os.fork()
if pid != 0: # в родительском процессе:
print(‘Process %d spawned’ % pid) # продолжить цикл else:
counter(5) # в дочернем процессе
os._exit(0) # вызвать функцию и завершиться
print(‘Main process exiting.’) # родитель не должен ждать
После запуска этот сценарий сразу запустит 5 процессов и завершит работу. Все 5 ответвленных процессов отображают секундой позже первое показание счетчика и далее - каждую последующую секунду. Обратите внимание, что дочерние процессы продолжают выполняться даже после того, как создавший их родительский процесс завершит свою работу:
[C:\...\PP4E\System\Processes]$ python fork-count.py
Process 4556 spawned
Process 3724 spawned
Process 6360 spawned
Process 6476 spawned
Process 6684 spawned
Main process exiting.
[4556] => 0 [3724] => 0 [6360] => 0 [6476] => 0 [6684] => 0 [4556] => 1 [3724] => 1 [6360] => 1 [6476] => 1 [6684] => 1 [4556] => 2 [3724] => 2 [6360] => 2 [6476] => 2 [6684] => 2
...остальная часть вывода опущена...
Вывод всех этих процессов отображается на одном и том же экране, потому что все они используют стандартный поток вывода (в процессе работы периодически может появляться системное приглашение к вводу). Технически ответвленный процесс получает копию глобальной памяти оригинального процесса, в том числе дескрипторы открытых файлов. Из-за этого глобальные объекты, такие как файлы, начинают работу в дочернем процессе с одними и теми же значениями, поэтому все процессы в этом примере оказываются подключенными к одному и тому же потоку вывода. Но важно помнить, что глобальная память копируется, а не используется совместно, - если дочерний процесс изменит глобальный объект, то изменит только свою копию этого объекта. (Как мы увидим, в потоках выполнения все происходит совсем иначе. Это тема следующего раздела.)
Комбинация fork/exec
В примерах 5.1 и 5.2 дочерние процессы просто вызывали функцию в программе и завершали свою работу. В Unix-подобных платформах ветвление часто служит основой для запуска программ, выполняющихся независимо и совершенно отличных от программы, вызвавшей функцию fork. Так, в примере 5.3 ответвление новых процессов также выполняется, пока не будет нажата клавиша q, но в дочерних процессах вместо вызова функции в том же файле запускается совершенно новая программа.
Пример 5.3. PP4E\System\Processes\fork-exec.py
"запускает программы, пока не будет нажата клавиша ‘q’”
import os
parm = 0 while True: parm += 1 pid = os.fork()
if pid == 0: # копия процесса
os.execlp(‘python’, ‘python’, ‘child.py’, str(parm)) # подменить прогр. assert False, ‘error starting program’ # возврата быть
# не должно
else:
print(‘Child is’, pid) if input() == ‘q’: break
Если вы достаточно много занимались разработкой программ для Unix, комбинация функций fork/exec наверняка будет вам знакома. Главное, на что следует обратить внимание, - это функция os.execlp. В двух словах, эта функция замещает программу, выполняющуюся в текущем процессе, новой программой. Поэтому комбинация функций os.fork и os.execlp означает запуск нового процесса и запуск новой программы в этом процессе. Другими словами - запуск новой программы параллельно оригинальной.
Формы вызова функции os.exec
Аргументы функции os.execlp определяют программу, которая должна быть выполнена, и аргументы командной строки, которые следует передать ей (доступные в сценариях Python в виде списка sys.argv). В случае успеха начинается выполнение новой программы, и возврата из вызова функции os.execlp не происходит (так как оригинальная программа замещается новой, то возвращаться действительно некуда). Если возврат все-таки происходит, это означает, что произошла ошибка, поэтому в сценарии после вызова функции стоит инструкция assert, при достижении которой всегда возбуждается исключение.
В стандартной библиотеке Python есть несколько разновидностей функции os.exec. Часть из них позволяет настраивать переменные окружения для новой программы, передавать аргументы командной строки в различных форматах и так далее. Все они имеются как в Unix, так и в Windows, и заменяют вызвавшую их программу (то есть интерпретатор Руthon). Всего существует восемь разновидностей функции exec, что может вызывать затруднения в выборе, если не сделать обобщение:
os.execv(program, commandlinesequence)
Базовая «vit-форма функции exec, которой передается имя выполняемой программы вместе со списком или кортежем строк аргументов командной строки, используемых при запуске программы (то есть слов, которые обычно можно ввести в командной строке для запуска программы).
os.execl(program, cmdargl, cmdarg2, ... cmdargN)
Базовая «1»-форма функции exec, которой передается имя выполняемой программы, за которым следуют один или более аргументов командной строки, передаваемых как отдельные аргументы функции. Соответствует вызову функции os.execv(program, (cmdargl, cmdarg2,
...)).
os.execlp
os.execvp
Символ «р», добавленный к именам execv и execl, означает, что Python станет искать каталог, где находится программа, используя системный путь поиска (то есть переменную PATH).
os.execle
os.execve
Символ «e», добавленный к именам execv и execl, означает, что дополнительный последний аргумент является словарем, содержащим переменные окружения, которые нужно передать программе.
os.execvpe
os.execlpe
Символы «р» и «e», добавленные к базовым именам exec, означают одновременное использование пути поиска и словаря с переменными окружения.
Поэтому, когда сценарий в примере 5.3 вызывает os.execlp, отдельно передаваемые параметры определяют аргументы командной строки для программы, которую нужно выполнить, а слово python отображается в выполняемый файл, находящийся в пути поиска системы (РАТН). Это соответствует выполнению в оболочке команды вида python child.py 1, но каждый раз с разными аргументами командной строки в конце.
Порожденная дочерняя программа
Так же, как при вводе в командной оболочке, строка аргументов, передаваемая функции os.execlp сценарием fork-exec из примера 5.3, запускает еще один файл программы Python, который приводится в примере 5.4.
Пример 5.4. PP4E\System\Processes\child.py
import os, sys
print(‘Hello from child’, os.getpid(), sys.argv[1])
Ниже показано, как этот программный код действует в Linux. Он не сильно отличается от оригинала fork1.py, но в действительности запускает новую программу в каждом ответвленном процессе. Наиболее наблюдательные читатели заметят, что идентификаторы ID дочернего процесса, отображаемые родительской программой и запущенной программой child.py, одинаковые - функция os.execlp просто замещает программу в том же самом процессе:
[C:\...\PP4E\System\Processes]$ python fork-exec.py
Child is 4556
Hello from child 4556 1
Child is 5920
Hello from child 5920 2
Child is 316
Hello from child 316 3
q
В языке Python существуют и другие способы запуска программ, помимо комбинации fork/exec. Например, функции os.system и os.popen и модуль subprocess, с которыми мы познакомились в главах 2 и 3, позволяют выполнять команды оболочки. Функция os.spawnv и пакет multiprocessing, с которым мы познакомимся далее в этой главе, позволяют запускать независимые программы и процессы более переносимым способом. Далее мы увидим, что в некоторых ситуациях модель порождения процессов с помощью пакета multiprocessing может использоваться как переносимая замена функции os.fork (хотя и менее эффективная) и применяться в соединении с функциями os.exec*, показанными здесь, для достижения того же эффекта в стандартной реализации Python для Windows.
Далее в этой главе будут представлены другие примеры ветвления процессов, особенно много - в разделах, посвященных приемам завершения процессов и организации взаимодействий между ними, поэтому мы здесь ограничимся уже приведенными примерами. В следующих главах этой книги мы также рассмотрим другие темы, относящиеся к процессам. Например, в главе 12 мы снова вернемся к приему ветвления процессов, чтобы разобраться с зомби - «мертвыми» процессами, затаившимися в системных таблицах после своего конца. А теперь перейдем к потокам выполнения - к теме, которую по крайней мере некоторые программисты находят значительно менее пугающей...
Подробнее о Cygwin Python для Windows
Как уже упоминалось, функция os.fork присутствует в версии Cygwin Python для Windows. Эта функция отсутствует в стандартной версии Python для Windows, тем не менее вы можете использовать прием ветвления процессов в Windows, если установите и будете использовать Cygwin. Однако реализация функции fork в Cygwin не так эффективна и действует немного не так, как функция fork в настоящих системах Unix.
Cygwin - это бесплатный и открытый пакет, включающий библиотеку, реализующую Unix-подобный прикладной интерфейс для использования в Windows, а также набор инструментов командной строки, реализующих Unix-подобное окружение. Это упрощает применение навыков программирования, полученных в Unix, в операционной системе Windows.
Однако, согласно сборнику часто задаваемых вопросов к этому пакету: «Функция fork() в Cygwin по сути действует, как некопирующая при записи версия fork() (как это было принято в старых версиях Unix). Вследствие этого она может оказаться немного медленнее. В большинстве случаев лучше использовать семейство функций spawn, когда это возможно». Поскольку производительность не является основной целью примеров в этой книге, будем считать представленную версию функции fork в Cygwin удовлетворительной.
В дополнение к функции fork Cygwin предоставляет и другие инструменты Unix, недоступные ни в одной из версий Windows, включая функцию os.mkfifo (обсуждается далее в этой главе). Кроме того, в состав пакета входит компилятор gcc, хорошо знакомый разработчикам программ для Unix и позволяющий выполнять сборку расширений на языке C для Python в Windows. Если вы будете использовать библиотеки Cygwin для сборки своих приложений и вашей версии Python, вы окажетесь очень близки к Unix в Windows.
Однако, как и все сторонние библиотеки, Cygwin привносит дополнительную зависимость. Что самое, пожалуй, важное, - Cygwin в настоящее время выходит под лицензией GNU GPL, которая добавляет дополнительные требования к распространению программ, которые гораздо шире требований лицензии для стандартной версии Python. При использовании библиотеки Cygwin в дополнение к самому интерпретатору Python может потребоваться распространять свои программы с открытыми исходными текстами (впрочем, компания RedHat предлагает возможность «выкупа», освобождающую вас от этого требования). Учтите, что это
достаточно сложный юридический вопрос, и вам необходимо внимательно изучить лицензию на Cygwin, которая может распространять свое действие и на ваши программы. Эта лицензия действительно налагает больше ограничений, чем лицензия на Python (Python распространяется под BSD-подобной лицензией, а не GPL).
Но несмотря на проблемы, связанные с лицензией, Cygwin все-таки может служить отличным способом обрести Unix-подобную функциональность в Windows без установки другой полноценной операционной системы, такой как Linux, - более полного, но и более сложного варианта. За дополнительной информацией обращайтесь по адресу http://cygwin.com или поищите в Интернете по фразе «Cygwin».
Обратите также внимание на пакет multiprocessing из стандартной библиотеки и на семейство функций os.spawn, которые будут рассматриваться далее в этой главе. Эти инструменты предоставляют альтернативный способ запуска параллельно выполняющихся заданий и программ в Unix и Windows, которые не требуют наличия в системе функций fork и exec. Чтобы в Windows запустить простую функцию параллельно основной программе (не в виде внешней программы), можно воспользоваться поддержкой потоков выполнения в стандартной библиотеке, о которой рассказывается далее в этой главе. Потоки выполнения, пакет multiprocessing и функции os.spawn можно использовать в стандартной версии Python для Windows.
Дополнение к четвертому изданию: когда я вносил дополнения в эту главу в феврале 2010 года, в Cygwin официальной версией Python по-прежнему оставалась версия Python 2.5.2. Чтобы получить версию Python 3.1 для Cygwin, ее необходимо собрать из исходных текстов. Если к моменту, когда вы читаете эти строки, данное требование все еще в силе, убедитесь, что в вашем окружении Cygwin установлены компилятор gcc и утилита make, затем загрузите исходные тексты Python с сайта python.org, распакуйте их и соберите Python с помощью следующих команд:
./configure
make
make test
sudo make install
Эти команды установят Python как python3. Ту же процедуру установки можно использовать во всех Unix-подобных системах. В OS X и Cygwin выполняемый файл интерпретатора называется python. exe, в остальных окружениях - python. Вообще говоря, последние
две команды можно не выполнять, если вы пожелаете запускать Python 3.1 из каталога сборки. Обязательно проверьте, не вошла ли версия Python 3.X в стандартный пакет для Cygwin к тому времени, когда вы будете читать эти строки, - при сборке из исходных текстов вам может потребоваться изменить несколько файлов (мне пришлось закомментировать инструкцию #define в файле Modules/main.c), однако эти изменения слишком специфические и необходимость в них может отпасть со временем, поэтому я не буду описывать их здесь.
Потоки выполнения
Потоки выполнения представляют еще один способ запуска операций, выполняемых одновременно. В двух словах, механизм потоков выполнения позволяет запустить функцию (или вызываемый объект другого типа) параллельно основной программе. Иногда их называют «облегченными процессами», потому что они работают параллельно, подобно дочерним процессам, но выполняются в рамках одного и того же процесса. Процессы обычно используются для запуска независимых программ, а потоки выполнения - для решения таких задач, как неблокирующий ввод, и для выполнения продолжительных заданий в программах с графическим интерфейсом. Они также представляют естественную модель реализации алгоритмов, которые можно выразить в терминах независимых заданий. В приложениях, которые выигрывают от параллельной обработки, потоки дают программистам большие выгоды:
Производительность
Поскольку все потоки выполняются в пределах одного процесса, их запуск не сопряжен с высокими накладными расходами, как при копировании процесса в целом. Издержки, связанные с копированием порождаемых дочерних процессов и запуском потоков, могут быть различными в зависимости от платформы, но обычно считается, что потоки обходятся дешевле в смысле производительности.
Простота
Потоки выполнения заметно проще в обращении, особенно если на сцену выходят более сложные аспекты процессов (например, завершение процессов, обмен информацией между процессами и процессы-«зомби», о которых рассказывается в главе 12).
Совместно используемая глобальная память
Кроме того, поскольку потоки выполняются в одном процессе, они используют общую глобальную память процесса. Благодаря этому потоки могут просто и естественно взаимодействовать друг с другом путем чтения и записи данных в глобальной памяти, доступной всем потокам выполнения. Для программиста на языке Python это означает, что глобальные переменные, объекты и их атрибуты и такие компоненты, как импортированные модули, совместно используются всеми потоками выполнения в программе - если, например, в одном потоке выполнения присваивается значение глобальной переменной, ее новое значение увидят все другие потоки выполнения. При обращении к совместно используемым глобальным объектам необходимо проявлять некоторую осторожность, но все равно это обычно проще, чем те средства организации взаимодействий, которые применяются для обмена данными с дочерними процессами и с которыми мы познакомимся ниже в этой главе (например, каналы, потоки ввода-вывода, сигналы, сокеты и так далее). Как и многое в программировании, все вышеизложенное не является универсальной и общепринятой истиной, поэтому вам самим придется взвесить и оценить различия с позиции своих программ и платформ.
Переносимость
Возможно, важнее всего, что приемы работы с потоками выполнения лучше переносятся на другие платформы, чем приемы работы с процессами. На момент написания данной книги функция os.fork вообще не поддерживается стандартной версией Python для Windows, тогда как потоки выполнения поддерживаются. Если вам необходимо обеспечить параллельное выполнение заданий в сценариях на языке Python переносимым способом, и вы не желаете или не можете установить в Windows Unix-подобную библиотеку, такую как Cygwin, потоки выполнения окажутся, скорее всего, лучшим решением. Инструменты для работы с потоками выполнения в Python автоматически учитывают специфические для каждой платформы различия в потоках выполнения и предоставляют единообразный интерфейс для всех операционных систем. Следует отметить, что относительно новый пакет multiprocessing, описываемый далее в этой главе, предлагает еще одно решение проблемы переносимости, которое может использоваться в некоторых случаях.
Так в чем же подвох? Существует три основных потенциальных недостатка, о которых следует знать, прежде чем нырять в свои потоки выполнения:
Вызовы функций и запуск программ
Прежде всего, потоки выполнения не являются способом, по крайней мере, не самым простым способом, запуска других программ. Потоки выполнения предназначены для запуска функций (точнее, любого вызываемого объекта, включая связанные и несвязанные методы), выполняющихся параллельно с основной программой. Как мы видели в предыдущем разделе, после выполнения операции ветвления дочерние процессы могут вызывать функции или запускать новые программы. Естественно, функция, запущенная в отдельном потоке выполнения, также способна запускать другие сценарии с помощью встроенной функции exec и новые программы с помощью таких инструментов, как функции os.system, os.popen и модуль subprocess, особенно если они производят продолжительные вычисления. Но вообще, потоки выполнения предназначены для запуска функций внутри программы.
С практической точки зрения это обычно не рассматривается, как недостаток. Для многих приложений возможность параллельного выполнения функций сама по себе является достаточно мощным приобретением. Например, если вам необходимо реализовать неблокирующий ввод и вывод или избежать «подвисания» графического интерфейса из-за выполнения продолжительной операции, с этим прекрасно справятся потоки выполнения - просто создайте поток выполнения. который запустит функцию, производящую продолжительные вычисления, а основная программа продолжит выполняться независимо.
Синхронизация потоков выполнения и очереди
Во-вторых, тот факт, что потоки выполнения совместно используют объекты и переменные в глобальной памяти процесса, имеет свои положительные и отрицательные стороны - это упрощает организацию взаимодействий, но при этом нам необходимо синхронизировать выполнение различных операций. Как мы увидим далее, даже такие операции, как вывод, могут стать источником конфликтов, потому что они пользуются одним потоком вывода sys.stdout процесса.
К счастью, модуль queue из стандартной библиотеки, описываемый в этом разделе, упрощает решение этой проблемы: на практике многопоточные программы обычно создают один или несколько потоков производителей (рабочих потоков), которые добавляют данные в очередь, и один или более потоков потребителей, которые извлекают данные из очереди и обрабатывают их. Например, в типичной реализации графического интерфейса производители могут загружать или вычислять данные и помещать их в очередь, а потребитель -главный поток выполнения в программе - периодически проверять наличие данных в очереди по событиям от таймера и отображать их в графическом интерфейсе. Поскольку стандартная реализация очередей уже предусматривает возможность работы с несколькими потоками выполнения, программы, структурированные таким способом, автоматически обеспечивают синхронизацию доступа к данным из нескольких потоков выполнения.
Глобальная блокировка интерпретатора (Global Interpreter Lock, GIL) Наконец, как мы узнаем далее в этом разделе, реализация механизма потоков выполнения в Python допускает выполнение виртуальной машиной только одного потока в каждый конкретный момент времени. Потоки выполнения в Python являются настоящими потоками выполнения операционной системы, но каждый поток должен приобрести единственную общедоступную блокировку, когда будет готов к запуску, и каждый поток выполнения может быть вытеснен через короткий промежуток времени (в настоящее время - после выполнения виртуальной машиной некоторого количества инструкций, хотя такой порядок может измениться в Python 3.2).
Вследствие этого потоки выполнения в языке Python не могут выполняться одновременно на нескольких процессорах в многопроцессорных системах. Чтобы воспользоваться преимуществами многопроцессорных систем, можно вместо потоков выполнения воспользоваться механизмом ветвления процессов (объем и сложность программного кода в обоих случаях остаются примерно одинаковыми). Кроме того, части потоков выполнения, реализованные как расширения на языке C, могут выполняться по-настоящему независимо, если они освобождают GIL, чтобы обеспечить возможность выполнения программного кода Python в других потоках. Однако программный код на языке Python не может выполняться одновременно в нескольких потоках.
Преимущество реализации механизма потоков выполнения в Python -высокая производительность. Первые попытки внедрить механизм поддержки потоков выполнения в виртуальную машину привели к двукратному снижению скорости выполнения программ в Windows, и еще большее снижение наблюдалось в Linux. Даже однопоточные программы работали в два раза медленнее.
Даже при том, что наличие GIL снижает практическую пользу потоков выполнения в языке Python, не позволяя использовать преимущества многопроцессорных систем, - потоки выполнения остаются полезным инструментом реализации неблокирующих операций, особенно в приложениях с графическим интерфейсом. Кроме того, новый пакет multiprocessing, с которым мы познакомимся далее, предлагает другое решение этой проблемы - он предоставляет переносимый прикладной интерфейс, похожий на интерфейс механизма потоков выполнения, но основанный на процессах, благодаря чему программы получают простоту обращения с потоками выполнения и преимущества выполнения независимых процессов в многопроцессорных системах.
Несмотря на то, что после прочтения этого обзора у вас могло сложиться иное мнение, я утверждаю, что потоки выполнения в языке Python удивительно просты в использовании. Фактически когда запускается программа, она уже выполняется в потоке, который обычно называется «главным потоком» процесса. Для запуска новых, независимых потоков выполнения в рамках одного и того же процесса в программах на языке Python обычно используется либо низкоуровневый модуль _thread, позволяющий запускать функции в порожденных потоках выполнения, либо высокоуровневый модуль threading, предоставляющий возможность управления потоками выполнения с помощью объектов высокого уровня, созданных на основе классов. Оба модуля также предусматривают инструменты синхронизации доступа к совместно используемым объектам с помощью блокировок.
В данной книге будут исследоваться оба модуля, _thread и threading, и в примерах они будут использоваться взаимозаменяемо. Некоторые программисты на языке Python могли бы порекомендовать всегда использовать модуль threading и оставить модуль _thread в покое. Последний из них ранее назывался thread и в версии 3.X получил название _thread, которое предполагает менее высокий статус модуля. Лично я считаю, что это крайность (это одна из причин, почему в некоторых примерах в данной книге используется конструкция as thread в инструкциях импортирования, позволяющая использовать оригинальное имя модуля в программном коде).
Если только вам не требуются мощные инструменты из модуля threading, выбор между этими двумя модулями является вопросом личных предпочтений, при этом дополнительные требования модуля threading могут считаться ничем не оправданными.
В базовом модуле _thread не используются приемы объектноориентированного программирования, и он очень прост в использовании, как будет показано в примерах этого раздела. Модуль threading лучше подходит для решения более сложных задач, которые требуют сохранения информации в контексте потоков или наблюдения за потоками, но не все многопоточные программы требуют применения дополнительных инструментов, и во многих из них используется достаточно ограниченный набор возможностей многопоточной модели. Фактически сравнение этих модулей напоминает сравнение функции os.walk с классами, реализующими обход дерева, с которыми мы встретимся в главе 6, - оба приема имеют своих сторонников и область применения. Как всегда, не забывайте основное правило Python: не добавляйте сложностей, когда сложности не нужны.
Модуль _thread
Поскольку базовый модуль _thread немного проще, чем более мощный модуль threading, о котором рассказывается далее в этом разделе, начнем с рассмотрения его интерфейсов. Этот модуль предоставляет переносимый интерфейс к любой системе потоков выполнения, имеющейся на вашей платформе: его интерфейсы одинаково работают в Windows, Solaris, SGI и любой другой системе, где установлена реализация pthreads потоков POSIX (включая Linux). Сценарии на языке Python, использующие модуль _thread, будут работать на всех этих платформах без внесения каких-либо изменений в исходный программный код.
Основы использования
Для начала поэкспериментируем со сценарием, демонстрирующим применение основных интерфейсов механизма потоков выполнения. Сценарий в примере 5.5 порождает потоки выполнения, пока в консоли не будет нажата клавиша q, и напоминает по духу (будучи немного проще) сценарий в примере 5.1; но он запускает параллельно потоки, а не дочерние процессы.
Пример 5.5. PP4E\System\Threads\thread1.py
"порождает потоки выполнения, пока не будет нажата клавиша ‘q’”
import _thread def child(tid):
print(‘Hello from thread’, tid) def parent():
i = 0
while True:
i += 1
_thread.start_new_thread(child, (i,)) if input() == ‘q’: break
parent()
В действительности в этом сценарии только две строки имеют отношение к потокам выполнения: инструкция импортирования модуля _thread и вызов функции, создающей поток. Чтобы запустить новый поток выполнения, достаточно просто вызвать функцию _thread.start_new_thread, независимо от того, на какой платформе выполняется программа.16 Эта функция принимает функцию (или другой вызываемый объект) и кортеж аргументов, и запускает новый поток выполнения, в котором будет вызвана указанная функция с переданными аргументами. Это очень похоже на синтаксис вызова function(*args) - и тут, и там принимается необязательный словарь именованных аргументов, - но в данном случае функция начинает выполняться параллельно основной программе.
Сама функция _thread.start_new_thread сразу же возвращает управление вызывающей, не возвращая какого-либо полезного значения, а порожденный ею поток тихо завершается, когда происходит возврат из выполняемой функции (значение, возвращаемое функцией, выполняемой в потоке, просто игнорируется). Кроме того, если выполняемая в потоке функция возбудит исключение, интерпретатор выведет трассировочную информацию и завершит работу потока, но остальная программа продолжит работу. На большинстве платформ при использовании модуля _thread вся программа завершит работу без вывода каких-либо сообщений, когда завершится главный поток (однако, как будет показано далее, при использовании модуля threading может потребоваться предпринять дополнительные действия, если дочерние потоки к этому моменту еще продолжают выполняться).
На практике, однако, использование потоков выполнения в сценариях на языке Python почти тривиально. Запустим эту программу и позволим ей породить несколько новых потоков. На этот раз ее можно выполнять как в Unix-подобных системах, так и в Windows, потому что потоки переносятся лучше, чем ветвление процессов. Ниже приводится пример порождения потоков в Windows:
C:\...\PP4E\System\Threads> python thread1.py
Hello from thread 1
Hello from thread 2
Hello from thread 3
Hello from thread 4
q
Здесь каждое сообщение выводится новым потоком выполнения, который завершается почти сразу после запуска.
Другие способы реализации потоков с помощью модуля _thread
В предыдущем примере сценарий запускает простую функцию, тем не менее в отдельном потоке выполнения можно запустить любой вызываемый объект, благодаря тому что все потоки выполняются в рамках одного и того же процесса. Например, в отдельном потоке можно запустить lambda-функцию или связанный метод объекта (ниже приводится фрагмент сценария thread-alts.py, входящего в состав пакета с примерами к книге):
import _thread # во всех 3 случаях
# выводится 4294967296
def action(i): # простая функция
print(i ** 32)
class Power:
def __init__(self, i):
self.i = i
def action(self): # связанный метод
print(self.i ** 32)
_thread.start_new_thread(action, (2,)) # запуск простой функции
_thread.start_new_thread((lambda: action(2)), ()) # запуск lambda-функции
obj = Power(2)
_thread.start_new_thread(obj.action, ()) # запуск связанного метода
Как будет показано далее в книге, в более крупных примерах, в этой роли особенно полезными оказываются связанные методы - так как они хранят в себе и ссылку на функцию, и ссылку на экземпляр объекта, то они обладают доступом к информации о состоянии и методам класса, которые могут использовать в процессе выполнения внутри потока.
Если смотреть глубже - так как все потоки выполняются в рамках одного и того же процесса, то связанные методы, выполняемые в отдельных потоках, имеют доступ к оригинальному экземпляру объекта, а не к его копии. Следовательно, любые изменения, выполненные в потоке, автоматически будут видимы для всех остальных потоков. Кроме того, связанные методы экземпляров классов, как вызываемые объекты, могут использоваться вместо простых функций, поэтому использование их в потоках выполнения не влечет никаких сложностей. И, как будет показано далее, тот факт, что они являются обычными объектами, позволяет сохранять их в общедоступных очередях.
Запуск нескольких потоков
По-настоящему ощутить всю мощь параллельно выполняющихся потоков можно, только если реализовать в них выполнение продолжительных операций, как мы делали это выше для процессов. Изменим программу fork-count из предыдущего раздела так, чтобы в ней использовались потоки выполнения. В сценарии из примера 5.6 запускается 5 экземпляров функции counter, которые выполняются параллельно в отдельных потоках.
Пример 5.6. PP4E\System\Threads\thread-count.py
основы потоков: запускает 5 копий функции в параллельных потоках; функция time. sleep используется, чтобы главный поток не завершился слишком рано, так как на некоторых платформах это приведет к завершению остальных потоков выполнения; поток вывода stdout - общий: результаты, выводимые потоками выполнения, в этой версии могут перемешиваться произвольным образом.
import _thread as thread, time
def counter(myId, count): # эта функция выполняется в потоках
for i in range(count):
time.sleep(1) # имитировать работу
print(‘[%s] => %s’ % (myId, i))
for i in range(5): # породить 5 потоков выполнения
thread.start_new_thread(counter, (i, 5)) # каждый поток выполняет 5 циклов
time.sleep(6)
print(‘Main thread exiting.’) # задержать выход из программы
Каждая параллельно выполняющаяся копия функции counter просто считает здесь от нуля до четырех и при каждом увеличении счетчика выводит сообщение в поток стандартного вывода.
Обратите внимание, что в самом конце этот сценарий приостанавливается на 6 секунд. В Windows и в Linux, как было проверено, главный поток не должен завершаться, пока все порожденные потоки не закончили работу, если важно, чтобы они доработали. Если главный поток завершится раньше, все порожденные потоки будут немедленно завершены. Этим потоки выполнения отличаются от процессов, где дочерние процессы продолжают работать после завершения родительского процесса. Если убрать вызов функции sleep в конце сценария, порожденные потоки выполнения будут немедленно завершены, практически сразу же после их запуска.
Может показаться, что так сделано специально, но это необходимо не на всех платформах, и программы обычно реализованы так, чтобы главный поток выполнения продолжал работать столько же, сколько потоки, им запущенные. Например, интерфейс пользователя может начать загрузку файла по протоколу FTP в потоке, но продолжительность операции загрузки значительно короче, чем время жизни самого интерфейса пользователя. Далее в этом разделе мы увидим, как различными способами можно избежать этой паузы с помощью глобальных блокировок и флагов, позволяющих потокам выполнения сигнализировать о своем завершении.
Кроме того, далее мы узнаем, что модуль threading предоставляет метод join, который позволяет дождаться завершения порожденных потоков и не дает программе завершиться до того, пока хотя бы один обычный поток выполнения продолжает работу (что было бы полезно в данном случае, но в других случаях может потребовать выполнения дополнительных операций по принудительному завершению потоков). Пакет multiprocessing, с которым мы встретимся далее в этой главе, также позволяет потомкам продолжать работу после завершения родителя, но это в значительной степени объясняется использованием модели процессов.
Если теперь запустить сценарий из примера 5.6 в Windows 7 под управлением Python 3.1, он выведет:
C:\.
..
PP4E\
\System\Threads> python thread-count.py
[1]
=>
0
[1]
=>
0
[0]
=>
0
[1]
=>
0
[0]
=>
0
[2]
=>
0
[3]
=>
0
[3]
=>
0
[1]
=>
1
[3]
=>
1
[3]
=>
1
[0]
=>
1[2]
=>
1
[3]
=>
1
[0]
=>
1[2]
=>
1
[4]
=>
1
[1]
=>
2
[3]
=>
2[4]
=>
2
[3]
=>
2[4]
=>
2
[0]
=>
2
[3]
=>
2[4]
=>
2
[0]
=>
2
[2]
=>
2
[3]
=>
2[4]
=>
2
[0]
=>
2
[2]
=>
2
...часть вывода опущена...
Main thread exiting.
Полученные результаты, возможно, покажутся вам странными, но так они и должны выглядеть. Данный пример демонстрирует один из наиболее необычных аспектов потоков выполнения. В этом примере результаты 5 потоков, действующих параллельно, перемешались между собой. Поскольку все потоки выполняются в рамках одного и того же процесса, все они совместно используют один и тот же поток стандартного вывода (в терминах языка Python они совместно используют файл sys.stdout, куда выводит текст функция print). В результате вывод потоков выполнения может перемешиваться произвольно. На практике при каждом запуске этого сценария могут быть получены разные результаты. В Python 3 перемешивание вывода стало еще более явным, что, вероятно, обусловлено новой реализацией вывода в файлы.
Из этого следует важный вывод: когда несколько потоков выполнения могут совместно использовать некоторый ресурс, как в данном примере, операции доступа в них должны синхронизироваться, чтобы избежать перекрытия во времени, а как - будет описано в следующем разделе.
Синхронизация доступа к глобальным объектам и переменным
Приятной особенностью потоков выполнения является наличие готового механизма обмена данными между заданиями: объектов и переменных процесса, совместно используемых потоками. Например, поскольку все потоки выполняются в одном и том же процессе, то при изменении глобальной переменной одним потоком это изменение видно всем другим потокам в процессе: и главному потоку, и дочерним. Точно так же потоки могут совместно использовать изменяемые объекты в памяти процесса, при условии, что они хранят ссылки на них (например, полученные в виде аргументов). Это упрощает возможность передачи информации между потоками программы - флаги завершения, объекты с результатами, индикаторы событий и так далее.
Недостатком такой схемы является то, что потоки должны следить за тем, чтобы не изменять глобальные объекты одновременно. Если два потока одновременно изменяют объект, может случиться так, что одно из двух изменений будет утрачено (или, что еще хуже, совместно используемый объект придет в полностью негодное состояние): один поток может приступить к выполнению операций, когда другой поток еще не завершил работу с объектом. Приводит ли это к ошибке, зависит от приложения; иногда проблем вообще не возникает.
Проблемы могут возникнуть и там, где этого совсем не ждешь. Например, файлы и потоки ввода-вывода совместно используются всеми потоками выполнения программы - если несколько потоков выполнения одновременно производят запись в один и тот же поток ввода-вывода, в последнем могут появиться перемежающиеся искаженные данные. Пример 5.6 из предыдущего раздела является простой, но показательной демонстрацией подобного рода конфликтов, которые могут происходить при параллельном выполнении нескольких потоков. Даже простейшие изменения могут пустить все вкривь и вкось, когда есть вероятность одновременного их выполнения. Чтобы исключить подобные ошибки, программы должны управлять доступом к глобальным объектам, чтобы в каждый конкретный момент времени только один поток выполнения мог использовать их.
К счастью, в модуле _thread имеются собственные простые в использовании инструменты синхронизации потоков, выполняющих операции с совместно используемыми объектами. Эти инструменты основаны на понятии блокировки - чтобы изменить совместно используемый объект, потоки приобретают блокировку, производят требуемые изменения и освобождают блокировку для использования в других потоках выполнения. Интерпретатор гарантирует, что в каждый конкретный момент времени только один поток выполнения будет владеть блокировкой, - если запрос на приобретение блокировки поступит в тот момент, когда она удерживается некоторым потоком, запросивший поток будет приостановлен до того момента, пока блокировка не будет освобождена.
Объекты блокировки размещаются в памяти, обрабатываются с помощью простых и переносимых функций из модуля _thread и автоматически отображаются на механизмы блокировки потоков, существующие на соответствующей платформе.
Так, в примере 5.7 с помощью функции _thread.allocate_lock создается объект блокировки, который приобретается и освобождается каждым потоком выполнения перед вызовом функции print, с помощью которой осуществляется вывод в совместно используемый стандартный поток вывода.
Пример 5.7. PP4E\System\Threads\thread-count-mutex.py
синхронизирует доступ к stdout: так как это общий глобальный объект, данные, которые выводятся из потоков выполнения, могут перемешиваться, если не синхронизировать операции
import _thread as thread, time
def counter(myId, count): # эта функция выполняется в потоках
for i in range(count):
time.sleep(1) # имитировать работу
mutex.acquire()
print(‘[%s] => %s’ % (myId, i)) # теперь работа функции print
# не будет прерываться
mutex.release()
mutex = thread.allocate_lock() # создать объект блокировки
for i in range(5): # породить 5 потоков выполнения
thread.start_new_thread(counter, (i, 5)) # каждый поток выполняет 5 циклов
time.sleep(6)
print(‘Main thread exiting.’) # задержать выход из программы
В действительности этот сценарий является всего лишь расширенной версией примера 5.6, в которую была добавлена синхронизация обращений к функции print с применением блокировки. Благодаря этому никакие два потока выполнения в этом сценарии не смогут одновременно вызвать функцию print - блокировка гарантирует исключительный доступ к стандартному потоку вывода stdout. Таким образом, мы получаем вывод, сходный с выводом оригинальной версии, за исключением того, что текст на выходе никогда не будет перемешиваться из-за перекрывающихся операций вывода:
C:\...\PP4E\System\Threads> thread-count-mutex.py
[0] => 0
[1] => 0
[3] => 0
[2] => 0
[4] => 0
[0] => 1
[1] => 1
[3] => 1
[2] => 1
[4] => 1
[0] => 2
[1] => 2
[3] => 2
[4] => 2
[2] => 2
[0] => 3
[1] => 3
[3] => 3
[4] => 3
[2] => 3
[0] => 4
[1] => 4
[3] => 4
[4] => 4
[2] => 4
Main thread exiting.
Порядок, в каком потоки выполнения выводят свои данные, зависит от платформы и по-прежнему может изменяться от запуска к запуску, потому что они выполняются параллельно (в конце концов, потоки выполнения как раз и предназначены для параллельной обработки данных). Но они больше не конфликтуют при выводе текста. Далее в этой главе мы увидим другие случаи использования блокировок - блокировки являются важной составляющей многопоточной модели выполнения.