Решение 11 распространенных проблем в многопоточном коде

ОГЛАВЛЕНИЕ

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

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

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

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

Я начну с рассмотрения тех вещей, которые часто работают неправильно в одновременных программах. Я именую их «угрозами корректности», поскольку с ними так легко столкнуться и поскольку их последствия обычно весьма нежелательны. Эти угрозы могут привести к нарушениям работы программ, вызывая их сбои или повреждая память.


Состязание за данные

(оно же условие состязания) возникает при одновременном доступе к данным из нескольких потоков. Говоря конкретнее, это случается, когда один или несколько потоков записывают кусок данных, в то время как один или несколько потоков считывают этот самый кусок данных. Эта проблема возникает потому, что программы Windows (как в C++, так и в Microsoft .NET Framework) построены на основе концепции общей памяти, где все потоки в процессе могут получать доступ к данным, находящимся в одном и том же виртуальном пространстве адресов. Для общего доступа можно использовать статические переменные и выделение памяти из кучи.

Рассмотрим следующий канонический пример:

static class Counter {
  internal static int s_curr = 0;
  internal static int GetNext() {
    return s_curr++;
  }
}

Целью класса Counter («Счетчик») предположительно является выдача уникального номера каждому вызову к методу GetNext. Если два потока в программе одновременно вызывают GetNext, двум потокам может быть дан одинаковый номер. Причина этого состоит в том, что s_curr++ компилируется в три отдельных шага:

  1. Считывание текущего значения из общей переменной s_curr в регистр процессора.
  2. Увеличение этого регистра на единицу.
  3. Запись значения регистра обратно в общую переменную s_curr.

Два потока, исполняющих эту последовательность, могут локально считать одно и то же значение из s_curr (скажем, 42), увеличить его на единицу (скажем, до 43) и опубликовать одно и то же итоговое значение. GetNext, тем самым, возвратит одно и то же число для обеих потоков, нарушая алгоритм. Хотя простой оператор s_curr++ и кажется атомарным, это совершенно не так.


Забытая синхронизация

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

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

Важно обращать внимание на то, когда объект (граф) становится из закрытого общим. Это называется публикацией, и о ней мы поговорим ниже, когда речь идет об изоляции. Обратный процесс именуется приватизацией – когда объект (граф) вновь становится закрытым.

Решение – добавить адекватную синхронизацию. В примере со счетчиком я могу использовать простой захват с помощью Interlocked:

static class Counter {
  internal static volatile int s_curr = 0;
  internal static int GetNext() {
    return Interlocked.Increment(ref s_curr);
  }
}

Это работает потому, что обновление сводится к единственному месту в памяти и потому, что (к счастью) существует машинная команда (LOCK INC), эквивалентная оператору программы, который я пытаюсь сделать атомарным.

Как вариант, я могу использовать полноценную блокировку:

static class Counter {
  internal static int s_curr = 0;
  private static object s_currLock = new object();
  internal static int GetNext() {
    lock (s_currLock) {
     return s_curr++;
    }
  }
}

Оператор блокировки гарантирует взаимное исключение между всеми потоками, пытающимися получить доступ к GetNext, и использует класс CLR System.Threading.Monitor. В программах на C++ для тех же целей используется CRITICAL_SECTION. Устанавливать блокировку в данном конкретном примере нет необходимости, но когда речь идет о нескольких операциях, их нечасто возможно свести в единую захватываемую операцию.


Неправильная детализация

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

Для примера взглянем на абстракцию банковского счета, показанную на рис. 1. Все вроде бы в порядке, и два метода объекта Deposit («Поместить») и Withdraw («Снять») кажутся корректными относительно одновременности. Некоторые банковские приложения могут использовать их, не волнуясь, что балансы станут неверными из-за одновременного доступа.

 Рис. 1. Банковский счет

class BankAccount {
  private decimal m_balance = 0.0M;
  private object m_balanceLock = new object();
  internal void Deposit(decimal delta) {
    lock (m_balanceLock) { m_balance += delta; }
  }
  internal void Withdraw(decimal delta) {
    lock (m_balanceLock) {
     if (m_balance < delta)
   throw new Exception("Insufficient funds");
     m_balance -= delta;
    }
  }
}

