Стандартная библиотека

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

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

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

Содержание лекции

Введение

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

Вместе эти функции неплохо заменяют традиционную документацию: достаточно с помощью dir узнать, какие функции и классы есть в модуле, а затем с помощью help получить информацию о том, как их использовать. Пусть, например, нам нужна функция, которая вычисляет логарифм. Логично предположить, что она может быть в модуле math (подробнее о нем чуть ниже). Выяснить это можно с помощью функции dir:

In [1]:
import math
print(dir(math))
['__doc__', '__loader__', '__name__', '__package__', '__spec__', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'copysign', 'cos', 'cosh', 'degrees', 'e', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'inf', 'isclose', 'isfinite', 'isinf', 'isnan', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'nan', 'pi', 'pow', 'radians', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'tau', 'trunc']

Судя по имени, функция, которая нам нужна - это log. Теперь осталось прочитать о том, как ее правильно использовать:

In [2]:
help(math.log)
Help on built-in function log in module math:

log(...)
    log(x[, base])
    
    Return the logarithm of x to the given base.
    If the base not specified, returns the natural logarithm (base e) of x.

В этой лекции при описании функций и методов мы используем следующее соглашение:

  1. Все обязательные параметры функций и методов указываются всегда.
  2. Необязательные параметры выделяются курсивом и для них указывается значение по умолчанию.
  3. Некоторые необязательные параметры могут быть опущены, чтобы описание функции оставалось коротким. Об этом сигнализирует последовательность "..." в списке параметров. Для получения полной информации обращайтесь к справочному руководству или функции help.

Модуль builtins

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

В следующей таблице собраны функции для различных преобразований значений одного типа к значениям другого:

Название
Описание
bool(x)
Создает из x значение с типом bool; если x не задан, возвращает False
int(x)
Создает из числа или строки x значение с типом int; если x не задан, возвращает 0
float(x)
Создает из числа или строки x значение с типом float; если x не задан, возвращает 0.0
str(x)
Возвращает строковую версию объекта x; если x не задан, возвращает пустую строку
bin(x)
Преобразует целое числое x в строку, содержащую его двоичное представление
oct(x)
Преобразует целое числое x в строку, содержащую его восьмеричное представление
hex(x)
Преобразует целое числое x в строку, содержащую его шестнадцатеричное представление
chr(x)
Возвращает строку с символом, имеющим позицию x в таблице Unicode
ord(x)
Возвращает позицию в таблице Unicode для символа x
In [3]:
num = 10
s = '100'

print(str(num))
print(int(s))
print(bin(num))
print(chr(65))
10
100
0b1010
A

В модуле builtins существует специальная функция, с помощью которой можно попросить пользователя ввести какие-нибудь данные:

Название
Описание
input(prompt='', ...)
Возвращает строку c данными, введенными пользователем (если prompt не пустой, отображает его рядом с полем для ввода)
In [4]:
name = input('Enter you name: ')
print('Hello, {}'.format(name))
Enter you name: Bob
Hello, Bob

Функции для работы с коллекциями мы уже рассматривали, но для полноты перечислим их здесь еще раз:

Название
Описание
len(col)
Возвращает количество элементов в коллекции
all(col)
Возвращает True, если все элементы коллекции в логическом контексте оцениваются как True
any(col)
Возвращает True, если хотя бы один элемент коллекции в логическом контексте оцениваeтся как True
max(col, ...)
Возвращает наибольший элемент в коллекции
min(col, ...)
Возвращает наименьший элемент в коллекции
sum(col, start=0)
Возвращает сумму элементов коллекции плюс элемент start
sorted(col, key=None, reverse=False)
Возвращает список отсортированных в прямом или обратном (reverse=True) порядке элементов коллекции
range(start=0, stop, step=1)
Возвращает итератор на последовательность целых чисел от start до stop с шагом step

То же самое касается и функций для работы с иерархией наследования:

