Python: Thread Synchronization & Safety

Imagine a bunch of kids trying to play with the same toy at the same time. One grabs it, another pulls, and suddenly it’s chaos — the toy’s broken, and everyone’s crying. Threads in Python can be just like those kids. They run at the same time and often need to use the same stuff — like a variable, a list, or a counter. And if we’re not careful, they’ll step on each other’s toes.

That’s where thread synchronization comes in. It’s like giving the toy to one kid at a time, making sure no one fights or breaks anything.

In this guide, we’ll look at how to help threads “take turns” using shared data. No scary theory — just simple, hands-on ways to keep your threads safe using something called a lock. Let’s get started!

Why Synchronization Matters

Think of a cookie jar in the middle of the kitchen. Now imagine two hungry kids running to grab cookies at the same time. If they both reach in without looking, one might grab the last cookie while the other gets nothing — or worse, they both try to take it, knock the jar over, and spill everything.

That’s what happens when threads share data without rules. They can read or change something at the same time, and this can lead to wrong results, strange bugs, or data getting lost.

In Python, when threads need to work with shared data — like counting money, writing to a file, or updating a score — they must take turns. Otherwise, they’ll end up stepping on each other’s work.

To fix that, we use something called a Lock. It’s like putting a lid on the cookie jar and saying, “Only one at a time!” With a lock, a thread has to ask for permission before it touches shared data. Once it’s done, it lets the next one in.

The Lock: Keeping Things Safe

Introducing threading.Lock

A lock is like a special key for shared toys. When one thread holds the key, it gets to play without interruptions. Other threads have to wait their turn until the key is free again. This way, only one thread can change the shared data at a time — no fights, no mix-ups.

In Python, you create a lock with this simple line:

lock = threading.Lock()

That lock is your guard, making sure threads don’t jump the line.

Using with lock (The Safe Way)

The safest way to use a lock is with the with statement. It automatically grabs the key before the code runs, and gives it back after — even if something goes wrong. This means you don’t forget to release the lock and block others forever.

Here’s a fun example: two threads both add to the same number, but thanks to the lock, the count stays perfect.

import threading

lock = threading.Lock()
count = 0

def add():

    global count

    for _ in range(100000):

        with lock:  # Grab the lock before changing count
            count += 1  # Safely add 1 to count

# Create two threads doing the same job
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)

t1.start()
t2.start()

t1.join()
t2.join()

print(f"Final count: {count}")

Thanks to the lock, both threads take turns updating count without stepping on each other’s toes. The final number is exactly what we expect!

Without Lock: What Goes Wrong

Let’s see what happens if we remove the lock and let the threads fight over the shared cookie jar… I mean, the count variable.

import threading

count = 0

def add():

    global count

    for _ in range(100000):
        count += 1  # No lock here!

t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)

t1.start()
t2.start()

t1.join()
t2.join()

print(f"Final count (no lock): {count}")

Without the lock, the threads try to update count at the same time. It’s like two squirrels scrambling for nuts in the same pile — chaos! Sometimes nuts get dropped, and the final count is less than expected.

This shows why locks are important: they keep threads from stepping on each other and messing up the shared data.

Locking Custom Objects (Like a Piggy Bank)

Sometimes, your shared data lives inside a class — like a piggy bank where you keep your coins safe. When many threads try to add coins, you want to make sure the piggy bank doesn’t get confused.

Here’s how you can protect your piggy bank using a lock inside the class. Each time a thread deposits money, it grabs the lock first, so no two threads add coins at the same time.

import threading

class PiggyBank:

    def __init__(self):
        self.total = 0
        self.lock = threading.Lock()  # Lock inside the piggy bank

    def deposit(self):

        for _ in range(100000):
            with self.lock:  # Only one thread can add at a time
                self.total += 1

bank = PiggyBank()

t1 = threading.Thread(target=bank.deposit)
t2 = threading.Thread(target=bank.deposit)

t1.start()
t2.start()

t1.join()
t2.join()

print(f"Total in piggy bank: {bank.total}")

With the lock guarding your piggy bank, every coin counts — no confusion, no missing coins.

When to Use Locks

You only need locks when threads share data that can change. If each thread works on its own stuff, no locks are needed.

When you do use locks, keep the locked part as small as possible — just the moment you change shared data. This helps threads wait less and keeps things smooth.

Always use with lock: to grab and release the lock automatically. This way, you won’t forget to let go of the lock, avoiding trouble and keeping your program running nicely.

Thread-Safe Built-ins

Good news! Some Python tools, like queue.Queue, are already safe to use with many threads at once. They handle the locking for you, so you don’t have to worry about it.

Here’s a fun and easy example where different threads use a thread-safe queue.Queue. One thread puts items in, another takes them out, and we print what’s happening to watch the queue work live:

import threading
import queue
import time

# Create a thread-safe queue
q = queue.Queue()

# Producer thread: puts items into the queue
def producer():

    for item in ['apple', 'banana', 'cherry']:

        print(f"Producer adding: {item}")
        q.put(item)

        time.sleep(0.5)

# Consumer thread: takes items from the queue
def consumer():

    for _ in range(3):

        item = q.get()
        print(f"Consumer got: {item}")

        q.task_done()

        time.sleep(1)

# Start producer and consumer threads
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)

t1.start()
t2.start()

t1.join()
t2.join()

print("All items processed!")

The producer puts fruits into the queue one by one, while the consumer takes them out. Because the queue is thread-safe, both can work at the same time without any mess. The output will show the order of adding and getting fruits. You can put items in and take them out from different threads safely — like a magic mailbox for your threads.

Conclusion

Threads are like helpful little workers speeding things up — but when they share tools or toys (like data), things can get messy fast. That’s where locks come in. They help threads take turns and play nice, keeping your program safe and bug-free.

Now that you’ve seen how to use Lock and even thread-safe tools like queue.Queue, you’re ready to build your own safe, multi-threaded apps.

Just remember: fast code is great, but safe code is better. Even threads need rules to work together.