Чтобы приступить к объяснению данной темы первоначально необходимо ответить на вопрос: «Что такое процесс, чем он отличается от программы и зачем нужны потоки?»
Что такое Программа?
Компьютерная программа – это последовательность инструкций, предназначенных для выполнения процессором.
Основной задачей любой операционной системы является выполнение программ. Все остальные функции, такие как управление памятью, ресурсами, взаимодействие между программами и т.д., являются вспомогательными механизмами.
Исходный код программы на языке высокого уровня переводится в последовательность машинных инструкций, которые может выполнить процессор. Этот машинный код записывается в файл в определенном формате в зависимости от операционной системы, в которой он будет выполняться (для Windows это формат PE – Portable Executable) т.е. в исполняемый файл – файл с машинным кодом, который выполняется напрямую физическим процессором (для Windows это файл с расширением .exe).

Note
Расширение файла – это короткий суффикс (обычно 2-4 символа), добавляемый к имени файла после точки (.). Расширение файла предназначено для подсказки операционной системе и пользователю о типе файла и, следовательно, о том, какое приложение может быть использовано для его открытия.
Формат файла – это внутренняя структура файла, определяющая, как данные хранятся и интерпретируются. Он описывает, какие данные хранятся в файле, в каком порядке и как они организованы. Формат файла определяет, как приложение должно читать, интерпретировать и отображать данные, содержащиеся в файле.
В Windows, .exe файлы используют формат Portable Executable (PE). .exe - это просто расширение файла, а PE - это формат, используемый Windows для исполняемых файлов.
Но в таком виде программа – это просто набор машинных инструкций со вспомогательной информацией и данными. Для непосредственного выполнения программы операционной системе требуется совершить ряд действий. Например, выделить необходимые программе ресурсы и загрузить программу в память и только после этого начать ее выполнение. После этого программа становится готовой к выполнению и называется процессом.

Что такое Процесс?
Процесс – это программа в состоянии выполнения. При подготовке программы к запуску операционная система выделяет необходимые ресурсы, загружает код в память, и программа переходит в состояние выполнения, становясь процессом.
Каждому процессу операционная система присваивает уникальный идентификационный номер, называемый PID (Process ID).
По нему можно осуществить поиск в диспетчере задач.

Получить PID текущего процесса можно средствами стандартной библиотеки, через модуль os:
import os print(f"Текущий PID: {os.getpid()}")
В современных операционных системах каждый процесс имеет свое виртуальное адресное пространство, которое изолировано от других процессов. Это означает, что один процесс не может напрямую получить доступ к памяти другого процесса, что обеспечивает безопасность и стабильность системы. Получить данные из другого процесса можно с помощью посредника в виде базы данных или файла.
Такая изоляция достигается с помощью виртуальной памяти и таблиц страниц. Вместо прямого доступа к физической оперативной памяти, каждый процесс работает с собственным виртуальным адресным пространством. Специальные структуры – таблицы страниц – отображают виртуальные адреса в физические, контролируя доступ, защиту и наличие данных в памяти. Это не только повышает безопасность, но и упрощает управление памятью, позволяя ОС эффективно размещать и выгружать данные, а также изолировать процессы друг от друга.
Если говорить более конкретно, в отличие от общего определения, процесс – это выполняющийся экземпляр программы в изолированном виртуальном адресном пространстве, в котором размещаются: машинные инструкции программы, статические и глобальные данные, область динамически выделяемой памяти (куча) и стек, используемый для хранения информации о вызываемых функциях, их аргументах и локальных переменных.

Многозадачность в программных системах
Поскольку в каждый конкретный момент времени процессор может выполнять ограниченное количество задач (в простейшем случае – одну на ядро), чтобы каждая задача получила свою долю времени, операционная система использует механизм планирования. Она выделяет каждой задаче квант времени – небольшой интервал, в течение которого задача может выполняться непрерывно, после чего происходит переключение на другую задачу.
Квант времени – это короткий промежуток времени, в течение которого задача исполняется до тех пор, пока либо не завершится, либо не уступит управление, либо не будет вытеснена планировщиком.
Если задача исчерпывает свой квант времени, но не завершена – её вытесняет следующая в очереди. Также задачи могут досрочно перейти в режим ожидания (например, ожидая ввода/вывода), освобождая процессор другим.
Когда задачи вытесняются автоматически по истечении кванта времени, такая модель называется вытесняющей многозадачностью (preemptive multitasking). В контексте операционных систем это означает, что планировщик ОС сам решает, когда прервать выполнение одного процесса и переключиться на другой, независимо от самого процесса.
Процесс выбора задачи, которая должна быть запущена следующей, называется планированием (scheduling). Модуль, который отвечает за данный выбор, называется планировщиком (scheduler).
Вытесняющая многозадачность – это модель, при которой решение о переключении между задачами принимается автоматически системой, независимо от самих задач. Все современные настольные ОС (например, Windows, macOS, Linux) работают именно по этому принципу.
Кооперативная (невытесняющая) многозадачность – это модель, в которой задача исполняется до тех пор, пока она сама не передаст управление системе. Такая модель применяется не только в ОС (например, в ранних версиях Mac OS). Эта модель часто используется в асинхронном программировании.
Многозадачность – это способность системы запускать несколько задач одновременно, но не обязательно выполнение всех задач в конкретный момент времени.
В отличие от многозадачных систем, однозадачные, напротив, могут запускать на выполнение только одну задачу. Такие системы всё ещё используются, например, в микроконтроллерах и бытовой электронике, где сложное управление задачами не требуется.
Вытесняющая и кооперативная многозадачность – это способ организации переключения между задачами, и он может применяться как на уровне всей ОС, так и внутри отдельной программы.
Вышеописанные термины применимы как к операционным системам, так и к отдельным программам. ОС, по сути, тоже является программой, но внутри неё задачи – это другие программы, или, точнее, процессы. В обычных приложениях задачами могут быть, например, асинхронные функции.

Примеры:
Однозадачные ОС: MS-DOS (система может запускать только одну программу)
ОС с кооперативной многозадачностью: ранние версии Mac OS и Windows
ОС с вытесняющей многозадачностью: Windows NT и последующие, Linux, macOS.
Для управления процессами в многозадачных операционных системах существует специальная структура – Блок управления процессом, или контекст процесса (Process Control Block, PCB), в которой хранится вся необходимая служебная информация: идентификатор и состояние процесса, значения регистров общего назначения, указатели на стек и т.д. Эта информация позволяет ОС приостанавливать и возобновлять выполнение процесса, переключаясь между ними.

Чем больше переключений контекста, тем больше времени тратится на переключения, а не на выполнение полезной работы. То есть чем больше процессов, тем больше надо между ними переключаться, в ущерб выполнению задач.
Несмотря на изоляцию и безопасность, процессы обладают одним существенным недостатком – они достаточно накладные по требуемым ресурсам. Создание нового процесса – это довольно ресурсоемкая операция, поскольку операционной системе нужно выделить адресное пространство и создать структуры данных для управления.
Итого по процессам: надёжно изолированы и безопасны, но дороги в создании и переключении контекста – для тесно связанных подзадач внутри одной программы они избыточны.
Что такое Поток?
Мы определили, что процессы – это изолированные контейнеры для выполнения программ, со своим адресным пространством и ресурсами. Создание, управление и переключение между процессами требуют от операционной системы значительных затрат.
Что делать, если в рамках одной программы нужно выполнять несколько задач так, чтобы одна не блокировала другую или когда нужно выполнять несколько вычислений одновременно (параллельные вычисления), чтобы максимально эффективно использовать вычислительные ресурсы и ускорить выполнение программы?
Например:
Сохранение отзывчивости пользовательского интерфейса (GUI).
Программа выполняет длительную операцию: скачивает большой файл, устанавливает обновление, производит сложные вычисления. Если эта операция блокирует остальную программу, то интерфейс будет неактивен до ее завершения. Пользователь не сможет отменить эту длительную операцию, нажать на кнопки и другим способом взаимодействовать с программой.
Параллельное выполнение независимых вычислений.
Если задача требует интенсивных вычислений (например, обработка изображений, научные расчеты, рендеринг), то ее последовательное выполнение не позволяет задействовать всю доступную вычислительную мощность, которую может предоставить процессор.
Пример программы с зависанием пользовательского интерфейса (threads/ui_freeze.py):
import tkinter as tk import time def long_task(): print("Starting long task...") time.sleep(5) print("Task done.") label.config(text="Задача завершена") def on_click(): label.config(text="Выполняется задача...") long_task() root = tk.Tk() root.title("UI freeze") root.minsize(300, 200) label = tk.Label(root, text="Нажмите кнопку для запуска задачи") label.pack(pady=20) button = tk.Button(root, text="Старт", command=on_click) button.pack(pady=10) root.mainloop()
Можно было бы создавать отдельный процесс для каждой задачи, но это решение содержит ряд недостатков:
Создание и ликвидация процесса требуют значительных системных затрат. При создании необходимо выделение адресного пространства, настройка таблиц страниц, инициализация структур управления (PCB). При ликвидации же требуется освобождение всех занятых процессом ресурсов, включая память и дескрипторы, а также удаление его управляющих структур из системы.
Дескриптор – целое число, которое операционная система предоставляет процессу в качестве указателя или "ручки" (handle) на открытый им ресурс.
Это описание ближе всего к POSIX-системам, где дескрипторы — небольшие целые числа; в Windows аналогичную роль играет HANDLE, который технически является непрозрачным значением, а не индексом.
Обычно каждый новый процесс получает как минимум три стандартных дескриптора:
0 - стандартный ввод (stdin)
1 - стандартный вывод (stdout)
2 - стандартный вывод ошибок (stderr)
Примеры ресурсов, на которые могут указывать дескрипторы:
Файлы на диске
Сетевые сокеты (для TCP/IP соединений, UDP и т.д.)
Каналы (pipes) для межпроцессного взаимодействия
Также особенностью процессов является их изоляция по памяти, что, с одной стороны, обеспечивает безопасность, но с другой – делает взаимодействие между ними сложным и требует специальных механизмов, таких как разделяемая память (shared memory).
Хотя технически возможно заставить несколько процессов отображать свое виртуальное адресное пространство на одну и ту же физическую память – но это скорее обходной путь, чем решение. Такой подход противоречит основной идее изоляции процессов и вносит дополнительные сложности в управление и синхронизацию данных, что не является оптимальным решением для тесно связанных задач внутри одной программы.
Более того, операционная система тратит значительное время на переключение контекста между процессами. Чем больше активных процессов, тем чаще происходят эти переключения.
Если части одной программы, которые должны тесно взаимодействовать и работать с общими данными, реализованы как отдельные процессы, это не только усложнит обмен информацией, но и приведет к увеличению числа переключений контекста, снижая общую производительность системы, особенно при большом количестве таких взаимодействующих процессов.
Стало очевидно, что нужен механизм, позволяющий выполнять несколько задач в рамках одного адресного пространства (процесса), разделяя общие ресурсы (память, открытые файлы) и избегая высоких затрат на создание и переключение, присущих процессам.
Таким механизмом стали потоки выполнения. Каждый поток представляет собой последовательность команд, выполняемую внутри процесса. Поэтому необходимо понимать, что поток существует только внутри процесса и у каждого процесса есть как минимум один поток, называемый главным, в котором начинается выполнение программы. Дополнительные потоки можно создавать при необходимости.
Пример однопоточной программы (processes_and_threads/process_and_thread.py):
import os import threading from time import sleep print(f'Исполняется Python-процесс с PID: {os.getpid()}') total_threads = threading.active_count() thread_name = threading.current_thread().name print(f'В данный момент Python исполняет {total_threads} поток(ов)') print(f'Имя текущего потока {thread_name}') sleep(15)

Пример многопоточной программы (processes_and_threads/process_and_threads.py):
import threading import time def hello_from_thread(): print(f'Поток {threading.current_thread()} TID потока {threading.current_thread().ident}') time.sleep(0.1) t = threading.Thread(target=hello_from_thread) t.start() total_threads = threading.active_count() thread_name = threading.current_thread().name tid = threading.current_thread().ident print(f'В данный момент Python выполняет {total_threads} поток(ов)') print(f'Имя текущего потока {thread_name} TID потока {tid}') t.join()

Без хотя бы одного потока процесс будет просто "пустым контейнером" и не сможет выполнять инструкции. Потоки позволяют разделить выполнение программы на несколько параллельных веток, при этом сохраняя общее адресное пространство и ресурсы процесса.
Поток привязывается к одному процессу, но в одном адресном пространстве может быть множество потоков, что обеспечивает лёгкий доступ к общим данным. Таким образом объектом планирования в операционных системах становятся потоки, а процессы служат лишь контейнерами для их выполнения. Поток можно представить как наименьшую единицу выполнения, которую планировщик операционной системы может назначить на выполнение процессору.
Основные свойства потоков определяют их преимущества перед процессами для решения задач внутри одной программы.
Во-первых, все потоки разделяют общее адресное пространство и ресурсы породившего их процесса, такие как память и открытые файлы, что упрощает обмен данными между ними. (что обеспечивает лёгкий доступ к общим данным)
Во-вторых, несмотря на общие ресурсы, каждый поток имеет свой собственный независимый контекст выполнения, включающий стек, счётчик команд (Program Counter) и набор регистров, что позволяет ему выполняться независимо от других потоков в рамках одного процесса.
В-третьих, потоки создаются значительно быстрее, чем процессы, так как не требуют выделения нового адресного пространства или создания объёмных блоков управления процессом (PCB) – достаточно лишь структур для управления потоками.
Наконец, переключение между потоками внутри одного процесса происходит значительно быстрее и требует меньше ресурсов, чем переключение между разными процессами. Это объясняется тем, что потоки одного процесса разделяют общее адресное пространство, включая таблицы страниц памяти (структуры, отображающие виртуальные адреса на физические и обеспечивающие изоляцию процессов), дескрипторы открытых файлов и другие ресурсы.

При переключении потока внутри одного процесса операционной системе достаточно сохранить и восстановить только минимальный контекст: регистры, указатель стека и счётчик команд. Нет необходимости изменять таблицы страниц, дескрипторы или другие глобальные структуры, что существенно снижает накладные расходы.
Напротив, при переключении между процессами требуется полная смена контекста: ОС должна заменить таблицы страниц, загрузить новый набор регистров, обновить дескрипторы и другие служебные данные. Это делает такое переключение значительно более затратным как по времени, так и по используемым ресурсам.
Следует учитывать, что если поток принадлежит другому процессу, его переключение также требует полной смены контекста – точно так же, как при переключении между процессами. Поэтому преимущество потоков в производительности достигается только при их работе в рамках одного процесса.
Одним из ключевых факторов, обеспечивающих высокую эффективность переключения между потоками, является отсутствие необходимости в смене виртуального адресного пространства. Благодаря этому не происходит сброса кешей процессора и буферов трансляции адресов (TLB), что сохраняет актуальность данных в кеше и дополнительно ускоряет выполнение.
Подробнее: TLB и кеши при переключении
При смене таблиц страниц (переключение процессов) происходит инвалидация TLB (Translation Lookaside Buffer). TLB – это аппаратный кэш, который хранит недавно использованные отображения виртуальных адресов в физические. Его сброс и последующее "прогревание" – одна из существенных составляющих накладных расходов при переключении процессов. При переключении потоков внутри одного процесса TLB остается в значительной степени актуальным.
Таким образом, потоки одного процесса позволяют операционной системе быстро и эффективно переключаться между задачами, минимизируя накладные расходы и повышая общую производительность.
Для управления потоками операционная система использует специальные структуры – TCB (Thread Control Block), аналогичные PCB (Process Control Block) для процессов. В TCB хранится вся информация, необходимая для планирования и исполнения потока:
идентификатор потока (TID)
указатель на стек (у каждого потока он свой)
счётчик команд (Program Counter)
состояние потока (выполняется, готов, ожидает и т.д.)
регистры общего назначения (сохраняемые при переключении)
ссылка на PCB процесса, которому поток принадлежит

TCB по своей структуре во многом напоминает PCB, но значительно легче, поскольку не содержит информации, дублирующей данные процесса (например, таблицы страниц или дескрипторы файлов).
Наглядно разницу между тем, что принадлежит процессу, а что – отдельному потоку, можно представить так:
Атрибуты процесса |
Атрибуты потока |
Адресное пространство |
Указатель стека (Stack Pointer, SP) |
Глобальные переменные |
Стек |
Открытые файлы |
Счётчик команд (Program Counter / Instruction Pointer) |
Дескрипторы файлов |
Регистры общего назначения |
Дочерние процессы |
Состояние |
Учётная информация |
Именно легковесность потоков делает их особенно эффективными для часто переключающихся задач, работающих в одном адресном пространстве. Это преимущество особенно заметно при многопоточном выполнении, когда планировщик ОС может оперативно переключаться между потоками одного процесса без серьёзных накладных расходов.
Потоки представляют собой абстракцию, позволяющую реализовать многозадачность внутри одного процесса и организовать независимое выполнение нескольких участков кода. Это важно для повышения производительности и эффективного использования вычислительных ресурсов.
Потоки как несколько независимых последовательностей инструкций – это абстракция, и полезно понимать, как эта концепция реализуется на практике. Все потоки существуют внутри одного процесса, а значит, разделяют общее адресное пространство и ресурсы, включая код программы.
Несмотря на это потоки могут выполняться независимо друг от друга благодаря раздельному контексту выполнения. У каждого потока есть собственный стек, набор регистров и счётчик команд, что позволяет ему иметь своё уникальное состояние выполнения. Эти различия дают операционной системе возможность планировать и управлять потоками по отдельности, чередуя их выполнение на процессоре.
Пример программы с зависанием интерфейса и с отзывчивым интерфейсом (threads/ui_responsive.py):
import tkinter as tk import threading import time def long_task(): print("Starting long task...") time.sleep(5) print("Task done.") root.after(0, lambda: label.config(text="Задача завершена")) def on_click(): label.config(text="Выполняется задача...") thread = threading.Thread(target=long_task) thread.start() root = tk.Tk() root.title("UI responsive") root.minsize(300, 200) # Установка минимального размера окна label = tk.Label(root, text="Нажмите кнопку для запуска задачи") label.pack(pady=20) button = tk.Button(root, text="Старт", command=on_click) button.pack(pady=10) root.mainloop()
Что такое Ядро?
Ядро процессора – это независимый физический блок центрального процессора, осуществляющий обработку инструкций и выполнение вычислений.

Для упрощения далее будем исходить из того, что одно физическое ядро в каждый конкретный момент времени исполняет инструкции только одного потока. Не затрагиваем такие технологии, как Hyper-Threading от Intel или Simultaneous Multithreading (SMT) от AMD, которые позволяют одному ядру обрабатывать несколько потоков – это усложнит понимание основной идеи.
Note
Если процессор поддерживает одну из этих технологий, то одно физическое ядро может представляться операционной системе как два логических (и более, но в пользовательских компьютерах это не применяется).
Чтобы это работало, в ядре дублируются лишь некоторые элементы – например, регистры и счётчики команд, позволяющие каждому потоку иметь собственное состояние. Основные же ресурсы ядра – исполнительные блоки, кэш, конвейеры – остаются общими.
Современные процессоры состоят из множества функциональных блоков, каждый из которых отвечает за выполнение определённых типов операций: например, целочисленные вычисления, операции с плавающей точкой, работа с SIMD-инструкциями (SSE, AVX и др.). При исполнении кода одного потока не всегда задействуются все ресурсы ядра одновременно – возникают промежутки в конвейере, когда часть блоков простаивает, ожидая данных или завершения предыдущих операций.
При технологии SMT существует аппаратный модуль диспетчеризации команд. Он анализирует, какие блоки в данный момент свободны, и пытается вставить туда инструкции второго потока. Например, если один поток загружает блок, исполняющий AVX-инструкции, а другой поток содержит команды SSE, то они могут выполняться параллельно, используя разные части ядра. Это позволяет заполнять простаивающие участки конвейера и повышать загрузку ядра.

Однако при конкуренции за ресурсы второй поток может получать мало времени, и прирост производительности ограничен – обычно не более 20–30%, и только если оба потока эффективно используют разные блоки ядра. В ситуациях, где один поток полностью загружает ядро, второй поток может практически не получить доступа к ресурсам, и прирост производительности будет минимален. А для старого ПО это может даже ухудшить производительность, так как оно не рассчитано на параллельное выполнение и может сталкиваться с конфликтами при доступе к разделяемым ресурсам.
Поэтому SMT повышает эффективность использования ресурсов, но не заменяет настоящую параллельность, которую дают дополнительные физические ядра, когда каждое ядро может независимо выполнять собственный поток.
Так как повышать производительность за счет увеличения тактовой частоты уже не получается, чтобы преодолеть ограничение одного ядра, были придуманы многоядерные процессоры. То есть на одном кристалле располагается несколько вычислительных ядер. Если в системе присутствует несколько физических ядер, каждое из них способно независимо выполнять инструкции своего потока. На такой системе действительно возможно параллельное выполнение нескольких потоков, по одному на каждое ядро.

Даже если в системе есть только одно физическое ядро, можно наблюдать «одновременную» работу нескольких программ. Это достигается за счет того, что ОС быстро переключает контекст выполнения между задачами (потоками), используя специальные алгоритмы планирования. Это переключение может происходить десятки или сотни раз в секунду, что создаёт эффект параллельной работы даже на одноядерном процессоре. Однако в этом случае они будут выполняться конкурентно.
Конкурентность и Параллелизм
В многозадачных операционных системах может быть запущено несколько задач одновременно. То, будут ли они выполняться поочерёдно (конкурентно) или действительно одновременно (параллельно), зависит от количества ядер процессора. А то, как именно происходит переключение между задачами, определяется моделью многозадачности – вытесняющей или кооперативной.

Конкурентное выполнение – это последовательное выполнение нескольких задач с быстрым переключением между ними.
За счет этого создаётся иллюзия одновременной работы, хотя в каждый момент времени выполняется только одна задача. На одноядерном процессоре или на одном ядре многоядерного процессора возможно только конкурентное выполнение: задачи конкурируют за ресурсы процессора, получая к ним доступ по очереди.
Конкурентность (concurrency) – это свойство системы, позволяющей упорядоченно переключаться между множеством задач, создавая иллюзию одновременного выполнения.
В современных многозадачных операционных системах количество одновременно активных процессов может достигать сотен, а потоков – тысяч. Несмотря на это, система создаёт впечатление их одновременного выполнения. Это достигается благодаря именно конкурентному выполнению – быстрой смене задач на уровне процессора.

