Как достичь параллелизма в Python с учетом GIL?

Ответ

GIL (Global Interpreter Lock) — это мьютекс в CPython, который не позволяет нескольким нативным потокам выполнять Python байт-код одновременно в рамках одного процесса. Из-за этого стандартный модуль threading не обеспечивает параллелизма для CPU-bound задач.

Выбор правильного подхода зависит от типа задачи.

1. Для CPU-bound задач: multiprocessing

Этот модуль обходит GIL, создавая отдельные процессы операционной системы. Каждый процесс имеет свой собственный интерпретатор Python и, соответственно, свой собственный GIL. Это единственный способ добиться истинного параллелизма для вычислений на нескольких ядрах CPU.

Пример:

from multiprocessing import Pool

def square(x):
    # Это CPU-bound задача
    return x * x

if __name__ == '__main__':
    # Запускаем 4 рабочих процесса
    with Pool(4) as p:
        results = p.map(square, [1, 2, 3, 4, 5, 6, 7, 8])
        print(results)  # Вывод: [1, 4, 9, 16, 25, 36, 49, 64]

2. Для I/O-bound задач: threading или asyncio

В задачах, ограниченных вводом-выводом (I/O), программа большую часть времени ждет ответа от сети, диска или базы данных. В эти моменты GIL освобождается, позволяя другим потокам выполняться.

  • threading: Использует вытесняющую многозадачность на уровне ОС. Подходит для интеграции с блокирующим кодом.

    import threading
    import requests
    
    def fetch_url(url):
        # GIL освобождается во время ожидания ответа от сети
        response = requests.get(url)
        print(f"Fetched {url}, status: {response.status_code}")
    
    urls = ["https://google.com", "https://bing.com"]
    threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
    for t in threads: t.start()
    for t in threads: t.join()
  • asyncio: Использует кооперативную многозадачность в одном потоке. Более эффективно по памяти и производительности для тысяч одновременных I/O операций.

    import asyncio
    import aiohttp
    
    async def fetch_url(session, url):
        async with session.get(url) as response:
            print(f"Fetched {url}, status: {response.status}")
    
    async def main():
        urls = ["https://google.com", "https://bing.com"]
        async with aiohttp.ClientSession() as session:
            tasks = [fetch_url(session, url) for url in urls]
            await asyncio.gather(*tasks)
    
    asyncio.run(main())

Итог:

  • CPU-bound: Используйте multiprocessing.
  • I/O-bound: Используйте threading для простоты или asyncio для максимальной производительности.

Ответ 18+ 🔞

Давай разберёмся с этим ГИЛом, а то народ путается, как последние идиоты. Представь, что твой интерпретатор Python — это один большой, важный сортир на стройке. И есть один мудак с огромным амбарным замком — это GIL, Global Interpreter Lock. Он стоит у двери и пускает внутрь работать только одного рабочего за раз. Хоть у тебя бригада из десяти здоровых качков (потоков threading), пилить байт-код внутри будет только один. Остальные ждут у двери, чешут репу и курят.

Так что если задача CPU-bound — то есть требует мозгов, вычислений, перемножения матриц до посинения — то threading тебе нихуя не поможет. Все твои потоки будут толкаться в одну дверь, а работать будет один. Производительность хуже, чем если бы один всё делал, потому что ещё и время на переключение контекста тратится. Пиздец, а не параллелизм.

Что делать? Взять кувалду и сделать ещё сортиров! Запустить отдельные процессы через multiprocessing. У каждого процесса — свой интерпретатор, свой ГИЛ и своя дверь. Вот тогда они реально будут работать параллельно на разных ядрах процессора.

Смотри, как это выглядит в коде:

from multiprocessing import Pool

def square(x):
    # Вот это самая CPU-bound задача, мозголомка
    return x * x

if __name__ == '__main__':
    # Запускаем 4 отдельных процесса, 4 отдельных сортира
    with Pool(4) as p:
        results = p.map(square, [1, 2, 3, 4, 5, 6, 7, 8])
        print(results)  # Вывод: [1, 4, 9, 16, 25, 36, 49, 64]

А теперь другая история. Если задача I/O-bound — то есть твоя программа не думает, а ждёт. Ждёт ответа от сервера, пока файл скачается, пока база данных отпишется. В эти моменты ожидания наш мудак с замком (GIL) говорит: "Окей, раз ты нихуя не делаешь, иди погуляй", — и выпускает текущего работника, запуская другого. Вот тут threading уже может дать выигрыш, потому что пока один поток спит, другой может работать.

Варианта два:

  1. threading — классика, потоки уровня ОС. Подходит, если у тебя старый, блокирующий код, который не переписать.

    import threading
    import requests
    
    def fetch_url(url):
        # Пока requests.get ждёт ответа от сети, GIL отпускает поток
        response = requests.get(url)
        print(f"Fetched {url}, status: {response.status_code}")
    
    urls = ["https://google.com", "https://bing.com"]
    threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
    for t in threads: t.start()
    for t in threads: t.join()
  2. asyncio — это уже высший пилотаж, кооперативная многозадачность. Все I/O операции делаются асинхронно в одном потоке, без переключений контекста. Эффективнее, но требует, чтобы весь стек был асинхронным. Если ты не готов переписывать всё под async/await, лучше не лезь — сломаешь.

    import asyncio
    import aiohttp
    
    async def fetch_url(session, url):
        async with session.get(url) as response:
            print(f"Fetched {url}, status: {response.status}")
    
    async def main():
        urls = ["https://google.com", "https://bing.com"]
        async with aiohttp.ClientSession() as session:
            tasks = [fetch_url(session, url) for url in urls]
            await asyncio.gather(*tasks)
    
    asyncio.run(main())

Короче, запомни как "Отче наш":

  • Задачи на вычисления (CPU-bound)?multiprocessing. Делай отдельные процессы, ебашь по всем ядрам.
  • Задачи на ожидание (I/O-bound)?threading (проще) или asyncio (круче, но сложнее). Потоки тут норм, потому что GIL во время простоя отпускает.

Вот и вся магия, никакого волшебства. Главное — не пытайся threading-ом гонять CPU-bound задачи, а то получишь обратный эффект и будешь потом чесать жопу, думая, где же твоя производительность.