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

Заключване

Накратко: Ключалката (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; }
}
}
Двете каси от миналия урок, със същите по 100 000 продажби:
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; }
}
}
Стъпка по стъпка, с думи
  1. Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
  2. През lock минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
  3. Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
  4. Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
  5. Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
  6. 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
    {
        
    }
}

Пробвай

Ключалката, която не заключва

Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?

Избери реда с грешката.

Идеята

Колко струва ключалката

Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (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, а ако не успее, връща „опитай пак“.

Тест

Провери се

1. Две нишки викат sell(), който прави left-- под една и съща ключалка. Какво става, когато втората стигне до ключалката, докато първата е вътре?
2. Проверката „има ли бройки“ е под ключалката, а изваждането е в отделен блок под същата ключалка. Двама купуват последната бройка. Какво става?
3. Под ключалката е изваждането от наличността и изпращането на имейл (~200 ms). Четири поръчки идват наведнъж. Колко чака последната?
4. Какво е грешно в synchronized (new Object()) { left--; }? (C#: lock (new object()), Python: with threading.Lock())

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