ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 150
Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 59
Abonament PDF

O scurtă introducere în programarea .Net Multithreading

Dan Sabadis
Team Lead @ SDL



PROGRAMARE


Java și .Net sunt sisteme de operare în miniatură care se ocupă de scenarii complexe precum alocare de memorie, cleanup (vezi Garbage Collector) și acces simultan la resurse (.Net/Java Managed Threads). Azi ne vom axa pe subiectul "cu greutate" al multi-threadingului într-un mediu enterprise mare. Deoarece experienţa mea anterioară s-a axat pe .Net, am ales să prezint exemple din C# , dar aceleași concepte, clase și abstracţiuni descrise mai jos au un echivalent aproape identic în Java.

Cele mai importante părţi din orice prezentare sunt definiţiile, deci trebuie să definim threadul. Răspunsul este surprinzător de complex, iar ca o precondiţie trebuie să definim ce este un program de calculator și un proces!

Un program de calculator este un set de instrucţiuni care trebuie dat oricărei mașini computaţionale. O mașină computaţională poate fi din silicon/metal, ca un robot sau un computer personal. Aceasta poate fi la fel de bine și un robot "biologic", precum sunt oamenii sau animalele. Printre exemplele de programe pe calculator pentru oameni găsim reţetele de gătit, un manual de prim ajutor în cazul rănilor sau un model de cusut. Printre exemplele aplicabile programelor de calculator pentru computerele "normale", de silicon, găsim un set de instrucţiuni care trebuie citite ca să se afișeze apoi un text pe ecran sau instrucţiuni de împletit și cusut pentru mașini de cusut japoneze (din metal).

Procesul constă în executarea unor acţiuni de către calculator. Procesul este un program în stare de execuţie! De multe ori, procesele sunt complexe, alcăuite din pași ce trebuie realizaţi în paralel. De exemplu, o reţetă de gătit poate instrui un individ să pună condimente în supă și, în același timp, să învârtă lichidul până când devine roșu. În acest caz, procesul este compus din două subprocese paralele sau două fire de execuţie (threads of execution) (sau procese ușoare în LINUX): amestecatul cu lingura și punerea condimentelor.

Deci, se poate spune că un thread este un subproces atomic, un simplu fir de execuţie ce nu poate fi împărţit în alte subprocese sau alte sub-threaduri! În Java sau .Net un thread poate avea multe stări (vezi imaginea de mai jos), dar pentru scopul acestei prezentări ne vom axa doar pe trei dintre ele: Running, Blocked și Frozen (sau WaitSleepJoin).

Funcţia rutină de mai jos se folosește sincron pentru a face o solicitare HTTP către un site web (sau un URI):

public static string Synchronous_WebDownload(string siteAddress)
{
 using (WebClient client = 
   new WebClient())
  {
    return client.
    DownloadString(siteAddress);
    }
}

Pe baza adresei utilizate ca parametru, interogarea sincronă web poate dura de la câteva milisecunde la o perioadă mai lungă de timp (secunde sau chiar minute, exemplele de mai jos).

String googleContent = 
  Synchronous_WebDownload(
  "http://www.google.com");

String yahooContent  = 
  Synchronous_WebDownload(
  "http://www.yahoo.com");

String localWebContent  = 
  Synchronous_WebDownload(
  "http://127.0.0.1");

De asemenea, acest tip de interogare web va face ca firul care apelează (calling thread) să intre în statusul "blocked" până când serverul Google, Yahoo sau local vor returna tot conţinutul site-lui web. Acest aspect nu pune probleme pentru o aplicaţie simplă, dar, pentru un site web mare care deservește sute de interogări pe secundă, un apel ca cel de mai sus poate cauza probleme grave. Iată de ce.

Să presupunem că avem un site web care suportă 10 apeluri pe secundă, iar în interiorul fiecărui apel site-ul nostru face un singur apel către metoda Synchronous_WebDownload descrisă mai sus. Prin urmare cele 10 apeluri, fiecare deservit de propriul thread, vor fi blocate și pe măsură ce pe site vor ajunge și mai multe apeluri - care vor trebui deservite - Common Language Runtime-ul (CLR) va crea threaduri mai multe, fiecare cu un segment alocat de memorie (peste 1 MB de memorie pentru fiecare thread din .Net CLR). Dacă noile apeluri nu sunt suficient de rapide, se vor crea noi threaduri până când memoria este plină și vor apărea excepţiile OutOfMemory. După un timp, serverul nostru web nu va mai răspunde. Prin urmare, abordarea descărcării sincrone pe web, deși extrem de simplă, în cazul serviciilor cu trafic mare necesită o nouă abordare: lumea celor trei tehnici rendezvous.

Toate aceste 3 tehnici rendezvous (rendezvous = un loc unde thread-urile se întâlnesc) se bazează pe cea mai importantă interfaţă pentru sincronizare de thread în .Net, IAsyncResult:

  public interface IAsyncResult
  {
    bool IsCompleted { get; }
    WaitHandle AsyncWaitHandle { get; }
    object AsyncState { get; }
    bool CompletedSynchronously { get; }
  }

Prima tehnică se numește "BeginCall-EndCall".

Metoda de mai jos citește un fișier fizic de pe un hard drive local și afișează conţinutul pe consolă. După cum se poate vedea, fișierul citit nu mai este sincron ca în exemplul anterior, ci asincron: fs.BeginRead! Clasa FileStream are o metodă corespondentă sincronă numită simplu Read, pe care am ales să o redenumim BeginRead. Acest apel asincron se va întoarce imediat, iar operația de citire nu va mai fi blocată ca atunci când se apelează funcția Read!

public static byte[] 
 BeginEndXXX_Rendezvous_FileRead()
{
  byte[] buffer = new byte[100];
  string filename = String.Concat(
  Environment.SystemDirectory, "ntdll.dll");
  FileStream fs = new FileStream(filename, 
   FileMode.Open, FileAccess.Read, FileShare.Read, 
   1024, FileOptions.Asynchronous);

  IAsyncResult result = fs.BeginRead(buffer, 0, 
    buffer.Length, null, null);
  int numBytes = fs.EndRead(result);

  fs.Close();
  Console.WriteLine(
    $"From {filename} We Read {numBytes}  Bytes:");
  Console.WriteLine(BitConverter.ToString(buffer));

  return buffer;
}

Deci, unde se află conținutul fișierului? Acesta nu este încă finalizat și accesibil, deoarece operația de citire este doar inițiată nu și finalizată. Totuși, există un fel de referință către fișier via variabila rezultat IAsyncResult returnată de funcția asincronă BeginRead().

După cum se poate observa, imediat după apelul BeginRead, apelăm metoda EndRead. Acest apel va îngheța "magic" threadul care apelează în acest moment, până când operația de citire de fișier este finalizată. Acesta este marele avantaj al acestei prime abordări asincrone comparativ cu apelul sincron precedent: threadul este înghețat, pus pe pauză, apoi trezit automat. Threadul nu este blocat.

A doua tehnică se numește "Wait until done".

După cum se vede din metoda de mai jos, cea mai mare diferență este că, după ce se apelează BeginRead, nu apelă EndRead imediat și nu punem threadul pe pauză, ci interogăm proprietatea IsCompleted a variabilei IAsyncResult returnată de BeginRead. Astfel, putem face alte operații utile până când citirea asincronă este finalizată. Când execuția ajunge la EndRead, thread-ul care rulează în acest moment nu va mai fi pus pe pauză, deoarece suntem siguri că citirea asincronă a fost deja finalizată (când iese din bucla while).

public static byte[] 
  WaitUntilDone_Rendezvous_FileRead()
{
  byte[] buffer = new byte[1000];
  string filename = String.Concat(
    Environment.SystemDirectory, "ntdll.dll");

  FileStream fs = new FileStream(filename, 
    FileMode.Open, FileAccess.Read, 
    FileShare.Read, 1024, FileOptions.Asynchronous);

  fs.Read(buffer, 0, buffer.Length);
  IAsyncResult result = fs.BeginRead(buffer, 0, 
     buffer.Length, null, null);

  while (!result.IsCompleted)
  {
  // do some work here if the call isn't completed
  Console.Write(".");
  }
  int numBytes = fs.EndRead(result);
  fs.Close();
  Console.WriteLine(
    $"From {filename} We Read {numBytes}  Bytes:");

  Console.WriteLine(BitConverter.ToString(buffer));

  return buffer;
}

A treia tehnică se numește "Callback Rendezvous"

În loc de a avea o metodă ca în cele două scenarii asincrone descrise mai sus, aici avem două metode Callback_RendezVous_FileRead și ReadIsDone:

public static void Callback_Rendezvous_FileRead()
{
  string filename = String.Concat(
    Environment.SystemDirectory, "\\ntdll.dll");

  FileStream fs = new FileStream(filename, 
    FileMode.Open, FileAccess.Read, 
    FileShare.Read, 1024, FileOptions.Asynchronous);

// Initiate an asynchronous read operation against /
// the FileStream
// Pass the FileStream (fs) to the callback method 
// (ReadIsDone)
  fs.BeginRead(__buffer, 0, __buffer.Length, 
    ReadIsDone, fs);

  Console.WriteLine($"main thread "+
    "ID={Thread.CurrentThread.ManagedThreadId}");
  Console.ReadLine();
}

private static void ReadIsDone(IAsyncResult ar)
{
  // Show the ID of the thread executing ReadIsDone
  Console.WriteLine($"ReadIsDone thread "+
    "ID={Thread.CurrentThread.ManagedThreadId}");

  // Extract the FileStream (state) out of the 
  // IAsyncResult object
  FileStream fs = (FileStream) ar.AsyncState;

  // Get the result
  Int32 bytesRead = fs.EndRead(ar);
  // No other operations to do, close the file
  fs.Close();

  // Now, it is OK to access the byte array and 
  // show the result.
  Console.WriteLine("Number of bytes read={0}", 
    bytesRead);

  Console.WriteLine(BitConverter.ToString(__buffer, 
    0, __buffer.Length));
}

Din punctul de vedere al performanței, tehnica "Callback Rendezvous" este cea mai bună din cele trei abordări prezentate. Prima metodă, Callback_RendezVous_FileRead, inițiază citirea asincronă a fișierului cu un clasic BeginRead și este returnată imediat. Dacă vedeți că se transmit parametri metodei BeginRead, urmează ca referința să fie transmisă metodei ReadIsDone! Astfel se garantează că atunci când operația I/O este finalizată, metoda ReadIsDone va fi apelată, iar această funcție trebuie să aibă un argument IAsyncResult. Din acest argument putem obține FileStreamul original care a inițiat operația de citire și se poate accesa tot conținutul citit. În ceea ce privește performanța, aceasta este cea mai bună abordare, deoarece nu așteptăm nici în stare de inactivitate (înghețare) ca în primul scenariu, nici într-o stare de așteptare, ca în al doilea scenariu unde am așteptat în bucla while. Cel mai mare dezavantaj al celei de-a treia abordări este complexitatea codului și degradarea designului. Nu avem o singură metodă ca înainte, ci două, și pe măsură ce scenariile devin din ce în ce mai complexe, programul poate deveni cod-spaghetti.

Pentru a preveni această diviziune (sau "spaghetizare") a apelurilor asincrone, creatorii frameworkului .Net au implementat o nouă librărie multi-thread în .Net versiunea 4.0, așa-zisul TPL - Task Parallel Library.

Să presupunem că dorim să apelăm site-ul web Yahoo, iar odată returnat rezultatul, vom folosi acel rezultat pentru a apela site-ul web Google.

Cu tiparul callback rendezvous am avea nevoie de trei funcții: prima, Async_Callback_YahooWebDownload,inițiază apelul Yahoo; a doua, Yahoo_DownloadStringCompleted, este callbackul ce va fi invocat pe site-ul web Yahoo pentru a returna rezultatul și a fi imediat invocat de site-ul web Google; a treia metodă va fi al treilea callback Google_DownloadStringCompleted invocat automat când site-ul web Google returnează rezultatul.

public static void Async_Callback_YahooWebDownload()
{
  WebClient client = new WebClient();
  client.DownloadStringAsync(
    new Uri("http://www.yahoo.com"));

  client.DownloadStringCompleted += 
    Yahoo_DownloadStringCompleted;

  Console.ReadLine();
}

private static void 
  Yahoo_DownloadStringCompleted(object sender, 
  DownloadStringCompletedEventArgs e)
{
  string content = e.Result;
  ((WebClient) sender).Dispose();

  Console.WriteLine($"content={content}");

  WebClient client = new WebClient();
  client.DownloadStringAsync(
    new Uri("http://www.google.com"));

  client.DownloadStringCompleted += 
    Google_DownloadStringCompleted;
}

private static void Google_DownloadStringCompleted (object sender, DownloadStringCompletedEventArgs e)
{
  string content = e.Result;
  ((WebClient) sender).Dispose();

  Console.WriteLine($"content={content}");
}

Observați că există deja o îmbunătățire în capacitatea de citire deoarece creatorii .Net au pus la dispoziție două metode pentru scenariul callback al clasei WebClient: DownloadStringAsync și DownloadStringCompleted. Comparați codul de mai sus cu codul de mai jos:

var yahooTask = client.DownloadDataTaskAsync(
  new Uri("http://www.yahoo.com"));

var googleTask = yahooTask.
  ContinueWith(initialYahooTask => client.
  DownloadDataTaskAsync(
  new Uri("http://www.google.com")));

googleTask.Wait();
var googleByteResult = googleTask.Result.Result;
Console.WriteLine($"content={
  Encoding.UTF8.GetString(googleByteResult)}");

Acest cod bazat pe taskuri pare mai compact, mai ușor de citit și mai sincron datorită task-urilor, care practic încapsulează threaduri împreună cu logica de sincronizare.

Odată cu .Net 4.5, simplitatea și caracterul citibil al apelurilor asincrone merg chiar și mai departe cu tiparul (patternul) async-await:

public static async void 
  Async_Await_Task_Continuation_WebDownload()
{
WebClient client = new WebClient();
var googleResult = await client.
  DownloadDataTaskAsync(
  new Uri("http://www.google.com"));

var stringGoogle = Encoding.UTF8.GetString(googleResult);
Console.WriteLine($"content={stringGoogle}");

var yahooResult = await client.DownloadDataTaskAsync(
  new Uri("http://www.yahoo.com"));

Console.WriteLine($"content={
 System.Text.Encoding.UTF8.GetString(yahooResult)}");
}

Tendința de a face funcțiile I/O asincrone mai citibile și cu aspect sincron se manifestă nu doar în mediile .Net/Java, ci și în Javascript. În Javascript/ECMAscript, abstracția Promises a fost introdusă în implementarea ECMA 6 care imită clasa Task prezentată anterior. Abordarea async-await va fi implementată complet în varianta standard a ECMA 7.

Într-un articol viitor, vom aborda constructele de sincronizare ce fac implementările IAsyncResult posibile, anume clasele kernel-mode WaitHandle: ManualResetEvent și AutoResetEvent, precum și metodele Monitor.Enter/Pulse. Vom compara kernel-mode cu constructe sincrone user-mode, precum "volatile" și "Interlocked".

Continuare, partea a II-a

Linkuri utile

  1. https://www.codeproject.com/Articles/28785/Thread-synchronization-Wait-and-Pulse-demystified
  2. https://www.amazon.com/CLR-via-4th-Developer-Reference

NUMĂRUL 149 - Development with AI

Sponsori

  • Accenture
  • BT Code Crafters
  • Accesa
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • Colors in projects