Backend разработчик
0,0
рейтинг
1 апреля 2012 в 13:31

Разработка → Python threading или GIL нам почти не помеха

Наверное всем, кто хоть раз интересовался Python, известно про GIL — его одновременно и сильное и слабое место.
Не мешая однопоточным скриптам работать, он ставит изрядные палки в колеса при многопоточной работе на CPU-bound задачах (когда потоки выполняются, а не висят попеременно в ожидании I/O и т.п.).
Подробности хорошо описаны в переводе двухгодичной давности. Побороть GIL в официальной сборке Python для настоящего распараллеливания потоков мы не можем, но можно пойти другим путем — запретить системе перебрасывать потоки Python между ядрами. В общем пост из серии, «если не нужно, но очень хочется» :)
Если вы знаете про processor/cpu affinity, пользовались ctypes и pywin32, то ничего нового не будет.


С чего все начиналось


Возьмем простой код (почти как в статье-переводе):
cnt = 100000000
trying = 2

def count():
    n = cnt
    while n>0:
        n-=1

def test1():
    count()
    count()

def test2():
    t1 = Thread(target=count)
    t1.start()
    t2 = Thread(target=count)
    t2.start()
    t1.join(); t2.join()

seq1 = timeit.timeit( 'test1()', 'from __main__ import test1', number=trying )/trying
print seq1

par1 = timeit.timeit( 'test2()', 'from __main__ import test2', number=trying )/trying
print par1


Запустим на python 2.6.5 (ubuntu 10.04 x64, i5 750):
10.41
13.25

И на python 2.7.2 (win7 x64, i5 750):
19.25
27.41

Сразу отбросим, что win-версия явно медленнее. В обоих случаях видно значительное замедление параллельного варианта.

Если очень хочется, то можно


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

Соответственно, чтобы потоки захватывали GIL поочередно, можно ограничить python-процесс одним ядром. А поможет нам в этом CPU Affinity Mask, позволяющая в формате битовых флагов указывать на каких ядрах/процессорах разрешено выполняться программе.

На разных ОС данная операция выполняется разными средствами, но сейчас рассмотрим Ubuntu Linux и WinXP+. Также изучалась FreeBSD 8.2 на Intel Xeon, но это останется за пределами статьи.

А сколько у нас вариантов?


Прежде чем выбирать ядра, нужно определиться сколько их у нас в распоряжении. Тут стоит плясать от возможностей платформы: multiprocessing.cpu_count() в python 2.6+, os.sysconf('SC_NPROCESSORS_ONLN') по POSIX и т.д. Пример определения можно посмотреть тут.

Непосредственно для работы с processor affinity были выбраны:


Linux Ubuntu


Чтобы достучаться до libc воспользуемся модулем ctypes. Для загрузки нужной библиотеки воспользуемся ctypes.CDLL:
libc = ctypes.CDLL( 'libc.so.6' )
libc.sched_setaffinity # наша функция

Все бы было хорошо, но есть два момента:
  • Жесткое задание имени libc.so.6 не переносимо, а файл libc.so, которому следовало бы являться симлинкой на реальную версию, на Debian/Ubuntu сделан текстовым файлом.
    На данный момент сделан костыль в виде поиска всех файлов, имена которых начинаются с «libc.so» и попытка подгрузить их с обработкой OSError. Загрузили — это наша библиотечка.
    Если кто-то знает лучшее и универсальное решение — буду раз увидеть в комментариях или в личке.
  • Указания имени функции недостаточно. Нужны же еще число параметров и их типы. Для этого воспользуемся заданием «магического» атрибута argtypes для нужных нам функций.

Наши функции:
int sched_setaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);
int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);

pid_t — это int, cpu_set_t — структура из одного поля размером в 1024 бита (т.е. возможно работать с 1024 ядрами/процессорами).
Воспользуемся cpusetsize, чтобы работать не сразу со всеми ядрами и считать, что cpu_set_t — это unsigned long. В общем случае следует воспользоваться ctypes.Arrays, но это выходит за рамки темы статьи.
Также стоит заметить, что mask передается как указатель, т.е. ctypes.POINTER(<тип самого значения>).
После проведения соответствия типов C и ctypes получаем:
__setaffinity = _libc.sched_setaffinity
__setaffinity.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]

