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

Задача без достаточного параллелизма

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

public static int sumArray(int[] arr) {
    int sum = 0;
    for (int i = 0; i < arr.length; i++) {
        sum += arr[i];
    }
    return sum;
}

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

Задачи неоднородны

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

public class DownloadTask implements Runnable {
    private String url;
    
    public DownloadTask(String url) {
        this.url = url;
    }
    
    @Override
    public void run() {
        try {
            // simulate download by sleeping for a random amount of time
            int sleepTime = new Random().nextInt(5000);
            Thread.sleep(sleepTime);
            System.out.println("Downloaded " + url + " in " + sleepTime + "ms");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class DownloadManager {
    public static void main(String[] args) {
        List<String> urls = new ArrayList<>();
        urls.add("https://www.example.com/file1.txt");
        urls.add("https://www.example.com/file2.txt");
        urls.add("https://www.example.com/file3.txt");
        urls.add("https://www.example.com/file4.txt");
        urls.add("https://www.example.com/file5.txt");
        
        long startTime = System.currentTimeMillis();
        
        for (String url : urls) {
            new Thread(new DownloadTask(url)).start();
        }
        
        long endTime = System.currentTimeMillis();
        
        System.out.println("Total time taken: " + (endTime - startTime) + "ms");
    }
}

В этом примере у нас есть список из 5 URL-адресов, которые необходимо загрузить. Каждая загрузка занимает случайное количество времени от 0 до 5 секунд, моделируемое вызовом Thread.sleep() в классе DownloadTask. Мы создаем новый поток для каждой загрузки и запускаем их все сразу.

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

Downloaded https://www.example.com/file4.txt in 1406ms
Downloaded https://www.example.com/file5.txt in 1587ms
Downloaded https://www.example.com/file1.txt in 2537ms
Downloaded https://www.example.com/file3.txt in 3908ms
Downloaded https://www.example.com/file2.txt in 4758ms
Total time taken: 4776ms

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

Использование общей службы глобального исполнителя

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

Ресурсы системы ограничены

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

Ограничение ввода-вывода

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

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

Условия гонки и проблемы с синхронизацией

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

Спасибо за внимание!
Пожалуйста, подпишитесь и не забудьте похлопать, если вам понравилось читать этот пост.