Заключване
Накратко: Ключалката (lock, mutex) пуска само една нишка наведнъж в критичната секция; в C# това е lock, а под нея стои само малкото, което трябва.
Проблем
Как да спреш другата каса
В урока „Race condition“ разпродажбата губеше продажби: две нишки правеха „прочети, извади, запиши“ наведнъж и едната презаписваше другата. Трите стъпки трябва да са едно цяло: докато една нишка е вътре, другата да чака отвън.
Точно това прави ключалката.
Идеята
Ключалката
Ключалката (mutex, от „mutual exclusion“, взаимно изключване) е обект с един собственик. Нишката я взима, прави своето и я пуска. Друга нишка, която я поиска междувременно, чака. Кодът между взимането и пускането е критичната секция: в нея е най-много една нишка.
В C# всеки обект може да е ключалка.lock (sync) { … } я взима при влизане в блока и я пуска при излизане, дори с изключение:class Stock{ private readonly object sync = new object(); private int left;
public Stock(int left) { this.left = left; }
public void Sell() { lock (sync) { left--; } }
public int Left { get { lock (sync) return left; } }}Stock stock = new Stock(200_000);ThreadStart checkout = () =>{ for (int i = 0; i < 100_000; i++) { stock.Sell(); }};Thread a = new Thread(checkout);Thread b = new Thread(checkout);a.Start();b.Start();a.Join();b.Join();Console.WriteLine($"Поръчки: {2 * 100_000}");Console.WriteLine("Продадени според склада:");Console.WriteLine(200_000 - stock.Left);Поръчки: 200000Продадени според склада:200000Точно, при всяко пускане. Ключалката е поле, създадено веднъж: двете каси заключват една и съща.
Обектът ключалка е отделно частно поле, а неthis. Така никой код отвън не може да заключи същия обект и да спре касите ти.Виж го
Последната бройка, поправена
Проверката „има ли“ и вземането са в една и съща критична секция. Ако някой е вътре и проверява, другият не може да провери, преди първият да е взел:
class Stock{ private readonly object sync = new object(); private int left;
public Stock(int left) { this.left = left; }
public bool Reserve() { lock (sync) { if (left == 0) { return false; // няма: отказ } left--; // има: взимам бройката return true; } }
public int Left { get { lock (sync) return left; } }}Стъпка по стъпка, с думи
- Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
- През lock минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
- Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
- Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
- Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
- 3 продадени, 1 отказ, наличност 0: вярно. Цената: купувачите се чакаха един друг пред турникета.
Четирима купувачи, три бройки, всички наведнъж:
Stock stock = new Stock(3);bool[] ok = new bool[4];List<Thread> buyers = [];for (int b = 0; b < 4; b++){ int buyer = b; Thread t = new Thread(() => ok[buyer] = stock.Reserve()); buyers.Add(t); t.Start();}foreach (Thread t in buyers) t.Join();int sold = ok.Count(o => o);Console.WriteLine($"Продадени: {sold}");Console.WriteLine($"Отказани: {4 - sold}");Console.WriteLine($"Наличност: {stock.Left}");Продадени: 3Отказани: 1Наличност: 0А ето двамата купувачи от миналия урок, които купуваха последната бройка. Таксуването на картата (~100 ms) е след ключалката: бройката е запазена, а картата се таксува, без другият да чака:
Stock stock = new Stock(1);bool[] bought = new bool[2];Thread[] buyers = new Thread[2];for (int b = 0; b < 2; b++){ int buyer = b; buyers[b] = new Thread(() => { if (stock.Reserve()) { Thread.Sleep(100); // таксуването на картата, ~100 ms bought[buyer] = true; } });}foreach (Thread t in buyers) t.Start();foreach (Thread t in buyers) t.Join();int sold = bought.Count(b => b);Console.WriteLine("Наличност преди: 1");Console.WriteLine($"Продадени: {sold}");Console.WriteLine($"Отказани: {2 - sold}");Console.WriteLine($"Наличност: {stock.Left}");Наличност преди: 1Продадени: 1Отказани: 1Наличност: 0Един продаден, един отказан, наличност 0. При всяко пускане.
Пробвай
Не забравяй да пуснеш
lock (sync) е съкратено за Monitor.Enter(sync) и Monitor.Exit(sync). Пускането е във finally, за да стане и при изключение:class Stock{ private readonly object sync = new object(); private int left;
public Stock(int left) { this.left = left; }
public void Sell() { Monitor.Enter(sync); try { left--; } finally { Monitor.Exit(sync); } }
public int Left { get { lock (sync) return left; } }}Какво пуска ключалката?
public void Sell()
{
Monitor.Enter(sync);
try
{
left--;
}
finally
{
}
}Без него ключалката остава взета, а следващата нишка, която я поиска, чака вечно. Затова обикновено пишеш lock: той пуска сам.
Пробвай
Ключалката, която не заключва
Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?
Избери реда с грешката.
new object() прави нова ключалка при всяко извикване. Всяка нишка взима своята, свободна ключалка и никой не чака никого. Ключалката е поле, създадено веднъж: private readonly object sync = new object();.Идеята
Колко струва ключалката
Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (contention).
Магазин с 10 бройки и четири поръчки наведнъж. Всяка поръчка вади бройка и праща имейл (~200 ms), а Network от урока „Защо нишки“ брои кръговете чакане:
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; } }}class Shop{ private readonly object sync = new object(); private readonly Network net; private int left = 10;
public Shop(Network net) { this.net = net; }
// Лошо: имейлът тръгва, докато ключалката е взета. public void OrderSlow(string customer) { lock (sync) { left--; net.Call("имейл", customer); } }
// Добре: под ключалката е само наличността. public void Order(string customer) { lock (sync) { left--; } net.Call("имейл", customer); }
public int Left { get { lock (sync) return left; } }}OrderSlow имейлът е под ключалката:Network net = new Network();Shop shop = new Shop(net);List<Thread> orders = [];for (int c = 1; c <= 4; c++){ string customer = $"клиент {c}"; Thread t = new Thread(() => shop.OrderSlow(customer)); orders.Add(t); t.Start();}foreach (Thread t in orders) t.Join();Console.WriteLine($"Поръчки: 4, наличност: {shop.Left}");Console.WriteLine($"Кръгове чакане: {net.Rounds}");Поръчки: 4, наличност: 6Кръгове чакане: 4Четири кръга: имейлите тръгват един по един, а последният клиент чака около 800 ms. С Order под ключалката е само наличността:
Network net = new Network();Shop shop = new Shop(net);List<Thread> orders = [];for (int c = 1; c <= 4; c++){ string customer = $"клиент {c}"; Thread t = new Thread(() => shop.Order(customer)); orders.Add(t); t.Start();}foreach (Thread t in orders) t.Join();Console.WriteLine($"Поръчки: 4, наличност: {shop.Left}");Console.WriteLine($"Кръгове чакане: {net.Rounds}");Поръчки: 4, наличност: 6Кръгове чакане: 1Същата наличност, един кръг. Правилата:
- Под ключалката само общите данни. Пресмятай, форматирай и чакай отвън. Вътре само прочети и запиши.
- Никога чакане под ключалка: мрежа, диск, база данни,
sleep. Вземи каквото трябва, пусни ключалката и тогава чакай. - Една ключалка за едни данни. Наличността и оборотът са отделни: всяко със своя ключалка, за да не се чакат касите, които пипат само едното.
По-точно, за любопитните
lockе повторно влизащ (reentrant): нишка, която вече държи ключалката, може да влезе пак вlockсъс същия обект, без да чака себе си.- От .NET 9 има отделен тип
System.Threading.Lock:private readonly Lock sync = new();и същотоlock (sync). Той е малко по-бърз и показва ясно, че полето е ключалка. Monitor.TryEnter(sync, 100)взима ключалката само ако успее за 100 ms.lockне може да съдържаawait: нишката, която пуска ключалката, трябва да е същата, която я е взела. Заasyncкод имаSemaphoreSlim.
Ключалката не е единственият начин. За един брояч има атомарни операции, които изобщо не карат нишките да се чакат, а данни, които никой не променя, не искат ключалка: урокът „Атомарни операции и неизменими обекти“. А две ключалки, които две нишки взимат в различен ред, могат да спрат програмата завинаги: урокът „Deadlock“.
Упражнение
Оборотът и поръчката от много бройки
Реши задачите в редактора си.
Оборотът. Поправи задачата от миналия урок: две каси по 50 000 продажби по 3 €, а Revenue пази total с ключалка. Пусни програмата няколко пъти: оборотът трябва да е един и същ всеки път.
Каса 1: 50000 продажбиКаса 2: 50000 продажбиОборот (€):300000Поръчка от няколко бройки. В склада има 5 бройки. Напиши Reserve(count): ако има поне count, взима ги и връща истина, иначе не взима нищо и връща лъжа. Трима клиенти искат по 2 бройки наведнъж, всеки в своя нишка.
Поръчани: 3Приети: 2Отказани: 1Наличност: 1Още задачи
- Цената на ключалката. Пусни двете каси по 1 000 000 продажби с ключалка и без нея и засечи времето. Колко по-бавно е с ключалка? А с 8 каси?
- Две ключалки. Добави оборот към склада, със своя ключалка. Продажбата вади от наличността и добавя към оборота. Пази ли двете заедно, ако някой прочете наличност и оборот между двете ключалки?
- TryEnter. Направи продажба, която чака ключалката най-много 50 ms с
Monitor.TryEnter, а ако не успее, връща „опитай пак“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди и делегати · Нишки · Защо нишки · Race condition
- Продължава в: Атомарни операции и неизменими обекти · Deadlock · Производител и потребител · async не е нишка
- Задачи по темата: Нишки и race conditions