Skip to content

Commit 14516a1

Browse files
committed
Initial commit
0 parents  commit 14516a1

File tree

21 files changed

+1639
-0
lines changed

21 files changed

+1639
-0
lines changed

.gitignore

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# IDE directories
2+
.idea/
3+
.vscode/
4+
5+
# OS generated files
6+
.DS_Store
7+
8+
# Python specific
9+
__pycache__/
10+
*.egg-info/
11+
*.pyc
12+
13+
# Virtualenvs
14+
venv/
15+
env/
16+
.env/
17+
18+
# Project specific
19+
.env
20+
.envrc
21+
.cache
22+
.ruff_cache
23+
24+
# Database
25+
*.sqlite3
26+
27+
# Supervisor
28+
supervisord.log
29+
supervisord.pid

Makefile

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
.PHONY: bootstrap install lint format locust uwsgi uwsgi-gevent gunicorn gunicorn-gevent uvicorn locust
2+
3+
default: bootstrap install
4+
5+
clean: ## Remove all build, test, coverage and Python artifacts
6+
find . -name '*.pyc' -exec rm -rf {} +
7+
find . -name '__pycache__' -exec rm -rf {} +
8+
find . -name '*.egg-info' -exec rm -rf {} +
9+
find . -name '*.egg' -exec rm -rf {} +
10+
11+
install:
12+
pip install -e .
13+
14+
lint:
15+
ruff check
16+
17+
format:
18+
ruff check --fix
19+
ruff format
20+
21+
api:
22+
ROOT_URLCONF=django_sync_or_async.urls.api uvicorn django_sync_or_async.asgi:application --port 5000
23+
24+
uwsgi-2-threads:
25+
ROOT_URLCONF=django_sync_or_async.urls.sync uwsgi --module django_sync_or_async.wsgi:application --processes 1 --threads 2 --master --die-on-term --http 0.0.0.0:8000 --stats :3030 --stats-http
26+
27+
uwsgi-100-threads:
28+
ROOT_URLCONF=django_sync_or_async.urls.sync uwsgi --module django_sync_or_async.wsgi:application --processes 1 --threads 100 --master --die-on-term --http 0.0.0.0:8001 --stats :3031 --stats-http
29+
30+
uwsgi-gevent:
31+
ROOT_URLCONF=django_sync_or_async.urls.sync uwsgi --module django_sync_or_async.wsgi:application --processes 1 --gevent 100 --gevent-early-monkey-patch --master --die-on-term --http 0.0.0.0:8002 --stats :3032 --stats-http
32+
33+
gunicorn-100-threads:
34+
ROOT_URLCONF=django_sync_or_async.urls.sync gunicorn django_sync_or_async.wsgi --workers=1 --threads 100 --access-logfile '-' --bind 0.0.0.0:8003
35+
36+
gunicorn-gevent:
37+
ROOT_URLCONF=django_sync_or_async.urls.sync gunicorn django_sync_or_async.wsgi --workers=1 --worker-class gevent --worker-connections 100 --access-logfile '-' --bind 0.0.0.0:8004
38+
39+
uvicorn:
40+
ROOT_URLCONF=django_sync_or_async.urls.async uvicorn django_sync_or_async.asgi:application --port 8005
41+
42+
locust:
43+
locust --users 100 --spawn-rate 10 -t 30s -f src/django_sync_or_async/locust.py -H $(HOST)

README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Django sync or async, that's the question
2+
3+
Test the performance and concurrency processing of a Django view calling an "external" API with the following servers:
4+
5+
- uWSGI (WSGI)
6+
- uWSGI with Gevent (WSGI)
7+
- Gunicorn (gthread) (WSGI)
8+
- Gunicorn with Gevent (WSGI)
9+
- Uvicorn (ASGI)
10+
11+
12+
## View calling an "external" API
13+
14+
We are testing a Django view which will call an "external" API several times.
15+
16+
We'll use the [httpx](https://www.python-httpx.org/) package for this, as it provides both a sync and async API.
17+
18+
The "external" API, which runs locally with `uvicorn` using `asyncio.sleep` to simulate latency selects a random country from a predefined list:
19+
20+
https://github.com/maerteijn/django-sync-or-async/blob/1c5a6e4738f111c3b09e173af2f3d0c02ca0f8b0/src/django_sync_or_async/views.py#L14-L22
21+
22+
23+
## Overview
24+
25+
We will test the performance implications with the following configurations:
26+
27+
```
28+
29+
┌─────────────────────────────┐
30+
┌────────────────────┤ uwsgi-2-threads (:8000) │
31+
│ │ (1 process, 2 threads) │
32+
│ └─────────────────────────────┘
33+
│ ┌─────────────────────────────┐
34+
│ ┌─────────────────┤ uwsgi-100-threads (:8001) │
35+
│ │ │ (1 process, 100 threads) │
36+
│ │ └─────────────────────────────┘
37+
│ │ ┌─────────────────────────────┐
38+
▼ ▼ │ uwsgi-gevent (:8002) │
39+
┌───────────┐ ┌───────┤ (1 process, 100 "workers") │
40+
│API (:5000)│◄──┘ └─────────────────────────────┘
41+
│ (uvicorn) │◄──┐ ┌─────────────────────────────┐
42+
└───────────┘ └───────┤ gunicorn-100-threads (:8003)│
43+
▲ ▲ │ (1 process, 100 threads) │
44+
│ │ └─────────────────────────────┘
45+
│ │ ┌─────────────────────────────┐
46+
│ └────────────────┤ gunicorn-gevent (:8004) │
47+
│ │(1 process, 100 "workers") │
48+
│ └─────────────────────────────┘
49+
│ ┌─────────────────────────────┐
50+
└────────────────────┤ uvicorn (:8005) │
51+
│ (1 process) │
52+
└─────────────────────────────┘
53+
54+
```
55+
### Concurrency
56+
57+
The view which will be benchmarked calls our "really slow" external API three times so we can also test these calls are done in parallel instead of sequential. The slowest response time is around 600ms, so this is about the longest time it takes the API should generate a response because the API calls should be executed in parallel. To achieve this we use a `ThreadPoolExecutor` for the sync view:
58+
59+
https://github.com/maerteijn/django-sync-or-async/blob/1c5a6e4738f111c3b09e173af2f3d0c02ca0f8b0/src/django_sync_or_async/views.py#L25-L38
60+
61+
> [!NOTE]
62+
> The standard `ThreadPoolExecutor` with actual system threads is used when using the `uwsgi-2-threads`, ` uwsgi-100-threads` and `gunicorn-100-threads` configurations. When using gevent, [threads are monkey patched to be cooperative](https://www.gevent.org/api/gevent.monkey.html), so new greenlets will be spawned when using the `ThreadPoolExecutor`.
63+
64+
For the `uvicorn` version (ASGI), the parallel calls are implemented with `asyncio.gather`:
65+
66+
https://github.com/maerteijn/django-sync-or-async/blob/1c5a6e4738f111c3b09e173af2f3d0c02ca0f8b0/src/django_sync_or_async/views.py#L41-L56
67+
68+
69+
## Installation
70+
71+
### Requirements
72+
73+
- Python 3.12 (minimum)
74+
- virtualenv (recommended)
75+
76+
77+
### Install the packages
78+
79+
First create a virtualenv in your preferred way, then install all packages with:
80+
```bash
81+
make install
82+
```
83+
84+
## Running the services
85+
86+
Make sure you are allowed to have many file descriptions open:
87+
```bash
88+
ulimit -n 32768
89+
```
90+
91+
Now run the supervisor daemon which will start all services:
92+
```bash
93+
$ supervisord
94+
```
95+
96+
This will start the API and all the different uwsgi / asgi services. Press `ctrl+c` to stop it.
97+
98+
99+
## Run the benchmarks
100+
101+
You can run the benchmarks for each individual server by selecting the relevant port number so comparison can be made after running them:
102+
103+
For `uwsgi`:
104+
```bash
105+
make locust HOST=http://localhost:8000
106+
```
107+
108+
This will start a locust interface, accessible via http://localhost:8089
109+
110+
For `uwsgi-100-threads`:
111+
```bash
112+
make locust HOST=http://localhost:8001
113+
```
114+
115+
Etcetera, see the [port numbers in the overview](#overview).
116+
117+
### uwsgitop
118+
119+
You can see detailed information during the benchmarks for the uWSGI processes using `uwsgitop`:
120+
```bash
121+
uwsgitop http://localhost:3030 # <-- for the 1 process 2 threads variant
122+
uwsgitop http://localhost:3031 # <-- for the 1 process 100 threads variant
123+
uwsgitop http://localhost:3032 # <-- for the 1 process gevent variant
124+
```

pyproject.toml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
[build-system]
2+
requires = ["setuptools >= 64.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "django-sync-or-async"
7+
description = "Django sync or async, that's the question"
8+
version = "0.0.1"
9+
license = { text = "Proprietary" }
10+
requires-python = ">=3.12"
11+
authors = [{name = "Martijn Jacobs", email = "[email protected]"}]
12+
classifiers = [
13+
"Environment :: Web Environment",
14+
"Framework :: Django",
15+
"Framework :: Django :: 5.1",
16+
"License :: Other/Proprietary License",
17+
"Programming Language :: Python :: 3.12",
18+
]
19+
dependencies = [
20+
"Django ~= 5.1",
21+
"uWSGI == 2.0.28",
22+
"uwsgitop == 0.12",
23+
"uvicorn ==0.34",
24+
"ruff == 0.8.5",
25+
"httpx == 0.28.1",
26+
"locust == 2.32.5",
27+
"gunicorn == 23.0.0",
28+
"gevent == 24.11.1",
29+
"supervisor == 4.2.5"
30+
]
31+
32+
[project.scripts]
33+
"manage.py" = "django_sync_or_async.manage:main"
34+
35+
36+
[tool.ruff]
37+
target-version = "py312" # minimum target version
38+
39+
# E501: Line too long
40+
# C408: Unnecessary `dict` call
41+
# F405: Star imports
42+
lint.ignore = ["E501", "C408", "F405"]
43+
44+
lint.select = [
45+
"E", # pycodestyle errors
46+
"F", # pyflakes
47+
"I", # isort
48+
"T20", # flake8-print
49+
"BLE", # flake8-blind-except
50+
"C4", # flake8-comprehensions
51+
"UP", # pyupgrade
52+
]
53+

src/django_sync_or_async/__init__.py

Whitespace-only changes.

src/django_sync_or_async/asgi.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
ASGI config for django_sync_or_async project.
3+
4+
It exposes the ASGI callable as a module-level variable named ``application``.
5+
6+
For more information on this file, see
7+
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
8+
"""
9+
10+
import os
11+
12+
from django.core.asgi import get_asgi_application
13+
14+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_sync_or_async.settings")
15+
16+
application = get_asgi_application()

0 commit comments

Comments
 (0)