Потоки в Python: threading, GIL и когда нужен multiprocessing

Потоки в Python: threading, GIL и когда нужен multiprocessing Полезное

Поток (thread) — это независимая последовательность выполнения инструкций внутри одного процесса, которая разделяет с другими потоками память и ресурсы этого процесса. В Python потоки создает и запускает модуль threading.

В статье разберем, как создать поток, чем поток отличается от процесса и GIL, что такое гонка данных и как ее закрыть через Lock, и почему для тяжелых вычислений потоки не ускоряют код — и что использовать вместо них.

Поток, процесс и GIL: три термина, которые путают

Три понятия рядом, и их легко смешать:

  • Процесс — отдельная программа в своей области памяти. Процессы не делят память напрямую.
  • Поток — единица выполнения внутри процесса. Все потоки одного процесса делят общую память, поэтому создание потока дешевле создания процесса.
  • GIL (Global Interpreter Lock) — блокировка интерпретатора в CPython (эталонной реализации Python), которая разрешает исполнять байткод Python только одному потоку процесса в конкретный момент времени.

Из-за GIL несколько потоков одного процесса Python не выполняют байткод параллельно на разных ядрах. Но во время ожидания ввода-вывода (сеть, диск, time.sleep) поток отдает GIL, и в это время может работать другой поток. Поэтому потоки в Python дают выигрыш на I/O-bound задачах (сеть, файлы, ожидание) и не дают выигрыша на CPU-bound задачах (расчеты, обработка чисел) — это и есть главное практическое правило раздела ниже.

Создаем поток: threading.Thread

Минимальный рабочий пример — два потока, каждый ждет и печатает статус:

import threading
import time

def worker(name, delay):
    print(f"{name}: старт")
    time.sleep(delay)
    print(f"{name}: финиш")

t1 = threading.Thread(target=worker, args=("Поток-1", 1))
t2 = threading.Thread(target=worker, args=("Поток-2", 1))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Итого: {time.time() - start:.1f} с")

Оба потока стартуют почти одновременно, обе строки «старт» появляются рядом, затем через секунду — обе строки «финиш». Итоговое время — около 1.0 с, а не 2.0 с: time.sleep освобождает GIL, и ожидание идет параллельно. join() дожидается завершения потока, без него программа могла бы завершиться раньше, чем отработают потоки.

Демон-потоки и необработанные исключения

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

import threading
import time

def background_task():
    while True:
        time.sleep(5)

t = threading.Thread(target=background_task, daemon=True)
t.start()
print("Основной поток продолжает работу и может завершиться")

Программа завершится сразу после основного потока, не дожидаясь бесконечного background_task — демон-потоки принудительно останавливаются при выходе из программы.

Отдельная особенность — необработанное исключение внутри потока:

import threading

def broken():
    raise ValueError("что-то пошло не так")

t = threading.Thread(target=broken)
t.start()
t.join()
print("Основной поток дошел до этой строки")

В консоли появится трассировка ValueError из Thread-1, но следом все равно напечатается «Основной поток дошел до этой строки»: join() не пробрасывает исключение из потока наверх, оно только печатается в стектрейс и не прерывает остальные потоки.

Чтобы узнать об ошибке в основном потоке, ее нужно поймать внутри функции потока и передать наружу самостоятельно (например, через queue.Queue) либо использовать concurrent.futures.ThreadPoolExecutor, где future.result() поднимет то же исключение уже в вызывающем коде.

Гонка данных: почему нужен Lock

Если несколько потоков меняют одну переменную без синхронизации, результат становится недетерминированным — это и есть гонка данных (race condition):

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)

Ожидаемо 400 000 (4 потока по 100 000 инкрементов). На практике число получается меньше и меняется от запуска к запуску — точное значение зависит от версии Python, ОС и нагрузки на машину. Причина: counter += 1 — это не одна атомарная операция, а чтение значения, прибавление и запись обратно, и между этими шагами GIL может переключить поток на другой, потеряв часть инкрементов.

Чтобы результат стал детерминированным, оборачиваем изменение общей переменной в Lock:

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)

Теперь вывод стабильно 400000: with lock пропускает в защищенный участок только один поток за раз, остальные ждут освобождения блокировки. Это упрощенная модель: Lock защищает только тот код, который явно обернут в него, — если забыть обернуть хотя бы одно место изменения общей переменной, гонка вернется.

GIL на CPU-bound задаче: потоки против multiprocessing

Проверим на расчетной задаче без ожидания ввода-вывода — сумме квадратов в цикле:

import time
from threading import Thread
from multiprocessing import Process

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

