Tuesday, January 28, 2014

Потоки в С#: 7 вещей, которые Вы обязаны знать

Недавно на работе нам прислали несколько “интересных ссылок по языку C#”. После перехода по соответствующей ссылке я ужаснулся тому, как можно писать статьи, туго понимая их суть. До этого мне приходилось читать оригинал других нелепых вещей данного автора и критику этих материалов Сергеем Тепляковым ("8 наиболее распространенных ошибок C# программистов"). После этого я перестал читать блог компании Goyello. Но после вчерашнего прочтения и будучи под неприятным впечатлением, не могу пройти мимо статьи Threading in C#: 7 Things you should always remember about. Перед градом субъективной критики этого материала рекомендую ознакомиться с ним.
Приступим к разбору ошибок.
Threads share data if they have a common reference to the same object instance
С первого взгляда насчет приведенного автором кода не возникает сомнений. Вот исходный код:
class SomeClass
{
    private bool _isWorkDone;
    static void Main(string[] args)
    {
        SomeClass someClass = new SomeClass();
        Thread newThread = new Thread(someClass.DoWork);
        newThread.Start();
        someClass.DoWork();
        Console.Read();
    }
    void DoWork()
    {
        if (!_isWorkDone)
        {
            _isWorkDone = true;
            Console.WriteLine("Work done");
        }
    }
}
Рассмотрим это код немного ближе. Автор пишет, что проблема в том, что программист ожидает выполнения кода дважды, а код выполнится только один раз. На мой взгляд, этот факт бесполезный. Сложно представить программиста, вызывающего функцию одного и того же класса с надеждой на то, что переменная каким-то образом изменится.


‘Finally’ blocks in background threads are not executed when the process terminates
Здравый совет. Только автор об этом говорит одним предложением, и у начинающих разработчиков создается предположение, что главное - это не забыть избегать непредвиденного выхода из программы. Автор, вроде, и дает совет, но на практике он не принесет никакой пользы. Объясню почему: во-первых, потому что прерывание выполнения приложения может произойти несколькими способами. Во-вторых, после перечисленных выше способов Вам от блока finally не будет никакой пользы. Одной из ситуаций, в которых не отработает блок finally, есть ситуация, в которой в блоке try происходит вызов бесконечного цикла. Иногда нужно иметь возможность корректно завершить приложение самому, записав при этом информацию в event log. В .NET 4.0 появился метод Environment.FailFast, который позволяет завершить процесс сразу после записи сообщения в журнал событий Windows, после чего включает сообщение в отчет об ошибках, отправляемый в корпорацию Майкрософт. Этот метод завершает процесс, минуя активные блоки try/finally и методы-финализаторы. Метод FailFast записывает строку message в журнале событий Windows-приложения, создает дамп приложения и затем завершает текущий процесс. Строка message также включена в отчет об ошибках для корпорации Майкрософт. Метод FailFast используется для завершения работы приложения вместо метода Exit, если повреждение состояния приложения не подлежит восстановлению, а выполнение блоков try/finally и методов завершения для этого приложения приведет к повреждению ресурсов программы. Совет автора звучит так: "помните о том, что Ваш блок finally может быть не вызван". На такой тип сообщения хочется ответить: "Спасибо, К.О.". Этот совет из разряда "Ваша программа может упасть, знайте об этом". С таким подходом к описанию проблемы этот совет можно отнести к таким, от которых польза равна нулю. Поэтому позволю себе дать несколько советов, на правах автора данной статьи.
1. Постарайтесь избегать в программе, если она часто использует выход с помощью методов Environment.Exit или Environment.FailFast, управление нативными ресурсами, так как это может привести к их повреждению. В большинстве же случаев ваши ресурсы корректно будут освобождены после того закрытия приложения.
2. С такими типами ошибок, как, например, OverflowException, при которых блок finally не отрабатывает, бороться намного сложнее. Если у Вас выпало приложение с OverflowException, то нужно смотреть в корень проблемы, а совет, как здесь позаботиться об освобождении ресурсов, не совсем уместный. Поскольку C# - управляемый язык, то в 95% случаев у Вас не будет краха ресурсов при нормальном написании кода. В остальных 5% нужен глубокий анализ кода и понимание причины воспроизведения такой ошибки.

Captured values in lambda expressions are shared as well
Еще один бессмысленный пример, который не имеет никакого отношения к потокам. Если бы автор переписал этот пример с помощью цикла foreach, то при запуске в .NET 4/4.5 он увидел бы тот результат, который он хотел получить. То, что описал автор статьи, называется closure variables или замыкание на переменных цикла. Более детально о замыканиях можно почитать в статьях Сергея Теплякова "Замыкание на переменных цикла в C# 5.0" и Замыкания в языке программирования C# или посмотреть эту информацию в блоге Эрика Липпера/Джона Скита. Я же приведу пример использования цикла foreach, в котором пример автора сработает.

static void Main(string[] args)
{
    var result = Enumerable.Range(0, 10);
    foreach (var i in result)
    {
        Thread thread = new Thread(() => Console.Write(i));
        thread.Start();
    }
    Console.ReadLine();
}

Locking does not restrict access to the synchronizing object itself in any way.
Этот совет можно назвать так: "Я никогда не смотрю в MSDN и это для меня проблема". С типом x, который использует автор, вообще ничего не понятно. Какого типа у нас x и что вообще автор пытался нам донести. Лично я перечитал этот пример несколько раз, но так и не понял, какую мысль читателям хотел донести автор. Не будем гадать и посмотрим, что по этому поводу говорится в MSDN:

Как правило, рекомендуется избегать блокировки типа public или экземпляров, которыми код не управляет. Распространенные конструкции lock (this), lock(typeof(MyType)) и lock("myLock") не соблюдают это правило.
  • lock (this) может привести к проблеме, если к экземпляру допускается открытый доступ.
  • lock (typeof(MyType))может привести к проблеме, если к MyType допускается открытый доступ.
  • lock("myLock")может привести к проблеме, поскольку любой код в процессе, используя ту же строку, будет совместно использовать ту же блокировку.
Для блокировки рекомендуется определять объект private или переменную объекта private static, чтобы защитить данные, являющиеся общими для всех экземпляров.


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

Try/catch/finally blocks in scope when a thread is created, are of no relevance to the thread when it starts executing
Этот совет из разряда советов, не поддающихся логике. Для новичков он бесполезен, а для программистов с опытом он вызовет когнитивный диссонанс. Новички могут подумать, что если они перепишут код следующим образом,

static void Main(string[] args)
{
    try
    {
        Thread thread = new Thread(() => Divide(10, 0));
        thread.Start();
    }
    catch (Exception ex)
    {
        Console.WriteLine("An exception occured");
    }
    Console.ReadLine();
}
static void Divide(int x, int y)
{
    int z = x / y;
}
то пример заработает. Это не так. Ошибка будет вызвана не в основном потоке, поэтому она не будет перехвачена. Решение о перемещении блока try->catch в метод Devide - хороший совет, но способ преподнесения автором советов начинает вводить в ступор. Пожалуй, немного расширю эту тему и расскажу о том, что автор исходной статьи опустил. Начнем из того, что есть возможность перекинуть ошибку в основной поток. Это можно сделать, дожидаясь ответа от дополнительного потока с помощью вызова метода Join(). Второй способ - использовать новые возможности C# 5.0 с классом ExceptionDispatchInfo. Третья возможность заключается в использовании модели Asynchronous Programming Model (APM). Четвертая – использование Task Parallel Library (TPL). Кроме способа, предложенного автором о переносе блока try->catch с одного места в другое, можно рассмотреть альтернативы, которые решают ситуацию лучшим образом. У меня осталось двоякое впечатление после этой рекомендации. С одной стороны, потому что совет, вроде, правильный, а с другой - он логичный и "капитанский". Поэтому просматривайте такие статьи осторожно и следуйте таким советам только с умом.

If an object is thread-safe it does not imply that you don’t need to lock around accessing it
Мне сложно назвать этот совет полезным. Дело в том, что об этом пишет компания Microsoft, а также известные авторы Рихтер, Скит, Троелсен и т.д. Они говорят о том, что коллекции в .NET не являются потокобезопасными. Если нужна безопасность для коллекций, можно воспользоваться concurrent collection. Конечно, возможно предположить, что об этом факте кто-то может не знать, но рассуждения автора о фактах приводят к мысли, что у него самого в голове творится "каша". Это выглядит как диалог (действующие лица: project-manager компании Goyello, разработчики):
ПМ: Коллекции в языке C# не потокобезопасны!!!
Разработчики: Да, Кэп.
ПМ: Ошибка, которая возникла не в основном потоке, не будет отловлена в основном потоке.
Разработчики: Да, капитан.
ПМ: Если я выйду с программы, блок finally не выполнится.
Разработчики: Да, капитан.

У меня вопрос к разработчикам, которые читали данную статью: вы действительно видите какую-то полезность в этих очевидных советах? Или это только я отнесся к ним с придирками?L

Your program’s instructions can be reordered by compiler, CLR or CPU to improve efficiency
Не могу не согласиться с последним фактом. Хотя автор и не приводит достаточного описания, почему так может произойти, это, пожалуй, единственный интересный факт, который сложно назвать обыденным. Кеширование в CLR не всегда происходит сразу. Для читателей своего блога я рекомендовал бы ознакомиться с темой: "Учимся писать правильные C#-бенчмарки" для понимания принципа работы кеширования. Этот факт нельзя поддать критике, как предыдущие заметки.


Итоги
В приведенной статье Вы ознакомились с субъективной критикой статьи Threading in C#: 7 Things you should always remember about. Возможно, в некоторых моментах мое мнение было жестковатым. Мне понравился комментарий MVP Сергея Теплякова об одной из статей этого автора: "Мне не хочется даже обсуждать этот совет, поскольку в нем нет никакого рационального звена. Но меня весьма пугает судьба компании Goyello, чей project manager раздает подобные советы". Было бы лучше, если бы автор оригинальной статьи описал преимущества в некоторых случаях ThreadPool против Thread, привел различие между обычным потоком и background-потоком, проблемы многопоточного доступа к некоторым данным, описал бы о concurrent collection. Это все принесло бы больше пользы, чем пост о семи вещах, из которых только пару (после грандиозных правок) можно назвать актуальными. Цель этой статьи - заставить разработчика думать, прежде чем воспринимать какие-то факты из разных источников. В том числе, нужно воспринимать также со скептицизмом факты, которые описаны здесь. Главное для разработчика - думать, перед тем как использовать непроверенные факты на практике. Тогда у Вас не возникнет никаких проблем с потоками.

No comments:

Post a Comment