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

98

При построении многопоточного приложения необходимо гарантировать, что любая часть разделяемых данных защищена от возможности изменения их значений множеством потоков. Учитывая, что все потоки в AppDomain имеют параллельный доступ к разделяемым данным приложения, представьте, что может случиться, если несколько потоков одновременно обратятся к одному и тому же элементу данных. Поскольку планировщик потоков случайным образом будет приостанавливать их работу, что если поток А будет прерван до того, как завершит свою работу? А вот что: поток В после этого прочтет нестабильные данные.

Чтобы проиллюстрировать проблему, связанную с параллелизмом, давайте рассмотрим следующий пример:

public class MyTheard
    {
        public void ThreadNumbers()
        {
            // Информация о потоке
            Console.WriteLine("{0} поток использует метод ThreadNumbers",Thread.CurrentThread.Name);
            // Выводим числа
            Console.Write("Числа: ");
            for (int i = 0; i < 10; i++)
            {
                Random rand = new Random();
                Thread.Sleep(1000*rand.Next(5));
                Console.Write(i+", ");
            }
            Console.WriteLine();
        }
    }

    class Program
    {
        static void Main()
        {
            MyTheard mt = new MyTheard();

            // Создаем 10 потоков
            Thread[] threads = new Thread[10];

            for (int i = 0; i < 10; i++)
            {
                threads[i] = new Thread(new ThreadStart(mt.ThreadNumbers));
                threads[i].Name = string.Format("Работает поток: #{0}", i);
            }

            // Запускаем все потоки
            foreach (Thread t in threads)
                t.Start();

            Console.ReadLine();            
        }
    }

Прежде чем посмотреть на тестовые запуски, давайте еще раз проясним проблему. Первичный поток внутри этого домена приложений начинает свое существование, порождая десять вторичных рабочих потоков. Каждый рабочий поток должен вызвать метод ThreadNumbers() на одном и том же экземпляре MyTheard. Учитывая, что никаких мер для блокировки разделяемых ресурсов этого объекта (консоли) не предпринималось, есть хороший шанс, что текущий поток будет отключен, прежде чем метод ThreadNumbers() сможет напечатать полные результаты. Поскольку в точности не известно, когда это может случиться (и может ли вообще), будут получаться непредвиденные результаты. Например, может появиться следующий вывод:

Блокировка ресурсов

Ясно, что здесь присутствует определенная проблема. Как только каждый поток требует от MyTheard печати числовых данных, планировщик потоков меняет их местами в фоновом режиме. В результате получается несогласованный вывод. Для решения подобных проблем в C# используется синхронизация.

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

Средство блокировки встроено в язык C#. Благодаря этому все объекты могут быть синхронизированы. Синхронизация организуется с помощью ключевого слова lock. Она была предусмотрена в C# с самого начала, и поэтому пользоваться ею намного проще, чем кажется на первый взгляд. В действительности синхронизация объектов во многих программах на C# происходит практически незаметно.

Ниже приведена общая форма блокировки:

lock(lockObj) {
// синхронизируемые операторы
}

где lockObj обозначает ссылку на синхронизируемый объект. Если же требуется синхронизировать только один оператор, то фигурные скобки не нужны. Оператор lock гарантирует, что фрагмент кода, защищенный блокировкой для данного объекта, будет использоваться только в потоке, получающем эту блокировку. А все остальные потоки блокируются до тех пор, пока блокировка не будет снята. Блокировка снимается по завершении защищаемого ею фрагмента кода.

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

В прошлом для блокировки объектов очень часто применялась конструкция lock (this). Но она пригодна только в том случае, если this является ссылкой на закрытый объект. В связи с возможными программными и концептуальными ошибками, к которым может привести конструкция lock (this), применять ее больше не рекомендуется. Вместо нее лучше создать закрытый объект, чтобы затем заблокировать его.

Давайте модифицируем предыдущий пример добавив в него синхронизацию:

public class MyTheard
    {
        private object threadLock = new object();

        public void ThreadNumbers()
        {
            // Используем маркер блокировки
            lock (threadLock)
            {
                // Информация о потоке
                Console.WriteLine("{0} поток использует метод ThreadNumbers", Thread.CurrentThread.Name);
                // Выводим числа
                Console.Write("Числа: ");
                for (int i = 0; i < 10; i++)
                {
                    Random rand = new Random();
                    Thread.Sleep(1000 * rand.Next(5));
                    Console.Write(i + ", ");
                }
                Console.WriteLine();
            }
        }
    }
    ...

Как только поток войдет в контекст lock, маркер блокировки (в данном случае — текущий объект) станет недоступным другим потокам до тех пор, пока блокировка не будет снята по выходе из контекста lock. Таким образом, если поток А захватит маркер блокировки, другие потоки не смогут войти ни в один из контекстов, использующих тот же маркер, до тех пор, пока поток А не освободит его.

Чтобы блокировать код в статическом методе, нужно объявить приватную статическую переменную-член, которая будет служить в качестве маркера блокировки. Если теперь запустить приложение, можно увидеть, что каждый поток получил возможность выполнить свою работу до конца:

Синхронизированные потоки
Пройди тесты
Лучший чат для C# программистов