Preparing for a Free-Threaded Python World

A Recap on Thread Safety and Synchronization Primitives

PyCon Finland 2025

About Me

  • 👨‍💻 Name: Daniel Vahla
  • 🏢 Role: Consultant at Mavericks Software
  • 🐍 Python Experience: 7 years
  • 💡 Interests: Cloud, Serverless, Python

Why This Talk Matters NOW

  • 🎉 Python 3.14 ships with GIL-free builds (opt-in via python3.14t)
  • 🔓 True parallelism = True race conditions
  • ⚠️ Code that "worked" with GIL may break without it
  • 🛠️ 7 synchronization primitives you need to know

The GIL Was Training Wheels

Previously "safe" (with GIL), now UNSAFE (without GIL):

# Integer operations - NOT atomic
counter += 1  # Read-Modify-Write race!

# Container mutations - NOT atomic  
shared_dict['key'] = value  # Multiple internal steps!
shared_list.append(item)    # NOT atomic!

# Global variables need protection
balance = 1000
balance -= 100  # UNSAFE without Lock

Without GIL: You need explicit synchronization

Our Running Example: Dice Game

Simple game to demonstrate each primitive:

  • Multiple players (threads)
  • Shared dice resource
  • Roll, accumulate score
  • Score over 20 to win

Each demo builds on the previous one

Demo 0: Race Condition

The Problem

Race Condition: The Chaos

What happens without synchronization:

  • Multiple threads access shared state
  • Non-deterministic results
  • Lost updates, corrupted data

Demo 0: Code (1/2)

from threading import Thread
import random

class Dice:
    def __init__(self):
        self.value = 0

    def roll(self):        self.value = random.randint(1, 6)
class Game:
    def __init__(self):
        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} started!")
        score = 0
        while score < 20:
            self.dice.roll()            score += self.dice.value            print(f"{player_name} score: {score}")
        print(f"{player_name} finished")

Demo 0: Code (2/2)

if __name__ == "__main__":
    game = Game()

    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

Demo 0: Results

Observations:

  • ❌ Unpredictable behavior
  • ❌ Possible data corruption
  • ❌ Non-deterministic results

Problem: Shared Dice with no protection

Demo 1: Lock

Basic Mutual Exclusion

Lock: Mutual Exclusion

Most fundamental primitive:

  • Only one thread can hold the lock at a time
  • Others wait until it's released
  • Protects critical sections

Use cases:

  • Protecting shared mutable state
  • Ensuring atomic operations on shared resources

Demo 1: Code (1/2)

from threading import Thread, Lock
import random

class Dice:
    def __init__(self):
        self.value = 0
        self.lock = Lock()
    def roll(self):
        with self.lock:            self.value = random.randint(1, 6)
class Game:
    def __init__(self):
        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} started!")
        score = 0
        while score < 20:
            self.dice.roll()            score += self.dice.value            print(f"{player_name} score: {score}")
        print(f"{player_name} finished")

Demo 1: Code (2/2)

if __name__ == "__main__":
    game = Game()

    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

Demo 1: Results

Observations:

  • ✅ Consistent results
  • ✅ No data corruption
  • ✅ Thread-safe dice rolling

Solution: Lock protects the shared Dice resource

Demo 2: RLock

Reentrant Lock

RLock: Recursive Locking

Problem with regular Lock:

  • Can't acquire same lock twice in same thread
  • Deadlock on recursive calls

RLock solution:

  • Same thread can acquire multiple times
  • Must release same number of times
  • Perfect for recursive methods

Demo 2: Code (1/2)

from threading import Thread, RLock
import random

class Dice:
    def __init__(self):
        self.value = 0
        self.lock = RLock()        self.score = 0
    def roll(self):
        time.sleep(0.01)
        with self.lock:            self.value = random.randint(1, 6)
            self.score += self.value            
            if self.value == 6:                print("Dice rolled 6! Rolling again...")                self.roll()  # Recursive call - needs RLock!            else:
                print(f"Dice rolled {self.value}")
            return self.score

Demo 2: Code (2/2)

class Game:
    def __init__(self):
        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} started!")
        score = 0
        while score < 20:
            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} score: {score}")
        print(f"{player_name} finished")

