10. Потоки виконання
10.1. Клас Thread
Коли запускається програма, вона виконується у потоці виконання, який називається потоком. Клас .NET, що моделює thread, — це клас System.Threading.Thread, який має таке визначення:
Конструктори
![]() |
У наступних прикладах ми будемо використовувати лише конструктори [1,3]. Конструктор [1] приймає як параметр метод із сигнатурою [2], c.a.d, що має параметр типу object і не повертає результату. Конструктор [3] приймає як параметр метод із сигнатурою [4], c.a.d, який не має параметрів і не повертає результату.
Властивості
Деякі корисні властивості:
- Потік CurrentThread: статична властивість, яка повертає посилання на потік, у якому знаходиться код, що запитав цю властивість
- string Name: ім’я потоку
- bool IsAlive: вказує, чи виконується потік у даний момент.
Методи
Найчастіше використовуються такі методи:
- Start(), Start(object obj): запускає асинхронне виконання потоку, при необхідності передаючи йому інформацію у типі object.
- Abort(), Abort(object obj): для примусового завершення роботи потоку
- Join(): потік T1, який виконує T2.Join, блокується до завершення потоку T2. Існують варіанти, що дозволяють завершити очікування через певний проміжок часу.
- Sleep(int n): статичний метод — потік, що виконує цей метод, призупиняється на n мілісекунд. При цьому він втрачає доступ до процесора, який передається іншому потоку.
Розглянемо перший приклад, що ілюструє наявність головного потоку виконання — того, в якому виконується функція Main класу:
using System;
using System.Threading;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// ініціалізація поточного потоку
Thread main = Thread.CurrentThread;
// виведення
Console.WriteLine("Thread courant : {0}", main.Name);
// зміна імені
main.Name = "main";
// перевірка
Console.WriteLine("Thread courant : {0}", main.Name);
// нескінченний цикл
while (true) {
// виведення на екран
Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
// тимчасове зупинення
Thread.Sleep(1000);
}//while
}
}
}
- рядок 8: отримуємо посилання на потік, у якому виконується метод [main]
- рядки 10–14: виводиться та змінюється його ім’я
- рядки 17–22: цикл, який виконує виведення кожні секунди
- рядок 21: потік, у якому виконується метод [main], буде призупинено на 1 секунду
Результати на екрані такі:
- рядок 1: поточний потік не мав імені
- рядок 2: він має ім’я
- рядки 3–7: виведення, яке відбувається щосекунди
- рядок 8: програма переривається комбінацією клавіш Ctrl-C.
10.2. Створення потоків виконання
Існують програми, у яких фрагменти коду виконуються «одночасно» у різних потоках виконання. Коли кажуть, що threads виконуються одночасно, це часто є неточною формулюванням. Якщо машина має лише один процесор, як це досі часто буває, thread-и ділять між собою цей процесор: кожен з них по черзі має доступ до нього протягом короткого проміжку часу (декількох мілісекунд). Саме це створює ілюзію паралельності виконання. Часовий проміжок, відведений для одного thread, залежить від різних факторів, зокрема від його пріоритету, який має значення за замовчуванням, але також може бути встановлений програмно. Коли thread має доступ до процесора, він зазвичай використовує його протягом усього відведеного йому часу. Однак він може звільнити його достроково:
- перейшовши в режим очікування події (Wait, Join)
- перейшовши в режим сну на певний час (Sleep)
- Потік T спочатку створюється за допомогою одного з конструкторів, наведених вище, наприклад:
де Start — це метод, що має одну з двох таких сигнатур:
Створення потоку не запускає його.
- Виконання потоку T запускається методом T.Start(): метод Start, переданий у конструктор T, буде виконаний потоком T. Програма, яка виконує інструкцію T.Start(), не чекає на завершення завдання T: вона одразу переходить до наступної інструкції. Таким чином, ми маємо два завдання, що виконуються паралельно. Часто їм потрібно мати можливість спілкуватися між собою, щоб знати, на якому етапі знаходиться спільна робота, яку потрібно виконати. Це і є проблема синхронізації потоків.
- Після запуску потік T виконується автономно. Він зупиниться, коли метод Start, який він виконує, завершить свою роботу.
- Можна примусово завершити роботу потоку T:
- T.Abort() вимагає від потоку T завершити роботу.
- Також можна дочекатися завершення його виконання за допомогою T.Join(). Це блокуюча інструкція: програма, яка її виконує, блокується доти, доки потік T не завершить свою роботу. Це один із способів синхронізації.
Розглянемо таку програму:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// ініціалізація поточного потоку
Thread main = Thread.CurrentThread;
// присвоєння імені потоку
main.Name = "Main";
// створення потоків виконання
Thread[] tâches = new Thread[5];
for (int i = 0; i < tâches.Length; i++) {
// створення потоку i
tâches[i] = new Thread(Affiche);
// присвоюється ім'я потоку
tâches[i].Name = i.ToString();
// запуск виконання потоку i
tâches[i].Start();
}
// кінець функції main
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
}
public static void Affiche() {
// виведення повідомлення про початок виконання
Console.WriteLine("Début d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}",Thread.CurrentThread.Name,DateTime.Now);
// перехід у режим очікування на 1 с
Thread.Sleep(1000);
// виведення повідомлення про завершення виконання
Console.WriteLine("Fin d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}", Thread.CurrentThread.Name, DateTime.Now);
}
}
}
- рядки 8–10: присвоюємо ім’я потоку, який виконує метод [Main]
- рядки 13–21: створюються 5 потоків і запускаються. Посилання на потоки зберігаються в масиві, щоб їх можна було отримати пізніше. Кожен потік виконує метод Affiche у рядках 27–35.
- рядок 20: запускається потік № i. Ця операція є неблокуючою. Потік № i буде виконуватися паралельно з потоком методу [Main], який його запустив.
- рядок 24: потік, що виконує метод [Main], завершується.
- рядки 27–35: метод [Affiche] виконує виведення на екран. Він виводить ім’я потоку, що його виконує, а також час початку та закінчення виконання.
- рядок 31: будь-який потік, що виконує метод [Affiche], зупиниться на 1 секунду. Після цього процесор буде передано іншому потоку, що очікує на доступ до процесора. По закінченні секунди зупинки зупинений потік стане претендентом на доступ до процесора. Він отримає його, коли настане його черга. Це залежить від різних факторів, зокрема від пріоритету інших потоків, що очікують на доступ до процесора.
Результати такі:
Ці результати є дуже повчальними:
- по-перше, видно, що запуск виконання потоку не є блокуючим. Метод Main запустив виконання 5 потоків паралельно і завершив своє виконання раніше за них. Операція
запускає виконання потоку tâches[i], але після цього виконання негайно продовжується з наступною інструкцією, не чекаючи на завершення виконання потоку.
- Усі створені потоки повинні виконати метод Affiche. Порядок виконання є непередбачуваним. Навіть якщо в цьому прикладі порядок виконання, здається, відповідає порядку запитів на виконання, з цього не можна робити загальних висновків. Операційна система в даному випадку має 6 потоків і один процесор. Вона розподілятиме ресурси процесора між цими 6 потоками згідно з власними правилами.
- У результатах ми бачимо наслідок виконання методу Sleep. У цьому прикладі саме потік 0 першим виконує метод Affiche. З’являється повідомлення про початок виконання, після чого він виконує метод Sleep, який призупиняє його на 1 секунду. Тоді він втрачає доступ до процесора, який стає доступним для іншого потоку. Приклад показує, що його отримає потік 1. Потік 1 пройде той самий шлях, що й інші потоки. Коли секунда очікування потоку 0 закінчиться, його виконання може поновитися. Система надає йому процесор, і він може завершити виконання методу Affiche.
Змінимо нашу програму, щоб завершити метод Main такими інструкціями:
// завершення обробки
Console.WriteLine("Fin du thread " + main.Name);
// зупинка всіх потоків
Environment.Exit(0);
Виконання нової програми дає такі результати:
- рядки 1–5: потоки, створені функцією Main, починають виконуватися і призупиняються на 1 секунду
- рядок 6: потік [Main] отримує доступ до процесора та виконує інструкцію:
Ця інструкція зупиняє всі потоки програми, а не лише потік Main.
Якщо метод Main хоче дочекатися завершення виконання потоків, які він створив, він може використати метод Join класу Thread:
public static void Main() {
...
// очікування завершення всіх потоків
for (int i = 0; i < tâches.Length; i++) {
// очікування завершення виконання потоку i
tâches[i].Join();
}
// кінець головного циклу
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
- рядок 6: потік [Main] очікує на завершення кожного з потоків. Спочатку він блокується в очікуванні потоку № 1, потім — потоку № 2 тощо. Зрештою, коли він виходить із циклу рядків 2–5, це означає, що всі 5 запущених ним потоків завершили роботу.
У результаті отримуємо такі результати:
- рядок 11: потік [Main] завершився після завершення потоків, які він запустив.
10.3. Значення потоків
Тепер, коли ми виявили наявність потоку за замовчуванням — того, що виконує метод Main — і знаємо, як створювати інші, зупинимося на користі потоків для нас та на причинах, чому ми їх тут розглядаємо. Існує тип додатків, які добре підходять для використання потоків, — це клієнт-серверні додатки в Інтернеті. Ми розглянемо їх у наступному розділі. У клієнт-серверному інтернет-додатку сервер, розташований на машині S1, відповідає на запити клієнтів, розташованих на віддалених машинах C1, C2, ..., Cn.
![]() |
Ми щодня користуємося інтернет-додатками, що відповідають цій схемі: веб-сервіси, електронна пошта, перегляд форумів, передача файлів... У наведеній вище схемі сервер S1 повинен одночасно обслуговувати клієнтів Ci. Якщо взяти за приклад сервер FTP (File Transfer Protocol), який передає файли своїм клієнтам, то відомо, що передача файлу іноді може тривати кілька хвилин. Звісно, не може бути й мови про те, щоб один клієнт монополізував сервер на такий тривалий час. Зазвичай сервер створює стільки потоків виконання, скільки є клієнтів. Кожен потік відповідає за обслуговування конкретного клієнта. Оскільки процесор циклічно розподіляється між усіма активними потоками машини, сервер приділяє трохи часу кожному клієнту, забезпечуючи таким чином одночасність обслуговування.
![]() |
На практиці сервер використовує пул потоків з обмеженою кількістю потоків, наприклад 50. 51-й клієнт у такому разі змушений чекати.
10.4. Обмін інформацією між потоками
У попередніх прикладах потік ініціалізувався таким чином:
де Run — це метод із таким сигнатурою:
Також можна використовувати таку сигнатуру:
Це дозволяє передавати інформацію запущеному потоку. Таким чином
запустить потік t, який потім виконає метод Run, пов’язаний з ним за замовчуванням, передавши йому фактичний параметр obj1. Ось приклад:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
public static void Main() {
// ініціалізація поточного потоку
Thread main = Thread.CurrentThread;
// присвоюємо ім'я потоку
main.Name = "Main";
// Створення потоків виконання
Thread[] tâches = new Thread[5];
Data[] data = new Data[5];
for (int i = 0; i < tâches.Length; i++) {
// створення потоку i
tâches[i] = new Thread(Sleep);
// присвоєння імені потоку
tâches[i].Name = i.ToString();
// запуск виконання потоку i
tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
}
// очікування завершення роботи всіх потоків
for (int i = 0; i < tâches.Length; i++) {
// очікування завершення виконання потоку i
tâches[i].Join();
// виведення результату
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
tâches[i].Name,data[i].Début,data[i].Durée,data[i].Fin,(data[i].Fin-data[i].Début));
}
// завершення функції
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
public static void Sleep(object infos) {
// отримано параметр
Data data = (Data)infos;
// перехід у режим очікування на Durée секунд
Thread.Sleep(data.Durée*1000);
// завершення виконання
data.Fin = DateTime.Now;
}
}
internal class Data {
// різна інформація
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- рядки 45–50: інформація типу [Data], передана потокам:
- Début: час початку виконання потоку — встановлюється запускаючим потоком
- Durée: тривалість у секундах функції Sleep, виконаної запущеним потоком — встановлюється потоком-запускачем
- Fin: час початку виконання потоку — встановлюється запускаючим потоком
- рядки 35–43: метод Sleep, що виконується потоками, має сигнатуру void Sleep(object obj). Фактичний параметр obj буде типу [Data], визначеного в рядку 45.
- рядки 15–22: створення 5 потоків
- рядок 17: кожен потік пов’язаний із методом Sleep у рядку 35
- рядок 21: об’єкт типу [Data] передається методу Start, який запускає потік. У цьому об’єкті зафіксовано час початку виконання потоку, а також тривалість у секундах, протягом якої він повинен перебувати у стані очікування. Цей об’єкт зберігається у масиві в рядку 14.
- рядки 24–30: потік [Main] очікує на завершення всіх потоків, які він запустив.
- рядки 28–29: потік [Main] отримує об’єкт data[i] від потоку № i та виводить його вміст.
- рядки 35–42: метод Sleep, що виконується потоками
- рядок 37: отримується параметр типу [Data]
- рядок 39: поле Durée параметра використовується для встановлення тривалості Sleep
- рядок 41: поле Fin параметра ініціалізовано
Результати виконання такі:
Цей приклад демонструє, що два потоки можуть обмінюватися інформацією:
- потоком-запускачем можна керувати виконанням запущеного потоку, передаючи йому інформацію
- запущений потік може повертати результати потоку-запускачу.
Щоб запущений потік знав, коли очікувані ним результати стануть доступними, його потрібно повідомити про завершення запущеного потоку. У цьому випадку він чекав на завершення, використовуючи метод Join. Існують й інші способи зробити те саме. Ми розглянемо їх пізніше.
10.5. Паралельний доступ до спільних ресурсів
10.5.1. Несинхронізований паралельний доступ
У розділі про обмін інформацією між потоками інформація обмінювалася лише між двома потоками й у чітко визначені моменти часу. Це був класичний приклад передачі параметрів. Існують інші випадки, коли інформація є спільною для декількох потоків, які можуть намагатися її прочитати або оновити одночасно. Тоді постає проблема цілісності цієї інформації. Припустимо, що спільною інформацією є структура S з різними елементами I1, I2, ... In.
- потік T1 починає оновлювати структуру S: він змінює поле I1 і переривається, не встигнувши повністю завершити оновлення структури S
- потім потік T2, який отримує доступ до процесора, зчитує структуру S для прийняття рішень. Він зчитує структуру, що перебуває в нестабільному стані: деякі поля оновлені, інші — ні.
Таку ситуацію називають доступом до спільного ресурсу, в даному випадку — структури S, і її часто досить складно контролювати. Розглянемо наступний приклад, щоб проілюструвати проблеми, які можуть виникнути:
- додаток генерує n потоків, де n передається як параметр
- спільним ресурсом є лічильник, який повинен інкрементуватися кожним згенерованим потоком
- наприкінці роботи програми відображається значення лічильника. Отже, ми маємо отримати n.
Програма має такий вигляд:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// змінні класу
static int cptrThreads = 0; // лічильник потоків
//main
public static void Main(string[] args) {
// інструкція з експлуатації
const string syntaxe = "pg nbThreads";
const int nbMaxThreads = 100;
// перевірка кількості аргументів
if (args.Length != 1) {
// помилка
Console.WriteLine(syntaxe);
// зупинка
Environment.Exit(1);
}
// перевірка якості аргументу
int nbThreads = 0;
bool erreur = false;
try {
nbThreads = int.Parse(args[0]);
if (nbThreads < 1 || nbThreads > nbMaxThreads)
erreur = true;
} catch {
// помилка
erreur = true;
}
// помилка?
if (erreur) {
// помилка
Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
// кінець
Environment.Exit(2);
}
// створення та генерація потоків
Thread[] threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
threads[i] = new Thread(Incrémente);
// іменування
threads[i].Name = "" + i;
// запуск
threads[i].Start();
}//for
// очікування завершення потоків
for (int i = 0; i < nbThreads; i++) {
threads[i].Join();
}
// відображення лічильника
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// збільшує лічильник потоків
// зчитування лічильника
int valeur = cptrThreads;
// відстеження
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// очікування
Thread.Sleep(1000);
// збільшення лічильника
cptrThreads = valeur + 1;
// відстеження
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
}
}
Ми не будемо зупинятися на частині, що стосується створення потоків, яку ми вже розглядали. Звернемо увагу на метод Incrémente у рядку 59, який використовується кожним потоком для інкрементування статичного лічильника cptrThreads у рядку 8.
- рядок 62: лічильник зчитується
- рядок 66: потік зупиняється на 1 с. Отже, він втрачає доступ до процесора
- рядок 68: лічильник інкрементується
Крок 2 існує лише для того, щоб змусити потік втратити доступ до процесора. Доступ до процесора буде передано іншому потоку. На практиці немає жодної гарантії, що потік не буде перерваний у проміжку часу між моментом зчитування лічильника та моментом його інкрементування. Навіть якщо написати cptrThreads++, створюючи таким чином ілюзію єдиної інструкції, існує ризик втратити доступ до процесора між моментом зчитування значення лічильника та моментом запису його значення, збільшеного на 1. Адже операція високого рівня cptrThreads++ буде реалізована на рівні процесора за допомогою декількох елементарних інструкцій. Тому пауза на 1 секунду на етапі 2 існує лише для того, щоб систематизувати цей ризик.
Результати, отримані з 5 потоками, такі:
З цих результатів добре видно, що відбувається:
- рядок 1: перший потік зчитує лічильник. Він знаходить 0. Він зупиняється на 1 с, отже, втрачає доступ до процесора
- рядок 2: другий потік забирає процесор і також зчитує значення лічильника. Воно все ще дорівнює 0, оскільки попередній потік ще не збільшив його. Він також зупиняється на 1 с і, у свою чергу, втрачає доступ до процесора.
- рядки 1–5: за 1 с усі 5 потоків встигають пройти і зчитати значення 0.
- рядки 6–10: коли вони по черзі «прокинуться», вони збільшать значення 0, яке прочитали, і запишуть значення 1 у лічильник, що підтверджує головна програма (Main) у рядку 11.
У чому полягає проблема? Другий потік прочитав неправильне значення через те, що перший потік був перерваний до завершення своєї роботи, яка полягала в оновленні лічильника у вікні. Це підводить нас до поняття критичного ресурсу та критичної ділянки програми:
- критичний ресурс — це ресурс, який одночасно може утримувати лише один потік. У даному випадку критичним ресурсом є лічильник.
- Критична секція програми — це послідовність інструкцій у потоці виконання потоку, під час якої він отримує доступ до критичного ресурсу. Необхідно забезпечити, щоб під час цієї критичної секції лише цей потік мав доступ до ресурсу.
У нашому прикладі критичною секцією є код, розташований між зчитуванням лічильника та записом його нового значення:
// зчитування показань лічильника
int valeur = cptrThreads;
// очікування
Thread.Sleep(1000);
// збільшення лічильника
cptrThreads = valeur + 1;
Щоб виконати цей код, потік повинен бути впевнений, що він працює наодинці. Його можна перервати, але під час цієї перерви інший потік не повинен мати можливості виконати цей самий код. Платформа .NET пропонує різні інструменти для забезпечення одночасного доступу до критичних ділянок коду. Деякі з них ми розглянемо зараз.
10.5.2. Оператор lock
Оператор lock дозволяє обмежити критичну ділянку таким чином:
obj має бути посиланням на об’єкт, видимим для всіх потоків, що виконують критичну ділянку. Ключове слово lock гарантує, що критичну ділянку одночасно виконуватиме лише один потік. Попередній приклад можна переписати так:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// змінні класу
static int cptrThreads = 0; // лічильник потоків
static object synchro = new object(); // об'єкт синхронізації
//main
public static void Main(string[] args) {
...
// очікування завершення потоків
Thread.CurrentThread.Name = "Main";
for (int i = nbThreads - 1; i >= 0; i--) {
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
threads[i].Join();
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a été prévenu de la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
}
// виведення лічильника
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// збільшує лічильник потоків
// запитується ексклюзивний доступ до лічильника
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend l'autorisation d'entrer dans la section critique", DateTime.Now, Thread.CurrentThread.Name);
lock (synchro) {
// читання лічильника
int valeur = cptrThreads;
// відстеження
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// очікування
Thread.Sleep(1000);
// інкрементування лічильника
cptrThreads = valeur + 1;
// спостереження
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a quitté la section critique", DateTime.Now, Thread.CurrentThread.Name);
}
}
}
- рядок 9: synchro — це об’єкт, який забезпечить синхронізацію всіх потоків.
- рядки 16–23: метод [Main] очікує на потоки у зворотному порядку їх створення.
- рядки 29–40: критичний фрагмент методу Incrémente обмежено клаузулою lock.
Результати, отримані з 3 потоками, такі:
- потік 0 першим входить у критичну ділянку: рядки 1, 2, 6, 8
- два інші потоки будуть заблоковані, доки потік 0 не вийде з критичної секції: рядки 3 і 4
- потім виконується потік 1: рядки 7, 9, 10
- потім виконується потік 2: рядки 11, 12, 13
- рядок 14: потік Main, який чекав на завершення потоку 2, отримує повідомлення
- рядок 15: потік Main тепер очікує на завершення потоку 1. Той уже завершився. Потік Main отримує відповідне повідомлення негайно, рядок 16.
- рядки 17–18: той самий процес відбувається з потоком 0
- рядок 19: кількість потоків правильна
10.5.3. Клас Mutex
Клас System.Threading.Mutex також дозволяє обмежувати критичні ділянки. Він відрізняється від оператора lock за видимістю:
- оператор lock дозволяє синхронізувати потоки однієї програми
- клас Mutex дозволяє синхронізувати потоки різних додатків.
Ми будемо використовувати такий конструктор та такі методи:
створює Mutex M | |
Пот T1, який виконує операцію M.WaitOne(), запитує право володіння об’єктом синхронізації M. Якщо Mutex M не утримується жодним потоком (що має місце на початку), він «передається» потоку T1, який його запросив. Якщо трохи пізніше потік T2 виконає ту саму операцію, він буде заблокований. Адже Mutex може належати лише одному потоку. Він буде розблоковано, коли потік T1 звільнить Mutex M, який він утримує. Таким чином, кілька потоків можуть бути заблоковані в очікуванні Mutex M. | |
Потік T1, який виконує операцію M.ReleaseMutex(), відмовляється від володіння м'ютексом Mutex. Коли потік T1 втратить доступ до процесора, система зможе передати його одному з потоків, що очікують на мьютекс M. Лише один з них отримає його у свою чергу, а інші, що очікують на M, залишаться заблокованими |
Mutex M керує доступом до спільного ресурсу R. Потік запитує ресурс R за допомогою M.WaitOne() і повертає його за допомогою M.ReleaseMutex(). Критичний фрагмент коду, який повинен виконуватися лише одним потоком одночасно, є спільним ресурсом. Синхронізація виконання критичного фрагмента може здійснюватися таким чином:
де M — об’єкт Mutex. Не слід забувати звільняти Mutex, що став непотрібним, щоб інший потік міг увійти в критичну секцію; інакше потоки, що очікують на Mutex, який так і не був звільнений, ніколи не отримають доступу до процесора.
Якщо застосувати на практиці те, що ми щойно розглянули, до попереднього прикладу, наш код виглядатиме так:
using System;
using System.Threading;
namespace Chap8 {
class Program3 {
// змінні класу
static int cptrThreads = 0; // лічильник потоків
static Mutex synchro = new Mutex(); // об'єкт синхронізації
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
....
synchro.WaitOne();
try {
...
} finally {
...
synchro.ReleaseMutex();
}
}
}
}
- рядок 9: об’єктом синхронізації потоків тепер є Mutex.
- рядок 18: початок критичної секції — до неї має увійти лише один потік. Ми блокуємося, доки Mutex synchro не звільниться.
- рядок 33: оскільки Mutex завжди має бути звільнений, незалежно від того, чи стався виняток, чи ні, критичний сегмент обробляється за допомогою блоку try / finally, щоб звільнити Mutex у finally.
- рядок 23: Mutex звільняється після проходження критичного сегмента.
Отримані результати такі самі, як і раніше.
10.5.4. Клас AutoResetEvent
Об’єкт AutoResetEvent є бар’єром, який пропускає лише один потік за раз, як і два попередні інструменти lock та Mutex. Об’єкт AutoResetEvent створюється таким чином:
Булеве значення état вказує на стан бар'єру: закритий (false) або відкритий (true). Потік, який хоче пройти через бар'єр, вкаже це наступним чином:
- якщо бар'єр відкритий, потік проходить, а бар'єр за ним знову закривається. Якщо чекало кілька потоків, можна бути впевненим, що пройде лише один.
- Якщо бар’єр закритий, потік блокується. Інший потік відкриє його, коли настане час. Цей момент повністю залежить від задачі, що вирішується. Бар’єр відкривається за допомогою операції:
Може трапитися так, що потік захоче закрити бар’єр. Він зможе це зробити за допомогою:
Якщо в попередньому прикладі замінити об’єкт Mutex на об’єкт типу AutoResetEvent, код стане таким:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
// змінні класу
static int cptrThreads = 0; // лічильник потоків
static EventWaitHandle synchro = new AutoResetEvent(false); // об'єкт синхронізації
//головна функція
public static void Main(string[] args) {
....
// відкриття бар'єру критичної секції
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} ouvre la barrière de la section critique", DateTime.Now, Thread.CurrentThread.Name);
synchro.Set();
// очікування завершення потоків
...
// виведення лічильника
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// збільшення лічильника потоків
// запитується ексклюзивний доступ до лічильника
...
synchro.WaitOne();
try {
...
} finally {
// ресурс звільняється
...
synchro.Set();
}
}
}
}
- рядок 9: бар'єр створюється у закритому стані. Він буде відкритий потоком Main у рядку 16.
- рядок 27: потік, відповідальний за інкрементування лічильника потоків, запитує дозвіл на вхід у критичну секцію. Різні потоки будуть накопичуватися перед закритим бар’єром. Коли потік Main відкриє його, один із потоків, що очікують, пройде.
- рядок 33: завершивши свою роботу, він знову відкриває бар'єр, дозволяючи іншому потоку увійти.
Отримуємо результати, аналогічні попереднім.
10.5.5. Клас Interlocked
Клас Interlocked дозволяє зробити групу операцій атомарною. У групі операцій atomique або всі операції виконуються потоком, що виконує групу, або жодна. Не може бути такого стану, коли деякі операції виконано, а інші — ні. Об’єкти синхронізації lock, Mutex, AutoResetEvent мають на меті зробити atomique групою операцій. Цей результат досягається за рахунок блокування потоків. Клас Interlocked дозволяє уникнути блокування потоків для простих, але досить частих операцій. Клас Interlocked надає такі статичні методи:

Метод Increment має такий сигнатур:
Він дозволяє збільшити на 1 значення параметра location. Операція є гарантованою atomique.
Тоді наша програма підрахунку потоків може виглядати так:
using System;
using System.Threading;
namespace Chap8 {
class Program5 {
// змінні класу
static int cptrThreads = 0; // лічильник потоків
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
// збільшує лічильник потоків
Interlocked.Increment(ref cptrThreads);
}
}
}
- рядок 17: лічильник потоків інкрементується атомарно.
10.6. Паралельний доступ до декількох спільних ресурсів
10.6.1. Приклад
У наших попередніх прикладах різні потоки спільно використовували один ресурс. Ситуація може ускладнитися, якщо таких ресурсів декілька і вони взаємозалежні. Зокрема, може виникнути ситуація взаємного блокування. Ця ситуація, яку також називають deadlock, полягає в тому, що два потоки очікують один на одного. Розглянемо такі дії, що відбуваються послідовно в часі:
- потік T1 отримує володіння мьютексом M1, щоб отримати доступ до спільного ресурсу R1
- потік T2 отримує володіння м'ютексом M2, щоб отримати доступ до спільного ресурсу R2
- потік T1 запитує м'ютекс M2. Він заблокований.
- потік T2 запитує м'ютекс M1. Він заблокований.
У цьому випадку потоки T1 та T2 очікують один на одного. Така ситуація виникає, коли потоки потребують двох спільних ресурсів: ресурсу R1, що контролюється м'ютексом M1, та ресурсу R2, що контролюється м'ютексом M2. Одним із можливих рішень є одночасне запитування обох ресурсів за допомогою єдиного мьютекса M. Однак це не завжди можливо, якщо, наприклад, це призведе до тривалого блокування ресурсу, використання якого є ресурсоємним. Інший варіант полягає в тому, що потік, який має M1 і не може отримати M2, звільняє M1, щоб уникнути взаємного блокування.
- Ми маємо масив, у який одні потоки записують дані (записувачі), а інші — читають їх (читачі).
- Записувачі є рівноправними між собою, але взаємовиключними: одночасно лише один записувач може записувати свої дані в масив.
- Читачі між собою рівноправні, але взаємовиключні: одночасно лише один читач може читати дані, розміщені в масиві.
- Читач може зчитувати дані з масиву лише тоді, коли записувач записав у нього дані, а записувач може записувати нові дані в масив лише тоді, коли ті, що вже там є, були прочитані читачем.
Можна виділити два типи спільних ресурсів:
- таблиця для запису: доступ до неї одночасно може мати лише один записувач.
- таблиця для читання: доступ до неї одночасно може мати лише один читач.
та порядок використання цих ресурсів:
- читач завжди повинен діяти після записувача.
- записувач завжди повинен виконуватися після читача, за винятком першого разу.
Доступ до цих двох ресурсів можна контролювати за допомогою двох бар'єрів типу AutoResetEvent:
- бар'єр peutEcrire контролюватиме доступ записувачів до масиву.
- Шлагбаум peutLire контролюватиме доступ читачів до дошки оголошень.
- Шлагбаум peutEcrire спочатку буде відкритий, пропускаючи першого користувача та блокуючи всіх інших.
- Бар'єр peutLire спочатку буде закритий, блокуючи всіх читачів.
- коли записувач завершить свою роботу, він відкриє шлагбаум peutLire, щоб пропустити читача.
- Коли читач завершить свою роботу, він відкриє бар’єр peutEcrire, щоб пропустити записувача.
Програма, що ілюструє цю синхронізацію за подіями, виглядає так:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// використання потоків читання та запису
// ілюструє використання подій синхронізації
// змінні класу
static int[] data = new int[3]; // ресурс, спільний для потоків читання та запису
static Random objRandom = new Random(DateTime.Now.Second); // генератор випадкових чисел
static AutoResetEvent peutLire; // вказує, що можна прочитати вміст data
static AutoResetEvent peutEcrire; // повідомляє, що можна записувати вміст data
//main
public static void Main(string[] args) {
// кількість потоків, що генеруються
const int nbThreads = 2;
// ініціалізація прапорців
peutLire = new AutoResetEvent(false); // читати ще не можна
peutEcrire = new AutoResetEvent(true); // вже можна записувати
// створення потоків для читання
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// запуск
lecteurs[i].Start();
}
// створення потоків запису
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// запуск
écrivains[i].Start();
}
//завершення обробки
Console.WriteLine("Fin de Main...");
}
// читання вмісту масиву
public static void Lire() {
...
}
// запис у масив
public static void Ecrire() {
....
}
}
}
- рядок 11: масив data є спільним ресурсом для потоків-читачів і потоків-записувачів. Він надається для читання потокам-читачам, а для запису — потокам-записувачам.
- рядок 13: об’єкт peutLire слугує для сповіщення потоків-читачів про те, що вони можуть читати масив data. Він встановлюється в «true» потоком-записувачем, який заповнив масив data. Він ініціалізується значенням false у рядку 23. Потрібно, щоб потік-записувач спочатку заповнив масив, перш ніж перевести подію peutLire у стан vrai.
- рядок 14: об’єкт peutEcrire слугує для сповіщення потоків-записувачів про те, що вони можуть записувати в масив data. Він встановлюється в «true» потоком-читачем, який повністю використав масив data. Він ініціалізується в true, рядок 24. Дійсно, масив data вільний для запису.
- рядки 27–34: створення та запуск потоків-читачів
- рядки 37–44: створення та запуск потоків запису
Метод Lire, що виконується потоками-читачами, має такий вигляд:
public static void Lire() {
// моніторинг
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// необхідно дочекатися дозволу на читання
peutLire.WaitOne();
// читання таблиці
for (int i = 0; i < data.Length; i++) {
//очікування 1 с
Thread.Sleep(1000);
// відображення
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// можна записувати
peutEcrire.Set();
// відстеження
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- рядок 5: очікується, поки потік запису повідомить, що масив заповнено. Коли цей сигнал буде отримано, лише один із потоків читання, що очікують на цей сигнал, зможе перейти до виконання.
- рядки 7–12: використання масиву data із Sleep посередині, щоб змусити потік звільнити процесор.
- рядок 14: повідомляє потокам-записувачам, що масив прочитано і його можна заповнювати знову.
Метод Ecrire, що виконується потоками запису, має такий вигляд:
public static void Ecrire() {
// відстеження
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// треба чекати дозволу на запис
peutEcrire.WaitOne();
// запис у масив
for (int i = 0; i < data.Length; i++) {
//очікування 1 с
Thread.Sleep(1000);
// виведення на екран
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// можна читати
peutLire.Set();
// моніторинг
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- рядок 5: очікується, поки потік-читач повідомить, що масив прочитано. Коли цей сигнал буде отримано, лише один із потоків-записувачів, що очікують на цей сигнал, зможе перейти до виконання.
- рядки 7–13: обробка масиву data із вставкою Sleep посередині, щоб змусити потік звільнити процесор.
- рядок 15: повідомляє потокам-читачам, що масив заповнено і його можна знову читати.
Виконання дає такі результати:
Можна відзначити наступне:
- одночасно працює лише 1 читач, хоча він і втрачає процесор у критичній секції Lire
- одночасно дійсно працює лише один записувач, хоча він і втрачає доступ до процесора в критичній секції Ecrire
- читач зчитує дані лише тоді, коли в масиві є що зчитувати
- записувач записує лише тоді, коли масив повністю прочитано
10.6.2. Клас Monitor
У попередньому прикладі:
- є два спільні ресурси, якими потрібно керувати
- для кожного ресурсу кількість потоків однакова.
Коли потоки запису заблоковані на інструкції peutEcrire.WaitOne, один із них, будь-який, розблоковується операцією peutEcrire.Set. Якщо попередня операція має відкрити бар’єр для конкретного потоку запису, ситуація ускладнюється.
Можна провести аналогію з установою, що обслуговує відвідувачів у вікнах, де кожне вікно спеціалізується на певних послугах. Коли клієнт приходить, він бере талон у талономаті для вікна X, а потім сідає чекати. Кожен талон пронумерований, і клієнтів викликають за їхнім номером через гучномовець. Під час очікування клієнт робить те, що хоче. Він може читати або дрімати. Його щоразу будить гучномовець, який оголошує, що № Y викликається до вікна X. Якщо це він, клієнт встає і підходить до вікна X, інакше продовжує те, чим займався.
Тут можна діяти аналогічно. Візьмемо для прикладу письменників:
їхні потоки заблоковані | |
потік, який використовував масив у режимі читання, повідомляє потокам-записувачам, що масив доступний. Цей або інший потік заблокував потік-записувач, який повинен пройти бар'єр. | |
кожен потік перевіряє, чи є він обраним. Якщо так, він проходить бар'єр. Якщо ні, він повертається в режим очікування. |
Клас Monitor дозволяє реалізувати цей сценарій.

Тепер ми опишемо стандартну конструкцію (pattern), запропоновану в розділі Threading книги «C# 3.0», на яку посилається вступ до цього документа, здатну вирішувати проблеми бар’єру з умовою входу.
- По-перше, потоки, які спільно використовують ресурс (вікно обслуговування тощо), отримують до нього доступ через об’єкт, який ми назвемо «жетоном». Щоб відкрити бар’єр, що веде до вікна обслуговування, потрібно мати жетон, і такий жетон є лише один. Отже, потоки повинні передавати жетон один одному.
- Щоб дістатися до вікна, потоки спочатку запитують жетон:
Якщо жетон вільний, його надають потоку, який виконав попередню операцію; інакше потік ставиться в чергу на отримання жетона.
- Якщо доступ до вікна здійснюється у довільному порядку, c.a.d. у випадку, коли не має значення, хто саме входить, достатньо попередньої операції. Потік, що має токен, підходить до вікна. Якщо доступ здійснюється в упорядкованому порядку, потік, що має токен, перевіряє, чи відповідає він умові для підходу до вікна:
Якщо цей потік не є тим, якого очікують біля вікна, він відмовляється від своєї черги, повертаючи токен. Він переходить у заблокований стан. Його буде пробуджено, щойно токен знову стане доступним для нього. Тоді він знову перевірить, чи виконується умова для переходу до вікна. Операція Monitor.Wait(токен), яка звільняє токен, може бути виконана лише в тому випадку, якщо потік є власником токена. Якщо це не так, генерується виняток.
- Пот, який перевіряє умову для звернення до вікна обслуговування, звертається до нього:
- // робота на касі
- ....
Перед тим, як покинути кассу, потік повинен повернути свій токен, інакше потоки, заблоковані в очікуванні цього токена, залишатимуться заблокованими на невизначений час. Існують дві різні ситуації:
- перша ситуація — це та, коли потік, що має токен, також повідомляє потокам, які очікують на цей токен, що він вільний. Він зробить це таким чином:
У рядку 6 він «пробуджує» потоки, що очікують на токен. Це «пробудження» означає, що вони стають придатними для отримання токена. Це не означає, що вони отримують його негайно. У рядку 8 токен звільняється. Усі придатні потоки по черзі отримають маркер у невизналеному порядку. Це дасть їм можливість знову перевірити, чи виконується умова доступу. Потік, що звільнив маркер, змінив цю умову в рядку 4, щоб дозволити увійти новому потоку. Перший, хто перевірить її, зберігає маркер і підходить до «віконця» у свою чергу.
- Друга ситуація — це та, коли потік, що має маркер, не є тим, який повинен повідомити потоки, що очікують на маркер, про те, що він вільний. Проте він повинен звільнити його, оскільки потік, відповідальний за надсилання цього сигналу, повинен бути власником маркера. Він зробить це за допомогою операції:
Тепер маркер доступний, але потоки, які на нього чекають (вони виконали операцію Wait(маркер)), про це не повідомлені. Це завдання доручається іншому потоку, який у певний момент виконає код, подібний до такого:
У підсумку стандартна конструкція, запропонована в розділі Threading книги «C# 3.0», виглядає так:
- визначити токен доступу до вікна:
- запросити доступ до вікна:
еквівалентно
Слід зауважити, що в цій схемі токен звільняється негайно, щойно бар'єр пройдено. Тоді інший потік може перевірити умову доступу. Отже, наведена вище конструкція пропускає всі потоки, що перевіряють умову доступу. Якщо це не те, що потрібно, можна написати:
де токен звільняється лише після проходження через касу.
- змінити умови доступу до вікна обслуговування та повідомити про це інші потоки
lock(jeton){
// зміна умови доступу до каси
...
// повідомити про це потоки, що очікують на токен
Monitor.PulseAll(jeton);
}
У наведеному вище прикладі умову доступу може змінити лише потік, що володіє токеном. Також можна написати:
// змінити умову доступу до каси
...
// повідомити про це потоки, що очікують на токен
Monitor.PulseAll(jeton);
// звільнити токен
Monitor.Exit(jeton);
якщо потік уже має маркер.
Маючи цю інформацію, ми можемо переписати програму для читачів/записувачів, визначивши порядок читачів і записувачів для доступу до їхніх відповідних каналів. Код виглядає так:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// використання потоків читання та запису
// ілюструє використання подій синхронізації
// змінні класу
static int[] data = new int[3]; // ресурс, спільний для потоків читання та запису
static Random objRandom = new Random(DateTime.Now.Second); // генератор випадкових чисел
static object peutLire = new object(); // вказує, що можна прочитати вміст data
static object peutEcrire = new object(); // показує, що можна записувати вміст data
static bool lectureAutorisée = false; // для дозволу читання масиву
static bool écritureAutorisée = false; // для надання дозволу на запис у масив
static string[] ordreLecture; // визначає порядок читачів
static string[] ordreEcriture; // визначає порядок записувачів
static int lecteurSuivant = 0; // вказує номер наступного читача
static int écrivainSuivant = 0; // вказує номер наступного записувача
//головний
public static void Main(string[] args) {
// кількість потоків, що генеруються
const int nbThreads = 5;
// створення потоків читачів
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// запуск
lecteurs[i].Start();
}
// створення порядку читання
ordreLecture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreLecture[i] = lecteurs[nbThreads - i - 1].Name;
Console.WriteLine("Le lecteur {0} est en position {1}", ordreLecture[i], i);
}
// створення потоків запису
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// запуск
écrivains[i].Start();
}
// створення черги запису
ordreEcriture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreEcriture[i] = écrivains[i].Name;
Console.WriteLine("L'écrivain {0} est en position {1}", ordreEcriture[i], i);
}
// дозвіл на запис
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.Pulse(peutEcrire);
}
//завершення операції
Console.WriteLine("Fin de Main...");
}
// читання вмісту масиву
public static void Lire() {
...
}
// запис у масив
public static void Ecrire() {
...
}
}
}
Доступ до канала читання залежить від таких елементів:
- рядок 13: токен peutLire
- рядок 15: логічне значення lectureAutorisée
- рядок 17: впорядкований масив читачів. Читачі підходять до вікна для читання у порядку, визначеному цим масивом, що містить їхні імена.
- рядок 19: lecteurSuivant вказує номер наступного читача, якому дозволено підійти до вікна.
Доступ до вікна запису залежить від таких елементів:
- рядок 14: жетон peutEcrire
- рядок 16: логічне значення écritureAutorisée
- рядок 18: впорядкований масив користувачів, що мають право на запис. Користувачі підходять до вікна запису у порядку, визначеному цим масивом, що містить їхні імена.
- рядок 20: écrivainSuivant вказує номер наступного автора, якому дозволено підійти до вікна.
Інші елементи коду такі:
- рядки 29–36: створення та запуск потоків читачів. Усі вони будуть заблоковані, оскільки читання заборонено (рядок 15).
- рядки 39–43: їхня черговість проходження до вікна відбуватиметься у зворотному порядку щодо їхнього створення.
- рядки 46–53: створення та запуск потоків запису. Усі вони будуть заблоковані, оскільки запис заборонено (рядок 16).
- рядки 56–60: їхня черговість проходження через касу буде у порядку їхнього створення.
- рядок 64: запис дозволено
- рядок 65: записувальників повідомляють, що щось змінилося.
Метод Lire виглядає наступним чином:
public static void Lire() {
// відстеження
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// необхідно дочекатися дозволу на читання
lock (peutLire) {
while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutLire);
}
// читання таблиці
for (int i = 0; i < data.Length; i++) {
//очікування 1 с
Thread.Sleep(1000);
// відображення
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// наступний читач
lectureAutorisée = false;
lecteurSuivant++;
// повідомляємо записувачам, що вони можуть записувати
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.PulseAll(peutEcrire);
}
// відстеження
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- весь доступ до вікна контролюється за допомогою lock у рядках 5–27. Читач, який отримує жетон, зберігає його протягом усього перебування біля вікна
- рядки 6–8: зчитувач, який отримав жетон у рядку 5, відпускає його, якщо зчитування не дозволено або якщо ще не настала його черга.
- рядки 10–15: проходження через вікно (обробка масиву)
- рядки 17–18: потік змінює умови доступу до вікна зчитування. Зазначимо, що він все ще має маркер зчитування і що ці зміни ще не дозволяють читачеві пройти.
- рядки 20–23: потік змінює умови доступу до каси запису та повідомляє всіх записувачів, що перебувають у черзі, про те, що щось змінилося.
- рядок 27: потік lock завершується, маркер peutLire звільняється. Потік читання міг би тоді отримати його (рядок 5), але він не пройшов би умову доступу, оскільки булеве значення lectureAutorisée дорівнює «false». Крім того, усі потоки, які очікують на токен peutLire, продовжують очікувати, оскільки операція PulseAll(peutLire) ще не відбулася.
Метод Ecrire виглядає наступним чином:
public static void Ecrire() {
// відстеження
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// треба чекати дозволу на запис
lock (peutEcrire) {
while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutEcrire);
}
// запис у масив
for (int i = 0; i < data.Length; i++) {
//очікування 1 с
Thread.Sleep(1000);
// виведення
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// наступний записувач
écritureAutorisée = false;
écrivainSuivant++;
// пробуджуємо зчитувачі, що очікують на токен peutLire
lock (peutLire) {
lectureAutorisée = true;
Monitor.PulseAll(peutLire);
}
// відстеження
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- весь доступ до вікна запису контролюється операцією lock у рядках 5–27. Записувач, який отримує токен, зберігає його протягом усього свого перебування у вікні
- рядки 6–8: записувач, який отримав токен у рядку 5, звільняє його, якщо запис не дозволено або якщо ще не настала його черга.
- рядки 10–16: проходження через касу (обробка масиву)
- рядки 18–19: потік змінює умови доступу до каси запису. Зазначимо, що він все ще має жетон запису і що ці зміни ще не дозволяють записувачу пройти.
- рядки 21–24: потік змінює умови доступу до каси читання та повідомляє всіх читачів, що перебувають у черзі, про те, що щось змінилося.
- рядок 27: потік lock завершується, маркер peutEcrire звільняється. Потік запису міг би тоді його отримати (рядок 5), але він не пройшов би умову доступу, оскільки булеве значення écritureAutorisée дорівнює «false». Крім того, усі потоки, які очікують на токен peutEcrire, продовжують очікувати на нову операцію PulseAll(peutEcrire).
Приклад виконання такий:
10.7. Пули потоків
Дотепер для управління потоками:
- ми створювали їх за допомогою Thread T = new Thread(...)
- а потім виконували за допомогою T.Start()
У розділі «Бази даних» ми бачили, що з деякими SGBD можна було мати пули відкритих з’єднань:
- n з’єднання відкриваються під час запуску пулу
- коли потік запитує з’єднання, йому надається одне з відкритих з’єднань із пулу
- коли потік закриває з’єднання, воно не закривається, а повертається до пулу
Використання пулу з'єднань є прозорим на рівні коду. Перевага полягає у підвищенні продуктивності: відкриття з'єднання вимагає значних ресурсів. У цьому випадку 10 відкритих з'єднань можуть обслуговувати сотні запитів.
Аналогічна система існує для потоків:
- min потоків створюється під час запуску пулу. Значення min встановлюється за допомогою методу ThreadPool.SetMinThreads(min1,min2). Пул потоків можна використовувати для виконання блокуючих або неблокуючих, так званих асинхронних, завдань. Перший параметр min1 визначає кількість блокуючих потоків, другий — min2 — кількість асинхронних потоків. Поточні значення цих двох параметрів можна отримати за допомогою ThreadPool.GetMinThreads(out min1,out min2).
- Якщо цієї кількості недостатньо, пул створить додаткові потоки для обробки запитів аж до межі, встановленої параметром max. Значення max встановлюється за допомогою методу ThreadPool.SetMaxThreads(max1,max2). Обидва параметри мають те саме значення, що й у методі SetMinThreads. Поточні значення цих двох параметрів можна отримати за допомогою ThreadPool.GetMaxThreads(out max1,out max2). Коли буде досягнуто кількості потоків, заданої методом max1, запити на потоки для блокуючих завдань будуть поставлені в чергу до появи вільного потоку в пулі.
Пул потоків має низку переваг:
- як і у випадку з пулом з’єднань, економиться час на створення потоків: 10 потоків можуть обслуговувати сотні запитів.
- забезпечується безпека додатка: встановлюючи максимальну кількість потоків, уникнути перевантаження додатка надмірною кількістю запитів. Ці запити будуть поставлені в чергу.
Щоб доручити завдання потоку з пулу, використовують один із двох методів:
- ThreadPool.QueueWorkItem(WaitCallBack)
- ThreadPool.QueueWorkItem(WaitCallBack,object)
де WaitCallBack — це будь-який метод із сигнатурою void WaitCallBack(object). Метод 1 вимагає від потоку виконати метод WaitCallBack без передачі йому параметра. Метод 2 робить те саме, але передає параметр типу object методу WaitCallBack.
Ось програма, що ілюструє ці концепції:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// ініціалізація поточного потоку
Thread main = Thread.CurrentThread;
// присвоюємо ім'я потоку
main.Name = "Main";
// використовується пул потоків
int min1, min2;
// встановлюємо мінімальну кількість блокуючих потоків
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool : {0}", min1);
Console.WriteLine("Nombre minimum de tâches asynchrones dans le pool : {0}", min2);
ThreadPool.SetMinThreads(3, min2);
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool après changement : {0}", min1);
// встановлюємо максимальну кількість блокуючих потоків
int max1, max2;
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool : {0}", max1);
Console.WriteLine("Nombre maximum de tâches asynchrones dans le pool : {0}", max2);
ThreadPool.SetMaxThreads(5, max2);
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool après changement : {0}", max1);
// виконується 7 потоків
for (int i = 0; i < 7; i++) {
// запускається виконання потоку i в пулі
ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
}
// кінець функції
Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
// очікування
Console.ReadLine();
}
public static void Sleep(object infos) {
// отримано параметр
Data2 data = infos as Data2;
Console.WriteLine("A {2:hh:mm:ss:FF}, le thread n° {0} va dormir pendant {1} seconde(s)", data.Numéro, data.Durée,DateTime.Now);
// стан пулу
int cpt1, cpt2;
ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
// перехід у режим очікування на Durée секунд
Thread.Sleep(data.Durée * 1000);
// завершення виконання
data.Fin = DateTime.Now;
Console.WriteLine("A {3:hh:mm:ss:FF}, le thread n° {0} se termine. Il était programmé pour durer {1} seconde(s). Il a duré {2} seconde(s)", data.Numéro, data.Durée, data.Fin - data.Début,DateTime.Now);
}
}
internal class Data2 {
// різна інформація
public string Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- рядки 15–17: запитується та виводиться поточна мінімальна кількість обох типів потоків у пулі потоків
- рядок 18: змінюється мінімальна кількість потоків для блокуючих завдань: 2
- рядки 19–21: виводимо нові мінімальні значення
- рядки 22–28: аналогічно встановлюємо максимальну кількість потоків для блокуючих завдань: 5
- рядки 30–33: запускається виконання 7 завдань у пулі з 5 потоків. 5 завдань повинні отримати по 1 потоку: перші 2 — швидко, оскільки 2 потоки завжди вільні, а інші 3 — із затримкою 0,5 секунди. 2 завдання повинні чекати, поки звільниться потік.
- рядок 32: завдання виконують метод Sleep із рядків 40–54, передаючи йому параметр типу Data2, визначений у рядках 56–62.
- рядок 40: метод Sleep, що виконується завданнями
- рядок 42: отримується параметр, переданий методу Sleep.
- рядок 43: завдання ідентифікується в консолі
- рядки 45–47: виводиться кількість потоків, доступних на даний момент. Ми хочемо побачити, як вона змінюється.
- рядок 49: завдання зупиняється на кілька секунд (блокуюче завдання).
- рядок 52: коли завдання відновлює роботу, виводиться деяка інформація про його обліковий запис.
Отримано такі результати.
Для чисел min та max, що позначають кількість потоків у пулі:
Для виконання 7 потоків:
- рядки 1–6: перші 3 завдання виконуються по черзі. Вони одразу знаходять 1 вільний потік (MinThreads=3), а потім переходять у режим очікування.
- рядки 7–9: для завдань 3 і 4 це займає трохи більше часу. Для кожного з них вільного потоку не було. Довелося створити новий. Цей механізм можливий до 5 (MaxThreads=5).
- рядок 10: вільних потоків більше немає: завдання 5 і 6 доведеться почекати.
- рядки 11–12: завдання 0 завершується. Завдання 5 займає свій потік.
- рядки 13–14: завдання 1 завершується. Завдання 6 займає його потік.
- рядки 17–21: завдання завершуються одне за одним.
10.8. Клас BackgroundWorker
10.8.1. Приклад 1
Клас BackgroundWorker належить до простору імен [System.ComponentModel]. Він використовується як потік, але має особливості, які в деяких випадках можуть зробити його цікавішим, ніж клас [Thread]:
- він генерує такі події:
- DoWork: потік запросив виконання BackgroundWorker
- ProgressChanged: об’єкт BackgroundWorker виконав метод ReportProgress. Цей метод використовується для визначення відсотка виконання.
- RunWorkerCompleted: об’єкт BackgroundWorker завершив свою роботу. Він міг завершити її у звичайному режимі, у результаті скасування або через виняток.
Ці події роблять BackgroundWorker корисним у графічних інтерфейсах: тривале завдання буде доручено об’єкту BackgroundWorker, який зможе повідомляти про свій прогрес за допомогою події ProgressChanged, а про завершення — за допомогою події RunWorkerCompleted. Робота, яку має виконати BackgroundWorker, буде здійснена методом, пов’язаним із подією DoWork.
- Можна подати запит на його скасування. У графічному інтерфейсі користувач зможе таким чином скасувати тривале завдання.
- Об’єкти BackgroundWorker належать до пулу та повторно використовуються за потреби. Додаток, якому потрібен об’єкт BackgroundWorker, отримає його з пулу, який надасть йому вже існуючий, але невикористаний потік. Таке повторне використання потоків замість створення кожного разу нового потоку покращує продуктивність.
Ми використовуємо цей інструмент у попередньому додатку в разі, коли доступ до вікна є неконтрольованим:
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program2 {
// використання потоків читання та запису
// ілюструє одночасне використання спільних ресурсів та синхронізації
// змінні класу
const int nbThreads = 2; // загальна кількість потоків
static int nbLecteursTerminés = 0; // кількість завершених потоків
static int[] data = new int[5]; // масив, спільний для потоків читання та запису
static object appli; // синхронізує доступ до кількості завершених потоків
static Random objRandom = new Random(DateTime.Now.Second); // генератор випадкових чисел
static AutoResetEvent peutLire; // повідомляє, що можна читати вміст масиву
static AutoResetEvent peutEcrire; // повідомляє, що в масив можна записувати
static AutoResetEvent finLecteurs; // повідомляє про завершення роботи читачів
//main
public static void Main(string[] args) {
// присвоюється ім'я потоку
Thread.CurrentThread.Name = "Main";
// ініціалізація прапорців
peutLire = new AutoResetEvent(false); // читати ще не можна
peutEcrire = new AutoResetEvent(true); // вже можна записувати
finLecteurs = new AutoResetEvent(false); // додаток не завершено
// синхронізується доступ до лічильника завершених потоків
appli = new object();
// створення потоків-читачів
MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
lecteurs[i] = new MyBackgroundWorker();
lecteurs[i].Numéro = "L" + i;
lecteurs[i].DoWork += Lire;
lecteurs[i].RunWorkerCompleted += EndLecteur;
// запуск
lecteurs[i].RunWorkerAsync();
}
// створення потоків запису
MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// створення
écrivains[i] = new MyBackgroundWorker();
écrivains[i].Numéro = "E" + i;
écrivains[i].DoWork += Ecrire;
// запуск
écrivains[i].RunWorkerAsync();
}
// очікування завершення всіх потоків
finLecteurs.WaitOne();
//завершення операції
Console.WriteLine("Fin de Main...");
}
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
}
// читання вмісту масиву
public static void Lire(object sender, DoWorkEventArgs infos) {
...
}
// запис у масив
public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
}
}
// тема
internal class MyBackgroundWorker : BackgroundWorker {
// різна інформація
public string Numéro { get; set; }
}
}
Ми детально описуємо лише зміни:
- клас Thread замінено на клас MyBackgroundWorker у рядках 79–82. Клас BackgroundWorker було похідним, щоб присвоїти номер потоку. Можна було б вчинити інакше, передавши об’єкт до методу RunWorkerAsync у рядках 43 та 54, об’єкт, що містить номер потоку.
- рядок 58: метод Main завершується після того, як усі потоки-читачі виконають свою роботу. Для цього у рядку 12 лічильник nbLecteursTerminés підраховує кількість потоків-читачів, які завершили свою роботу. Цей лічильник інкрементується методом EndLecteur у рядках 63–65, який виконується щоразу, коли завершується потік-читач. Саме ця процедура керує подією AutoResetEvent finLecteurs у рядку 18, на яку в рядку 59 синхронізується метод Main.
- рядок 16: оскільки кілька потоків-читачів можуть одночасно намагатися збільшити лічильник nbLecteursTerminés, ексклюзивний доступ до нього забезпечується об’єктом синхронізації appli. Такий випадок малоймовірний, але теоретично можливий.
- рядки 35–44: створення потоків-читачів
- рядок 38: створення потоку типу MyBackgroundWorker
- рядок 39: йому присвоюється номер
- рядок 40: йому призначається метод Lire для виконання
- рядок 41: метод EndLecteur буде виконано після завершення роботи потоку
- рядок 43: запускається потік
- рядки 47–55: створення потоків-записувачів
- рядок 50: створення потоку типу MyBackgroundWorker
- рядок 51: йому присвоюється номер
- рядок 52: йому призначається метод Ecrire для виконання
- рядок 54: запускається потік
Методи Lire та Ecrire залишаються без змін. Метод EndLecteur виконується наприкінці кожного потоку-читача. Його код такий:
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
// збільшення кількості завершених читачів
lock (appli) {
nbLecteursTerminés++;
if (nbLecteursTerminés == nbThreads)
finLecteurs.Set();
}
}
Роль методу EndLecteur полягає в тому, щоб повідомити метод Main про те, що всі зчитувачі виконали свою роботу.
- рядок 4: лічильник nbLecteursTerminés збільшується на одиницю.
- рядки 5–6: якщо всі зчитувачі виконали свою роботу, то подія finLecteurs встановлюється в значення «true», щоб сповістити метод Main, який очікує на цю подію.
- Оскільки процедура EndLecteur виконується кількома потоками, попередній критичний фрагмент захищений за допомогою умови lock у рядку 3.
Результати виконання аналогічні результатам версії, що використовує потоки.
10.8.2. Приклад 2
Наступний код ілюструє інші особливості класу BackgroundWorker:
- можливість скасування завдання
- передачу винятку, що виник у завданні
- передачу параметра вводу-виводу до завдання
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program3 {
// потоки
static BackgroundWorker[] tâches = new BackgroundWorker[5];
public static void Main() {
// ініціалізація поточного потоку
Thread main = Thread.CurrentThread;
// присвоєння імені потоку
main.Name = "Main";
// створення потоків
for (int i = 0; i < tâches.Length; i++) {
// створення потоку № i
tâches[i] = new BackgroundWorker();
// його ініціалізують
tâches[i].DoWork += Sleep;
tâches[i].RunWorkerCompleted += End;
tâches[i].WorkerSupportsCancellation = true;
// запуск
tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
}
// припинення останнього потоку
tâches[4].CancelAsync();
// завершення обробки
Console.WriteLine("Fin du thread {0}, tapez [entrée] pour terminer...", main.Name);
Console.ReadLine();
return;
}
public static void Sleep(object sender, DoWorkEventArgs infos) {
...
}
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
...
}
internal class Data {
// різна інформація
public int Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
}
- рядок 9: масив класу BackgroundWorker
- рядки 18–27: створення потоків
- рядок 20: створення потоку
- рядок 22: потік виконає метод Sleep з рядків 39–41
- рядок 23: метод End з рядків 43–45 буде виконано після завершення роботи потоку
- рядок 24: потік можна буде скасувати
- рядок 26: потік запускається з параметром типу [Data], визначеним у рядках 49–52. Цей об’єкт має такі поля:
- Numéro (вхідний): номер потоку
- Début (вхід): час початку виконання потоку
- Durée (вхід): тривалість виконання Sleep
- Fin (вихід): закінчення виконання потоку
- рядок 29: потік № 4 скасовано
Усі потоки виконують наступний метод Sleep:
public static void Sleep(object sender, DoWorkEventArgs infos) {
// обробляється параметр infos
Data data = (Data)infos.Argument;
// виняток для завдання № 3
if (data.Numéro == 3) {
throw new Exception("test....");
}
// перехід у режим очікування на Durée секунд із зупинкою кожні секунди
for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
// очікування 1 секунди
Thread.Sleep(1000);
}
// завершення виконання
data.Fin = DateTime.Now;
// ініціалізується результат
infos.Result = data;
infos.Cancel = tâches[data.Numéro].CancellationPending;
}
- рядок 1: метод Sleep має стандартну сигнатуру обробників подій. Він приймає два параметри:
- sender: джерело події, в даному випадку BackgroundWorker, що виконує метод
- infos: типу DoWorkEventArgs, який надає інформацію про подію DoWork. Цей параметр використовується як для передачі інформації до потоку, так і для отримання його результатів.
- рядок 3: параметр, переданий методу RunWorkerAsync завдання, міститься у властивості infos.Argument.
- рядки 5–7: генерується виняток для завдання № 3
- рядки 9–12: потік «спить» Durée секунд з інтервалом в одну секунду, щоб дозволити провести тест на скасування з рядка 9. Це імітує тривалу роботу, під час якої потік регулярно перевіряв би, чи є запит на скасування. Щоб повідомити про скасування, потік повинен встановити властивість infos.Cancel у значення «true» (рядок 17).
- рядок 16: потік може повернути результат потоку, який його запустив. Він розміщує цей результат у infos.Result.
Після завершення роботи потоки виконують наступний метод End:
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
// використовується параметр infos для відображення результату виконання
// виняток?
if (infos.Error != null) {
Console.WriteLine("Le thread {1} a rencontré l'erreur suivante : {0}", infos.Error.Message, sender);
} else
if (!infos.Cancelled) {
Data data = (Data)infos.Result;
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
data.Numéro, data.Début, data.Durée, data.Fin, (data.Fin - data.Début));
} else {
Console.WriteLine("Thread {0} annulé", sender);
}
}
- рядок 1: метод End має стандартну сигнатуру обробників подій. Він приймає два параметри:
- sender: джерело події, в даному випадку BackgroundWorker, що виконує метод
- infos: типу RunWorkerCompletedEventArgs, який надає інформацію про подію RunWorkerCompleted.
- рядок 4: поле infos.Error типу Exception заповнюється лише у разі виникнення винятку.
- рядок 7: поле infos.Cancelled булевого типу з значенням true, якщо потік було скасовано.
- рядок 8: якщо не відбулося ні винятку, ні переривання, то infos.Result є результатом виконаного потоку. Використання цього результату у випадку, якщо потік було скасовано або він викликав виняток, призводить до виникнення винятку. Отже, у рядках 5 та 13 ми не можемо відобразити номер потоку, який було скасовано або який викликав виняток, оскільки цей номер міститься в infos.Result. Цю проблему можна обійти, створивши похідний клас від BackgroundWorker, щоб розмістити в ньому інформацію, яка має обмінюватися між викликуючим і викликаним потоками, як це було зроблено в попередньому прикладі. У цьому випадку замість аргументу infos використовується аргумент sender, який представляє BackgroundWorker.
Результати виконання такі:
10.9. Дані, локальні для потоку
10.9.1. Принцип
Розглянемо трирівневу програму:
![]() |
Припустимо, що це багатокористувацька програма, наприклад, веб-додаток. Кожен користувач обслуговується окремим потоком, присвяченим саме йому. Життєвий цикл потоку виглядає так:
- потік створюється або запитується у пулі потоків для виконання запиту користувача
- якщо цей запит вимагає даних, потік виконує метод шару [ui], який викликає метод шару [metier], який, у свою чергу, викликає метод шару [dao].
- Потік повертає відповідь користувачеві. Після цього він зникає або повертається до пулу потоків.
У операції 2 може бути корисно, щоб потік мав власні дані, c.a.d, які не діляться з іншими потоками. Ці дані можуть, наприклад, належати конкретному користувачеві, якого обслуговує потік. Тоді ці дані можна використовувати в різних рівнях [ui, metier, dao].
Клас Thread дозволяє реалізувати цей сценарій завдяки своєрідному приватному словнику, ключі якого мають тип LocalDataStoreSlot:
створює запис у приватному словнику потоку для ключа name. | |
прив’язує значення data до ключа name у приватному словнику потоку | |
отримує значення, пов’язане з ключем name у приватному словнику потоку |
Приклад використання може бути таким:
- щоб створити пару (clé,valeur), пов’язану з поточним потоком:
- щоб отримати значення, пов’язане з clé:
10.9.2. Застосування принципу
Розглянемо таку трирівневу програму:
![]() |
Припустимо, що рівень [dao] управляє базою товарів і що його інтерфейс спочатку має такий вигляд:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- рядок 5: для додавання товару до бази
- рядок 6: для вилучення всіх товарів із бази
- рядок 7: для видалення всіх товарів з бази
Згодом виникає потреба у методі для введення таблиці товарів за допомогою транзакції, оскільки потрібно працювати за принципом «все або нічого»: або вводяться всі товари, або жоден. Тоді можна змінити інтерфейс, щоб врахувати цю нову потребу:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
void insertArticles(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- рядок 6: для додавання масиву товарів до бази
Згодом для іншого додатка виникає потреба видалити список товарів, записаний у списку, також у рамках транзакції. Бачимо, що для задоволення різних бізнес-потреб шар [dao] буде змушений розширюватися. Можна піти іншим шляхом:
- включити до шару [dao] лише базові операції InsertArticle, DeleteArticle, UpdateArticle, SelectArticle, SelectArticles
- перенести в шар [métier] операції одночасного оновлення декількох позицій. Вони використовуватимуть елементарні операції шару [dao].
Перевага цього рішення полягає в тому, що один і той самий рівень [dao] можна використовувати без змін із різними рівнями [metier]. Воно створює складність в управлінні транзакцією, яка об’єднує оновлення, що мають виконуватися атомарно в базі даних:
- транзакція має бути ініційована шаром [metier] до того, як він викличе методи шару [dao]
- методи рівня [dao] повинні знати про існування транзакції, щоб взяти в ній участь, якщо вона існує
- транзакція повинна бути завершена рівнем [métier].
Щоб методи рівня [dao] могли визначити наявність можливої поточної транзакції, можна додати транзакцію як параметр кожного методу рівня [dao]. Тоді цей параметр з’явиться у сигнатурі методів інтерфейсу, що пов’яже його з конкретним джерелом даних — базою даних. Локальні дані потоку пропонують нам більш елегантне рішення: рівень [métier] розмістить транзакцію в локальних даних потоку, і саме звідти рівень [dao] буде її отримувати. Отже, сигнатуру методів шару [dao] змінювати не потрібно.
Ми реалізуємо це рішення за допомогою такого проєкту Visual Studio:
![]() |
![]() |
- у [1]: рішення в цілому
- у [2]: використовувані посилання. Оскільки база даних [4] є базою даних SQL Server Compact, необхідно мати посилання [System.Data.SqlServerCe].
- у [3]: різні рівні додатка.
База даних [4] — це база даних SQL Server Compact, яка вже використовувалася в попередньому розділі, зокрема в параграфі 9.3.1.
![]() |
Клас Article
Один рядок з попередньої таблиці [articles] інкапсульовано в об’єкт типу Article:
namespace Chap8 {
public class Article {
// властивості
public int Id { get; set; }
public string Nom { get; set; }
public decimal Prix { get; set; }
public int StockActuel { get; set; }
public int StockMinimum { get; set; }
// конструктори
public Article() {
}
public Article(int id, string nom, decimal prix, int stockActuel, int stockMinimum) {
Id = id;
Nom = nom;
Prix = prix;
StockActuel = stockActuel;
StockMinimum = stockMinimum;
}
// ідентичність
public override string ToString() {
return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
}
}
}
Інтерфейс шару [dao]
Інтерфейс IDao шару [dao] матиме такий вигляд:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- рядок 5: для вставки запису в таблицю [articles]
- рядок 6: для додавання всіх рядків таблиці [articles] до списку об’єктів Article
- рядок 7: для видалення всіх рядків з таблиці [articles]
Інтерфейс шару [metier]
Інтерфейс IMetier шару [metier] матиме такий вигляд:
using System.Collections.Generic;
namespace Chap8 {
interface IMetier {
void InsertArticlesInTransaction(Article[] articles);
void InsertArticlesOutOfTransaction(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- рядок 5: для вставки набору товарів у межах транзакції
- рядок 6: те саме, але без транзакції
- рядок 7: для отримання списку всіх товарів
- рядок 8: для видалення всіх товарів
Реалізація шару [metier]
Бізнес-реалізація інтерфейсу IMetier буде такою:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Metier : IMetier {
// шар [dao]
public IDao Dao { get; set; }
// ланцюг з'єднання
public string ConnectionString { get; set; }
// вставлення таблиці товарів у транзакцію
public void InsertArticlesInTransaction(Article[] articles) {
// створення з'єднання з базою даних
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// відкриття з'єднання
connexion.Open();
// транзакція
SqlCeTransaction transaction = null;
try {
// початок транзакції
transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
// транзакцію записують у потік
Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
// вставлення товарів
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
// підтвердження транзакції
transaction.Commit();
} catch {
// скасування транзакції
if (transaction != null)
transaction.Rollback();
}
}
}
// вставлення масиву товарів без транзакції
public void InsertArticlesOutOfTransaction(Article[] articles) {
// додавання товарів
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
}
// перелік товарів
public List<Article> GetAllArticles() {
return Dao.GetAllArticles();
}
// видалити всі товари
public void DeleteAllArticles() {
Dao.DeleteAllArticles();
}
}
}
Клас має такі властивості:
- рядок 9: посилання на рівень [dao]
- рядок 11: рядок підключення, що дозволяє підключитися до бази даних товарів
Ми коментуємо лише метод InsertArticlesInTransaction, який є єдиним, що викликає труднощі:
- рядок 16: створюється з’єднання з базою даних
- рядок 18: з’єднання відкрито
- рядок 23: створюється транзакція
- рядок 25: транзакція записується в локальні дані потоку, пов’язана з ключем «transaction»
- рядки 27–29: для кожного елемента, що вставляється, викликається метод одиничного вставлення з рівня [dao]
- рядки 21 і 32: весь процес вставки масиву контролюється за допомогою блоку try/catch
- рядок 31: якщо ми дійшли до цього місця, значить, винятків не було. Тоді транзакція підтверджується.
- рядки 34–35: сталося виключення, тому транзакцію скасовують
- рядок 37: виходимо з блоку using. З’єднання, відкрите в рядку 18, автоматично закривається.
Реалізація шару [dao]
DAO-реалізація інтерфейсу IDao буде такою:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Dao : IDao {
// ланцюг з'єднання
public string ConnectionString { get; set; }
// запити
public string InsertText { get; set; }
public string DeleteAllText { get; set; }
public string GetAllText { get; set; }
// реалізація інтерфейсу
// додавання товару
public int InsertArticle(Article article) {
// чи є поточна транзакція?
SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
// отримати з'єднання або створити його
SqlCeConnection connexion = null;
if (transaction != null) {
// отримати з'єднання
connexion = transaction.Connection as SqlCeConnection;
} else {
// створити його
connexion = new SqlCeConnection(ConnectionString);
connexion.Open();
}
try {
// підготовка команди вставки
SqlCeCommand sqlCommand = new SqlCeCommand();
sqlCommand.Transaction = transaction;
sqlCommand.Connection = connexion;
sqlCommand.CommandText = InsertText;
sqlCommand.Parameters.Add("@nom", SqlDbType.NVarChar, 30);
sqlCommand.Parameters.Add("@prix", SqlDbType.Money);
sqlCommand.Parameters.Add("@sa", SqlDbType.Int);
sqlCommand.Parameters.Add("@sm", SqlDbType.Int);
sqlCommand.Parameters["@nom"].Value = article.Nom;
sqlCommand.Parameters["@prix"].Value = article.Prix;
sqlCommand.Parameters["@sa"].Value = article.StockActuel;
sqlCommand.Parameters["@sm"].Value = article.StockMinimum;
// виконання
return sqlCommand.ExecuteNonQuery();
} finally {
// якщо транзакція не була відкрита, закрити з'єднання
if (transaction == null) {
connexion.Close();
}
}
}
// список товарів
public List<Article> GetAllArticles() {
...
}
// видалення товарів
public void DeleteAllArticles() {
...
}
}
}
Клас має такі властивості:
- рядок 9: рядок з даними підключення, що дозволяє підключитися до бази даних статей
- рядок 11: команда SQL для вставки товару
- рядок 12: команда SQL для видалення всіх статей
- рядок 13: команда SQL для отримання всіх товарів
Ці властивості будуть ініціалізовані на основі наступного конфігураційного файлу [App.config]:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<connectionStrings>
<add name="dbArticlesSqlServerCe" connectionString="Data Source=|DataDirectory|\dbarticles.sdf;Password=dbarticles;" />
</connectionStrings>
<appSettings>
<add key="insertText" value="insert into articles(nom,prix,stockactuel,stockminimum) values(@nom,@prix,@sa,@sm)"/>
<add key="getAllText" value="select id,nom,prix,stockactuel,stockminimum from articles"/>
<add key="deleteAllText" value="delete from articles"/>
</appSettings>
</configuration>
Прокоментуємо метод InsertArticle:
- рядок 20: отримуємо транзакцію, яку, можливо, створив рівень [metier] у потоці
- рядки 23–25: якщо транзакція присутня, отримуємо з’єднання, до якого вона була прив’язана.
- рядки 26–30: інакше створюється та відкривається нове з’єднання.
- рядки 33–44: готується команда вставки. Вона налаштовується (див. рядок g у App.config).
- рядок 33: створюється об’єкт Command.
- рядок 34: він пов'язаний із поточною транзакцією. Якщо такої транзакції немає (transaction=null), це рівнозначно виконанню команди SQL без явної транзакції. Нагадаємо, що в цьому випадку все одно існує неявна транзакція. З SQL Server CE ця неявна транзакція за замовчуванням перебуває в режимі autocommit: команда SQL після виконання стає committé.
- рядок 35: об’єкт Command пов’язаний із поточним з’єднанням
- рядок 36: визначено текст SQl, який потрібно виконати. Це параметризований запит з рядка g об’єкта App.config.
- рядки 37–44: ініціалізовано 4 параметри запиту
- рядок 46: запит виконується.
- рядки 49–51: слід пам’ятати, що якщо транзакції не було, то було відкрито нове з’єднання з базою даних (рядки 26–30). У цьому випадку його потрібно закрити. Якщо ж транзакція була, з’єднання закривати не слід, оскільки ним керує рівень [metier].
Два інші методи повторюють те, що було розглянуто в розділі «Бази даних»:
// список товарів
public List<Article> GetAllArticles() {
// список товарів — спочатку порожній
List<Article> articles = new List<Article>();
// обробка з'єднання
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// відкриття з'єднання
connexion.Open();
// виконує sqlCommand із запитом select
SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
// обробка результату
while (reader.Read()) {
// обробка поточного рядка
articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
}
}
}
// повернення результату
return articles;
}
// видалення позицій
public void DeleteAllArticles() {
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// відкриття з'єднання
connexion.Open();
// виконання sqlCommand із запитом на оновлення
new SqlCeCommand(DeleteAllText, connexion).ExecuteNonQuery();
}
}
Тестовий додаток [console]
Тестова програма [console] має такий вигляд:
using System;
using System.Configuration;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// обробка файлу конфігурації
string connectionString = null;
string insertText;
string getAllText;
string deleteAllText;
try {
// ланцюг з'єднання
connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
// інші параметри
insertText = ConfigurationManager.AppSettings["insertText"];
getAllText = ConfigurationManager.AppSettings["getAllText"];
deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
} catch (Exception e) {
Console.WriteLine("Erreur de configuration : {0}", e.Message);
return;
}
// створення шару [dao]
Dao dao = new Dao();
dao.ConnectionString = connectionString;
dao.DeleteAllText = deleteAllText;
dao.GetAllText = getAllText;
dao.InsertText = insertText;
// створення шару [métier]
Metier metier = new Metier();
metier.Dao = dao;
metier.ConnectionString = connectionString;
// створення таблиці товарів
Article[] articles = new Article[2];
for (int i = 0; i < articles.Length; i++) {
articles[i] = new Article(0, "article", 100, 10, 1);
}
// видалення всіх товарів
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// вставляємо таблицю поза транзакцією
Console.WriteLine("Insertion des articles hors transaction...");
try {
metier.InsertArticlesOutOfTransaction(articles);
} catch (Exception e){
Console.WriteLine("Exception : {0}", e.Message);
}
// відображення товарів
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
// видалення всіх товарів
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// вставляємо таблицю в транзакцію
Console.WriteLine("Insertion des articles dans une transaction...");
metier.InsertArticlesInTransaction(articles);
// вивести товари
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
}
private static void AfficheArticles(IMetier metier) {
// виводить товари
foreach(Article article in metier.GetAllArticles()){
Console.WriteLine(article);
}
}
}
}
- рядки 12–22: використовується файл [App.config].
- рядки 24–28: створюється екземпляр шару [dao] та виконується його ініціалізація
- рядки 30–32: те саме робиться для шару [metier]
- рядки 34–37: створюється масив із 2 елементів з однаковою назвою. Таблиця [articles] у базі даних SQL на сервері [dbarticles.sdf] має обмеження на унікальність імені. Тому вставлення другого товару буде відхилено. Якщо вставлення таблиці відбувається поза транзакцією, спочатку буде вставлено перший товар, який залишиться в таблиці. Якщо вставлення таблиці відбувається в рамках транзакції, спочатку буде вставлено перший товар, а потім він буде видалено під час завершення транзакції.
- рядки 39–50: вставлення таблиці з 2 позиціями поза транзакцією та перевірка.
- рядки 52–59: те саме, але в транзакції
Результати виконання такі:
- рядки 5–6: вставлення поза транзакцією залишило перший товар у базі даних
- рядок 9: вставлення, виконане в транзакції, не залишило жодного товару в базі даних
10.9.3. Висновок
Попередній приклад продемонстрував користь локальних даних для потоку при управлінні транзакціями. Його не слід повторювати в такому вигляді. Такі фреймворки, як Spring, Nhibernate тощо, використовують цю техніку, але роблять її ще більш прозорою: шар [metier] може використовувати транзакції, не повідомляючи про це шару [dao]. Отже, у коді шару [dao] немає жодного об’єкта Transaction. Це досягається за допомогою техніки проксі, яка називається AOP (Aspects Oriented Programming). Знову ж таки, ми можемо лише закликати читача використовувати ці фреймворки.
10.10. Для поглиблення знань...
Щоб глибше ознайомитися зі складною темою синхронізації потоків, можна прочитати розділ Threading книги «C# 3.0», на яку посилається вступ до цього документа. У ньому представлено чимало технік синхронізації для різних типів ситуацій.







