¿Cuáles son las diferencias entre los módulos de subprocesamiento y multiprocesamiento?
Estoy aprendiendo a usar los módulos threading
y multiprocessing
en Python para ejecutar ciertas operaciones en paralelo y acelerar mi código.
Me resulta difícil (tal vez porque no tengo ninguna base teórica al respecto) entender cuál es la diferencia entre un threading.Thread()
objeto y multiprocessing.Process()
uno.
Además, no me queda del todo claro cómo instanciar una cola de trabajos y tener solo 4 (por ejemplo) de ellos ejecutándose en paralelo, mientras el otro espera a que se liberen recursos antes de ejecutarse.
Los ejemplos de la documentación los encuentro claros, pero no muy exhaustivos; Tan pronto como intento complicar un poco las cosas, recibo muchos errores extraños (como un método que no se puede conservar en vinagre, etc.).
Entonces, ¿cuándo debo utilizar los módulos threading
y ?multiprocessing
¿Puedes vincularme a algunos recursos que expliquen los conceptos detrás de estos dos módulos y cómo usarlos correctamente para tareas complejas?
Lo que dice Giulio Franco es cierto para el multiproceso versus el multiprocesamiento en general .
Sin embargo, Python * tiene un problema adicional: hay un bloqueo de intérprete global que evita que dos subprocesos 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 subprocesos, no podrá usar el 800% de la CPU y ejecutarse 8 veces más rápido; utilizará el mismo 100% de CPU y se ejecutará a la misma velocidad. (En realidad, se ejecutará un poco más lento, porque hay una sobrecarga adicional debido a los subprocesos, incluso si no tiene ningún dato compartido, pero ignórelo por ahora).
Existen excepciones para esto. Si el cálculo pesado de su código en realidad no ocurre en Python, sino en alguna biblioteca con código C personalizado que maneja adecuadamente GIL, como una aplicación numpy, obtendrá el beneficio de rendimiento esperado del subprocesamiento. Lo mismo ocurre si el cálculo pesado lo realiza algún subproceso que usted 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 de la red y una aplicación GUI pasa la mayor parte de su tiempo esperando eventos de usuario. Una razón para utilizar subprocesos en un servidor de red o una aplicación GUI es permitirle realizar "tareas en segundo plano" de larga duración sin impedir que el subproceso principal continúe atendiendo paquetes de red o eventos GUI. Y eso funciona bien con los subprocesos de Python. (En términos técnicos, esto significa que los subprocesos de Python le brindan concurrencia, aunque no le brindan paralelismo central).
Pero si estás escribiendo un programa vinculado a la CPU en Python puro, usar más subprocesos 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 existen las mismas compensaciones entre subprocesos y procesos que en cualquier otro lenguaje: es más difícil y más costoso compartir datos entre procesos que entre subprocesos, puede resultar costoso ejecutar una gran cantidad de procesos o crearlos y destruirlos. ellos con frecuencia, etc. Pero el GIL pesa mucho en la balanza hacia los procesos, de una manera que no es cierta para, digamos, 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 subprocesos y procesos con un cambio de una sola 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.futures
biblioteca para escribir su código en torno a 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 a que las cosas estén en orden de ejecución o de finalización, etc.; lea la sección sobre Future
objetos 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 está topando con el problema de GIL, por lo que necesita cambiar a 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 y valores de retorno de sus trabajos deben ser seleccionables (y no tomar demasiado tiempo o memoria para seleccionarlos) para que se puedan utilizar en procesos cruzados. Normalmente esto no es un problema, pero a veces sí lo es.
Pero ¿qué pasa si sus trabajos no pueden ser autónomos? Si puedes diseñar tu código en términos de trabajos que pasan mensajes de uno a otro, sigue siendo bastante fácil. Es posible que tengas que utilizar threading.Thread
o multiprocessing.Process
en lugar de depender de grupos. Y tendrás que crear queue.Queue
u multiprocessing.Queue
objetos explícitamente. (Hay muchas otras opciones: tuberías, enchufes, limas con bandadas,... pero el punto es que tienes que hacer algo manualmente si la magia automática de un Ejecutor es insuficiente).
Pero ¿qué pasa si ni siquiera puedes confiar en la transmisión de mensajes? ¿Qué pasa si necesita dos trabajos para mutar la misma estructura y ver los cambios de cada uno? En ese caso, necesitará realizar una sincronización manual (bloqueos, semáforos, condiciones, etc.) y, si desea utilizar procesos, objetos explícitos de memoria compartida para arrancar. Aquí es cuando el multiproceso (o multiprocesamiento) se vuelve difícil. Si puedes evitarlo, genial; Si no puede, necesitará leer más de lo que alguien puede incluir en una respuesta SO.
A partir de un comentario, querías saber cuál es la diferencia entre subprocesos y 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:
- Los hilos comparten datos de forma predeterminada; los procesos no.
- Como consecuencia de (1), el envío de datos entre procesos generalmente requiere decaparlos y deseleccionarlos. **
- Como otra consecuencia de (1), compartir datos directamente entre procesos generalmente requiere colocarlos en formatos de bajo nivel como Valor, Matriz y
ctypes
tipos. - Los procesos no están sujetos al GIL.
- En algunas plataformas (principalmente Windows), los procesos son mucho más costosos de crear y destruir.
- Existen algunas restricciones adicionales en los procesos, algunas de las cuales son diferentes en diferentes plataformas. Consulte las pautas de programación para obtener más detalles.
- El
threading
módulo no tiene algunas de las características delmultiprocessing
módulo. (Puede usarmultiprocessing.dummy
para obtener la mayor parte de la API que falta además de los subprocesos, o puede usar módulos de nivel superior comoconcurrent.futures
y no preocuparse por eso).
* En realidad, no es Python, el lenguaje, el que tiene este problema, sino CPython, la implementación "estándar" de ese lenguaje. Algunas otras implementaciones no tienen GIL, como Jython.
** Si está utilizando el método de inicio de bifurcación para multiprocesamiento (lo cual puede hacer en la mayoría de las plataformas que no son Windows), cada proceso secundario obtiene los recursos que tenía el padre cuando se inició el secundario, lo que puede ser otra forma de pasar datos a los secundarios.
Pueden existir varios subprocesos en un solo proceso. Los subprocesos 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 la memoria y cada uno de ellos tiene sus propias variables. Para comunicarse, los procesos tienen que utilizar otros canales (archivos, tuberías o sockets).
Si desea paralelizar un cálculo, probablemente necesitará subprocesos múltiples, porque probablemente desee que los subprocesos cooperen en la misma memoria.
Hablando de 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 escribir en la memoria de los demás, pero la forma en que esto sucede no siempre es obvia (debido a varios factores, principalmente reordenamiento de instrucciones y almacenamiento en caché de memoria), por lo que necesitará primitivas de sincronización para controlar el acceso. a tus variables.
Citas de documentación de Python
He resaltado las citas clave de la documentación de Python sobre Process vs Threads y GIL en: ¿ Qué es el bloqueo global del intérprete (GIL) en CPython?
Experimentos de proceso versus hilo
Hice un poco de evaluación comparativa para mostrar la diferencia de manera más concreta.
En el punto de referencia, cronometré el trabajo vinculado a CPU y IO para varios números de subprocesos en una CPU de 8 hiperprocesos . El trabajo suministrado por subproceso es siempre el mismo, de modo que más subprocesos significa más trabajo total suministrado.
Los resultados fueron:
Datos de la trama .
Significado de cada sección:
"CPU vinculado": cuánto tiempo tardan todos los subprocesos/procesos en finalizar una cantidad fija de trabajo vinculado a la CPU para cada subproceso. La cantidad de trabajo por subproceso es fija, por lo que, por ejemplo, cuando se utilizan 2 subprocesos, se realiza el doble del trabajo total.
Interpretación: los subprocesos siempre fueron más lentos porque luchaban por el bloqueo de la CPU
"CPU enlazada/subprocesos": el gráfico anterior dividido por el número de subprocesos. Esto da el tiempo promedio que tomó terminar cada unidad de trabajo. Observamos que:
para los subprocesos, esto es constante: el doble de trabajo tarda el doble de tiempo en finalizar. Por lo tanto, no hubo ningún paralelismo.
para los procesos, esto disminuye hasta 4x y luego permanece constante. Por lo tanto, se paralelizó bien hasta 4x y pudo ejecutar las cosas más rápido, pero no escaló más allá de eso.
Habría esperado escalar hasta 8x ya que estoy en una máquina Hyperthread de 4 núcleos y 8.
Compare esto con un trabajo vinculado a la CPU C POSIX que alcanza la aceleración esperada de 8x: ¿Qué significan "real", "usuario" y "sys" en la salida de time(1)?
TODO: No sé el motivo de esto, debe haber otras ineficiencias de Python en juego.
"Relación hilo/proceso": relación de las dos líneas anteriores. Esto nos muestra muy claramente el límite de aceleración de 4x.
"Enlazado a IO": igual que "vinculado a CPU" pero con una tarea vinculada a IO
Conclusiones:
- para el trabajo vinculado a la CPU, el multiprocesamiento siempre es más rápido, presumiblemente debido al GIL
- para trabajos vinculados a IO. ambos tienen exactamente la misma velocidad
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 + trazado de código 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 subprocesos), RAM: 2x Samsung M471A2K43BB1-CRC (2x 16GiB), SSD: Samsung MZVLB512HAJQ- 000L7 (3000 MB/s).
Visualice qué subprocesos se están ejecutando en un momento dado
Esta publicación https://rohanvarma.me/GIL/ me enseñó que puedes 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 termine esto, veremos algo como (hice este gráfico en particular):
+--------------------------------------+
+ Active threads / processes +
+-----------+--------------------------------------+
|Thread 1 |******** ************ |
| 2 | ***** *************|
+-----------+--------------------------------------+
|Process 1 |*** ************** ****** **** |
| 2 |** **** ****** ** ********* **********|
+-----------+--------------------------------------+
+ Time --> +
+--------------------------------------+
lo que mostraría que:
- Los hilos están completamente serializados por GIL.
- Los procesos pueden ejecutarse en paralelo.
Creo que este enlace responde a tu pregunta de una manera elegante.
Para ser breve, si uno de sus subproblemas tiene que esperar mientras otro finaliza, el subproceso múltiple es bueno (en operaciones pesadas de E/S, por ejemplo); por el contrario, si sus subproblemas realmente pudieran ocurrir al mismo tiempo, se sugiere el multiprocesamiento. Sin embargo, no creará más procesos que su número de núcleos.