Потоки: threading и concurrent.futures
Примеры этого урока не запускаются в браузере
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
Сделайте класс 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 под блокировкой"
import threading
class SafeCounter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self.value += 1
def get(self):
with self._lock:
return self.value
Реализуйте шаблон «производитель — потребитель» на 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) == []
import queue
def producer(q, items):
for item in items:
q.put(item)
q.put(None)
def consumer(q):
result = []
while True:
item = q.get()
if item is None:
break
result.append(item * item)
return result
Почему counter += 1 из нескольких потоков может потерять увеличения, несмотря на GIL?
Поток A захватил lock1 и ждёт lock2, поток B захватил lock2 и ждёт lock1. Как это называется и как избежать?
Чем удобен concurrent.futures.ThreadPoolExecutor по сравнению с ручным созданием Thread?
Войдите, чтобы отмечать пройденные уроки.