Skip to content

Python Internals

The most common Python implementation is CPython. We will use this section to explain some of the key internal features in Python.

Garbage Collector

The Python Garbage Collector is part of the memory management system responsible for automatic reclamation of unused memory. It prevents memory leaks by freeing memory occupied by objects no longer in use. Python employs a combination of reference counting and cyclic garbage collection.

Reference Counting

Each object in Python has a reference count (the number of references pointing to it). When an object’s reference count drops to 0, it is immediately deallocated.

Example:

a = []
b = a # Reference count = 2
del a # Reference count = 1
del b # Reference count = 0, object deallocated, but not necessarily deleted

Cyclic Garbage Collection

Python’s garbage collection (GC) system is built on a generational model and a cycle detection mechanism to manage memory and prevent cyclical references. Here’s how it works in detail:

1. Generations (0, 1, 2):

  • Objects are divided into three generations based on their age.
  • Generation 0 contains newly created objects.
  • Objects that survive garbage collection in generation 0 are promoted to generation 1, and similarly from generation 1 to generation 2.

Reasoning: Python works on the assumption that most objects die young. Newly created objects are more likely to become unreachable quickly. Long-lived objects are assumed to remain in use for longer, so they are collected less frequently.

2. Promotion Mechanism:

  • When an object is not garbage during a GC cycle (reachable or part of a cycle), it is promoted to the next generation.
  • This reduces the overhead of repeatedly inspecting long-lived objects.

3. Cycle Detection:

Cycles are identified during garbage collection by inspecting the object graph:

  • The GC maintains a list of all objects.
  • During a GC cycle, it identifies groups of objects that reference each other but are no longer reachable from the root ( e.g., global or local variables).
  • These unreachable cycles are then collected, breaking the cycle and freeing memory.

GC Triggers

Automatic Triggers: GC runs when thresholds for generation 0 are exceeded (set via gc.get_threshold() ).

Manual Trigger: Use the gc module:

import gc
gc.collect() # Forces garbage collection`

Managing Garbage Collection

Enable/Disable:

import gc
gc.disable() # Disable GC
gc.enable() # Enable GC

Reasons to disable:

  1. High performance is critical: The pause caused by garbage collection can disrupt performance in low-latency or high-frequency systems.
  2. Short-lived objects dominate: If most objects are short-lived, and you rely heavily on reference counting, GC overhead can become unnecessary.
  3. Manual cleanup is managed: In certain scenarios (e.g., temporary intensive computation), you may manually manage cleanup, making automatic collection redundant.

Inspecting Objects:

gc.get_objects() # List of tracked objects

Usages:

  1. Debugging memory leaks: Examine unreachable or unreferenced objects to identify leaks.
  2. Understanding memory usage: Profile the types and counts of objects in memory.
  3. Circular reference tracking: Identify objects in cyclic references that would otherwise remain uncollected.

Adjusting Thresholds

gc.set_threshold(generation0, generation1, generation2)

When to use:

  1. Too frequent collections: Increase thresholds if your app creates and discards many short-lived objects but has stable memory.
  2. Memory growth or fragmentation: Lower thresholds if garbage builds up and collections aren’t triggered soon enough.
  3. Custom profiles: If you notice consistent bottlenecks or leaks during certain object lifecycle patterns, tweaking thresholds might help.

Profile 1: Low-Latency System

Threshold Settings:

gc.set_threshold(1000, 10, 10)

When Useful:

  • High-frequency, low-latency applications (e.g., financial trading systems, real-time games).
  • When object creation/destruction is predictable, and garbage collection interruptions need to be minimized.
  • Assumes the application generates a small number of long-lived objects.

Effect:

  • Reduces collections for higher generations (Gen1, Gen2), focusing only on quickly collecting short-lived objects ( Gen0).
  • Less frequent but more comprehensive garbage collection cycles.

Profile 2: Memory-Intensive Batch Processing

Threshold Settings:

gc.set_threshold(500, 50, 5)

When Useful:

  • Large, memory-intensive applications with a mix of short-lived and long-lived objects (e.g., data processing pipelines, image or video rendering).
  • When reducing memory usage is more critical than runtime interruptions.
  • Ideal for preventing memory bloat caused by accumulation of long-lived garbage.

Effect:

  • Aggressively collects objects from all generations to minimize overall memory consumption.
  • Higher frequency of checks for long-lived objects (Gen2) reduces the risk of memory fragmentation and leaks.

Python Object Model

In Python, everything is an object because it follows a unified object model where every entity, including numbers, strings, classes, functions, and modules, is an instance of a class. This consistency allows for flexible and powerful programming paradigms. Functions being first-class objects means they can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures. This design supports higher-order functions, functional programming, and dynamic behavior, making Python versatile and expressive.

PyObject

PyObject is the core structure in CPython’s C API that represents all Python objects. It serves as the base type for all objects in Python, providing the foundation for Python’s object-oriented structure.

Structure:

  • Contains reference count (ob_refcnt) for memory management via reference counting.
  • Points to the type object (ob_type), which describes the type (class) of the object.

PyFunctionObject

PyFunction is a type that represents Python functions in CPython. It is derived from PyObject and encapsulates function-specific metadata.

Structure Contains:

  • Code object (__co_code__): Represents the compiled bytecode.
  • Globals (__func_globals__): The global variables available to the function.
  • __defaults__ and __kwdefaults__: Default values for positional and keyword arguments.
  • __closure__: Captures free variables in closures.

MRO

The Method Resolution Order (MRO) in Python is a system that determines the order in which classes are searched when executing a method or accessing an attribute. It is particularly important in the context of multiple inheritance.

  • Python computes a single linear order (sequence) for the classes in the inheritance hierarchy.
  • The MRO ensures consistent and predictable resolution of methods.
  • Python uses the C3 Linearization Algorithm, which ensures monotonicity, meaning that the order of searching remains consistent and avoids skipping parents in the hierarchy.
  • All classes have an mro() method, which returns the MRO of the class as a list. Syntax: ClassName.mro() or ClassName.__mro__

Inheritance Examples

Example 1: Single Inheritance

class A:
def say_hello(self):
print("Hello from A")
class B(A):
pass
b = B()
b.say_hello() # Output: Hello from A
print(B.mro()) # Output: [<class '__main__.B'>, <class '__main__.A'>, <class 'object'>]

Example 2: Multiple Inheritance

class A:
def say_hello(self):
print("Hello from A")
class B:
def say_hello(self):
print("Hello from B")
class C(A, B):
pass
c = C()
c.say_hello() # Output: Hello from A
print(C.mro()) # Output: [<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]

Example 3: Diamond Problem

class A:
def say_hello(self):
print("Hello from A")
class B(A):
def say_hello(self):
print("Hello from B")
class C(A):
def say_hello(self):
print("Hello from C")
class D(B, C):
pass
d = D()
d.say_hello() # Output: Hello from B
print(D.mro()) # Output: [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

C3 Linearization Algorithm

  1. Start with the current class.
  2. Follow the order of parent classes from left to right in the inheritance declaration.
  3. Merge the MROs of the parent classes while maintaining the order of appearance.

Key Properties

  • Monotonicity: The order respects the inheritance hierarchy; parent classes are resolved before grandparent classes.
  • Determinism: The MRO for a class is always the same, regardless of how it is instantiated or used.

Python Imports

Python resolves imports using the following detailed process:

1. Built-in Modules

First, Python checks for built-in modules (like sys or math). These are compiled into Python and have the highest precedence.

2. sys.modules Cache

Before searching the filesystem, Python checks if the module has already been imported by looking in sys.modules. If found, it reuses the existing module object instead of reloading it.

3. Search Paths (sys.path)

Python iterates over the list in sys.path, which contains the directories it looks through. By default, this includes:

  • The directory containing the script being run.
  • The PYTHONPATH environment variable (if set).
  • Standard library paths.
  • site-packages (third-party libraries installed via pip).

Namespace Packages

If no module is found, Python checks for namespace packages, which allow multiple directories to contribute to the same package.

Bytecode and Python Virtual Machine

When you run a Python script, the Python interpreter compiles the source code (.py files) into bytecode, which is a low-level, platform-independent representation of your code. This bytecode is saved in .pyc files located in the __pycache__ directory. This compilation step optimizes execution speed by avoiding repeated parsing and translation of the source code during subsequent runs.

The Python Virtual Machine (PVM), part of the Python runtime, reads and executes this bytecode. The PVM is a stack-based execution engine, meaning it uses a stack data structure to manage operands and intermediate results during execution.

In a stack-based execution model:

  1. Bytecode instructions (opcodes) push values onto the stack.
  2. Operations (like addition or function calls) pop values from the stack, compute results, and push the results back onto the stack.
  3. This process continues until the program completes execution.

For example:

A Python expression like a + b is compiled into bytecode instructions:

  1. LOAD_FAST to push a and b onto the stack.
  2. BINARY_ADD to pop a and b, compute the sum, and push the result back.
  3. STORE_FAST to store the result.

This stack-based approach simplifies the design of the virtual machine, as instructions don’t require explicit registers or complex addressing modes, unlike register-based architectures.

GIL

The Global Interpreter Lock (GIL) in Python is a mechanism that ensures only one thread executes Python bytecode at a time. This simplifies memory management in CPython but limits the performance of multithreaded programs on multicore processors, as threads cannot run in true parallel. Detailed Explanation:

The GIL is a mutex in CPython, Python’s standard implementation, that protects access to Python objects, ensuring thread safety. While it simplifies memory management by preventing race conditions and making reference counting efficient, it comes at a cost: only one thread can execute Python bytecode at any given moment, even on multicore processors. This means CPU-bound tasks in multithreaded programs don’t benefit from multiple cores. I/O-bound tasks, however, can benefit by releasing the GIL during I/O operations, allowing other threads to run. Alternatives like multiprocessing or implementations like Jython and PyPy can avoid the GIL’s limitations for specific use cases.

Mutex

The GIL is like a lock (mutex) that allows only one thread to execute Python code at a time. It ensures threads don’t interfere with each other when accessing Python objects, preventing bugs like race conditions. However, this also means threads can’t run in true parallel for CPU-heavy tasks, even on multicore processors.

Common Interview Questions

Explain Python’s MRO and its significance in multiple inheritance.

Python’s Method Resolution Order (MRO) determines the order in which classes are searched for a method or attribute during inheritance. It uses the C3 linearization algorithm to resolve the order consistently and prevent ambiguity. The MRO can be viewed using the __mro__ attribute or the mro() method.

How does Python's garbage collector handle cyclic references?

Python’s garbage collector detects cyclic references using the generational garbage collection algorithm. Cyclic garbage, which cannot be cleaned by reference counting, is identified and removed in later stages of collection. The gc module can be used to monitor or trigger garbage collection explicitly.

What role does the Python GIL play in object memory management? The Global Interpreter Lock (GIL) ensures that only one thread executes Python bytecode at a time, simplifying memory management for Python objects. It avoids the need for locks around reference counting, making it thread-safe. However, it limits multi-threaded performance in CPU-bound tasks.
How does Python decide the location to import modules from? Python searches for modules in directories listed in `sys.path`, which includes the script's directory, standard library paths, and directories specified in `PYTHONPATH`. If a module is not found, Python raises an `ImportError`. Customizing `sys.path` or using virtual environments can change the search behavior.
What is the purpose of `__del__` in Python objects, and what are its caveats? The `__del__` method is a destructor called when an object is about to be garbage collected. It should be used cautiously, as it can cause issues with reference cycles and delay garbage collection. Explicit resource management with `try...finally` or context managers is preferred.
How does the GIL impact Python’s garbage collection system? The GIL simplifies Python's garbage collection by preventing concurrent modifications to reference counts. This allows Python's memory management to remain thread-safe without explicit locking. However, it creates a bottleneck for multi-threaded programs in CPU-bound tasks.
What happens when an object is imported multiple times in Python? When a module is imported, Python checks `sys.modules` to see if it has already been loaded. If found, the cached module is reused instead of being re-executed. This ensures efficiency but can cause issues if the module's state is modified after the initial import.
How does Python handle attribute lookup with MRO? Attribute lookup in Python follows the MRO, checking the class and its base classes in the determined order. If no attribute is found, Python raises an `AttributeError`. The MRO ensures a consistent and predictable resolution path.
Explain Python’s generational garbage collection strategy. Python's garbage collector groups objects into three generations based on their lifespan. Younger objects are collected more frequently, while older objects are promoted to higher generations if they survive multiple collections. This optimization reduces the overhead of scanning long-lived objects repeatedly.