$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогGIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код

GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код

$ sudo teach IT
·16 июля 2026 г.·8 мин·99
GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код

Разбираем, как устроен Global Interpreter Lock, почему потоки в Python не работают параллельно на CPU-задачах и когда стоит переключиться на multiprocessing или asyncio.

Содержание
Что такое GIL и зачем он нуженКак GIL работает на практикеМеханизм переключения GILКогда потоки всё-таки работаютCPU-bound задачи: переходим на multiprocessingПодводные камни multiprocessingI/O-bound задачи: asyncio как альтернатива потокамЧто меняется в Python 3.13 и далееПрактические рекомендации: что выбратьТипичные ошибкиВывод

Один из самых распространённых сюрпризов для разработчиков, переходящих с Java или C++ на Python, — это поведение многопоточного кода. Вы запускаете несколько потоков, ожидаете линейного прироста производительности, а в итоге получаете примерно то же самое время выполнения, что и в однопоточном варианте. Иногда даже чуть хуже. Причина — Global Interpreter Lock, или GIL.

GIL — не баг и не недосмотр. Это осознанное архитектурное решение, которое существует в CPython с самых ранних версий и по сей день определяет поведение Python в многопоточных сценариях. Разобраться в том, как он работает, почему он нужен и когда его ограничения действительно важны, — значит стать заметно более зрелым Python-разработчиком.

Что такое GIL и зачем он нужен

GIL — это мьютекс (mutual exclusion lock), который защищает доступ к внутренним структурам данных интерпретатора CPython. Он гарантирует, что в каждый момент времени только один поток выполняет байт-код Python.

Звучит как ограничение — и это так. Но у него есть конкретная причина.

CPython управляет памятью через подсчёт ссылок (reference counting). Каждый объект хранит счётчик того, сколько ссылок на него существует. Когда счётчик падает до нуля, объект удаляется. Это простая и предсказуемая модель управления памятью, но она абсолютно не thread-safe без дополнительной синхронизации: если два потока одновременно изменяют счётчик ссылок одного объекта, возникает гонка данных (race condition), которая может привести к преждевременному удалению объекта или утечке памяти.

Теоретически можно было бы защитить каждый счётчик ссылок отдельным мьютексом. Именно так устроены некоторые другие реализации Python — например, Jython или IronPython. Но в CPython разработчики выбрали более простой путь: один глобальный лок на весь интерпретатор. Это упрощает реализацию, облегчает интеграцию C-расширений и исторически хорошо работало в то время, когда многоядерные процессоры были редкостью.

Как GIL работает на практике

Рассмотрим простой пример: CPU-bound задача, которую мы пытаемся распараллелить с помощью потоков.

code
import threading
import time


def cpu_bound_task(n):
    """Считаем сумму квадратов — типичная CPU-нагрузка."""
    total = 0
    for i in range(n):
        total += i * i
    return total


def run_single_thread(n, iterations):
    for _ in range(iterations):
        cpu_bound_task(n)


def run_multi_thread(n, iterations, num_threads):
    threads = []
    chunk = iterations // num_threads
    for _ in range(num_threads):
        t = threading.Thread(target=run_single_thread, args=(n, chunk))
        threads.append(t)
    for t in threads:
        t.start()
    for t in threads:
        t.join()


N = 500_000
ITERATIONS = 8

start = time.perf_counter()
run_single_thread(N, ITERATIONS)
single_time = time.perf_counter() - start
print(f"Single thread: {single_time:.2f}s")

start = time.perf_counter()
run_multi_thread(N, ITERATIONS, num_threads=4)
multi_time = time.perf_counter() - start
print(f"4 threads:     {multi_time:.2f}s")

На типичной машине результат будет примерно таким:

code
Single thread: 3.41s
4 threads:     3.58s

Четыре потока не только не ускорили выполнение — они немного замедлили его из-за накладных расходов на переключение контекста и конкуренцию за GIL. Это не интуитивно, но полностью объяснимо.

Механизм переключения GIL

До Python 3.2 GIL переключался каждые 100 байт-кодовых инструкций (так называемый sys.checkinterval). Это создавало проблемы: потоки могли «голодать», а переключения происходили в неудачные моменты.

Начиная с Python 3.2, механизм был переработан Антуаном Питру. Теперь GIL переключается на основе времени: каждые 5 миллисекунд (настраивается через sys.setswitchinterval). Поток, удерживающий GIL, выполняется, затем сигнализирует остальным, что готов его отдать. Если другой поток ждёт — он захватывает лок.

code
import sys

# Текущий интервал переключения (в секундах)
print(sys.getswitchinterval())  # 0.005 — то есть 5 мс

Это улучшило честность распределения GIL между потоками, но не решило фундаментальную проблему: в каждый момент времени CPU-код выполняется только в одном потоке.

Когда потоки всё-таки работают

GIL не делает потоки бесполезными. Он блокирует параллельное выполнение Python-байткода, но не блокирует параллельное ожидание.

Когда поток выполняет I/O-операцию — читает файл, ждёт ответа от сети, делает запрос к базе данных — он освобождает GIL. Пока один поток ждёт, другой может выполнять Python-код. Это делает потоки вполне разумным инструментом для I/O-bound задач.

code
import threading
import urllib.request
import time

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]


def fetch(url):
    with urllib.request.urlopen(url, timeout=10) as response:
        return response.read()


# Последовательно
start = time.perf_counter()
for url in URLS:
    fetch(url)
print(f"Sequential: {time.perf_counter() - start:.2f}s")  # ~4s

# Параллельно через потоки
start = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(url,)) for url in URLS]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"Threaded:   {time.perf_counter() - start:.2f}s")  # ~1s

Здесь потоки дают реальный выигрыш — потому что большую часть времени они ждут сети, а не занимают CPU.

Аналогично работают многие C-расширения: NumPy, например, при выполнении тяжёлых вычислений внутри своих C-функций может освобождать GIL, позволяя другим потокам работать параллельно.

CPU-bound задачи: переходим на multiprocessing

Если вам нужно реально распараллелить вычислительно тяжёлые задачи, стандартный ответ в Python — модуль multiprocessing. Каждый процесс получает собственный интерпретатор и собственный GIL, поэтому они действительно работают параллельно.

code
from multiprocessing import Pool
import time


def cpu_bound_task(n):
    total = 0
    for i in range(n):
        total += i * i
    return total


if __name__ == "__main__":
    N = 500_000
    ITERATIONS = 8

    # Последовательно
    start = time.perf_counter()
    for _ in range(ITERATIONS):
        cpu_bound_task(N)
    print(f"Sequential:      {time.perf_counter() - start:.2f}s")

    # Параллельно через процессы
    start = time.perf_counter()
    with Pool(processes=4) as pool:
        pool.starmap(cpu_bound_task, [(N,)] * ITERATIONS)
    print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")

Результат будет уже другим:

code
Sequential:      3.41s
Multiprocessing: 0.97s

Прирост близок к теоретическому максимуму для 4 ядер.

Подводные камни multiprocessing

Процессы — не серебряная пуля. У них есть существенные издержки:

  • Запуск процесса значительно дороже запуска потока. На Windows это особенно заметно, потому что там используется spawn по умолчанию (полный запуск нового интерпретатора), а не fork.
  • Передача данных между процессами происходит через сериализацию (pickle). Большие объекты передаются медленно, а некоторые вещи (например, лямбды или локальные функции) вообще не сериализуются.
  • Разделяемое состояние требует явной синхронизации через multiprocessing.Value, Queue или Manager.

Поэтому multiprocessing хорошо работает тогда, когда задача хорошо разбивается на независимые куски с небольшими входными/выходными данными.

I/O-bound задачи: asyncio как альтернатива потокам

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

code
import asyncio
import aiohttp
import time

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]


async def fetch(session, url):
    async with session.get(url) as response:
        return await response.read()


async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in URLS]
        return await asyncio.gather(*tasks)


start = time.perf_counter()
asyncio.run(main())
print(f"asyncio: {time.perf_counter() - start:.2f}s")  # ~1s

asyncio не обходит GIL — он просто не нуждается в параллельном выполнении кода. Пока одна корутина ждёт ответа сети, event loop переключается на другую. Всё это происходит в одном потоке, без накладных расходов на синхронизацию.

Для разработчиков, которые хотят глубоко разобраться в этих механизмах в контексте production-разработки, полезным ориентиром может служить курс «Senior Python» — там эти темы рассматриваются в связке с реальными архитектурными решениями, а не как изолированные концепции.

Что меняется в Python 3.13 и далее

Разговоры об удалении GIL ведутся давно, но конкретные шаги начались относительно недавно. В октябре 2023 года был принят PEP 703 («Making the Global Interpreter Lock Optional in CPython»), написанный Сэмом Гроссом. В Python 3.13 появилась экспериментальная сборка без GIL (--disable-gil).

Это значительный шаг, но с оговорками:

  • «Без GIL» пока является опциональным режимом, а не дефолтным.
  • Производительность однопоточного кода в no-GIL сборке немного ниже из-за более тонкой синхронизации счётчиков ссылок.
  • Многие C-расширения ещё не адаптированы к работе без GIL и могут вести себя непредсказуемо.

Реалистичный прогноз: no-GIL Python станет стабильным и дефолтным не раньше Python 3.15–3.16. До тех пор понимание GIL остаётся обязательным для любого серьёзного Python-разработчика.

Практические рекомендации: что выбрать

Чтобы не теряться в выборе инструмента, полезно держать в голове простую матрицу:

Тип задачиРекомендуемый подход
CPU-bound, независимые задачиmultiprocessing, concurrent.futures.ProcessPoolExecutor
CPU-bound, NumPy/SciPy вычисленияthreading (NumPy освобождает GIL) или multiprocessing
I/O-bound, много соединенийasyncio + асинхронные библиотеки
I/O-bound, legacy-код без asyncthreading, concurrent.futures.ThreadPoolExecutor
Смешанная нагрузкаКомбинация: asyncio + run_in_executor для CPU-частей

Типичные ошибки

Ошибка 1: Использовать threading для CPU-bound задач. Это не только не ускорит код, но и добавит накладные расходы. Проверяйте профилировщиком, является ли ваша задача CPU-bound или I/O-bound, прежде чем выбирать инструмент.

Ошибка 2: Игнорировать concurrent.futures. Этот модуль предоставляет удобный единый интерфейс над threading и multiprocessing. ThreadPoolExecutor и ProcessPoolExecutor — часто более удобный выбор, чем работа с потоками и процессами напрямую.

code
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

# CPU-bound
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(cpu_bound_task, [500_000] * 8))

# I/O-bound
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(fetch_url, url) for url in urls]
    results = [f.result() for f in futures]

Ошибка 3: Смешивать asyncio и блокирующие вызовы. Если в асинхронном коде вызвать блокирующую функцию напрямую (например, requests.get), она заблокирует весь event loop. Для таких случаев используйте loop.run_in_executor.

code
import asyncio
import requests
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor()

async def fetch_sync_in_async(url):
    loop = asyncio.get_event_loop()
    # Выполняем блокирующий вызов в пуле потоков
    response = await loop.run_in_executor(executor, requests.get, url)
    return response.content

Вывод

GIL — это компромисс, сделанный в конкретных исторических и технических условиях. Он упрощает реализацию CPython и интеграцию C-расширений, но ограничивает параллельное выполнение Python-кода на нескольких ядрах.

Понимание GIL позволяет принимать осознанные архитектурные решения: использовать multiprocessing там, где нужна реальная параллельность на CPU, и asyncio там, где узкое место — это ожидание I/O. Потоки при этом не становятся бесполезными — они хорошо работают для I/O-bound задач и для интеграции с C-расширениями, которые умеют освобождать GIL.

Если вы хотите разобраться в этих темах системно — с пониманием внутреннего устройства CPython, паттернов конкурентности и их применения в реальных проектах — стоит посмотреть на курс «Senior Python». Это один из немногих форматов, где подобные вещи разбираются не поверхностно, а с необходимой глубиной.

Python продолжает развиваться: PEP 703 и работа над no-GIL сборкой — это серьёзный сигнал о том, что ограничения GIL осознаны и решаются. Но пока это будущее, а не настоящее. Сегодня знание того, как GIL влияет на ваш код, — это не академическая деталь, а практический навык.

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Senior Python

Курс по теме

Senior Python

Senior Python: классы под микроскопом — погружение во внутреннее устройство CPython для middle/senior: от декораторов и метаклассов до байткода и GC. Разбор исходников языка, трассировки и тесты к каждому уроку.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Solidity pro - всё или ничего

Solidity pro - всё или ничего

Как пишут контракты, которые работают в основной сети. Наследование и OpenZeppelin, паттерны продакшена, оптимизация газа, ABI, delegatecall и подписи. 7 модулей, 33 урока, а в финале собственное dApp: мини-DAO с токеном, голосованием и казной.

Перейти к курсу

Другие статьи

Многопоточность в C++: std::thread, мьютексы и гонки данных
многопоточность

Многопоточность в C++: std::thread, мьютексы и гонки данных

Объясняем основы параллельного программирования на C++11 и выше: создаём потоки, защищаем разделяемые данные и разбираем классические ошибки, которые приводят к недетерминированным багам.

17 июля 2026 г.
980
Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки
устроен

Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки

Погружаемся в механизм reference counting и cyclic garbage collector — смотрим на исходный код CPython, пишем тесты и выясняем, когда объект действительно удаляется из памяти.

17 июля 2026 г.
950
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

Как выучить программирование самому и бесплатно: с чего начать и в каком порядке

Разберём понятный маршрут для новичка: как выбрать первый язык, что учить в первую очередь, какие мини-проекты делать и как не забросить занятия. Дам бесплатные ориентиры и план, чтобы знания складывались в портфолио.

21 сентября 2026 г.
1960
Как начать с HTML и сделать первую страницу: пошагово и без сложных слов
html

Как начать с HTML и сделать первую страницу: пошагово и без сложных слов

Разберём, что нужно новичку, чтобы за 1–2 вечера собрать свою первую веб-страницу: структура HTML, базовые теги, ссылки и картинки, плюс простой чек-лист, как понять, что страница работает правильно.

2 ноября 2026 г.
20
Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей
cargo

Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей

Разберём, как организовать Rust workspace, управлять optional features, фиксировать версии библиотек и уменьшать “дрейф” зависимостей при командной разработке и CI.

18 сентября 2026 г.
1960
Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах
такое

Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах

Разберём, из чего состоит веб-страница: что делает HTML (смысл и структура) и что делает CSS (оформление). В конце соберёте мини-страницу и поймёте, что нужно учить в каком порядке.

30 октября 2026 г.
130

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Что такое GIL и зачем он нуженКак GIL работает на практикеМеханизм переключения GILКогда потоки всё-таки работаютCPU-bound задачи: переходим на multiprocessingПодводные камни multiprocessingI/O-bound задачи: asyncio как альтернатива потокамЧто меняется в Python 3.13 и далееПрактические рекомендации: что выбратьТипичные ошибкиВывод