Проблемы многопоточности

33

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

Состязания за ресурсы

Состязание за ресурсы может возникать в случае, если два или более потоков получают доступ к одним и тем же объектам, а доступ к совместно используемому состоянию не синхронизируется.

Чтобы продемонстрировать состязание за ресурсы, ниже приведен пример, в котором определяется класс StateObject с полем int и методом ChangeState. В реализации ChangeState значение state проверяется на предмет равенства 5. Если это так, выполняется инкремент. Следующий оператор Trace.Assert немедленно проверяет, действительно ли state теперь имеет значение 6.

Кажется очевидным, что после инкремента переменной, имеющей значение 5, она должна быть равна 6. Однако это необязательно так. Например, если один поток только что выполнил оператор if (state == 5), планировщик может вытеснить его и запустить еще один поток. Второй поток попадет в тело if и, поскольку в переменной состояния по-прежнему содержится значение 5, оно будет инкрементировано до 6. После этого снова настанет черед выполнения первого потока, в результате чего в следующем операторе значение переменной состояния будет увеличено до 7. Именно здесь и возникает состязание за ресурсы с выводом соответствующего сообщения:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Diagnostics;
using System.Threading.Tasks;

namespace ConsoleApplication1
{
    public class StateObject
    {
        private int state = 5;
        public void ChangeState(int loop)
        {
            if (state == 5)
            {
                state++;
                Trace.Assert(state == 6, "Состязание за ресурсы возникло после " + loop + " циклов");
            }
            state = 5;
        }
    }

    public class SampleThread
    {
        public void RaceCondition(object obj)
        {
            Trace.Assert(obj is StateObject, "obj должен иметь тип StateObject");
            StateObject state = obj as StateObject;
            int i = 0;
            while (true)
                state.ChangeState(i++);
        }
    }

    class Program
    {
        static void Main()
        {
            var state = new StateObject();
            for (int i = 0; i < 20; i++)
                new Task(new SampleThread().RaceCondition, state).Start();
            Thread.Sleep(1000);
        }
    }
}

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

Из-за вытесняющей многозадачности в системе с одноядерным ЦП состязания также возникают, но не так часто. Ниже показан пример того, как может выглядеть выдаваемое программой сообщение. Здесь это сообщение информирует о том, что состязание за ресурсы возникло после 227 циклов. При каждом запуске приложения результаты будут выглядеть по-разному:

Возникновение состязания за ресурсы

Избежать возникновения данной проблемы можно, заблокировав разделяемый объект. Это делается с помощью оператора lock. Внутрь блока кода, отвечающего за блокировку объекта состояния, может попадать только один поток. Из-за того, что этот объект разделяется среди всех потоков, в случае, если какой-то один из потоков уже заблокировал его, другой поток при достижении отвечающего за блокировку блока кода должен остановиться и ожидать своей очереди.

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

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

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