__getaffinity = _libc.sched_getaffinity
__getaffinity.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]


После указания argtypes за типами передаваемых значений следит ctypes. Чтобы модуль не ругался, а делал свою работу, корректно укажем значения при вызове:
def get_affinity(pid=0):
    mask = ctypes.c_ulong(0)                        # инициализируем переменную
    c_ulong_size = ctypes.sizeof(ctypes.c_ulong)    # данные только по первым 32/64 ядрам
    if __getaffinity(pid, c_ulong_size, mask) < 0:
        raise OSError
    return mask.value                               # преобразование ctypes.c_ulong => python int

def set_affinity(pid=0, mask=1):
    mask = ctypes.c_ulong(mask)
    c_ulong_size = ctypes.sizeof(ctypes.c_ulong)
    if __setaffinity(pid, c_ulong_size, mask) < 0:
        raise OSError
    return


Как видно, ctypes сам неявно разобрался с указателем. Также стоит заметить, что вызов с pid=0 выполняется над текущим процессом.

Windows XP+


В документации к нужным нам функциям указано:
Minimum supported client - Windows XP
Minimum supported server - Windows Server 2003
DLL - Kernel32.dll

Теперь мы знаем, когда это будет работать и какую библиотеку нужно грузить.

Делаем по аналогии с Linux версией. Берем заголовки:
BOOL WINAPI SetProcessAffinityMask(
  __in  HANDLE hProcess,
  __in  DWORD_PTR dwProcessAffinityMask
);

BOOL WINAPI GetProcessAffinityMask(
  __in   HANDLE hProcess,
  __out  PDWORD_PTR lpProcessAffinityMask,
  __out  PDWORD_PTR lpSystemAffinityMask
);

В качестве HANDLE нас вполне устроит ctypes.c_uint, а вот с типами out параметров нужно быть аккуратными:
DWORD_PTR — это все тот же ctypes.c_uint, а PDWORD_PTR — это уже ctypes.POINTER(ctypes.c_uint).
Итого получаем:
__setaffinity = ctypes.windll.kernel32.SetProcessAffinityMask
__setaffinity.argtypes = [ctypes.c_uint, ctypes.c_uint]

__getaffinity = ctypes.windll.kernel32.GetProcessAffinityMask
__getaffinity.argtypes = [ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)]


И кажется, что вот сделаем там и все заработает:
def get_affinity(pid=0):
    mask_proc = ctypes.c_uint(0)
    mask_sys  = ctypes.c_uint(0)
    if not __getaffinity(pid, mask_proc, mask_sys):
        raise ValueError
    return mask_proc.value

def set_affinity(pid=0, mask=1):
    mask_proc = ctypes.c_uint(mask)
    res = __setaffinity(pid, mask_proc)
    if not res:
        raise OSError
    return

Но увы. Функции принимают не pid, а HANDLE процесса. Его еще нужно получить. Для этого воспользуемся функцией OpenProcess ну и «парной» к ней CloseHandle:
PROCESS_SET_INFORMATION   =  512
PROCESS_QUERY_INFORMATION = 1024

__close_handle = ctypes.windll.kernel32.CloseHandle

def __open_process(pid, ro=True):
    if not pid:
        pid = os.getpid()
    
    access = PROCESS_QUERY_INFORMATION
    if not ro:
        access |= PROCESS_SET_INFORMATION
    
    hProc = ctypes.windll.kernel32.OpenProcess(access, 0, pid)
    if not hProc:
        raise OSError
    return hProc

Если не вдаваться в подробности, то мы просто получаем HANDLE нужного нам процесса с доступом на чтение параметров, а при ro=False и на их изменение. Об этом написано в документации по SetProcessAffinityMask и GetProcessAffinityMask:
SetProcessAffinityMask:
hProcess [in]
A handle to the process whose affinity mask is to be set. This handle must have the PROCESS_SET_INFORMATION access right.