Название
Описание
isinstance(object, class)
Возвращает True, если object является экземпляром class
issubclass(subclass, class)
Возвращает True, если subclass является подклассом class
super(...)
Возвращает специальный объект, с помощью которого можно вызывать в наследнике методы базового класса

Следующие функции могут использоваться для выполнения кода на языке Python:

Название
Описание
eval(expression, ...)
Выполняет выражение Python, записанное в строке expression
exec(code_block, ...)
Выполняет произвольный блок кода Python, записанный в строке code_block

Функция eval позволяет выполнить простые выражения языка Python, записанные в обычной строке:

In [5]:
x = 7
y = 5
eval('print((x + y) * (x - y))')
24

Функция exec обладает гораздо большими возможностями. По сути, с ее помощью можно выполнить текст любой корректной программы на языке Python:

In [6]:
program = '''
def get_roots(a, b, c):
       d = b**2 - 4*a*c 
       x1 = (-b + d**0.5) / (2*a)
       x2 = (-b - d**0.5) / (2*a)
       return x1, x2
    
print(get_roots(1, -5, 6))
'''

exec(program)
(3.0, 2.0)

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

In [7]:
print(get_roots(1, -3, -10))
(5.0, -2.0)

Теперь расскажем о том, как происходит работа с файлами в Python. Для этого в модуле builtins есть следующая функция:

Название
Описание
open(filename, mode='r', ...)
Открывает файл с именем filename в режиме mode ('r', 'w', 'a' - чтение, запись, добавление в конец).
Возвращает объект, который будет использоваться для работы с файлом.
  1. В режиме чтения r информацию можно только прочитать из файла, записать в него ничего нельзя. Если указанный файл не существует, интерпретатор генерирует исключение FileNotFoundError.
  2. В режиме записи w в файл можно только записать данные. Если указанный файл не существует, то он создается. Если же существует, то немедленно полностью очищается
  3. Режим добавления a отличается от режима записи только то, что в случае добавления, файл не очищается при открытии, а все записываемые данные вставляются в его конец.

По умолчанию, функция open считает, что файл, который нужно открыть, является текстовым. Примером таких файлов являются файлы .txt, .html, .xml и многие другие. Их отличает то, что информация в них хранится в виде строк печатных символов (букв, цифр и т.д.). Большинство файлов, однако, являются двоичными, т.е. содержащими произвольный набор байтов, а не только печатные символы. Указать функции open, что открыть файл нужно как двоичный, можно, добавив к аргументу mode символ "b", например "rb" (прочитать двоичный файл) или "wb" (записать двоичный файл). Работа с двоичными файлами в целом аналогична работе с текстовыми, поэтому мы не будем рассматривать ее отдельно.

При открытии текстового файла, функция open возвращает объект класса TextIOWrapper, с помощью которого можно получить доступ к следующим методам (часть из них определена в самом классе, а часть - в его родителях):

Название
Описание
read(size=-1)
Считывает не более size байт из файла (по умолчанию - все)
readline(...)
Считывает одну строку из файла (если весь файл прочитан, возвращает пустую строку)
readlines(...)
Возвращает список, содеражащий строки файла
write(s)
Записывает строку в файл
writelines(l)
Записывает список строк в файл
flush()
Сохраняет данные из внутреннего буфера на диск
close()
Сохраняет данные из внутреннего буфера на диск и закрывает файл

Стоит подробнее рассказать про функции flush и close.

Первая нужна потому, что когда вы записываете что-то в файл, данные на самом деле не попадают сразу на жесткий диск. Вместо этого они помещаются в некоторый буфер в оперативной памяти, который целиком сохраняется на диск, только если он становится достаточно большим, либо вызывается функция flush. Такой алгоритм используется потому, что операции с жестким диском выполняются очень медленно (на 3 порядка медленнее операций с оперативной памятью), причем значительное влияние на это оказывается не только размером данных, но и другими факторами. Проще говоря, 10 записей на диск одного байта будут выполняться значительно дольше, чем одна запись 10 байт. Буфер как раз и позволяет накопить много данных, а потом сохранить их все одной операцией.

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

