Потоки: threading и concurrent.futures

⏱ 40 мин · 🧩 заданий: 5

Примеры этого урока не запускаются в браузере

Python в браузере (WebAssembly) работает в одном потоке, поэтому примеры с threading показаны с готовым выводом. Скопируйте их и запустите локально — это полезно, особенно пример с гонкой.

Thread: создать, запустить, дождаться

import threading, time

def download(name, seconds):
    print(f"  начал {name}")
    time.sleep(seconds)                     # «ждём сеть» — GIL отпущен
    print(f"  закончил {name}")

start = time.perf_counter()
threads = [threading.Thread(target=download, args=(f"файл{i}", 1)) for i in range(3)]
for t in threads:
    t.start()                               # запустить
for t in threads:
    t.join()                                # дождаться завершения
print(f"всего {time.perf_counter() - start:.1f} с")
  начал файл0
  начал файл1
  начал файл2
  закончил файл0
  закончил файл2
  закончил файл1
всего 1.0 с

Три «загрузки» по секунде заняли секунду. Порядок завершения не гарантирован — его решает планировщик ОС.

  • start() запускает поток; run() выполнил бы функцию в текущем потоке — частая ошибка.
  • join(timeout=...) ждёт завершения.
  • daemon=True — фоновый поток, который не мешает программе завершиться (и будет убит без очистки).
  • Возвращаемое значение функции потока теряется — результаты собирают через общую структуру, очередь или Future.

Гонка данных

import threading

counter = 0

def work():
    global counter
    for _ in range(100_000):
        counter += 1                       # НЕ атомарно

threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)                             # ожидали 800000
617394          # каждый раз разное число

Почему: counter += 1 — это несколько инструкций байткода. Посмотрите сами:

import dis

def increment():
    global counter
    counter += 1

dis.dis(increment)

Между LOAD_GLOBAL и STORE_GLOBAL поток может быть прерван: два потока прочитают одно и то же значение, оба прибавят 1, и одно увеличение потеряется.

Lock

import threading

counter = 0
lock = threading.Lock()

def work():
    global counter
    for _ in range(100_000):
        with lock:                         # только один поток внутри блока
            counter += 1

threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)                             # 800000 — всегда

with lock: = lock.acquire() + try/finally: lock.release(). Держите блокировку как можно меньше времени: никакого ввода-вывода внутри.

  • Lock — один захват; повторный захват тем же потоком → вечное ожидание.
  • RLock (реентерабельный) — тот же поток может захватить несколько раз (рекурсия, вложенные методы).

Взаимоблокировка

lock_a, lock_b = threading.Lock(), threading.Lock()

def transfer_1():
    with lock_a:
        time.sleep(0.1)
        with lock_b: ...       # ждёт lock_b, который держит transfer_2

def transfer_2():
    with lock_b:
        time.sleep(0.1)
        with lock_a: ...       # ждёт lock_a, который держит transfer_1 → deadlock

Решения: всегда захватывать блокировки в одном порядке (например, по id счёта), lock.acquire(timeout=1), а лучше — проектировать без общих изменяемых данных.

queue.Queue — безопасный обмен

queue.Queue уже потокобезопасна. Это самый надёжный способ передавать данные между потоками:

import threading, queue, time

tasks = queue.Queue()
results = queue.Queue()

def worker(n):
    while True:
        url = tasks.get()                  # блокируется, пока нет задач
        if url is None:                    # маркер завершения
            break
        time.sleep(0.1)                    # «скачиваем»
        results.put((n, url, len(url)))
        tasks.task_done()

workers = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for w in workers: w.start()
for url in ["a.ru", "bb.ru", "ccc.ru", "dddd.ru", "e.ru"]:
    tasks.put(url)
tasks.join()                               # дождаться обработки всех задач
for _ in workers: tasks.put(None)          # остановить рабочих
for w in workers: w.join()
print(sorted(results.queue, key=lambda r: r[1]))

Логику очереди можно проверить и без потоков:

import queue

q = queue.Queue(maxsize=2)
q.put("a"); q.put("b")
print(q.full(), q.qsize())
try:
    q.put("c", timeout=0.1)            # очередь полна → через таймаут исключение
except queue.Full:
    print("queue.Full")
print(q.get(), q.get(), q.empty())
try:
    q.get_nowait()
except queue.Empty:
    print("queue.Empty")
lifo = queue.LifoQueue(); prio = queue.PriorityQueue()
for x in (3, 1, 2):
    lifo.put(x); prio.put(x)
print([lifo.get() for _ in range(3)], [prio.get() for _ in range(3)])

Другие примитивы

Примитив Для чего
Event сигнал «можно начинать» / «пора останавливаться»: event.set(), event.wait()
Semaphore(n) не больше n потоков одновременно (лимит соединений)
Condition ждать выполнения условия с уведомлением notify()
Barrier(n) дождаться, пока n потоков дойдут до точки
threading.local() данные, свои у каждого потока (текущий запрос, соединение с БД)
Timer(sec, f) выполнить функцию через время
stop = threading.Event()

def heartbeat():
    while not stop.is_set():
        print("жив")
        stop.wait(1)            # «спим» секунду, но просыпаемся сразу при stop.set()

t = threading.Thread(target=heartbeat); t.start()
time.sleep(2.5); stop.set(); t.join()

ThreadPoolExecutor

В реальном коде потоки почти не создают вручную — используют пул:

from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request

URLS = ["https://python.org", "https://pypi.org", "https://docs.python.org", "https://bad.invalid"]

def fetch(url):
    with urllib.request.urlopen(url, timeout=5) as resp:
        return url, len(resp.read())

with ThreadPoolExecutor(max_workers=8) as pool:
    # 1. map — результаты в порядке входных данных
    # sizes = list(pool.map(fetch, URLS))

    # 2. submit + as_completed — по мере готовности, с обработкой ошибок
    futures = {pool.submit(fetch, url): url for url in URLS}
    for future in as_completed(futures, timeout=30):
        url = futures[future]
        try:
            _, size = future.result()           # исключение из потока бросится здесь
            print(f"{url}: {size} байт")
        except Exception as e:
            print(f"{url}: ошибка {type(e).__name__}")
https://pypi.org: 23154 байт
https://python.org: 50417 байт
https://bad.invalid: ошибка URLError
https://docs.python.org: 17996 байт

Future — «обещание результата»: result(timeout), exception(), done(), cancel(), add_done_callback(). Тот же интерфейс у ProcessPoolExecutor — поменять потоки на процессы можно одной строкой.

Сколько потоков

Для I/O-bound задач — десятки (по умолчанию min(32, os.cpu_count() + 4)). Больше — не всегда быстрее: упрётесь в лимиты API, соединений с БД или память. Для тысяч одновременных соединений лучше asyncio.

Вопрос на собеседовании

«Как безопасно передавать данные между потоками?» — Через queue.Queue (потокобезопасна, есть блокирующие get/put с таймаутами) или защищать общие данные Lock. Лучше всего минимизировать общее изменяемое состояние: потоки получают задачи из очереди и возвращают результаты через очередь или Future.

Итог

  • Thread(target=f).start() и join(); результаты — через очередь или Future.
  • counter += 1 не атомарен → гонка; защита — Lock / with lock:.
  • Deadlock — блокировки в разном порядке; захватывайте в одном порядке.
  • queue.Queue — лучший способ обмена данными между потоками.
  • ThreadPoolExecutor: submit, map, as_completed, исключения через future.result().

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Сделайте класс SafeCounter потокобезопасным: метод increment() увеличивает value на 1 под защитой threading.Lock (используйте with self._lock:), метод get() возвращает значение. Проверка убедится, что блокировка действительно захвачена во время изменения.

Какие тесты проверяют код (2)
c = SafeCounter(); [c.increment() for _ in range(1000)]; assert c.get() == 1000
c = SafeCounter()
assert hasattr(c, "_lock"), "Создайте блокировку self._lock = threading.Lock()"
seen = []
class SpyLock:
    def __init__(self, lock): self.lock = lock
    def __enter__(self): seen.append("enter"); return self.lock.__enter__()
    def __exit__(self, *a): seen.append("exit"); return self.lock.__exit__(*a)
    def acquire(self, *a, **k): seen.append("enter"); return self.lock.acquire(*a, **k)
    def release(self): seen.append("exit"); return self.lock.release()
c._lock = SpyLock(c._lock)
c.increment()
assert seen[:2] == ["enter", "exit"], "increment() должен изменять value под блокировкой"

Задание 2 Программа на Python

Реализуйте шаблон «производитель — потребитель» на queue.Queue без потоков (логика та же): producer(q, items) кладёт все элементы в очередь, а затем специальный маркер None; consumer(q) забирает элементы до маркера None и возвращает список квадратов полученных чисел.

Какие тесты проверяют код (2)
import queue; q = queue.Queue(); producer(q, [1, 2, 3]); assert consumer(q) == [1, 4, 9]
import queue; q = queue.Queue(); producer(q, []); assert consumer(q) == []
Задание 3 Один вариант ответа

Почему counter += 1 из нескольких потоков может потерять увеличения, несмотря на GIL?

Задание 4 Один вариант ответа

Поток A захватил lock1 и ждёт lock2, поток B захватил lock2 и ждёт lock1. Как это называется и как избежать?

Задание 5 Один вариант ответа

Чем удобен concurrent.futures.ThreadPoolExecutor по сравнению с ручным созданием Thread?

Войдите, чтобы отмечать пройденные уроки.