Procesare paralelă în Python cu concurrent.futures

  • 2 minute de citit
  • 3 exemple de cod
  • 1 imagine

Disponibil și în English

Pe această pagină

Salut 👋,

În acest articol scurt vreau să vorbesc despre procesarea paralelă în Python.

Introducere

Uneori va trebui să procesezi anumite lucruri în paralel. Dacă folosești Python, s-ar putea să cunoști global interpreter lock, abreviat pe scurt GIL. GIL este un lock care permite unui singur thread să controleze Python Interpreter la un moment dat (per proces), ceea ce înseamnă că un program Python multi-threaded va avea doar un singur thread care execută cod în același timp.

Pentru a ocoli problema GIL, recomand cu tărie modulul concurrent.futures împreună cu ProcessPoolExecutor, care este disponibil din Python 3.2.

ProcessPoolExecutor vine cu unele limitări: poți executa și returna doar obiecte care pot fi pickled. Modulul pickle este folosit pentru serializarea și deserializarea obiectelor Python.

Calculul sumelor

Pentru a demonstra utilizarea ProcessPoolExecutor am scris un program simplu care calculează suma unui vector cu 1_000_000_000 de elemente. Pe mașina mea, programul se execută în 20713ms.

from time import timedef main():    elements = 1_000_000_000    arr = [i for i in range(1, elements)]    start = time()    print(sum(arr))    end = time() - start    print("Duration", end * 1000, "ms.")if __name__ == '__main__':    main()

Pentru a accelera programul, putem executa codul în paralel, în mai multe procese. În loc să calculăm suma într-un singur pas, o putem împărți în 100 de pași și putem folosi ProcessPoolExecutor pentru a executa calculul sumei pentru fiecare pas.

Rulând următorul cod:

from concurrent.futures.process import ProcessPoolExecutorfrom time import timedef compute_sum(start, stop):    arr = [i for i in range(start, stop)]    return sum(arr)def main():    start = time()    elements = 1_000_000_000    with ProcessPoolExecutor(max_workers=20) as executor:        # Steps va fi o listă de la [0, 10000000, 20000000, ..., 990000000, 1000000000]        steps = [i for i in range(0, elements + 1, elements // 100)]        # Results va stoca future-urile noastre        results = []        for step, i in enumerate(range(2, len(steps)+1)):            print("Submitting", step)            # step este de la [0, 10000000] până la [990000000, 1000000000]            step = steps[i-2:i]            # calculează suma subvectorilor, de la 0 la 10000000; Returnează un future            future = executor.submit(compute_sum, step[0], step[1])            # salvează future-ul            results.append(future)        # Preluăm rezultatele și adunăm sumele.        total_sum = 0        for r in results:            total_sum += r.result()        print("Sum", total_sum)    end = time() - start    print("Duration", end * 1000, "ms.")if __name__ == '__main__':    main()

Vor fi pornite 20 de procese Python, iar fiecare va primi un task care calculează suma între un interval de început și un interval de sfârșit:

def compute_sum(start, stop):    arr = [i for i in range(start, stop)]    return sum(arr)

Comparativ cu codul anterior, cel care folosește process pool executor rulează în doar ~7 secunde. Aceasta înseamnă o îmbunătățire de 3x a timpului!

Concluzie

Rularea programului în paralel a îmbunătățit timpul de execuție de aproape 3X pe mașina mea.

Împărțirea unei probleme în subprobleme și rezolvarea fiecăreia în mod paralel este o modalitate bună de a îmbunătăți performanța programelor tale.

În Python, dacă rulăm același cod pe mai multe thread-uri, doar un thread se execută la un moment dat, ceea ce anulează scopul rulării pe mai multe thread-uri. Pentru a ocoli această limitare, am folosit ProcessPoolExecutor din modulul concurrent.futures pentru a rula codul în mai multe procese Python și, în final, am combinat rezultatele.

Mulțumesc pentru lectură!