Как стать автором
Обновить
138.23
KTS
Создаем цифровые продукты для бизнеса

Потоки под капотом: как работают многопоточность и синхронизация

Уровень сложностиСредний
Время на прочтение25 мин
Количество просмотров835

Привет! Я Александр Сычев, iOS‑эксперт в KTS. В этой статье поговорю потоках.

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

В данной статье мы рассмотрим детали этой темы, а именно:

  • проанализируем работу потоков;

  • выявим скрытые механизмы, обеспечивающие их функционирование;

  • определим, какую пользу практикующим iOS‑разработчикам приносит понимание внутреннего устройства потоков.

Оглавление

Синхронизация

В обыденном понимании «синхронизация» подразумевает, что два явления происходят одновременно. В контексте компьютерных систем это понятие приобретает более широкий смысл и относится к взаимосвязи между событиями, будь то их последовательность, одновременность или временная соотнесенность (до, во время, после).

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

  • последовательность — событие A должно произойти раньше события B;

  • взаимное исключение — события A и B не могут происходить одновременно.

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

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

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

В реальности на нашу простейшую модель накладываются два усложняющих фактора.

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

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

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

Конкурентность VS параллелизм

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

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

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

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

Например, задачи 2, 3 и 4 на рисунке выше могут выполняться параллельно после выполнения задачи 1. Между этими задачами нет строго определенного порядка, поэтому существует несколько возможных вариантов последовательного выполнения (на рисунке представлены только два из них). Кроме того, эти задачи могут выполняться параллельно, например, на другом ядре процессора или на другом процессоре.

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

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

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

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

Еще несколько ключевых понятий

Программа

Программа (program) — это набор инструкций или кода, который хранится в памяти устройства и готов к выполнению. Это пассивный объект, содержащий код и ресурсы, но не исполняющийся сам по себе.

Пример в iOS

Когда разработчик создает приложение (например, Telegram), это приложение представляет собой программу, состоящую из Swift или Objective‑C кода, изображений, аудиофайлов и других ресурсов.

Программа становится активной, когда запускается операционной системой и инициализируется в процесс.

Процесс

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

Пример в iOS

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

Каждый процесс на iOS может содержать несколько потоков, при этом они работают в одном адресном пространстве процесса.

Поток

Поток (thread) — это наименьшая единица исполнения внутри процесса, которая выполняет последовательный путь инструкций. Потоки делят между собой ресурсы, выделенные процессу операционной системой (память, файлы и т. д.).

Пример в iOS

В приложении Telegram основной поток (UI‑поток, main thread) отвечает за интерфейс, реагируя на действия пользователя. Дополнительные фоновые потоки могут использоваться для загрузки изображений или обновления списка чатов в фоновом режиме.

iOS предоставляет GCD, OperationQueue, Swift Concurrency и другие инструменты, чтобы управлять потоками и эффективно распределять задачи между ними.

Задача

Задача (task) — это отдельная логическая единица работы, которая может быть выполнена. Задачу можно назначить для выполнения потоку или процессу.

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

Таким образом:

  • программа становится процессом при запуске;

  • процесс может включать несколько потоков, которые выполняются параллельно;

  • поток может выполнять одну или несколько задач, которые добавлены в очередь для выполнения (часто асинхронно).

Потоки

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

  • состояние всех регистров;

  • стек;

  • указатель, который операционная система, в частности планировщик ядра, использует для отслеживания местоположения потока;

  • различные метаданные и дополнительные состояния, приоритет.

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

Kernel threads vs green threads

Потоки служат единицей планирования в ядре операционной системы. По этой причине они называются потоками ядра (kernel threads), что указывает на их принадлежность операционной системе.

Также существуют отличные от них потоки пользовательского пространства, называемые зелеными потоками (green threads), которые планируются планировщиком пользовательского пространства, например, виртуальной машиной.

В iOS и macOS для работы с потоками ядра (kernel threads) используется ядро XNU, которое включает компоненты Mach и BSD, обеспечивающие поддержку многозадачности.

