¿Cuáles son las diferencias entre los módulos de subprocesamiento y multiprocesamiento?

141

Estoy aprendiendo cómo utilizar el threadingy los multiprocessingmódulos en Python para ejecutar ciertas operaciones en paralelo y acelerar mi código.

Me resulta difícil (tal vez porque no tengo antecedentes teóricos al respecto) comprender cuál es la diferencia entre un threading.Thread()objeto y multiprocessing.Process()uno.

Además, no está del todo claro para mí cómo crear una instancia de una cola de trabajos y tener solo 4 (por ejemplo) ejecutándose en paralelo, mientras que el otro espera que los recursos se liberen antes de ejecutarse.

Los ejemplos en la documentación me parecen claros, pero no muy exhaustivos; Tan pronto como trato de complicar un poco las cosas, recibo muchos errores extraños (como un método que no se puede encurtir, etc.).

Entonces, ¿cuándo debo usar los módulos threadingy multiprocessing?

¿Me puede vincular a algunos recursos que explican los conceptos detrás de estos dos módulos y cómo usarlos adecuadamente para tareas complejas?

lucacerona
fuente
Hay más, también está el Threadmódulo (llamado _threaden python 3.x). Para ser honesto, nunca he entendido las diferencias mí mismo ...
No sé
3
@Dunno: Como la documentación Thread/ _threaddice explícitamente, son "primitivas de bajo nivel". Puede usarlo para construir objetos de sincronización personalizados, para controlar el orden de unión de un árbol de hilos, etc. Si no puede imaginar por qué necesitaría usarlo, no lo use y quédese con él threading.
abarnert

Respuestas:

260

Lo que dice Giulio Franco es cierto para el subprocesamiento múltiple frente al multiprocesamiento en general .

Sin embargo, Python * tiene un problema adicional: hay un bloqueo global de intérprete que evita que dos hilos en el mismo proceso ejecuten código Python al mismo tiempo. Esto significa que si tiene 8 núcleos y cambia su código para usar 8 hilos, no podrá usar 800% de CPU y ejecutar 8x más rápido; utilizará la misma CPU al 100% y se ejecutará a la misma velocidad. (En realidad, funcionará un poco más lento, porque hay una sobrecarga adicional por el enhebrado, incluso si no tiene datos compartidos, pero ignore eso por ahora).

Existen excepciones para esto. Si el cálculo pesado de su código en realidad no ocurre en Python, pero en alguna biblioteca con código C personalizado que realiza el manejo adecuado de GIL, como una aplicación complicada, obtendrá el beneficio de rendimiento esperado de los subprocesos. Lo mismo es cierto si el cálculo pesado se realiza mediante algún subproceso que ejecuta y espera.

Más importante aún, hay casos en los que esto no importa. Por ejemplo, un servidor de red pasa la mayor parte de su tiempo leyendo paquetes fuera de la red, y una aplicación GUI pasa la mayor parte del tiempo esperando los eventos del usuario. Una razón para usar subprocesos en un servidor de red o aplicación GUI es permitirle realizar "tareas en segundo plano" de larga ejecución sin detener el hilo principal de continuar sirviendo paquetes de red o eventos GUI. Y eso funciona bien con hilos de Python. (En términos técnicos, esto significa que los subprocesos de Python le brindan concurrencia, a pesar de que no le brindan paralelismo central).

Pero si está escribiendo un programa vinculado a la CPU en Python puro, usar más hilos generalmente no es útil.

El uso de procesos separados no tiene tales problemas con el GIL, porque cada proceso tiene su propio GIL separado. Por supuesto, todavía tiene las mismas compensaciones entre subprocesos y procesos que en cualquier otro idioma: es más difícil y más costoso compartir datos entre procesos que entre subprocesos, puede ser costoso ejecutar una gran cantidad de procesos o crear y destruir con frecuencia, etc. Pero el GIL pesa mucho en el equilibrio hacia los procesos, de una manera que no es cierta para, por ejemplo, C o Java. Por lo tanto, se encontrará utilizando el multiprocesamiento con mucha más frecuencia en Python que en C o Java.


Mientras tanto, la filosofía de "baterías incluidas" de Python trae buenas noticias: es muy fácil escribir código que se puede alternar entre hilos y procesos con un cambio de una línea.

Si diseña su código en términos de "trabajos" autónomos que no comparten nada con otros trabajos (o el programa principal) excepto entrada y salida, puede usar la concurrent.futuresbiblioteca para escribir su código alrededor de un grupo de subprocesos como este:

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    executor.submit(job, argument)
    executor.map(some_function, collection_of_independent_things)
    # ...

Incluso puede obtener los resultados de esos trabajos y pasarlos a otros trabajos, esperar cosas en orden de ejecución o de finalización, etc .; lea la sección sobre Futureobjetos para más detalles.

Ahora, si resulta que su programa usa constantemente el 100% de la CPU, y agregar más subprocesos solo lo hace más lento, entonces se encuentra con el problema GIL, por lo que debe cambiar a los procesos. Todo lo que tienes que hacer es cambiar esa primera línea:

with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:

La única advertencia real es que los argumentos de sus trabajos y los valores de retorno deben ser seleccionables (y no tomar demasiado tiempo o memoria para ser procesados) para ser utilizables en el proceso cruzado. Por lo general, esto no es un problema, pero a veces lo es.


Pero, ¿qué pasa si sus trabajos no pueden ser independientes? Si puede diseñar su código en términos de trabajos que pasan mensajes de uno a otro, todavía es bastante fácil. Puede que tenga que usar threading.Threado en multiprocessing.Processlugar de confiar en las piscinas. Y tendrá que crear queue.Queueu multiprocessing.Queueobjetos explícitamente. (Hay muchas otras opciones: tuberías, enchufes, archivos con bandadas, ... pero el punto es que debe hacer algo manualmente si la magia automática de un ejecutor es insuficiente).

Pero, ¿qué pasa si ni siquiera puede confiar en pasar mensajes? ¿Qué sucede si necesita dos trabajos para que ambos muten la misma estructura y vean los cambios de los demás? En ese caso, deberá realizar una sincronización manual (bloqueos, semáforos, condiciones, etc.) y, si desea utilizar procesos, objetos explícitos de memoria compartida para arrancar. Esto es cuando el subprocesamiento múltiple (o multiprocesamiento) se vuelve difícil. Si puedes evitarlo, genial; Si no puede, tendrá que leer más de lo que alguien puede poner en una respuesta SO.


A partir de un comentario, quería saber qué es diferente entre los hilos y los procesos en Python. Realmente, si lees la respuesta de Giulio Franco y la mía y todos nuestros enlaces, eso debería cubrir todo ... pero un resumen definitivamente sería útil, así que aquí va:

  1. Los hilos comparten datos por defecto; los procesos no lo hacen.
  2. Como consecuencia de (1), el envío de datos entre procesos generalmente requiere su preparación y eliminación. ** **
  3. Como otra consecuencia de (1), compartir datos directamente entre procesos generalmente requiere ponerlos en formatos de bajo nivel como Value, Array y ctypestipos.
  4. Los procesos no están sujetos a la GIL.
  5. En algunas plataformas (principalmente Windows), los procesos son mucho más caros de crear y destruir.
  6. Existen algunas restricciones adicionales en los procesos, algunas de las cuales son diferentes en diferentes plataformas. Consulte las pautas de programación para más detalles.
  7. El threadingmódulo no tiene algunas de las características del multiprocessingmódulo. (Puede usar multiprocessing.dummypara obtener la mayor parte de la API que falta en la parte superior de los hilos, o puede usar módulos de nivel superior como concurrent.futuresy no preocuparse por eso).

* No es realmente Python, el lenguaje, el que tiene este problema, sino CPython, la implementación "estándar" de ese lenguaje. Algunas otras implementaciones no tienen un GIL, como Jython.

** Si está utilizando el método de inicio fork para multiprocesamiento, que puede en la mayoría de las plataformas que no son de Windows, cada proceso secundario obtiene los recursos que tenía el padre cuando se inició el niño, que puede ser otra forma de pasar datos a los niños.

abarnert
fuente
gracias, pero no estoy seguro de haber entendido todo. De todos modos, estoy tratando de hacerlo un poco con fines de aprendizaje, y un poco porque con un uso ingenuo de hilo reduje a la mitad la velocidad de mi código (comenzando más de 1000 hilos al mismo tiempo, cada uno llamando a una aplicación externa ... esto se satura la CPU, sin embargo, hay un aumento x2 en la velocidad). Creo que administrar el subproceso de manera inteligente realmente podría mejorar la velocidad de mi código ...
lucacerone
3
@LucaCerone: Ah, si su código pasa la mayor parte del tiempo esperando programas externos, entonces sí, se beneficiará de los subprocesos. Buen punto. Déjame editar la respuesta para explicar eso.
abarnert
2
@LucaCerone: Mientras tanto, ¿qué partes no entiendes? Sin saber el nivel de conocimiento con el que está comenzando, es difícil escribir una buena respuesta ... pero con algunos comentarios, tal vez podamos llegar a algo que sea útil para usted y para los futuros lectores también.
abarnert
3
@LucaCerone Debería leer el PEP para multiprocesamiento aquí . Da tiempos y ejemplos de hilos vs multiprocesamiento.
mr2ert
1
@LucaCerone: si el objeto al que está vinculado el método no tiene ningún estado complejo, la solución más simple para el problema del decapado es escribir una función de envoltura estúpida que genera el objeto y llama a su método. Si no tiene complejo estado, entonces es probable que tenga que hacerlo estibables (que es bastante fácil; los pickledocumentos que explican), y luego en el peor de su envoltorio es estúpida def wrapper(obj, *args): return obj.wrapper(*args).
abarnert
32

Pueden existir múltiples hilos en un solo proceso. Los hilos que pertenecen al mismo proceso comparten la misma área de memoria (pueden leer y escribir en las mismas variables, y pueden interferir entre sí). Por el contrario, diferentes procesos viven en diferentes áreas de memoria, y cada uno de ellos tiene sus propias variables. Para comunicarse, los procesos tienen que usar otros canales (archivos, tuberías o sockets).

Si desea paralelizar un cálculo, es probable que necesite varios subprocesos, porque probablemente desee que los subprocesos cooperen en la misma memoria.

Hablando sobre el rendimiento, los subprocesos son más rápidos de crear y administrar que los procesos (porque el sistema operativo no necesita asignar un área de memoria virtual completamente nueva), y la comunicación entre subprocesos suele ser más rápida que la comunicación entre procesos. Pero los hilos son más difíciles de programar. Los subprocesos pueden interferir entre sí y pueden escribirse en la memoria del otro, pero la forma en que esto sucede no siempre es obvia (debido a varios factores, principalmente el reordenamiento de la instrucción y el almacenamiento en memoria caché), por lo que necesitará primitivas de sincronización para controlar el acceso a tus variables.

Giulio Franco
fuente
12
Falta información muy importante sobre el GIL, lo que lo hace engañoso.
abarnert
1
@ mr2ert: Sí, esa es la información muy importante en pocas palabras. :) Pero es un poco más complicado que eso, por eso escribí una respuesta por separado.
abarnert
2
Creo que comenté diciendo que @abarnert tiene razón, y me olvidé del GIL al responder aquí. Entonces esta respuesta es incorrecta, no deberías votarla.
Giulio Franco
66
Voté esta respuesta porque todavía no responde en absoluto cuál es la diferencia entre Python threadingy multiprocessing.
Antti Haapala
He leído que hay un GIL para cada proceso. ¿Pero todos los procesos usan el mismo intérprete de Python o hay un intérprete separado por hilo?
variable
3

Creo que este enlace responde a su pregunta de una manera elegante.

Para ser breve, si uno de sus subproblemas tiene que esperar mientras otro termina, el subprocesamiento múltiple es bueno (en operaciones pesadas de E / S, por ejemplo); por el contrario, si sus subproblemas realmente pueden ocurrir al mismo tiempo, se sugiere el multiprocesamiento. Sin embargo, no creará más procesos que su número de núcleos.

ehfaafzv
fuente
3

Cotizaciones de documentación de Python

He destacado las citas clave de la documentación de Python sobre Process vs Threads y el GIL en: ¿Qué es el bloqueo global del intérprete (GIL) en CPython?

Experimentos de proceso vs hilo

Hice un poco de evaluación comparativa para mostrar la diferencia más concretamente.

En el punto de referencia, cronometré el trabajo de CPU e IO para varios números de subprocesos en una CPU de 8 hyperthread . El trabajo proporcionado por hilo es siempre el mismo, de modo que más hilos significa más trabajo total suministrado.

Los resultados fueron:

ingrese la descripción de la imagen aquí

Trazar datos .

Conclusiones:

  • Para el trabajo con CPU, el multiprocesamiento siempre es más rápido, presumiblemente debido a la GIL

  • para IO trabajo encuadernado. ambos son exactamente la misma velocidad

  • los subprocesos solo se escalan hasta aproximadamente 4x en lugar del esperado 8x ya que estoy en una máquina de 8 hyperthread.

    Compare eso con un trabajo en CPU C POSIX que alcanza la aceleración esperada de 8x: ¿Qué significan 'real', 'user' y 'sys' en la salida del tiempo (1)?

    TODO: No sé la razón de esto, debe haber otras ineficiencias de Python que entran en juego.

Código de prueba:

#!/usr/bin/env python3

import multiprocessing
import threading
import time
import sys

def cpu_func(result, niters):
    '''
    A useless CPU bound function.
    '''
    for i in range(niters):
        result = (result * result * i + 2 * result * i * i + 3) % 10000000
    return result

class CpuThread(threading.Thread):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class CpuProcess(multiprocessing.Process):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class IoThread(threading.Thread):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

class IoProcess(multiprocessing.Process):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

if __name__ == '__main__':
    cpu_n_iters = int(sys.argv[1])
    sleep = 1
    cpu_count = multiprocessing.cpu_count()
    input_params = [
        (CpuThread, cpu_n_iters),
        (CpuProcess, cpu_n_iters),
        (IoThread, sleep),
        (IoProcess, sleep),
    ]
    header = ['nthreads']
    for thread_class, _ in input_params:
        header.append(thread_class.__name__)
    print(' '.join(header))
    for nthreads in range(1, 2 * cpu_count):
        results = [nthreads]
        for thread_class, work_size in input_params:
            start_time = time.time()
            threads = []
            for i in range(nthreads):
                thread = thread_class(work_size)
                threads.append(thread)
                thread.start()
            for i, thread in enumerate(threads):
                thread.join()
            results.append(time.time() - start_time)
        print(' '.join('{:.6e}'.format(result) for result in results))

GitHub upstream + código de trazado en el mismo directorio .

Probado en Ubuntu 18.10, Python 3.6.7, en una computadora portátil Lenovo ThinkPad P51 con CPU: CPU Intel Core i7-7820HQ (4 núcleos / 8 hilos), RAM: 2x Samsung M471A2K43BB1-CRC (2x 16GiB), SSD: Samsung MZVLB512HAJQ- 000L7 (3.000 MB / s).

Visualice qué hilos se están ejecutando en un momento dado

Esta publicación https://rohanvarma.me/GIL/ me enseñó que puede ejecutar una devolución de llamada cada vez que se programa un hilo con el target=argumento dethreading.Thread y lo mismo para multiprocessing.Process.

Esto nos permite ver exactamente qué hilo se ejecuta en cada momento. Cuando esto esté hecho, veríamos algo como (hice este gráfico en particular):

            +--------------------------------------+
            + Active threads / processes           +
+-----------+--------------------------------------+
|Thread   1 |********     ************             |
|         2 |        *****            *************|
+-----------+--------------------------------------+
|Process  1 |***  ************** ******  ****      |
|         2 |** **** ****** ** ********* **********|
+-----------+--------------------------------------+
            + Time -->                             +
            +--------------------------------------+

lo que demostraría que:

  • los hilos están completamente serializados por el GIL
  • los procesos pueden ejecutarse en paralelo
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
fuente
1

Aquí hay algunos datos de rendimiento para python 2.6.x que cuestionan la noción de que el subprocesamiento es más eficaz que el multiprocesamiento en escenarios vinculados a IO. Estos resultados provienen de un IBM System x3650 M4 BD de 40 procesadores.

Procesamiento IO-Bound: el grupo de procesos se desempeñó mejor que el grupo de subprocesos

>>> do_work(50, 300, 'thread','fileio')
do_work function took 455.752 ms

>>> do_work(50, 300, 'process','fileio')
do_work function took 319.279 ms

Procesamiento vinculado a la CPU: el grupo de procesos se desempeñó mejor que el grupo de subprocesos