Давайте теперь перейдем к простым примерам. Для начала создадим в нашей операционной системе простой текстовый файл text.txt, в который поместим несоколько строк.

Следующий код прочитает файл и выведет его на экран:

In [8]:
file = open('test.txt')

while True:
    line = file.readline()
    
    if len(line) != 0:
        print(line)
    else:
        # all read
        break

file.close()
My uncle — high ideals inspire him;

but when past joking he fell sick,

he really forced one to admire him —

and never played a shrewder trick.

Тип TextIOWrapper является итерируемым (итерация происходит по строкам), поэтому пример можно реализовать изящнее:

In [9]:
file = open('test.txt')
for line in file:
    print(line)
file.close()
My uncle — high ideals inspire him;

but when past joking he fell sick,

he really forced one to admire him —

and never played a shrewder trick.

Попробуем теперь записать что-нибудь в текстовый файл:

In [10]:
file = open('test2.txt', 'w') # режим записи!
file.write('this is first line\n') # '\n' означает переход на новую строку
file.write('this is second line')
file.close()

В заключение рассмотрим еще один пример:

In [11]:
class SomeError(Exception): pass

# эта функция моделирует какую-то полезную работу, во время
# выполнения которой может быть сгенерировано исключение
def some_processing():
    raise SomeError;

# далее идет реализация основной программы
    
file = open('test3.txt', 'w')
file.write('line1\n')
file.write('line2')
some_processing()
file.close()
---------------------------------------------------------------------------
SomeError                                 Traceback (most recent call last)
<ipython-input-11-b3f1a9b315fe> in <module>()
     11 file.write('line1\n')
     12 file.write('line2')
---> 13 some_processing()
     14 file.close()

<ipython-input-11-b3f1a9b315fe> in some_processing()
      4 # выполнения которой может быть сгенерировано исключение
      5 def some_processing():
----> 6     raise SomeError;
      7 
      8 # далее идет реализация основной программы

SomeError: 

Если посмотреть на результат выполнения этой программы, то окажется, что файл test3.txt создан, но пустой. Это произошло потому, что записанные в него данные не были сохранены на диск, а находились в буфере, когда было сгенерировано исключение. Это исключение прервало выполнение программы, и инструкция file.close(), которая записала бы данные из буфера на диск, так и не была выполнена. Чтобы такой ошибки не возникало, мы можем воспользоваться блоком try...except...finally:

In [ ]:
file = None
try:
    file = open('test3.txt', 'w')
    file.write('line1\n')
    file.write('line2')
    some_processing()
except:
    print('Error occurred')
finally:
    if file:
        file.close()

Теперь все отработало правильно - две строки оказались записаны в файл test3.txt. Можно было бы на этом остановиться, но мы хотим рассказать про более элегантный способ достижения такого же результата. Он основывается на инструкции with, имеющей следующий синтаксис:

with expression as variable:
    code_block

Инструкция with используется для создания менеджера контекста - объекта, который реализует специальные методы __enter__ и __exit__. Метод __enter__ вызывается, когда менеджер контекста создается с помощью выражения expression, а метод __exit__ - когда прекращается выполнение code_block (в том, числе, из-за возникшего исключения).

Объекты, возвращаемые функций open, являются менеджерами контекста, причем в своей реализации метода __exit__ они закрывают файл, для которого были созданы. Это позволяет переписать предыдущую версию программы без блока finally:

In [12]:
try:
    with open('test3.txt', 'w') as file:
        file.write('line1\n')
        file.write('line2')
        some_processing()
except:
    print('Error occurred')
Error occurred

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

Модули math и random

Модуль math предоставляет различные математические функции и константы.

