🧠 Internals de CPython
El pipeline de compilación de token a bytecode, la VM, el GIL y PEP 703, PyObject y refcounts, el GC generacional, listas y dicts por dentro, gestión de memoria, bytecode con dis y cómo contribuir al proyecto.
Internals de CPython
La mayoría de la gente dice “Python” y se refiere a CPython: la implementación de referencia del lenguaje, escrita en C, la que descargas desde python.org. Entender cómo funciona por dentro te hace mejor programador: sabes por qué las listas se comportan como se comportan, por qué existe el GIL, por qué ciertas operaciones son O(1) y otras O(n). Este artículo desmonta CPython pieza a pieza.
Qué es CPython
CPython es el intérprete de referencia de Python: traduce tu código Python a un bytecode y lo ejecuta en una máquina virtual. Está escrito en C y es la base de prácticamente todo el ecosistema.
No es la única implementación:
| Implementación | Descripción |
|---|---|
| CPython | la de referencia, en C, con GIL |
| PyPy | implementación en Python con JIT (más rápida en bucles) |
| Jython | corre sobre la JVM de Java |
| IronPython | corre sobre .NET |
| MicroPython | para microcontroladores, muy reducida |
Cuando hablamos de internals, hablamos de CPython.
El proceso de compilación
Tu código pasa por una cadena de transformaciones antes de ejecutarse:
Código fuente (.py)
│ 1. Tokenizador (tokenize)
▼
Tokens
│ 2. Parser (ast)
▼
Árbol de sintaxis abstracta (AST)
│ 3. Compilador (compile)
▼
Bytecode
│ 4. Máquina virtual (CPython VM)
▼
Resultado
1. El tokenizador
Divide el texto en tokens: unidades léxicas mínimas (palabras clave, identificadores, operadores, números, strings).
import tokenize, io
codigo = "x = 2 + 3"
for tok in tokenize.generate_tokens(io.StringIO(codigo).readline):
print(tok.type, repr(tok.string))
ENCODING 'utf-8'
NAME 'x'
OP '='
NUMBER '2'
OP '+'
NUMBER '3'
NEWLINE ''
ENDMARKER ''
2. El parser
Agrupa los tokens en un Árbol de Sintaxis Abstracta (AST): una estructura jerárquica que representa la gramática del programa.
import ast
arbol = ast.parse("x = 2 + 3")
print(ast.dump(arbol, indent=2))
Verás nodos como Assign, BinOp, Constant… El AST ignora lo superficial (espacios, paréntesis) y captura la estructura.
3. El compilador a bytecode
Convierte el AST en bytecode: instrucciones compactas que entiende la VM, no legibles para humanos pero mucho más simples de ejecutar que el AST.
import dis
def suma(a, b):
return a + b
dis.dis(suma)
3 0 RESUME 0
2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
4. La máquina virtual
La VM ejecuta el bytecode instrucción por instrucción: un bucle que lee cada opcode, lo ejecuta y avanza al siguiente.
El GIL (Global Interpreter Lock)
El GIL es un candado que permite que solo un hilo de CPython ejecute bytecode Python a la vez. Es la razón por la que Python no consigue paralelismo real de CPU con threads, aunque sí con procesos (multiprocessing).
Por qué existe
- Simplicidad de la memoria: sin el GIL, dos hilos podrían modificar el mismo objeto a la vez. El GIL hace que la gestión de memoria y el refcounting (más abajo) no necesiten cerrojos por objeto, simplificando mucho la implementación en C.
- Rendimiento de un solo hilo: en la era de un núcleo, el GIL hacía los programas de un solo hilo más rápidos que con locks por objeto.
Qué significa en la práctica
import threading, time
def tarea():
t0 = time.perf_counter()
while time.perf_counter() - t0 < 2:
pass
inicio = time.perf_counter()
hilos = [threading.Thread(target=tarea) for _ in range(4)]
for t in hilos:
t.start()
for t in hilos:
t.join()
print(f"tiempo: {time.perf_counter() - inicio:.2f} s")
En una máquina de 4 núcleos, este código tarda ~8 s (no ~2 s): el GIL solo deja ejecutar un hilo de CPU a la vez. El GIL sí se libera durante el I/O, por eso el I/O sí aprovecha threads.
⚠️ El GIL limita la concurrencia CPU-bound con threads. Para trabajo intensivo de CPU usa
multiprocessing(procesos reales, cada uno con su GIL) o librerías que liberen el GIL en C (numpy).
PEP 703: free-threading
Desde Python 3.13 hay un build experimental free-threaded (sin GIL) detrás del PEP 703. Permite a varios hilos ejecutar bytecode en paralelo de verdad. Sigue siendo experimental y tiene overhead en la gestión de memoria, por eso el build con GIL sigue siendo el predeterminado.
Objetos: PyObject y refcounts
Todo en Python es un objeto, y todo objeto CPython empieza con un PyObject en C:
typedef struct _object {
Py_ssize_t ob_refcnt; // contador de referencias
PyTypeObject *ob_type; // tipo del objeto
} PyObject;
ob_refcnt: cuántas referencias apuntan al objeto (el refcount).ob_type: puntero alPyTypeObject, que define el comportamiento del objeto.
Refcount: gestión de memoria por conteo
La memoria de un objeto se libera cuando su contador de referencias llega a cero. Cada referencia nueva (asignación, paso a función, elemento de lista) sube el refcount; cuando la referencia desaparece, baja.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # al menos 2: la variable + el argumento temporal
b = a
print(sys.getrefcount(a)) # sube con cada referencia nueva
del b
print(sys.getrefcount(a)) # baja al borrar la referencia
💡
sys.getrefcountdevuelve una referencia más de las “reales” porque él mismo crea una referencia temporal al pasarla.
La función que gestiona esto es Py_DECREF en C: decrementa el refcount y, si llega a cero, libera la memoria. Por eso CPython usa reference counting como gestión de memoria.
GC generacional vs refcount
El refcount maneja la mayoría de los objetos, pero tiene un punto ciego: los ciclos de referencia (A apunta a B y B apunta a A), donde ningún refcount llega a cero aunque el objeto sea inaccesible. Para eso existe el garbage collector generacional.
import gc
class Nodo:
def __init__(self):
self.par = None
a = Nodo()
b = Nodo()
a.par = b # ciclo: a→b→a
b.par = a
del a
del b # el refcount nunca llega a 0 → el GC los recoge
El GC generacional separa los objetos en generaciones (0, 1, 2). Los nuevos van a la generación 0, que se recolecta con frecuencia; los que sobreviven maduran a generaciones superiores, que se recolectan menos:
| Generación | Frecuencia | Objetos |
|---|---|---|
| 0 | alta (cada pocas alocaciones) | recién creados |
| 1 | media | supervivientes de la 0 |
| 2 | baja | supervivientes de la 1 |
import gc
print(gc.get_threshold()) # (700, 10, 10): umbrales de cada generación
print(gc.get_count()) # contadores actuales
💡 La mayoría de los objetos se liberan por refcount en cuanto dejan de referenciarse. El GC generacional solo interviene para romper ciclos inalcanzables. Por eso
delfunciona casi siempre de inmediato.
Tipos en C: PyTypeObject
Cada tipo de Python (int, str, list…) está descrito por un PyTypeObject en C, que contiene sus métodos y cómo operar sobre él:
typedef struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name; // nombre del tipo, ej. "list"
Py_ssize_t tp_basicsize; // tamaño base del objeto
...
destructor tp_dealloc; // cómo liberarlo
reprfunc tp_repr; // cómo representarlo
hashfunc tp_hash; // cómo calcular su hash
ternaryfunc tp_call; // cómo llamarlo
...
} PyTypeObject;
Este diseño es la base de todo: los objetos “primitivos” y los definidos en Python comparten el mismo mecanismo. Cuando escribes una clase, CPython crea un PyTypeObject detrás.
Listas y dicts por dentro
Listas: arrays dinámicos
Una lista CPython es un array dinámico de punteros a objetos (PyObject**), con ob_item (el array) y allocated (la capacidad). Cuando el array se llena, se reallocate a mayor tamaño (≈1.125x). Por eso:
lista[i]es O(1): acceso directo por índice.appendes O(1) amortizado: casi siempre hay hueco.insert(0, x)es O(n): hay que desplazar todos los elementos.x in listaes O(n): hay que recorrer.
import sys
lst = []
for i in range(20):
lst.append(i)
print(len(lst), sys.getsizeof(lst))
Verás cómo el tamaño en bytes no crece de uno en uno: crece a saltos (reallocations).
Dicts: tablas hash
Un dict es una tabla hash: guarda los pares clave→valor usando la función hash de las claves. Por eso:
d[k],d[k] = v,k in dson O(1) de media.- Las claves deben ser hashables (inmutables:
int,str,tuple…).
import sys
print(sys.getsizeof({})) # un dict vacío ya ocupa bastante
print(sys.getsizeof({"a": 1}))
print(hash("clave")) # función hash
⚠️ Una lista no puede ser clave de dict porque es mutable y no hashable. Por eso
{"lista": [1,2]}da error. Usa tuplas:{(1,2): "valor"}funciona.
El hash se calcula una vez y se guarda; si cambias la clave de forma que cambie su hash, el dict se corrompe. Por eso las claves deben ser inmutables.
Gestión de memoria: arenas y pools
Cuando un objeto se libera (refcount a cero), su memoria vuelve a un allocator de objetos (pymalloc) en vez de devolverse al sistema operativo de inmediato. El allocator usa tres niveles:
Arenas (bloques grandes ~256 KB)
└── Pools (bloques de 4 KB, para objetos de tamaño similar)
└── Bloques (chunks de tamaño fijo: 8, 16, 24, ..., 512 bytes)
- Una arena es un bloque grande de memoria.
- Se divide en pools, cada uno para objetos de un tamaño concreto.
- Cada pool se divide en bloques del mismo tamaño.
La idea: en vez de hacer miles de malloc/free pequeños (lentos), CPython reserva pools y recicla bloques dentro de ellos. Esto acelera la creación/destrucción de objetos pequeños y reduce la fragmentación. Los objetos grandes (>512 bytes) van directamente a malloc del sistema.
Bytecode con dis
El módulo dis te deja ver el bytecode de cualquier función. Es la mejor forma de entender qué hace la VM:
import dis
def calcular(x):
resultado = x * 2
return resultado + 1
dis.dis(calcular)
4 0 RESUME 0
5 2 LOAD_FAST 0 (x)
4 LOAD_CONST 1 (2)
6 BINARY_OP 5 (*)
10 STORE_FAST 1 (resultado)
6 12 LOAD_FAST 1 (resultado)
14 LOAD_CONST 2 (1)
16 BINARY_OP 13 (+)
20 RETURN_VALUE
Opcodes comunes
| Opcode | Qué hace |
|---|---|
LOAD_FAST |
carga una variable local a la pila |
STORE_FAST |
guarda el tope de la pila en una variable local |
LOAD_CONST |
carga una constante (número, string) |
LOAD_ATTR |
accede a un atributo (obj.atributo) |
CALL |
llama a una función |
BINARY_OP |
operación binaria (suma, multiplicación, comparación) |
RETURN_VALUE |
devuelve el tope de la pila |
RESUME |
punto de suspensión (generadores, trampolines) |
La VM es una máquina de pila: las instrucciones empujan valores a una pila y las operaciones los consumen. En el ejemplo, LOAD_FAST x y LOAD_CONST 2 apilan dos valores, y BINARY_OP * los desapila, los multiplica y apila el resultado.
💡
distambién funciona sobre módulos, clases y expresiones.dis.dis(dis)muestra el bytecode del propio módulodis— Python inspeccionándose a sí mismo.
asyncio por dentro
asyncio se apoya en un event loop, Task y Future. Verlo desde dentro:
- Event loop: el bucle que va ejecutando callbacks y coroutines listas para continuar. Es el “scheduler” de asyncio.
- Coroutine: una función
async def. Al llamarla devuelves un objeto que solo se ejecuta cuando el loop lo agenda. - Future: un contenedor de un resultado que llegará en el futuro; el loop reanuda la coroutine cuando la Future está lista.
- Task: una coroutine envuelta en una Future para que el loop la ejecute en paralelo con otras.
import asyncio
async def main():
t = asyncio.create_task(esperar()) # agenda la coroutine como una Task
await t
async def esperar():
await asyncio.sleep(1)
Cuando haces await asyncio.sleep(1), la coroutine le dice al loop: “no puedo continuar hasta que pasen 1 s, ejecuta a otros”. El loop guarda su estado y la reanuda cuando el temporizador termina. Ese es todo el “secreto” del async: multiplexar esperas en un solo thread.
Cómo contribuir a CPython
Contribuir a CPython es más accesible de lo que parece. La guía oficial es el devguide:
git clone https://github.com/python/cpython.git
cd cpython
./configure --with-pydebug # build de desarrollo con depuración
make
- La guía de desarrollo oficial está en devguide.python.org.
- Toda contribución pasa por un pull request en GitHub, revisado por la comunidad.
- Las discusiones de diseño van a la python-dev mailing list y a los PEP para cambios grandes.
- Para empezar, busca issues marcados como easy o good first issue en el repo.
💡 El mejor primer paso es leer el devguide y ejecutar la suite de tests (
./python -m test) para confirmar que tu build funciona. Colaborar en la documentación o en los tests es una entrada ideal.
Ejemplos prácticos de inspección
Las herramientas que dejan ver los internals en una sola pasada:
import dis, sys, gc
def f(n):
return n * 3
dis.dis(f) # 1. bytecode de una función
print(sys.getsizeof([])) # 2. tamaño en bytes (lista vacía)
print(sys.getsizeof("hola")) # string
print(sys.getsizeof(10)) # int
x = []
print(sys.getrefcount(x)) # 3. refcount de un objeto
print(gc.get_threshold()) # 4. estado del GC
print(type(x), x.__class__.__name__) # 5. introspección del tipo
⚠️
sys.getsizeofno cuenta la memoria de los elementos de una colección, solo la del contenedor.getsizeof([])mide la lista vacía; el contenido se mide por separado.
Cheatsheet
| Concepto | En una línea |
|---|---|
| CPython | la implementación de referencia, en C |
| Pipeline | tokenizador → parser → compilador → VM |
| Bytecode | instrucciones compactas que ejecuta la VM |
| GIL | solo un hilo ejecuta bytecode a la vez |
| PEP 703 | build experimental sin GIL (free-threading) |
| PyObject | todo objeto empieza con refcount + tipo |
| Refcount | la memoria se libera cuando llega a cero |
| GC generacional | rompe ciclos inalcanzables |
| PyTypeObject | describe el comportamiento de cada tipo |
| Lista | array dinámico: acceso O(1), insert en 0 es O(n) |
| Dict | tabla hash: acceso O(1) medio |
| Memoria | arenas → pools → bloques |
| asyncio | event loop + Task + Future multiplexando esperas |
| Contribuir | devguide + GitHub PRs |
Para profundizar
- CPython Internals — Real Python: guía de lectura del código fuente.
- Python Bytecode Disassembler — dis docs: referencia completa de opcodes.
- PEP 703 — Making the GIL Optional: el documento del free-threading.
- Python Developer’s Guide (devguide): cómo contribuir a CPython.
- CPython GitHub repository: el código fuente oficial.