Потоки и синхронизация: часть 1

ОГЛАВЛЕНИЕ

Статья рассматривает потоки и синхронизацию потоков с позиции Microsoft .NET

Введение

Современная многопроцессорная операционная система выполняет несколько операций одновременно, даже если в системе есть только один физический процессор. Теоретически это кажется невозможным, но рассмотрим, как достигается параллельная обработка с помощью одного процессора.

Есть много этапов в ходе выполнения процесса, когда процессор бездействует и ждет наступления некоторого события. Например, если ваше приложение выполняет какую-то операцию ввода-вывода, то во время операции ввода-вывода процессор бездействует и ждет завершения операции ввода-вывода. Аналогично, если ваше приложение выполняет запрос к базе данных, процессор бездействует и ждет, когда сервер баз данных ответит на запрос. По сути, в ходе выполнения процесса есть много этапов, когда процессор бездействует. Операционные системы используют эту возможность и используют бездействующий процессор для выполнения другого процесса. То есть, операционная система делит время процессора между процессами, чтобы достичь параллельной обработки.

В ранние времена параллельной обработки операционная система полагалась на то, что каждый процесс регулярно уступал процессор другим процессам в системе. При таком подходе плохо спроектированное или зависшее приложение могло остановить всю систему. Данный подход называется совместная многозадачность, когда каждый процесс должен взаимодействовать с операционной системой, чтобы добиться параллельной обработки, регулярно уступая процессор.

Современная операционная система придерживается подхода вытесняющей многозадачности, когда операционные системы имеют встроенные условия для переключения процессора с одного процесса на другой и не зависят от освобождения процессора основными процессами. Действие отнятия контроля над процессором у одного процесса и передачи его другому процессу называется "вытеснение". Данный подход позволяет другим процессам продолжать выполняться, даже если один из процессов завис. Переключение процессоров прозрачно для процессов, поэтому разработчикам не надо выполнять специальных действий для поддержки многопроцессорных операционных систем. Условиями для переключения процессора с одного процесса на другой могут быть:

•    Истекшее время: Операционная система может делить фиксированное время процессора между процессами
•    Приоритет: Процесс с более высоким приоритетом ожидает процессора
•    Ожидание наступления события: Текущий процесс ожидает ввода-вывода, и процессор бездействует
•    Сочетание вышеназванного

Структура данных, используемая операционной системой для планирования и разделения времени процессора между процессами, называется "поток". Каждый процесс имеет минимум один поток.

Пока было рассмотрено выполнение нескольких процессов. Современные технологии, среды разработки и языки позволяют одновременно выполнять несколько задач внутри одного процесса, например, одновременно рисовать график на экране и вычислять простое число с помощью одного и того же процесса. То есть, один процесс может иметь более одного потока операционной системы, чтобы выполнять несколько задач одновременно.

Следующий пример показывает одновременное выполнение нескольких задач путем создания нового потока. Вновь созданный поток используется для выполнения функции ConcurrentTask().Окно вывода показывает результаты, порожденные главным потоком и вновь созданным потоком. Замечание: Всякий раз при выполнении следующей программы вы будете получать разный вывод, так как нельзя предсказать, когда операционная система переключит процессор между потоками.

class Program
    {
        static void Main(string[] args)
        {
            ThreadsExperiment.Program experiment;
           
            experiment = new Program();
            System.Threading.Thread newThread =
                new System.Threading.Thread(
                new System.Threading.ThreadStart(experiment.ConcurrentTask));
            newThread.Start();
           
            for (int iLoop = 0; iLoop <= 100; iLoop++)
            {
                Console.WriteLine("Главная задача " + iLoop.ToString());
            }

            newThread.Join();

            Console.ReadLine();

        }

        public void ConcurrentTask()
        {
            for (int iLoop = 0; iLoop <= 100; iLoop++)
            {
                Console.WriteLine("Параллельная задача " + iLoop.ToString());

            }
        }
  }

 

Исходя из примера выше, многопоточность/многозадачность кажется простой и интересной темой. Исходя из опыта, рекомендуется использовать как можно меньше потоков, потому что управлять выполнением нескольких потоков в приложении          непросто. Это особенно верно, если потоки обращаются к общим ресурсам. В данной статье разобраны проблемы синхронизации в многопоточности и разные способы и приемы, доступные в Microsoft .NET.

Многопоточность в .NET

Каркас Microsoft .NET предоставляет разные способы достижения многопоточности. Простейший подход – использовать класс Thread, определенный в пространстве имен System.Threading. Хотя нет прямого соответствия между потоком операционной системы и классом управляемого потока, для простоты класс Thread можно считать управляемой оберткой вокруг потока операционной системы. Каждое управляемое приложение может иметь стандартный поток, предоставляемый CLR(общеязыковая среда исполнения).

