Multiprocessing vs Multithreading in Python: Complete Guide

# Multiprocessing vs Multithreading di Python: Panduan Lengkap Concurrency dan parallelism adalah konsep penting dalam programming modern untuk meningkatkan performa aplikasi. Python menyediakan dua...

By Ruby Abdullah · · tutorial
PythonMultithreadingMultiprocessingConcurrencyGILPerformance

Multiprocessing vs Multithreading in Python: Complete Guide

Concurrency and parallelism are important concepts in modern programming for improving application performance. Python provides two main approaches: multithreading and multiprocessing. In this tutorial, we'll learn the differences between them, when to use each, and how to implement them effectively.

Core Concepts

Threading

A thread is the smallest unit of execution within a process. Multiple threads in one process share the same memory space.

Threading Characteristics:
  • Lightweight
  • Shared memory space
  • Faster context switching
  • Suitable for I/O-bound tasks

Multiprocessing

A process is an independent instance of a running program. Each process has its own separate memory space.

Multiprocessing Characteristics:
  • Heavyweight
  • Separate memory space
  • True parallelism
  • Suitable for CPU-bound tasks

Global Interpreter Lock (GIL)

What is GIL?

GIL is a mutex (mutual exclusion lock) that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously.

GIL Implications:
import threading

import time

CPU-bound task

def cpuintensive():

count = 0

for i in range(50000000):

count += 1

return count

Single thread

start = time.time()

cpuintensive()

cpuintensive()

end = time.time()

print(f"Single thread: {end - start:.2f}s")

Multi-threading (NO speedup due to GIL!)

start = time.time()

t1 = threading.Thread(target=cpuintensive)

t2 = threading.Thread(target=cpuintensive)

t1.start()

t2.start()

t1.join()

t2.join()

end = time.time()

print(f"Multi-threading: {end - start:.2f}s")

Output:

Single thread: 3.45s

Multi-threading: 3.52s # No speedup!

Conclusion: Threading in Python does NOT provide speedup for CPU-bound tasks due to GIL.

When to Use Threading

Threading is effective for I/O-bound tasks because GIL is released when waiting for I/O.

Example 1: Download Multiple Files

import threading

import requests

import time

urls = [

'https://jsonplaceholder.typicode.com/posts/1',

'https://jsonplaceholder.typicode.com/posts/2',

'https://jsonplaceholder.typicode.com/posts/3',

'https://jsonplaceholder.typicode.com/posts/4',

'https://jsonplaceholder.typicode.com/posts/5',

]

def download(url):

"""Download content from URL"""

response = requests.get(url)

print(f"Downloaded {url}: {len(response.content)} bytes")

Sequential (without threading)

start = time.time()

for url in urls:

download(url)

sequentialtime = time.time() - start

print(f"\nSequential: {sequentialtime:.2f}s")

Multi-threading

start = time.time()

threads = []

for url in urls:

thread = threading.Thread(target=download, args=(url,))

threads.append(thread)

thread.start()

for thread in threads:

thread.join()

threadedtime = time.time() - start

print(f"Multi-threading: {threadedtime:.2f}s")

print(f"Speedup: {sequentialtime/threadedtime:.2f}x")

When to Use Multiprocessing

Multiprocessing is effective for CPU-bound tasks because it bypasses GIL by using separate processes.

Example: Heavy Computation

import multiprocessing

import time

import math

def cpuintensivetask(n):

"""CPU-intensive calculation"""

count = 0

for i in range(n):

count += math.sqrt(i) math.sin(i)

return count

Parameters

numbers = [10000000, 10000000, 10000000, 10000000]

Sequential

start = time.time()

results = [cpuintensivetask(n) for n in numbers]

sequentialtime = time.time() - start

print(f"Sequential: {sequentialtime:.2f}s")

Multiprocessing

start = time.time()

with multiprocessing.Pool(processes=4) as pool:

results = pool.map(cpuintensivetask, numbers)

multiprocessingtime = time.time() - start

print(f"Multiprocessing: {multiprocessingtime:.2f}s")

print(f"Speedup: {sequentialtime/multiprocessingtime:.2f}x")

Output (on 4-core CPU):

Sequential: 12.45s

Multiprocessing: 3.28s

Speedup: 3.80x # Nearly linear speedup!

Threading Advanced: ThreadPoolExecutor

Python 3 provides concurrent.futures for a cleaner interface.

Basic Usage

from concurrent.futures import ThreadPoolExecutor

import time

def task(name, delay):

print(f"Task {name} starting")

time.sleep(delay)

print(f"Task {name} completed")

return f"Result from {name}"

Using ThreadPoolExecutor

