понедельник, 4 июля 2011 г.

GIL и обработка сигналов

Небольшое добавление к статье о GIL.

Эта часть касается только поведения Питона на posix системах. Я проводил эксперимент на Linux, но на FreeBSD и MacOS результат должен быть тем же. На Windows свои тараканы, к последующему изложению не имеющие никакого отношения.

Описание проблемы

Итак, имеем простой код:

import threading

threads = []

running = True

def f():
    while running:
        pass

for i in range(1):
    th = threading.Thread(target=f)
    threads.append(th)
    th.start()

for th in threads:
    th.join()

Что произойдет, если после запуска пользователь нажмет <Ctrl+C>? Будет послан сигнал SIGINT, который мы не умеем обрабатывать.

Отлично, добавим нужное:

import threading
import signal

threads = []

running = True

def f():
    while running:
        pass

for i in range(1):
    th = threading.Thread(target=f)
    threads.append(th)
    th.start()

def sig_handler(sig_num, frame):
    print('SIGNAL')
    global running
    running = False

signal.signal(signal.SIGINT, sig_handler)

for th in threads:
    th.join()

Всё заработало?

Зависит от версии Питона. На 3.2 действительно всё отлично.

Python 2.7 не желает вызывать зарегистрированный обработчик сигнала. print не печатается, потоки не останавливаются.

В чём же дело?

Обработка сигналов в Питоне

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

Когда ОС вызывает этот обработчик, она предоставляет ему отдельный, очень маленький стек, на котором Питону просто не хватит места развернуться.

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

Есть еще одна особенность. Стандарт posix заявляет, что обработчик сигнала должен выполняться в главном потоке (том самом, который создается при запуске процесса). Разные операционные системы (и даже разные версии ядра linux) расширяют эти строгие рамки, но Питон в данном вопросе предельно консервативен — должен быть главный поток и всё тут!

Для этого Питон временно сбрасывает порог переключения GIL со 100 (или сколько там выставлено в sys.setcheckinterval до 1 в надежде быстро добраться до главного потока и асинхронно выполнить в нём свой питоновский код, зарегистрированный как обработчик сигнала.

Асинхронность в данном случае означает, что настоящий обработчик выполняется не сразу, а когда придет его очередь.

Так вот, Питон быстро молотит, переключая GIL в стремлении добраться до главного потока. Который, в свою очередь, упёрся в Thread.join.

А в питоне этот .join реализован через threading.Condition(threading.Lock()), а не через pthread_join. Так нужно — у питона поверх pthreads есть своя необходимая логика.

В результате главный поток блокирован Lock.acquire. Смотрим, что там происходит.

Открываем файл Python/thread_pthread.h из Python 2.7 и обращаем внимание на функцию PyThread_acquire_lock:

int PyThread_acquire_lock(PyThread_type_lock lock, int waitflag) {
    int success;
    sem_t *thelock = (sem_t *)lock;
    int status, error = 0;

    do {
        if (waitflag)
            status = fix_status(sem_wait(thelock));
        else
            status = fix_status(sem_trywait(thelock));
    } while (status == EINTR); /* Retry if interrupted by a signal */

    if (waitflag) {
        CHECK_STATUS("sem_wait");
    } else if (status != EAGAIN) {
        CHECK_STATUS("sem_trywait");
    }

    return (status == 0) ? 1 : 0;
}

Очень мило: если блокирующий вызов (sem_wait или sem_trywait) возвращает EINTR (произошло прерывание по сигналу) — игнорируем это и ждём дальше.

Беда в том, что весь этот код происходит при отпущенном GIL.

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

Еще раз.

Переключение на главный поток происходит, но не доходит до переключения GIL и выполнения питоновского кода.

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

Python 3.2

Теперь смотрим на ту же функцию в новом исполнении:

PyLockStatus
PyThread_acquire_lock_timed(PyThread_type_lock lock, PY_TIMEOUT_T microseconds,
                            int intr_flag)
{
    PyLockStatus success;
    sem_t *thelock = (sem_t *)lock;
    int status, error = 0;
    struct timespec ts;

    if (microseconds > 0)
        MICROSECONDS_TO_TIMESPEC(microseconds, ts);
    do {
        if (microseconds > 0)
            status = fix_status(sem_timedwait(thelock, &ts));
        else if (microseconds == 0)
            status = fix_status(sem_trywait(thelock));
        else
            status = fix_status(sem_wait(thelock));
        /* Retry if interrupted by a signal, unless the caller wants to be
           notified.  */
    } while (!intr_flag && status == EINTR);

    /* Don't check the status if we're stopping because of an interrupt.  */
    if (!(intr_flag && status == EINTR)) {
        if (microseconds > 0) {
            if (status != ETIMEDOUT)
                CHECK_STATUS("sem_timedwait");
        }
        else if (microseconds == 0) {
            if (status != EAGAIN)
                CHECK_STATUS("sem_trywait");
        }
        else {
            CHECK_STATUS("sem_wait");
        }
    }

    if (status == 0) {
        success = PY_LOCK_ACQUIRED;
    } else if (intr_flag && status == EINTR) {
        success = PY_LOCK_INTR;
    } else {
        success = PY_LOCK_FAILURE;
    }

    return success;
}

Добавилась поддержка таймаутов (и это само по себе здорово).

И, самое главное, появился новый параметр intr_flag. Если он установлен, то функция вернет управление наверх, с указанием, что произошел вызов сигнала. Дальше питон уже разберётся, что нужно сделать переключение GIL и дать возможность главному потоку запустить обработчики сигналов.

Теперь это стало стандартным поведением. Ядро Питона везде использует intr_flag, а старый способ остался для поддержки обратной совместимости со старым Python C API. Раздражающая вещь эта обратная совместимость, но без неё никуда...

Что делать, если приходится писать на Python 2.x?

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

Т.е. следует организовать другую схему сигнализации о завершении потоков, не упирающуюся в безнадежные блокировки (и сделать её перед .join, чтобы тот гарантировано «проскочил» проблемное место).

Решения могут быть разными.

В 2.7 нет ожидания по таймауту — но есть неблокирующие способы завладеть объектом threading.Lock. И если не получилось — поспать немного. Пример смотрите в реализации threading.Condition.wait для Python 2.7.

Или задействовать «непрофильно» select.select — например, создав на каждый рабочий поток локальный socket, в который этот самый поток запишет что-нибудь, а главный поток проснется и поймет, что вся работа закончена. У select есть таймаут, и это тоже может пригодится.

В конце концов, главному потоку можно просто уходить в time.sleep(1) и затем проверять флаги, устанавливаемые работниками по завершению своего грязного дела. Одна секунда ожидания — достаточно долго, чтобы ваш компьютер не грелся до неприличных температур и достаточно мало, чтобы исполняемый процесс сумел отреагировать с не слишком большой задержкой.

Заключение

Проблема в обработке сигналов есть, и теперь вы об этом знаете.

Как вы будете её решать — дело ваше. Предупреждён — значит вооружён.

Лучший способ — перейти на python 3.2. Если это невозможно по ряду причин — остаются другие, не столь элегантные но вполне рабочие решения.

8 комментариев:

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

    По моему опыту важные вещи должны быть явными.

    ОтветитьУдалить
  2. Простите, возможно, я вас не понял.
    В 3.2 лок не снимается — он остается за тем потоком, который его держит. Но вот попытка взять лок, прерванная сигналом, дает возможность получить управление главному потоку ОС.
    В котором Питон, не снимая блокировки с ожидающего, даст выполнить обработчик сигнала.
    Это будет сделано в главном потоке — да.
    Но с точки зрения внутренних структур питона — в отдельном контексте, никак не затрагивающем главный поток. Я не вижу никакого пересечения обработчика сигнала с потоком, выполняющим свое дело.
    Если считаете, что это нужно отдельно подчеркнуть — с удовольствием внесу поправку. Только подскажите, пожалуйста, как именно эта поправка должна звучать.

    ОтветитьУдалить
  3. Приходилось сталкиваться с потоками в wxPython и там обработка сигналов реализована с помощью Pubsub, подойдет ли он в данном случае?

    ОтветитьУдалить
  4. 2 Андрей Светлов:
    Ну если меняется поток выполнения, то я бы не сказал что лок остается локом, так как по сути в этот момент он игнорится и идет переключение.
    Я говорю о том что любая не прозрачная операция в коде это не очень гуд. Я к примеру привык знать что, где и когда у меня в коде выполняется.

    Представим например что есть участок программы, который имеет наивысший приоритет и не должен останавливать свое выполнение. Если остановкой для обработки сигнала управляем мы - то все ок. А вот если им управляет система - то получаем проблему.

    Я не спорю в питоне 3.2 хендлинг сигналов выходит проще, но я бы не сказал что лучше.

    ОтветитьУдалить
  5. >> andreynikishaev
    Как, по вашему, происходит обработка сигналов в 2.7?
    Тоже на самом деле непрозрачно, с переключением потока и так далее. Да вот беда — локи были дурные, мешали. Теперь не мешают, схема стала работать во всех случаях так, как и предполагалось изначально.

    ОтветитьУдалить
  6. >> Сергей Гуляев
    Извините, я не припомню какого-то особого способа для обработки сигналов именно в wxPython. Использовал стандартные механизмы Питона, и всё работало.

    ОтветитьУдалить
  7. Я в 2.x эту проблему решал установкой таймаута (не важно какого - главное не очень маленького) на join():
    while thread.isAlive(): thread.join(float(24 * 60 * 60))

    Честно говоря, в исходники не смотрел, но как только join() вызывается с каким-либо таймаутом, он начинает вести себя так, как нужно.

    ОтветитьУдалить
    Ответы
    1. Дмитрий, браво!

      Если join() вызывается с таймаутом, то вызов ожидает установки condition variable self.__block.

      В двойке lock не поддерживал timeout. Поэтому __block.wait() в цикле пытался захватить lock и если не получалось уходил в sleep. Максимальная задержка для sleep 0.05 сек, что не очень много.

      В результате хакерский способ работает, пусть и несколько прожорливо для CPU.

      Удалить