Способы обхода GIL для повышения производительности
Привет, Хабр!
Global Interpreter Lock в Питоне предотвращает одновременное выполнение нескольких потоков в одном процессе интерпретатора Python. Т.е даже на многоядерном процессоре многопоточные Python-приложения будут выполняться только в одном потоке за раз. Это было введено для некой потокобезопасности при работе с объектами Python, упрощая тем самым разработку на уровне интерпретатора.
На первый взгляд, GIL кажется разумным компромиссом для упрощения разработки. Однако, когда есть многоядерные процессоры и появляется необходимость в высокопроизводительных вычислениях GIL серьезно ограничивает возможности масштабирования и параллельную работу.
В этой статье рассмотрим способы обхода GIL и первый способ — использование многопроцессности вместо многопоточности.
Использование многопроцессности вместо многопоточности
Многопоточность оперирует потоками внутри одного процесса, деля память и состояние, тогда как многопроцессность запускает отдельные процессы, каждый со своей памятью и состоянием.
Реализовать многопроцессность можно с помощью либы multiprocessing
. Она дает возможность каждому процессу работать с собственным интерпретатором Python и, соответственно, собственным GIL.Т. е каждый процесс может полноценно использовать отдельное ядро процессора, обходя ограничения, налагаемые GIL на многопоточное выполнение кода.
Основные функции multiprocessing
Блокировки используются для синхронизации доступа к ресурсам между разными процессами. Например, можно использовать Lock
для гарантии того, что только один процесс может выполнять определенный участок кода одновременно:
from multiprocessing import Process, Lock
def printer(item, lock):
lock.acquire()
try:
print(item)
finally:
lock.release()
if __name__ == '__main__':
lock = Lock()
items = ['тест1', 'тест2', 'тест3']
for item in items:
p = Process(target=printer, args=(item, lock))
p.start()
Семафоры похожи на блокировки, но позволяют ограничить доступ к ресурсу не одним, а несколькими процессами одновременно:
from multiprocessing import Semaphore, Process
def worker(semaphore):
with semaphore:
# работа, требующая синхронизации
print('Работает')
if __name__ == '__main__':
semaphore = Semaphore(2)
for _ in range(4):
p = Process(target=worker, args=(semaphore,))
p.start()
События позволяют процессам ожидать сигнал от других процессов для начала выполнения определенных действий:
from multiprocessing import Process, Event
import time
def waiter(event):
print('Ожидание события')
event.wait()
print('Событие произошло')
if __name__ == '__main__':
event = Event()
for _ in range(3):
p = Process(target=waiter, args=(event,))
p.start()
print('Главный процесс спит')
time.sleep(3)
event.set()
Очереди в multiprocessing
позволяют безопасно обмениваться данными между процессами:
from multiprocessing import Process, Queue
def worker(queue):
queue.put('Элемент от процесса')
if __name__ == '__main__':
queue = Queue()
p = Process(target=worker, args=(queue,))
p.start()
p.join()
print(queue.get())
Асинхронное программирование
asyncio
— это библиотека для написания конкурентного кода с использованием синтаксиса async
/await
, введенного в Python 3.5. Она служит базой для многих асинхронных фреймворков Python
В отличие от многопоточного исполнения, asyncio
использует единственный поток и event loop для управления асинхронными операциями, что позволяет обходить ограничения, связанные с GIL.
Предположим, нужно собрать заголовки с нескольких веб-страниц. Будем юзатьaiohttp
в качестве асинхронного HTTP-клиента для отправки запросов:
import asyncio
import aiohttp
async def fetch_title(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
html = await response.text()
return html.split('')[1].split(' ')[0]
async def main(urls):
tasks = [fetch_title(url) for url in urls]
titles = await asyncio.gather(*tasks)
for title in titles:
print(title)
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
# предположим, здесь список из тысячи URL
]
asyncio.run(main(urls))
Функция fetch_title
асинхронно извлекает HTML-контент для заданного URL и возвращает содержимое тега
. А main
создает задачи для каждого URL и запускает их параллельно с помощью asyncio.gather()
. Таким образом можно тысячи веб-запросов одновременно, оптимизируя время ожидания ответов от серверов и эффективно используя ресурсы.
Интеграция с внешними С/С++ модулями
Весьма годный способ обхода ограничений GIL и повышения производительности при работе с CPU-интенсивными задачами. Создание расширений позволяет напрямую обращаться к системным вызовам и C-библиотекам, минуя оверхед интерпретатора Python и GIL.
Расширение Python на C или C++ представляет собой разделяемую библиотеку, которая экспортирует функцию инициализации. Функция возвращает полностью инициализированный модуль или экземпляр PyModuleDef
. Для модулей с именами в ASCII необходимо, чтобы функция инициализации называлась PyInit_<имямодуля>
. Для не ASCII имен модулей используется кодировка punycode и префикс PyInitU_
.
Для создания модуля на C, начинаем с определения методов модуля и таблицы методов, а затем определяем сам модуль:
static PyObject *method_fputs(PyObject *self, PyObject *args) {
char *str, *filename = NULL;
int bytes_copied = -1;
if (!PyArg_ParseTuple(args, "ss", &str, &filename)) {
return NULL;
}
FILE *fp = fopen(filename, "w");
bytes_copied = fputs(str, fp);
fclose(fp);
return PyLong_FromLong(bytes_copied);
}
static PyMethodDef CustomMethods[] = {
{"fputs", method_fputs, METH_VARARGS, "Python interface to fputs C library function"},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef custommodule = {
PyModuleDef_HEAD_INIT,
"custom",
"Python interface for the custom C library function",
-1,
CustomMethods
};
PyMODINIT_FUNC PyInit_custom(void) {
return PyModule_Create(&custommodule);
}
После определения функции инициализации и методов модуля создаем setup.py
файл, чтобы скомпилировать модуль:
from distutils.core import setup, Extension
setup(name="custom",
version="1.0",
description="Python interface for the custom C library function",
ext_modules=[Extension("custom", ["custommodule.c"])])
Выполнив команду python3 setup.py install
в терминале модуль скомпилируется и установится став доступным для импорта в Python.
Python и C/C++ имеют разные системы исключений. Если к примеру нужно выбросить исключение Python из C-расширения можно использовать API Python для работы с исключениями. Например, чтобы выбросить ValueError
если строка меньше 10 символов:
if (strlen(str) < 10) {
PyErr_SetString(PyExc_ValueError, "String length must be greater than 10");
return NULL;
}
Таким образом можно использовать предопределенные исключения Python или создать свои собственные.
Также некоторые библиотеки, к примеру как NumPy, Numba и Cython имеют встроенные возможности для обхода GIL.
В завершение хочу порекомендовать вам бесплатный вебинар про очереди и отложенное выполнение на примере RabbitMQ в .Net .