GetProcessAffinityMask:
hProcess [in]
A handle to the process whose affinity mask is desired.
Windows Server 2003 and Windows XP:  The handle must have the PROCESS_QUERY_INFORMATION access right.

Так что никакого метода Монте-Карло :)

Переписываем наши get_affinity и set_affinity c учетом изменений:
def get_affinity(pid=0):
    hProc = __open_process(pid)
    
    mask_proc = ctypes.c_uint(0)
    mask_sys  = ctypes.c_uint(0)
    if not __getaffinity(hProc, mask_proc, mask_sys):
        raise ValueError

    __close_handle(hProc)
    
    return mask_proc.value

def set_affinity(pid=0, mask=1):
    hProc = __open_process(pid, ro=False)

    mask_proc = ctypes.c_uint(mask)
    res = __setaffinity(hProc, mask_proc)
    __close_handle(hProc)
    if not res:
        raise OSError
    return


WindowsXP+ для ленивых


Чтобы немного сократить объем кода для Win-реализации можно поставить модуль pywin32. Он избавит нас от необходимости задавать константы и разбираться с библиотеками и параметрами вызова. Наш код выше мог бы выглядеть как-то так:
import win32process, win32con, win32api, win32security
import os

def __open_process(pid, ro=True):
    if not pid:
        pid = os.getpid()
    
    access = win32con.PROCESS_QUERY_INFORMATION
    if not ro:
        access |= win32con.PROCESS_SET_INFORMATION
    
    hProc = win32api.OpenProcess(access, 0, pid)
    if not hProc:
        raise OSError
    return hProc

def get_affinity(pid=0):
    hProc = __open_process(pid)
    mask, mask_sys = win32process.GetProcessAffinityMask(hProc)
    win32api.CloseHandle(hProc)
    return mask

def set_affinity(pid=0, mask=1):
    try:
        hProc = __open_process(pid, ro=False)
        mask_old, mask_sys_old = win32process.GetProcessAffinityMask(hProc)
        res = win32process.SetProcessAffinityMask(hProc, mask)
        win32api.CloseHandle(hProc)
        if res:
            raise OSError
    except win32process.error as e:
        raise ValueError, e
    return mask_old

Кратко, понятно, но это сторонний модуль.

И что в итоге?


Если собрать это все воедино и добавить к нашим первоначальным тестам еще один:
def test3():
    cpuinfo.affinity.set_affinity(0,1) # меняем в своем процессе (pid=0) affinity на первое ядро.
    test2()

par2 = timeit.timeit( 'test3()', 'from __main__ import test3', number=trying )/trying
print par2


то результаты будут следующими:
Linux:
test1 : 10.41 | 102.89
test2 : 13.25 | 135.29
test3 : 10.45 | 104.51

Windows:
test1 : 19.25 | 191.97
test2 : 27.41 | 269.78
test3 : 19.52 | 196.17

Цифры во второй колонке — теже тесты, но с cnt в 10 раз большим.
Мы получили два потока выполнения практически без потери в скорости работы по сравнению с однопоточным вариантом.

Affinity задается битовой маской на обоих ОС. На 4х ядерной машине get_affinity выдает значение 15 (1+2+4+8).

Пример и весь код для статьи выложил на github.
Принимаю любые предложения и претензии.
Также интересуют результаты на процессоре с поддержкой HT и на других версиях Linux.

