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".
de Bálint Ákos
de Andrei Oneț
de Raul Boldea
de Ioana Varga