Skip to content

Instantly share code, notes, and snippets.

@REASY
Created December 15, 2025 03:51
Show Gist options
  • Select an option

  • Save REASY/4010e427ac377ca2e25023b6c02bbd4f to your computer and use it in GitHub Desktop.

Select an option

Save REASY/4010e427ac377ca2e25023b6c02bbd4f to your computer and use it in GitHub Desktop.
Python: Multi-Threading with GIL and Free-Threading
import threading
import sys
import time
import os
from typing import List, NamedTuple
# Optional import for visualization
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
# --- Configuration ---
CHUNK_SIZE = 1_000 # Smaller chunks for better resolution
TOTAL_ITERS = 20_000_000 # Total work per thread
NUM_THREADS = 8 # Keep it clean
class TaskEvent(NamedTuple):
thread_id: int
start_time: float
end_time: float
class CpuBoundTask:
def __init__(self, thread_id: int, collector: ThreadSafeResultCollector):
self.thread_id = thread_id
self.num_chunks = TOTAL_ITERS // CHUNK_SIZE
self.collector = collector
def run(self) -> None:
local_events: List[TaskEvent] = []
for _ in range(self.num_chunks):
t0 = time.perf_counter()
# Burn CPU
val = 0
for i in range(CHUNK_SIZE):
val += i % 256
t1 = time.perf_counter()
local_events.append(TaskEvent(self.thread_id, t0, t1))
self.collector.add_result(local_events)
class ThreadSafeResultCollector:
def __init__(self) -> None:
self._results: List[TaskEvent] = []
self._lock = threading.Lock()
def add_result(self, events: List[TaskEvent]) -> None:
with self._lock:
self._results.extend(events)
def get_results(self) -> List[TaskEvent]:
with self._lock:
# Return a shallow copy to prevent external modification issues
return list(self._results)
def plot_timeline(history: List[TaskEvent]):
if not history:
print("No history recorded.")
return
if not HAS_MATPLOTLIB:
print("Error: matplotlib is not installed. Visualization disabled.")
return
# Normalize times
min_t = min(e.start_time for e in history)
max_t = max(e.end_time for e in history)
total_duration_ms = (max_t - min_t) * 1000
# Setup Plot
fig, ax = plt.subplots(figsize=(12, 6))
# Distinct colors for threads
colors = plt.cm.get_cmap("tab10", NUM_THREADS)
# Plot each chunk as a horizontal bar
# (y, width, height, left)
bar_height = 0.8
print("Rendering plot...")
for tid in range(NUM_THREADS):
events = sorted(
[e for e in history if e.thread_id == tid], key=lambda x: x.start_time
)
if not events:
continue
# Add broken barh
ranges = []
for e in events:
start_norm = (e.start_time - min_t) * 1000 # Convert to ms
duration = (e.end_time - e.start_time) * 1000
ranges.append((start_norm, duration))
ax.broken_barh(
ranges, (tid - bar_height / 2, bar_height), facecolors=colors(tid)
)
# Detect GIL status for title
is_gil_enabled = True
if hasattr(sys, "_is_gil_enabled"):
is_gil_enabled = sys._is_gil_enabled() # type: ignore
mode = (
"Standard (GIL Enabled)" if is_gil_enabled else "Free-Threading (GIL Disabled)"
)
ax.set_yticks(range(NUM_THREADS))
ax.set_yticklabels([f"Thread {i}" for i in range(NUM_THREADS)])
ax.set_xlabel("Time (milliseconds)")
# Updated title with total duration
title = (
f"Python Thread Scheduling Visualization\n"
f"{sys.version.split()[0]} - {mode}\n"
f"Total Duration: {total_duration_ms:.2f} ms"
)
ax.set_title(title)
ax.grid(True, axis="x", linestyle="--", alpha=0.7)
plt.tight_layout()
plt.show()
def report_text(history: List[TaskEvent]):
if not history:
print("No history recorded.")
return
min_t = min(e.start_time for e in history)
max_t = max(e.end_time for e in history)
total_duration = max_t - min_t
print("-" * 40)
print(f"Total Execution Time: {total_duration:.4f}s")
print("-" * 40)
# Calculate per-thread active time
thread_durations = {}
for e in history:
thread_durations[e.thread_id] = thread_durations.get(e.thread_id, 0.0) + (
e.end_time - e.start_time
)
print("Active CPU Time per Thread:")
for tid in sorted(thread_durations.keys()):
print(f" Thread {tid}: {thread_durations[tid]:.4f}s")
def main() -> None:
# Detect GIL status
is_gil_enabled = True
if hasattr(sys, "_is_gil_enabled"):
is_gil_enabled = sys._is_gil_enabled() # type: ignore
mode = "Standard (GIL)" if is_gil_enabled else "Free-Threading (No GIL)"
print(f"Python: {sys.version}")
print(f"Mode: {mode}")
print(f"Running {NUM_THREADS} threads...")
collector = ThreadSafeResultCollector()
threads = [
threading.Thread(target=CpuBoundTask(i, collector).run)
for i in range(NUM_THREADS)
]
start_time = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
# Check env var. Default to False (Text only) unless explicitly set to '1' or 'true'
visualize_env = os.environ.get("VISUALIZE", "0").lower()
should_visualize = visualize_env in ("1", "true", "yes", "on")
if should_visualize:
plot_timeline(collector.get_results())
else:
report_text(collector.get_results())
print("\nTip: Set VISUALIZE=1 to see the Matplotlib graph.")
if __name__ == "__main__":
main()
@REASY
Copy link
Copy Markdown
Author