if __name__ == "__main__":
    game = Game()

    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

Demo 2: Results

Observations:

  • ✅ Recursive calls work
  • ✅ No deadlock on reentry
  • ✅ Handles "roll 6, roll again" rule

Solution: RLock allows same thread to acquire multiple times

Demo 3: Semaphore

Counting Lock

Semaphore: Limited Access

Like a lock with a counter:

  • Allows N threads to access resource
  • Others wait when counter reaches zero
  • acquire() decrements, release() increments

Use cases:

  • Connection pools
  • Resource limits (max concurrent users)
  • Rate limiting

Demo 3: Code (1/2)

# ... Skipping imports and Dice class ...

class Game:
    def __init__(self, max_players=2):        self.semaphore = Semaphore(max_players)        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} waiting to join...")
        self.semaphore.acquire()        print(f"{player_name} joined the game")
        score = 0
        while score <= 20:
            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} score: {score}")
        print(f"{player_name} reached 20 and left the game")
        self.semaphore.release()

Demo 3: Code (2/2)

if __name__ == "__main__":
    game = Game(max_players=2)    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()
        time.sleep(0.05)
    for t in threads:
        t.join()

Demo 3: Results

Observations:

  • ✅ Max 2 players in game at once
  • ✅ Others wait until slot opens
  • ✅ FIFO ordering

Solution: Semaphore(2) limits concurrent players

Demo 4: BoundedSemaphore

Defensive Semaphore

BoundedSemaphore: Bug Detection

Problem with Semaphore:

  • Can release more than acquired
  • Silent bugs, counter goes above limit

BoundedSemaphore solution:

  • Raises ValueError on over-release
  • Catches double-release bugs
  • Best practice: Always use BoundedSemaphore

Demo 4: Code (1/2)

# ... Skipping imports and Dice class ...

class Game:
    def __init__(self, max_players=2):        self.semaphore = BoundedSemaphore(max_players)        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} waiting to join...")
        self.semaphore.acquire()        print(f"{player_name} joined the game")
        score = 0
        while score <= 20:
            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} score: {score}")
        print(f"{player_name} reached 20 and left the game")
        self.semaphore.release()

Demo 4: Code (2/2)

if __name__ == "__main__":
    game = Game(max_players=2)
    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()
        time.sleep(0.05)
    for t in threads:
        t.join()
    
    # Simulating monitoring/cleanup bug    try:        game.semaphore.release()        print("ERROR: Extra release succeeded!")    except ValueError:        print("BUG CAUGHT: Tried to release beyond capacity")

Demo 4: Results

Observations:

  • ✅ Game works normally
  • ✅ Extra release caught with ValueError
  • ✅ Bug detected at source

Solution: BoundedSemaphore catches over-release bugs

Demo 5: Event

Simple Signal

Event: Boolean Flag

Simplest coordination primitive:

  • Boolean state: set or not set
  • Threads can wait() for event
  • One thread calls set() to wake all

Use cases:

  • Start signal for multiple threads
  • Shutdown signal
  • Simple one-time notifications

Demo 5: Code (1/2)

# ... Skipping imports and Dice class ...

class Game:
    def __init__(self):
        self.start_event = Event()        self.dice = Dice()
    
    def play(self, player_name: str):
        print(f"{player_name} waiting for start...")
        self.start_event.wait()  # Block until set        print(f"{player_name} started!")
        score = 0
        while score < 20:
            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} score: {score}")
        print(f"{player_name} finished")

Demo 5: Code (2/2)

if __name__ == "__main__":
    game = Game()
    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()
        time.sleep(0.05)
    
    time.sleep(0.5)    print("GO!")    game.start_event.set()  # Wake everyone!    
    for t in threads:
        t.join()

Demo 5: Results

Observations:

  • ✅ All players wait for start signal
  • ✅ One set() wakes everyone
  • ✅ Synchronized start

Solution: Event coordinates game start

Demo 6: Condition

Complex Coordination

Condition: Wait + Notify

More complex than Event:

  • Built on a Lock
  • wait() releases lock and blocks
  • notify() or notify_all() wakes waiters
  • Always use in a while loop (spurious wakeups!)

