Skip to content
academia.sh

Lesson 01 / 10

The Global Interpreter Lock

This course counts overlapping steps, not duration; in an eighty-step setup, a single thread gives 80 ticks and 0 overlapping steps on every load, a single-slot regime takes at most 1 CPU step per tick, and the I/O-bound load's 26 CPU steps spread across 26 separate ticks.

Contents

The previous course spent eleven lessons measuring a single question: who answered the call, who enforced the contract. Its close exposed an assumption those measurements never wrote down — single thread. When asked which class answered, a single resolution chain ran; when asked which layer caught the violation, calls ran one finishing before the next started. Under a single thread, “who answered” had exactly one answer every time.

With more than one thread, the question changes shape. What is asked now is not who answered, but who could advance at the same time. This course counts that question over ten lessons, and starts the first at its narrowest point: how many threads can take a CPU step at once in Python’s interpreter.

The Lock Is a Number

The global interpreter lock (GIL) is a restriction the interpreter places to keep its own internal structures consistent: only one thread executes interpreter bytecode at a time. A thread not holding the lock waits. The lock is given to one thread at a time, released, and passed to another.

In this lesson’s view, the lock is not a mechanism, it is a number: how many threads can take a CPU step at once. The course’s measure name for this number is slot. With the lock in place, the slot count is one.

Places where the lock gets released do not disturb this number, because a step where the lock is released is not a CPU step to begin with. A thread reading a file, waiting on a network reply, or sleeping does not execute interpreter bytecode; the lock passes to another thread meanwhile. This is the course’s second step type: the I/O step. Slot count does not bound these.

Process, thread, scheduling, and context switching were built as mechanisms in the Operating System Concepts course and are not repeated here. The question here is not how the operating system orders threads, it is which step Python can overlap.

Why We Do Not Measure Duration

The first instinct for a concurrency measurement is comparing duration. This course does not do that, and will not in any lesson.

Real duration depends on three things at once: the machine itself, its load at the moment, and when the measurement started. All three are outside the writing. The same code run twice on the same machine gives two different numbers, and cannot be compared across two different machines. A claim written with duration cannot be reproduced where it is read.

What is needed instead is something countable: if more than one task advances in a tick, one less than the number that advanced counts as overlapped. That is the course’s measure, called the overlapping step. It comes from the setup, is read from the run, and gives the same number everywhere.

The ban is not limited to seconds. A speedup factor is a duration claim too and carries the same problem; “N times faster” says nothing unless it also states which work was measured under which condition. A lesson near the course’s end measures this from the other direction: same work, same regime, the only difference being what the measurement counts — and two different numbers come out. This lesson lays the ground for that claim; the counted unit gets named in every measurement.

The Setup

Eight tasks, ten steps each; 80 steps total. Every step is either a CPU step or an I/O step. A load is defined by its share of I/O steps: seven in ten for an I/O-bound load, five in ten for balanced, one in ten for CPU-bound.

Tasks advance tick by tick in a deterministic scheduler. Two numbers decide how many tasks can advance in one tick: worker, the number of tasks that can advance at once; and CPU slot, how many tasks can take a CPU step in one tick. A single slot means CPU steps get taken one at a time.

The measurement’s assumptions:

  • CM1 — The oracle is the setup itself: we generated the eight tasks’ step sequences, so which step is which type is known.
  • CM2 — Eight tasks, ten steps per task, eighty steps total. The three loads differ only in I/O share; step count is the same in all three.
  • CM3 — The tick is the scheduler’s unit. In one tick, a task takes at most one step.
  • CM4 — Worker is the most tasks that can advance in one tick; order is given by task index.
  • CM5 — CPU slot is how many tasks can take a CPU step in one tick. A single slot is this model’s counterpart to the global interpreter lock.
  • CM6 — An I/O step is unaffected by slots; this is the step where the lock gets released.
  • CM7 — Overlapping steps are one less than the number of tasks that advance in a tick; zero if no task advances.
  • CM8 — The single-thread regime has one worker, one slot: one task, one step per tick.
  • CM9 — Duration is never measured or written. What is counted is steps, ticks, and overlapping steps.
  • CM10trace is run‘s report twin: it makes the same decisions in the same order and does not change behavior. The output’s last line shows the two give the same number.
  • CM11 — The measurement is a single run; the seed is fixed and the setup is deterministic.
  • CM12 — This lesson looks only inside the tick. Regimes’ total tick counts are the next lessons’ measure and are not printed here.

The Measurement

"""Global interpreter lock: how many tasks can advance in one tick."""

SEED = 20260817
CPU, IO = "cpu", "io"


def make_rng(seed):
    state = seed % 2147483646 + 1

    def draw(n):
        nonlocal state
        state = (state * 48271) % 2147483647
        return state % n
    return draw


def tasks(count=8, steps=10, io_share=7, seed=SEED):
    """Each task is a step sequence; io_share/10 fraction are I/O steps."""
    draw, result = make_rng(seed), []
    for i in range(count):
        result.append([IO if draw(10) < io_share else CPU for _ in range(steps)])
    return result


def run(jobs, workers, cpu_slots):
    """Deterministic scheduler advancing tick by tick.

    workers   : how many tasks can advance at the same time
    cpu_slots : how many tasks can take a CPU step in one tick
    Returns: tick count, overlapping steps, cpu steps, io steps
    """
    remaining = [list(j) for j in jobs]
    tick = overlap = cpu_steps = io_steps = 0
    while any(remaining):
        active = [i for i, j in enumerate(remaining) if j][:workers]
        if not active:
            break
        slots_left, advanced = cpu_slots, 0
        for i in active:
            step = remaining[i][0]
            if step == CPU:
                if slots_left <= 0:
                    continue
                slots_left -= 1
                cpu_steps += 1
            else:
                io_steps += 1
            remaining[i].pop(0)
            advanced += 1
        tick += 1
        overlap += max(0, advanced - 1)
    return tick, overlap, cpu_steps, io_steps


def trace(jobs, workers, cpu_slots):
    """run's report twin: dumps the steps taken each tick, turn by turn."""
    remaining, rows = [list(j) for j in jobs], []
    while any(remaining):
        active = [i for i, j in enumerate(remaining) if j][:workers]
        if not active:
            break
        slots_left, cpu, io = cpu_slots, 0, 0
        for i in active:
            if remaining[i][0] == CPU:
                if slots_left <= 0:
                    continue
                slots_left -= 1
                cpu += 1
            else:
                io += 1
            remaining[i].pop(0)
        rows.append((cpu, io))
    return rows


LOADS = (("I/O bound", 7), ("balanced", 5), ("CPU bound", 1))

print(f"{'load':<20s} {'steps':>5s} {'cpu':>5s} {'io':>5s} "
      f"{'single-thread ticks':>20s} {'overlap':>8s}")
for label, share in LOADS:
    j = tasks(io_share=share)
    tick, overlap, cpu_steps, io_steps = run(j, 1, 1)
    print(f"{label:<20s} {sum(len(t) for t in j):5d} {cpu_steps:5d} "
          f"{io_steps:5d} {tick:20d} {overlap:8d}")

j = tasks(io_share=7)
rows = trace(j, 8, 1)
print()
print("single slot, eight workers, I/O bound load — first twelve ticks")
print(f"{'tick':>4s} {'advanced':>9s} {'cpu':>5s} {'io':>5s} {'overlap':>8s}")
for i, (cpu, io) in enumerate(rows[:12], 1):
    print(f"{i:4d} {cpu + io:9d} {cpu:5d} {io:5d} {max(0, cpu + io - 1):8d}")
print(f"steps taken in first twelve ticks {sum(cpu + io for cpu, io in rows[:12])}, "
      f"overlapping steps {sum(max(0, cpu + io - 1) for cpu, io in rows[:12])}")

print()
print(f"most cpu steps taken in one tick {max(cpu for cpu, _ in rows)}")
print(f"ticks carrying a cpu step {sum(1 for cpu, _ in rows if cpu)}, "
      f"total cpu steps {sum(cpu for cpu, _ in rows)}")
print(f"trace and run agree: cpu "
      f"{sum(cpu for cpu, _ in rows) == run(j, 8, 1)[2]}, io "
      f"{sum(io for _, io in rows) == run(j, 8, 1)[3]}")
load                 steps   cpu    io  single-thread ticks  overlap
I/O bound               80    26    54                   80        0
balanced                80    40    40                   80        0
CPU bound               80    73     7                   80        0

single slot, eight workers, I/O bound load — first twelve ticks
tick  advanced   cpu    io  overlap
   1         5     1     4        4
   2         5     1     4        4
   3         5     1     4        4
   4         5     1     4        4
   5         3     1     2        2
   6         4     1     3        3
   7         4     1     3        3
   8         5     1     4        4
   9         5     1     4        4
  10         3     1     2        2
  11         2     1     1        1
  12         1     1     0        0
steps taken in first twelve ticks 47, overlapping steps 35

most cpu steps taken in one tick 1
ticks carrying a cpu step 26, total cpu steps 26
trace and run agree: cpu True, io True

Single Thread: Load Composition Changes Nothing

The upper table’s last two columns are the same in all three rows: 80 ticks, 0 overlapping steps.

This is not a performance result, it is a definition. In the single-thread regime, worker is one; one task advances per tick, and one advancing task minus one is zero. Eighty steps make eighty ticks. Load composition has no effect: in the I/O-bound load, 54 steps could have released the lock, but with no other thread to release it to, releasing it pays off nothing.

This gives the course’s first reading: overlap is a property of the regime, not the load. The same eighty steps overlap on no load if there is no other task to advance.

The third and fourth columns, though, are the load’s own property, independent of regime. The I/O-bound load has 26 CPU, 54 I/O steps; the CPU-bound load has 73 CPU, 7 I/O steps. The total is 80 in all three. These two numbers stay fixed throughout the measurement, because the regime does not change a step’s type, only which tick it gets taken in.

Inside the Tick

The lower table opens up the single-slot regime’s first twelve ticks row by row, and this is the lesson’s real measurement.

The CPU column is 1 in all twelve rows. The most CPU steps taken in one tick is 1, and this holds for the entire run. Whatever the slot count is, that is the upper bound on CPU steps per tick; here the slot is one, so the bound is one.

The I/O column ranges between 0 and 4. Four tasks can take an I/O step in the same tick, because those steps do not use the slot. In the first tick, five tasks advance: four I/O steps, one CPU step. Four overlapping steps are earned in that tick.

With eight tasks alive, only five advancing might look odd at first. The reason is in the columns: at that tick, four tasks’ next step is a CPU step, and there is only one slot. Whichever task takes the slot advances, the other three wait. A waiting task takes no step at all that tick.

The twelfth row shows the lock at its plainest: 1 advanced, 1 CPU step, 0 overlapping steps. Only one task could advance at that tick, and its step was a CPU step. A tick like this is indistinguishable from the single-thread regime.

Worker and Slot Are Two Separate Numbers

The model has two settings, and confusing them is this topic’s most common mistake.

Worker says how many tasks can enter the queue in a tick. Entering the queue does not guarantee advancing: at the twelfth tick, two tasks were alive, one entered and advanced, the other’s step was a CPU step and the slot was taken. Slot says how many of those in the queue can take a CPU step.

Since the two are set independently, the model produces four separate regimes, and each of this course’s remaining lessons changes one of these two numbers. Opening a thread grows the worker count; since the interpreter is single, it does not grow the slot. Starting a separate process, though, means a separate interpreter, and grows the slot. Asynchronous programming takes a third path: it asks the operating system for neither worker nor slot, and leaves the queueing decision to the code itself.

This distinction matters because “concurrency” is not one thing, it is a set of choices. Adding a thread to a program does not speed it up under every load; what gets added is a worker, and the only thing a worker can earn is overlap in I/O steps. In the table above, the CPU-bound load has 7 steps that could overlap; giving it eight workers cannot push the gain past that number. How many workers earn how much is the next two lessons’ measure, but the upper bound is already written in this lesson: overlapping steps can never exceed the number of steps that could overlap.

The Step That Cannot Overlap

The last two lines give this lesson’s structural finding: ticks carrying a CPU step 26, total CPU steps 26.

The two numbers being equal is not a coincidence, it is the direct result of a single slot. Since a tick can carry at most one CPU step, 26 CPU steps have to spread across at least 26 separate ticks. The general rule: in a single-slot regime, tick count can never drop below CPU step count. With slot count y, the lower bound is CPU step count divided by y, rounded up.

This is where the course’s measurement axis comes from. Not all 80 steps of a load are equal: in the I/O-bound load, 54 steps can overlap, 26 cannot. What sets the upper bound on the gain expected from concurrency is not task count or worker count, it is the share of steps that can overlap.

Of the 47 steps taken in the first twelve ticks, 35 are overlapping. The difference between these two numbers is exactly 12 — the tick count. This is the identity that holds throughout the model: tick count is found by subtracting overlapping steps from total steps. Counting overlapping steps and counting ticks are two faces of the same measurement; this is why the course never needs duration.

Summary

  • In this course, the global interpreter lock is not a mechanism, it is a number: how many tasks can take a CPU step in one tick. With the lock in place, this number — the slot — is one.
  • I/O steps do not use the slot; these are the steps where the lock gets released, and in a single-slot regime, four tasks can take one in the same tick.
  • The single-thread regime gives 80 ticks and 0 overlapping steps on all three loads. Overlap is a property of the regime, not the load; no load overlaps without a second task to advance.
  • With a single slot, the most CPU steps taken in one tick is 1; the I/O-bound load’s 26 CPU steps spread across 26 separate ticks, and tick count cannot drop below that number.
  • Tick count is found by subtracting overlapping steps from total steps; this is why overlapping steps are enough to compare without measuring duration.

Next Step

This lesson looked inside the tick and never wrote the total tick count. The next lesson counts it: running eight workers with a single slot — the thread regime — finishes eighty steps in how many ticks? The answer is not one number, it is two — because the same mechanism behaves completely differently on an I/O-bound load versus a CPU-bound one, and where that difference comes from is already written in this lesson’s last two lines.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close