This weeks PyPuzzle will help you understand the Global Interpreter Lock (GIL) in Python and how it affects multithreading, especially with CPU-bound tasks.

Topics Covered

  • Global Interpreter Lock (GIL)
  • Threading and CPU-bound tasks
  • Race conditions

Feel free to use an online Python compiler and interpreter like [this] (https://www.online-python.com/) to try running the code yourself. The answer is supplied below the code.

Question

Given the following code, what do you expect the final value of counter to be after both threads finish? Run the code multiple times to observe any variations. What does this tell you about the GIL’s impact on multithreading in Python?

import threading
import time

# Global counter
counter = 0

def increment():
    global counter
    for _ in range(1000000):
        counter += 1

def run_threads():
    global counter
    counter = 0  # reset the counter before each run

    # Create two threads that both increment the counter
    thread1 = threading.Thread(target=increment)
    thread2 = threading.Thread(target=increment)

    start_time = time.time()

    # Start both threads
    thread1.start()
    thread2.start()

    # Wait for both threads to complete
    thread1.join()
    thread2.join()

    print("Final counter:", counter)
    print("Time taken:", time.time() - start_time)

# Run the threads
run_threads()

Hints

  1. Think about the Global Interpreter Lock (GIL): Can both threads execute Python code at exactly the same time?
  2. Why might counter not equal 2,000,000, even though each thread attempts to increment it by 1 million?
  3. Try running the code multiple times. Does the output stay consistent?
  4. Quickly research what a CPU-bound task is. How does it differ from an I/O-bound task?

Answer

Due to the GIL and lack of thread-safety, the expected output (2,000,000) is often not achieved. Instead, you’ll typically see a value lower than 2,000,000 due to race conditions. The threads compete to update counter, but because of the GIL, only one thread can execute at a time. As they interleave, they may overwrite each other’s updates, resulting in a lower counter value.

Expected output (specific numbers subject to change):

Final counter: 1323157
Time taken: 0.6897156238555908

Learnings

  1. Global Interpreter Lock (GIL): The GIL prevents true parallelism in Python for CPU-bound tasks because only one thread can execute Python bytecode at a time.
  2. CPU-bound Tasks: For tasks that require heavy CPU processing, the GIL limits the benefit of adding more threads. For such tasks, consider using multiprocessing to achieve true parallelism across multiple CPU cores.
  3. Race Conditions: Multithreading with shared resources like counter can lead to race conditions, where threads interfere with each other’s updates, producing inconsistent results.
  4. Performance and Consistency: The GIL introduces trade-offs between safety and performance in multithreading, especially for CPU-bound tasks, underscoring why threading in Python is more effective for I/O-bound rather than CPU-bound workloads.

Further Reading

  • CPU-bound tasks are those that spend most of their time waiting for the CPU to finish processing instructions (and thus most of their time hogging the GIL). This is compared to Input/Output (I/O)-bound tasks which spend most of their time waiting for an external event (e.g., network response, reading from a file, database access) to continue the task (and thus can free-up the GIL for a different task).

  • The Global Interpreter Lock (GIL) is a safety mechanism in Python that prevents having multiple threads running at the same time (even if you have multiple CPU cores available where each can hold a single thread at a time).

  • Interestingly, the GIL is mainly a feature of CPython (the default implementation of Python that is written using the C programming language). There are other implementations of Python like PyPy, Jython, and IronPython that are written in other programming languages like Python itself, Java, and C#, respectively. For multithreading, C is not an inherently safe programming language as C allows direct memory access. This means that if two threads were running simultaneously on the same memory, they could access a common memory address that is shared between them. And since processes don’t always take the exact same amount of time to run, this can lead to race conditions and combined with shared memory this would lead to unexpected behaviour, memory leaks, or even crashes. Additionally, since the main implementation of Python, CPython, is based on C, this means that the main implementation of Python is also not inherently safe for multithreading. Thus, the GIL was implemented to ensure only one thread executes at any given time to prevent simultaneous modifications of shared memory.

  • Python could be made safe for multithreading by modifying the underlying CPython code but this would add a significant computational overhead to all Python programs, even the ones that don’t use multithreading (which turns out to be the majority). Being a reliable and thread safe language out-of-the-box is also appealing to extension developers like NumPy and Pandas as it means they don’t need to worry about thread safety themselves.