Всех с первым апреля! Этот код действительно работает :)
Алексей @AterCattus
карма
103,0
рейтинг 0,0
Backend разработчик
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (34)

  • 0
    Не… на win7 x64 не работает :)
    • 0
      Вчера тестил, работало в обоих вариантах. На что ругается?
      • 0
        На функцию __search_so
                for file in os.listdir(dir):
                    if file.startswith(name):
                        res.append( os.path.join(dir, file) )
        

        WindowsError: [Error 3]: '/lib/*.*'
        • 0
          Исправлено, выложено на GIT, ответил в личку.
  • 0
    А как влияет affinity на ту часть кода потока, которая выполняется в режиме ядра? В частности, что на счет параллельных IO операций?
    • 0
      Поток также останавливается на время I/O. Никаких отличий, мы просто запрещаем системе выполнять наш код на других ядрах.
    • –2
      Посмотрите на календарь =)
  • +3
    Самый оригинальный рецепт работы с GIL, который я видел.
  • 0
    Исходыне данные:
    12.909442544
    17.7961015701


    Дописали в начало файла:
    from gevent import monkey; monkey.patch_all()

    Получили результат:
    12.9433840513
    12.8199769258


    Заменили в коде два слова Thread на два слова Process, получили результат:
    12.8545324802
    6.45484101772


    Вывод: по-моему автор пытается копать землю мотыгой, и вместо того, чтобы сменить мотыгу на лопату — он изобретает нейромотыгокопатель, который по-прежнему хуже, сложнее и менее надежен лопаты, но зато своего собственного производства.
    • 0
      Ах черт… первое апреля… Простите
  • +3
    Одна из причин, почему мы начали отказываться от питона в продакте.

    Почему-то все, кто говорит про GIL говорят «ну если у вас там зубодробительная математика, то»…

    А на самом деле проблема проще: если у вас сервер, то у вас есть очень серьёзная проблема при росте нагрузки. Даже если на каждый запрос уходит 1мс, как только к вам придёт 2000 запросов за секунду, у вас начинаются лаги. Хоть у вас там и 20 ядер в запасе.
    • 0
      На 20 ядрах вполне нормально работают 100 процессов.
      • 0
        Вы говорите про процессы. А сколько тредов одного питона будут нормально работать? Сразу отвечаю на пример задачи, которая процессами трудно реализуется.

        Предположим, у вас есть in-memory хранилище данных. Например, список объектов. Клиенты получают к ним доступ, что-то делают, отключаются. Частота обращений очень высокая, объём работы — маленький. В этом случае резать его на процессы — это выносить хранение в in-memory db (отдельным процессом), что сильно оверхед и иногда слишком медленно. Если же делать тредами и общими объектами — привет GIL'у.
        • 0
          Вы shared memory не рассматриваете в принципе?
          • 0
            Рассматривали в своё время. Оказалось настолько неудобно, что было проще реализовать pipe-подобный интерфейс между компонентами.

            Повторю, на python можно писать. Но как только речь заходит о росте нагрузок, как тут же выясняется, что нужно капитально напрягаться и придумывать архитектуру, нацеленную на борьбу с GIL.
            • 0
              Мой опыт подсказывает, что разработка высоконагруженных сервисов сильно отличается от «просто скриптиков». Так или иначе приходится хорошо думать, решая одновременно целый комплекс задач. Наличие или отсутствие GIL уже не играет большой роли — так, штришок к общим требованиям и ни разу не основное препятствие к прекрасному будущему.
              • 0
                Давайте скажем так, есть _ВЫСОКО_ нагруженные (условно говоря, мы выходим на уровень, когда возникает желание размазать сервис по нескольких серверам), а есть просто нагруженные — когда сервис вполне может уживаться в уголке сервера и не вякать. Казалось бы, в чём проблема? Но питоновское «не вякать» заканчивается раньше, чем хотелось.

                Грубый пример. У нас есть обработчик консолей на хосте виртуализации. Средняя плотность — 20-100 машин на хост. Обработать 100 потоков по 64к — это высокая нагрузка или нет? Питон с такой нагрузкой справляется с большим трудом.
                • 0
                  На чом в результате реализованили «100 потоков по 64к»? Не тролинга ради, а инфрормации для. Просто у меня сейчас у самого подобная задача есть, порядко тысячи постоянных медленных конектов обрабатывать, собирается статистика с специфичных устройств, объемы маленькие, но данные идут постонянно. Поделитесь идеями пожалуйста.
                  • 0
                    Одна часть всё ещё на питоне (подлагивает иногда, но терпим). Часть, которая в мир смотрит (и запросы клиентов обслуживает) — хаскел.
                    • 0
                      хаскел в продакте!?
                      вы не шутите?
                      • 0
                        Смотрите наш блог (http://habrahabr.ru/company/selectel/). В настоящий момент у нас несколько компонент уже переписаны и работают (с моей точки зрения) замечательно (то есть я не замечаю их сущетсвования и уверенно пользуюсь результатами их работы).
                • 0
                  поток в 64k — что имеется в виду?
                  Скорость потока принято измерять в килобайтах в секунду или килобитах в ту же секунду.

                  Если речь идет о виртуальных машинах — то собственная связность этих потоков очень небольшая. В результате все отлично должно раскладываться по процессам. Нет?
                  • 0
                    128*64=8192 бит/с
                    • 0
                      Ничего не понял
                      • 0
                        Пардон, 8192 кбит/с
                    • 0
                      Значит, имеем по 64КБит/сек от виртуалки. Предполагаю, что обработка этого потока делается почти независимо от остальных. Вас может интересовать периодическое обновление статистики, биллинга и обратный контроль над виртуалкой. Итого имеем звезду, в которой главный процесс управляет оркестром а работники (несколько штук) работают с потоками данных и т.д.
                      Получаем изолированность, масштабируемость и бОльшую надежность. Проблема GIL решается автоматически.
        • –1
          Так на хайлоаде обычно используется корпоративная многозадачности — tornado, twisted, gevent и другие, работающие через epoll. Т.е. запускается по 1 треду на ядро, а дальше работает по тому же принципу, по которому и nginx — никаких тредов.

          Производительность получается более чем достаточная для 2000 запросов в секунду, и GIL тут не помеха.
          • 0
            Не «корпоративная», а «кооперативная», это раз. С gevent'ом мы работаем, но на него много нареканий. Все не назову, нужно программистов внимательно выслушивать.

            Проблема «кооперативной многозадачности» состоит в том, что там лимит производительности — 100% CPU.
            • 0
              Спасибо за поправку, это да, я что-то совсем заговорился. Но ведь можно запустить по 1 процессу на ядро. Twisted и tornado по-моему делают это из коробки, по крайней мере второй точно делал. И тогда будет лимит — 100% всех ядер CPU, как и для любой другой программы или языка.
              • 0
                Не имеет значения, каким образом обеспечивается работа гринлетов (у них могут быть и другие названия). Проблема состоит в том, что получается 100% CPU (за вычетом IO), которые могут иметь общие объекты. Хочешь больше — начинай танцевать вокруг IPC или надейся на то, что GIL до какого-то предела выдаст больше 100% CPU (это действительно так, за счёт вызовов библиотек). А хочется иметь если не линейную, то хотя бы идущую вверх кривую производительности по мере роста числа ядер.
                • 0
                  Поясните, возможно я вас неверно понял: речь идет про 100% ядра или 100% всех ядер CPU?

                  Если первое, то чем плох способ пультипроцессинг (не мультитреадинг), по количеству ядер, поверх которого натягивается асинхронное IO?
                  Если второе, то каким образом другой язык может задействовать более 100% вычислительной мощности железа?
                • 0
                  Просто не далее чем неделю назад я общался с ведущим программистом компании, которая держит самую популярную игру ВКонтакте (≥9,1 млн пользователей), и разрабатывает другие игры. Так вот бэкенд у них пишется на python/tornado/nosql. При этом нагрузка далеко переваливает за 100 и 1000 одновременных процессов/потоков.

                  Возможно, это другой класс задач, нежели ваш. Однако это тоже хайлоад. Хочется понять под какой круг задач подходит инструмент, а под какой нет. И чем плоха кооперативная модель в Вашем случае.
                  • 0
                    Если tornado процессы между собой «общаются» только через NoSQL хранилище — то тут вопросов нет.
                    Если им нужно нечто большее, то, видимо, в гости приходят костыли и проблемы.

                    Я лично не сталкивался с нагрузкой более 20-30 запросов в секунду, но такую нагрузку отлично держит и один python-процесс (работа не cpu-bound).
                • 0
                  Можно синхронизовать внешне, ну или правда танцевать с IPC и/или multiprocessing.

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