Не бойтесь потоков в Python, они не кусаются

от автора

Привет, любитель Python!

Слышал о потоках, но чувствуешь себя немного неуверенно? Не волнуйся! Потоки в Python — это не про силу джедаев. Это хороший инструмент, который, кстати, вполне дружелюбен, если знать основные правила общения с ним. Правда, у потоков в Python есть свои нюансы, и часто можно услышать пугающее слово GIL. Но не спеши пугаться и бежать в сторону async-кода! Потоки в Python отлично работают в задачах ввода-вывода и могут здорово ускорить выполнение твоей программы, если применять их грамотно.

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

Почему потоки? Где и когда?

Будем честны — если твоя задача упирается в вычислительные ресурсы (например, считать Мандельброта, запуская миллионы операций), Python-потоки тебе мало помогут. Тут скорее пригодится multiprocessing, но для задач, где мы ожидаем много ввода-вывода, потоки вполне себе находка. Сетевые запросы, взаимодействие с базами данных, файловые операции — вот где Python-потоки, при правильном подходе, покажут себя во всей красе.

Итак, перед тем как идти к практике, небольшой чеклист для оценки:

  1. Задача связана с вводом-выводом? Потоки — хорошее решение.

  2. Много численных расчётов? Лучше идти к multiprocessing.

  3. Работаем с чужими библиотеками, где потокобезопасность под вопросом? Осторожнее, об этом мы тоже поговорим.

Основы создания потоков

Итак, начнем с простого: как вообще создать поток?

import threading import time  def print_numbers():     for i in range(10):         print(f"Number: {i}")         time.sleep(0.5)  # Создаем поток thread = threading.Thread(target=print_numbers) thread.start()  # Основной поток продолжает выполнять свои задачи print("Поток запущен!")  # Ждем, пока поток завершится thread.join() print("Поток завершен!")

Здесь создали поток thread с помощью threading.Thread, передав ему target, то есть функцию, которая будет выполняться в новом потоке. Дальше — вызвали start(), что дало старт потоку, а join() остановил главный поток и дождался завершения потока.

Вроде всё просто, да? Но вот тут-то и начинаются подводные камни. Главное — потоки в Python имеют доступ к общей памяти, и это тот момент, где легко ошибиться, поймав себе баг или, того хуже, гонку данных.

А теперь реальный сценарий: представим, что несколько потоков пытаются изменить один и тот же объект одновременно.

import threading  counter = 0  def increment():     global counter     for _ in range(100000):         counter += 1  threads = [threading.Thread(target=increment) for _ in range(5)]  for thread in threads:     thread.start()  for thread in threads:     thread.join()  print(f"Counter value: {counter}")

Можно подумать, что counter должен быть равен 500000, но на практике результат будет непредсказуемым. Это так называемся гонка данных. И вот тут наступает время познакомиться с Lock.

Lock

Когда несколько потоков обращаются к одному и тому же ресурсу, надо уметь их «запирать», иначе они начнут писать друг по другу. В Python для этого есть Lock.

import threading  counter = 0 lock = threading.Lock()  def increment():     global counter     for _ in range(100000):         with lock:             counter += 1  threads = [threading.Thread(target=increment) for _ in range(5)]  for thread in threads:     thread.start()  for thread in threads:     thread.join()  print(f"Counter value: {counter}")

Здесь with lock говорит потоку: «Подожди, пока не получишь доступ к lock, и только тогда выполняй операцию». С lock мы защищаем код от гонок данных и делаем его безопасным. Теперь counter точно будет равен 500000.

Queue для многопоточной обработки данных

Часто задачи не просто запускают отдельные потоки, но и передают между ними данные. Python предлагает для этого класс Queue, который как раз-таки потокобезопасен.

import threading import queue import time  def worker(q):     while not q.empty():         task = q.get()         print(f"Processing {task}")         time.sleep(1)         q.task_done()  # Создаем очередь и заполняем задачами q = queue.Queue() for i in range(5):     q.put(f"Task {i}")  # Запускаем несколько потоков-воркеров threads = [threading.Thread(target=worker, args=(q,)) for _ in range(3)]  for thread in threads:     thread.start()  # Ждем, пока все задачи не будут завершены q.join() print("All tasks are processed!")

Здесь запускаем три потока, которые берут задачи из очереди и выполняют их. Обрати внимание на q.join() — он дожидается завершения всех задач в очереди, что упрощает контроль.

GIL

И тут мы возвращаемся к нашему слону в комнате — GIL. Global Interpreter Lock позволяет только одному потоку исполнять Python-код в конкретный момент времени. Этот механизм помогает сделать Python более стабильным, но серьёзно ограничивает производительность потоков.

Как бороться с GIL? Ответ простой — не бороться. Если твоя задача — это ввод-вывод, ты почти не почувствуешь влияние GIL. Но если тебе нужно много считать — лучше задуматься об asyncio или multiprocessing.

Пример скачивания страниц с использованием потоков

Соберем всё, что разобрали, в небольшой, но полезный пример. Представим, что нужно скачать несколько страниц и обработать их.

import threading import queue import requests import time  def download_page(url, q):     try:         response = requests.get(url)         q.put((url, response.text))         print(f"{url} downloaded")     except Exception as e:         print(f"Failed to download {url}: {e}")  urls = ["https://example.com", "https://example.org", "https://example.net"] q = queue.Queue() threads = [threading.Thread(target=download_page, args=(url, q)) for url in urls]  # Запуск потоков for thread in threads:     thread.start()  # Ожидание завершения потоков for thread in threads:     thread.join()  # Обработка результатов while not q.empty():     url, content = q.get()     print(f"{url} has {len(content)} characters")

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


Заключение

Итак, что можно вынести из всего этого? Потоки в Python — штука полезная, но не панацея. Они отлично работают с вводом-выводом, но не подойдут для интенсивных вычислений. Главное, помни про GIL и используй Lock, если работаешь с общими данными.

Также в заключение рекомендую обратить внимание на открытые уроки по темам:


ссылка на оригинал статьи https://habr.com/ru/articles/857094/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *