This release is a pre-release and may not be stable for production use.
django-goroutine
Release candidate.
django-goroutineest en1.0.0rc1: l'API est considérée figée mais n'a pas encore été éprouvée par un usage réel en dehors de ce dépôt. Les retours (issues, cas d'usage, bugs) sont les bienvenus avant de tagger la version1.0.0finale — voir RELEASING.md.
Un orchestrateur structuré de tâches concurrentes pour Django, inspiré du modèle de concurrence de Go — sans prétendre le reproduire à l'identique.
Le problème
Django sait exécuter des vues et des méthodes ORM en async/await, mais
ne donne aucun outil clé en main pour lancer plusieurs opérations
indépendantes en parallèle dans une vue. À la main avec asyncio.gather, on
se heurte vite à trois obstacles : le pool de connexions de l'ORM (historique
sync) qui se comporte mal sous plusieurs threads, le contexte de requête
(utilisateur, langue, session) qui ne se propage pas toujours proprement, et
une dizaine de lignes de tuyauterie pour annuler/nettoyer si une sous-tâche
échoue. django-goroutine fournit group() (au-dessus d'asyncio.TaskGroup)
et cpu_map() (au-dessus d'un ProcessPoolExecutor persistant) pour couvrir
ces trois obstacles, avec des erreurs par tâche renvoyées en
pycatch.Result plutôt que levées.
Ce que "goroutine" ne veut pas dire ici
Une goroutine Go est un thread vert géré par le runtime, capable de migrer
entre threads OS. Python garde une boucle d'événements unique et
coopérative : group() ne réplique pas ce modèle, il en reprend l'esprit —
le point d'appel décide quoi lancer en concurrence, pas la fonction
elle-même — avec trois façons distinctes d'exécuter une tâche selon sa
nature réelle :
| Décorateur | Nature de la tâche | Exécutée sur |
|---|---|---|
@io (ou une coroutine non décorée) |
Attente réseau/disque (async def) |
La boucle d'événements, sans thread ni process dédié |
@db |
Appel ORM sync bloquant | Le pool de threads dédié (GOROUTINE["DB_POOL_SIZE"]) |
@cpu |
Calcul CPU-bound sync | Le pool de process dédié (GOROUTINE["CPU_POOL_SIZE"]) |
Ce choix explicite plutôt qu'une détection automatique est délibéré : une heuristique (timing, introspection) pour deviner la nature d'une fonction serait peu fiable et reproduirait justement les bugs sournois que ce projet cherche à éviter.
Installation
uv add django-goroutine
pip install django-goroutine
Une release candidate n'étant pas une version finale, PyPI ne l'installe
pas par défaut avec pip install django-goroutine — utilisez --pre ou
fixez la version exacte tant que 1.0.0 n'est pas taggé :
uv add "django-goroutine==1.0.0rc1"
pip install "django-goroutine==1.0.0rc1"
# settings.py
INSTALLED_APPS = [
...,
"django_goroutine",
]
ready() démarre le pool de threads (@db) au boot plutôt qu'au premier
appel, pour ne pas payer son coût de création sur la première requête qui
l'utilise. Le pool de process (@cpu) reste volontairement paresseux
(créé au premier appel réel) — voir la section Limitations connues.
Démarrage rapide
from django_goroutine import db, group, io
@db
def fetch_user(user_id: int) -> User:
return User.objects.get(pk=user_id)
@io
async def fetch_avatar(url: str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.content
async def profile_view(request, user_id):
async with group() as g:
user_task = g.go(fetch_user, user_id)
avatar_task = g.go(fetch_avatar, avatar_url)
match user_task.result():
case Ok(user):
...
case Err(err):
...
fetch_user et fetch_avatar tournent en parallèle : le temps de réponse
de la vue tombe au temps de la plus lente des deux, pas à leur somme.
Pour voir tout ça tourner sans rien configurer soi-même, voir
examples/ : un projet Django minimal avec des vues qui
démontrent group(), cpu_map(), le timeout et la backpressure, lançable
en une commande depuis ce dépôt.
Héritage des appels internes
Une fonction appelée depuis fetch_user (une aide interne, un second
appel ORM) s'exécute simplement dans le même appel de pile, sur le même
thread — aucune décoration supplémentaire n'est nécessaire ni utile. Le
décorateur ne sert qu'au moment où Group.go() dispatche la tâche, pas à la
propagation interne des appels.
Erreurs par tâche, pas d'annulation en cascade sur erreur métier
N'importe quelle exception levée par une tâche dispatchée devient un Err
porté par son TaskHandle : elle ne fait jamais planter les tâches sœurs ni
le bloc englobant — group() est un sync.WaitGroup, pas un errgroup à
annulation automatique sur erreur métier. L'annulation reste possible, mais
seulement de façon structurelle : si le bloc async with group() lui-même
est annulé de l'extérieur (timeout du serveur ASGI, déconnexion client), les
tâches encore en cours sont annulées par asyncio.TaskGroup, comme
n'importe quel autre await.
async with group() as g:
a = g.go(fetch_user, user_id) # échoue
b = g.go(fetch_avatar, avatar_url) # continue quand même, n'est pas annulée
a.result() # Err(UserDoesNotExist(...))
b.result() # Ok(b"...")
TaskHandle.result() ne peut être lu qu'une fois le bloc async with group() refermé — l'appeler avant lève RuntimeError.
Timeout par tâche
Un timeout par défaut ne peut pas se poser sur Group.go() lui-même (ses
*args/**kwargs sont déjà réservés au transfert vers la fonction
décorée), donc @io/@db/@cpu s'utilisent nus ou paramétrés :
@db(timeout=2.0)
def fetch_user(user_id: int) -> User:
return User.objects.get(pk=user_id)
Au-delà de timeout secondes, TaskHandle.result() devient
Err(TimeoutError(...)) — sans faire planter les tâches sœurs, comme
n'importe quel autre échec. GOROUTINE["TASK_TIMEOUT"] fixe une valeur par
défaut pour les tâches qui ne précisent pas la leur (None par défaut, donc
pas de timeout du tout tant que rien n'est configuré). Une réserve
importante à connaître : Python ne peut pas interrompre de force un thread
ou un process déjà lancé sur la tâche — passé le timeout, l'appelant cesse
d'attendre et récupère son Err, mais le thread/process continue
d'exécuter la tâche en arrière-plan jusqu'à sa fin naturelle.
Backpressure
Le nombre de tâches @db/@cpu simultanément en file ou en cours est
borné (GOROUTINE["DB_MAX_PENDING"]/CPU_MAX_PENDING, par défaut 4× la
taille du pool correspondant) : au-delà, un nouvel appel attend qu'une
place se libère plutôt que de s'empiler sans limite dans la file interne de
l'executor — c'est ce qui évite qu'un pic de charge fasse exploser la
mémoire ou la latence au lieu d'échouer ou d'attendre proprement. Ça se
combine naturellement avec timeout, qui borne alors l'attente d'une place
libre et l'exécution elle-même.
Auto-récupération du pool @cpu
Si un worker du pool @cpu crashe durement (segfault, os._exit...), le
pool entier devient inutilisable (BrokenProcessPool) tant qu'il n'est pas
recréé. django-goroutine détecte ce cas et réinitialise le pool
automatiquement : l'appel en cours échoue (Err(BrokenProcessPool(...)),
pas de retry automatique — rejouer une fonction qui a peut-être déjà eu des
effets de bord serait pire), mais les appels suivants retrouvent un pool
sain plutôt que de rester cassés indéfiniment.
Paralléliser un calcul CPU-bound (cpu_map)
@cpu sur group().go() ne fait gagner du temps que sur du travail déjà
découpé en unités indépendantes. Une seule fonction CPU-bound dispatchée
seule n'accélère pas — exactement comme une goroutine Go seule n'accélère
pas un calcul monolithique : le gain vient toujours du découpage en unités
indépendantes réparties sur plusieurs cœurs, jamais de l'outil
d'orchestration en lui-même. cpu_map() couvre le cas où ce découpage
existe déjà :
from django_goroutine import cpu_map
def resize_one(image_bytes: bytes) -> bytes:
...
async def batch_resize_view(request, images):
results = await cpu_map(resize_one, images)
...
fn doit être une fonction sync CPU-bound, importable au niveau module
(contrainte de pickle du ProcessPoolExecutor sous-jacent) — jamais une
lambda, une closure, ou une méthode d'instance liée. Un échec sur un
élément, y compris un dépassement de timeout (secondes, optionnel,
appliqué individuellement à chaque élément — cpu_map(fn, items, timeout=5.0)) ou un pool @cpu cassé (auto-réinitialisé), ne fait pas
échouer les autres : chaque résultat est un Result indépendant, dans
l'ordre d'entrée. cpu_map() partage le même sémaphore de backpressure que
les tâches @cpu de group() — les deux se disputent la même ressource.
Le GIL empêche deux threads d'exécuter du bytecode Python en parallèle : si
votre calcul lourd passe déjà par une bibliothèque C qui relâche le GIL
(numpy, Pillow, OpenCV, hashlib...), @db-style thread offload suffirait —
@cpu/cpu_map() n'apportent un vrai gain que pour du code Python pur
CPU-bound, via des process séparés.
Configuration
GOROUTINE = {
"DB_POOL_SIZE": 10, # taille du pool de threads @db
"CPU_POOL_SIZE": os.cpu_count(), # taille du pool de process @cpu
"DB_MAX_PENDING": None, # backpressure @db ; None => DB_POOL_SIZE * 4
"CPU_MAX_PENDING": None, # backpressure @cpu/cpu_map ; None => CPU_POOL_SIZE * 4
"TASK_TIMEOUT": None, # timeout par défaut (secondes) ; None => aucun
}
Journalisation
Toutes les tâches et événements de pool passent par le logger Python
"django_goroutine", à brancher comme n'importe quel autre logger Django :
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django_goroutine": {"handlers": ["console"], "level": "INFO"},
},
}
| Niveau | Émis pour |
|---|---|
DEBUG |
Une tâche @io/@db/@cpu/cpu_map() échoue avec une exception métier (cas normal, géré via Result — pas de bruit par défaut). |
INFO |
Démarrage d'un pool (@db au boot, @cpu au premier appel). |
WARNING |
Une tâche dépasse son timeout. |
ERROR |
Le pool @cpu est cassé (BrokenProcessPool) et se réinitialise. |
Sans configuration LOGGING explicite, Django journalise déjà WARNING et
au-dessus sur la console via son handler racine par défaut — seul DEBUG
demande une configuration explicite pour devenir visible.
Limitations connues
- sqlite et écritures concurrentes. sqlite n'a qu'un verrou d'écriture
global : plusieurs fonctions
@dbqui écrivent en parallèle contre une base sqlite peuvent leverOperationalError("database is locked"). PostgreSQL et MySQL encaissent des écritures concurrentes sans ce verrou — en développement avec sqlite, augmentezOPTIONS.timeoutou évitez les écritures concurrentes sur le même pool. - Le pool
@cpuest paresseux, pas démarré parapps.ready(). Deux raisons, pas une question de goût :ready()s'exécute pour n'importe quel process qui charge l'app Django —migrate,shell, ou mêmemypyvia le plugin django-stubs, qui appelledjango.setup()pour de vrai — pas seulement un serveur applicatif ; spawn des process OS à chaque fois en aurait fait une source de fuites de ressources sur des commandes qui n'utilisent jamais@cpu. Et démarrer unProcessPoolExecutoravant un fork (gunicorn--preload) est une source connue de blocages enmultiprocessing— la création paresseuse élimine ce risque au passage, le pool étant créé dans chaque worker après le fork, pas avant. Le pool utilise en outre le contextespawnplutôt que leforkpar défaut de Linux : forker un process multi-threadé (boucle asyncio + pool@db) peut figer l'enfant si un thread tenait un verrou interne au moment du fork —spawndémarre un interpréteur neuf, plus lent au premier appel mais sans cet héritage. Chaque workerspawnappelledjango.setup()à son démarrage (via l'initializerdu pool) pour rester importable même si son module touche, même indirectement, à des modèles Django. - Pas d'annulation automatique des tâches sœurs sur erreur métier.
group()est volontairement unsync.WaitGroup, pas unerrgroupà annulation sur premier échec — voir la section dédiée ci-dessus. Un modecancel_on_errorpourrait être ajouté dans une version mineure future si le besoin se confirme à l'usage. @cpu/cpu_map()exigent des fonctions picklables au niveau module. Contrainte deProcessPoolExecutor, pas de ce projet — une lambda, une closure ou une méthode liée échouent silencieusement à être picklées.
Développement
uv sync --group dev
uv run ruff check src tests
uv run ruff format --check src tests
uv run mypy
uv run pytest --cov=django_goroutine --cov-report=term-missing
Voir CONTRIBUTING.md pour contribuer, CHANGELOG.md pour l'historique des versions, et RELEASING.md pour le processus de publication.
Licence
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_goroutine-1.0.0rc1.tar.gz.
File metadata
- Download URL: django_goroutine-1.0.0rc1.tar.gz
- Upload date:
- Size: 76.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f8700bc61e19757efc2376f956488eed0c7041f000bbce0131a4b89885a18518
|
|
| MD5 |
fcf2cdf4007a209c86cba729e0e60768
|
|
| BLAKE2b-256 |
ec333e136c806c6d9b6a1c3572694bf8c864ad910db04987317894ada6e5f126
|
Provenance
The following attestation bundles were made for django_goroutine-1.0.0rc1.tar.gz:
Publisher:
publish.yml on alzeph/django-goroutine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_goroutine-1.0.0rc1.tar.gz -
Subject digest:
f8700bc61e19757efc2376f956488eed0c7041f000bbce0131a4b89885a18518 - Sigstore transparency entry: 2481094758
- Sigstore integration time:
-
Permalink:
alzeph/django-goroutine@a2786b383c18a4656fee68db272ded8bb3721963 -
Branch / Tag:
refs/tags/v1.0.0rc1 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a2786b383c18a4656fee68db272ded8bb3721963 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_goroutine-1.0.0rc1-py3-none-any.whl.
File metadata
- Download URL: django_goroutine-1.0.0rc1-py3-none-any.whl
- Upload date:
- Size: 21.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf44acf0683509fee5e60dc154d80dcb9245fb71495e30d648a74546f0aa194c
|
|
| MD5 |
7d2c9ec9a30a28715ab0e88fbc2865de
|
|
| BLAKE2b-256 |
2709c4e4bc6d2a97c4ebc128d1a900efe39dc1ea34083bfb01f1b3eb8f201934
|
Provenance
The following attestation bundles were made for django_goroutine-1.0.0rc1-py3-none-any.whl:
Publisher:
publish.yml on alzeph/django-goroutine
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_goroutine-1.0.0rc1-py3-none-any.whl -
Subject digest:
cf44acf0683509fee5e60dc154d80dcb9245fb71495e30d648a74546f0aa194c - Sigstore transparency entry: 2481094960
- Sigstore integration time:
-
Permalink:
alzeph/django-goroutine@a2786b383c18a4656fee68db272ded8bb3721963 -
Branch / Tag:
refs/tags/v1.0.0rc1 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a2786b383c18a4656fee68db272ded8bb3721963 -
Trigger Event:
release
-
Statement type: