🔎 Buscar

🧠 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.

Wiki / Apuntes📖 Contenido

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 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 al PyTypeObject, 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.getrefcount devuelve 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 del funciona 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.
  • append es O(1) amortizado: casi siempre hay hueco.
  • insert(0, x) es O(n): hay que desplazar todos los elementos.
  • x in lista es 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 d son 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.

💡 dis también funciona sobre módulos, clases y expresiones. dis.dis(dis) muestra el bytecode del propio módulo dis — 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.getsizeof no 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.