Как стать автором
Обновить
1038.76
OTUS
Цифровые навыки от ведущих экспертов

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

Уровень сложностиПростой
Время на прочтение4 мин
Количество просмотров1.7K

Привет, любитель 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, если работаешь с общими данными.

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

Теги:
Хабы:
+5
Комментарии5

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS