Однопоточные приложения на Python ограничены в производительности: они выполняют задачи последовательно и не используют преимущества многоядерных процессоров. Кроме того, такие программы не справляются с обработкой множества операций одновременно, особенно если речь идет о задачах, связанных с вводом-выводом, например сетевыми запросами или чтением файлов.
Производительность можно значительно улучшить, внедрив в код параллельные вычисления, конкурентность или асинхронное программирование. Для этого Python предлагает такие инструменты, как multiprocessing, threading и asyncio.
Multiprocessing, threading и asyncio: в чем разница?
Для начала вспомним, что такое потоки и процессы.
Процессы — это отдельные программы, каждая со своей памятью, которые могут работать параллельно на разных ядрах процессора.
Потоки — это части одного процесса, которые одновременно выполняют задачи и используют для этого общую память.
Модуль threading запускает потоки для конкурентной работы внутри одного процесса.
Библиотека asyncio управляет задачами асинхронно, без дополнительных потоков или процессов.
Модуль multiprocessing создает процессы для параллельного выполнения на нескольких ядрах.
Разберем, как эти инструменты работают и где используются.
Многопоточность и конкурентное выполнение задач
Модуль threading позволяет запускать несколько потоков внутри одного процесса для конкурентного выполнения задач.
Конкурентность — это способность программы управлять несколькими задачами так, чтобы они выполнялись одновременно с точки зрения пользователя, даже если физически они чередуются. Например, пока одна задача ждет ответа от сервера, другая может обработать данные.
Потоки в threading работают схожим образом — они переключаются между задачами и разделяют общую память и ресурсы процесса.
Работа с threading
Основной инструмент модуля — класс Thread, который позволяет запускать функции в отдельных потоках.
Базовый синтаксис:
import threading
def task(name):
print(f"Задача {name} выполняется в потоке {threading.current_thread().name}")
t = threading.Thread(target=task, args=("A",))
t.start()
t.join()
Здесь метод start() запускает поток, а join() заставляет основной код ждать его завершения.
GIL и его влияние на многопоточность
В стандартной реализации Python (CPython) есть глобальная блокировка интерпретатора (GIL), которая ограничивает многопоточность. GIL позволяет только одному потоку выполняться в интерпретаторе в любой момент времени, даже на многоядерных процессорах.
Это означает, что потоки в threading не могут параллельно использовать несколько ядер для вычислений. Поэтому они неэффективны для задач CPU-bound, где скорость зависит от процессора (например, сложных математических расчетов или обработки больших массивов данных).
А вот в задачах I/O-bound, где программа много времени ждет внешних операций, таких как сетевые запросы или чтение файлов, GIL не мешает. Потоки переключаются между собой, пока одна операция ждет завершения.
Синхронизация потоков: Lock, Semaphore, Event
Поскольку потоки делят общую память, могут возникать конфликты, например если два потока одновременно изменяют одну переменную. Для их предотвращения используются инструменты синхронизации:
Lock блокирует доступ к ресурсу, разрешая только одному потоку работать с ним в данный момент. Это предотвращает «гонку данных», когда результат зависит от порядка выполнения потоков.
Пример:
import threading
import time
shared_resource = 0
lock = threading.Lock()
def increment():
global shared_resource
thread_name = threading.current_thread().name
print(f"Поток {thread_name} пытается увеличить значение")
with lock:
current_value = shared_resource
# Имитация небольшой задержки
time.sleep(0.1)
shared_resource = current_value + 1
print(f"Поток {thread_name} увеличил значение до {shared_resource}")
threads = [threading.Thread(target=increment) for in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Итоговое значение: {sharedresource}")
Здесь Lock гарантирует, что shared_resource увеличивается ровно 10 раз, а не теряет значения из-за одновременных изменений.
Вывод покажет, как разные потоки по очереди получают доступ к ресурсу, ожидая, пока предыдущий освободит блокировку. Без Lock результат мог бы быть меньше 10 из-за наложения операций.
Semaphore ограничивает количество потоков, которые могут одновременно обращаться к ресурсу. Это полезно, когда доступ нужно дать не одному, а нескольким потокам, но не всем сразу.
Пример:
import threading
import time
# Ставим ограничение на максимум 2 одновременных потока
semaphore = threading.Semaphore(2)
def task(name):
with semaphore:
print(f"Поток {name} начал работу")
time.sleep(1)
print(f"Поток {name} завершил работу")
threads = [threading.Thread(target=task, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
В этом случае только два потока работают одновременно, остальные ждут, пока освободится «место». Вывод покажет, что задачи выполняются партиями по две.
Event позволяет одному потоку ждать сигнала от другого, чтобы продолжить работу. Это удобно для координации действий между потоками.
Пример:
import threading
import time
event = threading.Event()
def waiter():
print("Ожидаю сигнала...")
event.wait()
print("Сигнал получен, продолжаю работу")
def signaler():
time.sleep(2)
print("Отправляю сигнал")
event.set()
t1 = threading.Thread(target=waiter)
t2 = threading.Thread(target=signaler)
t1.start()
t2.start()
t1.join()
t2.join()
Здесь поток waiter ждет, пока signaler не вызовет event.set(), чтобы продолжить выполнение.
Примеры многопоточного программирования
Модуль threading особенно полезен в ситуациях, где задачи тратят много времени на ожидание внешних ресурсов (задачи типа I/O-bound). То есть скорость выполнения задач ограничена не вычислениями процессора, а операциями ввода-вывода (input/output), которые часто связаны с длительными задержками.
Потоки позволяют переключаться между ними: пока одна задача ждет завершения операции, другая может начать работу.
Рассмотрим два примера: загрузку веб-страниц и фоновую обработку задач.
Конкурентная загрузка URL
Загрузка данных из интернета — классический пример задачи ввода-вывода, где последовательное выполнение может быть неэффективным.
import threading
import urllib.request
urls = ["https://python.org", "https://example.com"]
def fetch_url(url):
response = urllib.request.urlopen(url)
print(f"Загружен {url}, длина: {len(response.read())}")
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
Этот код создает отдельный поток для каждого URL, запускает их с помощью start() и ждет завершения через join(). В результате страницы загружаются конкурентно, что быстрее, чем последовательное выполнение, особенно если сетевые задержки значительны.
Обработка задач в фоновом режиме
Потоки можно использовать для выполнения задач, таких как логирование или мониторинг, пока основная программа продолжает работу.
Пример с фоновым логированием
import threading
import time
def background_logger(logs):
while True:
if logs:
print(f"Лог: {logs.pop(0)}")
time.sleep(1)
def main_task(logs):
for i in range(5):
logs.append(f"Событие {i} в основном потоке")
print(f"Основной поток работает: шаг {i}")
time.sleep(0.5)
logs = []
logger_thread = threading.Thread(target=background_logger, args=(logs,), daemon=True)
logger_thread.start()
main_task(logs)
В этом примере поток background_logger работает в фоновом режиме, периодически выводя сообщения из списка logs, пока основной поток выполняет свою задачу. Атрибут daemon=True означает, что фоновый поток завершится вместе с основной программой.
Асинхронное программирование с asyncio
Асинхронное программирование позволяет выполнять задачи без блокировки основного потока, что особенно полезно для I/O-bound-задач. В отличие от threading, где для конкурентности используются потоки, asyncio управляет задачами в одном потоке через цикл событий.
Это снижает накладные расходы на создание потоков и подходит для приложений с большим количеством операций ввода-вывода, например веб-скрейперов или серверов.
В чем отличие синхронного кода от асинхронного?
Асинхронный код отличается от синхронного тем, что он не блокирует выполнение программы во время ожидания операций, а переключается между задачами в одном потоке через цикл событий.
Синхронный код: последовательное выполнение с блокировкой, сравнение с очередью.
Асинхронный код: запуск задач без ожидания, переключение через цикл событий.
Основы asyncio: async и await
В основе библиотеки asyncio сопрограммы (корутины) с ключевыми словами async и await, введенные в Python 3.5. Они позволяют писать асинхронный код, похожий на синхронный по читаемости.
async def определяет корутину, которая может приостанавливаться, не блокируя выполнение программы.
await указывает, где корутина должна ждать завершения другой асинхронной операции, освобождая управление для других задач.
Пример:
import asyncio
async def say_hello():
print("Начинаем...")
# Ждем 1 секунду, не блокируя
await asyncio.sleep(1)
print("Привет!")
async def main():
# Запускаем корутину
await say_hello()
# Запускаем программу
asyncio.run(main())
Здесь asyncio.sleep(1) имитирует асинхронную операцию (например, запрос к серверу), а await позволяет программе продолжать работу, пока ожидание не завершится. Функция asyncio.run() — стандартный способ запускать асинхронный код.
Event Loop и управление задачами
Асинхронность в asyncio работает благодаря циклу событий (Event Loop) — механизму, который управляет выполнением корутин, переключаясь между ними, когда они ждут операций.
Пока одна корутина ждет ответа от сети, другая может обработать свои данные.
Для планирования нескольких задач используется asyncio.create_task():
async def task(name):
print(f"Задача {name} началась")
await asyncio.sleep(1)
print(f"Задача {name} завершилась")
async def main():
task1 = asyncio.create_task(task("A"))
task2 = asyncio.create_task(task("B"))
await task1 # Ждем первую задачу
await task2 # Ждем вторую
asyncio.run(main())
В этом примере две задачи запускаются конкурентно, и цикл событий переключается между ними. Метод create_task() превращает корутину в задачу, которую asyncio выполняет независимо.
Асинхронные HTTP-запросы
Asyncio работает с другими библиотеками, например aiohttp для быстрой, конкурентной загрузки страниц.
В отличие от синхронной загрузки страниц с помощью urllib.request, где каждая страница загружается последовательно, aiohttp отправляет запросы к нескольким URL одновременно, не блокируя основной поток.
Пример:
import asyncio
import aiohttp
async def fetch_url(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
content = await response.text()
print(f"Загружен {url}, длина: {len(content)}")
async def main():
urls = ["https://python.org", "https://example.com"]
tasks = [asyncio.create_task(fetch_url(url)) for url in urls]
await asyncio.wait(tasks)
asyncio.run(main())
В итоге если одна страница отвечает медленно, то другая ее не ждет. Это значительно сокращает общее время по сравнению с синхронным кодом, где задержки суммируются.
Работа с сокетами
Asyncio позволяет создавать асинхронные серверы и клиенты для обработки соединений.
Рассмотрим пример, где сервер принимает сообщения от клиентов и отправляет их обратно, преобразовав текст в заглавные буквы: если клиент отправляет «привет», сервер отвечает «ПРИВЕТ».
import asyncio
async def handle_client(reader, writer):
data = await reader.read(100)
writer.write(data.upper())
await writer.drain()
writer.close()
async def main():
server = await asyncio.start_server(handle_client, "127.0.0.1", 8888)
async with server:
await server.serve_forever()
asyncio.run(main())
Этот сервер принимает сообщения и отвечает заглавными буквами, обрабатывая множество клиентов одновременно.
Многопроцессность и параллельные вычисления
Параллельные вычисления — это подход, при котором несколько вычислительных задач выполняются одновременно, распределяясь по доступным ресурсам, таким как ядра процессора. Этот метод позволяет ускорить выполнение программ.
Одна из форм параллельных вычислений — многопроцессность, при которой задачи распределяются между несколькими независимыми процессами. В Python она реализована с помощью модуля multiprocessing.
Работа с задачами CPU-bound
Многопроцессность реализуется с собственным интерпретатором Python в обход GIL. Это позволяет параллельно задействовать все ядра процессора и обеспечивать настоящий параллелизм, а не только конкурентность, как в threading или asyncio.
Поэтому модуль multiprocessing — лучший выбор для задач CPU-bound, в отличие от I/O-bound сценариев, где лучше работают потоки или асинхронность.
Как работает модуль multiprocessing
Модуль multiprocessing создает отдельные процессы, каждый из которых работает со своим собственным интерпретатором Python.
Пример:
from multiprocessing import Process
def square(number):
print(f"Квадрат числа {number}: {number * number}")
if name == "__main__":
processes = []
for i in range(5):
p = Process(target=square, args=(i,))
processes.append(p)
p.start()
for p in processes:
p.join()
В этом примере создается пять процессов, каждый из которых вычисляет квадрат числа. Метод start() запускает процесс, а join() ожидает его завершения.
В multiprocessing есть инструменты:
Process — класс для создания и управления отдельным процессом.
Нужно указать целевую функцию (target) и аргументы (args), после чего процесс запускается с помощью метода start().
Пример:
from multiprocessing import Process
def square(number):
result = number ** 2
print(f"Квадрат числа {number}: {result}")
if name == "__main__":
processes = []
for i in range(5):
p = Process(target=square, args=(i,))
processes.append(p)
p.start()
for p in processes:
p.join()
Здесь создаем пять процессов, каждый из которых вычисляет квадрат числа. Проверка if name == "__main__" обязательна, чтобы избежать рекурсивного импорта при запуске процессов.
Это особенно критично на Windows, где процессы создаются через spawn и импортируют модуль заново, но также важно для переносимости кода на другие платформы, такие как Linux или macOS, где поведение зависит от метода запуска.
Pool — класс для создания пула процессов, который автоматически распределяет задачи между несколькими процессами. Метод map() позволяет применить функцию к списку данных и разделяет работу между процессами в пуле.
Пример:
from multiprocessing import Pool
def cube(number):
return number ** 3
if name == "__main__":
# Определяем пул из 3 процессов
with Pool(3) as pool:
results = pool.map(cube, [1, 2, 3, 4, 5])
print(results)
В этом случае Pool распределяет вычисление кубов чисел между тремя процессами. Каждый процесс берет часть списка [1, 2, 3, 4, 5], выполняет задачу и возвращает результат, который собирается в итоговый список.
Сравнение подходов
Давайте разберем, чем отличаются друг от друга все три подхода: конкурентность, асинхронность и параллелизм.
multiprocessing | threading | asyncio |
Использует процессы, обходит GIL, подходит для CPU-bound-задач (вычисления, обработка данных). Требует больше ресурсов, чем потоки, из-за изоляции процессов. | Работает с потоками в одном процессе, ограничен GIL, эффективен для I/O-bound-задач (сеть, файлы), где ожидание преобладает над вычислениями. Легче и быстрее в создании, чем процессы. | Асинхронность в одном потоке через цикл событий, идеален для I/O-bound-задач с большим количеством операций (например, веб-запросы). Не подходит для CPU-bound-задач, но минимизирует накладные расходы. |
Оптимизация производительности кода
Чтобы оптимизировать код, нужно сперва найти узкие места производительности с помощью профилирования.
Профилирование — это анализ кода для выявления узких мест, то есть участков, где программа тратит больше всего времени или ресурсов.
В Python есть несколько инструментов для этого:
cProfile — встроенный модуль, который измеряет время выполнения каждой функции и количество их вызовов.
Пример:
import cProfile
def slow_function():
return sum(i * i for i in range(1000000))
cProfile.run("slow_function()")
Вывод покажет таблицу с колонками: ncalls (число вызовов), tottime (общее время в функции), cumtime (время с учетом подфункций). Например, если цикл занимает 0,5 секунды, вы увидите, что именно он — узкое место.
time — простой инструмент для замера общего времени выполнения, подойдет для сравнения подходов (например, последовательного и параллельного).
Пример:
import time
from multiprocessing import Pool
def square(n):
return n * n
start = time.time()
with Pool(4) as pool:
pool.map(square, range(1000))
print(f"Время: {time.time() - start:.2f} сек")
Это позволяет быстро проверить, ускоряет ли multiprocessing код (например, 0,1 сек вместо 0,4 сек в однопоточном варианте).
Другие инструменты . Модуль line_profiler показывает время выполнения каждой строки кода (требует установки через pip install line_profiler), а Py-Spy — визуальный профайлер, который строит графики работы программы в реальном времени.
После профилирования используйте результаты для оптимизации:
Определите тип задачи. Если узкое место — вычисления (CPU-bound), переходите на multiprocessing. Для ожидания ввода-вывода (I/O-bound) — asyncio или threading.
Параллелизуйте тяжелые операции. Если cProfile показывает, что функция занимает много времени, разбейте данные и используйте Pool для распределения по ядрам.
Минимизируйте накладные расходы. Не создавайте процессы или потоки для мелких задач — запуск Process дороже, чем выгода от параллелизма на коротких операциях.
Тестируйте изменения. Замеряйте время с time до и после оптимизации, чтобы убедиться, что многопроцессность или асинхронность действительно ускоряют код.
Обучиться с нуля на программиста и растить навыки до техлида или архитектора ПО можно на магистратуре МИФИ и Skillfactory по направлению «Программная инженерия». Оттачивайте навыки на реальных задачах от бизнеса и набирайтесь опыта.