Python Lock With threading: Protect Shared State Safely

Quick answer: threading.Lock coordinates access to shared mutable state. Acquire it for the smallest critical section, use a with statement so release happens on exceptions, and avoid calling unknown or blocking code while holding it. A lock protects code that uses the same lock; it does not make unrelated access safe automatically.

Python Pool infographic showing threading Lock acquire critical section release timeout and shared state
A lock coordinates access to shared state; acquire it for the smallest required critical section and release it reliably with a context manager.

A Python lock is a synchronization object that lets one thread enter a protected section of code at a time. In the threading module, threading.Lock is the usual tool for guarding shared data such as counters, dictionaries, lists, caches, files, and connection state.

The official Python documentation covers threading.Lock, threading.RLock, and condition objects.

A lock is needed when a thread reads, changes, and writes shared state while other threads can do the same work. A statement such as counter += 1 looks small, but it involves several steps. Another thread can run between those steps, which creates a race condition. The result may be correct during testing and wrong under load.

The safest habit is to keep the protected section short and obvious. Do the slow work before or after the lock when possible. Hold the lock only while reading or changing the shared state that must stay consistent. This reduces contention and makes the code easier to review.

Use with lock: for most cases. It acquires the lock at the start of the block and releases it even if an exception happens inside the block. Use explicit acquire() and release() only when the control flow truly needs that style, and place release() in a finally block.

Protect Shared Data With Lock

The common pattern is to create one lock beside the shared state, then use that same lock every time the state is changed. This example starts four threads and protects the counter update.

import threading

counter = 0
lock = threading.Lock()

def add_one():
    global counter
    for _ in range(1000):
        with lock:
            counter += 1

threads = [threading.Thread(target=add_one) for _ in range(4)]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(counter)

The final value is predictable because each update runs while the lock is held. The protected block is short, so threads spend little time waiting for one another.

Use A Lock Around Related Changes

A lock can protect more than one value when those values must change together. In this example, a balance and a ledger entry are updated as one operation.

from threading import Lock, Thread

account = {"balance": 0, "ledger": []}
lock = Lock()

def deposit(amount):
    with lock:
        account["balance"] += amount
        account["ledger"].append(amount)

threads = [Thread(target=deposit, args=(10,)) for _ in range(5)]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(account["balance"])
print(account["ledger"])

Both pieces of shared data remain in sync. If a program updated the balance under one lock and the ledger under another lock, another thread could observe a half-finished state.

Python Pool infographic showing threads, shared state, lock acquire, update, and release
A lock protects a critical section that reads or updates shared state.

Acquire And Release Explicitly

with lock: is usually cleaner, but manual acquire() and release() are still part of the API. Always pair them with try and finally so the lock is released after errors.

from threading import Lock

lock = Lock()
messages = []

lock.acquire()
try:
    messages.append("protected update")
finally:
    lock.release()

print(messages)
print(lock.locked())

The final line prints False because the lock was released. Leaving a lock held by accident can stop other threads from making progress.

Use Timeouts For Optional Work

acquire() can wait forever, return immediately, or wait for a limited time. A timeout is useful when a worker should skip optional work instead of blocking the whole program.

from threading import Lock

lock = Lock()
lock.acquire()

try:
    got_lock = lock.acquire(timeout=0.01)
    print(got_lock)
finally:
    lock.release()

got_lock = lock.acquire(blocking=False)
try:
    print(got_lock)
finally:
    if got_lock:
        lock.release()

The first attempt returns False because the lock is already held. The second attempt happens after release and succeeds without waiting. In production code, check the return value before touching the protected data.

Python Pool infographic mapping a lock context manager through acquire, work, and guaranteed release
with lock: makes acquisition and release behavior explicit and exception-safe.

Use RLock For Nested Locking

threading.RLock is a reentrant lock. The same thread can acquire it more than once, which is useful when a public method and a helper method both need the same protection.

from threading import RLock

lock = RLock()
events = []

def outer():
    with lock:
        events.append("outer")
        inner()

def inner():
    with lock:
        events.append("inner")

outer()
print(events)

A normal Lock would block if the same thread tried to acquire it again before release. Use RLock only when nested acquisition is part of the design. If the code can be arranged so one layer owns the lock, a normal Lock is simpler.

Coordinate Threads With Condition

A Condition combines a lock with waiting and notification. It is useful when one thread must wait until another thread has made data available.

from threading import Condition, Thread

condition = Condition()
items = []
results = []

def consumer():
    with condition:
        while not items:
            condition.wait(timeout=1)
        results.append(items.pop(0))

def producer():
    with condition:
        items.append("job")
        condition.notify()

worker = Thread(target=consumer)
worker.start()
producer()
worker.join()

print(results)

The consumer waits in a loop because a thread should check the condition again after waking. The producer changes the shared list while holding the same condition lock, then notifies one waiting thread.

Keep Locking Rules Small

Locks work best when the ownership rule is clear: one lock protects one small group of related state. Document that rule near the state, then follow it consistently in every method that reads or changes the data.

Avoid holding a lock while doing slow network calls, shell commands, long sleeps, or heavy calculations. Holding a lock across slow work makes other threads wait even though the shared state is not being changed. Gather inputs first, enter the protected section briefly, then release the lock before doing independent work.

Also avoid acquiring multiple locks in different orders. If one thread holds lock A and waits for lock B while another thread holds lock B and waits for lock A, both can stop forever. When multiple locks are unavoidable, define one order and use it everywhere.

The practical rule is simple: use threading.Lock for shared data, prefer with lock:, use acquire() and release() with finally when manual control is needed, add timeouts when waiting should be bounded, and choose RLock only for deliberate nested locking.

Python Pool infographic comparing two locks, order, waiting, timeout, and deadlock
Consistent lock order and bounded waits reduce deadlock risk.

Protect A Shared Update

A read-modify-write sequence must be protected as one operation when multiple threads can change the same value. Create one lock shared by the workers and keep the protected block small. The context manager releases it even when the body raises.

import threading

counter = 0
counter_lock = threading.Lock()

def increment():
    global counter
    with counter_lock:
        counter += 1

Use Blocking, Non-Blocking, And Timed Acquisition

A blocking acquire waits until another thread releases the lock. Non-blocking mode lets a worker do other work when the lock is busy, while a timeout provides a bounded wait. Always release only after a successful manual acquire; the with form is safer for normal critical sections.

import threading

lock = threading.Lock()
if lock.acquire(timeout=0.2):
    try:
        print("protected work")
    finally:
        lock.release()
else:
    print("could not acquire lock in time")
Python Pool infographic testing contention, timeout, reentrancy, exceptions, and validation
Check contention, timeout policy, reentrancy, exception paths, and shared-state invariants.

Avoid Deadlocks And Long Holds

Deadlocks arise when threads wait for locks in inconsistent order or when code holds one lock while waiting for another resource. Establish a consistent acquisition order, keep I/O outside the critical section when possible, and use timeouts or higher-level queues when ownership is becoming complex.

import threading

first = threading.Lock()
second = threading.Lock()

def update_both():
    # Every caller must use this same order.
    with first:
        with second:
            print("state updated")

Choose A Higher-Level Primitive When Appropriate

Lock is the basic primitive. An Event communicates a signal, a Condition waits for a state predicate, a Semaphore limits concurrent capacity, and queue.Queue transfers work safely between threads. Pick the primitive that describes the coordination problem instead of building a protocol from flags and sleeps.

import queue
import threading

jobs = queue.Queue()
jobs.put("compile")

def worker():
    job = jobs.get()
    try:
        print("working on", job)
    finally:
        jobs.task_done()

threading.Thread(target=worker).start()
jobs.join()

Python’s threading Lock documentation defines acquire(), release(), context-manager use, timeouts, and the unlocked-release error. Use the same synchronization policy at every access boundary to shared state.

For related concurrency design, compare threading.Lock examples, threads versus processes, and a threaded GUI workflow before choosing a synchronization primitive.

Frequently Asked Questions

What is a lock in Python?

threading.Lock is a synchronization primitive that lets one thread at a time enter a protected critical section.

How do I use a Python lock correctly?

Create one shared lock and use it with the with statement around the smallest block that reads or mutates shared state.

What happens when a lock is already held?

A blocking acquire waits, while a non-blocking or timed acquire returns whether the lock was obtained; choose the behavior deliberately.

Can Python locks prevent every concurrency bug?

No. They protect the state covered by the same lock, but inconsistent lock ordering, forgotten shared access, and long critical sections can still cause problems.

Subscribe
Notify of
guest
2 Comments
Oldest
Newest Most Voted
Cathal Harte
Cathal Harte
5 years ago

lock=threading.Lock should be lock=threading.Lock()