DEV Community

Cover image for Python Concurrency: Processes, Threads, and Coroutines Explained
Sushant Gaurav
Sushant Gaurav

Posted on

Python Concurrency: Processes, Threads, and Coroutines Explained

Difference between process, thread, coroutine

Process

A process is an independent unit of execution with its own memory space. Communication between processes requires inter-process communication (IPC) mechanisms.

Example: Running python app.py twice creates two separate processes.

from multiprocessing import Process

def worker(num):
  print(f"Process {num} running")

if __name__ == "__main__":
  p1 = Process(target=worker, args=(1,))
  p2 = Process(target=worker, args=(2,))
  p1.start()
  p2.start()
  p1.join()
  p2.join()

# Output:
# Process 2 running
# Process 1 running
Enter fullscreen mode Exit fullscreen mode

Thread

A thread is a lightweight unit of execution inside a process. Multiple threads within the same process share the same memory space.

Example: Web browsers use multiple threads (one for UI, one for network, etc.).

from threading import Thread

def task(name):
    print(f"Thread {name} is running")

t1 = Thread(target=task, args=("A",))
t2 = Thread(target=task, args=("B",))
t1.start()
t2.start()
t1.join()
t2.join()

# Output:
# Thread A is running
# Thread B is running
Enter fullscreen mode Exit fullscreen mode

Coroutine

A coroutine is a special function that can pause and resume execution. They are cooperative, i.e. they yield control instead of being preempted like threads. They are used in async programming.

Note: Coroutines run on a single thread but handle many tasks by pausing (await) instead of blocking.

Example:

import asyncio

async def task(name):
    print(f"Coroutine {name} started")
    await asyncio.sleep(1)
    print(f"Coroutine {name} finished")

async def main():
    await asyncio.gather(task("X"), task("Y"))

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Creating, starting, and stopping threads with the threading module

Creating and starting threads

Threads in Python are created using the Thread class from the threading module.

The typical lifecycle of a thread looks like this:

  1. Create an object of the Thread class.
  2. Start the thread using .start().
  3. The thread runs the target function or its run() method.

Note: The .join() method can be used to wait for the thread to finish. It is optional but recommended.

Example:

from threading import Thread
import time

def worker():
    print("Thread started")
    time.sleep(2)  # simulate some work
    print("Thread finished")

# Step 1: Create a thread with a worker function as a target
t = Thread(target=worker)
# Step 2: Start the thread
t.start()
# Step 3 (optional but good practice): Wait for the thread to finish
t.join()

print("Main program finished")
Enter fullscreen mode Exit fullscreen mode

Output:

Thread started
Thread finished
Main program finished
Enter fullscreen mode Exit fullscreen mode

Stopping threads

Python does not provide a direct method to stop threads. This is intentional because abruptly killing a thread can leave resources in an inconsistent state (for example, file handles, database connections).

Instead, the common pattern is to use a shared flag that the thread checks regularly to know when to exit.

Example:

from threading import Thread
import time

# Shared flag to tell the thread when to stop
stop_thread = False

def worker():
    while not stop_thread:
        print("Working...")
        time.sleep(1)
    print("Thread stopping gracefully")

# Start the thread
t = Thread(target=worker)
t.start()
# Let it run for a few seconds
time.sleep(3)
# Change flag -> signals thread to stop
stop_thread = True
# Wait for the thread to finish
t.join()

print("Main program finished")
Enter fullscreen mode Exit fullscreen mode

Output:

Working...
Working...
Working...
Thread stopping gracefully
Main program finished
Enter fullscreen mode Exit fullscreen mode

Note: For more advanced use cases, threading.Event is often used instead of a simple boolean flag, because it is thread-safe and avoids race conditions.

Various ways of creating a thread

1. Using the Thread class with a target function

This is the simplest and most common method, passing a function to the Thread constructor.

from threading import Thread

def task():
    print("Task executed")

# Create and start the thread
t = Thread(target=task)
t.start()
t.join()

# Output: Task executed
Enter fullscreen mode Exit fullscreen mode

2. Extending the Thread class

In case there is a need to encapsulate thread logic in a class (for example, pass state, override behaviour, make code more reusable), subclass threading.Thread can be used. In this case, the run() method needs to be overridden.

from threading import Thread

class MyThread(Thread):
  def __init__(self, name):
    super().__init__()
    self.name = name

  def run(self):
    print(f"Task executed inside custom Thread class by {self.name}")

# Create and start the custom thread
t = MyThread("Worker-1")
t.start()
t.join()

# Output: Task executed inside custom Thread class by Worker-1
Enter fullscreen mode Exit fullscreen mode

Why super().__init__() is called?

The Thread class (from threading) already has its own __init__ method, which sets up internal bookkeeping:

  • assigning a unique identifier to the thread
  • initialising the thread state (new, started, finished, etc.)
  • preparing resources needed by the operating system to handle the thread

So, if super().__init__() is not called, all that setup will not happen, and the custom thread class may not behave like a proper thread. It will raise an error like RuntimeError: thread.__init__() not called

Advantage of creating your own thread class

  • Thread behavior can be customized by overriding run() or adding extra attributes/methods.
  • This makes code cleaner when there is a need to manage state or handle complex tasks.

Thread identifier vs Native Identifier

Thread identifier (ident):

The thread identifier is an integer assigned by Python's threading module that is unique to the thread for its entire lifetime. You can get this ID using the threading.get_ident() function or by accessing the .ident attribute of a Thread object.

Example:

import threading

def worker():
  print(f"[Worker] Running in Thread Name: {threading.current_thread().name}, ID: {threading.get_ident()}")

# Main thread info
print(f"[Main] Thread Name: {threading.current_thread().name}, ID: {threading.get_ident()}")

t = threading.Thread(target=worker)
t.start()
t.join()

print(f"[Main] After worker finished, Worker thread ID was: {t.ident}")
Enter fullscreen mode Exit fullscreen mode

Output:

[Main] Thread Name: MainThread, ID: 8456970432
[Worker] Running in Thread Name: Thread-1 (worker), ID: 6141423616
[Main] After worker finished, Worker thread ID was: 6141423616
Enter fullscreen mode Exit fullscreen mode

Note: You can set the name of a thread using Thread.name or threading.current_thread().name

Native identifier (native_id):

It is the identifier assigned by the underlying OS (available from Python 3.8+). It is useful when debugging at the OS-level or with external tools.

import threading

def worker():
  print("Native ID:", threading.get_native_id())

t = threading.Thread(target=worker)
t.start()
t.join()

# Output: Native ID: 2386631
Enter fullscreen mode Exit fullscreen mode

Further topics related to Threading and Multithreading, along with practice sets and interview questions, will be covered in the upcoming article. Stay Tuned!

Top comments (0)