Рассмотрим основные особенности kernel threads.

  • Управляются операционной системой: планировщик ядра решает, когда и где выполнять потоки, распределяя их между процессорными ядрами.

  • Поддерживают мультипроцессорные системы: kernel threads могут выполняться параллельно на нескольких ядрах, что позволяет использовать многоядерные процессоры для повышения производительности.

  • Системные вызовы: любые операции по созданию, завершению и переключению между kernel threads требуют участия ядра, что добавляет некоторые накладные расходы. Например, работа с потоками через GCD (Grand Central Dispatch) в iOS в конечном итоге опирается на kernel threads.

  • Системный приоритет: ядро может назначить каждому потоку приоритет, который будет использоваться для управления порядком и временем выполнения. Kernel threads могут быть приоритетными для выполнения задач реального времени (например, для обработки событий интерфейса или воспроизведения аудио).

Green threads — это пользовательские потоки, управляемые на уровне приложения, а не операционной системы. Они имитируют многозадачность, но не требуют вмешательства ядра для управления их планированием. В iOS и macOS green threads не используются, хотя концепция схожа с тем, как устроены очереди в GCD. Однако в некоторых языках программирования и виртуальных машинах (например, в Java) green threads были популярны из‑за их эффективности на ранних однопроцессорных системах.

У green threads есть несколько отличительных особенностей.

  • Они управляются на уровне приложения: планирование выполняется в пользовательском пространстве, что означает, что приложение решает, когда и какие потоки выполнять.

  • Green threads отличаются ограниченной поддержкой многозадачности: поскольку они управляются на уровне приложения, такие потоки не могут выполнять задачи параллельно на нескольких ядрах.

  • Меньше накладных расходов: green threads не требуют системных вызовов для переключения контекста, что делает их более легкими и менее затратными в сравнении с kernel threads.

Потоки на уровне операционной системы

В контексте iOS‑разработки нас интересуют kernel threads. Именно про них мы говорим, когда используем понятие многопоточность.

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

В основе macOS лежит ядро ​​XNU («X is Not Unix») — гибридное ядро, объединяющее микроядро Mach, компоненты BSD и I/O Kit (объектно‑ориентированный API для драйверов устройств). Такая композиция инструментов извлекает выгоду из надежности BSD, подобной Unix, и при этом использует гибкость микроядра Mach. В целом, ядро XNU — это всегда выбор из инструментов микроядра и обвязки из имплементации индустриальных стандартов BSD.

Ядро XNU является частью проекта с открытым исходным кодом Darwin (исходный код находится в свободном доступе, хоть и в архиве). Это основа операционных систем Apple (macOS, iOS, watchOS, tvOS и iPadOS).

Истоки Darwin восходят к NeXT, компании, основанной Стивом Джобсом в 1985 году после того, как он покинул Apple. NeXT разработала NeXTSTEP, операционную систему, построенную на микроядре Mach и BSD. Она превратилась в OpenStep и в конечном итоге привела к Darwin, когда Стив Джобс вернулся в Apple, принеся с собой технологию NeXT.

Если покопаться в доках Apple, то можно найти простые изображения, но на примере OS X. Хотя суть для iOS та же.

Примечание
Я немного освежил изображения из документации, чтобы не пугать читателя разрешением 660×250 в 2025 году.

Что важно для нас:

  • модель процесса macOS / iOS основана на микроядре Mach и использует системные потоки Mach на базовом уровне управления процессом; при этом в BSD реализована поддержка для pthreads (потоков POSIX). Связка Mach и BSD, например, ведет к тому, что при использовании системного вызова BSD fork() код BSD в ядре использует функции Mach для создания задачи (task) и структуры потока (thread);

  • OS X — это среда вытесняющей/принудительной многозадачности (до того в уже почти античные времена в Mac OS 9 была кооперативная многозадачность, об этом подробнее ниже). В OS X ядро ​​обеспечивает принудительное взаимодействие, управляя потоками для совместного использования времени. Благодаря этому можно поддерживать поведение в реальном времени в приложениях, которым это необходимо (например, воспроизведение потокового аудио).

Пользовательские разработчики могут использовать pthreads (потоки POSIX) для своих хардкорных задач, kernel‑разработчики могут использовать еще и другой API, в который нам с вами лезть не надо.

Примечание
Хочется отметить, что когда‑то существовала библиотека cthreads, но сейчас она в статусе deprecated и больше не используется.

C kernel‑потоками мы редко взаимодействуем напрямую, но такие ситуации все же бывают.

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

Примечание
Значения времени работы в API kernel threads указываются в единицах абсолютного времени Mach. Поскольку эти значения различаются на разных процессорах, обычно следует использовать числа относительно HZ (глобальной переменная в ядре, которая содержит текущее количество тиков в секунду).

PTHREAD

Потоки на уровне клиентских приложений BSD (pthread) функционируют поверх потоков ядра (kernel thread) в Mach. Когда поток из пользовательского процесса блокируется в системном вызове, остальные потоки этого же процесса могут продолжать выполнение на том же или других ядрах, что обеспечивается планировщиком.

Другими словами:

  • Mach управляет очередью готовых к выполнению потоков и назначает их на доступные ядра CPU в зависимости от приоритетов и текущей нагрузки;

  • BSD обеспечивает интерфейс для взаимодействия с потоками через POSIX и другие API.

import Foundation
func threadRoutine(arg: UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? {
    print("POSIX thread executing")
    return nil
}
// Создание потока
var thread: pthread_t?
pthread_create(&thread, nil, threadRoutine, nil)
// Ожидание завершения потока
pthread_join(thread!, nil)

В этом примере pthread_create вызывает «под капотом» функцию ядра для создания Mach‑потока и назначения ему соответствующего контекста выполнения. POSIX‑потоки предоставляют более детальный контроль над выполнением потоков, однако требуют ручного управления синхронизацией и завершением работы, в отличие от инструментов более высокого уровня.

Существует около 100 функций POSIX threads, все с префиксом pthread_. Они решают 2 вида задач:

  • управление потоками: создание, присоединение и т. д.;

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

Многозадачность

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

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

Как работает переключение? У него есть два алгоритма работы, и я предлагаю рассмотреть их подробнее.

Cooperative Multitasking (Кооперативная многозадачность)

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

Преимущества:

  • управление переключением между задачами проще, так как его контролируют сами задачи, не требуя вмешательства системы;

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

Недостатки:

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

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

Кооперативная многозадачность использовалась в ранних версиях Mac OS и Windows, но она была заменена на принудительную многозадачность (preemptive multitasking) из‑за недостатков в стабильности.

Preemptive Multitasking (Принудительная многозадачность)

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

Преимущества:

  • повышенная стабильность и отзывчивость системы, так как ОС контролирует выполнение задач и не позволяет одной задаче блокировать выполнение других;

  • оптимальное использование ресурсов, поскольку задачи могут приостанавливаться и возобновляться системой в зависимости от приоритетов.

Недостатки

  • более сложная реализация, требующая ресурсов для управления переключением контекста и планирования задач;

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

Современные операционные системы, такие как iOS, macOS и Linux, используют принудительную многозадачность для стабильного выполнения всех процессов.

Планировщики

Для управления переключением в режиме принудительной многозадачности существуют специальные планировщики — утилиты, работающие на уровне ядра (в контексте iOS — Darwin). Эти планировщики с одной стороны взаимодействуют с dispatcher (утилитой, которая запускает и приостанавливает потоки), а с другой стороны стремятся обеспечить равномерное распределение нагрузки на систему и выявить зависшие или чрезмерно ресурсоемкие (жадные) потоки.

Как это работает в iOS / macOS? Планировщик осуществляет управление очередью выполнения потоков, основываясь на их приоритетах (priority‑based scheduling: поток с наивысшим приоритетом выполняется первым, в то время как потоки с одинаковым приоритетом выполняются на основе алгоритма FIFO). Эту очередь использует dispatcher, который фиксирует статистику использования CPU для каждого потока и, в соответствии с заданной политикой, производит расчет и изменение относительных приоритетов.

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

Приоритеты могут быть следующими:

  • обычный (Normal) — приоритеты потоков приложений;

  • высокий (System high priority) — потоки, приоритет которых был повышен относительно обычных потоков;

  • режим ядра (​​Kernel mode only) — зарезервирован для потоков, созданных внутри ядра, которым необходимо выполняться с более высоким приоритетом, чем все потоки пользовательского пространства (например, рабочие циклы I/O Kit);

  • потоки реального времени (Real‑time thread) — потоки, приоритет которых основан на получении четко определенной доли от общего числа тактов, независимо от другой активности (например, в приложении аудиоплеера).

Примечание
Альтернативой приоритетам могли бы быть другие алгоритмы, например:

  • FIFO (First In, First Out): этот метод планирования обеспечивает выполнение процессов в порядке их поступления в очередь. Как только процесс запланирован, он выполняется до завершения, если только он не заблокирован;

  • Round Robin: каждому из потоков выделяется фиксированный временной интервал (квант). Когда один поток достигает конца своего временного интервала, выполняется следующий в очереди в рамках кванта;

  • Shortest remaining time first: следующий поток в очереди — это тот, которому, по оценкам, требуется наименьшее количество времени для завершения. Для использования этого алгоритма нужно знать, сколько времени займет каждая задача внутри потока.

Планировщик при изменении приоритетов использует правила. Например:

  1. временем надо делиться. Если поток выполняется достаточно долго, его внутренний приоритет снижается;

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

Инструменты многопоточности и многозадачность

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

GCD (Grand Central Dispatch)

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

Swift Concurrency

Используя async‑await и Task, Swift Concurrency применяет элементы cooperative multitasking (кооперативной многозадачности) на уровне языка, сохраняя принудительную многозадачность операционной системы. То есть формально Swift Concurrency использует гибридную модель: есть легкие «потокоподобные» Tasks, о которых ядро ​​ничего не знает, и библиотека Concurrency выполняет собственную кооперативную многозадачность, чтобы решить, какие из задач будут выполняться на небольшой горстке «реальных» потоков, поддерживаемых ядром.

Каждый раз, когда задача ожидает чего‑либо, мы предоставляем системе кооперативной многозадачности возможность сказать «хорошо, теперь очередь за кем‑то другим», то есть отказываемся от своего фактического потока. Иногда в литературе это называется моделью потоков «M:N»: M потоков программы отображаются на N потоков ядра, где M > N.

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

Пример

func fetchData() async -> String {
    await Task.sleep(1_000_000_000) // Пример кооперативной паузы
    return "Data fetched!"
}
Task {
    let data = await fetchData()
    print(data)
}

Swift Concurrency позволяет задачам приостанавливаться в определенных точках (например, на вызовах await) и передавать управление следующей задаче, что делает Swift Concurrency кооперативной на уровне пользовательских задач. Задачи управляют своим контекстом, а не прерываются операционной системой принудительно.

Thread Sanitizer

При работе с многопоточностью в принудительной многозадачности (Preemptive Multitasking) особое внимание следует уделять потенциальным проблемам синхронизации, таким как:

  • гонки данных (data races) — когда несколько потоков одновременно обращаются к одной переменной без соответствующей синхронизации;

  • доступ к объектам после их удаления (use‑after‑free);

  • несоответствие ожиданий выполнения потоков (thread order violations).

Такие ошибки крайне сложно отследить, так как их проявления зависят от времени выполнения и случайных факторов. В этом и помогает Thread Sanitizer — инструмент динамического анализа многопоточного кода, встроенный в Xcode. Он перехватывает операции чтения и записи памяти во время выполнения и проверяет, выполняются ли они из разных потоков без должной синхронизации. Если он обнаруживает потенциальную гонку данных, Xcode немедленно сообщает об этом в консоли, указывая точные строки кода, вызвавшие проблему.

Закон Амдалла

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

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

Обычно конвейерные архитектуры процессоров включают в себя следующие стадии.

  1. Fetch (выборка) — загрузка инструкции из памяти.

  2. Decode (декодирование) — интерпретация инструкции.

  3. Execute (выполнение) — выполнение операции (например, арифметической).

  4. Memory Access (доступ к памяти) — обращение к памяти, если это необходимо.

  5. Write Back (запись) — запись результата в регистр.

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

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

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

Допустим, у нас есть процессор, где каждая инструкция требует 5 тактов для выполнения, и у нас есть 10 инструкций, которые нужно выполнить.

  • В неконвейерном процессоре каждая инструкция выполняется полностью прежде, чем начнётся следующая.

  • В конвейерном процессоре выполняется одна стадия инструкции за такт, и после того, как одна стадия завершается, следующая инструкция входит в конвейер.

Неконвейерный процессор

В неконвейерной архитектуре каждая инструкция занимает 5 тактов, поэтому для 10 инструкций:

Время выполнения = 10 x 5 = 50 тактов

Конвейерный процессор (5 стадий)

В конвейерном процессоре первая инструкция проходит все 5 стадий, но каждая следующая инструкция начинает выполняться после 1 такта (при входе в конвейер).

  • Первая инструкция завершится за 5 тактов.

  • Каждая следующая инструкция будет завершаться на каждом последующем такте.

Формула вычисления времени выполнения:

Время выполнения = 5 + (10 — 1) = 14 тактов

Расчет ускорения

Отношение времени выполнения в неконвейерной и конвейерной архитектуре:

Speedup = 50 / 14 = 3,57

То есть в этом синтетическом примере конвейерный процессор ускоряет выполнение в 3,57 раза по сравнению с неконвейерным.

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

Закон Амдала: ускорение, достигнутое за счет параллельной обработки, ограничено той частью программы, которую невозможно распараллелить. Он применим к любому виду параллелизма: не только к многопоточности или распараллеливанию вычислений на уровне инструкций, но также и к распределенным системам.

Закон выражается следующей формулой

где

  • S — ускорение программы (во сколько раз быстрее она работает);

  • P — доля программы, которая может быть выполнена параллельно;

  • N — количество доступных процессоров или потоков.

Когда N стремится к бесконечности, ускорение S приближается к 1 / (1 — P), показывая, что параллельная часть программы не может бесконечно ускорять ее выполнение из‑за последовательных участков кода.

Так, даже если 75% задачи можно распараллелить (например, обработку большого массива данных), то при наличии 4 ядер ускорение составит примерно 2,1 раза (а не 4 — красная линия на графике ниже). Это связано с тем, что оставшиеся 25% все еще выполняются последовательно. Также реальная эффективность параллелизации ограничивается затратами на синхронизацию между потоками.

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

Полезные в практике свойства потока

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

Прежде всего, надо

  • ответственно создавать новые потоки, корректируя их количество по мере изменения условий системы;

  • тщательно управлять ими, освобождая их из памяти после завершения выполнения;

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

Накладные расходы
Накладные расходы

Сравним инструменты по скорости создания и потреблению памяти.

  • Новый поток (pthread)

    • Скорость создания: создание нового потока быстрее, чем процесса, но все же требует времени на выделение стека и инициализацию структуры потока. Занимает микросекунды.

    • Потребление памяти: каждый поток имеет собственный стек, обычно размером от 512 КБ до 1 МБ, что увеличивает общее потребление памяти.

  • Новая очередь GCD

    • Скорость создания: создание очереди GCD (Grand Central Dispatch) является легкой операцией. Очереди могут быть серийными или параллельными, и их создание не требует значительных ресурсов по сравнению с pthread.

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

  • Новая OperationQueue

    • Скорость создания: OperationQueue построена поверх GCD и предоставляет более высокоуровневый интерфейс. Создание OperationQueue немного медленнее, чем создание очереди GCD, из‑за дополнительной функциональности, но разница занимает наносекунды.

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

  • Новый Task

    • Скорость создания: в Swift Concurrency создание Task является очень легкой операцией, так как Task управляются планировщиком Swift и не требуют создания новых потоков.

    • Потребление памяти: Task потребляют минимальное количество памяти, так как они работают в рамках существующих потоков и управляются планировщиком.

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

NSThread

NSThread реализован поверх POSIX threads. Это видно, например, из стека вызовов.

6 Foundation __NSThread__start__ + 1024, 
7 libsystem_pthread.dylib _pthread_body + 240, 
8 libsystem_pthread.dylib _pthread_body + 282, 
9 libsystem_pthread.dylib thread_start + 4

Выделю две ситуации, когда будет полезно использовать свойства этого класса.

callStackSymbols & callStackReturnAddresses

Можно использовать два static‑свойства NSThread для анализа стека вызовов.

func logErrorWithCallStackAddresses(errorMessage: String) {
    print("Error: \(errorMessage)")
    print("Call Stack Symbols:")

    // Выводим обычный стек вызовов с символами
    for symbol in Thread.callStackSymbols {
        print(symbol)
    }
    print("\nCall Stack Return Addresses:")

    // Выводим адреса возврата для стека вызовов
    for address in Thread.callStackReturnAddresses {
        print("0x\(String(format: "%016lx", address as! UInt64))")
    }
}

В этом примере функция logErrorWithCallStackAddresses:

  • логирует стек вызовов двумя способами: сначала с помощью callStackSymbols для обычного представления, а затем с помощью callStackReturnAddresses для вывода только адресов возврата.

  • адреса из callStackReturnAddresses форматируются в шестнадцатеричном виде для удобства чтения и последующего анализа.

Пример Call Stack Symbols

0 Test.debug.dylib   0x0000000102bfb700 $s4Test14ViewControllerC30logErrorWithCallStackAddresses12errorMessageySS_tF + 648

1 Test.debug.dylib   0x0000000102bfc180 $s4Test14ViewControllerC34performRiskyOperationWithAddressesyyF + 356

2  Test.debug.dylib  0x0000000102bfb40c $s4Test14ViewControllerC11viewDidLoadyyF + 108

3  Test.debug.dylib  0x0000000102bfb460 $s4Test14ViewControllerC11viewDidLoadyyFTo + 36

callStackSymbols и callStackReturnAddresses могут помочь в анализе стека потоков. Важный момент: они эффективны до момента падения (краша), так как они не способны поймать краш после завершения процесса, ведь стек будет уже уничтожен. В этом случае лучше использовать отладочные инструменты, как, например, указано в статье.

threadDictionary

threadDictionary — полезный инструмент для хранения данных, специфичных для каждого потока. Например, можно сохранить дополнительную отладочную информацию.

Пример

func performExpensiveCalculation() -> Int {
    if let cachedResult = Thread.current.threadDictionary["cached"] as? Int {
        print("Using cached result: \(cachedResult)")
        return cachedResult
    }
    
    let result = Int.random(in: 1...100)
    print("Calculated result: \(result)")
    
    Thread.current.threadDictionary["cached"] = result
    
    return result
}
  1. Первый вызов performExpensiveCalculation выполняет вычисление и сохраняет результат в threadDictionary, используя ключ cachedCalculationResult.

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

  3. Третий вызов в отличном от текущего потоке выполняет вычисление заново, так как threadDictionary привязан к каждому потоку, и кэшированного значения для этого потока не существует.

Выводы

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

В разработке под iOS существует множество инструментов для работы с многопоточностью, таких как GCD, OperationQueue и Swift Concurrency. Однако понимание работы kernel threads, планировщиков и закона Амдала позволяет создавать более производительный и стабильный код, избегая таких проблем, как блокировки, конкуренция за ресурсы и неэффективное распределение нагрузки.

Что еще почитать про потоки

Что еще почитать в нашем блоге iOS-разработчику

Мой блог в Telegram: @headOfMobile.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Приходилось ли вам погружаться в низкоуровневую многопоточность?
22.22% Да, для решения рабочих задач (расскажу в комментах)2
44.44% Да, из любопытства4
33.33% Нет3
Проголосовали 9 пользователей. Воздержавшихся нет.
Теги:
Хабы:
+11
Комментарии1

Публикации

Информация

Сайт
kts.tech
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия