$ 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 мин·71
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 – для начинающих!

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

Перейти к курсу
Приложения для iPhone и Apple Watch на SwiftUI

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

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

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

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

Перейти к курсу
Приложения для macOS на SwiftUI

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

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

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

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

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

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

17 июля 2026 г.
790
Ownership и Borrowing в Rust: почему компилятор умнее вас (и это хорошо)
ownership

Ownership и Borrowing в Rust: почему компилятор умнее вас (и это хорошо)

Пошагово разбираем систему владения Rust, объясняем, почему она исключает целые классы ошибок памяти и как научиться думать в её терминах с первых дней.

16 июля 2026 г.
690
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1421
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

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

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

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

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

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

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

Содержание

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