Python
with statement
threading
concurrent programming
resource management

With statement and python threading

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Python's with statement is a context manager that ensures resources are properly acquired and released, even when exceptions occur. In threading, with is used to acquire and release locks, semaphores, and conditions automatically. Writing with lock: is equivalent to lock.acquire() followed by a try/finally block with lock.release(). This pattern prevents common threading bugs like forgetting to release a lock after an exception, which would deadlock all other threads waiting for that lock.

Using with for Thread Locks

python
1import threading
2
3counter = 0
4lock = threading.Lock()
5
6# WITHOUT with statement — error-prone
7def increment_unsafe():
8    global counter
9    lock.acquire()
10    try:
11        counter += 1
12    finally:
13        lock.release()  # Must remember to release!
14
15# WITH statement — clean and safe
16def increment_safe():
17    global counter
18    with lock:  # Acquires lock, releases automatically
19        counter += 1

The with statement guarantees the lock is released even if an exception occurs inside the block.

Thread-Safe Counter Example

python
1import threading
2
3class SafeCounter:
4    def __init__(self):
5        self._count = 0
6        self._lock = threading.Lock()
7
8    def increment(self):
9        with self._lock:
10            self._count += 1
11
12    def decrement(self):
13        with self._lock:
14            self._count -= 1
15
16    @property
17    def value(self):
18        with self._lock:
19            return self._count
20
21counter = SafeCounter()
22
23def worker(n):
24    for _ in range(100000):
25        counter.increment()
26
27threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)]
28for t in threads:
29    t.start()
30for t in threads:
31    t.join()
32
33print(counter.value)  # 400000 — always correct with locking

RLock (Reentrant Lock)

An RLock allows the same thread to acquire the lock multiple times without deadlocking.

python
1import threading
2
3rlock = threading.RLock()
4
5def outer():
6    with rlock:
7        print("Outer acquired lock")
8        inner()  # Same thread can acquire again
9
10def inner():
11    with rlock:  # Would deadlock with a regular Lock
12        print("Inner acquired lock")
13
14outer()

Use RLock when a function that holds a lock calls another function that also needs the same lock.

Semaphore

A Semaphore limits the number of threads that can access a resource simultaneously.

python
1import threading
2import time
3
4# Allow at most 3 concurrent connections
5pool = threading.Semaphore(3)
6
7def access_database(thread_id):
8    with pool:  # Blocks if 3 threads already hold the semaphore
9        print(f"Thread {thread_id}: connected")
10        time.sleep(1)  # Simulate database work
11        print(f"Thread {thread_id}: disconnected")
12
13threads = [threading.Thread(target=access_database, args=(i,)) for i in range(10)]
14for t in threads:
15    t.start()
16for t in threads:
17    t.join()
18# Only 3 threads run concurrently at a time

Condition Variable

A Condition lets threads wait for a specific state change.

python
1import threading
2import time
3
4queue = []
5condition = threading.Condition()
6
7def producer():
8    for i in range(5):
9        time.sleep(0.5)
10        with condition:
11            queue.append(i)
12            print(f"Produced: {i}")
13            condition.notify()  # Wake up one waiting consumer
14
15def consumer():
16    while True:
17        with condition:
18            while not queue:
19                condition.wait()  # Release lock and wait for notify
20            item = queue.pop(0)
21            print(f"Consumed: {item}")
22        if item == 4:
23            break
24
25t1 = threading.Thread(target=producer)
26t2 = threading.Thread(target=consumer)
27t2.start()
28t1.start()
29t1.join()
30t2.join()

Event

An Event is a simple flag for thread signaling. It does not use the with statement (it has no acquire/release) but is commonly used alongside locks.

python
1import threading
2
3stop_event = threading.Event()
4
5def worker():
6    while not stop_event.is_set():
7        print("Working...")
8        stop_event.wait(timeout=1)  # Sleep 1 second or until set
9    print("Stopped")
10
11t = threading.Thread(target=worker)
12t.start()
13
14import time
15time.sleep(3)
16stop_event.set()  # Signal the thread to stop
17t.join()

Custom Context Manager for Threading

python
1import threading
2import time
3
4class TimedLock:
5    """A lock that logs how long it was held."""
6    def __init__(self, name="lock"):
7        self._lock = threading.Lock()
8        self._name = name
9
10    def __enter__(self):
11        self._lock.acquire()
12        self._start = time.monotonic()
13        return self
14
15    def __exit__(self, exc_type, exc_val, exc_tb):
16        elapsed = time.monotonic() - self._start
17        self._lock.release()
18        if elapsed > 0.1:
19            print(f"Warning: {self._name} held for {elapsed:.3f}s")
20        return False  # Don't suppress exceptions
21
22lock = TimedLock("db_lock")
23with lock:
24    time.sleep(0.2)  # Warning: db_lock held for 0.200s

Common Pitfalls

  • Forgetting to release a lock without with: If code between lock.acquire() and lock.release() throws an exception, the lock is never released and all other threads deadlock. Always use with lock: instead of manual acquire/release.
  • Holding a lock during I/O or sleep: Acquiring a lock and then performing network I/O, file I/O, or time.sleep() inside the with block starves other threads. Keep the critical section as short as possible — only protect shared state access, not I/O operations.
  • Deadlock from acquiring multiple locks in different orders: Thread A acquires lock1 then lock2; Thread B acquires lock2 then lock1 — deadlock. Always acquire multiple locks in a consistent global order, or use threading.RLock when nesting is unavoidable.
  • Using Lock when RLock is needed: If a function that holds a Lock calls another function that also tries to acquire the same Lock, the thread deadlocks on itself. Use RLock for reentrant locking scenarios.
  • GIL misconception: Python's Global Interpreter Lock (GIL) does not make your code thread-safe. The GIL prevents concurrent Python bytecode execution but does not prevent race conditions on multi-step operations (e.g., counter += 1 is multiple bytecodes). You still need explicit locks for shared mutable state.

Summary

  • Use with lock: to automatically acquire and release threading locks safely
  • Lock for simple mutual exclusion, RLock for reentrant (nested) locking
  • Semaphore limits concurrent access to a fixed number of threads
  • Condition lets threads wait for state changes with wait() and notify()
  • Keep critical sections short — do not hold locks during I/O or sleep

Course illustration
Course illustration

All Rights Reserved.