Last Updated on September 12, 2022
You can avoid common threading anti-patterns.
In this tutorial you will discover how to identify common threading anti-patterns in Python.
Let’s get started.
Threading Anti-Patterns
A programming anti-pattern is a common solution to a problem that itself introduces new problems.
Anti-patterns are programming solutions that are typically counter-productive, making things worse.
We call them patterns instead of simply “bugs” because they are implemented again and again, typically by inexperienced developers.
Anti-patterns in concurrent programming typically result in race conditions where shared data or variables become inconsistent or corrupt.
Five common threading anti-patterns are as follows:
- Relying On the GIL
- Assuming Operations Are Atomic
- Not Using The Context Manager Interface
- Not Using a Lock Consistently
- Using Sleep to Coordinate Threads
Let’s take a closer look at each of these threading anti-patterns and how to avoid them in our own programs.
Run loops using all CPUs, download your FREE book to learn how.
Anti-Pattern 1: Relying On the GIL
The Global Interpreter Lock or GIL for short is a lock used within the reference CPython Python interpreter to ensure only one thread can run at a time.
The mechanism used by the CPython interpreter to assure that only one thread executes Python bytecode at a time. This simplifies the CPython implementation by making the object model (including critical built-in types such as dict) implicitly safe against concurrent access. Locking the entire interpreter makes it easier for the interpreter to be multi-threaded, at the expense of much of the parallelism afforded by multi-processor machines.
— global interpreter lock, Python Glossary.
The GIL means that Python threads may not be appropriate for computationally intensive tasks.
However, the GIL is released in some situations, such as blocking IO and when accessing some external C-libraries like NumPy. This means that Python threads are appropriate for IO-bound tasks such as working with files and socket connections.
Nevertheless, it is often assumed that because of the GIL that race conditions are not possible between threads that share variables or resources.
This is dangerously incorrect.
Race conditions are possible when using Python threads, in the face of the GIL.
Python threads may be context switched at any time by the underlying operating system. The context switch is performed at the Python bytecode level, which is a lower level than lines of Python code.
This means if critical sections of code are not appropriately protected with concurrency primitives like mutex locks, then race conditions are possible.
A common example is the operation of adding a number to a number.
For example:
1 2 3 |
... # add a value to a number total += 2 |
This involves at least three Python bytecode operations:
- Read the current value of the variable.
- Calculate the new value of the variable.
- Assign the new value of the variable.
A context switch may occur between steps 1 and 2 or between 2 and 3. Another thread may change the value of the variable, then the thread may be switched back, in which case an incorrect value of the variable will be assigned.
You can see examples of race conditions in these tutorials:
Summary
- Problem: Assume thread race conditions are not possible because of the GIL, which is false.
- Solution: Always protect critical sections with concurrency primitives like mutex locks.
Anti-Pattern 2: Assuming Atomic
Many operations in Python are atomic.
In the context of concurrent programming with threads, atomic operations are those that either happen or don’t happen.
There is no inbetween case where the operation is half-performed and the thread is context-switched before the operation can be completed. As such, atomic operations are thread-safe.
Python instructions are only atomic at the Python bytecode level.
In most cases, it means that operations on built-in data structures like lists, sets, and dicts are atomic, such as:
- Appending to a list.
- Popping an item from a list.
- Adding an item to a dictionary
- Adding one dictionary to another.
And so on.
The problem is that many operations on primitive types, on built-in data structures, and in general are in fact compound operations consisting of many Python bytecode instructions.
The most common example is incrementing an integer, for example:
1 2 3 |
... # increment an integer i += 1 |
This is not thread safe.
Assuming all lines of Python code map onto a single atomic bytecode instruction is dangerous.
You can learn more about thread-safe atomic operations in this tutorial:
Summary
- Problem: Assume all python operations are atomic, which is false.
- Solution: Always protect critical sections with concurrency primitives like mutex locks.
Free Python Threading Course
Download your FREE threading PDF cheat sheet and get BONUS access to my free 7-day crash course on the threading API.
Discover how to use the Python threading module including how to create and start new threads and how to use a mutex locks and semaphores
Anti-Pattern 3: Not Using The Context Manager
Critical sections of code can be protected using a mutex lock such as the threading.Lock class.
A critical section may involve program state like variables, data, and resources shared among multiple threads.
If left unprotected, then updates to the shared program state may result in a race condition where data is lost, is corrupted or the program becomes inconsistent.
A mutex lock provides a way to ensure that blocks of code are executed serially by threads.
This is achieved by ensuring that access to the block is mutually exclusive among threads.
If one thread holds the lock, all other threads must wait until the lock is made available until the next thread can acquire the lock and access the protected critical section. The waiting and handing over from one thread to the next is all handled automatically within the lock.
You can learn more about mutex locks in Python in this tutorial:
A thread must call the acquire() function on the lock at the beginning of the critical section and the release() function at the end of the critical section.
For example:
1 2 3 4 5 6 7 |
... # acquire the lock lock.acquire() # critical section ... # release the lock lock.release() |
Generally, this is an anti-pattern.
The reason is that if the critical section raises an unexpected Error or Exception, the lock will never be released.
This will likely result in a deadlock as all other threads waiting to acquire the lock will not be able to acquire it and will wait forever.
The solution is to acquire the lock using the context manager interface.
This will ensure that the lock is always released automatically once the block within the context manager is exited, such as normally or by raising an Error or Exception.
For example:
1 2 3 4 5 |
... # acquire the lock with lock: # critical section ... |
This is equivalent to using a try-finally pattern.
For example:
1 2 3 4 5 6 7 8 9 |
... # acquire the lock lock.acquire() try: # critical section ... finally: # release the lock lock.release() |
This context manager interface can be used on mutex locks via the threading.Lock class, and is also available for a suite of related concurrency primitives.
Specifically, the interface is provided on the following concurrency primitives:
- Mutex locks provided by the threading.Lock class.
- Reentrant locks provided by the threading.RLock class.
- Conditions aka Monitors provided by the threading.Condition class.
- Semaphores provided by the threading.Semaphore class.
- Bounded semaphores provided by the threading.BoundedSemaphore class.
You can learn more about the context manager on concurrency primitives in this tutorial:
Summary
- Problem: Not using the context manager interface with concurrency primitives, which can be dangerous.
- Solution: Use the context manager with concurrency primitives wherever possible.
Overwhelmed by the python concurrency APIs?
Find relief, download my FREE Python Concurrency Mind Maps
Anti-Pattern 4: Not Using a Lock Consistently
A lock may be used to protect access to a shared variable that may be updated from multiple threads.
The lock must be acquired at the beginning of the critical section by calling the acquire() function and released at the end of the critical section by calling the release() function.
For example, this can be achieved using the context manager interface:
1 2 3 4 5 |
... # acquire the lock with lock: # update variable total += amount |
The problem is, if the lock is not always used whenever the variable is updated, then it will not be protected and may be subject to race conditions.
Protecting a shared variable with a lock requires an audit of the program to identify all cases where the variable is being used and update them so that it is protected by the lock appropriately.
It also requires that all future code written to interact with the variable also be protected by the lock.
Assuming that the variable is protected by the lock because one usage of the variable is protected is insufficient.
Summary
- Problem: Only protecting one or a few critical sections with a lock, which is insufficient.
- Solution: Protect all critical sections with the lock, e.g. all uses of a protected variable.
Anti-Pattern 5: Coordinating Threads With sleep()
It is common to use a background thread to load or prepare data used by another thread.
This typically requires one thread to wait for another thread to finish before the result can be retrieved.
Sometimes a call time.sleep() function is used to make one thread wait for another that is preparing the result.
For example:
1 2 3 |
... # wait for a result from another thread time.sleep(5) |
You can learn more about sleeping threads in this tutorial:
If the waiting thread finishes too soon, the solution is to simply increase the sleep time.
For example:
1 2 3 |
... # wait for a result from another thread time.sleep(10) |
Using sleep to wait for a result is an anti-pattern.
Sleep is a fragile way of trying to coordinate two threads when more robust concurrency primitives and concurrency patterns can be used instead.
If you find yourself dialing in the sleep period while waiting for a result, something is very wrong.
A better solution might be to wait for the thread that is preparing the result to terminate.
This can be achieved by joining the thread.
For example:
1 2 3 |
... # wait for new thread to terminate thread.join() |
You can learn more about joining threads in this tutorial:
Alternatively, you may want to signal a result is available from one thread to another.
This could be achieved using a threading.Barrier, threading.Condition or using a threading.Event.
Summary
- Problem: Using a sleep in one thread to wait for a result from another thread, which is fragile.
- Solution: Join the new thread or use a primitive like a barrier, condition, or an event.
Further Reading
This section provides additional resources that you may find helpful.
Python Threading Books
- Python Threading Jump-Start, Jason Brownlee (my book!)
- Threading API Interview Questions
- Threading Module API Cheat Sheet
I also recommend specific chapters in the following books:
- Python Cookbook, David Beazley and Brian Jones, 2013.
- See: Chapter 12: Concurrency
- Effective Python, Brett Slatkin, 2019.
- See: Chapter 7: Concurrency and Parallelism
- Python in a Nutshell, Alex Martelli, et al., 2017.
- See: Chapter: 14: Threads and Processes
Guides
- Python Threading: The Complete Guide
- Python ThreadPoolExecutor: The Complete Guide
- Python ThreadPool: The Complete Guide
APIs
References
Takeaways
You now know how to identify and avoid thread anti-patterns in Python.
Do you have any questions?
Ask your questions in the comments below and I will do my best to answer.
Photo by Rudi Strydom on Unsplash
Do you have any questions?