AI Hints
Home›Python Tutorial›Concurrency: Threads, Processes and Asyncio
Python · Lesson 28 of 49

Threads and Processes

Do many things at once.

⏱ 2 min read▶ Video at 1:26:44🎓 Free
Watch this lesson in the Python courseWatch on YouTube ↗Full Python course

On a busy morning, Ava's program must check the stock of fifty suppliers. Each check waits one second for a reply from the internet. One after another, that takes fifty seconds, with the computer just sitting and waiting. If the waits could overlap, the whole job would finish in about one second. That is concurrency.

Threads and Processes
From the video: Threads and Processes
  • Fifty checks, one second of waiting each
  • One after another: fifty seconds
  • Overlap the waiting: one second

Threads or processes?

Python gives you two main tools. Threads share memory, and are light, and ideal when your program mostly waits, for the network, or the disk. Processes are separate programs, each with its own memory and its own core, and are ideal for heavy calculations. Python has a rule, the global interpreter lock, that lets only one thread run Python code at a time, so threads do not speed up pure calculations.

Threads

  • share memory
  • light and quick to start
  • best when waiting (I/O)
  • limited by the GIL for maths

Processes

  • separate memory
  • heavier to start
  • best for heavy maths (CPU)
  • use every core

One after another

First, the slow way. Each supplier check sleeps for a fifth of a second, to pretend to wait for the network. Five checks in a row take one second. We measure the time, rounded, to see it clearly. This is our baseline.

slow.pypython
import time

def check(name):
    time.sleep(0.2)
    return f"{name} ok"

start = time.perf_counter()
results = [check(n) for n in ["a", "b", "c", "d", "e"]]
print(results)
print("seconds:", round(time.perf_counter() - start, 1))
Output
['a ok', 'b ok', 'c ok', 'd ok', 'e ok']
seconds: 1.0

Threads with a pool

Now the same job, with a thread pool. The ThreadPoolExecutor starts several threads, and map runs the function on all the items, collecting the results in order. The five checks now overlap, and the total time falls to about a fifth of a second. Same function, a much faster program.

threads.pypython
import time
from concurrent.futures import ThreadPoolExecutor

def check(name):
    time.sleep(0.2)
    return f"{name} ok"

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    results = list(pool.map(check, ["a", "b", "c", "d", "e"]))
print(results)
print("seconds:", round(time.perf_counter() - start, 1))
Output
['a ok', 'b ok', 'c ok', 'd ok', 'e ok']
seconds: 0.2

Race conditions and locks

Sharing memory has a danger. When two threads change the same variable at the same time, updates can be lost. This is a race condition. A lock allows only one thread at a time into a sensitive section. Here, four threads add to a counter, safely, protected by a lock, and the answer is always exactly right.

lock.pypython
import threading

count = 0
lock = threading.Lock()

def add_many():
    global count
    for _ in range(50_000):
        with lock:
            count += 1

threads = [threading.Thread(target=add_many) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(count)
Output
200000

Processes for heavy maths

For heavy calculations, use processes. The ProcessPoolExecutor runs the function in separate Python processes, on different cores, so they truly run at the same time. The main guard is required, so that new processes do not start themselves in a loop. Here we sum big ranges in parallel.

procs.pypython
from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=2) as pool:
        out = list(pool.map(heavy, [10_000, 20_000, 30_000]))
    print(out)
Output
[333283335000, 2666466670000, 8999550005000]

Parallel greetings

Your turn. Use a thread pool with the map method, to run a function that returns a greeting for each name in a list. Print the results. Ready? Here is one solution. A function, a pool, and map, collected into a list.

✍️ Exercise: Parallel greetings

Use ThreadPoolExecutor.map to call greet(name) for three names and print the results.

Show hint

pool.map(function, items)

Show solution
par_greet.pypython
from concurrent.futures import ThreadPoolExecutor

def greet(name):
    return f"Hello, {name}!"

with ThreadPoolExecutor(max_workers=3) as pool:
    print(list(pool.map(greet, ["Ava", "Sam", "Lee"])))
Output
['Hello, Ava!', 'Hello, Sam!', 'Hello, Lee!']

Summary

Nice work. Threads overlap waiting and share memory, so protect shared data with locks. Processes use many cores, for heavy calculations. Pools make both easy. Next, a third way to wait efficiently, and a favourite in modern Python, asyncio.

Key takeaways
  • Threads: waiting jobs, share memory
  • Processes: heavy maths, many cores
  • ThreadPoolExecutor / ProcessPoolExecutor
Next up: Asyncio — thousands of waits, one thread