Python Concurrency Tutorial

Acesta este un rezumat al tutorialului meu de la PyCon 2020. Puteți găsi videoclipul original și codul sursă aici: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

https://www.youtube.com/watch?v=18B1pznaU1o

Acesta este un ghid/tutorial rapid despre cum să scrieți eficient programe concurente folosind Python. Concurrența în Python poate fi confuză. Există mai multe module (threading, _thread, multiprocessing, subprocess). Există, de asemenea, mult urâtul GIL, dar numai pentru CPython (PyPy și Jython nu au un GIL). Colecțiile nu sunt thread safe, cu excepția unor detalii de implementare cu CPython.

Obiectivul acestui tutorial este de a oferi recomandări concrete privind modul de abordare a fiecărui caz de utilizare a codului concurent (sau paralel).

Vă veți da seama că, spre deosebire de alte tutoriale Python mai tradiționale „multithreading only”, încep cu un rezumat atât al arhitecturii calculatoarelor, cât și al conceptelor de sisteme de operare. Înțelegerea acestor concepte de bază este fundamentală pentru a construi corect programe concurente. Vă rugăm să săriți peste aceste secțiuni dacă sunteți deja familiarizați cu aceste subiecte.

Acest tutorial este împărțit în următoarele secțiuni:

  • Concurență vs. paralelism
  • Arhitectura calculatoarelor
  • Rolul sistemului de operare
  • Threads cu Python
  • Sincronizarea firelor
  • Multiprocesare
  • Biblioteci de nivel înalt: concurrent.futures și parallel

Concursivitate vs paralelism

Paralelismul este atunci când mai multe sarcini rulează în același timp. Este obiectivul final al programelor concurente. Concurrența este mai puțin decât paralelismul, înseamnă că pornim mai multe sarcini și jonglăm cu ele în aceeași perioadă de timp. Cu toate acestea, la un moment dat, facem doar unul la un moment dat.

Aplicat la gătit, acestea ar fi exemple de concurență și paralelism.

Concurența are o singură persoană în bucătărie:

  • Începeți să tăiați ceapa
  • Începeți să încălziți tigaia
  • Finalizați tăierea cepei

În acest caz este clar că nu putem face mai multe lucruri ÎN ACELAȘI TIMP. Le putem începe pe toate, dar trebuie să sărim înainte și înapoi pentru a le controla.

Paralelismul are mai multe persoane în bucătărie:

  • Persoana 1 taie ceapa
  • Persoana 2 taie ardeii roșii
  • Persoana 3 așteaptă ca tigaia să se încălzească

În acest caz sunt mai multe sarcini care se fac în același timp.

Arhitectura calculatoarelor

Toate calculatoarele moderne pot fi simplificate folosind arhitectura von Neumann, care are 3 componente principale: Calculator (CPU), Memorie (RAM), I/O (hard disk-uri, rețele, ieșire video).

Arhitectura von Neumann

Codul dumneavoastră va efectua întotdeauna sarcini legate de fiecare dintre aceste componente. Unele instrucțiuni sunt „computaționale” ( x + 1), unele sunt de memorie (x = 1), iar altele sunt de I/O (fp = open('data.csv')).

Pentru a înțelege corect detaliile concurenței în Python, este important să țineți cont de diferitele „timpi de acces” ai acestor componente. Accesarea CPU este mult mai rapidă decât accesarea RAM, ca să nu mai vorbim de I/O. Există un studiu care compară latența calculatoarelor cu timpii relativi ai oamenilor: dacă un ciclu de CPU reprezintă 1 secundă de timp uman, o cerere de rețea din San Francisco până în Hong Kong durează 11 ani.

Latența la scară umană (sursă)

Diferența radicală de timp cu aceste „instrucțiuni” va fi fundamentală pentru a discuta despre firele de execuție și GIL-ul Python.

Rolul sistemului de operare

Dacă aș construi un curs universitar despre Concurrency, aș avea o întreagă prelegere care să acopere doar istoria sistemelor de operare. Este singura modalitate de a înțelege cum au evoluat mecanismele de concurență.

