#!/usr/bin/env python3
"""Experiment 03 — the GIL: what it protects, what it does not, and where it is going.

    python3 exp03_gil.py

Predict every output first. The race in section 2 is timing-dependent; the script
says so rather than pretending otherwise.
"""

from __future__ import annotations

import dis
import io
import multiprocessing
import sys
import threading
import time


def section(title: str) -> None:
    print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")


def claim(text: str) -> None:
    print(f"\nCLAIM: {text}")


def cpu_work(n: int) -> int:
    total = 0
    for i in range(n):
        total += i * i
    return total


# ---------------------------------------------------------------------------
def s1_bytecode() -> None:
    section("1. Why `x += 1` is not atomic — read the bytecode")
    claim("`x += 1` on a module-level int compiles to LOAD / ADD / STORE. The "
          "interpreter may switch threads between any two of them.")

    def increment():
        global counter
        counter += 1

    buf = io.StringIO()
    dis.dis(increment, file=buf)
    for line in buf.getvalue().splitlines():
        if line.strip():
            print("   " + line.rstrip())

    print("\n  Three separate operations. A thread switch between LOAD and STORE")
    print("  loses an update. list.append is ONE call into C that never releases")
    print("  the GIL mid-way, which is why it is atomic and this is not.")


# ---------------------------------------------------------------------------
counter = 0


def _race(target, threads_n: int, per_thread: int) -> float:
    threads = [threading.Thread(target=target, args=(per_thread,)) for _ in range(threads_n)]
    start = time.perf_counter()
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return time.perf_counter() - start


class SlowInt(int):
    """An int whose __add__ is a PYTHON function, so `+=` involves a call."""

    def __add__(self, other):
        return SlowInt(int(self) + other)


def _add_one(x):
    return x + 1


slow_counter = SlowInt(0)


def s2_race() -> None:
    section("2. The race — and why the textbook demo no longer reproduces")
    threads_n, per_thread = 8, 200_000
    expected = threads_n * per_thread

    claim("The classic demo — bare `counter += 1` in a tight loop — often loses "
          "NOTHING on modern CPython. Most people conclude it is atomic. It is not.")

    global counter
    counter = 0

    def bump_bare(n: int) -> None:
        global counter
        for _ in range(n):
            counter += 1

    elapsed = _race(bump_bare, threads_n, per_thread)
    print(f"  bare `counter += 1`   expected {expected:,}  got {counter:,}  "
          f"lost {expected - counter:,}   ({elapsed:.2f}s)")
    print("""
  WHY. Since CPython 3.10 the interpreter checks the 'eval breaker' — the flag
  that hands the GIL to another thread — only at SPECIFIC instructions, mainly
  backward jumps and calls. It is not checked between every bytecode. In this
  loop the check lands on JUMP_BACKWARD, which is AFTER the STORE, so the
  LOAD/ADD/STORE triple happens to be uninterrupted every time.

  It is uninterrupted BY COINCIDENCE OF CODE SHAPE, not by guarantee. Change
  the shape and the race is immediate:""")

    counter = 0

    def bump_with_call(n: int) -> None:
        global counter
        for _ in range(n):
            counter = _add_one(counter)  # a call BETWEEN the load and the store

    elapsed = _race(bump_with_call, threads_n, per_thread)
    lost = expected - counter
    print(f"\n  call in the expression   expected {expected:,}  got {counter:,}  "
          f"lost {lost:,}  ({lost / expected * 100:.1f}%)")

    global slow_counter
    slow_counter = SlowInt(0)

    def bump_slow(n: int) -> None:
        global slow_counter
        for _ in range(n):
            slow_counter += 1  # __add__ is Python, so `+=` IS a call

    elapsed = _race(bump_slow, threads_n, per_thread)
    lost = expected - int(slow_counter)
    print(f"  Python-level __add__     expected {expected:,}  got "
          f"{int(slow_counter):,}  lost {lost:,}  ({lost / expected * 100:.1f}%)")

    claim("list.append is one C call that never releases the GIL mid-way, so it "
          "loses nothing regardless of code shape.")

    shared: list[int] = []

    def appender(n: int) -> None:
        for i in range(n):
            shared.append(i)

    _race(appender, threads_n, per_thread // 10)
    print(f"  list.append   expected {threads_n * (per_thread // 10):,}  "
          f"got {len(shared):,}   <-- no loss")

    print("""
  THE LESSON, and it is the whole point of this section: "I ran it and it
  didn't lose anything" is not evidence of atomicity. The GIL protects
  INTERPRETER INTERNALS, not YOUR INVARIANTS. Whether a given read-modify-write
  happens to be interrupted depends on interpreter version, code shape, and
  operand type — none of which are part of any contract you can rely on.

  If you need atomicity, take a lock. If an interviewer asks whether `+=` is
  thread-safe, the answer is no, and this section is why the naive experiment
  will not show it to you.""")


# ---------------------------------------------------------------------------
def s3_parallelism() -> None:
    section("3. Threads give no CPU parallelism (under a GIL build)")
    claim("Splitting CPU-bound work across threads does not speed it up. "
          "Splitting it across processes does.")

    work, chunks = 3_000_000, 4

    start = time.perf_counter()
    for _ in range(chunks):
        cpu_work(work)
    sequential = time.perf_counter() - start

    start = time.perf_counter()
    threads = [threading.Thread(target=cpu_work, args=(work,)) for _ in range(chunks)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    threaded = time.perf_counter() - start

    start = time.perf_counter()
    with multiprocessing.Pool(chunks) as pool:
        pool.map(cpu_work, [work] * chunks)
    processed = time.perf_counter() - start

    print(f"  sequential : {sequential:.2f}s   (baseline)")
    print(f"  {chunks} threads  : {threaded:.2f}s   "
          f"speedup {sequential / threaded:.2f}x")
    print(f"  {chunks} processes: {processed:.2f}s   "
          f"speedup {sequential / processed:.2f}x")
    print("\n  Threads often come out SLOWER than sequential: you paid for context")
    print("  switching and got no parallelism. Processes pay startup and pickling")
    print("  costs, which is why small workloads lose there too — measure yours.")


# ---------------------------------------------------------------------------
def s4_free_threading() -> None:
    section("4. Where GIL removal actually stands")
    claim("PEP 703 designed it; PEP 779 defined 'supported'. Python 3.14 made the "
          "free-threaded build officially supported (Phase II) but NOT the default.")

    version = ".".join(str(x) for x in sys.version_info[:3])
    print(f"  running Python {version}")

    if hasattr(sys, "_is_gil_enabled"):
        print(f"  sys._is_gil_enabled() -> {sys._is_gil_enabled()}")
    else:
        print("  sys._is_gil_enabled() not present (added in 3.13)")

    print("""
  The answer to "is the GIL gone?", in the four parts that matter:

    1. WHICH BUILD.  Two builds ship. The default still has the GIL. The
       free-threaded build (python3.14t) does not. Opt-in.
    2. WHICH PHASE.  Phase I (3.13) experimental. Phase II (3.14) officially
       supported, still optional. Phase III (default) is not scheduled.
    3. WHAT IT COSTS. ~5-10% single-threaded overhead at 3.14 (down from ~40%
       at 3.13), ~15-20% more memory, ~4x on suitable CPU-bound multi-threaded
       work.
    4. WHAT IT DOES NOT FIX. `counter += 1` is still not atomic. Removing the
       GIL removes a global lock, not your data races. C extensions must opt
       in, and many still have not.

  Saying only "yes, in 3.14" is the confidently-wrong version of this answer.""")


# ---------------------------------------------------------------------------
def main() -> None:
    s1_bytecode()
    s2_race()
    s3_parallelism()
    s4_free_threading()
    print("\n" + "=" * 70)
    print("Interview form of every claim above lives in ../README.md § B3.")
    print("=" * 70)


if __name__ == "__main__":
    main()
