понедельник, 17 января 2011 г.

О вреде метода Thread.Abort

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

Давайте в качестве примера рассмотрим различные сопособы прекращения работы созданного вручную потока. Если вы начали знакомство с многопоточностью, с таких чудесных библиотек как WinAPI и не менее чудесных языков программирования как С и С++, то вы наверняка очень быстро поняли, что завершать выполнение потока с помощью вызова функции TerminateThread мягко говоря не стоит. Вызов функции TerminateThread гарантировано приводит к утечкам ресурсов, памяти, некорректному состоянию объектов ядра операционной системы и еще десятку других напастей, после которых корректное состояние приложения – мало вероятно. Вызов этой функции достаточно быстро завоевал дурную славу у разработчиков (благо даже в официальной документации на эту функцию в MSDN черным по английскому сказано, что вызывать ее не стоит) и большинству разработчиков пришлось искать другие способы завершения работы потока, начиная от уведомление рабочего потока с помощью событий (events), заканчивая применением булевого флага, который проверяется рабочим потоком и устанвливается в true для его завершения.

// Объявили поле в классе.
// В большинстве случаев ключевого слова volatile будет достаточно;
// это предотвратит переупорядочивания инструкций при доступе к этому полю
volatile bool threadRunning;

// И где-то в функции потока
while(threadRunning)
{
// Основный цикл нового потока
}

// Теперь после старта потока выставляем threadRunning в true,
// а когда нам понадобится его завершить - установим его в false

«Управляемые» языки программирования и платформа .Net, подняли тот самый уровень абстракции, решив многие насущные проблемы разработчиков, но при этом добавили ложное чувство безопасности туда, где ее на самом деле нет. И речь в данном случае идет как раз о вызове метода Thread.Abort для прекращения работы потока. На форумах не редко можно встретить пример такого кода:

thread = new Thread(ThreadFunction);
thread.Start();
//...
thread.Abort();

После вызова метода Thread.Abort из другого потока, в целевом потоке генерируется исключение ThreadAbortException, которое по своей природе является асинхронным, а это значит, что оно может возникнуть практически в любой точке управляемого кода*. И даже несмотря на то, что с переходом на .Net Framework 2.0 количество «практически любых точек кода» несколько уменьшилось, и теперь невозможно прервать выполнение статического конструктора и блоков catch/finally, вероятность получения рассогласованного состояния приложения все еще весьма велика. Чтобы убедиться в этом, давайте рассмотрим несколько примеров.

Итак, предположим, что в нашей функции потока мы работаем с некоторым ресурсом (например, с файлом), и поскольку мы прочитали уйму умных книжек и не меньшее количество форумов, то мы знаем, что все ресурсы нужно использовать в блоке using, для их детерминированной очистки:

// Функция нового потока
void ThreadFunction()
{
    using (var file = File.OpenWrite(fileName))
    {
        // Записываем информацию в файл

    }
}

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

var file = File.OpenWrite(fileName);
try
{
    // Записываем информацию в файл
}
finally
{
    if (file != null)
        ((IDisposable) file).Dispose();
}

А теперь скажите, что произойдет, если асинхронное исключение возникнет после выполнения конструктора и до присваивания ссылки на новый объект переменной file? В этом случае ресурс уже будет захвачен, без каких-либо шансов на детерминированное освобождение. Конечно, в случае работы с файлом, когда-то будет вызван метод завершения (a.k.a. finalizer) и файл закроется, но ведь вполне возможно, что ваше приложение содержит некоторую логику, рассчитывающую на то, что файл будет освобожден как можно скорее (ведь не просто так вы обернули его использование в блок using), и может «упасть» уже с синхронным исключением в совершенно другой части программы, которая рассчитывает на то, что файл уже будет закрыт.

Кроме того, даже библиотечные классы BCL не гарантируют согласованное состояние объектов при возникновении асинхронных исключений по очень простой причине: разработка кода безопасного с точки зрения асинхронных исключений задача практически невыполнимая. Так, например, в случае с файлом, вполне возможна ситуация, кода выполнение конструктора будет прервано после получения дескриптора файла, но до окончания выполнения конструктора. И вы можете себе только представить, насколько будет сложно определить, почему это вдруг нормально работающая критическая система начала вдруг падать раз в неделю или раз в месяц.

Другим потенциально опасным примером является использование оператора lock(syncRoot), который до появления .Net Framework 4.0 преобразовывался компилятором в следующий код [Lippert2009], [Duffy2007]:

var temp = syncRoot;
Monitor.Enter(temp);
try
{
    // тело блока lock
}
finally
{
    Monitor.Exit(temp);
}

Однако при компиляции без оптимизации, а также при компиляции на некоторых аппаратных платформах, между вызовом Monitor.Enter и блоком try компилятором C# или JIT компилятором могут вставляться пустые инструкции, что дает возможность «вклиниться» исключению ThreadAbortException. А это приведет к тому, что монитор не будет освобожден в блоке finally и навсегда останется в занятом состоянии, что, в свою очередь, приведет к дедлоку при любой последующей попытке его захвата.

Начиная с .Net Framework 4.0 эта ситуация наконец-то исправлена и оператор lock(syncRoot) разворачивается компилятором таким образом, чтобы вклиниться между захватом монитора и блоком try было невозможно, поскольку преобразуется компилятором следующим образом:

bool lockWasTaken = false;
var temp = syncRoot;
try
{
    Monitor.Enter(temp, ref lockWasTaken);
    {
        // тело блока lock
    }
}
finally
{
    if (lockWasTaken)
        Monitor.Exit(temp);
}

Однако даже такая реализация не исключает возможности получения рассогласованного состояния ваших объектов, когда исключение возникнет внутри оператора lock в том месте, где вы совершенно этого не ждете. В результате чего ваша программа не «подвиснет», а просто начнет вести себя непредсказуемо, поскольку продолжит свою работу, когда ваши объекту будут находиться в промежуточном состоянии.

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

----------------
* ThreadAbortException не может возникнуть внутри статического конструктора, внутри блоков catch/finally, внутри constrained execution region или внутри неуправляемого кода.

** Использование того же самого булевого флага с модификатором volatile или маркеров отмены (Cancellation Tokens), если вы работаете с .Net Framework 4.0.

Дополнительные ссылки

  1. [Lippert 2009] Eric Lippert. Locks and exceptions do not mix
  2. [Lippert 2010] Eric Lippert. Careful with that axe, part one

10 комментариев:

  1. Спасибо, интересная статья.
    Кстати если для работы вам вс-таки требуются потоки и есть возможность использовать TPL, то я рекомендую использовать именно этот инструмент, тк намного удобнее и безопаснее.

    ОтветитьУдалить
  2. На самом деле, в подобных знаниях есть несколько полезных моментов.

    Во-первых, ThreadAbortException - это не единственное асинхронное исключение, которое может возникнуть в вашем коде. Другим распространенным примером такого исключения является OutOfMemoryException (у него просто есть две "ипостаси" - синхронная и асинхронная). Так вот, когда в очередной раз зайдет спор о том, стоит ли обрабатывать асинхронное OOM можно будет привести такие же аргументы.

    Во-вторых, есть легаси код. И в нем могут применяться вызовы Thread.Abort и у вас должна быть аргументированная позиция, почему делать этого не стоит.

    ОтветитьУдалить
  3. Очень понравилось. Достаточно коротко и ясно написал ;-)

    ОтветитьУдалить
  4. Response.Redirect
    Response.Flush

    а вообще вы пишете очень интересно...

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

    ОтветитьУдалить
  6. @Павел: думаю, что никогда.
    Вы только почитайте статейки Димы Вьюкова (либо его избранные сообщения на rsdn.ru, либо на его сайте 1024 cores). Все, что я обычно описываю, с моей точки зрения, очень простые темы, по сравнению с теми проблемами, о которых пишет Дмитрий.
    Так что, повторюсь, думаю, что многопоточность всегда будет очень сложной темой.

    ОтветитьУдалить
  7. Web Forms задумывалось как все тоже самое как и Win Forms только для в интернете, и что из этого получилось.
    Не каждый разработчик ASP .Нет может внятно пописать как и почему получается событие Button1_Click..
    А вы тут про поточность...

    P.S. Хотя Сергей тут меня навел на хорошее решения для web для тех кто не хочет разбираться - AsyncPage Опиши пару событий и вперед.

    ОтветитьУдалить
  8. Кстати, вроде когда-то было такое дело, что вызов Thread.Abort() приводил к блокировке и ожиданию, пока не завершится выполнение блока finally (кажется) у завершаемого потока. И если там какой-нибудь бесконечный цикл, то повисали навсегда. Сейчас это исправили? Или я что-то путаю? :)

    ОтветитьУдалить
  9. @nightcoder: как раз наоборот. В первой версии .net framework была возможность прервать выполнение блоков catch/finally, начиная со второй версии, выполнение блока finally не может быть прервано исключением ThreadAbortException.

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

    Кроме того, нужно же всегда помнить, что вызов метода Thread.Abort совершенно не гарантирует прекращения выполнения потока. Если код не захочет, чтобы его прервали - он всегда сможет быть написан соответствующим образом.

    В одном из постов Эрик Липперт как раз об этом рассказывает: Careful with that axe, part one.

    ОтветитьУдалить
  10. Спасибо за статью, хотел понять почему у меня using не срабатывает при аборте потока и файл остается вечно блокированным до закрытия приложения. Решением вижу только другую архитектуру без абортов. Вообщем тоже поддерживаю - нет абортам=)

    ОтветитьУдалить