REASY commented Dec 15, 2025

(python-gil) ➜  python-gil uv run --python 3.14t /usr/bin/time -pv python gil_v11.py
Python: 3.14.2 free-threading build (main, Dec  9 2025, 19:03:17) [Clang 21.1.4 ]
Mode:   Free-Threading (No GIL)
Running 8 threads...
----------------------------------------
Total Execution Time: 0.3225s
----------------------------------------
Active CPU Time per Thread:
  Thread 0: 0.3104s
  Thread 1: 0.3168s
  Thread 2: 0.3081s
  Thread 3: 0.3130s
  Thread 4: 0.3119s
  Thread 5: 0.3074s
  Thread 6: 0.3081s
  Thread 7: 0.3048s

Tip: Set VISUALIZE=1 to see the Matplotlib graph.
        Command being timed: "python gil_v11.py"
        User time (seconds): 2.50
        System time (seconds): 0.01
        Percent of CPU this job got: 658%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.38
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 61344
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 14107
        Voluntary context switches: 185
        Involuntary context switches: 45
        Swaps: 0
        File system inputs: 0
        File system outputs: 0
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0
(python-gil) ➜  python-gil uv run --python python /usr/bin/time -pv python gil_v11.py
Python: 3.14.2 (main, Dec  9 2025, 19:03:28) [Clang 21.1.4 ]
Mode:   Standard (GIL)
Running 8 threads...
----------------------------------------
Total Execution Time: 2.2919s
----------------------------------------
Active CPU Time per Thread:
  Thread 0: 1.5147s
  Thread 1: 2.1062s
  Thread 2: 1.8900s
  Thread 3: 1.7269s
  Thread 4: 1.7083s
  Thread 5: 0.8628s
  Thread 6: 2.0455s
  Thread 7: 1.6888s

Tip: Set VISUALIZE=1 to see the Matplotlib graph.
        Command being timed: "python gil_v11.py"
        User time (seconds): 4.42
        System time (seconds): 0.03
        Percent of CPU this job got: 177%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 0:02.51
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 92320
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 18225
        Voluntary context switches: 3705
        Involuntary context switches: 274
        Swaps: 0
        File system inputs: 0
        File system outputs: 0
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

@REASY
Copy link
Copy Markdown
Author

REASY commented Dec 15, 2025

FreeThreading GIL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment