Рассмотрим один вспомогательный класс из пространства System.Threading – класс Timer. При помощи этого класса можно организовать вызов определенного метода через указанный промежуток времени.
using System;
using System.Threading;
class MyApp {
static bool TickNext = true;
static void Main() {
Console.WriteLine("Press Enter to terminate...");
TimerCallback callback = new TimerCallback(TickTock);
Timer timer = new Timer(callback, null, 1000, 2000);
Console.ReadLine();
}
static void TickTock(object state) {
Console.WriteLine(TickNext ? "Tick" : "Tock");
TickNext = ! TickNext;
}
}
В приведенном примере через 1 секунду после создания (третий параметр в конструкторе Timer) с периодичностью 2 секунды (четвертый параметр конструктора) вызывается метод, заданный делегатом callback (первый параметр конструктора и инициализация делегата). Более подробное описание класса Timer можно найти в соответствующем разделе документации SDK.
2.11. Синхронизация потоков
В предыдущем параграфе рассматривались простейшие ситуации, когда несколько потоков работали с независимыми данными. Однако в реальной программе потоки обычно логически связаны друг с другом по данным или ресурсам. Для того чтобы скоординировать их совместную работу, требуются специальные механизмы синхронизации.
Критические секции являются простейшим сервисом синхронизации кода. Они позволяют предотвратить одновременное исполнение защищенных участков кода из различных потоков. Рассмотрим следующий пример. Пусть два потока пытаются выводить данные на консоль порциями по 10 символов:
using System;
using System.Threading;
class MyApp {
static void PrintText(string text) {
for(int i = 0; i < 10; i++) {
Console.Write(text);
Thread.Sleep(100);
}
}
static void FirstPrinter() {
while(true) PrintText("x");
}
static void SecondPrinter() {
while(true) PrintText("o");
}
static void Main() {
Thread th1 = new Thread(new ThreadStart(FirstPrinter));
Thread th2 = new Thread(new ThreadStart(SecondPrinter));
th1.Start();
th2.Start();
}
}
Работа данной программы отличается от ожидаемой: на консоль выводятся символы "x" и "o" в случайном порядке, так как в цикл вывод символов на консоль может «вклиниться» другой поток. В данном примере консоль выступает в качестве такого ресурса, доступ к которому требуется заблокировать, чтобы с этим ресурсом мог работать только один поток. Вывод последовательности из десяти символов является критической секцией программы.
Язык C# содержит специальный оператор lock, задающий критическую секцию. Формат данного оператора следующий:
lock(<выражение>) { <блок критической секции> }
<Выражение> является идентификатором критической секции. В качестве выражения выступает переменная ссылочного типа. Для lock-секций, размещенных в экземплярных методах класса, выражение обычно равно this, для критических секций в статических методах в качестве выражения используется typeof(<имя класса>).
Изменим предыдущий пример следующим образом:
using System;
using System.Threading;
class MyApp {
static void PrintText(string text) {
// Задаем критическую секцию
lock(typeof(MyApp)) {
for(int i = 0; i < 10; i++) {
Console.Write(text);
Thread.Sleep(100);
}
}
}
. . .
}
После подобного изменения данные на консоль выводятся правильно – порциями по 10 символов.
Рассмотрим еще один пример, в котором необходимо использование критической секции. Пусть имеется класс с целочисленным массивом и методами, работающими с данным массивом:
using System;
using System.Threading;
class MyApp {
// В buffer хранятся данные, с которыми работают потоки
static int[] buffer = new int[100];
static Thread writer;
static void Main() {
// Инициализируем the buffer
for(int i=0; i<100; i++)
buffer[i] = i + 1;
// Запустим поток для перезаписи данных
writer = new Thread(new ThreadStart(WriterFunc));
writer.Start();
// запустим 10 потоков для чтения данных
Thread[] readers = new Thread[10];
for(int i=0; i<10; i++) {
readers[i] =
new Thread(new ThreadStart(ReaderFunc));
readers[i].Start();
}
}
static void ReaderFunc() {
// Работаем, пока работает поток записи
while(writer.IsAlive) {
int sum = 0;
// Считаем сумму элементов из buffer
for(int k=0; k<100; k++) sum += buffer[k];
// Если сумма неправильная, сигнализируем
if(sum != 5050) {
Console.WriteLine("Error in sum!");
return;
}
}
}
static void WriterFunc() {
Random rnd = new Random();
// Цикл на 10 секунд
DateTime start = DateTime.Now;
while((DateTime.Now - start).Seconds < 10) {
int j = rnd.Next(0, 100);
int k = rnd.Next(0, 100);
int tmp = buffer[j];
buffer[j] = buffer[k];
buffer[k] = tmp;
}
}
}
При работе данного приложения периодически возникают сообщения о неправильно посчитанной сумме. Причина этого заключается в том, что метод WriterFunc() может изменить данные в массиве buffer во время подсчета суммы. Решение проблемы: объявим критическую секцию, содержащую код, работающий с массивом buffer.
static void ReaderFunc() {
while(writer.IsAlive) {
int sum = 0;
lock(buffer) {
for(int k=0; k<100; k++) sum += buffer[k];
}
// Далее по тексту
. . .
}
}
static void WriterFunc() {
Random rnd = new Random();
DateTime start = DateTime.Now;
while((DateTime.Now - start).Seconds < 10) {
int j = rnd.Next(0, 100);
int k = rnd.Next(0, 100);
lock(buffer) {
int tmp = buffer[j];
buffer[j] = buffer[k];
buffer[k] = tmp;
}
}
}
Обратите внимание на использование одинаковых идентификаторов при указании критической секции (в разных частях программы).
Команда lock языка C# – это всего лишь скрытый способ работы со специальным классом System.Threading.Monitor. А именно, объявление вида
lock(buffer){ . . . }
эквивалентно следующему:
Monitor.Enter(buffer);
try {
. . .
}
finally {
Monitor.Exit(buffer);
}
Статический метод Monitor.Enter() определяет вход в критическую секцию, статический метод Monitor.Exit() – выход из секции. Параметрами данных методов является объект – идентификатор критической секции.
Коротко опишем базовые принципы внутренней организации критической секции. Любой объект имеет скрытое поле syncnum, которое хранит указатель на элемент таблицы блокировок. Если некоторый поток пытается войти в критическую секцию, выполняется проверка значения syncnum. Если данное значение равно null, то код критической секции «свободен» и его можно выполнять. В противном случае поток ставиться в системную очередь, из которой извлекается для выполнения тогда, когда критическая секция освободиться.
Вернемся к предыдущему примеру. Требование наличия критической секции в методе WriterFunc() очевидно: иначе подсчет суммы может вклиниться между инструкциями buffer[j] = buffer[k] и buffer[k] = tmp и получить неверное значение. Когда мы считаем сумму в методе ReaderFunc(), то очевидно, что мы не должны менять значение массива. Однако и в первом и во втором случае требуется блокировать потоки на одном ресурсе. Соответственно, речь идет об одной критической секции, но как бы «размазанной» по двум методам. Не важно, что мы используем buffer в качестве идентификатора критической секции. Это может быть любой инициализированный объект. Таким образом, следующий код также обеспечивает правильную работу:
class MyApp {
. . .
static object someObj = new Random(); // Какой-то объект
. . .
static void ReaderFunc() {
while(. . .) {
. . .
lock(someObj) {
. . .
}
. . .
}
}
static void WriterFunc() {
. . .
while(. . .) {
. . .
lock(someObj) {
. . .
}
}
}
}
Если требуется простая синхронизация потоковых действий с целочисленной переменной, то для этих целей можно использовать класс System.Threading.Interlocked. Данный класс располагает следующими четырьмя статическими методами:
· Increment() – Увеличивает на единицу переменную типа int или long;
· Decrement() – Уменьшает на единицу переменную типа int или long;
· Exchange() – Обменивает значения двух переменных типа int, long или любых двух объектов;
· CompareExchange() – Сравнивает значения первых двух параметров, в случае совпадения заменяет этим значением значение третьего параметра. Тип параметров: int, float, object.
Платформа .NET предоставляет простой способ синхронизации доступа к методам на основе атрибутов. В пространстве имен System.Runtime.CompilerServices описан атрибут MethodImplAttribute, который может применяться к конструкторам и методам и указывает для компилятора особенности реализации метода. Аргументом атрибута являются элементы перечисления MethodImplOptions. В контексте рассматриваемой темы представляет интерес элемент MethodImplOptions.Synchronized. Для того чтобы запретить одновременное выполнение некоторого метода в разных потоках, достаточно объявить метод следующим образом:
[MethodImpl(MethodImplOptions.Synchronized)]
void TransformData(byte[] buffer) { . . . }
При таком объявлении метода можно считать, что любой его вызов будет неявно заключен в критическую секцию.
В заключение рассмотрим класс System.Threading.ThreadPool. Данный класс предназначен для поддержки пула потоков. Пул потоков автоматически запускает указанные методы в различных потоках. Одновременно пул поддерживает 25 запущенных потоков, другие потоки ожидают своей очереди в пуле.
Для регистрации методов в пуле потока служит статический метод QueueUserWorkItem(). Его параметр – это делегат типа WaitCallback:
public delegate void WaitCallback(object state);
При помощи объекта state в метод потока передаются параметры.
Рассмотрим пример приложения, использующего ThreadPool. В приложении в пул помещается 5 одинаковых методов, выводящих значение счетчика на экран:
usingSystem;
usingSystem.Threading;
class MyApp {
static intcount = 0; // счетчик
static void Main() {
WaitCallback callback =
new WaitCallback(ProcessRequest);
ThreadPool.QueueUserWorkItem(callback);
ThreadPool.QueueUserWorkItem(callback);
ThreadPool.QueueUserWorkItem(callback);
ThreadPool.QueueUserWorkItem(callback);
ThreadPool.QueueUserWorkItem(callback);
// Приостанавливаемся, чтобы выполнились методы
Thread.Sleep(5000);
}
static void ProcessRequest(object state) {
int n = Interlocked.Increment(ref count);
Console.WriteLine(n);
}
}
Перегруженная версия метода ThreadPool.QueueUserWorkItem() имеет два параметра: первый – это делегат, второй – объект, при помощи которого делегату можно передать информацию:
int[] vals = new int[5]{1, 2, 3, 4, 5};
ThreadPool.QueueUserWorkItem(callback, vals);
// Объявление и реализация ProcessRequest()
static void ProcessRequest(object state) {
int[] vals = (int[])state;
. . .
}
Поток из пула никогда не должен уничтожаться «вручную». Автоматический менеджер пула потоков берет на себя работу по созданию потока в пуле, он же будет уничтожать потоки. Для того чтобы определить вид потока, можно использовать свойство IsThreadPoolThread класса Thread. В следующем примере поток уничтожает себя только в том случае, если он не запущен из пула:
if (!Thread.CurrentThread.IsThreadPoolThread)
Thread.CurrentThread.Abort();
2.12. Асинхронный вызов методов
Платформа .NET содержит средства для поддержки асинхронного вызова методов. При асинхронном вызове поток выполнения разделяется на две части: в одной выполняется метод, а в другой – нормальный процесс программы. Асинхронный вызов может служить (в некоторых случаях) альтернативой использованию многопоточности явным образом.
Асинхронный вызов метода всегда выполняется посредством объекта некоторого делегата. Любой такой объект содержит два специальных метода для асинхронных вызовов – BeginInvoke() и EndInvoke(). Данные методы генерируются во время выполнения программы (как и метод Invoke()), так как их сигнатура зависит от делегата.
Метод BeginInvoke() обеспечивает асинхронный запуск. Данный метод имеет два дополнительных параметра по сравнению с описанными в делегате. Назначение первого дополнительного параметра – передать делегат, указывающий на функцию обратного вызова, выполняемую после работы асинхронного метода (функция завершения). Второй дополнительный параметр – это объект, при помощи которого функции завершения может быть передана некоторая информация. Метод BeginInvoke() возвращает объект, реализующий интерфейс IAsyncResult, при помощи этого объекта становится возможным различать асинхронные вызовы одного и того же метода.
Приведем описание интерфейса IAsyncResult:
interface IAsyncResult {
object AsyncState{ get; }
WaitHandle AsyncWaitHandle{ get; }
bool CompletedSynchronously{ get; }
bool IsCompleted{ get; }
}
Поле IsCompleted позволяет узнать, завершилась ли работа асинхронного метода. В поле AsyncWaitHandle храниться объект типа WaitHandle. Программист может вызывать методы класса WaitHandle, такие как WaitOne(), WaitAny(), WaitAll(), для контроля над потоком выполнения асинхронного делегата. Объект AsyncState хранит последний параметр, указанный при вызове BeginInvoke().
Делегат для функции завершения описан следующим образом:
public delegate void AsyncCallback(IAsyncResult ar);
Как видим, функции завершения передается единственный параметр: объект, реализующий интерфейс IAsyncResult.
Рассмотрим пример, иллюстрирующий описанные возможности.
using System;
using System.Threading; // Нужно для "усыпления" потоков
// Делегат для асинхронного метода
public delegate void Func(int x);
class MainClass {
// Этот метод делает необходимую работу
public static void Fib(int n) {
int a = 1, b = 1, res = 1;
for(int i = 3; i <= n; i++) {
res = a + b;
a = b;
b = res;
Thread.Sleep(10); // Намерено замедлим!
}
Console.WriteLine("Fib calculated: " + res);
}
public static void Main() {
Func A = new Func(Fib);
// Асинхронный вызов "выстрелил и забыл"
// У BeginInvoke() три параметра, два не используем
A.BeginInvoke(6, null, null);
// Изображаем работу
for (int i = 1; i < 10; i++) {
Thread.Sleep(20);
Console.Write(i);
}
}
}
Вывод программы:
12Fib calculated: 8
В данном приложении имеется функция для подсчета n-ного числа Фибоначчи. Чтобы эмулировать продолжительные действия, функция намеренно замедлена. После подсчета число выводится на экран. Ни функции завершения, ни возвращаемое BeginInvoke() значение не используется. Подобный метод работы с асинхронными методами называется «выстрелил и забыл» (fire and forget).
Модифицируем предыдущее приложение. Будем использовать при вызове BeginInvoke() функцию завершения, выводящую строку текста:
using System;
using System.Threading;
public delegate void Func(int x);
class MainClass {
public static void Fib(int n) { . . . }
// Это будет функция завершения
public static void Callback(IAsyncResult ar) {
// Достаем параметр
string s = (string) ar.AsyncState;
Console.WriteLine("AsyncCall is finished with " + s);
}
public static void Main() {
Func A = new Func(Fib);
// Два асинхронных вызова
A.BeginInvoke(6, new AsyncCallback(Callback), "The end");
A.BeginInvoke(8, new AsyncCallback(Callback), "Second call");
// Изображаем работу
for (int i = 1; i < 10; i++) {
Thread.Sleep(20);
Console.Write(i);
}
}
}
Вывод программы:
12Fib calculated: 8
Async Call is finished with The end
345Fib calculated: 21
Async Call is finished with Second call
В рассмотренных примерах использовались такие асинхронные методы, которые не возвращают значения. В приложениях может возникнуть необходимость работать с асинхронными методами-функциями. Для этой цели предназначен метод делегата EndInvoke(). Сигнатура метода EndInvoke() определяется на основе сигнатуры метода, инкапсулированного делегатом. Во-первых, метод является функцией, тип возвращаемого значения – такой как у делегата. Во-вторых, метод EndInvoke() содержит все out- и ref- параметры делегата, а его последний параметр имеет тип IAsyncResult. При вызове метода EndInvoke() основной поток выполнения приостанавливается до завершения работы соответствующего асинхронного метода.
Изменим метод Fib() из примера. Пусть он имеет следующую реализацию:
public static int Fib(int n, ref bool overflow) {
int a = 1, b = 1, res = 1;
overflow = false;
for (int i = 3; i <= n; i++) {
res = a + b;
// Устанавливаем флаг переполнения
if (res < 0) overflow = true;
a = b;
b = res;
}
return res;
}
В следующем примере запускаются два асинхронных метода, затем приложение дожидается их выполнения и выводит результаты на экран.
using System;
using System.Threading;
// Вот такой у нас теперь делегат
public delegate int Func(int n, ref bool overflow);
class MainClass {
// Функция считает числа Фибоначчи, следя за переполнением
// Вспомнили про методы. Остановились, ждем результат
int res = A.EndInvoke(ref over, ar2);
Console.WriteLine("Result is {0}, overflowed = {1}",
res, over);
// Теперь второй метод
res = A.EndInvoke(ref over, ar1);
Console.WriteLine("Result is {0}, overflowed = {1}",
res, over);
}
}
Вывод программы:
123456789Result is -298632863, overflowed = True
Result is 55, overflowed = False
Подведем небольшой итог. Асинхронный вызов является алтернативой использования многопоточности, так как реализует ее неявно, при помощи среды исполнения. Широкий спектр настроек позволяет решать при помощи асинхронных вызовов большой круг практических задач программирования.
2.13. Состав и взаимодействие сборок
Сборка (assembly) – это единица развертывания и контроля версий в .NET Framework. Заметим, что сборка задает границы видимости для типов (модификатор internal в C#). Сборка состоит из одного или нескольких программных модулей и файлов ресурсов. Эти компоненты могут размещаться в отдельных файлах, либо содержаться в одном файле. В любом случае, сборка содержит в некотором из своих файлов манифест, описывающий состав сборки. Будем называть сборку однофайловой, если она состоит из одного файла. В противном случае сборку будем называть многофайловой. Тот файл, который содержит манифест сборки, будем называть главным файлом сборки.
Рис. 5. Однофайловая и многофайловая сборки
Простые приложения обычно представлены однофайловыми сборками. При разработке сложных приложений переход к многофайловым сборкам дает следующие преимущества:
1.Ресурсы приложения (текстовые строки, изображения и т.д.) можно хранить вне кода приложения, что позволяет при необходимости изменять их без перекомпиляции приложения.
2.Если исполняемый код приложения разделен на несколько модулей, то модули загружаются в память только по мере необходимости. Кроме этого, скомпилированный модуль может использоваться в нескольких сборках.
Рассмотрим пример создания и использования многофайловых сборок. К сожалению, IDE Visual Studio не позволяет работать с многофайловыми сборками, поэтому все файлы примера придется компилировать, используя компилятор командной строки csc.exe.
Пусть требуется создать консольное приложение, в котором функция Main() печатает на экране строку. Содержимое строки оформим в виде текстового файла-ресурса. Код, который читает данный ресурс, будет оформлен в виде отдельного модуля (файла с расширением netmodule).
Ниже представлен класс, выполняющий чтение строки из подключенного к сборке ресурса. Код, выполняющий требуемые операции, стандартен и взят из примеров MSDN:
using System;
using System.Reflection;
using System.IO;
public class TextClass {
public static string GetText() {
// получаем ссылку на выполняемую сборку
Assembly a = Assembly.GetExecutingAssembly();
// получаем доступ к связанному ресурсу как к потоку
Stream s = a.GetManifestResourceStream("message.txt");
// "оборачиваем" в текстовый поток (специфика ресурса)
StreamReader sr = new StreamReader(s);
return sr.ReadToEnd();
}
}
Файл TextClass.cs с исходным текстом класса TextClass скомпилируем в виде модуля (обратите внимание на ключ компилятора):
C:\Temp\Test>csc /t:module TextClass.cs
После компиляции получим файл-модуль TextClass.netmodule. Далее, создадим главный файл нашего приложения (main.cs):
using System;
class MainClass {
static void Main() {
Console.WriteLine("Text from resource");
Console.WriteLine(TextClass.GetText());
Console.ReadLine();
}
}
Также создадим собственно текстовый файл-ресурс с именем message.txt (внимание: при работе с текстовыми ресурсами предпочтительнее использовать кодировку Unicode).
Теперь соберем нашу многофайловую сборку:
c:\TEMP>csc /linkresource:message.txt
/addmodule:textclass.netmodule main.cs
Обратите внимание на ключи компилятора. Ключ /addmodule позволяет добавить к сборке ссылку на внешний файл-модуль, ключ /linkresource позволяет связать со сборкой внешний файл-ресурс. Ключи могут использоваться произвольное количество раз.
В итоге, мы получили многофайловую сборку main.exe, состоящую из трех файлов: главного файла main.exe, дополнительного файла-модуля textclass.netmodule и файла-ресурса message.txt. Еще раз подчеркнем возможные преимущества многофайловых сборок. Во-первых, мы можем менять содержимое файла message.txt, не перекомпилируя сборку. Во-вторых, мы можем создать новую сборку, в которой используется код из модуля textclass.netmodule, то есть сделать этот модуль разделяемым между несколькими сборками. Важное замечание: предполагается, что все три файла, составляющие нашу многофайловую сборку, размещены в одном каталоге.
Следующий вопрос, рассматриваемый в данном параграфе, это взаимодействие сборок. Как правило, большие проекты состоят из нескольких сборок, ссылающихся друг на друга. Среди этих сборок имеется некая основная (обычно оформленная как исполняемый файл *.exe), а другие сборки играют роль подключаемых библиотек с кодом необходимых классов (обычно такие сборки – это файлы с расширением *.dll). Платформа .NET разделяет сборки на локальные (или сборки со слабыми именами) и глобальные (или сборки с сильными именами).
Представим пример, который будем использовать в дальнейшем. Пусть имеется следующий класс (в файле UL.cs), содержащий «полезную» функцию:
using System;
namespace UsefulLibrary {
public class UsefulClass {
public void Print() {
Console.WriteLine("Useful function");
}
}
}
Скомпилируем данный класс как DLL:
c:\TEMP\Test>csc /t:library UL.cs
Пусть основное приложение (файл main.cs) собирается использовать код из сборки UL.dll:
using System;
// подключаем требуемое пространство имен
using UsefulLibrary;
class MainClass {
static void Main() {
// используем класс
UsefulClass a = new UsefulClass();
a.Print();
Console.ReadLine();
}
}
Если UL.dll рассматривается как локальная сборка, то она должна находиться в том же каталоге[6], что и основное приложение main.exe как при компиляции, так и при выполнении приложения. Скомпилируем основное приложение main.cs:
c:\TEMP\Test>csc /r:UL.dll main.cs
Ключ компилятора /r (или /reference) позволяет установить ссылку на требуемую сборку.
Применением локальных сборок достигается простота развертывания приложения (все его компоненты сосредоточены в одном месте) и изолированность компонентов приложения. Имя локальной сборки – слабое имя – это имя файла сборки без расширения.
Хотя использование локальных сборок имеет свои преимущества, иногда необходимо сделать сборку общедоступной. До появления .NET Framework основным был подход, при котором код общих библиотек помещался в системный каталог простым копированием фалов при установке программ. Такой подход привел к проблеме, известной как «ад DLL» (DLL Hell). Новое установленное приложение могло заменить требуемую библиотеку новой версией, причем сделать это так, что приложения, ориентированные на старую версию библиотеки, переставали работать. Для решения проблемы DLL Hell в .NET Framework используется специальное защищенное хранилище сборок (Global Assembly Cache, GAC).
Сборки, помещаемые в GAC, должны удовлетворять определенным условиям. Во-первых, такие глобальные сборки должны иметь цифровую подпись. Это исключает подмену сборок злоумышленниками. Во-вторых, для глобальных сборок отслеживаются версии, и вполне допустимой является ситуация, когда в GAC находятся разные версии одной и той же сборки, используемые разными приложениями.
Сборка, помещенная в GAC, получает сильное имя. Именно использование сильного имени является тем признаком, по которому среда исполнения понимает, что речь идет не о локальной сборке, а о сборке из GAC. Сильное имя имеет следующую структуру:
Сильное имя включает собственно имя главного файла сборки без расширения, версию сборки, указание о региональной принадлежности сборки и маркер открытого ключа сборки.
Рассмотрим процесс создания строго именованной сборки на примере сборки UL.dll. Первое: необходимо создать пару криптографических ключей для цифровой подписи сборки. Для этих цели служит утилита sn.exe, входящая в состав Microsoft .NET Framework SDK.
sn -k keys.snk
Параметр -k указывает на создание ключей, keys.snk – это файл с ключами. Просмотреть полученные ключи можно, используя команду sn -tp.
Далее необходимо подписать сборку полученными ключами. Для этого используется специальный атрибут уровня сборки AssemblyKeyFile:
using System;
using System.Reflection;
[assembly: AssemblyKeyFile("keys.snk")]
namespace UsefulLibrary { . . . }
Обратите внимание: для использования атрибута необходимо подключить пространство имен System.Reflection; в качестве параметра атрибута указывается полное имя файла с ключами; атрибут должен располагаться вне любого пространства имен.