Table of Contents

Laborator 09 - Thread-uri Linux

Materiale ajutătoare

Nice to read

Prezentare teoretică

În laboratoarele anterioare a fost prezentat conceptul de proces, acesta fiind unitatea elementară de alocare a resurselor utilizatorilor. În cadrul acestui laborator este prezentat conceptul de fir de execuție (sau thread), acesta fiind unitatea elementară de planificare într-un sistem. Ca și procesele, firele de execuție reprezintă un mecanism prin care un calculator poate sǎ ruleze mai multe task-uri simultan.

Un fir de execuție există în cadrul unui proces, și reprezintă o unitate de execuție mai fină decât acesta. În momentul în care un proces este creat, în cadrul lui există un singur fir de execuție, care execută programul secvențial. Acest fir poate la rândul lui sǎ creeze alte fire de execuție; aceste fire vor rula porțiuni ale binarului asociat cu procesul curent, posibil aceleași cu firul inițial (care le-a creat).

Diferențe dintre fire de execuție și procese

Procesele sunt folosite de SO pentru a grupa și aloca resurse, iar firele de execuție pentru a planifica execuția de cod care accesează (în mod partajat) aceste resurse.

Avantajele firelor de execuție

Deoarece toate firele de execuție ale unui proces folosesc spațiul de adrese al procesului de care aparțin, folosirea lor are o serie de avantaje:

Firele de execuție se pot dovedi utile în multe situații, de exemplu, pentru a îmbunătăți timpul de răspuns al aplicațiilor cu interfețe grafice (GUI), unde prelucrările CPU-intensive se fac de obicei într-un fir de execuție diferit de cel care afișează interfața.

De asemenea, ele simplifică structura unui program și conduc la utilizarea unui număr mai mic de resurse (pentru că nu mai este nevoie de diversele forme de IPC pentru a comunica).

Tipuri de fire de execuție

Din punctul de vedere al implementării, există 3 categorii de fire de execuție:

Detalii despre categoriile de fire de execuţie

Detalii despre categoriile de fire de execuţie

Kernel Level Threads

Managementul și planificarea firelor de execuție sunt realizate în kernel; programele creează/distrug fire de execuție prin apeluri de sistem. Kernel-ul menține informații de context, atât pentru procese, cât și pentru firele de execuție din cadrul proceselor, iar planificarea execuției se face la nivel de fir.

Avantaje :

  • dacă avem mai multe procesoare putem lansa în execuție simultană mai multe fire de execuție ale aceluiași proces;
  • blocarea unui fir nu înseamnă blocarea întregului proces;
  • putem scrie cod în kernel care să se bazeze pe fire de execuție.

Dezavantaje :

  • comutarea contextului este efectuată de kernel (cu o viteză de comutare mai mică):
    • se trece dintr-un fir de execuție în kernel
    • kernelul întoarce controlul unui alt fir de execuție.

User Level Threads

Kernel-ul nu este conștient de existența firelor de execuție, iar managementul acestora este realizat de procesul în care ele există (implementarea managementului firelor de execuție este realizată de obicei în biblioteci). Schimbarea contextului nu necesită intervenția kernel-ului, iar algoritmul de planificare depinde de aplicație.

Avantaje :

  • schimbarea de context nu implică kernelul ⇒ comutare rapidă
  • planificarea poate fi aleasă de aplicație; aplicația poate folosi acea planificare care favorizează creșterea performanțelor
  • firele de execuție pot rula pe orice SO, inclusiv pe SO-uri care nu suportă fire de execuție la nivel kernel (au nevoie doar de biblioteca care implementează firele de execuție la nivel utilizator).

Dezavantaje :

  • kernel-ul nu știe de fire de execuție ⇒ dacă un fir de execuție face un apel blocant toate firele de execuție planificate de aplicație vor fi blocate. Acest lucru poate fi un impediment întrucât majoritatea apelurilor de sistem sunt blocante. O soluție este utilizarea unor variante non-blocante pentru apelurile de sistem.
  • nu se pot utiliza la maximum resursele hardware: kernelul planifică firele de execuție de care știe, câte unul pe fiecare procesor. Kernelul nu este conștient de existența firelor de execuție user-level ⇒ el va vedea un singur fir de execuție ⇒ va planifica procesul respectiv pe maximum un procesor, chiar dacă aplicația ar avea mai multe fire de execuție planificabile în același timp.

Fire de execuție hibride

Aceste fire încearcă să combine avantajele firelor de execuție user-level cu cele ale firelor de execuție kernel-level. O modalitate de a face acest lucru este de a utiliza fire kernel-level pe care să fie multiplexate fire user-level. KLT sunt unitățile elementare care pot fi distribuite pe procesoare. De regulă, crearea firelor de execuție se face în user space și tot aici se face aproape toată planificarea și sincronizarea. Kernel-ul știe doar de KLT-urile pe care sunt multiplexate ULT, și doar pe acestea le planifică. Programatorul poate schimba eventual numărul de KLT alocate unui proces.

Modulul threading

În ceea ce privește firele de execuție, POSIX nu specifică dacă acestea trebuie implementate în user-space sau kernel-space. Linux le implementează în kernel-space, dar nu diferențiază firele de execuție de procese decât prin faptul că firele de execuție partajează spațiul de adresă (atât firele de execuție, cât și procesele, sunt un caz particular de “task”). Pentru folosirea firelor de execuție în Python trebuie să includem modulul threading.