Вы можете создать свой собственный поток путем передачи делегата типа System.Threading.ThreadStart. Этот делегат ссылается на метод, который будет выполняться асинхронно вновь созданным потоком. .NET 2.0 вводит новый конструктор класса Thread, принимающий делегат типа System.Threading.ParameterizedThreadStart в качестве параметра. Этот делегат, в свою очередь, принимает System.Object в качестве параметра. Итак, с .NET 2.0 и далее, вы можете отправить любой объект или даже коллекцию объектов асинхронной операции, как определено в следующем примере.

class Program
    {
        static void Main(string[] args)
        {
            ThreadsExperiment.Program experiment;
            System.Threading.Thread thread;
            int num1;
            int num2;

            System.Threading.Thread.CurrentThread.Name = "Default Thead";
            experiment = new Program();

            Console.Write ("Введите 1е число: ");
            num1 = int.Parse(Console.ReadLine());

            Console.Write("Введите 2nd число: ");
            num2 = int.Parse(Console.ReadLine());

            thread = new System.Threading.Thread(new      
            System.Threading.ParameterizedThreadStart(
                experiment.CalculateFactorial));
            thread.Name = "New Thread";
            thread.Start(num1);

            experiment.CalculateFactorial(num2);

            thread.Join();
            Console.ReadLine();
        }

        public void CalculateFactorial(object obj)
        {
            int factorial;

            int num = (int)obj;
            factorial = 1;
            for (int iloop = num; iloop >= 1; iloop--)
            {
                factorial = factorial * iloop;
            }
            Console.WriteLine(System.Threading.Thread.CurrentThread.Name +
                " вычисляется факториал " +
                num.ToString() + " как " + factorial.ToString());
        }
    }

В примере выше асинхронно вычисляются факториалы двух чисел. Оператор thread.Join() используется для проверки того, что вновь созданный поток закончил обработку перед завершением приложения. В каркасе .NET управляемый поток может быть приоритетным или фоновым потоком. CLR предполагает, что управляемое приложение работает, если минимум один приоритетный поток работает. Если в приложении нет ни одного работающего приоритетного потока, CLR завершит работу приложения. По умолчанию, потоки, создаваемые классом Thread, являются приоритетными. Но можно сделать их фоновыми с помощью свойства IsBackground.

Пул потоков .NET

Чтобы дополнительно упростить модель потоков, Microsoft предоставляет встроенный пул потоков в .NET. Создание и уничтожение потоков тратит ресурсы и время. В ходе создания потока объект потока ядра создается и инициализируется. Затем выделяется стек потока, и извещения отправляются всем DLL(динамическая библиотека). Аналогично, когда поток уничтожается, объект ядра освобождается, память стека освобождается, и извещения отправляются DLL.

Для повышения производительности CLR предоставляет пул потоков. Когда приложению надо выполнить задачу асинхронно, он просит пул потоков выполнить эту задачу. Пул потоков выполняет задачу, используя один из своих потоков. Когда задача выполнена, поток возвращается обратно пулу. Для каждого асинхронного запроса не создается новый поток. Если приложение запрашивает больше потоков, чем имеется в пуле, пул потоков помещает запрос в очередь с ожиданием или создает дополнительные потоки. Если в пуле потоков есть много неработающих потоков, пул потоков уничтожает их для освобождения ресурсов системы. Следующий код использует тот же пример вычисления факториала двух чисел асинхронно с помощью пула потоков.

class Program
    {
        static void Main(string[] args)
        {
            ThreadsExperiment.Program experiment;
            int num1;
            int num2;

            experiment = new Program();

            Console.Write ("Please enter 1st number: ");
            num1 = int.Parse(Console.ReadLine());

            Console.Write("Please enter 2nd number: ");
            num2 = int.Parse(Console.ReadLine());

            System.Threading.ThreadPool.QueueUserWorkItem(new
            System.Threading.WaitCallback(
                experiment.CalculateFactorial), num1);
         
            experiment.CalculateFactorial(num2);
            System.Threading.Thread.Sleep(1000);
            Console.ReadLine();
        }

        public void CalculateFactorial(object obj)
        {
            int factorial;

            int num = (int)obj;
            factorial = 1;
            for (int iloop = num; iloop >= 1; iloop--)
            {
                factorial = factorial * iloop;
            }
            Console.WriteLine(System.Threading.Thread.CurrentThread.Name +
                " calculates the factrorial of " +
                num.ToString() + " as " + factorial.ToString());
           
        }
    }

Так как все потоки пула потоков являются фоновыми, главный приоритетный поток ждет, когда фоновые потоки завершат выполнение своих задач. Замечание: Вы можете получить другой результат из нижеприведенного.

Фоновый работник

Microsoft .NET также предоставляет компонент пользовательского интерфейса System.ComponentModel.BackgroundWorker для реализации многопоточности. Этот компонент внутри использует пул потоков для асинхронного выполнения задач. Этот класс возбуждает события, чтобы известить хост о состоянии асинхронной операции. Данный класс делает ваш пользовательский интерфейс отзывчивым, даже если вы выполняете длинную операцию.

Таймер

Для асинхронного выполнения задачи на основе интервалов Microsoft .NET предоставляет класс Timer. Этот класс внутри использует пул потоков для асинхронного выполнения задач.


Асинхронные делегаты

При определении делегата в приложении CLR добавляет два дополнительных метода: BeginInvoke и EndInvoke. Эти методы применяются для асинхронного вызова метода, на который ссылается делегат. BeginInvoke возвращает IAsyncResult, применяемый для отслеживания хода асинхронной операции. BeginInvoke внутри использует класс пула потоков для асинхронного вызова метода.

class Program
    {
        public delegate void FactorialDelegate(object obj);

        static void Main(string[] args)
        {
             ThreadsExperiment.Program experiment;
            FactorialDelegate factorialImpl;
            IAsyncResult asyncResult;
            int num1;
            int num2;

            experiment = new Program();

            Console.Write ("Please enter 1st number: ");
            num1 = int.Parse(Console.ReadLine());

            Console.Write("Please enter 2nd number: ");
            num2 = int.Parse(Console.ReadLine());

            factorialImpl = experiment.CalculateFactorial;
            asyncResult =  factorialImpl.BeginInvoke (num1, null, null);
         
            experiment.CalculateFactorial(num2);

            factorialImpl.EndInvoke(asyncResult);
            Console.ReadLine();
        }
        public void CalculateFactorial(object obj)
        {
            int factorial;

            int num = (int)obj;
            factorial = 1;
            for (int iloop = num; iloop >= 1; iloop--)
            {
                factorial = factorial * iloop;
            }
            Console.WriteLine(System.Threading.Thread.CurrentThread.Name +
                " calculates the factrorial of " +
                num.ToString() + " as " + factorial.ToString());
           
        }
    }

Чтобы больше узнать о делегатах, читайте статью Изучение делегатов. Пока были рассмотрены разные способы разработки многопоточного приложения в .NET. Далее рассмотрены способы синхронизации потоков, имеющиеся в каркасе Microsoft .NET.

Синхронизация потоков

Синхронизация потоков необходима, когда несколько потоков обращаются к общему ресурсу. Одновременное обращение нескольких потоков к общему ресурсу может нарушить устойчивость приложения; например, если два потока пишут в один и тот же файл, то данные в файле будут непредсказуемыми. Общим ресурсом может быть файл, блок данных в памяти, устройство или коллекция объектов.

Кусок кода, где поток обращается к общему ресурсу, называется "критической секцией". Способы синхронизации потоков гарантируют, что если один поток выполняет свою критическую секцию, ни один другой поток не может войти в критическую секцию. Для написания стабильного многопоточного приложения надо ясно понимать способы синхронизации потоков. Если эта область программирования написана плохо, крайне тяжело выявить и устранить ошибки, что ухудшает производительность приложения. Способы синхронизации потоков должны удовлетворять следующим условиям:
•    Взаимное исключение: Если поток находится в критической секции, ни один другой поток не может войти в критическую секцию.
•    Прогресс: Если ни один поток не выполняет свою критическую секцию и есть другие потоки, желающие войти в свою критическую секцию, то выбор потоков, которые следующими войдут в критическую секцию, нельзя откладывать бесконечно.

Каркас Microsoft .NET предоставляет несколько способов синхронизации потоков. Разберем их по одному и обсудим, насколько они отвечают вышеназванным требованиям к синхронизации потоков.

SyncBlock

Когда объект создается в куче, CLR добавляет поле под именем SyncBlock. CLR предоставляет механизм предоставления владения SyncBlock объекта потоку. В любой момент времени только один поток может владеть SyncBlock для конкретного объекта. Потоки могут запрашивать владение SyncBlock для конкретного объекта obj путем выполнения оператора Monitor.Enter(obj). Если никакой другой поток не владеет SyncBlock объекта obj, то владение предоставляется текущему потоку, в противном случае текущий поток приостанавливается. Monitor.Exit(obj) применяется для снятия владения. Следующий пример использует класс Monitor для синхронизации потоков, обращающихся к общему ресурсу historyData;:

class HistoryManager
    {
        private Object threadSyncObject;
        private System.Collections.Hashtable historyData;

        public HistoryManager()
        {
            threadSyncObject = new object();
            historyData = new System.Collections.Hashtable();
        }


        public Object ReadHistoryRecord(int historyID)
        {
            try
            {
                //Пытаемся завладеть SyncBlock объекта threadSyncObject
                System.Threading.Monitor.Enter(threadSyncObject);
                 return historyData[historyID];
            }
            finally
            {
                System.Threading.Monitor.Exit(threadSyncObject);
                //Снимаем владение
            }
        }

        public Object AddHistoryRecord(int historyID, Object historyRecord)
        {
            try
            {
                //Пытаемся завладеть SyncBlock объекта threadSyncObject
                System.Threading.Monitor.Enter(threadSyncObject);
                historyData.Add(historyID, historyRecord);
            }
            finally
            {
                // Снимаем владение
                System.Threading.Monitor.Exit(threadSyncObject);  
            }
        }
    }

Язык C# предоставляет простой оператор "блокировки" в качестве альтернативы классу Monitor. Следуя подходу SyncBlock, помните о следующих типичных ошибках:

Не используйте типы данных значений для синхронизации потоков.

SyncBlock связан с типом ссылочных данных. Если типы данных значений передаются в качестве аргументов Monitor.Enter, они сначала упаковываются, а затем владение SyncBlock упакованного значения предоставляется вызывающему потоку. Поэтому, если два потока пытаются завладеть типом значения, им завладеют оба потока, так как оба ссылаются на два разных упакованных значения. Аналогично, Monitor.Exit не снимет блокировку, так как она будет ссылаться на другое упакованное значение, как показано в следующем примере:

class HistoryManager
    {
        private int threadSync;
        private System.Collections.Hashtable historyData;

        public HistoryManager()
        {
            threadSyncObject = new object();
            historyData = new System.Collections.Hashtable();
        }


        public Object ReadHistoryRecord(int historyID)
        {
            try
            {
                //threadSync будет упакован, а затем
                //владение SyncBlock
                //вновь упакованного значения сразу будет
                //предоставлено текущему потоку
                System.Threading.Monitor.Enter(threadSync);  
                return historyData[historyID];
            }
            finally
            {
                //Не снимет блокировку, так как в этот раз
                //это другое упакованное значение
                System.Threading.Monitor.Exit(threadSync);
            }
        }

        public Object AddHistoryRecord(int historyID, Object historyRecord)
        {
            try
            {
                //threadSync будет упакован, а затем
                //владение SyncBlock
                // вновь упакованного значения будет сразу
                //предоставлено текущему потоку
                System.Threading.Monitor.Enter(threadSync);  
                historyData.Add(historyID, historyRecord);
            }
            finally
            {
                // Не снимет блокировку, так как в этот раз
                // это другое упакованное значение
                System.Threading.Monitor.Exit(threadSync);   
            }
        }
    }

Не используйте класс Тип для синхронизации потоков.

Каждому типу соответствует класс описателя типа. Ссылку описателя типа можно получить путем вызова метода GetType(). Так как описатель типа является объектом и имеет связанный с ним SyncBlock, он может применяться для синхронизации потоков. Так как есть только один экземпляр на каждый тип в процессе, нельзя использовать его для синхронизации потоков, так как некий посторонний код может использовать его для блокировки и способен вызвать зависание. Microsoft рекомендует использовать локальные объекты для синхронизации потоков.

Сборщик мусора собирает мусор в своем собственном потоке.

Если объект блокируется, а затем уничтожается без снятия блокировки, и если одновременно сборщик мусора начинает собирать объект, метод finalize может быть вызван. Причина состоит в том, что когда сборщик мусора начинает собирать мусор, все прочие потоки останавливаются, и метод finalize вызывается потоком сборщика мусора. Если заблокировать объект в методе finalize, поток сборщика мусора не сможет заблокировать объект, так как он уже заблокирован основным потоком. Это остановит приложение. Такая ситуация показана ниже:

class Program
    {
        public delegate void FactorialDelegate(object obj);

        static void Main(string[] args)
        {
           

            ThreadsExperiment.Program experiment;
            experiment = new Program();

            System.Threading.Monitor.Enter(experiment);
            experiment = null;
           
            //Сборщик мусора вынужден собирать и вызывать все ожидающие методы
// Завершить.
            GC.Collect();
            GC.WaitForPendingFinalizers();

            Console.WriteLine("press any enter to exit");
            Console.ReadLine();
        }

        ~Program()
        {
            System.Threading.Monitor.Enter(this);
            //Управление никогда не перейдет сюда
            System.Threading.Monitor.Exit(this);
        }
    }

SyncBlock является одним из способов реализации синхронизации между потоками в каркасе Microsoft .NET. В части 2 данной статьи будут рассмотрены другие способы синхронизации потоков, доступные в каркасе Microsoft .NET.