N = 20_000_000

def run_threads():
    threads = [Thread(target=cpu_task, args=(N // 2,)) for _ in range(2)]
    start = time.time()
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return time.time() - start

def run_processes():
    processes = [Process(target=cpu_task, args=(N // 2,)) for _ in range(2)]
    start = time.time()
    for p in processes:
        p.start()
    for p in processes:
        p.join()
    return time.time() - start

if __name__ == "__main__":
    print(f"Потоки: {run_threads():.1f} с")
    print(f"Процессы: {run_processes():.1f} с")

Обратите внимание на if __name__ == "__main__": — без этой защиты запуск процессов на некоторых платформах уйдет в рекурсивный повторный импорт модуля.

На машине с несколькими ядрами вариант с потоками не становится заметно быстрее одного потока: GIL не дает двум потокам считать байткод одновременно. Вариант с процессами на многоядерной машине выигрывает по времени, потому что у каждого процесса свой интерпретатор и свой GIL, и они реально считают параллельно на разных ядрах. Точное ускорение зависит от числа ядер и загрузки машины — в «Точках для фактчека» ниже.

Сводка, когда что брать:

Механизм Что дает Когда использовать Ограничение
threading Параллельное ожидание I/O-bound: сеть, файлы, БД Не ускоряет CPU-bound из-за GIL
multiprocessing Параллельные вычисления на ядрах CPU-bound: расчеты, обработка данных Дороже по памяти, обмен данными сложнее
asyncio Одна нить, кооперативное переключение Много I/O-ожиданий одновременно (тысячи соединений) Код и библиотеки должны быть async-совместимыми

Free-threaded Python: GIL стал опциональным

Начиная с Python 3.13 в CPython появилась сборка без GIL (free-threaded, PEP 703, флаг --disable-gil при сборке, бинарник вида python3.13t). В такой сборке потоки могут реально исполнять байткод параллельно на разных ядрах.

В Python 3.13 этот режим был помечен экспериментальным. В Python 3.14 free-threaded сборка по критериям PEP 779 перешла в статус официально поддерживаемой — это не значит, что GIL убрали по умолчанию: обычная сборка CPython с GIL остается сборкой по умолчанию, а free-threaded ставится и используется отдельно. Часть библиотек с C-расширениями к free-threaded сборке еще донастраивается, а однопоточный код в ней может идти немного медленнее обычной сборки с GIL. Для типовых задач по умолчанию ставится стандартный CPython с GIL, free-threaded сборку имеет смысл включать осознанно, проверив совместимость нужных библиотек.

Выводы

  • Поток — единица выполнения внутри процесса, потоки одного процесса делят память.
  • GIL в CPython разрешает исполнять байткод Python только одному потоку процесса одновременно, поэтому потоки ускоряют I/O-bound задачи и не ускоряют CPU-bound.
  • Общую переменную из нескольких потоков нужно менять только под Lock — иначе возможна гонка данных с недетерминированным результатом.
  • Для тяжелых вычислений на нескольких ядрах нужен multiprocessing, а не threading.
  • Free-threaded Python (PEP 703) снимает GIL в отдельной сборке: экспериментальная в 3.13, официально поддерживаемая с 3.14 (PEP 779), но не сборка по умолчанию.

Где применяется / связь с практикой

Освойте тему на практике

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

Освойте тему на практике

Если пока не уверены, что готовы к полному курсу — можно начать с открытых уроков Otus: там разбирают конкретные темы Python в формате одного занятия.

Смежные темы: Словари в Python и их перебор, Java Concurrency: как работают потоки на практике.

FAQ

Можно ли использовать потоки вместе с asyncio?
Технически да, но это усложняет код: async-код рассчитан на одну нить с кооперативным переключением, а примешивание threading требует аккуратной синхронизации между циклом событий и потоками. Для новых проектов обычно выбирают один подход — либо asyncio, либо threading — под тип нагрузки.

Сколько потоков можно создать в одном процессе Python?
Формального лимита в языке нет, ограничение практическое — память на стек каждого потока и накладные расходы на переключение контекста. Для сотен параллельных I/O-ожиданий обычно эффективнее пул потоков (concurrent.futures.ThreadPoolExecutor) или asyncio, а не тысячи ручных потоков.

Нужен ли Lock, если несколько потоков только читают общую переменную, не изменяя ее?
Если ни один поток не пишет в переменную, конкурентное чтение само по себе не создает гонку данных. Lock нужен, когда есть хотя бы один поток, который переменную изменяет, пока другие ее читают или тоже изменяют.

OTUS Журнал