Раздел «Технологии программирования».Threads:

Параллельное программирование: нити (Threads)

Зачастую, при написании программ возникает необходимость в одновременной работе двух или более парельных приложений, в языке С# существует инструмент нити(threads). Использование нитей на языке С# схоже с использование нитей в JAVA.

Процессы, нити и волокна в ОС Windows

Процессом (process) называется экземпляр программы, загруженной в память. Этот экземпляр может создавать нити (thread), которые представляют собой последовательность инструкций на выполнение. Важно понимать, что выполняются не процессы, а именно нити. Причем любой процесс имеет хотя бы одну нить. Эта нить называется главной (основной) нитью приложения. Так как практически всегда нитей гораздо больше, чем физических процессоров для их выполнения, то нити на самом деле выполняются не одновременно, а по очереди(распределение процессорного времени происходит именно между нитями). Но переключение между ними происходит так часто, что кажется, будто они выполняются параллельно. В зависимости от ситуации нити могут находиться в трех состояниях. Во-первых, нить может выполняться, когда ей выделено процессорное время, т.е. она может находиться в состоянии активности. Во-вторых, она может быть неактивной и ожидать выделения процессора, т.е. быть в состоянии готовности. И есть еще третье, тоже очень важное состояние - состояние блокировки. Когда нить заблокирована, ей вообще не выделяется время. Обычно блокировка ставится на время ожидания какого-либо события. При возникновении этого события нить автоматически переводится из состояния блокировки в состояние готовности. Например, если одна нить выполняет вычисления, а другая должна ждать результатов, чтобы сохранить их на диск. Вторая могла бы использовать цикл типа "while( !isCalcFinished ) continue;", но легко убедиться на практике, что во время выполнения этого цикла процессор занят на 100 % (это называется активным ожиданием). Таких вот циклов следует по возможности избегать, в чем оказывает неоценимую помощь механизм блокировки. Вторая нить может заблокировать себя до тех пор, пока первая не установит событие, сигнализирующее о том, что чтение окончено. Рассмотри следующих конкретный пример: создаем 2 нити, одна 5 раза печатает на консоли слово "Open", другая 5 раза печатает "Close". В перерывах между печатями каждая нить засыпает на случайное время, равномерно распределенное в интервале от нуля до секунды.
using System; using System.Threading;

public class Try {
    public static Random rand = new Random();
    public String result;

    public Try(String s) { result = s; }

    public void Run() {
        for (int i = 0; i < 5; i++) {
            int dt = rand.Next(1000);
            // Console.WriteLine("dt = {0}", dt);
            Thread.Sleep(dt);
            System.Console.WriteLine(result);
        }
    }


    public static void Main() {
        Try t1 = new Try("Open");
        Try t2 = new Try("Close");
        Thread thread1 = new Thread(new ThreadStart(t1.Run));
        Thread thread2 = new Thread(new ThreadStart(t2.Run));
        thread1.Start();
        thread2.Start();
    }
}

Синхронизация нитей и процессов

Синхронизация нитей в ОС Windows.

В Windows реализована вытесняющая многозадачность - это значит, что в любой момент система может прервать выполнение одной нити и передать управление другой. Ранее, в Windows (напрмер в windows 3.1), использовался способ организации, называемый кооперативной многозадачностью: система ждала, пока нить сама не передаст ей управление и именно поэтому в случае зависания одного приложения приходилось перезагружать компьютер. Все нити, принадлежащие одному процессу, разделяют некоторые общие ресурсы - такие, как адресное пространство оперативной памяти или открытые файлы. Эти ресурсы принадлежат всему процессу, а значит, и каждой его нити. Следовательно, каждая нить может работать с этими ресурсами без каких-либо ограничений. Но... Если одна нить еще не закончила работать с каким-либо общим ресурсом, а система переключилась на другую нить, использующую этот же ресурс, то результат работы этих нитей может чрезвычайно сильно отличаться от задуманного. Такие конфликты могут возникнуть и между нитями, принадлежащими различным процессам. Всегда, когда две или более нитей используют какой-либо общий ресурс, возникает эта проблема.

Пример несинхронизированной программы

В программе массив размера 100 заполняется целыми числами от 1 до 100. Помимо основной нити, создаются еще 10 нитей (с номерами от 1 до 10). Основная нить "перемешивает" массив в течение 10 секунд. Она выбирает случайно 2 индекса i, j в пределах от 0 до 99 и меняет местами элементы массива с этими индексами. Остальные нити проверяют сумму элементов массива. Каждая нить повторяет бесконечно одну и ту же последовательность из двух действий: суммирует массив и сравнивает его сумму с числом 5050. Как только сумма не совпала с числом 5050, нить печатает сообщение об ошибке и выставляет флаг ошибки, по которому все нити завершают работу. При отсутствии ошибки по завершении работы основной нити остальные также завершают работу. Ошибка возникает из-за того, что в процессе обмена в какой-то момент два элемента временно равны друг другу. Если при этом произойдет переключение нитей, то сумма массива получится некорректной. Вот текст программы:
using System;
using System.Threading;

public class SyncTest {
    public static int[] a = new int[100];
    public static bool stop = false;
    public static bool incorrect = false;

    public static int Main() {
        int i;
        Thread[] threads = new Thread[10];

        // Fill in the array
        for (i = 0; i < 100; i++)
            a[i] = i + 1;

        for (i = 0; i < 10; i++) {
            threads[i] = new Thread(
                new ThreadStart(CheckSum)
            );
            threads[i].Name = "Thread " + (i + 1).ToString();
            threads[i].Start();
        }

        Mix();

        stop = true;

        if (!incorrect)
            Console.WriteLine("Sum is correct!");

        return 0;
    }

    public static void Mix() {
        Console.WriteLine("mixing...");
        Random rnd = new Random();
        DateTime start = DateTime.Now;
        while (!stop && (DateTime.Now - start).Seconds < 10) {
            int i = rnd.Next(0, 100);
            int j = rnd.Next(0, 100);
            // Console.WriteLine("swapping ({0}, {1})", i, j);
            int tmp = a[i];
            a[i] = a[j];
            a[j] = tmp;
        }
    }

    public static void CheckSum() {
        while (!stop) {
            int s = 0;

            for (int i = 0; i < 100; i++) {
                s += a[i];
            }

            if (s != 5050) {
                Console.WriteLine(
                    "{0}: Sum = {1} — incorrect!",
                    Thread.CurrentThread.Name, s
                );
                incorrect = true;
                stop = true;
            }
        }
    }
}
Пример выдачи на экран: 
    mixing...
    Thread 7: Sum = 3970 — incorrect!
    Thread 1: Sum = 4151 — incorrect!
    Thread 2: Sum = 3896 — incorrect!
    Thread 3: Sum = 4151 — incorrect!
    Thread 4: Sum = 4853 — incorrect!
    Thread 5: Sum = 4935 — incorrect!
    Thread 6: Sum = 3881 — incorrect!
    Thread 10: Sum = 5017 — incorrect!
    Thread 9: Sum = 4577 — incorrect!
    Thread 8: Sum = 4267 — incorrect!

Синхронизация с помощью класса Monitor

Рассмотрим тот же пример, что и в предыдущем случае, но для исключения доступа к массиву в момент выполнения обмена двух элементов используем синхронизацию с помощью класса Monitor. Исходный текст программы — в файле "SyncTst1.cs". Идея состоит в том, что нить захватывает специальный синхронизирующий элемент ("монитор"), связанный с охраняемым объектом. В нашем случае охраняемый объект — это массив "a". Перед выполнением критической операции выполняется захват монитора: Monitor.Enter(a); По окончании критической операции монитор освобождается: Monitor.Exit(a); В момент первого входа система создает специальный синхронизирующий объект, связаянный с объектом "a", если он до этого не был создан. Если при попытке захвата монитора он уже захвачен, то нить приостанавливает работу и ждет, пока монитор не освободится, потенциально бесконечно долго. Методы "Monitor.Enter" и "Monitor.Exit" — это статические методы класса Monitor. Мониторы аналогичны критическим секциям в Windows. При выполнении критической операции может возникнуть прерывание, в результате чего монитор никогда не будет освобожден! Поэтому критическую секцию надо обязательно помещать внутрь блока "try", а освобождение монитора выполнять внутри блока "finally":
    Monitor.Enter(a);
    try {
        // Текст критической секции
        // . . .
    }
    finally {
        Monitor.Exit(a);
    }

В рассматриваемой программе с помощью монитора защищается участок программы, в котором два элемента массива меняются местами

    Monitor.Enter(a);
    try {
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    } finally {
        Monitor.Exit(a);
    }
а также фрагмент, в котором массив суммируется: 
    Monitor.Enter(a);
    try {
        for (int i = 0; i < 100; i++) {
            s += a[i];
        }
    } finally {
        Monitor.Exit(a);
    }
В результате две нити не могут одновременно войти в эти критические секции. Как легко убедиться, при работе синхронизированной программы ошибки не происходит, вот ее выдача:
    mixing...
    Sum is correct!

Синхронизация с помощью ключевого слова "lock"

Тот же пример, синхронизация с помощью ключевого слова lock (неявно также используется объект типа Monitor): программа "SyncTst2.cs". Ключевое слово "lock" позволяет записывать синхронизацию с помощью монитора более коротко и изящно. А именно, конструкция
    lock(a) {
        // Текст критической секции
        // . . .
    }
полностью эквивалентна фрагменту кода 
    Monitor.Enter(a);
    try {
        // Текст критической секции
        // . . .
    }
    finally {
        Monitor.Exit(a);
    }
но короче и понятнее для начинающих. Синхронизация с помощью объекта типа ReaderWriterLock? Тот же пример, синхронизация с помощью объекта типа ReaderWriterLock?: программа "SyncTst3.cs". Объект типа ReaderWriterLock? позволяет исключить одновременное чтение/запись или запись/запись охраняемого объекта различными нитями, но разрешить одновременное чтение. Для этого создается экземпляр объекта типа ReaderWriterLock? static ReaderWriterLock? rwlock = new ReaderWriterLock?(); Нить, желающая записать информацию в охраняемый объект, сначала должна получить разрешение на запись у объекта rwlock: rwlock.AcquireWriterLock(Timeout.Infinite); Если разрешение не получено, то нить приостанавливается до освобождения охраняемого объекта или до истечения таймаута. Аналогично можно получить разрешение на чтение: rwlock.AcquireReaderLock(Timeout.Infinite); В нашем примере перемешивающая нить просит разрешение на запись, а суммирующие нити — на чтение. В результате суммирующие нити не мешают друг другу. Перемешивающая нить выполняет следующий код:
   rwlock.AcquireWriterLock(Timeout.Infinite);
    try {
        // Console.WriteLine("swapping ({0}, {1})", i, j);
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    } finally {
        rwlock.ReleaseWriterLock();
    }
Суммирующая нить выполняет код 
    rwlock.AcquireReaderLock(Timeout.Infinite);
    try {
        for (int i = 0; i < 100; i++) {
            s += a[i];
        }
    } finally {
        rwlock.ReleaseReaderLock();
    }

Синхронизация с помощью объектов типа AutoResetEvent? и ManualResetEvent?

Синхронизация (точнее сказать, сигнализация) при помощи объектов типа AutoResetEvent? иллюстрируется программой "SyncTst4.cs". Создаются две нити, одна печатает на консоль нечетные числа от 1 до 999, другая — четные числа от 2 до 1000. При отсутствии синхронизации печатаются длинные серии из только четных или только нечетных чисел с непредсказуемыми моментами переключения. В сированном варианте первая нить печатает нечетное число, затем сигнализирует второй нити, что она это сделала, и ждет, пока вторая нить напечатает четное число. Вторая нить ждет, пока первая напечатает нечетное число, затем печатает четное число и сигнализирует первой нити, что она это сделала. Каждая нить использует для сигнализации свой объект типа AutoResetEvent? (т.е. всего таких объектов два: первый устанавливается в сигнальное состояние первой нитью и ожидается второй нитью; второй устанавливается второй нитью и ожидается первой). текст программы:
using System;
using System.Threading;

class EventTst {
    static AutoResetEvent e1 = new AutoResetEvent(false);
    static AutoResetEvent e2 = new AutoResetEvent(false);

    public static void Main() {
        try {
            Thread thread1 = new Thread(
                new ThreadStart(PrintOdds)
            );
            Thread thread2 = new Thread(
                new ThreadStart(PrintEvens)
            );

            thread1.Start();
            thread2.Start();

            thread1.Join();
            thread2.Join();
        } finally {
            e1.Close();
            e2.Close();
        }
    }

    static void PrintOdds() {
        for (int i = 1; i <= 999; i += 2) {
            Console.WriteLine("{0} --", i);
            e1.Set();
            e2.WaitOne(); // Wait until even number is printed
        }
    }

    static void PrintEvens() {
        for (int i = 2; i <= 1000; i += 2) {
            e1.WaitOne(); // Wait until odd number is printed
            Console.WriteLine("-- {0}", i);
            e2.Set();
        }
    }
}
Здесь e1 и e2 — это два объекта типа AutoResetEvent?, которые создаются в начале работы программы: static AutoResetEvent? e1 = new AutoResetEvent?(false); static AutoResetEvent? e2 = new AutoResetEvent?(false); Объект e1 сигнализирует, что очередное нечетное число напечатано, e2 — что четное напечатано. Объект e1 устанавливается в сигнальное состояние первой нитью и ожидается второй нитью. Соответственно, e2 устанавливается второй нитью и ожидается первой. Для установки в сигнальное состояние используется метод "Set", для ожидания — метод "WaitOne". Первая нить выполняет фрагмент кода
    for (int i = 1; i <= 999; i += 2) {
        Console.WriteLine("{0} --", i);
        e1.Set();     // Set e1 in the signal state
        e2.WaitOne(); // Wait until even number is printed
    }
Вторая нить выполняет фрагмент 
    for (int i = 2; i <= 1000; i += 2) {
        e1.WaitOne(); // Wait until odd number is printed
        Console.WriteLine("-- {0}", i);
        e2.Set();     // Set e2 in the signal state
    }
По окончанию работы следует обязательно закрыть объекты типа AutoResetEvent?, т.к. с ними связаны объекты операционной системы, которые надо явно освобождать, когда они становятся ненужными. Как всегда, это делается в блоке "finally":
    static AutoResetEvent e1 = new AutoResetEvent(false);
    static AutoResetEvent e2 = new AutoResetEvent(false);

    public static void Main() {
        try {
            //... тело метода Main
            //...
        } finally {
            e1.Close();
            e2.Close();
        }
    }
Объект типа ManualResetEvent? отличатся от AutoResetEvent? тем, что он не переходит автоматически в нормальное (не сигнальное) состояние после выполнения метода WaitOne?. Для этого нужно дополнительно вызвать метод "Reset", например
    ManualResetEvent e3 = new ManualResetEvent(false);
    . . .
    e3.WaitOne();
    e3.Reset();
Таким образом, объект типа ManualResetEvent? можно использовать для сигнализации о каком-то событии сразу нескольким нитям.

Литература.

1. Материалы по языку С#. http://www.math.msu.su/~vvb/2course/Borisenko/AddEdu/CSharp/CSharp.html#Threads 2. An Introduction to Programming with C# Threads, Andrew D. Birrell , http://research.microsoft.com/~birrell/papers/ThreadsCSharp.pdf 3. Синхронизация нитей внутри процесса в ОС Windows, http://subscribe.ru/archive/comp.soft.win.swodniwgniqaf/200305/23131921.text

-- ArtjomProkopenko? - 20 Jun 2005