Но что если нам нужно добавить метод Transfer («Перевод»)? Наивный (и неверный) подход будет состоять в предположении, что, поскольку Deposit и Withdraw корректны в изоляции, их можно легко сочетать:

class BankAccount {
  internal static void Transfer(
   BankAccount a, BankAccount b, decimal delta) {
    Withdraw(a, delta);
    Deposit(b, delta);
  }
  // As before
}

Это неверно. На самом деле, между вызовами Withdraw и Deposit имеется период времени, в который деньги отсутствуют полностью.

Для правильной реализации потребуется получение блокировок как на a, так и на b заранее с последующим выполнением вызовов методов:

class BankAccount {
  internal static void Transfer(
   BankAccount a, BankAccount b, decimal delta) {
    lock (a.m_balanceLock) {
 lock (b.m_balanceLock) {
   Withdraw(a, delta);
   Deposit(b, delta);
 }
    }
  }
  // As before
}

Получается, что, хоть этот подход и решает проблему детализации, он подвержен взаимоблокировкам. Ниже будет показано, как исправить этот недостаток.


Разрыв чтения и записи

Как уже упоминалось ранее, щадящие состязания позволяют получать доступ к переменным без синхронизации. Чтение и запись выровненных слов естественного размера, например подходящих под величину указателя переменных, имеющих размер 32 бита (4 байта) на 32-разрядных процессорах и 64 бита (8 байт) на 64-разрядных процессорах, являются атомарными. Если поток просто считывает единственную переменную, которую какой-нибудь другой поток запишет, и не затрагивается никаких сложных инвариантов, порой эта гарантия позволяет полностью пропустить синхронизацию.

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

К примеру, представьте себе, что ThreadA находится в цикле и записывает только 0x0L и 0xaaaabbbbccccddddL в 64-разрядную переменную s_x. ThreadB находится в читающем ее цикле (см. рис. 2).

 Рис. 2. Разрыв готов произойти

internal static volatile long s_x;
void ThreadA() {
  int i = 0;
  while (true) {
    s_x = (i & 1) == 0 ? 0x0L : 0xaaaabbbbccccddddL;
    i++;
  }
}
void ThreadB() {
  while (true) {
    long x = s_x;
    Debug.Assert(x == 0x0L || x == 0xaaaabbbbccccddddL);
  }
}

Читатель может быть удивлен тем, что подтверждение ThreadB готово сработать. Причина этого состоит в том, что запись в потоке ThreadA будет состоять из двух частей, старшей 32-разрядной и младшей 32-разрядной, в некотором порядке, зависящем от компилятора. То же касается считывания в потоке ThreadB. Таким образом, поток ThreadB может обнаружить значения 0xaaaabbbb00000000L или 0x00000000aaaabbbbL.


Свободное от блокировок переупорядочение

Порой написание свободного от блокировок кода может быть соблазнительным путем достижения лучшей масштабируемости и надежности. Достижение этого требует глубокого знакомства с моделью памяти целевой платформы (подробности приведены в статье Вэнса Моррисона (Vance Morrison) "Memory Models: Understand the Impact of Low-Lock Techniques in Multithreaded Apps". Неспособность осознать эти правила и следовать им может привести к ошибкам с переупорядочиванием в памяти. Они происходят потому, что компиляторы и процессоры могут свободно переупорядочивать операции с памятью в процессе выполнения оптимизаций.

В качестве примера предположим, что s_x и s_y имеют в начале значение 0, как можно увидеть здесь:

internal static volatile int s_x = 0;
internal static volatile int s_xa = 0;
internal static volatile int s_y = 0;
internal static volatile int s_ya = 0;

void ThreadA() {
  s_x = 1;
  s_ya = s_y;
}

void ThreadB() {
  s_y = 1;
  s_xa = s_x;
}

Возможно ли, чтобы после того как и ThreadA, и ThreadB достигнут завершения, как s_ya, так и s_xa содержали бы значение 0? С формальной точки зрения это кажется невероятным. Либо s_x = 1, либо s_y = 1 произойдет раньше, в этом случае другой поток заметит обновление, когда возьмется за свое собственное обновление. По крайней мере, в теории.

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

void ThreadA() {
  s_x = 1;
  Thread.MemoryBarrier();
  s_ya = s_y;
}

.NET Framework предлагает этот конкретный интерфейс API, а C++ предлагает _MemoryBarrier и подобные ему макросы. Но мораль этой истории состоит не в том, что следует повсюду вставлять барьеры в памяти. Мораль состоит в том, что следует избегать свободного от блокировок кода, пока модели памяти не освоены, и даже после этого соблюдать осторожность.

В Windows, включая Win32 и .NET Framework, большинство блокировок поддерживают рекурсивные получения. Это просто значит, что если текущий поток уже получил блокировку и пытается получить ее снова, запрос будет удовлетворен. Это упрощает составление крупных атомарных операций из более мелких. На деле показанный ранее пример BankAccount зависит от рекурсивных получений: Transfer вызывает Withdraw и Deposit, каждый из которых получил дубль блокировки, уже полученной Transfer.

Но это может быть источником проблем, если возникнет ситуация, в которой рекурсивное приобретение происходит, когда это не ожидается. Это может стать результатом повторного входа, который возможен в силу прямых обращений к динамическому коду (таких как виртуальные методы и делегаты), либо благодаря неявному повторному входу в код (такому как подкачка сообщений STA и асинхронные вызовы процедур). По этой причине (помимо прочих) имеет смысл избегать вызовов к динамическому методу изнутри области блокировки.

Например, представьте себе метод, который временно разбивает инварианты, а затем вызывает делегат:

class C {
  private int m_x = 0;
  private object m_xLock = new object();
  private Action m_action = ...;

  internal void M() {
    lock (m_xLock) {
 m_x++;
 try { m_action(); }
 finally {
   Debug.Assert(m_x == 1);
   m_x--;
 }
    }
  }
}

Принадлежащий C метод M гарантирует неизменность m_x. Но имеется короткий период времени, в течении которого m_x приращивается на один, перед уменьшением обратно. Обращение к m_action выглядит достаточно невинно. Увы, если оно было делегатом, принимаемым от пользователей класса C, то представляет произвольный код, который может делать что угодно, в том числе и производить обратный вызов к методу M того же экземпляра. А если это происходит, утверждение внутри может сработать; при этом возможно несколько активных вызовов к M на одном и том же стеке, даже если разработчик не делал этого напрямую, что, очевидно, может привести к тому, что в m_x будет находиться значение, большее 1.

Когда несколько потоков сталкиваются с взаимоблокировкой, система просто перестает отвечать на запросы. В нескольких статьях в журнале MSDN Magazine было описаны условия возникновения взаимоблокировок и некоторые способы выдерживать их, включая мою собственную статью "No More Hangs: Advanced Techniques to Avoid and Detect Deadlocks in .NET Apps", («Скажем «Нет!» зависаниям. Методы диагностики и устранения взаимоблокировок в приложениях .NET»), расположенную по адресу msdn.microsoft.com/magazine/cc163618, и выпуск рубрики «.NETs: вопросы и ответы» за октябрь 2007, написанный Стивеном Таубом (Stephen Toub) msdn.microsoft.com/magazine/cc163352, так что описание этого здесь будет сжатым. В общем, когда создается кольцевая цепь ожидания, например, если некий поток ThreadA ожидает ресурс, удерживаемый потоком ThreadB, который также в свою очередь ждет ресурс, удерживаемый потоком ThreadA (возможно, не напрямую, например путем ожидания третьего потока ThreadC или более чем одного потока), продвижение операций может затормозиться до полной остановки.

Обычным источником этой проблемы являются взаимоисключающие блокировки. Между прочим, показанный ранее пример BankAccount страдает от этой проблемы. Если ThreadA пытается перевести 500 долларов США со счета #1234 на счет #5678, а в то же время ThreadB пытается перевести 500 долларов с #5678 на #1234, может возникнуть взаимоблокировка кода.

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

 Рис. 3. Согласованный порядок получения

class BankAccount
{
    private int m_id;
    // Unique bank account ID.  
    internal static void Transfer(BankAccount a, BankAccount b, decimal delta)
    {
        if (a.m_id < b.m_id)
        {
            Monitor.Enter(a.m_balanceLock);
            // A first 
            Monitor.Enter(b.m_balanceLock);
            // ...and then B    
        }
        else
        {
            Monitor.Enter(b.m_balanceLock);
            // B first 
            Monitor.Enter(a.m_balanceLock);
            // ...and then A
        }
        try
        {
            Withdraw(a, delta);
            Deposit(b, delta);
        }
        finally
        {
            Monitor.Exit(a.m_balanceLock);
            Monitor.Exit(b.m_balanceLock);
        }
    }
    // As before ...
}

Но блокировки не являются единственным источником взаимоблокировок. Пропущенные пробуждения являются еще одним явлением, при котором какое-либо событие пропускается, и поток засыпает навсегда. Это часто случается с событиями синхронизации, такими, как события автоматического сброса и ручного сброса Win32, CONDITION_VARIABLE, а также вызовами CLR Monitor.Wait, Pulse и PulseAll. Пропущенное пробуждение обычно является признаком неверной синхронизации, невозможности повторно протестировать события сброса или использования примитива, пробуждающего что-то одно (WakeConditionVariable или Monitor.Pulse), когда более адекватен был бы примитив, пробуждающий всё (WakeAllConditionVariable или Monitor.PulseAll).

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


Очереди блокировок

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

Например, представьте себе следующую ситуацию. В среднем каждые 100 миллисекунд прибывает 8 запросов. Для обслуживания запросов используется восемь потоков (поскольку мы работаем на 8-процессорном компьютере). Каждому из этих восьми потоков необходимо приобрести блокировку и удерживать ее в течении 20 миллисекунд, прежде чем он сможет начать осмысленную работу.

Увы, доступ к этой блокировке должен быть сериализован, так что вход в нее и выход из нее всеми восемью потоками занимает 160 секунд. После того как первый из них существует, пройдет 140 миллисекунд, прежде чем доступ к блокировке сможет получить девятый поток. Эта схема по природе своей не масштабируется, что приведет к постоянному нарастанию числа ожидающих запросов. Если со временем интенсивность прибытия запросов не снизится, то начнет истекать время ожидания клиентских запросов, что приведет к сбою.

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

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

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

Одним из способов реализации блокирующей очереди является использование события ручного сброса, которое становится неоповещенным, когда очередь пуста, и оповещенным, когда в ней что-то есть. Увы, в случае наличия большого числа ожидающих потоков во время перехода от нуля элементов к одному элементу толкучка может возникнуть все равно. Это вызвано тем, что только один поток примет единственный элемент, что делает очередь вновь пустой и по необходимости включает сброс события. При наличии 100 ожидающих потоков 99 из них пробудятся и переключат контекст (и вызовут все эти непопадания в кэш) только для того, чтобы обнаружить, что им надо ждать дальше.


Двухшажный танец

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

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

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


Инверсия приоритета

Изменение приоритета потоков обычно является поиском неприятностей. Инверсия приоритета может произойти, когда многие потоки с различным приоритетом имеют общий доступ к одним и тем же блокировкам и ресурсам, и поток с низким приоритетом практически до бесконечности задерживает работу потока с более высоким приоритетом. Мораль этой истории – просто избегайте изменений приоритетов потоков насколько это возможно.

Вот крайний пример инверсии приоритета. Представьте себе, что поток ThreadA с низким приоритетом получает блокировку L. Затем появляется поток ThreadB, имеющий высокий приоритет. Он пытается получить L, но не может, поскольку ее удерживает ThreadA. Это и есть «инверсия»: дело обстоит так, как если бы потоку ThreadA был искусственно и временно дан больший приоритет, чем у потока ThreadB, просто потому, что он удерживает блокировку, необходимую потоку ThreadB.

Эта ситуация со временем разрешится сама собой, когда поток ThreadA освободит блокировку. Увы, это еще не все – представьте, что произойдет, когда на сцене появится поток ThreadC со средним приоритетом. Хотя поток ThreadC не нуждается в блокировке L, его простое присутствие может полностью исключить выполнение потока ThreadA, что непрямым образом предотвратит выполнение высокоприоритетного потока ThreadB.

Рано или поздно эта ситуация будет замечена потоком диспетчера установки баланса Windows Balance Set Manager. Даже если поток ThreadC останется работоспособным навсегда, поток ThreadA со временем (через четыре секунды) получит временное повышение приоритета от ОС. Остается надеяться, что этого достаточно, чтобы он довел работу до завершения и освободил блокировку. Но задержка здесь огромна (четыре секунды), и если с этим связан какой-либо интерфейс пользователя, пользователь приложения наверняка заметит проблему.


Пути достижения корректности

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

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

Но с распространением одновременности за этими привычками необходимо следить. И здесь можно последовать примеру функциональных языков программирования, таких как Haskell, LISP, Scheme, ML и даже F# (новый язык, совместимый с .NET), принимая неизменность, чистоту и изоляцию как основные концепции разработки.


Неизменность

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

Неизменность в некоторой степени поддерживается в C++ через const, а в C# через модификатор «только для чтения». Например, тип .NET, у которого существуют лишь поля только для чтения, является неглубоким неизменным. F# создает неглубокие неизменные типы по умолчанию, если не использовать модификатор открытости для изменений (mutable). Заходя на шаг дальше, если каждое из этих полей само ссылается на другой тип, все поля которого предназначены только для чтения (и ссылается на глубоко неизменные типы), то тип является глубоко неизменным. Результатом является законченная объектная структура, которая гарантированно не изменится когда не надо, что очень полезно.

Все это описывает неизменность как статическое свойство. Объекты также могут быть неизменными по соглашению, что означает наличие некоей гарантии неизменности состояния в течение определенных периодов времени. Это динамическое свойство. Cвойство freezable (фиксируемое) Windows Presentation Foundation (WPF) реализует именно это, и оно также делает возможным параллельный доступ без синхронизации (хотя его и нельзя проверить так же, как статическую поддержку). Динамическая неизменность часто полезна для объектов, которые превращаются из неизменных в открытые для изменений в ходе своего жизненного цикла.

У неизменности есть некоторые недостатки. Например, когда что-либо должно измениться, необходимо будет создать копию первоначального объекта и применить изменения по пути. Кроме того, в графе объектов обычно невозможны циклы (за исключением динамической неизменности).

Например, представьте себе, что у вас есть ImmutableStack<T>, как показано на рис. 4. Вместо набора изменяющихся методов Push («Принудительная отправка») и Pop («Извлечение») из них потребуется вернуть новые объекты ImmutableStack<T>, которые содержат примененные изменения. В некоторых случаях можно использовать хитрые трюки (как в случае стека), чтобы экземпляры использовали общую память.

 Рис. 4. Использование ImmutableStack<T>

public class ImmutableStack<T>
{
    private readonly T m_value;
    private readonly ImmutableStack<T> m_next;
    private readonly bool m_empty;
    public ImmutableStack()
    {
        m_empty = true;
    }
    internal ImmutableStack(T value, Node next)
    {
        m_value = value;
        m_next = next;
        m_empty = false;
    }
    public ImmutableStack<T> Push(T value)
    {
        return new ImmutableStack(value, this);
    }
    public ImmutableStack<T> Pop(out T value)
    {
        if (m_empty)
            throw new Exception("Empty.");
        return m_next;
    }
}

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

Некоторые неизменные типы уже выпущены на волю. Класс CLR System.String неизменен, и существует проектная рекомендация, согласно которой все новые типы значений также должны быть неизменными. Здесь же я рекомендую использовать неизменность, когда это возможно и кажется естественным, сопротивляясь соблазну выполнения изменений просто потому, что это удобно в нынешнем поколении языков.


Чистота

Даже в случае неизменных типов данных большинством операций, выполняемых программой, являются вызовы методов. И вызовы методов могут иметь побочные эффекты, создающие проблемы в параллельном коде потому, что побочный эффект подразумевает какое-либо изменение. Чаще всего это будет простая запись в общую память, но это также может быть физически изменяющаяся операция, такая как транзакция базы данных, вызов веб-службы или операция файловой системы. Во многих случаях я хотел бы иметь возможность вызвать определенный метод, не опасаясь, что это приведет к угрозам, связанным с одновременностью. Хорошим примером этого являются простые методы вроде GetHashCode и ToString на System.Object. Большинство программистов не будут ожидать от них побочных эффектов.

Чистый метод всегда можно использовать в среде одновременного выполнения без добавочной синхронизации. Хотя чистота не обладает распространенной поддержкой в языках, чистый метод определить очень просто:

  1. Он считывает только из общей памяти и считывает только неизменное или постоянное состояние.
  2. Само собой, он может записывать в локальные переменные.
  3. Он может вызывать только другие чистые методы.

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


Изоляция

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

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

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

Джо Даффи (Joe Duffy)