Название
Описание
ceil(x)
Возвращает наименьшее целое число большее или равное числу x
floor(x)
Возвращает наибольшее целое число меньшее или равное числу x
gcd(x, y)
Возвращает наибольший общий делитель целых чисел x и y
exp(x)
Возвращает экспоненту в степени x
log(x, base=e)
Возвращает логарифм числа x по основанию base
pow(x, y)
Возвращает число x, возведенное в степень y
sqrt(x)
Возвращает квадратный корень из x
cos(x)
Возвращает косинус для угла в x радиан
sin(x)
Возвращает синус для угла в x радиан
tan(x)
Возвращает тангенс для угла в x радиан
acos(x)
Возвращает арккосинус числа x в радианах
asin(x)
Возвращает арксинус числа x в радианах
atan(x)
Возвращает арктангенс числа x в радианах
degrees(x)
Преобразует угол x из радиан в градусы
radians(x)
Преобразует угол x из градусов в радианы

Среди констант, определенных в модуле math есть $\pi$ (math.pi) и $e$ (math.e).

Рассмотрим небольшой пример использования функций из модуля math:

In [13]:
import math
x = 3.77

# исторически сложилось, что функция, которая выполняет обычное округление
# в соответствии с правилами арифметики, определена в модуле builtins
print(math.ceil(x), math.floor(x), round(x))

angle = 45 # в градусах
print(math.cos(math.radians(45)))

print(math.pow(3.5, 1.23))
print(math.log(100, 10))
4 3 4
0.7071067811865476
4.668783061171329
2.0

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

Название
Описание
seed(data=None, ...)
Инициализирует с помощью data начальное значение, с которого начнется генерация случайных чисел
choice(seq)
Возвращает случайный элемент из последовательности
shuffle(seq, ...)
Перемешивает случайным образом элементы последовательности
random()
Возвращает следующее случайное число из диапазона [0.0, 1.0)
uniform(a, b)
Возвращает случайное число, распределенное равномерно в интервале [a, b]
expovariate(lambda)
Возвращает случайное число, распределенное по экспоненциальному закону с параметром lambda
normalvariate(mu, sigma)
Возвращает случайное число, распределенное по нормальному закону с параметрами mu и sigma
randint(a, b)
Возвращает целое случайное число из диапазона [a, b]

Немного поясним функцию seed. Как уже было сказано, все функции модуля random генерируют псевдослучайные числа, высчитывая каждое следующее из предыдущего по сложной формуле. Функция seed задает начальное значение, из которого потом будут получаться все "случайные" числа. Это означает, что для одного и того же аргумента seed будет возвращаться одна и та же последовательность чисел:

In [14]:
import random

random.seed('test')
print(random.random(), random.random(), random.random())

random.seed('test')
print(random.random(), random.random(), random.random())
0.6555960613392863 0.7346312914910028 0.25037423249421464
0.6555960613392863 0.7346312914910028 0.25037423249421464

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

Модуль datetime

Этот модуль содержит реализацию нескольких типов для работы с датой и временем:

  1. Класс date представляет дату и имеет такие атрибуты, как year, month и day.
  2. Класс time представляет время и имеет такие атрибуты, как hour, minute, second, microsecond.
  3. Класс datetime представляет дату и время, объединяя предыдущие два типа и их атрибуты.
  4. Класс timedelta представляет разницу между двумя моментами времени и имеет такие атрибуты, как days, seconds и microseconds.

