Skip to content

Concurrency Primitives

The threading module provides low-level thread primitives. Python threads are OS-level threads Managed by the platform’s native threading implementation (pthreads on Linux, Windows threads on Windows).

import threading
import time
def worker(thread_id, duration):
print(f"Thread {thread_id} starting")
time.sleep(duration)
print(f"Thread {thread_id} finished after {duration}s")
threads = []
for i in range(5):
t = threading.Thread(target=worker, args=(i, i + 1))
threads.append(t)
t.start()
for t in threads:
t.join() # Wait for all threads to complete
print("All threads finished")
import threading
class WorkerThread(threading.Thread):
def __init__(self, name, task_data):
super().__init__(name=name)
self.task_data = task_data
self.result = None
def run(self):
print(f"{self.name} processing {self.task_data}")
self.result = f"processed_{self.task_data}"
t = WorkerThread("worker-1", "task-a")
t.start()
t.join()
print(t.result) # processed_task-a
import threading
import time
def daemon_worker():
while True:
print("Daemon running")
time.sleep(1)
def regular_worker():
time.sleep(3)
print("Regular worker done")
d = threading.Thread(target=daemon_worker, daemon=True)
r = threading.Thread(target=regular_worker)
d.start()
r.start()
## When the main thread exits, daemon threads are killed immediately
## Regular threads keep the process alive
r.join() # Wait for regular thread
# Program exits here — daemon thread is terminated
## Lock and RLock
import threading
counter = 0
lock = threading.Lock()
def increment(n):
global counter
for _ in range(n):
with lock:
counter += 1
threads = [
threading.Thread(target=increment, args=(100000,)),
threading.Thread(target=increment, args=(100000,)),
]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Counter: {counter}") # Counter: 200000

Without the lock, counter += 1 is not atomic — it involves reading, incrementing, and writing, Which can be interleaved between threads.

import threading
lock = threading.RLock()
def outer():
with lock:
print("outer acquired")
inner()
def inner():
with lock:
print("inner acquired — same thread, reentrant")
outer() # Works fine — RLock allows same thread to re-acquire
FeatureLockRLock
ReentrantNo (deadlocks)Yes
Release by any threadYesNo (must be same thread)
acquire() countingNoYes
OverheadLowerSlightly higher
## Semaphore, Event, Condition, Barrier
import threading
import time
semaphore = threading.Semaphore(3) # Allow 3 concurrent accesses
def access_resource(thread_id):
print(f"Thread {thread_id} waiting...")
with semaphore:
print(f"Thread {thread_id} acquired, working...")
time.sleep(2)
print(f"Thread {thread_id} released")
threads = [threading.Thread(target=access_resource, args=(i,)) for i in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
import threading
event = threading.Event()
def waiter(name):
print(f"{name} waiting for signal")
event.wait()
print(f"{name} received signal, proceeding")
def setter():
time.sleep(2)
print("Setting event")
event.set()
w1 = threading.Thread(target=waiter, args=("W1",))
w2 = threading.Thread(target=waiter, args=("W2",))
s = threading.Thread(target=setter)
w1.start()
w2.start()
s.start()
w1.join()
w2.join()
s.join()
import threading
import time
import random
buffer = []
buffer_lock = threading.Condition()
MAX_SIZE = 5
def producer():
for i in range(10):
with buffer_lock:
while len(buffer) >= MAX_SIZE:
buffer_lock.wait()
item = f"item_{i}"
buffer.append(item)
print(f"Produced: {item}, buffer size: {len(buffer)}")
buffer_lock.notify()
time.sleep(random.random() * 0.1)
def consumer():
for _ in range(10):
with buffer_lock:
while len(buffer) == 0:
buffer_lock.wait()
item = buffer.pop(0)
print(f"Consumed: {item}, buffer size: {len(buffer)}")
buffer_lock.notify()
time.sleep(random.random() * 0.1)
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer)
p.start()
c.start()
p.join()
c.join()
import threading
barrier = threading.Barrier(3)
def phase(thread_id):
print(f"Thread {thread_id} in phase 1")
barrier.wait()
print(f"Thread {thread_id} in phase 2")
barrier.wait()
print(f"Thread {thread_id} done")
threads = [threading.Thread(target=phase, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()

The multiprocessing module creates separate OS processes, each with its own Python interpreter and GIL. This is the primary way to achieve true parallelism for CPU-bound work in Python.

import multiprocessing
def square(x):
return x * x
if __name__ == "__main__":
with multiprocessing.Pool(4) as pool:
results = pool.map(square, range(10))
print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
### Process
import multiprocessing
import time
def compute(n):
total = 0
for i in range(n):
total += i ** 2
return total
if __name__ == "__main__":
start = time.time()
processes = []
for i in range(4):
p = multiprocessing.Process(target=compute, args=(10_000_000,))
processes.append(p)
p.start()
for p in processes:
p.join()
elapsed = time.time() - start
print(f"4 processes: {elapsed:.2f}s")
import multiprocessing
def increment(counter, lock):
for _ in range(100000):
with lock:
counter.value += 1
if __name__ == "__main__":
counter = multiprocessing.Value("i", 0) # Signed integer
lock = multiprocessing.Lock()
processes = [
multiprocessing.Process(target=increment, args=(counter, lock))
for _ in range(4)
]
for p in processes:
p.start()
for p in processes:
p.join()
print(f"Counter: {counter.value}") # Counter: 400000
import multiprocessing
def fill_array(arr, start, end):
for i in range(start, end):
arr[i] = i * i
if __name__ == "__main__":
arr = multiprocessing.Array("d", 100) # Array of doubles
size = len(arr)
chunk = size // 4
processes = [
multiprocessing.Process(target=fill_array, args=(arr, i * chunk, (i + 1) * chunk))
for i in range(4)
]
for p in processes:
p.start()
for p in processes:
p.join()
print(list(arr[:10])) # [0.0, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0, 81.0]
import multiprocessing
def sender(conn):
conn.send(["hello", "from", "sender"])
conn.close()
def receiver(conn):
data = conn.recv()
print(f"Received: {data}")
conn.close()
if __name__ == "__main__":
parent_conn, child_conn = multiprocessing.Pipe()
p1 = multiprocessing.Process(target=sender, args=(child_conn,))
p2 = multiprocessing.Process(target=receiver, args=(parent_conn,))
p1.start()
p2.start()
p1.join()
p2.join()
## concurrent.futures

concurrent.futures provides a high-level interface for asynchronously executing callables using Threads or processes.

from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def fetch_url(url):
time.sleep(1) # Simulate network latency
return f"fetched {url}"
urls = [f"http://example.com/page/{i}" for i in range(10)]
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {executor.submit(fetch_url, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
result = future.result()
print(result)
except Exception as e:
print(f"Error fetching {url}: {e}")
from concurrent.futures import ProcessPoolExecutor
import time
def cpu_bound(n):
total = 0
for i in range(n):
total += i ** 3
return total
start = time.time()
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_bound, [10_000_000] * 8))
elapsed = time.time() - start
print(f"Elapsed: {elapsed:.2f}s, results: {results[:2]}...")
from concurrent.futures import ThreadPoolExecutor
def process(item):
return item * 2
# map — simple, ordered, blocks until all complete
with ThreadPoolExecutor(max_workers=3) as executor:
results = list(executor.map(process, range(5)))
print(results) # [0, 2, 4, 6, 8]
# submit — more control, unordered results
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(process, i) for i in range(5)]
for future in futures:
print(future.result()) # May be out of order
from concurrent.futures import ThreadPoolExecutor
def may_fail(x):
if x == 3:
raise ValueError(f"Cannot process {x}")
return x * 2
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(may_fail, i) for i in range(5)]
for future in futures:
try:
print(future.result())
except ValueError as e:
print(f"Failed: {e}")
# 0, 2, Failed: Cannot process 3, 6, 8

The queue module provides thread-safe FIFO, LIFO, and priority queues.

import queue
import threading
q = queue.Queue(maxsize=10)
def producer():
for i in range(20):
q.put(f"item_{i}")
print(f"Produced item_{i}")
def consumer():
while True:
item = q.get()
print(f"Consumed {item}")
q.task_done()
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer, daemon=True)
p.start()
c.start()
p.join()
q.join() # Block until all items are processed
import queue
lq = queue.LifoQueue()
lq.put(1)
lq.put(2)
lq.put(3)
print(lq.get()) # 3 — last in, first out
print(lq.get()) # 2
import queue
pq = queue.PriorityQueue()
pq.put((2, "medium priority"))
pq.put((1, "high priority"))
pq.put((3, "low priority"))
print(pq.get()) # (1, 'high priority')
print(pq.get()) # (2, 'medium priority')
print(pq.get()) # (3, 'low priority')
## Cross-References
  • Serialization: Shows how to serialize and deserialize data for inter-process communication in multiprocessing scenarios.
  • Async/Await: Explores the asyncio event loop and coroutines as an alternative concurrency model to threads and processes.
  • File I/O: Demonstrates thread-safe file operations and how concurrency primitives protect shared file resources.
  • Error Handling: Explains how to handle exceptions in concurrent code, including thread-safe error logging and recovery.