вторник, 30 ноября 2010 г.

Мультипоточность в Питоне. Часть 1 - потоки

Продолжение - здесь

Писать многопоточные программы сложно. К сожалению, время от времени это приходится делать.

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

Для начала рассмотрим минимально необходимый базис:

  • работа с потоками
  • синхронизация разделяемых объектов (тех, которые одновременно используются в нескольких потоках)

Различные интересные вариации (неблокирующий доступ, невытесняющая многозадачность, распараллеливание на процессы) лежат несколько в стороне от "основной линии". Возможно, они будут рассмотрены в конце серии.

Потоки.

Потоки нужно

  • создавать
  • завершать
  • дожидаться завершения

Изначально у каждой программы есть один поток - он же "главный". Этот поток создается операционной системой при запуске процесса.

С точки зрения программиста почти не отличается от созданных вручную. Практически же существуют некоторые особенности.

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

Создание потока.

Работой с потоком занимается threading.Thread (документация).

Меня захотят поправить - есть еще thread. Добрый совет - никогда его не используйте. Потому что!.. Цитата из документации: "The threading module provides an easier to use and higher-level threading API built on top of thread module."

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

Первый вариант:

def f(a, b, c):
    # do something
    pass

th = threading.Thread(name='th1', target=f, args=(1, 2), kwargs={'c': 3})
  • name - имя потока. Ни на что не влияет, но может быть полезно при отладке.
  • target - точка входа. Любой callable object - функция, связязанный метод класса или что-то еще
  • args - позиционные аргументы
  • kwargs - именованные аргументы.

Поток с именем 'th1' будет создан, но не запущен. После запуска будет вызвана функция f с параметрами a=1, b=2, c=3. Все аргументы могут быть опущены.

Второй вариант:

class MyThread(threading.Thread):
    def __init__(self, a, b, c):
        threading.Thread.__init__(self)
        self.a = a
        self.b = b
        self.c = c

    def run(self):
        # do something, using self.a, self.b, self.c
        pass

th = MyThread(1, 2, 3)

Результат практически тот же самый, но в новом потоке будет запущен метод run.

После того, как объект создан - его нужно запустить. В обоих случаях это делается через вызов

th1.start()

Все очень просто и понятно, верно?

Завершение потока.

Любой поток рано или поздно нужно завершить. Делается это простым выходом из функции потока. Не существует ПРАВИЛЬНОГО способа завершить поток снаружи. Это - принципиальное ограничение. Т.е. если вы хотите завершить поток из другого - просигнализируйте ему о своей просьбе (выставив флаг-переменную, например). В конце статьи я объясню, почему это важно.

Присоединение потока.

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

# signal to th1 for exiting
th1.join()

.join приостановит выполнение потока, вызвавшего ее, и будет ждать когда поток th1 завершит свое выполнение. Зачастую поток, стартовавший th1 его же и ждет - но бывают и исключения.

Почему нельзя "прибить" поток снаружи?

Если подумать, то все очень просто.

С точки зрения операционной системы программа (запущенный процесс) и потоки - ортогональные вещи.

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

Поток - это нить исполнения. Он просто исполняет код параллельно с другими потоками. При этом поток ресурсами не владеет.

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

Возникает еще один вопрос: а почему нельзя послать потоку исключение, чтобы он корректно свернулся - освободив при этом все что нужно?

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

Объекты блокировки - это в общем случае объекты ядра операционной системы. Т.е. блокированный поток нельзя "разблокировать", не затронув другие потоки.

Т.е. принудительное завершение потока - это потенциально опасная операция. В некоторых случаях все получается, иногда происходит утечка ресурсов.

Несмотря на то, что в низкоуровневом API операционной системы почти всегда есть способ аварийно завершить поток, в Питоне он не реализован.

Давайте просто писать "правильно", к чему нас и подталкивают.

Зачем нужно ждать завершения потока?

Хорошо, поток создан и запущен. В свое время ему послана просьба завершить работу. Зачем ждать подтверждения?

Нет, это не паранойя.

Во первых, так заведено в Питоне (корни растут из posix threads) - процесс завершается только тогда, когда все его потоки завершены.

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

Откровенно говоря, есть лазейка, позволяющая это правило обойти. Достаточно указать

th1.setDaemon(True)

и можно о нем забыть. Отличие потока-демона от "стандартного" только в том, что демоны "не считаются" при завершении процесса.

Вуаля, решение найдено и оно работает? Не совсем.

Дело в том, что программа завершается не мгновенно. После того, как закончился главный поток с точки зрения программиста, Питон начинает чистить свои ресурсы, разрушая интерпретатор.

Закрываются файлы, уничтожаются модули.

И такой безобидный код как

import logging

def f():
    a = logging.getLogger("a.b.c").debug("f is called")

Может "поломаться", потому что модуль logging уже не существует, вернее - он частично разрушен сборщиком мусора.

При аккуратном программировании можно обойти все проблемы, связанные с саморазрушением интерпретатора - но оно вам нужно?

Проще и безопасней писать "по правилам".

Заключение.

С потоками покончено.

  • Потоки можно создавать и запускать.
  • Можно просить их закончить свою работу, но нельзя приказывать.
  • Завершения потока нужно дожидаться.

Но мультипоточное программирование только начинается.

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

Или, другими словами, изменять состояние разделяемых между ними объектов. Несколько потоков могут пытаться изменить один объект одновременно, с непредсказуемым результатом.

Объекты могут быть сложными, и запись в объект "пользователь" фамилии одним потоком, а имени другим может привести к неожиданному результату. Существуют и более "разрушительные" примеры.

Для решения проблемы придуманы объекты синхронизации. О них - в следующей статье.

3 комментария:

  1. Спасибо, очень вовремя мне пришлось :)
    Буквально пару дней назад осиливал потоки, почитаю ваше!

    ОтветитьУдалить
  2. Очень хорошо и доступно написано. Спасибо.

    ОтветитьУдалить
  3. Большое спасибо Вам !

    ОтветитьУдалить