Поделиться через


Возможные ошибки, связанные с параллелизмом данных и задач

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

Не считайте, что параллельные процессы всегда быстрее.

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

Избегайте размещения в общей памяти.

В последовательном коде для чтения и записи часто используются статические переменные и поля классов. Но всякий раз, когда к таким переменным обращаются сразу несколько потоков, может возникать состояние гонки. Несмотря на то что для синхронизации доступа к переменной можно использовать блокировки, связанные с нею затраты ресурсов могут снизить производительность. В связи с этим рекомендуем не использовать или хотя бы максимально ограничить обращение к общему состоянию в параллельном цикле. Для этого лучше всего использовать перегрузки Parallel.For и Parallel.ForEach, которые используют переменную System.Threading.ThreadLocal<T> для хранения локального состояния потока во время выполнения цикла. Дополнительные сведения см. в разделах Практическое руководство. Написание цикла Parallel.For с локальными переменными потока и Практическое руководство. Написание цикла Parallel.ForEach с локальными переменными раздела.

Избегайте излишней параллелизации.

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

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

  • Внутренний цикл очень длинный.

  • С каждым заказом вы выполняете дорогостоящие вычисления. (Операция, показанная в примере, не является дорогостоящей.)

  • Целевая система имеет достаточно процессоров для обработки того количества потоков, которое будет создано при параллелизации запроса в cust.Orders.

В любом случае лучший способ определения оптимальной формы запроса — это проверка и измерение.

Избегайте вызова методов, небезопасных для потоков.

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

FileStream fs = File.OpenWrite(path);
byte[] bytes = new Byte[10000000];
// ...
Parallel.For(0, bytes.Length, (i) => fs.WriteByte(bytes[i]));
Dim fs As FileStream = File.OpenWrite(filepath)
Dim bytes() As Byte
ReDim bytes(1000000)
' ...init byte array
Parallel.For(0, bytes.Length, Sub(n) fs.WriteByte(bytes(n)))

Ограничение вызовов потокобезопасных методов

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

Примечание.

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

Помните о проблемах сходства потоков.

Некоторые технологии, например COM-взаимодействие для компонентов однопотокового подразделения (STA), Windows Forms и Windows Presentation Foundation (WPF), накладывают ограничения на сходство потоков, требующие, чтобы код выполнялся в определенном потоке. Например, и в Windows Forms, и в WPF элемент управления может быть доступен только в том потоке, в котором он был создан. В этом случае вы, например, не сможете обновить элемент управления "список" из параллельного цикла, не настроив планировщик потоков на выполнение задач только в потоке пользовательского интерфейса. Дополнительные сведения см. в статье Указание контекста синхронизации.

Будьте внимательны при ожидании в делегатах, вызываемых методом Parallel.Invoke.

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

Не считайте, что итерации операторов ForEach, For и ForAll всегда выполняются параллельно.

Важно помнить, что отдельные итерации цикла For, ForEach или ForAll иногда могут выполняться параллельно, но это не гарантируется. В связи с этим старайтесь не писать код, который будет зависеть от правильности параллельного выполнения итераций или от выполнения итераций в определенном порядке. Например, этот код может вызвать взаимоблокировку:

ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100)
    .AsParallel()
    .ForAll((j) =>
        {
            if (j == Environment.ProcessorCount)
            {
                Console.WriteLine("Set on {0} with value of {1}",
                    Thread.CurrentThread.ManagedThreadId, j);
                mre.Set();
            }
            else
            {
                Console.WriteLine("Waiting on {0} with value of {1}",
                    Thread.CurrentThread.ManagedThreadId, j);
                mre.Wait();
            }
        }); //deadlocks
Dim mres = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100) _
.AsParallel() _
.ForAll(Sub(j)

            If j = Environment.ProcessorCount Then
                Console.WriteLine("Set on {0} with value of {1}",
                                  Thread.CurrentThread.ManagedThreadId, j)
                mres.Set()
            Else
                Console.WriteLine("Waiting on {0} with value of {1}",
                                  Thread.CurrentThread.ManagedThreadId, j)
                mres.Wait()
            End If
        End Sub) ' deadlocks

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

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

Избегайте выполнения параллельных циклов в потоке пользовательского интерфейса.

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

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

private void button1_Click(object sender, EventArgs e)
{
    Parallel.For(0, N, i =>
    {
        // do work for i
        button1.Invoke((Action)delegate { DisplayProgress(i); });
    });
}
Private Sub Button1_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click

    Dim iterations As Integer = 20
    Parallel.For(0, iterations, Sub(x)
                                    Button1.Invoke(Sub()
                                                       DisplayProgress(x)
                                                   End Sub)
                                End Sub)
End Sub

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

private void button1_Click(object sender, EventArgs e)
{
    Task.Factory.StartNew(() =>
        Parallel.For(0, N, i =>
        {
            // do work for i
            button1.Invoke((Action)delegate { DisplayProgress(i); });
        })
         );
}
Private Sub Button2_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click

    Dim iterations As Integer = 20
    Task.Factory.StartNew(Sub() Parallel.For(0, iterations, Sub(x)
                                                                Button1.Invoke(Sub()
                                                                                   DisplayProgress(x)
                                                                               End Sub)
                                                            End Sub))
End Sub

См. также