Към съдържанието
wannacode

Защо нишки

Накратко: Нишката е отделен ред на изпълнение в програмата; нишките ускоряват работа, която чака (мрежа, диск), дори на едно ядро, а работа, която смята, иска повече ядра.

Проблем

Клиентът чака три пъти

Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 200 ms.

Тук мрежата е Network. Call чака 200 ms и връща отговора на услугата. Програмата пита трите една след друга:

Network net = new Network();
string payment = net.Call("плащане", "прието");
string stock = net.Call("склад", "има");
string email = net.Call("имейл", "изпратен");
Console.WriteLine(payment);
Console.WriteLine(stock);
Console.WriteLine(email);
Console.WriteLine($"Кръгове чакане: {net.Rounds}");
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 3

Всяко питане започва, след като предишното е свършило. Клиентът чака сбора: около 600 ms. А през цялото време процесорът не прави нищо: само чака отговор.

Network брои и кръговете чакане: нов кръг започва, когато програмата пита, а никой друг отговор не се чака. Тук са три, по един за всяко питане. В урока бързината се мери с този брой, а не с милисекунди: времето зависи от машината, а броят кръгове е един и същ при всяко пускане.

По-точно, за любопитните

Така работи Network. lock е от урока „Нишки“: двата брояча се пипат от няколко нишки наведнъж. Защо без него се губят числа, е следващият урок, „Race condition“.

class Network
{
private readonly object sync = new object();
private int inFlight = 0;
private int rounds = 0;
// Пита услуга по мрежата: отговорът идва след ~200 ms.
public string Call(string service, string answer)
{
lock (sync)
{
if (inFlight == 0) rounds++; // никой не чака: нов кръг
inFlight++;
}
Thread.Sleep(200);
lock (sync)
{
inFlight--;
}
return $"{service}: {answer}";
}
public int Rounds
{
get { lock (sync) return rounds; }
}
}

Идеята

Работа, която чака, и работа, която смята

Работата на една програма е от два вида.

  • Работа, която чака (I/O-bound): заявка към банката, заявка към базата, четене на файл, изпращане на имейл. Програмата пита и стои, докато отговорът дойде. Процесорът през това време е свободен.
  • Работа, която смята (CPU-bound): 1000 фактури в PDF, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
Нишка, която чака, не ползва процесора. Затова нишките помагат на работа, която чака, дори на едно ядро: първата нишка пита и заспива, втората пита и заспива, третата също, и трите чакания вървят заедно. Работа, която смята, иска ядро през цялото време. Тя става по-бърза само ако има повече ядра, по едно на нишка.

Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.

Виж го

Трите наведнъж

Всяко питане е в своя нишка. Първо пускаш трите със Start, после чакаш трите с Join (урокът „Нишки“). Ламбдата записва отговора в променлива отвън:
Network net = new Network();
string payment = "", stock = "", email = "";
Thread[] calls =
[
new(() => payment = net.Call("плащане", "прието")),
new(() => stock = net.Call("склад", "има")),
new(() => email = net.Call("имейл", "изпратен")),
];
foreach (Thread t in calls) t.Start();
foreach (Thread t in calls) t.Join();
Console.WriteLine(payment);
Console.WriteLine(stock);
Console.WriteLine(email);
Console.WriteLine($"Кръгове чакане: {net.Rounds}");
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 1

Network net = new Network();
string payment = "", stock = "", email = "";
Thread[] calls =
[
new(() => payment = net.Call("плащане", "прието")),
new(() => stock = net.Call("склад", "има")),
new(() => email = net.Call("имейл", "изпратен")),
];
foreach (Thread t in calls) t.Start();
foreach (Thread t in calls) t.Join();
Console.WriteLine(payment);
Console.WriteLine(stock);
Console.WriteLine(email);
Console.WriteLine($"Кръгове чакане: {net.Rounds}");
Стъпка по стъпка, с думи
  1. Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
  2. Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
  3. Чак тогава пита за склада. Още 200 ms чакане.
  4. Чак тогава пита за имейла. Още 200 ms чакане.
  5. 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
  6. Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
  7. Програмата чака отговорите. Докато дойде първият, другите два също са готови.
  8. 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.

Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.

Нишките тук почти не ползват процесора: три кратки изпълнения на call и три заспивания. Затова това работи и на сървър с едно ядро.
По-точно, за любопитните
Три нишки за три питания са ясни, но скъпи: всяка има свой стек, а докато чака, тя стои. В C# обичайният начин за работа, която чака, е async/await: Task.Delay и истинските заявки не държат нишка, докато чакат. Това е урокът „async не е нишка“. Task.Run от басейна на .NET тук също би работил, но басейнът пуска нови нишки бавно, ако всичките му чакат.

Пробвай

Имейлът чака плащането

Колега преправя поръчката: имейлът трябва да казва дали е платено и дали го има, затова тръгва чак когато плащането и складът са отговорили.

Network net = new Network();
string payment = "", stock = "";
Thread pay = new(() => payment = net.Call("плащане", "прието"));
Thread check = new(() => stock = net.Call("склад", "има"));
pay.Start();
check.Start();
pay.Join();
check.Join();
// имейлът казва дали е платено и дали го има
Console.WriteLine(payment);
Console.WriteLine(stock);
Console.WriteLine(net.Call("имейл", "изпратен"));
Console.WriteLine($"Кръгове чакане: {net.Rounds}");
Отговор

Пробвай

Чакай отговора

Какво чака нишката на плащането да свърши?

Thread pay = new Thread(() => payment = net.Call("плащане", "прието"));
pay.Start();
pay.;
Console.WriteLine(payment);

Проблем

Хиляда фактури в края на месеца

В края на месеца магазинът прави фактура в PDF за всяка от 1000 поръчки. Тук нищо не чака мрежата: процесорът смята през цялото време. render замества рисуването на PDF със сметка, която само товари процесора и връща контролна сума:

// Рисува фактурата (тук: сметка, която само товари процесора).
static int Render(int invoice)
{
int h = invoice;
for (int i = 0; i < 2000; i++)
{
h = (h * 31 + i) % 1_000_003;
}
return h;
}
// Фактурите от from до to включително.
static long RenderRange(int from, int to)
{
long sum = 0;
for (int n = from; n <= to; n++)
{
sum += Render(n);
}
return sum;
}
long total = RenderRange(1, 1000);
Console.WriteLine("Фактури: 1000");
Console.WriteLine($"Контролна сума: {total}");
Изход
Фактури: 1000
Контролна сума: 499409728

Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.

Виж го

Четири задачи, четири ядра

Фактурите се делят на 4 части по 250, а всяка част е задача в басейна на .NET (Task.Run). Всяка нишка смята на свое ядро:
List<Task<long>> parts = [];
for (int p = 0; p < 4; p++)
{
int from = p * 250 + 1;
parts.Add(Task.Run(() => RenderRange(from, from + 249)));
}
long total = 0;
foreach (Task<long> part in parts)
{
total += part.Result;
}
Console.WriteLine("Фактури: 1000");
Console.WriteLine($"Контролна сума: {total}");
Изход
Фактури: 1000
Контролна сума: 499409728

Контролната сума е същата: разделянето не сменя резултата, само кой го смята.

Колко по-бързо е, зависи от машината: с четири свободни ядра до около четири пъти, а на едно ядро изобщо не е по-бързо.

Това са примерни времена, не обещание. Повече части от ядрата не помагат: те само се редуват на същите ядра.

По-точно, за любопитните
  • Колко нишки. За работа, която смята, колкото ядрата: Environment.ProcessorCount казва колко са. За работа, която чака, може много повече: те само спят.
  • Не всичко се дели. Ако една десета от работата не може да се раздели (например събирането на частите или записът на един файл), никакъв брой ядра не я прави повече от 10 пъти по-бърза. Това е законът на Amdahl.
  • Parallel.For от урока „Нишки“ прави същото с по-малко код: дели работата и чака всички части.

Идеята

Кое помага

Работа, която чака Работа, която смята
Какво я бави отговорът по мрежата, диска, базата самото смятане
Помага да чакаш няколко неща наведнъж повече ядра
В C# async и await, нишки Task.Run и Parallel.For
На едно ядро помага не помага

Нишките идват с цена. Докато всяка работи със своите данни (своята услуга, своите 250 фактури), всичко е наред. Щом две нишки пипат едно и също (общ брояч, наличността в склада), започват грешки, които не се виждат при всяко пускане. Това е следващият урок: „Race condition“.

Упражнение

Страницата на продукта

Реши задачата в редактора си.

Страницата на продукта. Преди да покаже страницата, сървърът пита три услуги: цената, отзивите и доставката. Всяка отговаря за около 200 ms и не зависи от другите. Ползвай Network от първата карта и ги питай наведнъж, така че кръгът чакане да е един:

Изход
цена: 89 €
отзиви: 4,6 от 5
доставка: утре
Кръгове чакане: 1

Още задачи

  • Пет услуги. Добави наличност и отстъпка. Колко са кръговете, ако отстъпката зависи от цената?
  • Колко ядра. Пусни фактурите с 1, 2, 4 и 8 части и мери времето при теб. Кога спира да става по-бързо? Сравни с броя ядра на машината си. - Грешният инструмент. Сложи pause/sleep от 200 ms в render на всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?

Тест

Провери се

1. Поръчката чака три услуги по 200 ms, които не зависят една от друга. Колко чака клиентът, ако ги питаш наведнъж?
2. Сървърът има едно ядро. Помагат ли нишките на работа, която чака мрежата?
3. Трябва да сметнеш 1000 фактури и процесорът има 4 ядра. Колко нишки помагат най-много?
4. Защо урокът брои кръговете чакане, а не милисекундите?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·