Вот, казалось бы, простой класс для суммирования всех элементов в массиве:

class ArraySum
{
    class SumRange
    {
        int left;
        int right;
        int[] arr;
        public int Answer { get; private set; }

        public SumRange(int[] a, int l, int r)
        {
            left = l;
            right = r;
            arr = a;
            Answer = 0;
        }

        public void Run()
        {
            if (right - left == 1)
            {
                Answer = arr[left];
            }
            else
            {
                SumRange leftRange = new SumRange(arr, left, (left + right) / 2);
                SumRange rightRange = new SumRange(arr, (left + right) / 2, right);

                Thread leftThread = new Thread(leftRange.Run);
                Thread rightThread = new Thread(rightRange.Run);
                leftThread.Start();
                rightThread.Start();
                leftThread.Join();
                rightThread.Join();

                Answer = leftRange.Answer + rightRange.Answer;
            }
        }
    }

    public static int Sum(int[] arr)
    {
        SumRange s = new SumRange(arr, 0, arr.Length);
        s.Run();
        return s.Answer;
    }
}

Конечно, это неэффективный способ выполнить эту задачу. И это тоже очень неэффективное использование потоков. Этот класс написан для иллюстрации базовой концепции решения «разделяй и властвуй», и мы надеемся, что это так.

Вот также простой модульный тест для этого класса:

public void should_calculate_array_sum()
{
    int N = 1000;
    int[] arr = System.Linq.Enumerable.Range(0, N).ToArray();

    int sum = ArraySum.Sum(arr);

    Assert.AreEqual(arr.Sum(), sum);
}

И вот в чем проблема. Когда N установлено на 1000, этот тест на моей машине не проходит примерно 3 раза из 5, при этом фактический результат оказывается меньше ожидаемого. Когда N равно 100 и ниже - он никогда не дает сбоев, по крайней мере, я никогда не видел сбоев.

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

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

5
Andrei 1 Мар 2015 в 03:32

2 ответа

Лучший ответ

Я поместил этот код в консольное приложение и запустил его несколько раз после включения функции Run в try-catch (см. Код ниже). Несколько раз, когда я видел, что числа различаются, возникало несколько OutOfMemory исключений.

Таким образом, кажется, что это зависит от того, как и когда среда выполнения выделяет потоки и ресурсы, которые она имеет в данный момент. Чтобы уточнить, если среда выполнения решает выделить потоки, а затем перейти к следующему временному срезу без того, чтобы ни один из потоков выполнял свою работу, можно иметь ВСЕ 2000+ потоков, запущенных и работающих одновременно (с выделением каждого потока 1 МБ пространства стека среди других ресурсов памяти). Это быстро исчерпает выделенную для процесса 2 ГБ памяти (которая есть у всех 32-разрядных процессов Windows).

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

public void Run()
{
    try
    {
        if (right - left == 1)
        {
            Answer = arr[left];
        }
        else
        {
            SumRange leftRange = new SumRange(arr, left, (left + right) / 2);
            SumRange rightRange = new SumRange(arr, (left + right) / 2, right);

            Thread leftThread = new Thread(leftRange.Run);
            Thread rightThread = new Thread(rightRange.Run);
            leftThread.Start();
            rightThread.Start();
            leftThread.Join();
            rightThread.Join();

            Answer = leftRange.Answer + rightRange.Answer;
        }
    }
    catch(Exception e)
    {
        Console.WriteLine("Error: " + e.Message);
        Debug.WriteLine("Error: " + e.Message);
    }
}
4
Gjeltema 2 Мар 2015 в 14:25

Вы не создаете сотни потоков или даже 1000 потоков. Это может быть больше 2000 потоков.

< Сильный > Proof

Чтобы упростить вычисления, скажите N = 1024.

# bisections  Range  Number of threads
      1       1024     1      (main thread)
      2       512      2
      3       256      4
      4       128      8
      5       64       16
      6       32       32
      7       16       64
      8       8        128
      9       4        256
      10      2        512
      11      1        1024   (individual sum thread)

Общее количество потоков = 1024 + 512 + 256 + ... 4 + 2 + 1 = 2047 . Очевидно, что не все потоки должны быть активными одновременно (когда я запускал его, многие из потоков были убиты во время вычислений), но вы определенно создаете около 2000 потоков.


Я не ищу лучшего способа решить эту конкретную проблему или лучшего способа проиллюстрировать ту же концепцию.

Если вы хотите (возможно) решить вашу проблему с помощью незначительных изменений, следуйте моему совету 1. Я добавил несколько других способов сделать это (TPL, ThreadPool) на случай, если вы захотите сделать это по-другому (но Я почти уверен, что это не то, что вы хотите делать).

Предложение 1. Уменьшение параллелизации потоков

Если вы измените способ использования потоков, например

Thread leftThread = new Thread(leftRange.Run);
leftThread.Start();
leftThread.Join();

Thread rightThread = new Thread(rightRange.Run);
rightThread.Start();
rightThread.Join();

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

Предложение 2. Использование библиотеки параллельных задач

Начиная с .NET Framework 4, TPL является предпочтительным способом написания многопоточного и параллельного кода.

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

То, что ниже, далеко не оптимизировано - использование TPL, как я делаю ниже, накладывает довольно много накладных расходов, но оно демонстрирует подход.

public void Run()
{
    if ( right - left == 1 )
    {
        Answer = arr[left];
    }
    else
    {
        Answer = new bool[] { true, false }
            .AsParallel()
            .Sum(isLeft =>
                {
                    SumRange sumRange = isLeft
                        ? new SumRange(arr, left, (left + right) / 2)
                        : new SumRange(arr, (left + right) / 2, right);
                    sumRange.Run();
                    return sumRange.Answer;
                });
    }
}

Когда я запускал его, он был до смешного медленным, потому что он запускал два элемента параллельно. Возможно, вы захотите разбить на более крупные группы (например, 10) вместо того, чтобы делить пополам. Возвращаясь к N = 1000:

# bisections  Range  Number of threads
      1       1000     1      (main thread)
      2       100      10
      3       10       100
      4       1        1000

Это уменьшает максимальное количество потоков до 1111, но TPL значительно сократит это количество.

Предложение 3. ThreadPool

Я думаю, вам стоит подумать об использовании ThreadPool для создания потоков - таким образом, минимальное количество потоков будет только 11 (то есть путь от пополам 1 до пополам 11). Я не уверен в том, как использовать ThreadPool, но вот ссылка, которая выглядит полезной: MSDN: Как использовать пул потоков.

3
Wai Ha Lee 1 Мар 2015 в 09:55