Introduction

Les modules de threading et de multiprocessing en python visent à faire la même chose, c’est-à-dire à faire plusieurs choses en même temps, mais la façon dont le module de threading et le module de multiprocessing s’y prennent est très différente.

C’est pourquoi une définition générale s’impose:

Process: Est une instance d’un programme qui tourne dans un ordinateur (une machine)

Thread: Est une unité de répartition d’une œuvre dans un processus

Un exemple concret: Supposons qu’on est entrain de créer une GUI (interface graphique) et dans cette dernière on aura besoin de cliquer sur un bouton pour afficher un text dans une zone précise de cette interface. De ce fait:

Différences entre Threading et Multiprocessing

Threading:

Multiprocessing:

A noter:

Concurrency: Taches concurrentes

A la première vue on pourra dire que les taches concurrentes (Threading et Couroutines) ne tournent pas en parallère et ceci est vrai en absolue. Car, la définition la plus banale de ce concept dit que que les threads peuvent faire des allers-retours entre les taches quand ils auront le temps. Cependant, en diminuant la fréquence d’altération d’une tache vers une autre on pourra aboutir à un processus qui tendera vers des processus qui tournent en parallèle.

Deux processus peuvent-ils partager le même segment de mémoire partagée?

Oui et non. Généralement, avec les systèmes d’exploitation modernes, lorsqu’un autre processus est dérivé du premier, ils partagent le même espace mémoire avec un jeu de copie en écriture sur toutes les pages. Toutes les mises à jour apportées à l’une des pages de mémoire en lecture-écriture entraînent une copie de la page, il y aura donc deux copies et la page de mémoire ne sera plus partagée entre le processus parent et enfant. Cela signifie que seules les pages en lecture seule ou les pages qui n’ont pas été écrites seront partagées.

Si un processus n’a pas été issu d’un autre (aucun fork), il ne partage généralement pas de mémoire. Une exception est que si vous exécutez deux instances du même programme, elles peuvent partager du code et peut-être même des segments de données statiques, mais aucune autre page ne sera partagée.

Il existe également des appels de mappage de mémoire spécifiques pour partager le même segment de mémoire. L’appel indique si la carte est en lecture seule ou en lecture-écriture. La façon de procéder est très dépendante du système d’exploitation.

Deux threads peuvent-ils partager la même mémoire partagée?

Certainement. En général, toute la mémoire à l’intérieur d’un processus multithread est “partagée” par tous les threads. C’est généralement la définition des threads en ce sens qu’ils s’exécutent tous dans le même espace mémoire.

Les threads ont également la complexité supplémentaire d’avoir des segments de mémoire mis en cache dans la mémoire haute vitesse liés au processeur / noyau. Cette mémoire en cache n’est pas partagée et les mises à jour des pages de mémoire sont vidées dans le stockage central en fonction des opérations de synchronisation. Et c’est là où le GIL (Global Interpreter Lock) de Python intervient.

Assez de théories :D faisons un peu de code !

Multithreading avec/sans Mutex:

#
# Threading with/without mutex
#
import time
import threading
MUTEX = threading.Lock()
def task(arg, result):
# With mutex
with MUTEX:
result.append(arg ** 2)
def task2(arg, result):
# GIL will prevent the simultaneous access to the list
result.append(arg ** 2)
def run(n, switch_task=False):
_task = task if not switch_task else task2
threads, result, start = [], [], time.time()
for k in range(n):
thread = threading.Thread(
target=_task,
args=(k, result)
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
print(result)
print('<{task_name}> Finished in: {duration}'.format(
task_name=_task.__name__,
duration=time.time() - start
))
if __name__ == '__main__':
run(10)
run(10, switch_task=True)
# Output is similar to:
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# <task> Finished in: 0.0009059906005859375
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# <task2> Finished in: 0.0006284713745117188

Multiprocessing avec Mutex: Notez dans cet exemple le fait qu’on a partagé une liste et des chiffres entre les différents process en utilisant des Mutex.

#
# MultiProcessing using Mutex example
#
import time
from multiprocessing import Process, Lock, Array, Value
def task(index, result):
result[index.value] = index.value ** 2
def run(n):
mutex = Lock()
start = time.time()
result = Array('i', range(n), lock=mutex)
for k in range(n):
value = Value('i', k)
process = Process(
target=task,
args=(value, result)
)
process.start()
process.join()
print(list(result))
print("Finished in: ", time.time() - start)
if __name__ == '__main__':
run(10)
# Output is similar to:
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Finished in: 0.08156561851501465

Multiprocessing avec une Pool: Notez ici le fait que les processus sont indépendant les uns des autres.

#
# Multiprocessing using Pool
#
import time
from multiprocessing import Pool, cpu_count
def task(arg):
return arg ** 2
def run(n):
start = time.time()
result = Pool(cpu_count()).map(task, range(n))
print(result)
print("Finished in: ", time.time() - start)
if __name__ == '__main__':
run(10)
# Output is similar to:
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Finished in: 0.014499902725219727

Coroutines en utilisant Async/Await:

#
# Example using coroutines
#
import time
import asyncio
async def task(num):
# Wait 1s
await asyncio.sleep(1)
return num ** 2
async def run_tasks(num):
# Wait all tasks to finish
return await asyncio.gather(*[task(k) for k in range(num)])
def run(num):
start = time.perf_counter()
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(run_tasks(num))
print(result)
print("Finished in: ", time.perf_counter() - start)
finally:
# We should close the loop
loop.close()
if __name__ == '__main__':
run(10)
# Output is similar to
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Finished in: 1.001627395000014
view raw couroutines.py hosted with ❤ by GitHub

Coroutines en utilisant ThreadPoolExecutor:

#
# Example using coroutines within Threads and blocking task
#
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
def blocking_task(num):
# time.sleep is a blocking function
time.sleep(1)
return num ** 2
async def run_tasks(executor, num):
loop = asyncio.get_event_loop()
blocking_tasks = [loop.run_in_executor(executor, blocking_task, k) for k in range(num)]
# Wait for tasks to finish
completed, pending = await asyncio.wait(blocking_tasks)
return [elm.result() for elm in completed]
def run(num):
start = time.perf_counter()
# How many workers should be launched
executor = ThreadPoolExecutor(max_workers=num)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(run_tasks(executor, num))
print(result)
print("Finished in: ", time.perf_counter() - start)
finally:
loop.close()
if __name__ == '__main__':
run(10)
# Output is similar to:
# Note here how the output is unordered
# [1, 9, 64, 0, 16, 81, 25, 36, 4, 49]
# Finished in: 1.0040447339997627

Coroutines en utilisant des Mutex:

#
# Example using coroutines within Mutex
#
import asyncio
MUTEX = asyncio.Lock()
async def task(num, result):
# Wait 1s
await asyncio.sleep(1)
# Check if the Mutex is acquired or not in order to append to the list
async with MUTEX:
result.append(num ** 2)
async def run_tasks(k, result):
_ = await asyncio.gather(*[task(k, result) for k in range(k)])
def run(num):
result = []
start = time.perf_counter()
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(run_tasks(num, result))
print(result)
print("Finished in: ", time.perf_counter() - start)
finally:
loop.close()
if __name__ == '__main__':
run(10)
# Output is similar to:
# Note the unordered list
# [4, 64, 9, 81, 0, 16, 1, 25, 36, 49]
# Finished in: 1.0023566509999

Avant de finir: Une question qui se pose toujours: Quand dois-je utiliser le Multithreading ? Quand dois-je utiliser le Multiprocessing ? et Quand dois-je utiliser les Coroutines ?

En gros: