Параллельная обработка вместо широких потоков

Замеры: распознавание даёт x52 на 4 потоках против x13 на 16, разделение
говорящих x33 против x8. Дальше четырёх потоков синхронизация съедает весь
выигрыш, поэтому ядра занимаются несколькими задачами сразу.

По умолчанию 4 потока на задачу и до 4 задач параллельно. Захват задачи
из очереди стал атомарным - без этого два воркера брали одну и ту же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-15 22:33:54 +05:00
co-authored by Claude Opus 5
parent e3b4e3c73f
commit be837bab0b
6 changed files with 176 additions and 38 deletions
+97
View File
@@ -0,0 +1,97 @@
"""Тесты параллельной обработки.
Замеры показали: обе стадии упираются в 4 потока, а на 16 работают вчетверо
медленнее. Значит ядра нужно занимать не шириной одной задачи, а несколькими
задачами сразу - и тогда очередь обязана быть устойчивой к гонкам.
"""
import threading
import pytest
from app.store import JobStatus, JobStore
@pytest.fixture
def store(tmp_path):
return JobStore(tmp_path / "jobs.db")
class TestClaimIsAtomic:
def test_claim_marks_running(self, store):
job_id = store.create(filename="a.wav", duration_sec=1.0)
assert store.claim_next() == job_id
assert store.get(job_id)["status"] == JobStatus.RUNNING
def test_second_claim_gets_nothing(self, store):
store.create(filename="a.wav", duration_sec=1.0)
store.claim_next()
assert store.claim_next() is None
def test_each_job_claimed_once_under_load(self, store):
"""Главное требование: два воркера не должны взять одну задачу."""
ids = {store.create(filename=f"{i}.wav", duration_sec=1.0) for i in range(50)}
claimed: list[str] = []
lock = threading.Lock()
def worker():
while True:
job_id = store.claim_next()
if job_id is None:
return
with lock:
claimed.append(job_id)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(claimed) == len(set(claimed)) == 50
assert set(claimed) == ids
def test_claims_oldest_first(self, store):
first = store.create(filename="1.wav", duration_sec=1.0)
store.create(filename="2.wav", duration_sec=1.0)
assert store.claim_next() == first
class TestWorkerSettings:
def test_default_threads_is_four_not_all_cores(self, tmp_path):
"""Широкие потоки замедляют обе стадии, поэтому по умолчанию их немного."""
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=0\n', encoding="utf-8")
assert load_settings(config).effective_threads() == 4
def test_explicit_threads_respected(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=6\n', encoding="utf-8")
assert load_settings(config).effective_threads() == 6
def test_workers_derived_from_cores(self, tmp_path, monkeypatch):
from app.config import load_settings
monkeypatch.setattr("os.cpu_count", lambda: 32)
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
# 32 логических ядра при 4 потоках на задачу - но не больше разумного предела
assert 2 <= load_settings(config).effective_workers() <= 4
def test_workers_never_below_one(self, tmp_path, monkeypatch):
from app.config import load_settings
monkeypatch.setattr("os.cpu_count", lambda: 1)
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
assert load_settings(config).effective_workers() == 1
def test_explicit_workers_respected(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nworkers=3\n', encoding="utf-8")
assert load_settings(config).effective_workers() == 3