В классе datetime существует несколько методов, которые можно вызывать без объекта (они определены с помощью декоратора classmethod. Они реализуют разные способы создания объекта класса datetime:

Название
Описание
today()
Возвращает объект класс datetime для текущего времени
combine(date, time)
Возвращает объект класса datetime, созданный из объектов классов date и time
fromtimestamp(timestamp, ...)
Возвращает объект класса datetime, созданный из timestamp (см. далее)
strptime(datetime_str, format_spec)
Возвращает объект класса datetime, созданный из строки datetime_str

Метод fromtimestamp создает объект datetime из таймстэмпа (этот термин в русском языке употребляется именно так, если вы скажете "временная отметка", вас не поймут), представляющего собой количество секунд, прошедших с начала времен. Это начало времен может отличаться для разных операционных систем, но в большинстве случаев в качестве него используется 01/01/1970 00:00:00 по Гринвичу. Таймстампы являются удобным вариантом хранения информации о дате и времени, и часто используются в операционных системах, базах данных и т.д.

Метод strptime принимает в качестве параметра строку format_spec, которая описывает, как именно компоненты даты и времени записаны в datetime_str. Для этого в format_spec можно использовать следующие заполнители: %Y (год, 4 знака), %y (год, 2 знака), %m (месяц), %A (день недели), %d (день), %H (час), %M (минута), %S (секунда) и другие.

In [15]:
from datetime import datetime
datetime_str = '01.02.17 23:59:59'
dt = datetime.strptime(datetime_str, '%d.%m.%y %H:%M:%S')
print('{}.{}.{} {}:{}:{}'.format(\
      dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second))
2017.2.1 23:59:59

Перечислим некоторые методы экземпляров класса datetime:

Название
Описание
date()
Возвращает объект класса date для представления даты
time()
Возвращает объект класса time для представления времени
timestamp()
Возвращает таймстэмп, соответствующий объекту
weekday()
Возвращает число, обозначающее день недели (0 - понедельник, 1 - вторник, ...)
strftime(format_spec)
Возвращает строковое представление даты, сформированное в соотвествии с format_spec
In [16]:
from datetime import date
dt = datetime.today()
print(dt.strftime('Today is %A, %d. Current time is %H:%M:%S'))
Today is Wednesday, 04. Current time is 14:43:27

Класс timedelta дополняет функциональность datetime, предоставляя возможность удобно изменять объекты последнего, а также вычислять разницу между произвольными моментами времени. Объекты класса timedelta имеют атрибуты days, seconds и microseconds. Его конструктор может принимать любой набор из следующих именованных аргументов: days, seconds, microseconds, milliseconds, minutes, hours и weeks.

In [17]:
from datetime import datetime, timedelta
delta = timedelta(days=35, minutes=51)
dt = datetime.today()

# какое время будет через 35 дней и 51 минуту?

dt += delta 
print(dt)

# сколько секунд между 21-05-1984 и сегодняшним днем?

dt1 = datetime.strptime('21-05-1984','%d-%m-%Y')
dt2 = datetime.today()

delta = dt2 - dt1
seconds_in_day = 86400
print(delta.days * 86400 + delta.seconds)
2018-08-08 15:34:29.521114
1076769809

Методы классов date и time повторяют те, которые есть в классе datetime.

Модуль threading

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

Операционной системой называют совокупность множества программ, предназначенных для управления ресурсами компьютера и выполняющимися пользовательскими программами, которые называются процессами. Пока процесс выполняется, ОС следит за ним и отслеживает все его обращения к общим ресурсам компьютера (таким как оперативная память, жесткий диск, сетевая карта и т.д.). Цели, которые достигаются благодаря этому, следующие:

  • Изоляция одного процесса от всех остальных. Это нужно для того, чтобы процессы не могли случайно или намеренно повредить работе друг друга (например, изменив участок памяти, используемый другим процессом).
  • Контроль ресурсов позволяет ОС точно знать, какие из них используются каждым процессом. Это позволяет обнаруживать "подозрительные" процессы (потребляющие слишком много ресурсов), а также гарантировать освобождение занятых ресурсов, когда процесс завершается (обычным образом или же из-за возникшего исключения)

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

Потоки

Ответ заключается в том, что в параллельность в современных операционных системах (Windows, Linux, MacOS) достигается с помощью алгоритма вытесняющей многозадачности. Суть его в том, что ОС предоставляет каждому потоку небольшой квант времени (несколько миллисекунд), в течение которого он выполняется процессором. Когда это время заканчивается, ОС заменяет выполняющийся поток на другой.

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

Самым важным типом данных, предоставляемым модулем threading является класс Thread, имеющий следующие методы:

Название
Описание
__init__(group=None, target=None, ...)
Конструктор класса, в параметре target нужно передать вызываемый объект
start()
Запускает поток, а затем начинает выполнять в нем метод __call__ вызываемого объекта target
join()
Ждет завершения потока, которое происходит тогда, когда метод __call__
вызываемого объекта target выполняет инструкцию return

В следующем примере мы используем функцию sleep из модуля time, которая заставляет поток "уснуть" на указанное время. Остальные функции этого модуля не представляют большого интереса.

In [18]:
import time
import datetime
import threading

class ThreadOperation:
    def __call__(self): # этот метод будет выполняться в отдельном потоке!
        for i in range(5):
            current_time = datetime.datetime.today().time()
            
            print('')
            print('second thread: {} -- {}'.format(\
                  current_time.strftime('%H:%M:%S'), i))
            
            time.sleep(5) # спим 5 секунд


# основной поток

second_thread = threading.Thread(target=ThreadOperation())
second_thread.start()

for i in range(5):
    current_time = datetime.datetime.today().time()
    
    print('')
    print('main thread: {} -- {}'.format(\
          current_time.strftime('%H:%M:%S'), i))
    
    time.sleep(3) # спим 3 секунды

# нужно ОБЯЗАТЕЛЬНО дожидаться завершения дополнительных потоков,
# иначе при завершении основной программы они будут остановлены
# операционной системой, и могут не успеть выполнить свою работу
second_thread.join()

# на эту строку кода мы перейдем после того, как завершится second_thread
print('')
print('all done')
second thread: 14:43:34 -- 0

main thread: 14:43:34 -- 0

main thread: 14:43:37 -- 1

second thread: 14:43:39 -- 1

main thread: 14:43:40 -- 2

main thread: 14:43:43 -- 3

second thread: 14:43:44 -- 2

main thread: 14:43:46 -- 4

second thread: 14:43:49 -- 3

second thread: 14:43:54 -- 4

all done

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

In [19]:
import threading

# это общая переменная для двух потоков
common_variable = 0

class ThreadOperation:
    def __call__(self):
        global common_variable
        for i in range(1000000):
            common_variable = common_variable + 1


# основной поток

second_thread = threading.Thread(target=ThreadOperation())
second_thread.start()

for i in range(1000000):
    common_variable = common_variable + 1

second_thread.join()

print(common_variable)
1193943

Давайте проанализируем результат. Казалось бы, оба потока должны были увеличить значение common_variable на миллион, значит в результате значением переменной должно было стать два миллиона, но это не так. Более того, если вы несколько раз запустите программу, то увидите, что результаты будут разными. Почему это происходит?

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

  1. common_variable = 0
  2. основной поток прочитал значение common_variable (0)
  3. основной поток прибавил 1 к прочитанному значению, получил 1
  4. ОС вытеснила основной поток с процессора и поместила туда второй
  5. второй поток прочитал значение common_variable (оно по-прежнему 0, ведь основной поток не успел записать 1 в память!)
  6. второй поток прибавил 1 к прочитанному значению, получил 1
  7. второй поток записал 1 в common_variable
  8. ОС вытеснила второй поток с процессора и поместила туда основной
  9. основной продолжил свое выполнение с того места, где его прервали и записал 1 в common_variable

Как видите, оба потока по разу прибавили 1 к common_variable, но его значение стало 1, а не 2!

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

Простейший способ добиться этого - использовать блокировку (англ. lock), представляющую собой специальный объект для синхронизации доступа к общим данным. Типом блокировки является класс Lock, который предоставляет два метода:

  1. acquire(...) - захватить блокировку. Если блокировка уже захвачена, то поток, который пытается ее захватить, будет ждать, пока она не освободится.
  2. release() - освободить блокировку. После этого она может быть захвачена другим потоком.

Покажем, как переписать наш пример, чтобы не было ошибки:

In [20]:
import threading

# общая переменная для двух потоков
common_variable = 0

# блокировка для синхронизации доступа к общей переменной
common_lock = threading.Lock()

class ThreadOperation:
    def __call__(self):
        global common_variable
        global common_lock
        
        for i in range(1000000):
            common_lock.acquire()
            common_variable = common_variable + 1
            common_lock.release()


# основной поток

second_thread = threading.Thread(target=ThreadOperation())
second_thread.start()

for i in range(1000000):
    common_lock.acquire()
    common_variable = common_variable + 1
    common_lock.release()

second_thread.join()

print(common_variable)
2000000

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

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

Еще одним полезным типом, используемым для синхронизации потоков, является Condition. Он используется тогда, когда потоку нужно ждать наступления некоторого события, которое инициируется другим потоком. Объект Condition использует объект Lock для реализации следующих методов:

Название
Описание
__init__(lock=None)
Конструктор класса, в параметре lock можно передать блокировку, которую будет
использовать объект (если None, то блокировка будет создана автоматически)
acquire(...)
Захватывает блокировку, связанную с объектом
release()
Освобождает блокировку, связанную с объектом
wait(timeout=None)
Ждет, пока не придет нотификация или не истечет таймаут (None трактуется как бесконечный таймаут)
notify(...)
Нотифицирует один поток из тех, что ждут нотификации (поток выбирается случайным образом)
notify_all()
Нотифицирует все потоки, которые ждут нотификации

Методы acquire и release работают так же, как и для блокировок (собственно, они просто вызывают соответствующий метод у блокировки).

Метод wait должен вызываться только после того, как была захвачена блокировка с помощью метода acquire. В этом случае, он освобождает ее, а затем поток, который вызывал метод wait "засыпает" до тех пор, пока он не будет нотифицирован с помощью метода notify или notify_all. Когда это происходит, поток "просыпается", снова захватывает блокировку и только после этого происходит возврат из функции wait. Важно запомнить, что при выходе из wait блокировка уже захвачена, и можно спокойно модифицировать общие данные.

Методы notify и notify_all также должны вызываться тогда, когда блокировка уже захвачена с помощью метода acquire. Методы "пробуждают" один или все потоки, вызвавшие wait для этого объекта Condition. После этого происходит возврат из методов notifyи notify_all, причем блокировка остается захваченной. Чтобы потоки, которые были "разбужены", смогли выполнить свой код, ее нужно освободить с помощью метода release.

В качестве примера рассмотрим типичную задачу, возникающую при создании многопоточных программ. Она заключается в том, что один поток создает какие-то данные, а другой - обрабатывает их. Между этими потоками находится список или другая подходящая коллекция, в которую первый поток помещает данные, а второй затем забирает их из нее и выполняет обработку. Эта модель иногда называется "producer-consumer", и без объекта Condition ее реализация может выглядеть так:

In [21]:
import time
import random

from datetime import datetime
from threading import Thread
from threading import Lock


# общий буфер и блокировка для него
buffer = list()
buffer_lock = Lock()

class ProducerOp:
    def __call__(self):
        for i in range(1, 6):
            buffer_lock.acquire()
            buffer.append(i)
            buffer_lock.release()
            time.sleep(random.randint(0, 5)) # пусть поток спит 0-5 секунд
        
        # вставляем в список специальное значение, по которому
        # consumer-поток определит, что больше данных не будет
        buffer_lock.acquire()
        buffer.append(0)
        buffer_lock.release()

class ConsumerOp:
    def __call__(self):
        should_exit = False
        
        # постоянно в цикле пытаемся обнаружить новый элемент в буфере
        while not should_exit:
            buffer_lock.acquire()
            
            if len(buffer) != 0:
                value = buffer.pop(0)
                time_str = datetime.today().time().strftime('%H:%M:%S')
                print(time_str, 'processed:', value ** 2)
                
                if value == 0:
                    should_exit = True
            
            buffer_lock.release()


# основной поток

producer = Thread(target=ProducerOp())
consumer = Thread(target=ConsumerOp())

producer.start()
consumer.start()

producer.join()
consumer.join()

print('all done')
14:44:54 processed: 1
14:44:55 processed: 4
14:44:56 processed: 9
14:44:56 processed: 16
14:45:00 processed: 25
14:45:03 processed: 0
all done

В этой реализации есть один очень существенный недостаток - поток, обрабатывающий данные, вынужден постоянно "крутиться" в цикле, чтобы обнаруживать и обрабатывать новые элементы. Такие потоки обеспечивают процессору загрузку, близкую к 100%. Гораздо лучше было бы, чтобы все время, пока данные в списке отсутствуют, поток просто "спал" и не загружал процессор вообще. Добиться этого и позволяет объект Condition, которым вы воспользуемся в новой реализации нашей программы:

In [22]:
import time
import random

from datetime import datetime
from threading import Thread
from threading import Condition


buffer = list()
buffer_cond = Condition()

class ProducerOp:
    def __call__(self):
        for i in range(1, 6):
            # захватываем блокировку, чтобы записать элемент в общий буфер
            buffer_cond.acquire()
            buffer.append(i)
            
            # нотифицируем один поток, что в буфере теперь есть элемент
            buffer_cond.notify()
            
            # осовобождаем блокировку, чтобы consumer мог обработать элемент
            buffer_cond.release()
            
            time.sleep(random.randint(0, 5)) # пусть поток спит 0-5 секунд
        
        # вставляем в список специальное значение, по которому
        # consumer-поток определит, что больше данных не будет
        buffer_cond.acquire()
        buffer.append(0)
        buffer_cond.notify()
        buffer_cond.release()

class ConsumerOp:
    def __call__(self):
        should_exit = False
        
        while not should_exit:
            buffer_cond.acquire()
            
            if len(buffer) == 0:
                # буфер пустой, поэтому ждем, пока появится следующий элемент
                buffer_cond.wait()
            
            # дождались, обрабатываем элемент (блокировка уже захвачена, не
            # нужно опять вызывать acquire!)
            
            value = buffer.pop(0)
            time_str = datetime.today().time().strftime('%H:%M:%S')
            print(time_str, 'processed:', value ** 2)
            
            if value == 0:
                should_exit = True
            
            # освобождаем блокировку, чтобы producer смог добавить новый элемент
            buffer_cond.release()


# основной поток

producer = Thread(target=ProducerOp())
consumer = Thread(target=ConsumerOp())

producer.start()
consumer.start()

producer.join()
consumer.join()

print('all done')
14:45:14 processed: 1
14:45:15 processed: 4
14:45:16 processed: 9
14:45:20 processed: 16
14:45:22 processed: 25
14:45:26 processed: 0
all done

Вопросы для самоконтроля

  1. Объясните, как происходит запись данных в файл? Для чего нужен метод flush?
  2. Что такое менеджер контекста? Для чего его можно использовать?
  3. Безопасно ли использовать функции из модуля random для генерации пароля пользователя в какой-нибудь системе? Объясните ответ.
  4. Что такое процесс и поток?
  5. Каким образом в совеременных ОС реализовано параллельное выполнение множества программ?
  6. Что такое синхронизация потоков? Для чего она нужна? Какие объекты в Python используются для обеспечения ее?

Задание

  1. Создайте программу, которая получает на вход список, в котором перечислено произвольное количество текстовых файлов file1, ..., fileN, а в результате создает файлы file1_stat, ..., fileN_stat, в которых представлена информация о том, сколько раз встречается то или иное слово в соответствующем файле fileK. Эта программа должна содержать минимум три потока:
    • поток, который считывает данные из файла fileK в строку
    • поток, который подсчитывает, сколько раз встречается каждое слово в файле
    • поток, который записывает эту информацию в файл fileK_stat в виде строк "слово: сколько_раз_встречается"