Skip to content

Threads

A native function releases the GIL, so compute in it scales across threads. busy is a 200-million-iteration loop. Under plain CPython two threads take as long as one; under ppy run they finish in half the time. It is the same file and the same threading.Thread.

Run it

python  threads.ppy
ppy run threads.ppy

What it prints

python threads.ppy

1 thread    3364.0 ms
2 threads   6794.2 ms
scaling       0.99x

ppy run threads.ppy

1 thread     130.3 ms
2 threads    129.6 ms
scaling       2.01x

The scaling line at the bottom of the output is the measurement, 1.95× here.

The kernel

@ppy.pure
@ppy.opt(3)
def busy(rounds: int) -> int:
    total: int = 0
    for i in range(rounds):
        total += i % 7
    return total

Once its arguments are unpacked, busy touches no Python object. The generated wrapper therefore wraps the call in Py_BEGIN_ALLOW_THREADS.

When the GIL stays held

  • A function with an effect that can reach the interpreter keeps the GIL.
  • A function that performs I/O is not lowered at all.
  • Borrowed buffers get the same treatment NumPy gives them: the boundary pins the memory for the whole call.

Threads inside native code

This example is Python threads calling native code. The other direction, threads and shared memory inside native code, is ppy.concurrent and ppy.atomic.

Read on: Atomics and threads · Architecture: the boundary

threads.ppy is hand-written; there is no .py source and no conversion step.

28_threads/threads.ppy

import threading
import time

import ppy


@ppy.pure
@ppy.opt(3)
def busy(rounds: int) -> int:
    total: int = 0
    for i in range(rounds):
        total += i % 7
    return total


def timed(rounds: int, workers: int) -> float:
    threads: list[threading.Thread] = []
    for _ in range(workers):
        threads.append(threading.Thread(target=busy, args=(rounds,)))
    started: float = time.perf_counter()
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    return (time.perf_counter() - started) * 1000.0


def main() -> None:
    rounds: int = 200000000
    one: float = timed(rounds, 1)
    two: float = timed(rounds, 2)
    print(f"1 thread  {one:8.1f} ms")
    print(f"2 threads {two:8.1f} ms")
    print(f"scaling   {2.0 * one / two:8.2f}x")


main()

Source: examples/28_threads.