This post is available in English.
Dans cet article, nous allons vérifier si le client Python Elasticsearch est compatible avec les nouvelles versions Python 3.13 dites "free-threading", où le GIL a été supprimé.
Introduction
Mais tout d'abord, qu'est-ce que le GIL ? Le Global Interpreter Lock (GIL) est un mutex qui empêche plusieurs threads d'exécuter du bytecode Python simultanément. Ce n'est pas toujours un problème en pratique.
- La programmation scientifique peut utiliser des bibliothèques comme NumPy qui ne retiennent pas le GIL.
- Certains programmes ne sont pas limités par le CPU mais par les Entrées/Sorties (E/S ou I/O). Par exemple, si votre code effectue des requêtes coûteuses vers Elasticsearch et ne fait pas de calculs intensifs sur les résultats, il peut utiliser efficacement plusieurs threads. En effet, même si un seul thread s'exécute, il ne bloquera pas les autres qui seront en attente d'E/S et ne bloqueront donc pas le GIL. (C'est aussi le scénario où async/await brille en Python.)
Malgré, la levée de cette limitation pour permettre une véritable programmation multi-thread a été un objectif pendant des décennies. Grâce au travail incroyable de Sam Gross, c'est maintenant possible ! Ce travail, initialement appelé nogil, est maintenant officiellement appelé free-threading. Bien que le code Python pur existant fonctionne toujours de la même manière avec les builds free-threading (quoique plus lentement pour le moment) , tout le code compilé à partir d'autres langages comme C ou Rust nécessite une refonte. Par le passé, un tel changement incompatible aurait été une justification suffisante pour sortir Python 4. Cependant, la migration vers Python 3 a entraîné une scission du langage pendant plus de 10 ans, et la douleur qui en a résulté est encore fraîche dans de nombreux esprits. Par conséquent, le plan actuel est un déploiement progressif :
- Dans la phase 1 (la phase actuelle), Python fournit des builds free-threading expérimentaux, et c'est à chaque bibliothèque et application de tester leur compatibilité
- Dans la phase 2, ces builds ne seront plus qualifiés d'"expérimentaux" et seront supportés à part entière.
- Dans la phase 3, ils deviendront la configuration par défaut.
Le client Python Elasticsearch est du code Python pur sans multi-threading complexe ni de dépendance spécifique au ramasse-miettes (GC), donc il fonctionnera tout aussi bien avec les builds free-threading. Cependant, il a des dépendances optionnelles qui sont affectées, comme aiohttp ou orjson.
Nous allons tester ces différentes parties et voir si elles fonctionnent. Le benchmarking sera laissé comme exercice au lecteur !
Utilisation de Python free-threading
Il existe différentes façons d'installer les builds Python free-threading. Nous utiliserons le gestionnaire de paquets uv d'Astral qui permet de spécifier les builds free-threading avec --python 3.13t
. Astral a contribué des builds free-threading à python-build-standalone, et uv les utilisera si nécessaire :
$ uv run --python 3.13t python
Using CPython 3.13.0
Creating virtual environment at: .venv
Installed 4 packages in 16ms
Python 3.13.0 experimental free-threading build (main, Oct 16 2024, 08:24:33)
[Clang 18.1.8 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
Cependant, si vous avez déjà un interpréteur free-threading installé, il l'utilisera. Par exemple, si vous voulez utiliser le build fourni par Homebrew sur macOS (installé avec brew install python-freethreading
), vous obtiendrez plutôt la sortie suivante :
$ uv run --python 3.13t python
Using CPython 3.13.0 interpreter at:
/opt/homebrew/opt/python-freethreading/bin/python3.13t
Creating virtual environment at: .venv
Installed 4 packages in 4ms
Python 3.13.0 experimental free-threading build (main, Oct 7 2024, 05:02:14)
[Clang 16.0.0 (clang-1600.0.26.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
Et comme uv prend également en charge le standard Inline scripting metadata, nous fournirons des scripts complets comme celui-ci :
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "numpy",
# ]
# ///
import numpy as np
c = np.arange(24).reshape(2, 3, 4)
Vous pouvez les exécuter avec uv
qui se chargera d'installer les dépendances nécessaires :
$ uv run --python 3.13t example.py
Reading inline script metadata from `example.py`
[[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
[[12 13 14 15]
[16 17 18 19]
[20 21 22 23]]]
Utiliser Elasticsearch
Grâce au script start-local, Elasticsearch est tout aussi facile à lancer :
$ curl -fsSL https://elastic.co/start-local | sh
______ _ _ _
| ____| | | | (_)
| |__ | | __ _ ___| |_ _ ___
| __| | |/ _` / __| __| |/ __|
| |____| | (_| \__ \ |_| | (__
|______|_|\__,_|___/\__|_|\___|
-------------------------------------------------
🚀 Run Elasticsearch and Kibana for local testing
-------------------------------------------------
ℹ️ Do not use this script in a production environment
⌛️ Setting up Elasticsearch and Kibana v8.16.0...
- Generated random passwords
- Created the elastic-start-local folder containing the files:
- .env, with settings
- docker-compose.yml, for Docker services
- start/stop/uninstall commands
- Running docker compose up --wait
🎉 Congrats, Elasticsearch and Kibana are installed and running in Docker!
🌐 Open your browser at http://localhost:5601
🔌 Elasticsearch API endpoint: http://localhost:9200
Assemblons le tout pour voir si ça fonctionne :
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "elasticsearch",
# ]
# ///
import os
import sys
from elasticsearch import Elasticsearch
print(sys.version)
client = Elasticsearch(
"http://localhost:9200", api_key=os.environ["ES_LOCAL_API_KEY"]
)
print(client.info()["tagline"])
Comment le lancer ? Le script start-local ne configure pas HTTPS mais met en place une authentication par défaut. Les secrets concernés sont stockés dans le fichier elastic-start-local/.env
qu'on peut sourcer dans notre terminal et ainsi définir la valeur d'environnement ES_LOCAL_API_KEY
:
$ source elastic-start-local/.env
$ ES_LOCAL_API_KEY=$ES_LOCAL_API_KEY uv run --python 3.13t ex1.py
Reading inline script metadata from `ex1.py`
You Know, for Search
Top ! Une requête simple a fonctionné comme prévu. Maintenant, testons différentes fonctionnalités du client Python.
Assistant bulk
Le seul endroit où nous utilisons explicitement des threads dans le client Python est dans l'assistant parallel_bulk. Indexons le jeu de données books.csv et faisons une requête pour voir si cela a fonctionné.
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "elasticsearch",
# ]
# ///
import csv
import os
import sys
import time
from elasticsearch import Elasticsearch, helpers
client = Elasticsearch(
"http://localhost:9200", api_key=os.environ["ES_LOCAL_API_KEY"]
)
mappings = {
"properties": {
"Title": {"type": "text"},
"Description": {"type": "text"},
"Author": {"type": "text"},
"Year": {"type": "date", "format": "yyyy"},
"Published": {"type": "keyword"},
"Rating": {"type": "scaled_float", "scaling_factor": 100},
}
}
client.options(ignore_status=[404]).indices.delete(index="books")
client.indices.create(index="books", mappings=mappings)
print("Created index")
def generate_docs():
with open("books.csv", newline="") as csvfile:
reader = csv.DictReader(csvfile, delimiter=";")
for row in reader:
yield {"_index": "books", **row}
start = time.perf_counter()
n, errors = helpers.bulk(client, generate_docs())
end = time.perf_counter()
print(f"Indexed {n} books in {end - start:.1f} seconds.")
client.indices.refresh(index="books")
print("Searching for Stephen King:")
resp = client.search(
index="books", query={"match": {"Author": "Stephen King"}}
)
for hit in resp.body["hits"]["hits"]:
book = hit["_source"]
description = f'{book["Author"]} - {book["Title"]} ({book["Year"]})'
rating = f'{book["Ratings"]} stars'
print(f" {description}: {rating}")
La sortie de ce script montre que nous avons en effet indexé les 82k livres en moins de 2 secondes (ce qui est environ deux fois plus rapide qu'avec le simple assistant bulk sur mon laptop) :
$ ES_LOCAL_API_KEY=$ES_LOCAL_API_KEY uv run --python 3.13t ex2.py
Reading inline script metadata from `ex2.py`
Created index
Indexed 81828 books in 1.6 seconds.
Searching for Stephen King:
Stephen King - THE ELEMENTS OF STYLE (2013): 5.00 stars
Stephen King - Star (Thorndike Core) (2021): 3.11 stars
Stephen King - Hearts in Atlantis (2017): 4.08 stars
Stephen King - Misery (Spanish Edition) (2016): 4.43 stars
Stephen King - The Dead Zone (2016): 4.40 stars
Stephen King - Another World (Thorndike Core) (2021): 3.78 stars
Stephen King - FROM A BUICK 8 (True first edition) (2017): 3.25 stars
Stephen King - Road Work (2017): 4.29 stars
Stephen King - Icon (Thorndike Core) (2021): 4.00 stars
Stephen King - Misery (2016): 4.43 stars
aiohttp
Le client Python Elasticsearch prend en charge asyncio avec deux clients HTTP, aiohttp et httpx, le client par défaut étant aiohttp. Bien qu'aiohttp ne prenne pas encore officiellement en charge les builds free-threading (et échoue effectivement actuellement à la compilation), il est possible de l'utiliser en mode Python pur en définissant AIOHTTP_NO_EXTENSIONS=1
. Ce sera plus lent mais fonctionnera avec les builds free-threading.
En termes de tests, il n'y a pas grand-chose à faire. La boucle d'événements (event loop) asyncio est déjà limitée à un seul thread. En réutilisant l'exemple précédent mais en utilisant asyncio à la place, on obtient :
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "elasticsearch[async]",
# ]
# ///
import asyncio
import os
import sys
from elasticsearch import AsyncElasticsearch
print(sys.version)
async def main():
async with AsyncElasticsearch(
"http://localhost:9200", api_key=os.environ["ES_LOCAL_API_KEY"]
) as client:
info = await client.info()
print(info["tagline"])
asyncio.run(main())
Comme uv run
installe les dépendances à la volée, il faut définir AIOHTTP_NO_EXTENSIONS
. Et en effet, le script se comporte comme attendu :
$ export AIOHTTP_NO_EXTENSIONS=1
$ export ES_LOCAL_API_KEY=$ES_LOCAL_API_KEY
$ uv run --python 3.13t ex3.py
Reading inline script metadata from `ex3.py`
3.13.0 experimental free-threading build (main, Oct 16 2024, 08:24:33
[Clang 18.1.8 ]
You Know, for Search
Sérialisation et désérialisation
Le client Python Elasticsearch prend en charge plusieurs bibliothèques pour sérialiser ou désérialiser les données. Elles utilisent souvent du code natif pour des raisons de performance, et ces bibliothèques doivent être adaptées pour fonctionner avec les builds free-threading.
orjson permet une sérialisation/désérialisation rapide du JSON mais ne prend pas encore en charge les builds free-threading et ne compile d'ailleurs même pas.
Par contre, PyArrow 18+ et Pandas 2.2.3+ prennent en charge les builds free-threading. Réutilisons l'index books
en faisant plutôt une requête ES|QL :
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "elasticsearch",
# "pandas",
# "pyarrow",
# ]
# ///
import csv
import os
import sys
import time
import pandas as pd
from elasticsearch import Elasticsearch, helpers
client = Elasticsearch(
"http://localhost:9200", api_key=os.environ["ES_LOCAL_API_KEY"]
)
print("Searching for Stephen King:")
resp = client.esql.query(
query="""
FROM books
| WHERE Author == "Stephen King"
| SORT Rating DESC
| LIMIT 10
""",
format="arrow",
)
df = resp.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
La sortie est la suivante :
$ PYTHON_GIL=0 ES_LOCAL_API_KEY=$ES_LOCAL_API_KEY uv run --python 3.13t ex4.py
Reading inline script metadata from `ex4.py`
Searching for Stephen King:
Author ... Title Year
0 Stephen King ... Another World (Thorndike Core) 2021-01-01 00:00:00
1 Stephen King ... FROM A BUICK 8 (True first edition) 2017-01-01 00:00:00
2 Stephen King ... Hearts in Atlantis 2017-01-01 00:00:00
3 Stephen King ... Misery (Spanish Edition) 2016-01-01 00:00:00
4 Stephen King ... The Dark Tower: The Gunslinger 2017-01-01 00:00:00
5 Stephen King ... The Dead Zone 2016-01-01 00:00:00
6 Stephen King ... NIGHTMARES AND DREAMSCAPES 2017-01-01 00:00:00
7 Stephen King ... How writers write 2002-01-01 00:00:00
8 Stephen King ... THE ELEMENTS OF STYLE 2013-01-01 00:00:00
9 Stephen King ... Road Work 2017-01-01 00:00:00
Notez ici que j'ai du définir PYTHON_GIL=0
pour cacher l'avertissement (warning) suivant qui ne devrait pas à ma connaissance être émis étant donnés que ces librairies support les builds free-threading. Peut-être est-ce un bug qui sera corrigé dans le futur ?
<frozen importlib._bootstrap>:488: RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'pandas._libs.pandas_parser', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.
Conclusion
En conclusion, les builds free-threading fonctionnent étonnamment bien ! De nombreuses bibliothèques importantes prennent déjà en charge le free-threading. Il reste encore quelques bibliothèques comme orjson ou Polars, mais ce sont les exceptions, pas la règle. L'avenir est prometteur pour le free-threading, et je ne serais pas surpris de voir ces builds sortir rapidement de leur statut expérimental. (Mais d'ici là, je déconseillerais de les utiliser en production.)
Et pour répondre à ma question initiale : oui, le client Python Elasticsearch et par extension ses dépendances comme urllib3 fonctionnent très bien avec le free-threading !