Threading in Python

by Andrew Pikler

Reading and Reading Questions

Look at the following code: hello.py. Run it several times to see what happens, then while consulting the documentation for the Python threading module (located here), answer the following questions about it:

1. What does the run() method of HelloThread do?

2. What happens when you call start() on a Thread object, like on line 13 (t1.start())? Why not call the run() method directly?

3. What does the join() method of a Thread object do, for example on line 15 (t1.join())?

4. Make sure you run hello.py several times. The printouts should not always occur in the same order. Why is this?

Additionally, read the documentation up to (and including) the section about Lock Objects. Answer the following questions:

6. How can you find out if a thread is currently running?

5. What is a daemon thread? In hello.py, which thread(s) are daemon threads and which aren't?

6. What does the term "blocks" (or "blocking") mean in the context of threads? For example, "a call to this method blocks until [some condition] is met."

Lecture Notes

Look at and run this code: xkcd.py

It downloads the HTML of xkcd comics 1 through 15. It uses a single thread of execution; the main thread puts numbers 1 through 15 on the queue, after which the run() method of ComicFetcher goes through the queue (in the same order that the numbers were inserted) and downloads the corresponding comic.

Note that the design of this program is overly complex for what it does; I wrote it the way I did to make it very similar to the next example, which is the same code but with threads.

Speaking of which, here's that code: xkcd_threads.py

This code uses the same design to download multiple comics at once. It launches 4 threads to download the comics using ComicFetcher, which is now a subclass of threading.Thread.

Notice that the run time of this is, on average, much faster than the version above with only one thread. This is because most of time in the code is spent download the comics, and doing that in series (one thread) is less efficient than doing it in parallel (multiple threads).

So, using multiple threads can significantly improve the runtime of a program. But having more than one thread isn't always beneficial, and can lead to nasty bugs. Let's investigate what can happen when threads go wrong.

Look at and run this code: counter.py

It increments a global value 20 million times, then outputs the final value (i.e., 20 million).

Like before, let's threadify it: counter_threads.py

This launches two threads, each of which increment the global value 10 million times. At the end of the program, it outputs the final value, which you would expect to be 20 million. But wait... the value it outputs is less than 20 million, and furthermore, the value differs between various runs of the program. On my machine, it's usually around 13 million.

Why does this happen?

The culprit is line 35, where value is incremented (value += 1). This one line represents three distinct actions:

1. The value (in memory) is read into a register of the CPU (let's call this R).

2. R is incremented by 1.

3. The new value of R is written back into memory, into the variable value.

The problem happens when two threads execute these steps simultaneously. In that case, they both read the same value into the CPU, then increment it, and then write back the same value into memory. In other words, even though the value should have gotten incremented twice (once by each thread), it only got incremented once. Furthermore, it is impossible to predict how many times this will happen over the course of the execution of the program, which accounts for the different output value each time the program runs.

To solve this problem, we need some way of preventing multiple threads from incrementing value at the same time. We can use something called a Lock for this: counter_threads_lock.py

This code is identical to the last version, except for line 5, where the Lock instance is created, and in the run() method of Incrementor, where the lock object is acquired and released before and after value is incremented, respectively.

Here's how it works: only one thread can hold the lock at any given time. The first thread that gets to the lock.acquire() call now has the lock. If the other thread gets to the same line before the first thread has released the lock, it is forced to wait until the lock is released. In this way, we can ensure that only one thread can increment value at a time.

Notice, though, that even though this version of the program always outputs the correct value at the end (20 million), it takes much longer to run than the version with only one thread. This is because most of the time is spent acquiring and releasing the locks.

But locks can cause problems too. Consider this code: deadlock.py

This consists of two threads, A and B, and two locks, a and b. Thread A grabs lock a, sleeps for a second, then attempts to grab lock b. Simultaneously, thread B grabs lock b, sleeps for a second, then attempts to grab lock a.

Here is the problem: when thread A attempts to grab lock b, it can't, because thread B currently has control of it. So it hangs, waiting for thread B to release lock b. Simultaneously, when thread B attempts to grab lock a, it can't, because thread A currently has control of lock a. So, both threads block, waiting for the other to do something. The execution of the program halts.

This condition is known as deadlock.

Even worse, let's look at a version of the code that doesn't have calls to sleep() in it: deadlock_loop.py

This code has the potential to deadlock, but doesn't always do so. If thread A releases lock a before thread B attempts to grab lock a, then everything is fine. Otherwise, if the program executes in the same order as in deadlock.py, we have a deadlock. deadlock_loop.py loops through the code infinitely until you get a deadlock, so you can see that the number of times it takes to get one is not consistent. You can imagine that bugs with deadlocks are very hard to reproduce and debug precisely because of this inconsistency.

To avoid a deadlock, all threads need to grab the locks in the same order: no_deadlock.py

In this code both threads A and B attempt to grab lock a, and then grab lock b. Deadlock is avoided.

tl;dr: Threads can be useful, but can also introduce nasty bugs into your code if you're not careful with them.

In-Class Activities

1. Go over the reading questions.

2. Lecture.

3. Review in detail (and run) all of the code in the lecture notes, making sure that you understand everything that's going on in them. Discuss in small groups to make sure everyone understands.

4. You might have noticed that you can't use Ctrl+C to quit a multithreaded Python program. Use the method described in the first answer here to fix deadlock.py so that Ctrl+C does end the program. (Solution: deadlock_quit.py)

5. Quiz.

6. Work on the homework.