Нишки
Накратко: Нишката е отделен ред на изпълнение в програмата; няколко нишки работят едновременно и делят паметта, затова общ брояч се пази с lock или Interlocked.
Проблем
Три отчета, един по един
Thread.Sleep(ms) спира програмата за толкова милисекунди. Thread е от System.Threading, който е включен по подразбиране.static void Download(string file, int ms){ Thread.Sleep(ms); Console.WriteLine($"Свалих {file}");}
static void Main(){ Download("март.pdf", 600); Download("април.pdf", 200); Download("май.pdf", 400); Console.WriteLine("Готово");}Свалих март.pdfСвалих април.pdfСвалих май.pdfГотовоИдеята
Процес и нишка
Когато пуснеш програмата, операционната система ѝ дава процес: своя памет, свои отворени файлове. Вътре в процеса работят една или повече нишки (threads). Нишката е поредица от команди, които се изпълняват една след друга. Досега всяка твоя програма имаше една нишка: тази, която изпълнява Main.
Нишките в един процес делят паметта си: виждат едни и същи обекти. Това ги прави удобни (лесно си предават данни) и опасни (могат да се объркат взаимно, ще го видим след малко).
- Паралелно значи наистина едновременно, на различни ядра на процесора.
- Конкурентно значи на едно ядро, като се редуват толкова бързо, че изглежда едновременно.
За програмиста разликата рядко има значение: и в двата случая не знаеш коя нишка кога ще продължи.
По-точно, за любопитните
- Браузърът Chrome пуска отделен процес за всеки раздел. Затова един увиснал сайт не спира останалите, но и затова браузърът ползва много памет.
- Нишки ползва всяка програма, която прави няколко неща наведнъж: играта пуска звука в една нишка, а логиката в друга; уеб сървърът обслужва всеки клиент в своя нишка.
- Цената: всяка нишка има свой стек и паметта за нея се заделя отделно. Хиляди нишки струват. Затова в истински програми се ползва басейн от нишки (последната идея в урока).
Виж го
Първата нишка
Класът Thread е нишка. На конструктора му подаваш какво да прави: делегат ThreadStart, метод без параметри, който не връща нищо. Най-кратко е с ламбда:
Thread worker = new Thread(() => Download("април.pdf", 200));worker.Start();Console.WriteLine("Main не чака");worker.Join();Console.WriteLine("Отчетът е свален");Main не чакаСвалих април.pdfОтчетът е сваленStart()пуска новата нишка и се връща веднага.Mainпродължава, без да чака, затова „Main не чака“ се отпечатва първо.Join()караMainда изчака нишката да свърши.
Download е методът от първата карта. В C# изключенията не са проверени, затова тук няма throws и нищо не трябва да се хваща: Main си остава static void Main().
По-точно, за любопитните
class ReportJob{ public void Run() { Console.WriteLine("2. метод на обект"); }}
static void PrintReport(){ Console.WriteLine("1. статичен метод по име");}
static void Main(){ Thread a = new Thread(PrintReport); Thread b = new Thread(new ReportJob().Run); Thread c = new Thread(delegate () { Console.WriteLine("3. анонимен метод"); }); Thread d = new Thread(() => Console.WriteLine("4. ламбда")); foreach (Thread t in new Thread[] { a, b, c, d }) { t.Start(); t.Join(); }}1. статичен метод по име2. метод на обект3. анонимен метод4. ламбдаThread в C# не може да има: класът е sealed (запечатан). Анонимни класове също няма, а старият им двойник е анонимният метод delegate () { ... }. Затова задачата винаги е отделена от нишката: делегат, който може да се даде и на басейн от нишки. Ако нишката трябва да получи аргумент, има и ParameterizedThreadStart: new Thread(file => ...) и Start("март.pdf"). Аргументът там е object, затова ламбда, която хваща променлива, е по-удобна.Виж го
Три наведнъж
long start = Environment.TickCount64;Thread march = new Thread(() => Download("март.pdf", 600));Thread april = new Thread(() => Download("април.pdf", 200));Thread may = new Thread(() => Download("май.pdf", 400));march.Start();april.Start();may.Start();march.Join();april.Join();may.Join();long ms = Environment.TickCount64 - start;Console.WriteLine($"Готово за под секунда: {ms < 1000}");Свалих април.pdfСвалих май.pdfСвалих март.pdfГотово за под секунда: TrueНишките тръгват в реда март, април, май, но свършват по реда на паузите: април (0,2 s), май (0,4 s), март (0,6 s). Цялото чакане е колкото най-дългото, 0,6 секунди вместо 1,2.
Реда, в който нишките стигат доWriteLine, решава операционната система. Тук паузите се различават достатъчно, затова редът е един и същ. Ако бяха еднакви, при всяко пускане можеше да е различен.Environment.TickCount64 е броят милисекунди, откакто компютърът е пуснат: разликата между две четения е колко време е минало.Пробвай
Start или извикване
Какво ще отпечата програмата? (Name е името на нишката)
Thread.CurrentThread.Name = "Main";ThreadStart job = () => Console.WriteLine($"Работи в: {Thread.CurrentThread.Name}");Thread t = new Thread(job);t.Name = "сваляне";job();t.Start();t.Join();job() е обикновено извикване на делегата: ламбдата се изпълнява веднага, в нишката на Main. Нова нишка няма. Чак t.Start() пуска нишката „сваляне“, която изпълнява същата ламбда. Главната нишка в .NET няма име, затова първият ред ѝ дава „Main“. Метод Run(), който да объркаш със Start(), Thread в C# няма, но същата грешка е да извикаш метода сам, вместо да го дадеш на нишка: програмата работи, само че нищо не е паралелно.Идеята
Животът на нишката
ThreadState казва в кое е:Thread t = new Thread(() => Download("май.pdf", 500));Console.WriteLine(t.ThreadState);t.Start();Thread.Sleep(100);Console.WriteLine(t.ThreadState);t.Join();Console.WriteLine(t.ThreadState);try{ t.Start();}catch (ThreadStateException){ Console.WriteLine("Втори Start: ThreadStateException");}UnstartedWaitSleepJoinСвалих май.pdfStoppedВтори Start: ThreadStateException| Състояние | Кога |
|---|---|
Unstarted |
създадена, още не е пусната със Start() |
Running |
работи или е готова да работи |
WaitSleepJoin |
чака: ключалка, друга нишка (Join), или определено време (Sleep) |
Stopped |
методът ѝ е свършил |
Нишката се пуска само веднъж. Втори Start() на същата нишка хвърля ThreadStateException. По-кратко, само „работи ли“, казва IsAlive.
По-точно, за любопитните
Още за Thread:
Interrupt()моли нишката да спре. Ако тя спи или чака,Sleep/JoinхвърляThreadInterruptedException. В C# то не е проверено: ако нишката не го хване, програмата спира с грешка. Спирането отвън сAbort()в днешния .NET не работи.Name,ManagedThreadId: име и номер на нишката.Thread.CurrentThreadдава нишката, в която се изпълнява кодът.Thread.Yield()подсказва, че нишката може да отстъпи процесора на друга. Рядко има нужда от него.
Проблем
Загубените посещения
Increment() 100 000 пъти:class Counter{ private int count = 0;
public void Increment() { count++; }
public int Count => count;}
static void Main(){ Counter visits = new Counter(); ThreadStart work = () => { for (int i = 0; i < 100_000; i++) { visits.Increment(); } }; Thread first = new Thread(work); Thread second = new Thread(work); first.Start(); second.Start(); first.Join(); second.Join(); Console.WriteLine($"Посещения: {visits.Count}");}Очакваш 200 000. Пет пускания:
Посещения: 146528Посещения: 120201Посещения: 178089Посещения: 152070Посещения: 153205Всеки път по-малко и всеки път различно. Причината е, че count++ не е една стъпка, а три:
int value = count; // 1. прочетиvalue = value + 1; // 2. добави 1count = value; // 3. запишиclass Counter{ private int count = 5; private readonly object sync = new object();
public void Increment() { count++; // прочети, добави 1, запиши }
public void SafeIncrement() { lock (sync) { count++; } }
public int Count => count;}Стъпка по стъпка, с думи
- count е 5. Нишките A и B викат Increment() едновременно.
- A прочита count: 5.
- B прочита count, преди A да е записала: също 5.
- A добавя 1 в своето копие и записва 6.
- B също записва 6. Две извиквания, а count е 6, не 7: едното добавяне е загубено.
- Отначало, с lock: A взима ключалката, прочита 5 и записва 6.
- B стига до lock (sync) и чака, докато A пусне ключалката.
- B взима ключалката, прочита 6 и записва 7: нищо не е загубено.
Когато двете нишки прочетат една и съща стойност, преди някоя да е записала, едното добавяне се губи. Това се казва състезание (race condition). Дали ще стане, зависи от това кога операционната система сменя нишките, затова резултатът е различен при всяко пускане.
Идеята
lock: по една
lock прави точно това:class Counter{ private int count = 0; private readonly object sync = new object();
public void Increment() { lock (sync) { count++; } }
public int Count { get { lock (sync) { return count; } } }}Посещения: 200000sync е обикновен обект, който служи само за ключ: всеки обект в C# може да е ключалка. Нишката я взима, когато влезе в lock (sync), и я пуска, когато излезе, дори при изключение. Другата нишка, която иска същия ключ, чака на входа. Сега резултатът е 200 000 при всяко пускане. И четенето на Count е в lock: така нишката, която чете, вижда последната записана стойност.
Ключът е частно поле, а не this: така никой код отвън не може да заключи същия обект и да те накара да чакаш. Дума synchronized пред метода, както в Java, в C# няма. Най-близкото е атрибутът [MethodImpl(MethodImplOptions.Synchronized)], но той заключва целия обект (this), затова не се препоръчва:
using System.Runtime.CompilerServices;
class Counter{ private int count = 0;
[MethodImpl(MethodImplOptions.Synchronized)] public void Increment() { count++; }
public int Count => count;}За брояч има и готов клас: Interlocked. Interlocked.Increment(ref count) прави трите стъпки наведнъж, без ключалка. ref подава самото поле, а не копие на стойността му:
class Counter{ private int count = 0;
public void Increment() { Interlocked.Increment(ref count); }
public int Count => count;}Посещения: 200000Тук Count чете без защита, защото Main го чете чак след Join. Join гарантира, че вижда всичко, което нишката е записала.
По-точно, за любопитните
Monitor.WaitиMonitor.PulseAll: нишка в lock блок може да пусне ключалката и да заспи, докато друга не я събуди. Така кухнята чака поръчка, а касата чака кухнята да вземе предишната (задачата „производител и потребител“):
class OrderBox{ private string order; private readonly object sync = new object();
public void Put(string newOrder) { lock (sync) { while (order != null) { Monitor.Wait(sync); } order = newOrder; Monitor.PulseAll(sync); } }
public string Take() { lock (sync) { while (order == null) { Monitor.Wait(sync); } string result = order; order = null; Monitor.PulseAll(sync); return result; } }}
static void Main(){ OrderBox box = new OrderBox(); Thread kitchen = new Thread(() => { for (int i = 0; i < 3; i++) { Console.WriteLine($"Кухнята прие {box.Take()}"); } }); kitchen.Start(); box.Put("пица"); box.Put("салата"); box.Put("супа"); kitchen.Join();}Кухнята прие пицаКухнята прие салатаКухнята прие супаMonitor.Wait е в while, а не в if: нишката може да се събуди и без причина, затова проверява условието наново. Същото готово прави BlockingCollection<string> с boundedCapacity: 1: Add чака, ако е пълна, а Take чака, ако е празна.
- Мъртва хватка (deadlock): две нишки чакат една друга завинаги. Офисът държи принтера и чака скенера, а рецепцията държи скенера и чака принтера:
object printer = new object();object scanner = new object();
Thread office = new Thread(() =>{ lock (printer) { Thread.Sleep(100); lock (scanner) { Console.WriteLine("Офисът копира"); } }});Thread reception = new Thread(() =>{ lock (scanner) { Thread.Sleep(100); lock (printer) { Console.WriteLine("Рецепцията копира"); } }});Програмата увисва и никога не печата нищо. Начинът да го избегнеш е всички нишки да взимат ключалките в един и същ ред.
- Приоритети и фонови нишки:
Priorityподсказва колко е важна нишката, но операционната система може да не се съобрази, затова не разчитай на нея за правилността на програмата.IsBackground = trueправи нишката фонова: програмата свършва, без да я чака:
Thread report = new Thread(() => Download("годишен.pdf", 100));report.Priority = ThreadPriority.Lowest; // Lowest, BelowNormal, Normal, AboveNormal, Highestreport.IsBackground = true; // програмата не чака фоновите нишкиConsole.WriteLine($"{report.Priority}, фонова: {report.IsBackground}");Lowest, фонова: True- Готови колекции за много нишки са в
System.Collections.Concurrent:ConcurrentDictionary,ConcurrentQueue,BlockingCollection. Методите им правят проверката и промяната наведнъж, без твойlock.
Пробвай
Два банкомата
Избери реда с грешката.
Проверката balance >= amount и тегленето не са едно цяло. Двете нишки проверяват едновременно, и двете виждат 100 и двете теглят. В този малък пример това става рядко, затова грешката е коварна: минава хиляда пъти и пада при клиента. Поправката е lock около проверката и тегленето (и около четенето на Balance):
class Account{ private int balance = 100; private readonly object sync = new object();
public void Withdraw(int amount) { lock (sync) { if (balance >= amount) { balance -= amount; } } }
public int Balance { get { lock (sync) { return balance; } } }}Тогава вторият банкомат чака първия, вижда 20 € и отказва. Балансът остава 20 €.
Идеята
Басейн от нишки и Task
ThreadPool, и сам решава колко нишки да има. Задача му дава Task.Run (от System.Threading.Tasks):static int SizeOf(string file, int ms){ Thread.Sleep(ms); return file.Length * 100;}string[] files = { "март.pdf", "април.pdf", "май.pdf", "юни.pdf" };List<Task<int>> sizes = new List<Task<int>>();foreach (string file in files){ sizes.Add(Task.Run(() => SizeOf(file, 300)));}for (int i = 0; i < files.Length; i++){ Console.WriteLine($"{files[i]}: {sizes[i].Result} KB");}март.pdf: 800 KBаприл.pdf: 900 KBмай.pdf: 700 KBюни.pdf: 700 KBTask.Runприема задача, която връща резултат, дава я на басейна и веднага връщаTask<int>: обещание, че резултатът ще дойде (в Java това еFuture)..Resultчака задачата да свърши и връща резултата. Ако задачата е хвърлила изключение,.Resultго хвърля, увито вAggregateException.- Басейнът не се спира накрая: нишките му са фонови, затова програмата свършва, когато свърши
Main. Задача, която още работи тогава, просто се прекъсва.
Колко задачи вървят наведнъж, решава басейнът. Ако искаш да ги ограничиш, както басейн от 2 нишки, Parallel.For пуска тялото за всяко i в басейна, най-много MaxDegreeOfParallelism наведнъж, и чака всички:
string[] files = { "март.pdf", "април.pdf", "май.pdf", "юни.pdf" };int[] sizes = new int[files.Length];ParallelOptions options = new ParallelOptions();options.MaxDegreeOfParallelism = 2;Parallel.For(0, files.Length, options, i =>{ sizes[i] = SizeOf(files[i], 300);});for (int i = 0; i < files.Length; i++){ Console.WriteLine($"{files[i]}: {sizes[i]} KB");}март.pdf: 800 KBаприл.pdf: 900 KBмай.pdf: 700 KBюни.pdf: 700 KB.Result чака, като държи нишката заета. В уеб модула ще видиш await, който чака същата задача, без да заема нишка.
По-точно, за любопитните
Task.Run дава Task без тип. Тогава чакаш всички с Task.WaitAll:List<Task> jobs = new List<Task>();for (int i = 1; i <= 5; i++){ string file = $"отчет-{i}.pdf"; jobs.Add(Task.Run(() => Download(file, 100)));}Task.WaitAll(jobs.ToArray());Console.WriteLine("Всички свършиха.");Свалих отчет-2.pdfСвалих отчет-1.pdfСвалих отчет-4.pdfСвалих отчет-5.pdfСвалих отчет-3.pdfВсички свършиха.ThreadPool.QueueUserWorkItem, но той не дава задача, която да изчакаш.Пробвай
Кой резултат пръв?
„архив.zip“ се сваля 0,6 s, а „бележка.txt“ 0,1 s. Какво ще отпечата програмата?
Task<int> big = Task.Run(() => SizeOf("архив.zip", 600));Task<int> small = Task.Run(() => SizeOf("бележка.txt", 100));Console.WriteLine($"Голям: {big.Result}");Console.WriteLine($"Малък: {small.Result}");Малкият файл наистина е готов първи. Но big.Result е първият ред и чака големия. Когато той дойде, small.Result връща веднага, защото резултатът вече е там. Редът на изхода е редът на .Result, затова е един и същ при всяко пускане.
Упражнение
Програми с нишки
Реши задачите в редактора си.
1. Паралелна сума. Масивът sales има 1 000 000 продажби, sales[i] = i % 100. Раздели го на 4 равни части и сумирай всяка в своя нишка. Всяка нишка пише в свое място в масив long[] partial, затова не трябва синхронизация. Ламбдата в for хваща самата променлива на цикъла, а не стойността ѝ (урокът „Ламбди“): копирай номера на частта в нова променлива преди ламбдата. Изчакай ги с Join и събери частите:
Части: [12375000, 12375000, 12375000, 12375000]Общо: 495000002. Сваляне с басейн. Свали шест отчета (март.pdf, април.pdf, май.pdf, юни.pdf, юли.pdf, август.pdf) с Parallel.For, по 3 наведнъж. Ползвай SizeOf(file, 300) от картата за басейна. Отпечатай размерите по реда на файловете, сбора и дали всичко е станало за под 1,5 секунди (едно след друго би било 1,8 s):
март.pdf: 800 KBаприл.pdf: 900 KBмай.pdf: 700 KBюни.pdf: 700 KBюли.pdf: 700 KBавгуст.pdf: 1000 KBОбщо: 4800 KBПод 1,5 секунди: True3. Брояч без загуби. Четири нишки, всяка с по 50 000 посещения, увеличават общ брояч. Напиши брояча така, че резултатът да е винаги 200 000. Опитай първо без защита и виж колко се губи, после поправи веднъж с lock и веднъж с Interlocked.
Посещения: 200000Още задачи
- Тест за брояча. Напиши xUnit тест (урокът „Тестове“), който пуска 8 нишки по 100 000 увеличения и проверява 800 000 с
Assert.Equal. Пусни го срещу брояча без защита няколко пъти, после срещу брояча сlock. Тест, който пада понякога, е типичният знак за състезание. С 10 000 на нишка в .NET нишката често свършва, преди следващата да е тръгнала, и тестът минава: състезанието е там, но не се вижда. - Мъртвата хватка. Пусни примера с принтера и скенера от „По-точно“ (с
StartиJoin) и виж как увисва. Поправи го, като и двете нишки взимат първо принтера.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: try, catch и finally · Списъци · Ламбди и делегати · Тестове с xUnit
- Продължава в: Мрежово програмиране · async и await · WebAPI: данни за други програми · Quick sort и лошият пивот
- Задачи по темата: Програми в истинския свят