Подборка материалов для освоения темы многозадачности в Python
YouTube ролики
- Как работает GIL в Python. Многопоточность. Многопроцессность. IO/CPU-Bound
- Yandex for Developers — 01. Устройство CPython – Егор Овчаренко
- [ZProger] Многопоточность и Многопроцессорность Python. Threading & Multiprocessing Python
- Асинхронность, многопоточность, многопроцессность в python | Библиотека asyncio и асинхронный код
- Threading. Кратко про Python
- Плейлист Асинхронность в Python
- Young&&Yandex ШБР 2023 — Асинхронное программирование (Python) — полный плейлист Python ШБР 2023
- Особенности asyncio.wait_for() в асинхронном Python. Как работает таймаут для корутины
- Плейлист Асинхронность в Python
- Асинхронное программирование на примере Python / asyncio
- Собеседование Python. Разбор вопросов
- Денис Аникин. Вновь ускоряем cpu-bound задачи
- Python: Threads, GIL, asyncio
- Лекция Тимофей Хирьянов — Параллельное программирование на Python
- Yandex Developer (плейлист Школа бэкенд-разработки 2019) — Асинхронное программирование — Лекция 1, Лекция 2, Лекция 3
- Плейлист «Конкурентность в Python»
- GIL в Python: зачем он нужен и как с этим жить
- Асинхронный Python-код медленнее обычного кода! Ааа!!1один. Aiohttp VS синхронные фреймворки
YouTube English:
- PlayList: Воспроизвести все import asyncio: Learn Python’s AsyncIO
- CPU Bound vs. I/O Bound | Computer Basics
- threading vs multiprocessing in python
- I/OBound vs CPU Bound Code
Статьи
- CPython простыми словами: всё, что нужно знать начинающему
- Как устроен GIL (Global Interpreter Lock) в Python: влияние на многозадачность и производительность
- Как устроен GIL в Python
- Всё, что нужно знать о сборщике мусора в Python
- Визуализация управления памятью в Python: что творится внутри?
Введение в Python
Исходный глоссарий
Виртуальное адресное пространство
Виртуальное адресное пространство — это абстракция, предоставляемая ОС, в рамках которой каждый процесс видит собственную непрерывную адресную память, не зная о реальном физическом расположении данных.
Структура виртуального адресного пространства
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Высокие адреса ┌─────────────────────────┐ │ Kernel space (отображ.) │ ← недоступен напрямую ├─────────────────────────┤ │ Stack │ ← стек потоков ├─────────────────────────┤ │ Heap │ ← объекты Python ├─────────────────────────┤ │ Data / BSS │ ← глобальные переменные ├─────────────────────────┤ │ Code (text segment) │ ← байткод + C-расширения └─────────────────────────┘ Низкие адреса |
Python не управляет адресным пространством напрямую — он запрашивает память у ОС через malloc, mmap, brk.
Heap & Stack
Стек(stack) и куча(heap) – области в оперативной памяти (ОЗУ, RAM), в которых хранятся данные приложения во время его выполнения. Управление оперативной памятью для приложения Python осуществляется с помощью Python memory manager.
В управлении памятью (Python memory management) существует механизм учёта ссылок (reference counting), который ведет внутренний журнал того, как много ссылок ссылается на объект в куче. Когда на объект не ссылается ни одна ссылка сборщик мусора (Garbage collector) автоматически освобождает память выделенную ранее для этого объекта.
Heap — область памяти процесса, предназначенная для динамического выделения памяти во время выполнения.
В Python:
- все объекты Python живут в heap
int,list,dict,class,function— всё heap
Stack — область памяти, используемая для хранения локальных переменных, адресов возврата, аргументов функций. Каждый поток имеет собственный стек.
Python stack — это логическая абстракция, а не «настоящий» stack ОС.
Дескрипторы ресурсов (File Descriptors)
File descriptor — это целое число, которое операционная система даёт твоей программе, когда она открывает файл или другое устройство ввода-вывода (например, сокет, pipe). Это как минимальный идентификатор ресурса: Python использует его для низкоуровневых операций с файлами.
- Это не объект Python, это число, под которым ОС видит открытый файл/ресурс.
- С помощью FD можно делать низкоуровневые операции (чтение, запись, дупликация, перемещение позиции и т.п.).
- Отличие от обычного open() в том, что FD используют функции модуля os, а не методы объекта файла.
Каждый FD — ограниченный ресурс. Если ты открыл много файлов или сокетов и не закрыл их, система закончится и новые операции упадут с ошибками вроде Too many open files. Это особенно критично для серверов, которые держат много соединений одновременно.
В Unix-системах всё представляется как файл. Стандартные дескрипторы:
0— stdin1— stdout2— stderr
Ты можешь перенаправлять их (например, в скриптах bash или в приложениях), и это тоже работает через FD.
Примеры ресурсов: файлы, сокеты, pipe, eventfd, epoll/kqueue
Глобальные переменные в Python
Глобальные переменные — это имена, привязанные в namespace модуля.
В реальности:
- имя
x→ указатель - объект
10→ heap - namespace модуля → dict в heap
Регистры CPU
Регистры CPU — сверхбыстрая память внутри процессора.
Хранят: указатель инструкции (IP), указатель стека (SP), флаги, временные значения.
Python не управляет регистрами напрямую. Но при context switch ОС сохраняет регистры, при переключении потоков Python → регистры меняются. Это основная стоимость context switch.
User Space
User space — режим выполнения с ограниченными правами.
Python-код выполняется исключительно в user space.
Запрещено:
- прямой доступ к устройствам
- управление памятью
- прерывания
Kernel Space
Kernel space — привилегированный режим выполнения.
Ядро:
- управляет памятью
- планирует процессы
- обрабатывает I/O
- управляет сетевым стеком
Что такое процесс, поток, системный вызов и context switch?
Процесс — это изолированное выполняемое окружение, предоставляемое ОС. Каждый процесс имеет собственное виртуальное адресное пространство, heap, stack, дескрипторы ресурсов (файлы, сокеты).
Поток (Thread) — это единица выполнения внутри процесса. Потоки разделяют одно адресное пространство процесса, каждый поток имеет собственный stack, выполняются псевдопараллельно внутри 1 процесса. Общее у потоков heap, глобальные переменные, объекты Python. Раздельное — stack, регистры CPU.
Системный вызов — это контролируемый переход из user space в kernel space.
Python-код не может напрямую:
- читать диск
- писать в сокет
- создавать процесс
- спать
Что происходит при системном вызове:
- Python вызывает C-функцию
- C-функция делает
syscall - ОС выполняет операцию
- Поток блокируется, пока ОС не закончит
В этот момент:
- GIL может быть освобождён
- другой поток может выполняться
Context switch — это переключение CPU с одной задачи на другую.
Бывает:
- между потоками
- между процессами
Что сохраняется:
- регистры CPU
- указатель стека
- состояние планировщика
Архитектура CPython
CPython — это эталонная реализация языка программирования Python. Это версия Python по умолчанию, наиболее широко используемая и оригинальная реализация, написанная преимущественно на языке C.
Иными словами CPython — это программа, которая принимает ваш код на Python и выполняет его, преобразуя в понятные машине действия.
- Исходный код (Source code) – mymodule.py преобразуется в байт-код с помощью компилятора (compiler) Python
- Байт-код (Byte code) сохраняется в определенном формате (.pyc, .pyo, .pyd) – mymodule.pyc
- Виртуальная машина Python (или PVM) получает байт-код и с помощью интерпретатора преобразует его в бинарный код.
- Бинарный или машинный код (Binary code)
- Компьютер читает бинарный код и выполняет программу
Важно понимать разницу между языком Python и интерпретатором CPython. Язык Python — это набор правил и синтаксиса (описанных в документации), а CPython конкретная программа, исполняющая код на этом языке.
Python — язык, а CPython — его основной движок.
Что такое PVM?
Мы знаем, что компьютеры понимают только машинный код, состоящий из нулей и единиц. Поскольку компьютер понимает исключительно машинный код, любой программный код перед выполнением должен быть преобразован в машинный код. Для этого используется компилятор. Обычно компилятор преобразует исходный код программы непосредственно в машинный код.
Компилятор Python выполняет ту же задачу, но несколько иным образом. Он преобразует исходный код программы в другой вид кода, называемый байт-кодом. Каждая инструкция программы на Python преобразуется в набор инструкций байт-кода.
Виртуальная машина Python (Python Virtual Machine, PVM) принимает этот байт-код и преобразует его в машинный код, чтобы компьютер мог выполнить соответствующие инструкции и вывести итоговый результат. Для выполнения этого преобразования PVM оснащена интерпретатором. Интерпретатор преобразует байт-код в машинный код и передаёт этот машинный код процессору компьютера для выполнения. Поскольку именно интерпретатор играет ключевую роль, виртуальную машину Python часто также называют интерпретатором.
Альтернативные реализации
Хотя CPython является стандартной реализацией, существуют и другие реализации Python, созданные для конкретных задач, таких как повышение производительности или интеграция с другими платформами:
- PyPy — использует компиляцию Just-In-Time (JIT), что позволяет во многих случаях выполнять Python-код значительно быстрее, чем в CPython.
- Jython — написан на Java и компилирует Python-код в байткод Java, что позволяет запускать Python на виртуальной машине Java (JVM) и взаимодействовать с библиотеками Java.
- IronPython — реализован для Common Language Infrastructure (CLI), благодаря чему может работать на платформе
.NET. - MicroPython / CircuitPython — оптимизированные реализации, предназначенные для микроконтроллеров и встраиваемых систем.
Производительность
Те, кто имеют опыт работы с компилирующими языками программирования, такими как C и C++, могут заметить несколько отличий в модели выполнения Python.
- Первое, что бросается в глаза, – это отсутствие этапа сборки, или вызова утилиты «make»: программный код может запускаться сразу же, как только будет написан.
- Второе отличие: байт код не является двоичным машинным кодом (например, инструкциями для микропроцессора Intel). Байт код – это внутреннее представление программ на языке Python.
По этой причине программный код на языке Python не может выполняться так же быстро, как программный код на языке C или C++. Обход инструкций выполняет виртуальная машина, а не микропроцессор, и чтобы выполнить байт код, необходима дополнительная интерпретация, инструкции которого требуют на выполнение больше времени, чем машинные инструкции микропроцессора. С другой стороны, в отличие от классических интерпретаторов, здесь присутствует дополнительный этап компиляции – интерпретатору не требуется всякий раз снова и снова анализировать инструкции исходного текста. В результате Python способен обеспечить скорость выполнения где то между традиционными компилирующими и традиционными интерпретирующими языками программирования.
GIL (Global Interpreter Lock)
GIL (Global Interpreter Lock) — интерпретатор Python однопоточный в том смысле, что в каждый момент времени может выполняться только один участок байт-кода, даже если в процессе работает несколько потоков. Глобальная блокировка интерпретатора не позволяет выполнять несколько потоков одновременно.
Python может освободить GIL на время выполнения операций ввода-вывода (I/O Bound), потому что для выполнения ввода-вывода вызывается низкоуровневая функция операционной системы. Эти функции работают за пределами интерпретатора, т. е. никак не могут повредить его внутренние структуры, от чего и призвана защитить GIL.
GIL был введён для упрощения управления памятью в Python, поскольку многие внутренние операции, такие как создание объектов, по умолчанию не являются потокобезопасными. Без GIL нескольким потокам, одновременно обращающимся к общим ресурсам, потребовались бы сложные механизмы блокировок или синхронизации для предотвращения гонок данных и повреждения состояния.
Когда GIL становится узким местом?
- В однопоточных программах GIL не имеет значения, так как поток обладает эксклюзивным доступом к интерпретатору Python.
- В многопоточных I/O-bound программах влияние GIL менее заметно, поскольку потоки освобождают GIL во время ожидания операций ввода-вывода.
- В многопоточных CPU-bound задачах GIL становится серьёзным узким местом. Несколько потоков, конкурируя за GIL, вынуждены по очереди выполнять байткод Python.
Интересный случай, на который стоит обратить внимание, — использование time.sleep. Python фактически рассматривает time.sleep как I/O-операцию. Функция time.sleep не является CPU-bound, поскольку во время сна не происходит активных вычислений или выполнения байткода Python. Вместо этого ответственность за отслеживание прошедшего времени передаётся операционной системе. В течение этого времени поток освобождает GIL, позволяя другим потокам выполняться и использовать интерпретатор.
CPU-bound vs I/O-bound задачи
I/O-bound
I/O-bound задача — это задача, выполнение которой блокируется ожиданием операций ввода-вывода (I/O), например сетевых запросов, чтения/записи на диск или работы с внешними устройствами, и поэтому большая часть времени тратится не на вычисления, а на ожидание завершения этих операций.
CPU-bound
CPU-bound задача — это задача, выполнение которой ограничено мощностью центрального процессора (CPU), а не ожиданием ввода-вывода. Время её выполнения определяется главным образом количеством вычислительных операций, которые нужно выполнить CPU, а не тем, сколько времени тратится на ожидание данных из внешних источников.
Многозадачность в Python
Concurrency vs Parallelism
- Concurrency — это управление несколькими задачами в одно и то же время, но не обязательно их одновременное выполнение. Задачи могут выполняться по очереди, создавая иллюзию многозадачности.
- Parallelism — это одновременное выполнение нескольких задач, как правило за счёт использования нескольких ядер CPU.
Критерии выбора подхода — Multithreading, Multiprocessing или Asyncio
Multiprocessing (многопроцессность)
- Лучше всего подходит для CPU-bound задач, требующих интенсивных вычислений.
- Используется, когда необходимо обойти GIL — каждый процесс имеет собственный интерпретатор Python, что позволяет достичь настоящего параллелизма.
Multithreading (многопоточность)
- Лучше всего подходит для быстрых I/O-bound задач, так как уменьшается частота переключений контекста, и интерпретатор Python дольше остаётся в одном потоке.
- Не подходит для CPU-bound задач из-за ограничений GIL.
Asyncio (асинхронность)
- Идеально подходит для медленных I/O-bound задач, таких как длительные сетевые запросы или обращения к базе данных, поскольку эффективно обрабатывает ожидание и хорошо масштабируется.
- Не подходит для CPU-bound задач, если вычисления не выносятся в другие процессы.
threading
Начальный пример Threading
Модуль threading предоставляет способ запуска нескольких потоков (меньших единиц процесса) конкурентно внутри одного процесса. Он позволяет создавать и управлять потоками, делая возможным параллельное выполнение задач с разделяемым адресным пространством памяти. Потоки особенно полезны, когда задачи являются I/O-bound, например при работе с файлами или выполнении сетевых запросов, где значительная часть времени тратится на ожидание внешних ресурсов.
Типичный сценарий использования threading — управление пулом рабочих потоков, которые могут конкурентно обрабатывать несколько задач. Ниже приведён базовый пример создания и запуска потоков с использованием Thread:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import threading import time import random from datetime import datetime def crawl(link): print(f"crawl запустился для ссылки {link}. Время вызова: {datetime.now()}") time.sleep(random.randint(1, 11)) # Блокирующий I/O (имитация сетевого запроса) print(f"crawl завершен для {link}. Время вызова: {datetime.now()}") links = [ "https://python.org", "https://docs.python.org", "https://peps.python.org", ] # Создаём потоки для каждой ссылки threads = [] for i, link in enumerate(links): # Используем `args` для позиционных аргументов и `kwargs` для именованных t = threading.Thread(target=crawl, args=(link,), name=f"Thread-{i+1}") threads.append(t) # Запускаем каждый поток for t in threads: t.start() print(f'Поток {t} запущен в {datetime.now()}') # Ожидаем завершения всех потоков for t in threads: t.join() print(f'{t} завершен в {datetime.now()}') |
Результат:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
crawl запустился для ссылки https://python.org. Время вызова: 2025-12-27 13:38:45.574122 Поток <Thread(Thread-1, started 138519994431168)> запущен в 2025-12-27 13:38:45.574331 crawl запустился для ссылки https://docs.python.org. Время вызова: 2025-12-27 13:38:45.574500 Поток <Thread(Thread-2, started 138519986038464)> запущен в 2025-12-27 13:38:45.574558 crawl запустился для ссылки https://peps.python.org. Время вызова: 2025-12-27 13:38:45.574701 Поток <Thread(Thread-3, started 138519977645760)> запущен в 2025-12-27 13:38:45.574758 crawl завершен для https://python.org. Время вызова: 2025-12-27 13:38:46.574254 <Thread(Thread-1, stopped 138519994431168)> завершен в 2025-12-27 13:38:46.574417 crawl завершен для https://docs.python.org. Время вызова: 2025-12-27 13:38:47.574610 <Thread(Thread-2, stopped 138519986038464)> завершен в 2025-12-27 13:38:47.574773 crawl завершен для https://peps.python.org. Время вызова: 2025-12-27 13:38:47.574813 <Thread(Thread-3, stopped 138519977645760)> завершен в 2025-12-27 13:38:47.574895 |
Результат второго запуска:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
crawl запустился для ссылки https://python.org. Время вызова: 2025-12-27 13:44:50.159111 Поток <Thread(Thread-1, started 126693911033536)> запущен в 2025-12-27 13:44:50.159203 crawl запустился для ссылки https://docs.python.org. Время вызова: 2025-12-27 13:44:50.159412 Поток <Thread(Thread-2, started 126693902640832)> запущен в 2025-12-27 13:44:50.159460 crawl запустился для ссылки https://peps.python.org. Время вызова: 2025-12-27 13:44:50.159612 Поток <Thread(Thread-3, started 126693894248128)> запущен в 2025-12-27 13:44:50.159679 crawl завершен для https://docs.python.org. Время вызова: 2025-12-27 13:44:57.159525 crawl завершен для https://peps.python.org. Время вызова: 2025-12-27 13:44:58.159735 crawl завершен для https://python.org. Время вызова: 2025-12-27 13:44:59.159292 <Thread(Thread-1, stopped 126693911033536)> завершен в 2025-12-27 13:44:59.159502 <Thread(Thread-2, stopped 126693902640832)> завершен в 2025-12-27 13:44:59.159545 <Thread(Thread-3, stopped 126693894248128)> завершен в 2025-12-27 13:44:59.159565 |
Общая схема start и join в threading:
Деталь реализации CPython
В CPython из-за глобальной блокировки интерпретатора (GIL) только один поток может выполнять Python-код в каждый момент времени (хотя некоторые ориентированные на производительность библиотеки могут обходить это ограничение). Если требуется более эффективно использовать вычислительные ресурсы многоядерных машин, рекомендуется использовать multiprocessing или concurrent.futures.ProcessPoolExecutor. Тем не менее, threading остаётся подходящей моделью, если нужно одновременно выполнять несколько I/O-bound задач.
GIL и вопросы производительности
В отличие от модуля multiprocessing, который использует отдельные процессы для обхода GIL, модуль threading работает внутри одного процесса, а значит все потоки разделяют одно и то же адресное пространство памяти. Однако GIL ограничивает прирост производительности при работе с CPU-bound задачами, поскольку только один поток может выполнять байткод Python одновременно. Несмотря на это, потоки остаются полезным инструментом для достижения конкурентности во многих сценариях.
Начиная с Python 3.13, существуют free-threaded сборки, в которых GIL может быть отключён, что позволяет добиться настоящего параллельного выполнения потоков. Однако по умолчанию эта возможность недоступна (см. PEP 703).
Жизненный цикл потока
Жизненным циклом потоков можно управлять с помощью следующих методов:
start()— Дает потоку жизнь.run()— Этот метод представляет действия, которые должны быть выполнены в
потоке.join([timeout])— Поток, который вызывает этот метод, приостанавливается, ожидая завершения потока, чей метод вызван. Параметрtimeout(число с плавающей точкой) позволяет указать время ожидания (в секундах), по истечении которого приостановленный поток продолжает свою работу независимо от завершения потока, чей методjoinбыл вызван. Вызыватьjoin()некоторого потока можно много раз. Поток не может вызвать методjoin()самого себя. Также нельзя ожидать завершения еще не запущенного потока. Слово «join» в переводе с английского означает «присоединить», то есть, метод, вызвавшийjoin(), желает, чтобы поток по завершении присоединился к вызывающему метод потоку.getName()— Возвращает имя потока. Для главного потока это «MainThread«.setName(name)— Присваивает потоку имя name.isAlive()— Возвращает истину, если поток работает (методrun()уже вызван, но еще не завершился).isDaemon()— Возвращает истину, если поток имеет признак демона. Программа на Python завершается по завершении всех потоков, не являющихся демонами. Главный поток демоном не является.setDaemon(daemonic)— Устанавливает признак daemonic того, что поток является демоном. Начальное значение этого признака заимствуется у потока, запустившего данный. Признак можно изменять только для потоков, которые еще не запущены.
Атрибуты потока:
t.name— имя потокаt.ident— Уникальный идентификатор потока (ID) — None, если поток ещё не запущенthreading.current_thread()— Возвращает объект текущего потокаt.daemon = True— Демон-потоки убиваются при завершении главного потока. Используются для фоновых задач, логирования, heartbeat-потоков. daemon нужно задавать до start()threading.active_count()— Количество активных потоковthreading.enumerate()— Список всех живых потоков
Реализация потокобезопасной записи результатов с Lock, чтобы избежать race condition
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
import threading import time import random from datetime import datetime def crawl(link, results, lock): print(f"Поток {threading.current_thread().name} запущен. Время вызова: {datetime.now()}") # Имитация сетевого запроса delay = random.randint(1, 10) time.sleep(delay) # Имитация полученного JSON response = { "url": link, "status": 200, "data": { "title": f"Данные с {link}", "value": random.randint(1, 100), }, "fetched_at": datetime.now().isoformat(), "thread": threading.current_thread().name, } # Потокобезопасная запись результата with lock: results[link] = response print(f"Поток {threading.current_thread().name} завершен. Запрос длился {delay} секунд. Время завершения: {datetime.now()}") links = [ "https://python.org", "https://docs.python.org", "https://peps.python.org", ] # Общее хранилище результатов results = {} # Lock для синхронизации доступа к results lock = threading.Lock() # Создаём потоки threads = [] for i, link in enumerate(links): t = threading.Thread( target=crawl, args=(link, results, lock), name=f"Thread-{i + 1}", ) threads.append(t) # Запускаем потоки for t in threads: t.start() # Ждём завершения for t in threads: t.join() # Итоговый объединённый результат print("\nИТОГОВЫЙ РЕЗУЛЬТАТ:") for url, data in results.items(): print(f"{url} → {data}") |
Что выполняется в коде:
t.start()— Создаёт реальный системный поток. Вызывает crawl(…) в новом потоке. Нельзя вызывать start() дважды для одного и того же объекта.t.join()— Блокирует главный поток и ждёт, пока поток t завершится. Гарантирует, что все данные собраны.threading.current_thread().name— Позволяет узнать, какой поток сейчас выполняется. Используется для логирования и отладки
| Команда | Где используется | Назначение |
|---|---|---|
Thread(...) |
создание потоков | описание задачи |
start() |
запуск | старт выполнения |
join() |
ожидание | синхронизация |
current_thread() |
внутри crawl |
диагностика |
Lock() |
защита results |
потокобезопасность |
Результат выполнения скрипта:
|
1 2 3 4 5 6 7 8 9 10 11 |
Поток Thread-1 запущен. Время вызова: 2025-12-27 20:45:11.807784 Поток Thread-2 запущен. Время вызова: 2025-12-27 20:45:11.807957 Поток Thread-3 запущен. Время вызова: 2025-12-27 20:45:11.808193 Поток Thread-3 завершен. Запрос длился 1 секунд. Время завершения: 2025-12-27 20:45:12.808385 Поток Thread-1 завершен. Запрос длился 4 секунд. Время завершения: 2025-12-27 20:45:15.808069 Поток Thread-2 завершен. Запрос длился 10 секунд. Время завершения: 2025-12-27 20:45:21.808165 ИТОГОВЫЙ РЕЗУЛЬТАТ: https://peps.python.org → {'url': 'https://peps.python.org', 'status': 200, 'data': {'title': 'Данные с https://peps.python.org', 'value': 15}, 'fetched_at': '2025-12-27T20:45:12.808319', 'thread': 'Thread-3'} https://python.org → {'url': 'https://python.org', 'status': 200, 'data': {'title': 'Данные с https://python.org', 'value': 84}, 'fetched_at': '2025-12-27T20:45:15.808019', 'thread': 'Thread-1'} https://docs.python.org → {'url': 'https://docs.python.org', 'status': 200, 'data': {'title': 'Данные с https://docs.python.org', 'value': 17}, 'fetched_at': '2025-12-27T20:45:21.808113', 'thread': 'Thread-2'} |
Основы threading
Создание потока через конструктор threading.Thread
|
1 2 3 4 5 6 7 |
threading.Thread( target=None, args=(), kwargs={}, name=None, daemon=None ) |
Основные параметры:
| Параметр | Описание |
|---|---|
target |
Функция, которая будет выполнена в потоке |
args |
Кортеж позиционных аргументов |
kwargs |
Именованные аргументы |
name |
Имя потока |
daemon |
Демон-поток (True/False) |
Запуск и управление потоками t.start()
|
1 |
t.start() |
- Запускает поток
- Внутри вызывает run()
- Нельзя вызвать повторно
|
1 |
t.run() |
- Содержит код потока
- Не запускает новый поток, если вызвать напрямую
- Обычно не вызывается вручную
|
1 |
join(timeout=None) |
- Ждёт завершения потока
timeout— максимальное время ожидания (в секундах)
|
1 |
t.is_alive() |
- Возвращает
True, если поток ещё работает
Примитивы синхронизации: Lock, RLock, Semaphore, Event, Condition
Подробнее: Python. Урок 23. Потоки и процессы в Python. Часть 2. Синхронизация потоков
В Python примитивы синхронизации из модуля threading решают одну ключевую задачу: они позволяют нескольким потокам безопасно и предсказуемо взаимодействовать с общим состоянием. Несмотря на наличие GIL, эти примитивы остаются необходимыми, потому что GIL защищает интерпретатор, но не бизнес-логику и не целостность данных.
Диспетчеры контекста предусмотрены для всех объектов модуля threading, таких как Lock, RLock, Condition, Semaphore и BoundedSemaphore, то есть для работы с этими объектами может применяться инструкция with.
Начнём с Lock и RLock, так как они лежат в основе почти всех сценариев синхронизации.
Lock
Lock — это обычный мьютекс, который может быть захвачен только одним потоком в конкретный момент времени. Когда поток вызывает acquire(), он либо сразу получает доступ к критической секции, либо блокируется до тех пор, пока другой поток не освободит lock. После выполнения защищённого участка кода поток обязан вызвать release(). В реальном коде Lock почти всегда используется через контекстный менеджер with, потому что это гарантирует освобождение блокировки даже при исключении. Lock подходит для защиты простых структур данных, таких как словари, списки или счётчики, и для коротких критических секций. Важно понимать, что один и тот же поток не может захватить Lock повторно: попытка сделать это приведёт к deadlock, когда поток будет ждать самого себя.
Проблема (без Lock)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import threading counter = 0 def increment(): global counter for _ in range(100_000): counter += 1 threads = [threading.Thread(target=increment) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() print(counter) # ❗ НЕ гарантировано 200000 |
Решение с Lock
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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(2)] for t in threads: t.start() for t in threads: t.join() print(counter) # ✅ всегда 200000 |
Что гарантирует Lock
- Только один поток изменяет общее состояние
- Нет гонок данных
RLock
Эта проблема решается с помощью RLock, или reentrant lock. По сути это мьютекс с учётом владельца. Поток, который уже владеет RLock, может захватить его ещё раз, и Python просто увеличит внутренний счётчик захватов. Освобождать такой lock нужно столько же раз, сколько он был захвачен. RLock необходим в более сложных архитектурах, когда функции с защитой lock вызывают друг друга, либо когда публичный метод и внутренний метод используют одну и ту же блокировку. Без RLock такой код почти неизбежно приводит к взаимной блокировке.
Проблема с Lock
|
1 2 3 4 5 6 7 8 9 |
lock = threading.Lock() def outer(): with lock: inner() def inner(): with lock: # ❌ deadlock: поток уже держит lock print("inner") |
Решение с RLock
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import threading lock = threading.RLock() def outer(): with lock: print("outer") inner() def inner(): with lock: print("inner") threading.Thread(target=outer).start() |
Что даёт RLock
- Один поток может захватывать блокировку несколько раз
- Важно для рекурсии и вложенных вызовов
Semaphore
Следующий важный примитив — Semaphore. В отличие от Lock, который допускает ровно одного владельца, semaphore разрешает одновременно находиться в критической секции ограниченному числу потоков. При создании семафора задаётся счётчик, который уменьшается при acquire() и увеличивается при release(). Пока счётчик положительный, потоки могут входить без ожидания, а когда он становится равным нулю, все последующие вызовы acquire() блокируются. Семантически семафор описывает не владение ресурсом, а количество доступных слотов. Это делает его удобным для ограничения параллельного доступа к внешним системам, таким как база данных, пул соединений или сторонний API. В отличие от Lock, семафор не привязан к конкретному потоку, поэтому важно строго соблюдать баланс acquire() и release(), иначе система либо «утечёт» в блокировку, либо начнёт пускать больше потоков, чем предполагалось.
Пример: максимум 2 потока одновременно
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import threading import time semaphore = threading.Semaphore(2) def worker(name): print(f"{name} ждёт доступ") with semaphore: print(f"{name} вошёл") time.sleep(2) print(f"{name} вышел") threads = [ threading.Thread(target=worker, args=(f"Thread-{i}",)) for i in range(5) ] for t in threads: t.start() |
Что гарантирует Semaphore
- Не более N потоков внутри секции
- Остальные ждут освобождения ресурса
BoundedSemaphore
BoundedSemaphore — это вариант семафора из модуля threading, который предназначен для строгого контроля количества одновременных доступов к ресурсу и дополнительно защищает от логических ошибок в коде.
По своей сути BoundedSemaphore работает так же, как обычный Semaphore: он хранит внутренний счётчик, и поток может войти в критическую секцию, только если счётчик больше нуля. При входе счётчик уменьшается, при выходе увеличивается. Это позволяет ограничить количество потоков, которые одновременно используют общий ресурс.
Ключевое отличие BoundedSemaphore от Semaphore заключается в том, что он не позволяет превысить начальное значение счётчика. Если вызвать release() больше раз, чем было успешных acquire(), BoundedSemaphore выбросит исключение ValueError. Обычный Semaphore такого не делает и молча увеличивает счётчик, что может привести к незаметным ошибкам и нарушению инвариантов программы.
Таким образом, BoundedSemaphore полезен в ситуациях, где важно гарантировать, что количество «освобождений» ресурса строго соответствует количеству его захватов, например при реализации пулов соединений или управлении ограниченными системными ресурсами. Он помогает выявлять ошибки проектирования на раннем этапе, вместо того чтобы позволять программе продолжать работу в некорректном состоянии.
Пример использования BoundedSemaphore для ограничения числа одновременных работников:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import threading import time pool = threading.BoundedSemaphore(2) def worker(name): print(f"{name} пытается войти") pool.acquire() try: print(f"{name} работает") time.sleep(1) finally: pool.release() print(f"{name} вышел") threads = [ threading.Thread(target=worker, args=(f"Thread-{i}",)) for i in range(4) ] for t in threads: t.start() for t in threads: t.join() |
Если в этом примере по ошибке вызвать pool.release() дважды в одном потоке, программа сразу упадёт с ValueError, что явно укажет на ошибку управления ресурсом. Именно это поведение и является главным практическим отличием BoundedSemaphore от обычного Semaphore.
Event
Event решает другую задачу и не предназначен для защиты критических секций. Это потокобезопасный флаг, который может быть установлен или сброшен, и который другие потоки могут проверять или ожидать. Внутренне Event хранит состояние «установлен» или «не установлен». Когда поток вызывает wait(), он блокируется до тех пор, пока другой поток не вызовет set(). Если событие уже установлено, wait() возвращается сразу. В отличие от lock-ов, событие не «потребляется» при ожидании, и все потоки, ожидающие одного и того же события, будут разбужены одновременно. На практике Event чаще всего используется для управления жизненным циклом потоков, например для корректной остановки воркеров или для сигнализации о готовности системы к работе. Это более выразительная и безопасная альтернатива общим флагам и бесконечным циклам с sleep.
Пример: ожидание старта
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import threading import time event = threading.Event() def worker(): print("Рабочий поток ждёт сигнал...") event.wait() # ⏳ блокируется print("Рабочий поток получил сигнал!") def starter(): time.sleep(3) print("Сигнал отправлен!") event.set() # 🚦 разблокирует всех threading.Thread(target=worker).start() threading.Thread(target=starter).start() |
Event идеально подходит для
- старт / стоп сигналов
- graceful shutdown — (плавное или корректное завершение работы) — это процесс остановки компьютерной системы (приложения, сервера, контейнера), при котором она успевает выполнить необходимые задачи по очистке и сохранению данных перед полным выключением.
- ожидания готовности ресурса
Condition
Condition является самым сложным и одновременно самым гибким примитивом синхронизации. Он объединяет в себе мьютекс и механизм ожидания уведомлений. Идея Condition заключается в том, что поток может ждать не просто сигнала, а выполнения определённого логического условия, связанного с состоянием программы. Поток захватывает условие, проверяет состояние, и если оно не удовлетворяет требованиям, вызывает wait(). При этом lock временно освобождается, чтобы другие потоки могли изменить состояние. Когда другой поток вызывает notify() или notify_all(), ожидающие потоки пробуждаются и снова проверяют условие. Именно повторная проверка условия является ключевым моментом, так как пробуждение не гарантирует, что состояние действительно изменилось нужным образом. Condition активно используется в классических паттернах producer–consumer, очередях задач и системах, где потоки должны реагировать на изменение общего состояния, а не просто на факт события.
Если смотреть на эти примитивы как на систему, то Lock и RLock отвечают за эксклюзивный доступ, Semaphore ограничивает параллелизм, Event передаёт сигналы между потоками, а Condition позволяет потокам координироваться на основе сложных условий. В продакшене выбор примитива почти всегда диктуется смыслом задачи, а не техническими деталями. Хорошая синхронизация делает код не только корректным, но и читаемым, потому что по выбранному примитиву сразу понятно, как именно потоки должны взаимодействовать друг с другом.
Producer / Consumer с Condition
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import threading import time import random condition = threading.Condition() queue = [] MAX_ITEMS = 5 def producer(): for i in range(10): time.sleep(random.uniform(0.1, 0.5)) with condition: while len(queue) >= MAX_ITEMS: condition.wait() # ⏳ ждём, пока потребитель заберёт queue.append(i) print(f"Producer добавил {i}") condition.notify() # 🔔 сигнал потребителю def consumer(): for _ in range(10): with condition: while not queue: condition.wait() # ⏳ ждём данные item = queue.pop(0) print(f"Consumer забрал {item}") condition.notify() # 🔔 сигнал производителю time.sleep(random.uniform(0.2, 0.6)) threading.Thread(target=producer).start() threading.Thread(target=consumer).start() |
Condition = Lock + Event
wait()→ отпускает lock и ждётnotify()→ будит ожидающий поток- Позволяет ждать логических условий, а не просто блокировки
Barrier
threading.Barrier — это примитив синхронизации в Python, который позволяет группе потоков одновременно ожидать друг друга в определенной точке выполнения (контрольной точке) перед тем, как продолжить работу.
todo
Итоговая таблица
| Примитив | Для чего |
|---|---|
Lock |
Простая защита общего состояния |
RLock |
Вложенные / рекурсивные блокировки |
Semaphore |
Ограничение количества потоков |
Event |
Сигналы между потоками |
Condition |
Сложная координация и ожидание условий |
concurrent.futures
todo
multiprocessing
Теория
Разница между:
fork / spawn / forkserver
IPC (межпроцессное взаимодействие):
Queue
Pipe
Manager
Стоимость сериализации (pickle)
Copy-on-write (Linux)
Практика
Параллельная обработка данных
Использование multiprocessing.Pool
Бенчмарк:
threading vs multiprocessing
Поймать баг с pickling’ом
📌 Продакшн insight: multiprocessing часто убивает latency, если использовать бездумно.
asyncio
Ключевая тема для highload backend.
Теория
Event loop
Coroutine
Awaitable
Task vs Future
Cooperative multitasking
Почему async ≠ threading
Практика
Переписать синхронный код в async
Одновременные HTTP-запросы (aiohttp)
Ограничение параллелизма (Semaphore)
Ошибки:
blocking call внутри async
забытый await
Критически важно: понимание, почему один blocking вызов убивает весь сервис.





















Leave a Reply