Привет, Хабр!
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('<title>')[1].split('</title>')[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 и возвращает содержимое тега <title>
. А 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.