Laborator 8 - Thread-uri Linux

Materiale ajutătoare

Nice to read

  • TLPI - Chapter 29, Threads: Introduction
  • TLPI - Chapter 30, Threads: Thread Synchronization
  • TLPI - Chapter 31, Threads: Thread Safety and Per-Thread Storage

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 nu partajează resurse între ele (decât dacă programatorul folosește un mecanism special pentru asta - shared memory spre exemplu), pe când firele de execuție partajează în mod implicit majoritatea resurselor unui proces. Modificarea unei astfel de resurse dintr-un fir este vizibilă instantaneu și din celelalte fire:
    • segmentele de memorie precum .heap, .data și .bss (deci și variabilele stocate în ele)
    • descriptorii de fișiere (așadar, închiderea unui fișier este vizibilă imediat pentru toate firele de execuție), indiferent de tipul fișierului:
      • sockeți
      • fișiere normale
      • pipe-uri
      • fișiere ce reprezintă dispozitive hardware (de ex. /dev/sda1).
  • fiecare fir are un context de execuție propriu, format din:
    • stivă
    • set de registre (deci și un contor de program - registrul (E)IP)

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:

  • crearea/distrugerea unui fir de execuție durează mai puțin decât crearea/distrugerea unui proces
  • durata context switch-ului între firele de execuție aceluiași proces este foarte mică, întrucât nu e necesar să se “comute” și spațiul de adrese (pentru mai multe informații, căutați „TLB flush”)
  • comunicarea între firele de execuție are un overhead mai mic (realizată prin modificarea unor zone de memorie din spațiul comun de adrese)

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:

  • Kernel Level Threads (KLT)
  • User Level Threads (ULT)
  • Fire de execuție hibride

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)
  • group - se foloseste la extinderea clasei Thread; se recomanda pastrarea valoarei None;
  • target - functia care va fi apelata la rularea threadului;
  • name - numele threadului; daca acesta nu este specificat, se va genera un nume de formaThread-N, unde e un numar;
  • args - lista de parametrii care va fi pasata functiei target; parametrii vor fi pasati in ordinea in care functia ii primeste;
  • kwargs - dictionar care va stoca parametrii pasati functiei target, sub forma numele parametru-valoare;
  • daemon - specifică dacă threadul este de tip daemon; dacă valoarea este None, proprietatea va fi moștenită de la threadul curent.

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:

  • Queue - coadă de tip FIFO, primul element inserat în coadă va fi primul extras;
  • LifoQueue - ultimul element inserat în coadă va fi primul extras;
  • PriorityQueue - Fiecare element are o prioritate, pe baza căreia se alege care element va fi cel extras;
  • SimpleQueue - coadă de tip FIFO cu funcționalități limitate;
  • Empty - se aruncă o excepție dacă se încearcă extragerea unui element când coada e goală;
  • Full - se aruncă o excepție dacă se încearcă adăugarea unui element când coada e plină.

Î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()

Thread Specific Data (TSD)

Uneori este util ca o variabilă să fie specifică unui fir de execuție (invizibilă pentru celelalte fire). Linux permite memorarea de perechi (cheie, valoare) într-o zonă special desemnată din stiva fiecărui fir de execuție al procesului curent. Cheia are același rol pe care îl are numele unei variabile: desemnează locația de memorie la care se află valoarea.

Fiecare fir de execuție va avea propria copie a unei “variabile” corespunzătoare unei chei k, pe care o poate modifica, fără ca acest lucru să fie observat de celelalte fire, sau să necesite sincronizare. De aceea, TSD este folosită uneori pentru a optimiza operațiile care necesită multă sincronizare între fire de execuție: fiecare fir calculează informația specifică, și există un singur pas de sincronizare la sfârșit, necesar pentru reunirea rezultatelor tuturor firelor de execuție.

Crearea unei variabile

Pentru a crea o variabila specifică threadului, se folosește clasa local a modulului threading.

import threading
 
local_data = threading.local()
local_data.my_var = 'SDE'

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.

sde/laboratoare/08_ro_python.txt · Last modified: 2020/04/08 13:21 by ioana_maria.culic
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0