>>> do_work(50, 2000, 'thread','square')
do_work function took 338.309 ms

>>> do_work(50, 2000, 'process','square')
do_work function took 287.488 ms

Estas no son pruebas rigurosas, pero me dicen que el multiprocesamiento no es totalmente improductivo en comparación con los subprocesos.

Código utilizado en la consola interactiva de Python para las pruebas anteriores

from multiprocessing import Pool
from multiprocessing.pool import ThreadPool
import time
import sys
import os
from glob import glob

text_for_test = str(range(1,100000))

def fileio(i):
 try :
  os.remove(glob('./test/test-*'))
 except : 
  pass
 f=open('./test/test-'+str(i),'a')
 f.write(text_for_test)
 f.close()
 f=open('./test/test-'+str(i),'r')
 text = f.read()
 f.close()


def square(i):
 return i*i

def timing(f):
 def wrap(*args):
  time1 = time.time()
  ret = f(*args)
  time2 = time.time()
  print '%s function took %0.3f ms' % (f.func_name, (time2-time1)*1000.0)
  return ret
 return wrap

result = None

@timing
def do_work(process_count, items, process_type, method) :
 pool = None
 if process_type == 'process' :
  pool = Pool(processes=process_count)
 else :
  pool = ThreadPool(processes=process_count)
 if method == 'square' : 
  multiple_results = [pool.apply_async(square,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]
 else :
  multiple_results = [pool.apply_async(fileio,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]


do_work(50, 300, 'thread','fileio')
do_work(50, 300, 'process','fileio')

do_work(50, 2000, 'thread','square')
do_work(50, 2000, 'process','square')
Mario Aguilera
fuente
He usado su código ( eliminé la parte global ) y he encontrado estos resultados interesantes con Python 2.6.6:>>> do_work(50, 300, 'thread', 'fileio') --> 237.557 ms >>> do_work(50, 300, 'process', 'fileio') --> 323.963 ms >>> do_work(50, 2000, 'thread', 'square') --> 232.082 ms >>> do_work(50, 2000, 'process', 'square') --> 282.785 ms
Alan Garrido
-5

Bueno, la mayor parte de la pregunta es respondida por Giulio Franco. Seguiré explicando el problema del consumidor-productor, que supongo que lo pondrá en el camino correcto para su solución al uso de una aplicación multiproceso.

fill_count = Semaphore(0) # items produced
empty_count = Semaphore(BUFFER_SIZE) # remaining space
buffer = Buffer()

def producer(fill_count, empty_count, buffer):
    while True:
        item = produceItem()
        empty_count.down();
        buffer.push(item)
        fill_count.up()

def consumer(fill_count, empty_count, buffer):
    while True:
        fill_count.down()
        item = buffer.pop()
        empty_count.up()
        consume_item(item)

Puede leer más sobre las primitivas de sincronización en:

 http://linux.die.net/man/7/sem_overview
 http://docs.python.org/2/library/threading.html

El pseudocódigo está arriba. Supongo que deberías buscar el problema productor-consumidor para obtener más referencias.

innosam
fuente
lo siento innosam, pero esto me parece C ++? gracias por los enlaces :)
lucacerone
En realidad, las ideas detrás del multiprocesamiento y el multiproceso son independientes del lenguaje. La solución sería similar al código anterior.
innosam
2
Esto no es C ++; es un pseudocódigo (o es un código para un lenguaje de tipo dinámico en su mayoría con una sintaxis tipo C). Dicho esto, creo que es más útil escribir un pseudocódigo similar a Python para enseñar a los usuarios de Python. (Especialmente porque el psuedocódigo similar a Python a menudo resulta ser un código ejecutable, o al menos cercano a él, lo que rara vez es cierto para el pseudocódigo tipo C ...)
abarnert
Lo he reescrito como pseudocódigo similar a Python (también usando OO y pasando parámetros en lugar de usar objetos globales); siéntase libre de revertir si cree que eso aclara las cosas.
abarnert
Además, vale la pena señalar que Python stdlib tiene una cola sincronizada integrada que envuelve todos estos detalles, y sus API de agrupación de procesos y subprocesos resumen aún más las cosas. Definitivamente vale la pena entender cómo funcionan las colas sincronizadas debajo de las cubiertas, pero rara vez necesitará escribir una usted mismo.
abarnert