Use cases:

  • Producer-consumer patterns
  • Waiting for complex state changes
  • Conditional waiting

Demo 6: Code (1/2)

# ... Skipping imports and Dice class ...

class Game:
    def __init__(self, num_players=3):        self.condition = Condition()        self.dice = Dice()        self.num_players = num_players        self.ready_count = 0    
    def play(self, player_name: str):
        with self.condition:            self.ready_count += 1            print(f"{player_name} ready ({self.ready_count}/{self.num_players})")            while self.ready_count < self.num_players:                self.condition.wait()  # Wait for all players            self.condition.notify_all()  # Wake others        print(f"{player_name} started!")
        score = 0
        while score < 20:
            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} score: {score}")
        print(f"{player_name} finished")

Demo 6: Code (2/2)

if __name__ == "__main__":
    game = Game(num_players=3)    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()
        time.sleep(0.2)
    for t in threads:
        t.join()

Demo 6: Results

Observations:

  • ✅ Each player increments counter
  • ✅ Wait until all 3 ready
  • ✅ Last player triggers notify_all()

Solution: Condition waits for complex state (all players ready)

Demo 7: Barrier

N-Way Synchronization

Barrier: Phase Sync

Cleaner than Condition for rounds:

  • Created with party count: Barrier(n)
  • All n threads call wait()
  • None proceed until all arrive
  • Optional action callback

Use cases:

  • Multi-phase algorithms
  • Round-based games
  • Synchronizing computational stages

Demo 7: Code (1/2)

# ... Skipping imports and Dice class ...

class Game:
    def __init__(self, num_players=3, num_rounds=3):        self.dice = Dice()
        self.barrier = Barrier(num_players, action=self.round_start)        self.num_rounds = num_rounds        self.current_round = 0    
    def round_start(self):        print(f"--- Round {self.current_round} started ---")    
    def play(self, player_name: str):
        print(f"{player_name} ready!")
        score = 0
        for round_num in range(1, self.num_rounds + 1):            self.current_round = round_num            self.barrier.wait()  # All wait here            self.dice.roll()
            score += self.dice.value
            print(f"{player_name} round {round_num}: {score}")
        print(f"{player_name} finished")

Demo 7: Code (2/2)

if __name__ == "__main__":
    game = Game(num_players=3, num_rounds=3)    threads: list[Thread] = []
    for i in range(1, 4):
        t = Thread(target=game.play, args=(f"Player_{i}",))
        threads.append(t)
        t.start()
    for t in threads:
        t.join()

Demo 7: Results

Observations:

  • ✅ All players sync at barrier
  • ✅ Action callback prints round start
  • ✅ Clean phase synchronization

Solution: Barrier coordinates round-based gameplay

Summary

The 7 Synchronization Primitives

Primitive Use Case
Lock Mutual exclusion, protect shared state
RLock Reentrant lock, recursive calls
Semaphore Limit concurrent access (N threads)
BoundedSemaphore Defensive semaphore, catch bugs
Event Simple boolean signal
Condition Complex wait/notify patterns
Barrier N-way synchronization, phases

Key Takeaways

  1. 🔓 GIL-free Python is here (3.14+, opt-in via python3.14t)
  2. ⚠️ Thread safety is critical - no more GIL protection
  3. 🛠️ Know your primitives - use the right tool for the job
  4. ✅ Test NOW with python3.14t
  5. 📚 Always use BoundedSemaphore over Semaphore
  6. 🔒 When in doubt, use a Lock

Resources

Questions?

Thank you!

Switch to terminal to run: python demos/v3/0_race_condition.py

Switch to terminal to run: python demos/v3/1_lock.py

Switch to terminal to run: python demos/v3/2_rlock.py

Switch to terminal to run: python demos/v3/3_semaphore.py

Switch to terminal to run: python demos/v3/4_bounded_semaphore.py

Switch to terminal to run: python demos/v3/5_event.py

Switch to terminal to run: python demos/v3/6_condition.py

Switch to terminal to run: python demos/v3/7_barrier.py

TODO: Add your contact info