То, как реализуется переключение между задачами при конкурентности, зависит от модели многозадачности. В случае вытесняющей многозадачности планировщик операционной системы сам решает, какая задача будет исполняться в следующий момент. В кооперативной многозадачности задача добровольно передаёт управление планировщику, уступая место другой задаче.
Параллельное выполнение – это одновременное выполнение нескольких задач на разных ядрах процессора в один момент времени. В отличие от конкурентности, параллельность – это действительно одновременное исполнение, возможное только на многоядерных системах.

При этом важно понимать, что даже в параллельной системе, если задач больше, чем доступных ядер, оставшиеся задачи будут выполняться конкурентно. Кроме того, на каждом отдельном ядре, если ему назначено несколько задач, также происходит конкурентное выполнение.
Максимум параллельно может исполняться столько потоков, сколько есть ядер в системе. Количество ядер – это теоретический максимум одновременно выполняемых задач. Нельзя по-настоящему распараллелить выполнение на 100 потоков, если в системе нет 100 ядер. Не всегда даже все ядра будут задействованы для выполнения программы, так как в системе присутствуют и другие задачи.
Параллельность – это один из способов реализации конкурентности, чтобы задачи могли выполняться одновременно на нескольких ядрах, их сначала нужно правильно организовать – как независимые, готовые к одновременному исполнению.
В однозадачных операционных системах эти понятия не применимы. Запускается и, следовательно, выполняется только одна задача, вне зависимости от числа ядер. Никакая конкурентность в ней невозможна. Даже на многоядерном процессоре задействовано будет только одно ядро, поскольку ОС не умеет управлять несколькими задачами.
Подводя итог, ниже представлена общая схема: программа на диске превращается в процесс в оперативной памяти, процесс содержит потоки, а планировщик ОС выбирает потоки на исполнение процессором.

Проблемы при работе с несколькими потоками
Работа с многопоточностью сопряжена с рядом типичных проблем, среди которых наиболее известны:
Взаимная блокировка (deadlock) – ситуация, когда два или более потока находятся в состоянии ожидания ресурсов занятых друг другом и ни один из них не может продолжить выполнение.
Состояние гонки (race condition) – ситуация, возникающая, когда несколько потоков одновременно обращаются к общему ресурсу без синхронизации, и результат зависит от порядка их выполнения. Это может привести к непредсказуемым ошибкам, которые сложно выявить, так как они проявляются лишь при определённых условиях.
Пример программы с взаимной блокировкой (sync_primitives/deadlock.py):
import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def thread1(): with lock_a: print("Поток 1 захватил lock_a") time.sleep(1) print("Поток 1 пытается захватить lock_b") with lock_b: print("Поток 1 захватил оба ресурса") def thread2(): with lock_b: print("Поток 2 захватил lock_b") time.sleep(1) print("Поток 2 пытается захватить lock_a") with lock_a: print("Поток 2 захватил оба ресурса") t1 = threading.Thread(target=thread1) t2 = threading.Thread(target=thread2) t1.start() t2.start() t1.join() t2.join()
Классический способ избежать такого взаимоблокирования – всегда захватывать блокировки в одном и том же порядке. Если оба потока берут сначала lock_a, а затем lock_b, ситуация, в которой они ждут друг друга, становится невозможной:
import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def worker(name): # Оба потока захватывают блокировки в одном порядке: сначала lock_a, потом lock_b with lock_a: print(f"{name} захватил lock_a") time.sleep(1) with lock_b: print(f"{name} захватил оба ресурса") t1 = threading.Thread(target=worker, args=("Поток 1",)) t2 = threading.Thread(target=worker, args=("Поток 2",)) t1.start() t2.start() t1.join() t2.join()
Пример программы с состоянием гонки (race_cond_and_deadlock/race_condition_example.py):
import threading # Общий ресурс counter = 0 # Функция для увеличения счетчика на 1 def increment(): global counter for _ in range(1_000_000): # Увеличиваем счетчик много раз counter += 1 counter += int(1) # Создаем два потока, которые будут увеличивать счетчик thread1 = threading.Thread(target=increment) thread2 = threading.Thread(target=increment) # Запускаем потоки thread1.start() thread2.start() # Ждем завершения потоков thread1.join() thread2.join() # Выводим значение счетчика print("Counter value:", counter)
Объект, который гарантированно защищён от таких проблем, называется thread-safe (потокобезопасный); например, в Python есть потокобезопасные очереди. Одной из ключевых задач при работе с потоками является синхронизация – обеспечение безопасного доступа к разделяемым данным. Для этого используются механизмы вроде мьютексов, семафоров, условных переменных и других блокировок.
Thread Safety (Потокобезопасность) – свойство объекта, гарантирующее его корректную работу при одновременном доступе из нескольких потоков. В многопоточном программировании могут возникать проблемы, например, такие как взаимная блокировка (deadlock) и состояние гонки (race condition).
Мьютекс (Mutex). Взаимное исключение (mutual exclusion) – позволяет только одному потоку в конкретный момент времени получить доступ к критической секции кода или ресурсу. Остальные потоки ждут, пока мьютекс освободится.
Семафор (Semaphore). Похож на мьютекс, но позволяет одновременно работать ограниченному числу потоков (не обязательно одному). Например, семафор с числом 3 может пропускать одновременно до трёх потоков.
Условная переменная (Condition Variable). Используется для организации взаимодействия между потоками. Один поток может ждать определённого условия (например, пока очередь не станет непустой), а другой поток может его «разбудить», когда это условие выполнено.
Блокировка (Lock). Общее название для простого механизма блокировки, часто реализуемого как мьютекс. В Python – это, например, threading.Lock.
Примеры программ с примитивами синхронизации (sync_primitives).
Мьютекс (sync_primitives/mutex.py):
import threading lock = threading.Lock() counter = 0 def increment(): global counter for _ in range(10_000): with lock: # Только один поток заходит внутрь одновременно counter += int(1) threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # Без lock результат был бы непредсказуем
Семафор (sync_primitives/semaphore.py):
import threading import time sem = threading.Semaphore(3) # Разрешено максимум 3 потока одновременно def limited_access(thread_id): with sem: print(f"Поток {thread_id} начал работу ") time.sleep(1) print(f"Поток {thread_id} закончил работу ") threads = [threading.Thread(target=limited_access, args=(i,)) for i in range(6)] for t in threads: t.start() for t in threads: t.join()
Условная переменная (sync_primitives/cond_var.py):
import threading import time queue = [] condition = threading.Condition() def producer(): with condition: print("Производитель добавляет элемент") queue.append(1) condition.notify() # Оповещаем потребителя def consumer(): with condition: while not queue: print("Потребитель ждет") condition.wait() # Ждёт, пока не появится элемент print("Потребитель получил элемент") t1 = threading.Thread(target=consumer) t2 = threading.Thread(target=producer) t1.start() time.sleep(1) t2.start() t1.join() t2.join()
Хотя потоки позволяют эффективно использовать вычислительные ресурсы, они также требуют более ответственной работы с разделяемыми данными. Поскольку потоки в пределах одного процесса разделяют память, увеличивается риск ошибок, связанных с конкурентным доступом.
Кроме того, существует практическое ограничение на количество потоков. Несмотря на возможность создать тысячи потоков, из-за ограниченного числа ядер и затрат на переключение контекста большое количество потоков может снижать общую производительность.
Особенности многопоточности в Python
Интерпретатор Python изначально не проектировался для многопоточной работы, так как создавался во времена одноядерных процессоров. Одной из ключевых проблем является непотокобезопасность внутренних механизмов, в частности, управление счётчиком ссылок. Если несколько потоков одновременно создают или удаляют объекты, может возникнуть ошибка.
Для решения этой проблемы в Python существует GIL (Global Interpreter Lock) – глобальная блокировка интерпретатора. GIL гарантирует, что в каждый момент времени только один поток выполняет байт-код Python, даже если в программе несколько потоков. Это обеспечивает стабильность работы интерпретатора, но одновременно ограничивает параллелизм через многопоточность.
Выгода от потоков в таком случае сравнима с многопоточностью на одном ядре. Несмотря на то, что исполняется только один поток, потоки позволяют реализовать конкурентное выполнение. Это удобно, например, для организации фоновых операций, чтобы не блокировать выполнение основного потока.
Тем не менее, в Python существуют потокобезопасные структуры данных, например, очереди из модуля queue, которые можно безопасно использовать из нескольких потоков.
Однако это ограничение касается только интерпретируемого байткода Python. Если вычисления выполняются внутри внешней C-библиотеки, которая не взаимодействует с Python-объектами во время работы, GIL временно освобождается, что позволяет добиться настоящего параллелизма. Таким образом, GIL можно обойти за счёт использования C-расширений.
Библиотеки NumPy, Pandas, SciPy используют эффективные алгоритмы, реализованные на C/C++ или Fortran. Внутри этих операций GIL освобождается, и вычисления действительно выполняются параллельно на нескольких ядрах.
Также можно использовать многопроцессную модель (через multiprocessing), при которой создаются отдельные процессы – каждый со своим интерпретатором и, соответственно, собственным GIL. Это даёт реальный прирост в CPU-bound задачах, но связано с большими накладными расходами на управление процессами.
Таким образом, потоки появились как компромисс между производительностью и сложностью. Они позволяют эффективно использовать ресурсы системы, но требуют более аккуратной и ответственной работы с разделяемыми данными. Без правильной синхронизации легко допустить критические ошибки, поэтому важно использовать соответствующие средства и понимать ограничения конкретного языка программирования, такого как Python.
Может показаться, что наличие GIL полностью защищает от состояния гонки, но это не так. GIL действительно помогает избежать некоторых проблем, связанных с параллельным доступом, однако он не гарантирует защиту от race condition. Даже в Python-программах с GIL могут возникать гонки, если доступ к общим данным не синхронизирован. В языках и средах без GIL, где потоки действительно исполняются параллельно, подобные проблемы проявляются ещё более явно и часто.
Пример программы, когда GIL помогает избежать гонки (race_cond_and_deadlock/race_condition.py):
import time from threading import Thread counter = [0] def inc(): c = counter[0] time.sleep(0.1) counter[0] = c + 1 threads = [Thread(target=inc) for _ in range(100)] for t in threads: t.start() for t in threads: t.join() print(counter)
Сколько окажется в counter, если два потока по миллиону раз увеличат его? Казалось бы, ровно 2 000 000 – но не всё так просто. Вернёмся к тому же примеру со счётчиком, но в этот раз без вызова функции внутри цикла – строка counter += int(1) закомментирована (race_cond_and_deadlock/race_condition_example.py):
import threading # Общий ресурс counter = 0 # Функция для увеличения счетчика на 1 def increment(): global counter for _ in range(1_000_000): # Увеличиваем счетчик много раз counter += 1 # counter += int(1) # Создаем два потока, которые будут увеличивать счетчик thread1 = threading.Thread(target=increment) thread2 = threading.Thread(target=increment) # Запускаем потоки thread1.start() thread2.start() # Ждем завершения потоков thread1.join() thread2.join() # Выводим значение счетчика print("Counter value:", counter)
Если запустить этот код на версии Python ниже 3.11, то значение переменной counter с большой вероятностью окажется меньше 2 000 000, хотя каждый из двух потоков выполняет миллион операций увеличения счётчика. Это происходит из-за состояния гонки – типичной проблемы при работе с потоками, когда несколько потоков одновременно обращаются к одному и тому же ресурсу (в данном случае к переменной counter) без надлежащей синхронизации.

Операция counter += 1 не является атомарной. За этим простым выражением скрываются три байт-код инструкции:
Загрузка текущего значения переменной (LOAD)
Прибавление единицы (INPLACE_ADD)
Сохранение результата обратно в переменную (STORE)
Если один поток прерывается между этими шагами, и другой поток в это же время выполняет ту же операцию, результат может быть потерян: оба потока считают старое значение, прибавляют по единице и сохраняют один и тот же результат, тем самым одна из инкрементаций теряется
Здесь важно понимать, как GIL решает, когда передать управление другому потоку. Исторически (до Python 3.2) интерпретатор проверял необходимость переключения примерно каждые N инструкций байткода. Начиная с Python 3.2 механизм был переписан: переключение привязано не к числу инструкций, а к интервалу времени (по умолчанию 5 мс, настраивается через sys.setswitchinterval()). По истечении интервала интерпретатор просит текущий поток уступить GIL, а сама проверка («надо ли отдать GIL») выполняется между байт-код инструкциями, в специальных точках – eval breaker.
Из-за этого поведение конкретной операции counter += 1 оказывается тонким и зависит от версии интерпретатора и платформы. Сама по себе операция counter += 1 не атомарна и в общем случае подвержена состоянию гонки. Однако на практике на современных версиях CPython эта конкретная гонка часто не воспроизводится: инкремент целиком укладывается в один интервал переключения, и поток успевает выполнить чтение-изменение-запись прежде, чем GIL будет передан другому потоку.
Если же добавить в выражение вызов функции, например counter += int(1), в байткоде появляется дополнительная инструкция вызова. Это даёт интерпретатору больше точек, где он может уступить GIL «в середине» операции, поэтому шанс переключения между чтением и записью возрастает, и гонка проявляется заметно чаще.
Примечание: это упрощённая модель. Формально GIL может быть передан другому потоку между любыми байт-код инструкциями (в точках проверки eval breaker), а не только на вызовах функций; описанное поведение — лишь следствие того, как часто эти проверки срабатывают относительно интервала переключения, и может отличаться между версиями CPython и платформами.
Главный вывод: не стоит полагаться на «случайную» атомарность – она не гарантируется и легко ломается при изменении кода или версии Python.
Таким образом, поведение многопоточного кода в Python может отличаться в зависимости от конкретных инструкций байткода и версии интерпретатора.
Итого по GIL: он упрощает интерпретатор и спасает от части гонок, но не делает операции атомарными и не даёт потокам исполнять байт-код параллельно. Полагаться на «случайную» потокобезопасность нельзя – синхронизируйте доступ к общим данным явно.
Будущее без GIL
Начиная с Python 3.13 появилась экспериментальная сборка интерпретатора без GIL (free-threaded build, PEP 703). В ней потоки могут по-настоящему параллельно исполнять байт-код Python на нескольких ядрах, что снимает главное ограничение многопоточности для CPU-bound задач. Пока это опциональный режим с рядом ограничений: не все C-расширения с ним совместимы, а однопоточный код может работать чуть медленнее. Но в перспективе он способен заметно изменить подход к многопоточности в Python. До его широкого распространения для CPU-bound задач по-прежнему используют процессы или C-расширения.
CPU-Bound и I/O-Bound задачи
CPU-bound – задачи, скорость выполнения которых в первую очередь ограничена мощностью процессора.
Например, это может быть выполнение вычислений, рендеринг 3D-графики, задачи машинного обучения и т.д.
I/O-bound – задачи, скорость выполнения которых в первую очередь ограничена скоростью, с которой они могут выполнять операции ввода вывода.
Например, это могут быть сетевые запросы к веб-серверу, чтение/запись файлов на диск, обращение к базе данных и т.д.
Замеры ниже сделаны на тестовой машине с 2 ядрами (CPython 3.10) и усреднены по нескольким прогонам. CPU-bound примеры считают ряд Лейбница (по 5 млн членов × 3 задачи). I/O-bound замеры выполнены на локальном сервере с искусственной задержкой 0.5 с на запрос. Абсолютные числа зависят от железа – важны соотношения между вариантами. Если будете мерить у себя, используйте
timeitили хотя бы несколько прогонов с усреднением и «прогревом» – единичный запуск часто вводит в заблуждение.
Многопоточность в Python
Пример многопоточной программы в CPU-Bound задаче.
Последовательный вариант (cpu_io_bound_tasks/cpu_bound/cpu_bound_non_threads.py):
import time def compute_pi(n_terms): pi = 0 for k in range(n_terms): pi += (-1)**k / (2 * k + 1) return 4 * pi def worker(n_terms): print(f"Computing π with {n_terms} terms...") result = compute_pi(n_terms) print(f"Approximate π = {result}") start = time.time() # Последовательный запуск for _ in range(3): worker(5_000_000) print(f"Total time (no threads): {time.time() - start:.2f} seconds")
Многопоточный вариант (cpu_io_bound_tasks/cpu_bound/cpu_bound_threads.py):
import threading import time def compute_pi(n_terms): pi = 0 for k in range(n_terms): pi += (-1)**k / (2 * k + 1) return 4 * pi def worker(n_terms): print(f"Computing π with {n_terms} terms...") result = compute_pi(n_terms) print(f"Approximate π = {result}") start = time.time() threads = [] for _ in range(3): t = threading.Thread(target=worker, args=(5_000_000,)) t.start() threads.append(t) for t in threads: t.join() print(f"Total time (threads): {time.time() - start:.2f} seconds")
Результат замера (среднее по 3 прогонам): последовательный вариант – 3.62 с, многопоточный – 3.62 с. Разницы нет – потоки не ускоряют CPU-bound задачу (а иногда и чуть замедляют из-за накладных расходов на переключение). Это и иллюстрирует влияние GIL.
На первый взгляд может показаться, что потоки в Python бесполезны из-за GIL (Global Interpreter Lock) – механизма, который позволяет одновременно выполнять байт-код только одному потоку. Отчасти это верно, потоки будут бесполезны в CPU-bound задачах. Многопоточность действительно не даст прироста производительности: потоки будут поочерёдно конкурировать за GIL, не ускоряя выполнение, а, наоборот, создавая накладные расходы.
Однако для I/O-bound задач потоки могут быть полезны. Во время ожидания ввода-вывода (например, отклика от сервера или завершения записи на диск) интерпретатор ставит на выполнение другой поток. Это позволяет обрабатывать несколько задач. То есть получится многозадачная программа с конкурентным исполнением задач, организованным через вытесняющую многозадачность на уровне интерпретатора (при этом в ОС также будет вытесняющая многозадачность). В Python из-за GIL через потоки можно достичь только конкурентного выполнения.
Пример многопоточной программы в I/O-Bound задаче.
Последовательный вариант (cpu_io_bound_tasks/io_bound/io_bound_non_threads.py):
import requests import time urls = [ "https://example.com/", "https://example.com/", "https://example.com/", "https://example.com/", ] def fetch(url): print(f"Start fetching {url}") response = requests.get(url) print(f"Done fetching {url} with status {response.status_code}") start = time.time() for url in urls: fetch(url) print(f"Total time: {time.time() - start:.2f} seconds")
Многопоточный вариант (cpu_io_bound_tasks/io_bound/io_bound_threads.py):
import threading import requests import time urls = [ "https://example.com/", "https://example.com/", "https://example.com/", "https://example.com/", ] def fetch(url): print(f"Start fetching {url}") response = requests.get(url) print(f"Done fetching {url} with status {response.status_code}") start = time.time() threads = [] for url in urls: thread = threading.Thread(target=fetch, args=(url,)) thread.start() threads.append(thread) for thread in threads: thread.join() print(f"Total time: {time.time() - start:.2f} seconds")
Результат замера (4 запроса по 0.5 с, среднее по 4 прогонам): последовательная версия – 2.01 с (запросы идут один за другим), многопоточная – 0.51 с (запросы ожидают ответа одновременно). Здесь потоки дают явный выигрыш, потому что во время ожидания ввода-вывода GIL освобождается и другой поток может работать.
Многопроцессность
Так как потоки в Python не позволяют ускорить выполнение CPU-bound задач, то можно реализовать параллельное выполнение с помощью процессов. В отличие от потоков, каждый процесс имеет собственный GIL, и поток этого процесса может выполняться независимо на отдельном ядре процессора. Это позволяет по-настоящему распараллеливать вычисления.
Это будет затратнее по ресурсам чем на потоках. Тем не менее, для такого типа задач (рендеринг, ML, математические вычисления) – это единственный выбор (помимо написания C-расширений).
Важно понимать, что бессмысленно создавать больше потоков (или процессов), чем доступных ядер, в случае CPU-bound задач. Это может только ухудшить производительность за счёт переключения контекста.
Пример многопроцессной программы в CPU-Bound задаче (cpu_io_bound_tasks/cpu_bound/cpu_bound_processes.py):
from multiprocessing import Process import time def compute_pi(n_terms): pi = 0 for k in range(n_terms): pi += (-1)**k / (2 * k + 1) return 4 * pi def worker(n_terms): print(f"Computing π with {n_terms} terms...") result = compute_pi(n_terms) print(f"Approximate π = {result}") if __name__ == '__main__': start = time.time() processes = [] for _ in range(3): p = Process(target=worker, args=(5_000_000,)) p.start() processes.append(p) for p in processes: p.join() print(f"Total time (processes): {time.time() - start:.2f} seconds")
Результат замера (те же 3 задачи, что и в CPU-bound примере выше, среднее по 3 прогонам): многопроцессный вариант – 1.95 с против 3.62 с у последовательного, то есть примерно в 1.86 раза быстрее даже на 2 ядрах и несмотря на накладные расходы на создание процессов. Здесь достигается настоящий параллелизм, поскольку у каждого процесса свой GIL (на машинах с большим числом ядер выигрыш будет ещё заметнее).
Обратите внимание на if name == '__main__' в примере: при способе запуска процессов spawn (по умолчанию на Windows и macOS) дочерний процесс заново импортирует модуль, и без этой защиты он начал бы рекурсивно создавать новые процессы.
Удобная обёртка: concurrent.futures
На практике вместо ручного создания Thread и Process чаще используют модуль concurrent.futures – он даёт единый высокоуровневый интерфейс через пулы. Достаточно поменять ThreadPoolExecutor на ProcessPoolExecutor, чтобы переключиться с потоков (для I/O-bound) на процессы (для CPU-bound):
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def compute_pi(n_terms): pi = 0 for k in range(n_terms): pi += (-1) ** k / (2 * k + 1) return 4 * pi if __name__ == '__main__': tasks = [5_000_000] * 3 # Потоки – удобно для I/O-bound задач with ThreadPoolExecutor(max_workers=3) as pool: results_threads = list(pool.map(compute_pi, tasks)) # Процессы – для CPU-bound задач (настоящий параллелизм) with ProcessPoolExecutor(max_workers=3) as pool: results_processes = list(pool.map(compute_pi, tasks)) print(results_processes)
pool.map() сам распределяет задачи по воркерам и собирает результаты по порядку, а контекстный менеджер (with) гарантирует корректное завершение пула.
Асинхронность и корутины в Python
Асинхронность – это способ организации выполнения программ, при котором задачи (например, сетевые запросы или операции с диском) выполняются без блокировки основного потока исполнения. Программа может продолжать выполнять другие действия, пока одна из задач ожидает завершения.
Это обобщенный термин, описывающий способ организации выполнения задач таким образом, что они не блокируют друг друга.
Важно понимать, что асинхронность – это не конкретная технология, а модель поведения программы, которая может быть реализована через разные механизмы: событийные циклы, потоки или процессы.
В Python асинхронность можно реализовать, например с помощью:
Потоков (многопоточность)
Процессов (многопроцессность)
Корутин и библиотеки asyncio (стандартная с 3.4, но есть и другие библиотеки)
Корутины
Корутина (от англ. coroutine, сопрограмма) – это специальная функция, которая может приостанавливать своё выполнение в определённой точке во время ожидания, передавать управление другим корутинам и затем продолжать его с того же места. Она сохраняет своё состояние при остановке и может быть возобновлена позже. Это похоже на работу генераторов, которые используют yield, но в асинхронном контексте используется await.

