Поток (thread) — это независимая последовательность выполнения инструкций внутри одного процесса, которая разделяет с другими потоками память и ресурсы этого процесса. В Python потоки создает и запускает модуль threading.
Содержание
- Поток, процесс и GIL: три термина, которые путают
- Создаем поток: threading.Thread
- Демон-потоки и необработанные исключения
- Гонка данных: почему нужен Lock
- GIL на CPU-bound задаче: потоки против multiprocessing
- Free-threaded Python: GIL стал опциональным
- Выводы
- Где применяется / связь с практикой
- FAQ
В статье разберем, как создать поток, чем поток отличается от процесса и 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 нужен, когда есть хотя бы один поток, который переменную изменяет, пока другие ее читают или тоже изменяют.