with ThreadPoolExecutor(maxworkers=3) as executor:

# Submit tasks

future1 = executor.submit(task, "A", 2)

future2 = executor.submit(task, "B", 1)

future3 = executor.submit(task, "C", 3)

# Get results

print(future1.result()) # Blocks until complete

print(future2.result())

print(future3.result())

As Completed

from concurrent.futures import ThreadPoolExecutor, ascompleted

import time

import random

def task(n):

sleeptime = random.uniform(0.5, 2.0)

time.sleep(sleeptime)

return f"Task {n} completed in {sleeptime:.2f}s"

tasks = list(range(10))

with ThreadPoolExecutor(maxworkers=3) as executor:

# Submit all tasks

futuretotask = {executor.submit(task, t): t for t in tasks}

# Process as they complete

for future in ascompleted(futuretotask):

tasknum = futuretotask[future]

try:

result = future.result()

print(result)

except Exception as e:

print(f"Task {tasknum} generated exception: {e}")

Multiprocessing Advanced: ProcessPoolExecutor

Basic Usage

from concurrent.futures import ProcessPoolExecutor

import os

def cputask(n):

"""CPU-intensive task"""

print(f"Process {os.getpid()} working on {n}")

result = sum(ii for i in range(n))

return result

numbers = [1000000, 2000000, 3000000, 4000000]

with ProcessPoolExecutor(maxworkers=4) as executor:

results = executor.map(cputask, numbers)

for num, result in zip(numbers, results):

print(f"Sum of squares up to {num}: {result}")

Synchronization

Thread Locks

import threading

Without lock (race condition)

counter = 0

def increment():

global counter

for in range(100000):

counter += 1

threads = [threading.Thread(target=increment) for in range(10)]

for t in threads:

t.start()

for t in threads:

t.join()

print(f"Counter without lock: {counter}") # Incorrect! < 1000000

With lock

counter = 0

lock = threading.Lock()

def incrementwithlock():

global counter

for in range(100000):

with lock:

counter += 1

threads = [threading.Thread(target=incrementwithlock) for in range(10)]

for t in threads:

t.start()

for t in threads:

t.join()

print(f"Counter with lock: {counter}") # Correct! = 1000000

Semaphores

import threading

import time

Limit concurrent access to resource

semaphore = threading.Semaphore(3) # Max 3 threads

def accessresource(threadid):

print(f"Thread {threadid} waiting...")

semaphore.acquire()

try:

print(f"Thread {threadid} accessing resource")

time.sleep(2) # Simulate work

finally:

print(f"Thread {threadid} releasing resource")

semaphore.release()

threads = [threading.Thread(target=accessresource, args=(i,)) for i in range(10)]

for t in threads:

t.start()

for t in threads:

t.join()

Performance Comparison

Benchmark: I/O-bound vs CPU-bound

import time

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

I/O-bound task

def iotask(n):

time.sleep(0.1) # Simulate I/O

return n 2

CPU-bound task

def cputask(n):

return sum(ii for i in range(n))

Test data

iodata = list(range(20))

cpudata = [1000000] 8

print("=== I/O-bound Tasks ===")

Sequential I/O

start = time.time()

results = [iotask(x) for x in iodata]

print(f"Sequential: {time.time() - start:.2f}s")

Threading I/O

start = time.time()

with ThreadPoolExecutor(maxworkers=10) as executor:

results = list(executor.map(iotask, iodata))

print(f"Threading: {time.time() - start:.2f}s")

Multiprocessing I/O (overhead!)

start = time.time()

with ProcessPoolExecutor(maxworkers=4) as executor:

results = list(executor.map(iotask, iodata))

print(f"Multiprocessing: {time.time() - start:.2f}s")

print("\n=== CPU-bound Tasks ===")

Sequential CPU

start = time.time()

results = [cputask(x) for x in cpudata]

print(f"Sequential: {time.time() - start:.2f}s")

Threading CPU (no speedup!)

start = time.time()

with ThreadPoolExecutor(maxworkers=4) as executor:

results = list(executor.map(cputask, cpudata))

print(f"Threading: {time.time() - start:.2f}s")

Multiprocessing CPU

start = time.time()

with ProcessPoolExecutor(maxworkers=4) as executor:

results = list(executor.map(cputask, cpudata))

print(f"Multiprocessing: {time.time() - start:.2f}s")

Output (example):

=== I/O-bound Tasks ===

Sequential: 2.01s

Threading: 0.21s # 9.5x faster!

Multiprocessing: 0.45s # Overhead

=== CPU-bound Tasks ===

Sequential: 3.42s

Threading: 3.51s # No speedup (GIL)

Multiprocessing: 0.94s # 3.6x faster!

Best Practices

1. Choose the Right Tool

# Decision tree

def chooseconcurrency(tasktype):

if tasktype == "I/O-bound":

return "Use Threading or AsyncIO"

elif tasktype == "CPU-bound":

return "Use Multiprocessing"

else:

return "Profile first!"

2. Optimal Worker Count

import os

For I/O-bound (threading)

ioworkers = 10 # Or more, depends on I/O latency

For CPU-bound (multiprocessing)

cpuworkers = os.cpucount() # Number of CPU cores

Conservative approach

cpuworkers = os.cpucount() - 1 # Leave one core free

3. Handle Errors Gracefully

from concurrent.futures import ThreadPoolExecutor, ascompleted

def riskytask(n):

if n == 5:

raise ValueError("Number 5 is not allowed!")

return n 2

numbers = range(10)

with ThreadPoolExecutor(maxworkers=3) as executor:

futuretonum = {executor.submit(riskytask, n): n for n in numbers}

for future in ascompleted(futuretonum):

num = futuretonum[future]

try:

result = future.result()

print(f"{num} -> {result}")

except Exception as e:

print(f"{num} generated exception: {e}")

Common Pitfalls

1. Race Conditions

import threading

❌ Bad: Race condition

sharedlist = []

def appenditems():

for i in range(1000):

sharedlist.append(i)

threads = [threading.Thread(target=appenditems) for in range(10)]

for t in threads:

t.start()

for t in threads:

t.join()

print(len(sharedlist)) # May not be 10000!

✅ Good: Use lock or thread-safe structure

from queue import Queue

sharedqueue = Queue()

def appenditemssafe():

for i in range(1000):

sharedqueue.put(i)

threads = [threading.Thread(target=appenditemssafe) for in range(10)]

for t in threads:

t.start()

for t in threads:

t.join()

print(sharedqueue.qsize()) # Exactly 10000

2. Deadlocks

import threading

lock1 = threading.Lock()

lock2 = threading.Lock()

❌ Bad: Potential deadlock

def thread1():

lock1.acquire()

lock2.acquire() # May wait forever

lock2.release()

lock1.release()

def thread2():

lock2.acquire()

lock1.acquire() # May wait forever

lock1.release()

lock2.release()

✅ Good: Consistent lock ordering

def thread1safe():

lock1.acquire()

lock2.acquire()

lock2.release()

lock1.release()

def thread2_safe():

lock1.acquire() # Same order!

lock2.acquire()

lock2.release()

lock1.release()

Conclusion

Quick Reference

| Aspect | Threading | Multiprocessing |

|--------|-----------|-----------------|

| Best for | I/O-bound tasks | CPU-bound tasks |

| GIL Impact | Limited by GIL | Bypasses GIL |

| Memory | Shared | Separate |

| Overhead | Low | High |

| Communication | Easy (shared memory) | Complex (IPC) |

| Debugging | Easier | Harder |

Decision Matrix

Use Threading when:
  • Network I/O (API calls, web scraping)
  • File I/O
  • Database queries
  • Waiting for external resources

Use Multiprocessing when:
  • Heavy computation
  • Data processing
  • Image/video processing
  • Scientific computing
  • Embarrassingly parallel problems

Use AsyncIO when:
  • High concurrency I/O
  • Web servers
  • Real-time applications

Key Takeaways

  • GIL limits threading for CPU-bound tasks
  • Multiprocessing bypasses GIL but with higher overhead
  • Threading is excellent for I/O-bound tasks
  • Always profile before optimizing
  • concurrent.futures provides clean interface
  • Happy concurrent programming in Python! 🚀

    Related Articles

    Polars Tutorial: Ultra-Fast DataFrame Library for Data Science

    Polars - Tutorial Lengkap Library DataFrame Ultra-Cepat Daftar Isi Pendahuluan Prasyarat Dasar-Dasar Polars [Evaluasi La...

    How to Setup Gunicorn on FastAPI for Concurrent Invocation

    Pendahuluan FastAPI adalah framework Python modern yang sangat cepat untuk membangun API. Namun, secara default, FastAPI...

    MergeKit Tutorial: Merging LLM Models to Create Custom AI Models

    Tutorial MergeKit: Menggabungkan Model LLM untuk Membuat Model AI Kustom Model bahasa besar (LLM) open-source terus berk...

    LlamaParse Tutorial: Parsing Complex Documents for RAG Pipelines

    Tutorial LlamaParse: Parsing Dokumen Kompleks untuk RAG Pipeline Membangun aplikasi Retrieval-Augmented Generation (RAG)...