Это позволяет добиться конкурентного выполнения без запуска задач в отдельных потоках или процессах.
Особенность корутин заключается в том, что всё выполнение происходит в одном потоке, без параллелизма. Это означает, что такие программы эффективны для задач, связанных с ожиданием (I/O-bound), но не дают прироста производительности в задачах, требующих интенсивных вычислений (CPU-bound). В таких случаях необходимо использовать параллелизм – например, через multiprocessing.
Пример программы с асинхронностью через корутины (asynchrony_green_threads/asynchrony.py):
import asyncio import time async def task_one(): print('Task one start') await asyncio.sleep(1) print('Task one finish') async def task_two(): print('Task two start') await asyncio.sleep(2) print('Task two finish') async def task_three(): print('Task three start') await asyncio.sleep(3) print('Task three finish') async def main(): await asyncio.gather(task_one(), task_two(), task_three()) start = time.time() asyncio.run(main()) print(time.time() - start)
Асинхронная функция определяется с помощью ключевого слова async, а приостановка и передача управления происходит явно через await, что и делает многозадачность кооперативной.
Здесь asyncio.sleep(1) – неблокирующая (асинхронная) замена time.sleep, которая приостанавливает выполнение корутины, но не блокирует весь поток.
Event Loop
Асинхронная программа в Python обычно начинается с вызова asyncio.run(main()), где main() – это корутина, служащая точкой входа. Внутри asyncio.run() создаётся цикл событий (event loop) – механизм, который управляет выполнением корутин: он запускает их, приостанавливает, когда они встречают await, и возобновляет позже.