Primele sisteme de operare orientate către consumatori (gândiți-vă la MS-DOS) nu suportau multitasking. Cel mult, doar un singur program putea rula la un moment dat. Ele au fost urmate de sistemele „multitasking cooperativ” (Windows 3.0). Acest tip de multitasking semăna mai mult cu o „buclă de evenimente” (gândiți-vă la asyncio din Python) decât cu un program multithread modern. Într-un sistem de operare multitasking cooperativ, aplicația are sarcina de a „elibera” procesorul atunci când nu mai este necesar și de a lăsa alte aplicații să ruleze. Evident, acest lucru nu a funcționat, deoarece nu puteți avea încredere în fiecare aplicație (sau chiar în dezvoltatorii dvs. să fie suficient de ageri pentru a lua decizia corectă cu privire la momentul în care trebuie să elibereze CPU). Sistemele de operare moderne (Windows 95, Linux etc.) și-au schimbat designul de multitasking în „Preemptive Multitasking”, în care sistemul de operare este cel care decide ce aplicații primesc CPU, cât de des și pentru cât timp.

Sistemul de operare este deci cel care decide ce procese pot rula și pentru cât timp. Aceasta este ceea ce permite aplicației să aibă în continuare senzația că este „multitasking” chiar dacă are doar 1 CPU. Programatorul sistemului de operare schimbă procesele înainte și înapoi foarte repede.

În următoarea diagramă, 3 procese (P1, P2, P3) rulează simultan, împărțind același CPU (linia albastră reprezintă timpul de procesare). P1 ajunge să ruleze pentru o perioadă destul de lungă de timp înainte de a fi trecut rapid la P2, apoi la P3 și așa mai departe.

3 procese rulează „concomitent”

Procesul

Un proces este abstracțiunea pe care sistemul de operare o folosește pentru a rula codul dumneavoastră. Acesta înfășoară codul dumneavoastră în acest proces și îi atribuie memorie și alte resurse partajate. Abstracțiunea procesului permite sistemului de operare să „distingă” între programele care rulează, astfel încât acestea să creeze conflicte între ele. De exemplu, procesul 1 nu poate accesa memoria rezervată de procesul 2. De asemenea, este importantă în ceea ce privește securitatea utilizatorilor. Dacă utilizatorul 1 pornește un proces, acel proces va putea citi fișierele accesibile acelui utilizator.

Diagrama următoare conține o reprezentare vizuală a unui proces. Procesul conține codul pe care trebuie să-l execute, memoria RAM alocată și toate datele create de program (variabile, fișiere deschise, etc.).

Reprezentarea vizuală a unui proces al sistemului de operare

Acum putem vedea același „program” (aceeași bucată de cod) care rulează în mai multe procese concomitent.

Acum putem trece la faza a doua: cum se realizează de fapt concurența (sau chiar paralelismul)? Din perspectiva dumneavoastră, ca dezvoltator, există două mecanisme pe care le puteți utiliza pentru a obține „concurența”: multithreading sau multiprocesare.

Multithreading

Threads sunt fluxuri de execuție concurente în cadrul aceluiași proces. Procesul pornește mai multe fire de execuție, care „evoluează” independent în funcție de ceea ce au de făcut.

De exemplu, să spunem că avem un program care trebuie să descarce date de pe trei site-uri web și apoi să le combine pentru un raport final. Fiecare site web are nevoie de aproximativ două secunde pentru a răspunde cu datele. Un program cu un singur fir ar fi ceva de genul:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

Timpul total de execuție va fi de >6 secunde:

Un program cu un singur fir de execuție

Un program cu mai multe fire de execuție va începe să descarce datele de pe cele trei site-uri web în același timp și va aștepta până când toate acestea sunt gata pentru a face raportul final.

t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)

Executarea acum va fi aproape de ~2 secunde, deoarece cele trei site-uri web vor răspunde cam în același timp:

Un program cu mai multe fire de execuție

În Python, firele de execuție sunt create cu clasa Thread, din modulul threading, și puteți vedea câteva exemple în primul caiet din repo-ul tutorialului meu: 1. Thread Basics.ipynb.

Threads and Shared Data

Threads sunt create în mod explicit de către programul dumneavoastră, deci trăiesc în cadrul aceluiași proces. Aceasta înseamnă că ele împart toate aceleași date din proces. Ele pot citi și modifica variabilele locale și pot accesa aceeași memorie sau aceleași resurse (fișiere, socket-uri de rețea etc.).

Tread-urile împart datele procesului

Acest lucru poate cauza unele probleme, în special mutații neașteptate ale datelor partajate și Race Conditions. Puteți vedea un exemplu de condiție de cursă în caietul 2. Thread Data & Race Conditions.ipynb. În același caiet, introducem unele mecanisme de „sincronizare” a firelor de execuție pentru a evita condițiile de cursă, dar aceleași mecanisme pot introduce, potențial, problema unui Deadlock. Există un exemplu de Deadlock în caiet: 3. Deadlocks.ipynb.

Producători, consumatori și cozi de așteptare

Problemele de sincronizare pot fi rezolvate prin simpla schimbare a paradigmei.

Cel mai bun mod de a evita problemele de sincronizare este de a evita sincronizarea cu totul.

Acest lucru se realizează prin utilizarea modelului Producător-Consumator. Firele producătoare creează „sarcini în așteptare” de executat și apoi le plasează într-o coadă partajată. Firele consumator vor citi sarcinile din coada de așteptare și vor face munca efectivă.

În Python, clasa Queue din modulul queue este o coadă sigură pentru fire de așteptare care este utilizată în aceste scopuri.

Există un exemplu de model Producător-Consumator în caietul
4. Producer-Consumer model.ipynb.

The Python Global Interpreter Lock

GIL este una dintre cele mai urâte caracteristici ale Python. De fapt, nu este chiar atât de rea, dar are o reputație teribilă.

GIL este un blocaj „global” care este plasat de către interpretorul Python care garantează că doar 1 singur fir poate accesa CPU la un moment dat. Acest lucru sună stupid: de ce să vă faceți griji cu privire la codul multithreaded dacă oricum doar un singur fir va putea accesa CPU la un moment dat?

Aici este important să înțelegeți că codul dumneavoastră nu se referă numai la utilizarea CPU. Codul dvs. va efectua diferite sarcini, cum ar fi citirea fișierelor, obținerea de informații din rețea etc. Vă amintiți de timpii noștri de acces cu latență? Iată-le din nou:

Latență la scară umană (sursă)

Aceasta este cheia pentru a combate limitarea GIL. În momentul în care un fir de execuție trebuie să citească ceva dintr-un fișier, acesta poate elibera CPU și poate lăsa alte fire de execuție să ruleze. Va dura mult timp până când hard disk-ul returnează datele. acest lucru se datorează faptului că majoritatea programelor sunt ceea ce noi numim I/O-Bound. Adică, ele folosesc o mulțime de caracteristici I/O (rețea, sistem de fișiere, etc.).

Puteți vedea o „dovadă” a unei sarcini I/O bound în caietul de notițe: 5. The Python GIL.ipynb.

Multiprocesare

Alte programe sunt inevitabil CPU-Bound, cum ar fi programele care trebuie să facă multe calcule. Uneori, doriți să accelerați lucrurile. Dacă folosiți multithreading pentru sarcinile CPU-Bound, vă veți da seama că programele dvs. nu sunt mai rapide din cauza GIL (de fapt, ele pot deveni chiar mai lente).

Intrați în multiprocesare, al doilea mecanism pe care îl avem pentru a scrie programe concurente. În loc să avem mai multe fire de execuție, vom genera de fapt mai multe procese copil care se vor ocupa de realizarea unor sarcini diferite în mod concurent.

Există un exemplu de program bazat pe multiprocesare în caietul 6. Multiprocessing.ipynb

concurrent.futures

Pentru a încheia tutorialul meu, aș dori să subliniez faptul că există un modul de nivel superior care face parte din Biblioteca standard Python și care ar trebui folosit atunci când este posibil: concurrent.futures.

Acesta oferă abstracțiuni de nivel înalt pentru a crea grupuri de fire și procese, și este suficient de matur pentru a fi considerat, ori de câte ori este posibil, alternativa implicită pentru a scrie cod concurent.

Iată-l! Anunțați-mă dacă aveți întrebări. Sunt bucuros să vă răspund la ele.

Lasă un răspuns

Adresa ta de email nu va fi publicată.