Защо нишки
Накратко: Нишката е отделен ред на изпълнение в програмата; нишките ускоряват работа, която чака (мрежа, диск), дори на едно ядро, а работа, която смята, иска повече ядра.
Проблем
Клиентът чака три пъти
Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 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}");плащане: приетосклад: имаимейл: изпратенКръгове чакане: 1Network 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}");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}");Стъпка по стъпка, с думи
- Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
- Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
- Чак тогава пита за склада. Още 200 ms чакане.
- Чак тогава пита за имейла. Още 200 ms чакане.
- 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
- Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
- Програмата чака отговорите. Докато дойде първият, другите два също са готови.
- 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.
Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.
Нишките тук почти не ползват процесора: три кратки изпълнения наcall и три заспивания. Затова това работи и на сървър с едно ядро.По-точно, за любопитните
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}");Плащането и складът тръгват заедно: това е първият кръг. Имейлът тръгва чак след двата отговора, когато нищо друго не се чака: втори кръг. Около 400 ms. Работа, която зависи от друга, не може да се застъпи с нея.
Пробвай
Чакай отговора
Какво чака нишката на плащането да свърши?
Thread pay = new Thread(() => payment = net.Call("плащане", "прието"));
pay.Start();
pay.;
Console.WriteLine(payment);Join() чака нишката да свърши. Чак тогава 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Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.
Виж го
Четири задачи, четири ядра
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на всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци · Ламбди и делегати · Нишки
- Продължава в: Race condition · Заключване · Атомарни операции и неизменими обекти · Производител и потребител
- Задачи по темата: Нишки и race conditions