Threads
Problem
Three reports, one at a time
time.sleep(seconds) stops the program for that many seconds.import time
def download(file, seconds): time.sleep(seconds) print(f"Downloaded {file}")
download("march.pdf", 0.6)download("april.pdf", 0.2)download("may.pdf", 0.4)print("Done")Downloaded march.pdfDownloaded april.pdfDownloaded may.pdfDoneThe idea
Process and thread
When you start the program, the operating system gives it a process: its own memory, its own open files. Inside the process, one or more threads run. A thread is a sequence of commands that run one after another. So far, each of your programs had one thread: the one that runs the file from top to bottom.
The threads in one process share their memory: they see the same objects. That makes them handy (they can easily pass data to each other) and dangerous (they can mess each other up, as you will see in a moment).
- Parallel means really at the same time, on different cores of the processor.
- Concurrent means on one core, taking turns so fast that it looks like the same time.
For the programmer the difference rarely matters: in both cases you do not know which thread will go on when.
Python (CPython, the one from python.org) has a catch: the GIL (global interpreter lock). Only one thread at a time runs Python code, even if the processor has many cores. So threads help when the program waits (network, files, sleep): while one waits, another one works. For heavy calculations threads do not speed things up. There you use processes, which you will see in the exercise.
More precisely, if you are curious
- The Chrome browser starts a separate process for each tab. That is why one frozen site does not stop the others, and also why the browser uses a lot of memory.
- Every program that does several things at once uses threads: a game plays the sound in one thread and runs the logic in another; a web server serves each client in its own thread.
- The price: each thread has its own stack, and the memory for it is set aside separately. Thousands of threads cost a lot. That is why real programs use a thread pool (the last idea in the lesson). - Python 3.13 also has a separate build without the GIL (free-threaded). It is still experimental, so here we write about ordinary Python.
See it
The first thread
The Thread class from the threading module is a thread. With target you tell it which function to run, and with args which arguments to pass (a tuple):
worker = threading.Thread(target=download, args=("april.pdf", 0.2))worker.start()print("The program does not wait")worker.join()print("The report is downloaded")The program does not waitDownloaded april.pdfThe report is downloadedstart()starts the new thread and returns right away. The program goes on without waiting, so "The program does not wait" is printed first.join()makes the program wait for the thread to finish.
download is the function from the first card. Python has no checked exceptions, so there is nothing to declare here.
More precisely, if you are curious
target is the shortest way, but not the only one. You will also come across a subclass of Thread that writes its own run(), and a lambda:class ReportThread(threading.Thread): def run(self): print("1. a subclass of Thread")
def report_job(): print("2. a function as target")
a = ReportThread()b = threading.Thread(target=report_job)c = threading.Thread(target=lambda: print("3. a lambda"))for t in [a, b, c]: t.start() t.join()1. a subclass of Thread2. a function as target3. a lambdaSee it
Three at once
start = time.time()march = threading.Thread(target=download, args=("march.pdf", 0.6))april = threading.Thread(target=download, args=("april.pdf", 0.2))may = threading.Thread(target=download, args=("may.pdf", 0.4))march.start()april.start()may.start()march.join()april.join()may.join()seconds = time.time() - startprint(f"Done in under a second: {seconds < 1}")Downloaded april.pdfDownloaded may.pdfDownloaded march.pdfDone in under a second: TrueThe threads start in the order March, April, May, but they finish in the order of the pauses: April (0.2 s), May (0.4 s), March (0.6 s). The whole wait is as long as the longest one, 0.6 seconds instead of 1.2.
The operating system decides the order in which the threads reachprint. Here the pauses are different enough, so the order is the same. If they were equal, it could be different on every run.Try it
start or run
What will the program print? (name is the name of the thread)
class Report(threading.Thread): def run(self): print(f"Running in: {threading.current_thread().name}")
t = Report(name="downloader")t.run()t.start()t.join()t.run() is an ordinary method call: the code runs right away, in the main thread, which Python calls MainThread. There is no new thread. Only t.start() starts the thread "downloader", which runs the same run(). Writing run() instead of start() is a common mistake: the program works, only nothing runs at the same time.The idea
The life of a thread
is_alive() tells you whether the thread is running right now: from start() to the end of its function:t = threading.Thread(target=download, args=("may.pdf", 0.5))print(t.is_alive())t.start()time.sleep(0.1)print(t.is_alive())t.join()print(t.is_alive())try: t.start()except RuntimeError as e: print(f"RuntimeError: {e}")FalseTrueDownloaded may.pdfFalseRuntimeError: threads can only be started oncePython does not tell you more than this: you cannot see whether it is waiting or calculating. A thread can be started only once. A second start() on the same thread raises RuntimeError.
More precisely, if you are curious
- A thread in Python cannot be stopped from outside. If it has to stop, it checks a signal itself, for example a
threading.Eventthat another thread raises withset(). nameandnative_idare the name and the number of the thread.threading.current_thread()gives you the thread the code is running in.time.sleep(0)hints that the thread can give way to another one. We will use it in a moment.
Problem
The lost visits
increment() 10,000 times. Between reading and writing the counter, the thread does something else. Here that is time.sleep(0): "let the other threads run for a moment":class Counter: def __init__(self): self.count = 0
def increment(self): value = self.count time.sleep(0) # here the thread gives way to the other one self.count = value + 1
visits = Counter()
def work(): for i in range(10_000): visits.increment()
first = threading.Thread(target=work)second = threading.Thread(target=work)first.start()second.start()first.join()second.join()print(f"Visits: {visits.count}")You expect 20,000. Five runs:
Visits: 10000Visits: 10001Visits: 10001Visits: 10000Visits: 10000Less every time, almost by half. The reason is that the increase is not one step, but three:
value = self.count # 1. readvalue = value + 1 # 2. add 1self.count = value # 3. writeclass Counter: def __init__(self): self.count = 5 self.lock = threading.Lock()
def increment(self): self.count += 1 # read, add 1, write
def safe_increment(self): with self.lock: self.count += 1Step by step, in words
- count is 5. Threads A and B call increment() at the same time.
- A reads count: 5.
- B reads count before A has written: also 5.
- A adds 1 in its own copy and writes 6.
- B writes 6 too. Two calls, and count is 6, not 7: one of the additions is lost.
- Once more, now using with self.lock: A takes the lock, reads 5 and writes 6.
- B reaches with self.lock and waits until A releases the lock.
- B takes the lock, reads 6 and writes 7: nothing is lost.
When both threads read the same value before either of them has written, one of the additions is lost. This is called a race condition. Whether it happens depends on when the threads are switched, so the result is different on every run.
More precisely, if you are curious
Without time.sleep(0), with self.count += 1 and 100,000 increases per thread, five runs in Python 3.12 gave 200,000 five times:
class Counter: def __init__(self): self.count = 0
def increment(self): self.count += 1Visits: 200000Visits: 200000Visits: 200000Visits: 200000Visits: 200000This is not because it is safe. The GIL switches threads only at certain points in the execution, and here it happens not to be between the read and the write. Python does not promise this: another version, other code in the method or Python without the GIL will lose visits. So shared data is always protected with a lock.
The idea
Lock: one at a time
threading.Lock, does: the block with self.lock: is for only one thread at a time:class Counter: def __init__(self): self.count = 0 self.lock = threading.Lock()
def increment(self): with self.lock: value = self.count time.sleep(0) self.count = value + 1Visits: 20000The thread takes the lock when it enters with and releases it when it leaves, even on an exception. The other thread waits in front of with. Now the result is 20,000 on every run. Python has no ready-made atomic counter like Java: the lock is the way.
More precisely, if you are curious
Condition:wait()andnotify_all(): a thread in the with block of a Condition can release the lock and go to sleep until another thread wakes it up. That way the kitchen waits for an order, and the checkout waits for the kitchen to take the previous one (the "producer and consumer" problem):
class OrderBox: def __init__(self): self.order = None self.changed = threading.Condition()
def put(self, new_order): with self.changed: while self.order is not None: self.changed.wait() self.order = new_order self.changed.notify_all()
def take(self): with self.changed: while self.order is None: self.changed.wait() result = self.order self.order = None self.changed.notify_all() return result
box = OrderBox()
def kitchen(): for i in range(3): print(f"The kitchen took {box.take()}")
cook = threading.Thread(target=kitchen)cook.start()box.put("pizza")box.put("salad")box.put("soup")cook.join()The kitchen took pizzaThe kitchen took saladThe kitchen took soupwait() is in a while, not in an if: the thread can wake up for no reason, so it checks the condition again. queue.Queue(maxsize=1) does the same out of the box: put waits if the queue is full, and get waits if it is empty.
- Deadlock: two threads wait for each other forever. The office holds the printer and waits for the scanner, and the front desk holds the scanner and waits for the printer:
printer = threading.Lock()scanner = threading.Lock()
def office(): with printer: time.sleep(0.1) with scanner: print("The office is copying")
def reception(): with scanner: time.sleep(0.1) with printer: print("The front desk is copying")The program hangs and never prints anything. The way to avoid it is for all the threads to take the locks in the same order.
- Threads in Python have no priorities. There are daemon threads (
daemon=True): the program ends without waiting for them:
report = threading.Thread(target=download, args=("annual.pdf", 0.1), daemon=True)print(report.name, report.daemon)Thread-1 (download) TrueTry it
Two ATMs
Pick the line with the bug.
The check self.balance >= amount and the withdrawal are not one whole. If the thread is switched between them, both see 100 and both withdraw. With the GIL this happens rarely, which makes the bug sneaky: it passes a thousand times and fails at the customer's. The fix is a lock around the two lines:
class Account: def __init__(self): self.balance = 100 self.lock = threading.Lock()
def withdraw(self, amount): with self.lock: if self.balance >= amount: self.balance -= amountThen the second ATM waits for the first one, sees €20 and refuses. The balance stays €20.
The idea
Thread pool and Future
ThreadPoolExecutor (from concurrent.futures):def size_of(file, seconds): time.sleep(seconds) return len(file) * 100files = ["march.pdf", "april.pdf", "may.pdf", "june.pdf"]with ThreadPoolExecutor(max_workers=2) as pool: sizes = [pool.submit(size_of, file, 0.3) for file in files] for file, size in zip(files, sizes): print(f"{file}: {size.result()} KB")march.pdf: 900 KBapril.pdf: 900 KBmay.pdf: 700 KBjune.pdf: 800 KBThreadPoolExecutor(max_workers=2)makes a pool of 2 threads. The four tasks take turns in them.submit(function, arguments)takes a task and right away gives you aFuture: a promise that the result will come.result()waits for the task to finish and returns the result. If the task raised an exception,result()raises it again.withshuts the pool down at the end: it waits for the tasks to finish and stops its threads.
More precisely, if you are curious
Future. The end of with waits for all of them:with ThreadPoolExecutor(max_workers=2) as pool: for i in range(1, 6): pool.submit(download, f"report-{i}.pdf", 0.1)print("All done.")Downloaded report-2.pdfDownloaded report-1.pdfDownloaded report-3.pdfDownloaded report-4.pdfDownloaded report-5.pdfAll done.with, you write pool.shutdown(wait=True) at the end.Try it
Which result comes first?
"archive.zip" takes 0.6 s to download, and "note.txt" takes 0.1 s. What will the program print?
with ThreadPoolExecutor(max_workers=2) as pool: big = pool.submit(size_of, "archive.zip", 0.6) small = pool.submit(size_of, "note.txt", 0.1) print(f"Big: {big.result()}") print(f"Small: {small.result()}")The small file really is ready first. But big.result() is the first line, and it waits for the big one. When that one comes, small.result() returns right away, because the result is already there. The order of the output is the order of the result() calls, so it is the same on every run.
Exercise
Programs with threads
Solve these in your editor.
1. Parallel sum. The list sales has 1,000,000 sales, sales[i] = i % 100. Split it into 4 equal parts and sum each one in its own thread. Each thread writes to its own place in the list partial, so you do not need synchronization. Wait for them with join and add up the parts:
Parts: [12375000, 12375000, 12375000, 12375000]Total: 49500000More precisely, if you are curious
Because of the GIL, the four threads do not calculate faster than one. Really parallel is with processes: ProcessPoolExecutor starts separate copies of Python, each with its own GIL. The parts are passed to them, and the result comes back:
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__": sales = [i % 100 for i in range(1_000_000)] size = len(sales) // 4 chunks = [sales[p * size:(p + 1) * size] for p in range(4)] with ProcessPoolExecutor(max_workers=4) as pool: partial = list(pool.map(sum, chunks)) print(f"Parts: {partial}") print(f"Total: {sum(partial)}")Parts: [12375000, 12375000, 12375000, 12375000]Total: 49500000if __name__ == "__main__": keeps the main code from running again in each process: on Windows and macOS a process starts by loading the file from the beginning.
2. Downloading with a pool. Download six reports (march.pdf, april.pdf, may.pdf, june.pdf, july.pdf, august.pdf) with a pool of 3 threads. Use size_of(file, 0.3) from the pool card. Print the sizes in the order of the files, the total, and whether it all took under 1.5 seconds (one after another would be 1.8 s):
march.pdf: 900 KBapril.pdf: 900 KBmay.pdf: 700 KBjune.pdf: 800 KBjuly.pdf: 800 KBaugust.pdf: 1000 KBTotal: 5100 KBUnder 1.5 seconds: True3. A counter without losses. Four threads, each with 50,000 visits, increase a shared counter. Write the counter so that the result is always 200,000. First try it without a Lock and with time.sleep(0) between the read and the write and see how much is lost, then fix it with a Lock.
Visits: 200000More tasks
- A test for the counter. Write a unittest test that starts 8 threads with 10,000 increases each and checks for 80,000 with
assertEqual. Run it a few times against the counter withtime.sleep(0)and without a Lock. A test that fails only sometimes is the typical sign of a race condition. - The deadlock. Run the example with the printer and the scanner from "More precisely" (with two threads and
join) and watch it hang. Fix it so that both threads take the printer first.
Test