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

ОГЛАВЛЕНИЕ

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

Порой написание свободного от блокировок кода может быть соблазнительным путем достижения лучшей масштабируемости и надежности. Достижение этого требует глубокого знакомства с моделью памяти целевой платформы (подробности приведены в статье Вэнса Моррисона (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).

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