Привет, любитель Python!
Слышал о потоках, но чувствуешь себя немного неуверенно? Не волнуйся! Потоки в Python — это не про силу джедаев. Это хороший инструмент, который, кстати, вполне дружелюбен, если знать основные правила общения с ним. Правда, у потоков в Python есть свои нюансы, и часто можно услышать пугающее слово GIL. Но не спеши пугаться и бежать в сторону async-кода! Потоки в Python отлично работают в задачах ввода-вывода и могут здорово ускорить выполнение твоей программы, если применять их грамотно.
Эта статья — как раз для тех, кто хочет понять потоки с нуля: разберём, для чего они нужны, когда стоит их использовать, а главное — как не наломать дров.
Почему потоки? Где и когда?
Будем честны — если твоя задача упирается в вычислительные ресурсы (например, считать Мандельброта, запуская миллионы операций), Python-потоки тебе мало помогут. Тут скорее пригодится multiprocessing
, но для задач, где мы ожидаем много ввода-вывода, потоки вполне себе находка. Сетевые запросы, взаимодействие с базами данных, файловые операции — вот где Python-потоки, при правильном подходе, покажут себя во всей красе.
Итак, перед тем как идти к практике, небольшой чеклист для оценки:
Задача связана с вводом-выводом? Потоки — хорошее решение.
Много численных расчётов? Лучше идти к
multiprocessing
.Работаем с чужими библиотеками, где потокобезопасность под вопросом? Осторожнее, об этом мы тоже поговорим.
Основы создания потоков
Итак, начнем с простого: как вообще создать поток?
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
, если работаешь с общими данными.
Также в заключение рекомендую обратить внимание на открытые уроки по темам:
12 ноября: Асинхронность и потоки: в чем разница? Узнать подробнее
26 ноября: Основы визуализации данных в работе аналитика. Узнать подробнее