Ранее цикл событий приходилось создавать вручную, но теперь asyncio.run() делает это автоматически.
Преимущества корутин:
Быстрее создаются и переключаются, чем потоки, потому что не требуют обращения к ядру ОС.
Могут использоваться в большем количестве, чем потоки, благодаря меньшим накладным расходам.
Явно указана точка передачи управления (реализация через кооперативную многозадачность)
Поэтому корутины предпочтительнее использовать, когда обрабатывается много IO-bound задач, – это приведёт к более экономному использованию ресурсов, чем через потоки.
Недостатки:
Все корутины работают в одном потоке, в котором запущен цикл событий, поэтому блокирующий вызов (например,
time.sleep()или чтение большого файла безawait) остановит выполнение событийного цикла и, следовательно, всей программы.Неэффективны для CPU-bound задач, где лучше использовать параллелизм (процессы).
Совмещение асинхронности и многопоточности
Если вам нужно использовать библиотеку, написанную в синхронном, блокирующем стиле, которая не имеет асинхронных аналогов, но вы не хотите, чтобы она заблокировала весь асинхронный код в основном потоке, можно вынести вызов этой блокирующей функции в отдельный поток. Таким образом, основной поток (или событийный цикл) продолжит работать, пока отдельный поток будет ждать завершения блокирующей операции. В Python asyncio для этого есть loop.run_in_executor().
Пример программы с запуском блокирующей операции в отдельном потоке, которая не блокирует событийный цикл (asynchrony_green_threads/run_blocking_in_executor.py):
import asyncio import time from concurrent.futures import ThreadPoolExecutor # Синхронная блокирующая функция def blocking_task(): print("[blocking_task] Start (runs in separate thread)") time.sleep(2) print("[blocking_task] Done") return "blocking result" # Асинхронная функция, которая работает параллельно async def async_worker(): for i in range(5): print(f"[async_worker] Tick {i}") await asyncio.sleep(1) # Точка входа async def main(): loop = asyncio.get_running_loop() with ThreadPoolExecutor() as pool: # Запуск блокирующей и асинхронной задачи blocking_future = loop.run_in_executor(pool, blocking_task) async_future = async_worker() # Ожидание выполнения задач result = await asyncio.gather(blocking_future, async_future) print(f"[main] Result: {result[0]}") # Запуск asyncio.run(main())
Современная и более короткая альтернатива (начиная с Python 3.9) – asyncio.to_thread(). Она делает то же самое – выносит блокирующий вызов в отдельный поток, – но без ручного создания пула:
import asyncio import time def blocking_task(): print("[blocking_task] Start (runs in separate thread)") time.sleep(2) print("[blocking_task] Done") return "blocking result" async def async_worker(): for i in range(5): print(f"[async_worker] Tick {i}") await asyncio.sleep(1) async def main(): result = await asyncio.gather( asyncio.to_thread(blocking_task), async_worker(), ) print(f"[main] Result: {result[0]}") asyncio.run(main())
Итого по асинхронности: корутины и вынос блокирующих вызовов через run_in_executor() / to_thread() позволяют не блокировать событийный цикл и эффективно обслуживать массу I/O-bound задач в одном потоке, но не ускоряют вычисления – для CPU-bound по-прежнему нужен параллелизм через процессы.
User-Level Thread и Kernel-Level Threads
Всё, что до этого было сказано про потоки и их создание через threading, относилось к потокам режима ядра (kernel level threads, KLT). Они управляются операционной системой и представляют собой полноценные системные единицы исполнения. В отличие от них, существуют потоки уровня пользователя (user level thread, ULT), создание такого потока, переключение между ними выполняется без участия ядра. Потоки режима пользователя могут быть на один-два порядка быстрее потоков режима ядра. Именно такие потоки и называются green threads (в англоязычной среде также может встретиться термин fibers).