Crearea firelor de execuție

Modulul threading expune clasa Thread. Astfel, un fir de execuție este creat prin instantierea clasei:

import threading
threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

Noul fir creat poate fi lansat prin apelarea functiei start(). Acesta va executa codul specificat de funcția target căreia i se vor pasa argumentele din args sau kwargs.

Pentru a determina firului de execuție curent se poate folosi funcția current_thread:

import threading
threading.current_thread()

Așteptarea firelor de execuție

Firele de execuție se așteaptă folosind funcția join:

t = threading.Thread()
t.join(timeout=None)

Odata ce un thread a facut join pe un altul, acesta se va bloca pana threadul pe care s-a facut join isi va incheia executia. Daca threadul pe care s-a facut join va arunca o exceptie de tipul excepthook va fi aruncata in threadul care a facut join.

Este recomandat ca programul principal să apeleze întotdeauna funcția join pentru threadurile pe care le generează.

Fire de execuție de tip daemon

Un thread de tip daemon are drept scop procesarea unor operații fară a impacta firul de execuție principal.

Proprietatea principală a acestor threaduri este că programul principal își va încheia execuția dacă rămân doar threaduri de acest tip.

Deși este o operație legală, se recomandă să nu folosiți funcția join pe un thread de tip daemon.

Terminarea firelor de execuție

Un fir de execuție își încheie execuția în mod automat, la sfârșitul codului firului de execuție.

Interacțiunea cu firele de execuție

Pentru a schimba informații între programul principal si alte threaduri, vom folosi modulul queue. Folosind acest modul, putem implementa o coadă în care vom plasa mesajele din partea firelor de execuție.

Modululul suportă crearea a 6 tipuri diferite de cozi:

În continuare vom discuta doar despre primele 3 tipuri de cozi, la restul aplicându-se alte operații. Pentru mai multe detalii despre restul claselor din modulul queue, vă recomandăm citirea documentației.

Pentru a crea o coadă, trebuie să instanțiem una din clasele expuse de modulul queue:

import queue
 
fifo_queue = queue.Queue()
lifo_queue = queue.LifoQueue()
priority_queue = queue.PriorityQueue()

Funcțiile put și put_nowait adaugă elemente în coadă.

q.put (item, block=True, timeout=None)
q.put_nowait (item)

Funcțiile get și get_nowait extrag elemente în coadă.

q.get (block=True, timeout=None)
q.get_nowait ()

Dacă coada este goală la apelarea uneia din cele două funcții, o excepție de tipul Empty este generată.

Detalii despre așteptarea cozilor

Detalii despre așteptarea cozilor

Funcția join

Similar cu așteptarea firelor de execuție, putem să apelăm funcția join pe o coadă, pentru a opri execuția programului până toate elementele din coadă au fost procesate.

Funcția join trebuie apelată în paralel cu funcția task_done. Fiecare thread va apela această funcție pentru a semnala terminarea procesării elementului extras din coadă. În momentul în care numărul de apeluri task_done este egal cu numărul de elemente care a fost adăugat în coadă, programul care a apelat join își va continua execuția.

Exemplu

Mai jos vom crea un program care generează un thread ce primește valori de la programul principal și răspunde la aceastea.

import queue
import threading
 
def worker ():
    while True:
        item = q1.get()
        if item == 'Hello':
            print (item)
            q2.put ('SDE')
        elif item == 'Good':
            print (item)
            q2.put ('bye')
        else:
            break
 
q1 = queue.Queue()
q2 = queue.Queue()
 
t = threading.Thread (target=worker)
t.start ()
 
q1.put ('Hello')
print (q2.get ())
 
q1.put ('Good')
print (q2.get ())
 
q1.put ('exit')
 
t.join()

Exerciţii de laborator

Exercițiul 1

a. Creați un program care pornește 5 fire de execuție. Fiecare fir de execuție afișeaƶă numerele de la 0 la 5.

b. Afișați identificatorul fiecărui fir de execuție (hint: ident).

Exercițiul 2

Creați un program care primește 3 parametrii din linia de comandă și pornește 3 fire de execuție. Fiecare fir de execuție va primi ca parametru de la programul principal unul din cele 3 numere si va afișa pe ecran par sau impar, în funcție de paritatea numărului.

Exercițiul 3

În fișierul din directorul 3-numbers, porniți un thread care afișează numerele de la 0 la 5 la interval de o secundă.

Nu folosiți funcția join.

Observați când procesul își termină execuția. Modificați programul principal pentru a își înceta execuția imediat după ce afișează cele două linii

main 1
main 2

Exercițiul 4

În fișierul din directorul 4-alive, porniți câte un thread pentru fiecare funcție worker definită si așteptați doar după threadurile inca in executie (hint: is_alive).

Exercițiul 5

Creați două threaduri, unul care să genereze numerele prime de la 0 la 50 și unul care să genereze numerele patrate perfecte de la 0 la 50. Programul principal va afișa numerele generate de cele două threaduri, dupa care isi va incheia executia.

Exercițiul 6

Creați un program care primește comenzi bash de la tastatură și le execută intr-un thread.

Exercițiul 7

Creati un program care foloseste 3 threaduri pentru a găsi maximul dintr-un șir de lungime 100000. Fiecare thread citeste valori din sir printr-o coada și pune intr-o altă coadă maximul. Folositi comanda queue.join pentru sincronizare.