понедельник, 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. Если это невозможно по ряду причин — остаются другие, не столь элегантные но вполне рабочие решения.