Но существуют и недостатки использования таких потоков. Так как их управление происходит не на уровне ОС, а внутри интерпретатора или специальной библиотеки, то ОС их не видит и с их помощью нельзя добиться параллельного исполнения, только конкурентного.
Green threads (fibers), как и корутины реализуют кооперативную многозадачность, но не через event loop, а через свою систему планирования, встроенную в библиотеку. Примером является библиотека gevent, которая реализует green threads в Python. При этом с точки зрения ОС всё также исполняется в одном потоке.
Таким образом, и корутины, и green threads реализуют конкурентность через кооперативную многозадачность, но достигается она по-разному:
Корутины при переключении сохраняют своё состояние в объектах-корутинах, точнее – во фреймах генераторов: сохраняются локальные переменные, точка остановки, стек вызовов (если вызывались другие корутины).
Green threads, напротив, создают свои собственные стеки (в которых сохраняется весь стек), но не на уровне ОС, а внутри интерпретатора. Эти стеки хранятся в структурах, реализованных на низком уровне, обычно на языке C.
Результат использования корутин и green threads один и тот же – конкурентность в одном потоке, но достигается он разными механизмами кооперативной многозадачности.
Поэтому можно сказать, что и корутины, и green threads создают внутри одного потока операционной системы множество логических потоков выполнения, и переключение между ними происходит без участия ОС.
В момент ожидания ввода-вывода выполнение обычного синхронного кода просто блокируется и ждет завершения операции. В green threads или корутинах – выполнение передается другой задаче, что и делает возможным многозадачность.
Интересным аспектом является то, как green threads заменяют блокирующие системные вызовы на неблокирующие. Это достигается через технику monkey patching – библиотека (например, gevent) подменяет стандартные функции ввода-вывода на свои аналоги, которые используют неблокирующий I/O и ожидание событий от ОС. Благодаря этому, поток не простаивает в ожидании – управление передается другому green thread'у.
Пример программы с monkey_patching (asynchrony_green_threads/monkey_patching.py):
import gevent from gevent import monkey import time # monkey.patch_all() def task(name): print(f"[{name}] Start") time.sleep(2) # Не будет заблокировано благодаря monkey patching print(f"[{name}] End") # Создаём зелёные потоки g1 = gevent.spawn(task, "Task 1") g2 = gevent.spawn(task, "Task 2") # Ожидание завершения gevent.joinall([g1, g2])
Однако у monkey patching есть недостатки: он изменяет поведение стандартных библиотек глобально, что может привести к неожиданным ошибкам, снижению читаемости кода и проблемам совместимости с другими библиотеками или подходами к асинхронности, такими как asyncio.
Пример программы сравнения корутин и green threads.
Корутины (asynchrony_green_threads/coroutine.py):
import asyncio import aiohttp import time async def fetch(session, url, n): async with session.get(url) as response: await response.text() print(f"Coroutine {n} done") async def main(): async with aiohttp.ClientSession() as session: tasks = [fetch(session, "https://example.com", i) for i in range(10)] await asyncio.gather(*tasks) start = time.time() asyncio.run(main()) print(f"Asyncio done in {time.time() - start:.2f} seconds")
Green threads (asynchrony_green_threads/green_thread.py):
import gevent from gevent import monkey import requests import time # Патч стандартных блокирующих модулей (вроде socket, time и т.п.) monkey.patch_all() def fetch(url, n): response = requests.get(url) print(f"Green thread {n} done") start = time.time() tasks = [gevent.spawn(fetch, "https://example.com", i) for i in range(10)] gevent.joinall(tasks) print(f"Gevent done in {time.time() - start:.2f} seconds")
Результат замера (10 запросов по 0.5 с, среднее по 4 прогонам): наивный синхронный код выполнял бы их по очереди и потратил 5.04 с, тогда как asyncio укладывается в 0.51 с, а gevent – в 1.57 с. Обе асинхронные версии радикально быстрее последовательной. То, что asyncio здесь оказался быстрее gevent, – особенность конкретных примеров, а не общее правило: в коде с aiohttp используется единая сессия (ClientSession) с переиспользованием соединений, тогда как в примере с gevent каждый запрос через requests.get открывает новое соединение. Различия между подходами разбираются ниже.
Считается, что green threads (например, через gevent) могут работать быстрее, чем asyncio, потому что не обеспечивают защиту от состояния гонки (race condition) по умолчанию. В gevent нет встроенных механизмов синхронизации – задачи переключаются вручную внутри одного потока, и разработчик сам отвечает за безопасность данных. В отличие от этого, asyncio гарантирует, что между двумя await код выполняется без прерываний, что предотвращает race condition, но добавляет накладные расходы. На практике, однако, выигрыш в скорости не гарантирован и сильно зависит от реализации: в наших замерах выше asyncio оказался быстрее. Поэтому gevent правильнее рассматривать не как «более быстрый», а как менее безопасный по умолчанию вариант, который удобен, когда синхронизация не нужна.
Важно понимать, что все эти технологии позволяют добиться конкурентности в одном потоке, но они не обеспечивают настоящего параллелизма (то есть одновременного исполнения на нескольких ядрах). Однако благодаря подходу к управлению выполнением и сохранением состояния – они подходят для задач с интенсивным вводом-выводом.
Сравнение зелёных потоков и корутин
Преимущества и недостатки зелёных потоков и корутин по сравнению с системными потоками во многом схожи, но между собой эти подходы имеют принципиальные отличия.
Зеленые потоки (green threads)
Позволяют перейти на асинхронное выполнение без серьёзного переписывания существующего кода (благодаря автоматическому патчингу).
После применения патчинга возможно неожиданное поведение и сложности отладки, так как часть стандартных блокирующих вызовов становится асинхронными в обход прямого контроля.
Корутины (coroutines)
Управление переключением происходит явно через await, что обеспечивает более прозрачное и предсказуемое поведение кода.
Используют встроенные механизмы синхронизации (asyncio.Lock, asyncio.Event и т.д.) по умолчанию, что облегчает работу с конкурентным доступом, но иногда является излишним усложнением, если синхронизация не нужна.
Зелёные потоки удобны для быстрой адаптации существующего синхронного кода с минимальными изменениями, но могут скрывать сложности из-за патчинга и отсутствия явного управления переключением.
Корутины обеспечивают более явный, контролируемый и современный способ асинхронного программирования, но требуют более глубокого рефакторинга и внимательного подхода к синхронизации.
Частые ошибки при работе с многозадачностью
Несколько типичных граблей, на которые легко наступить:
Доступ к общим изменяемым данным без блокировки. Самый частый источник трудноуловимых багов: несколько потоков пишут в одну переменную или коллекцию без
Lockлибо потокобезопасной структуры.Потоки для CPU-bound задач. Из-за GIL они не ускорят вычисления, а лишь добавят накладных расходов. Для вычислений нужны процессы или C-расширения.
Слишком много потоков или процессов. Создавать их больше, чем есть ядер (для CPU-bound), бессмысленно – переключения контекста съедят выигрыш.
Блокирующий вызов внутри корутины.
time.sleep(), синхронный сетевой запрос или тяжёлое чтение файла безawaitостанавливают весь событийный цикл. Используйте асинхронные аналоги илиasyncio.to_thread().Забытый
join(). Главный поток может завершиться раньше рабочих, и результат потеряется или программа поведёт себя непредсказуемо.multiprocessingбезif name == '__main__'. При запуске процессов черезspawnэто приводит к рекурсивному созданию процессов.
Итоги: что и когда выбирать
Коротко подытожим, какой инструмент подходит под какой тип задач:
I/O-bound задачи (сетевые запросы, работа с диском, обращения к БД): потоки (
threading) или асинхронность (asyncio/ корутины, green threads). Async экономичнее по ресурсам, когда одновременных задач очень много (тысячи соединений).CPU-bound задачи (вычисления, обработка изображений, ML, рендеринг): процессы (
multiprocessing) или C-расширения (NumPy, Pandas и т.п.), потому что GIL не даёт потокам параллельно исполнять байт-код. В перспективе – free-threaded сборка Python.Смешанные сценарии: блокирующий код внутри async-программы выносите в отдельный поток через
asyncio.to_thread()(илиloop.run_in_executor()), чтобы не блокировать событийный цикл.
И главное правило: при любом доступе нескольких потоков к общим изменяемым данным используйте явную синхронизацию (Lock, Semaphore, потокобезопасные очереди и т.д.) – не полагайтесь на «случайную» атомарность операций.
capjdcoder
Очень хорошая статья, разжевывающая основы в доступном виде. Думаю что поможет многим, кто не знал или не обращал внимания, единственное пожелание - картинок наверное побольше надо бы, ибо современная молодежь может сказать, что "... слишком много букв..." чтобы удержать внимание.
Автор - молодец! :)