Здравствуйте!
— Не хотите ли войти
Оглавление

Основы Grand Central Dispatch, часть вторая: производительность в условиях нескольких ядер

1 июля 2010
Уровень сложности: для профессионалов

Аннотация

В прошлый раз мы обсуждали общую информацию про Grand Central Dispatch, увлекательнейшую новую технологию в Snow Leopard. Теперь же я собираюсь углубиться в GCD и посмотреть, как можно его использовать для ускорения вычислений, чтобы получить преимущество многоядерных процессоров. Этот документ предполагает, что вы ознакомились с предыдущим, прочитайте его, если вы еще это не сделали.

Концепция

Чтобы воспользоваться преимуществами многоядерности в рамках одного процесса, необходимо использовать потоки (я сознательно не рассматриваю многопроцессный параллелизм, так как он не относится к GCD). Это настолько же правда в мире GCD, как и в обычном, многопоточном мире. На низком уровне, глобальные очереди посылок GCD — это просто абстракции над пулом рабочих потоков. Блоки из очередей направляются в рабочие потоки по мере их освобождения. Те блоки, которые направлены в пользовательские очереди, в конце-концов тоже через глобальные очереди попадают в тот же пул рабочих потоков (если только вы не завязали пользовательскую очередь на главный поток, но этого никогда не стоит делать для «ускорения»).

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

Глобальные очереди

Представим себе следующий цикл:

for(id obj in array) {
    [self doSomethingIntensiveWith:obj];
}

Предположим, что метод -doSomethingWith: — безопасен с точки зрения многопоточности и может работать параллельно сам с собой. Если это верно и array обычно содержит более одного объекта, то можно запросто использовать GCD для параллелизации этого кода:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
for(id obj in array) {
    dispatch_async(queue, ^{
        [self doSomethingIntensiveWith:obj];
    });
}

Проще некуда. Ваш код теперь работает на нескольких ядрах одновременно.

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

for(id obj in array) {
    [self doSomethingIntensiveWith:obj];
}

[self doSomethingWith:array];

Использование dispatch_async в данном случае не будет работать, а dispatch_sync пользоваться не получится, так как это убьет весь параллелизм.

Один из способов решения этой проблемы — использование групп посылок (dispatch groups). Группа посылок способна сгруппировать несколько блоков и либо подождать, пока выполнение всей группы завершится, либо сообщить о завершении (выполняясь асинхронно). Создать группу можно при помощи функции dispatch_group_create, а функция dispatch_group_async позволяет отправить блок в очередь посылок, одновременно добавив его в группу. С использованием групп код получается следующий:

dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for(id obj in array) {
    dispatch_group_async(group, queue, ^{
        [self doSomethingIntensiveWith:obj];
    });
}

 dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
 dispatch_release(group);
    
 [self doSomethingWith:array];

Если эта работа (doSomethingWith:) может быть выполнена асинхронно с основным потоком выполнения, то можно написать еще смешнее, выполнив ее в фоновой очереди вместо того, чтобы ожидать завершения выполнения группы. Для этого нужно использовать dispatch_group_async чтобы выполнить блок после завершения работы остальной группы:

dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for(id obj in array) {
    dispatch_group_async(group, queue, ^{
        [self doSomethingIntensiveWith:obj];
    });
}

dispatch_group_notify(group, queue, ^{
    [self doSomethingWith:array];
});

dispatch_release(group);

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

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

dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply([array count], queue, ^(size_t index){
    [self doSomethingIntensiveWith:[array objectAtIndex:index]];
});
[self doSomethingWith:array];

Это все хорошо, но что делать в асинхронном случае? Асинхронной версии dispatch_apply, которую мы могли бы использовать, нет. Но мы используем API, который построен на асинхронном выполнении. Возьмем и просто обернем предыдущий код в dispatch_async, чтобы запихнуть это все в фон:

dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{
    dispatch_apply([array count], queue, ^(size_t index){
        [self doSomethingIntensiveWith:[array objectAtIndex:index]];
    });
    [self doSomethingWith:array];
});

Элементарно!

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

Чтобы увидеть прирост производительности, нужно произвести достаточно существенное количество работы. GCD легковесен и вносит немного накладных расходов по сравнению с потоками, но процедура добавления задачи в очередь все-равно затратна. Блок должен быть скопирован, поставлен в очередь, после чего нужно как-то уведомить соответствующий рабочий поток. Создание блока для каждого пикселя на экране — это скорее всего не тот метод, который даст выигрыш. С другой стороны, один блок на изображение, в случае преобразования списка изображений, скорее всего будет правильным решением и приведет к ускорению. Та точка, где использование GCD начинает давать выгоду, видимо, находится где-то между. Если сомневаетесь, экспериментируйте. Распараллеливание приложений — это прием оптимизации, и поэтому всегда нужно делать замеры до и после, чтобы удостовериться, что ваши изменения помогли (и чтобы проверить, что вы исправили правильное место).

Параллельность подсистем

В предыдущем разделе мы обсуждали использование многоядерности в одной подсистеме приложения. Но оно может быть полезно и в условиях множества подсистем.

К примеру, представим себе приложение, которое открывает содержащий метаданные документ. Данные самого документа должны быть обработаны и преобразованы в объекты модели для отображения, вместе с метаданными. Но, пусть данные и метаданные документа не взаимодействуют между собой. Тогда можно создать по очереди посылок для каждой из частей, чтобы они смогли выполняться параллельно. Код для обработки каждой будет последователен и потоковая безопасность (thread safety) не будет нас особо волновать до тех пор, пока не нужно будет обмениваться данными между ними, но сами обработчики будут исполняться параллельно.

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

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

Заключение

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

Ресурсы

Об авторе оригинальной статьи. Его зовут Mike Ash (Майк Эш), он — разработчик под Mac, как он говорит сам на своей домашней страничке, работает на Rogue Amoeba ночью и летает на глайдере днем.

© 2009-2012, ООО «Инру»
Вход
Имя пользователя:
Пароль:
Или…
Twi
Отмена
Войти
Восстановить забытый пароль…