
Персонаж с картинки — Трейсер из игры Overwatch
Привет, Хабр! Для отладки и анализа производительности часто используется трассировка (сбор) стека вызовов aka стектрейс. И если для трассировки стека различных потоков выполнения есть системные средства, то работа с асинхронными языками и фреймворками предполагает наличие отдельного контекста выполнения и стека вызовов для каждой единицы исполнения. В этой статье мы поговорим о файберах. Они прозрачны с точки зрения операционной системы, что влечет за собой определенные сложности. Если трассировка стека вызовов активного файбера тривиальна (можно представить, что кооперативной многозадачности вообще нет), то как собирать стектрейс с неактивных файберов?
За этим вопросом кроется некоторый пласт «черной магии», и найти ответ на него не так просто: информация разбросана по разным источникам, а подходящие примеры встречаются только в определенных проектах. Меня зовут Георгий Лебедев, я работаю в команде разработки ядра Tarantool. Под катом я поделюсь опытом, который мы выработали в Tarantool, и развею ту самую «черную магию».
Попробуем ответить на обозначенный вопрос, поступательно погружаясь в тему. Для начала конкретизируем термин «кооперативная многозадачность» и его свойства.
Что такое кооперативная многозадачность
В этой главе я приведу довольно поверхностный обзор, который необходим для понимания статьи. Подробнее о реализации кооперативной многозадачности в Tarantool можно почитать здесь, а о файберах — тут.
Оптимальное использование вычислительных ресурсов в многопроцессорных системах предполагает возможность единовременного выполнения разных задач — многозадачность. Она может быть одного из двух доступных видов:
- Вытесняющая многозадачность — это привычные нам потоки выполнения. Вытесняющей она называется потому, что потоки имеют иллюзию (абстракцию) монопольного выполнения в процессоре, будь то настоящий процессор или виртуальная машина. Они ничего не знают о планировании выполнения и управлении потоками и не могут никак влиять на эти процессы — этим заведует операционная система (или рантайм, или виртуальная машина). ОС по своему усмотрению вытесняет из исполнения одни потоки и передает управление другим, обеспечивая прогресс в выполнении всех потоков.
- Кооперативная многозадачность, напротив, вносит в модель выполнения (рантайм) такие понятия, как планировщик и передача управления. В рамках этой модели единицы исполнения — файберы — сами решают, когда отдать управление другим. Операционная система никак не влияет на исполнение отдельных файберов. Для обеспечения прогресса в выполнении файберы должны кооперировать друг с другом, отсюда и происходит название. Кстати, кроме файберов существуют еще корутины, но в статье мы рассматриваем именно файберы.
Сейчас асинхронные фреймворки и языки в высоконагруженных приложениях используются повсеместно, например: folly::fibers (C++), asyncio (Python), Seastar (C++), Tokio (Rust), userver (C++), Boost.Asio (C++), boost.Fiber (C++), Erlang, Golang, Kotlin, Lua, Julia. Некоторые из них основаны на концепции кооперативной многозадачности. Не обошло это стороной и Tarantool, в основе архитектуры которого лежит кооперативная многозадачность на файберах.
Типичный файберный рантайм состоит из главного управляющего файбера, планировщика и остальных файберов, которые запускаются планировщиком. Каждый файбер имеет свой контекст выполнения. Он состоит из стека вызовов (каждый файбер имеет динамический выделяемый стек) и состояния регистров.

В любой момент времени в рамках одного потока может исполняться строго один файбер. Он же отвечает за передачу управления. Контекст выполнения текущего файбера при этом сохраняется, но управление передается планировщику. Планировщик, в свою очередь, выбирает новый файбер для исполнения, восстанавливает его контекст исполнения и передает управление.
Трассировка стека вызовов
Теперь самое время напомнить, что из себя представляет трассировка стека вызовов. Стектрейсинг — это важная часть жизненного цикла разработки. Часто этот процесс используется для анализа выполнения программ, для отладки или оптимизации производительности. По сути, это отчет о действующей цепочке вызовов функций в определенный момент времени при выполнении программы.
Как на x86_64, так и на AArch64 каноническая структура фрейма стека вызовов выглядит следующим образом:
- Адрес возврата в начало фрейма.
- Адрес начала предыдущего фрейма.
- Локальный контекст: аргументы функции, не поместившиеся в регистры, локальные переменные и Caller-Saved-регистры.

Трассировка стека вызовов состоит из последовательного выполнения двух шагов:
- Получить контекст для восстановления текущего фрейма aka Call frame information (CFI).
- Получить контекст для трассировки стека наверх, состоящий из текущего адреса возврата и состояния стека предыдущего фрейма aka Canonical frame address (CFA).
Расхождения начинаются на шаге получения контекста: этот процесс зависит от архитектуры машины и имеющейся отладочной информации. Существуют два основных стандарта: Frame pointer based и DWARF.
- Во Frame pointer based используют выделенный регистр для сохранения CFA (Base pointer register aka RBP на x86_64 и Frame pointer register aka FP (x29) на AArch64) и на строго фиксированной структуре стекового фрейма, как на картинке выше. При этом регистр для сохранения CFA перестает быть регистром общего назначения, а компилятор ограничивается в оптимизации стекового фрейма. Это потенциально негативно влияет на производительность на быстром пути (Fast path), когда трассировка стека не используется.
- DWARF основан на использовании формата отладочной информации DWARF. Машинные инструкции аннотируются специальной отладочной информацией. Она позволяет по текущему состоянию машины восстановить весь необходимый контекст. Для аннотации компилятор генерирует в ассемблере специальные CFI-директивы, которые позволяют генерировать вплоть до стековой машины для вычислений.
CFI-директивы представляют собой простые арифметические и ссылочные инструкции, позволяющие по каким-то референсным значениям текущего состояния машины (например, Stack pointer register, RSP на x86_64 или SP на AArch64) вычислить CFA или значение определенного регистра. Например, link register LR (x30) на AArch64, в котором сохраняется адрес возврата.
Рассмотрим пример:
```asm .cfi_register x9, x8 .cfi_def_cfa x9, 128 .cfi_rel_offset x29, 64 .cfi_val_offset x30, 256 ```
Здесь задается следующее состояние:
- Значение регистра x9 сохранено в x8.
- Значение CFA можно вычислить как значение регистра x8 плюс 128.
- Значение регистра x29 сохранено по адресу CFA плюс 64.
- Значение регистра x30 есть адрес CFA плюс 256.
При генерации объектного файла в специальной секции .eh_frame CFI-директивы синтезируются в CFI-records. Они состоят из Common information entry (CIE) и массива Frame description entry (FDE), ставящих в соответствие диапазону машинных инструкций набор CFI-директив. Более подробную информацию о структуре .eh_frame можно найти здесь.
Этот подход используется для реализации программных исключений, например в C++, и потому DWARF и так генерируется при сборке Tarantool. Поэтому он же используется и для трассировки стека.
Трассировка стека вызовов в среде кооперативной многозадачности
Выше мы уже отмечали, что в любой момент времени в рамках одного потока может исполняться строго один файбер. Для текущего, активного файбера трассировка стека вызовов тривиальна: достаточно просто вызвать библиотечную функцию, например backtrace из glibc или unw_backtrace из GNU Libunwind. К сожалению, такие библиотеки умеют собирать стек вызовов только из текущего контекста выполнения.
В асинхронных фреймворках, использующих файберы, как правило, нет встроенной поддержки трассировки стека вызовов неактивных файберов. Есть только расширения для отладчика, например у folly. Основная сложность заключается в том, что нужно искусственно восстановить контекст выполнения неактивного файбера без передачи ему управления. Для трассировки стеков вызовов всех файберов главного (транзакционного) потока в сервер приложений Tarantool встроены соответствующие функции.
Как же собрать стек вызовов неактивных файберов? Для этого в Tarantool используется следующий трюк, использующий ассемблерную вставку:
- Сохраняем контекст текущего файбера.
- Восстанавливаем контекст неактивного файбера без передачи ему управления.
- Вызываем функцию, собирающую стек вызовов.
- Восстанавливаем контекст текущего файбера.
Такой трюк, к сожалению, несовместим с работой трассировщиков стеков, которую я описал выше. Несовместимость возникает из-за того, что во время выполнения трюка в ассемблерной вставке изменяется состояние машины — значение стекового указателя и других платформенных регистров. При этом CFI остается такой же, какой была до Inline assembly, из-за чего трассировщик стека начинает сходить с ума.
CFI-директивы спешат на помощь
К счастью, CFI-директивы можно самостоятельно вставлять в ассемблерный код, тем самым вручную размечая контекст восстановления для трассировщика стека. Примеры такого кода можно найти в реализации clone, start (glibc), во фреймворке Seastar, в языке Julia, в ART (Android Runtime) — там они используются для аннотации начала стека вызовов их кастомных единиц исполнения, будь то потоки или корутины.
Все, что нам необходимо сделать, это разметить CFA и адрес возврата для того искусственного фрейма, который образовался при восстановлении контекста неактивного файбера.
```asm 1. Save current fiber context. */ 2. pushq %rbp 3. pushq %rbx 4. pushq %r12 5. pushq %r13 6. pushq %r14 7. pushq %r15 8. /* Setup first function argument. */ 9. movq %1, %rdi 10. /* Setup second function argument. */ 11.movq %rsp, %rsi 12. /* Restore target fiber context. */ 13. "movq (%2), %rsp 14. movq 0(%rsp), %r15 15. movq 8(%rsp), %r14 16. movq 16(%rsp), %r13 17. movq 24(%rsp), %r12 18. movq 32(%rsp), %rbx 19. movq 40(%rsp), %rbp 20. /* Setup CFI. */ 21. ".cfi_remember_state 22. ".cfi_def_cfa %rsp, 8 * 7 23. leaq %P3(%rip), %rax 24. call *%rax 25. .cfi_restore_state 26. /* Restore original fiber context. */ 27. mov %rax, %rsp 28. popq %r15 29. popq %r14 30. popq %r13 31. popq %r12 32. popq %rbx 33. popq %rbp ``` ```asm 1. /* Save current fiber context. */ 2. sub sp, sp, #8 * 20 3. stp x19, x20, [sp, #16 * 0] 4. stp x21, x22, [sp, #16 * 1] 5. stp x23, x24, [sp, #16 * 2] 6. stp x25, x26, [sp, #16 * 3] 7. stp x27, x28, [sp, #16 * 4] 8. stp x29, x30, [sp, #16 * 5] 9. stp d8, d9, [sp, #16 * 6] 10. stp d10, d11, [sp, #16 * 7] 11. stp d12, d13, [sp, #16 * 8] 12. tstp d14, d15, [sp, #16 * 9] 13. /* Setup first function argument. */ 14. mov x0, %1 15. /* Setup second function argument. */ 16. mov x1, sp 17. /* Restore target fiber context. */ 18. ldr x2, [%2] 19. mov sp, x2 20. ldp x19, x20, [sp, #16 * 0] 21. ldp x21, x22, [sp, #16 * 1] 22. ldp x23, x24, [sp, #16 * 2] 23. ldp x25, x26, [sp, #16 * 3] 24. ldp x27, x28, [sp, #16 * 4] 25. ldp x29, x30, [sp, #16 * 5] 26. ldp d8, d9, [sp, #16 * 6] 27. ldp d10, d11, [sp, #16 * 7] 28. ldp d12, d13, [sp, #16 * 8] 29. ldp d14, d15, [sp, #16 * 9] 30. /* Setup CFI. */ 31. .cfi_remember_state 32. .cfi_def_cfa sp, 16 * 10 33. .cfi_offset x29, -16 * 5 34. .cfi_offset x30, -16 * 5 + 8 35. bl %3 36. .cfi_restore_state\n" 37. /* Restore original fiber context. */ 38. ldp x19, x20, [x0, #16 * 0] 39. ldp x21, x22, [x0, #16 * 1] 40. ldp x23, x24, [x0, #16 * 2] 41. ldp x25, x26, [x0, #16 * 3] 42. ldp x27, x28, [x0, #16 * 4] 43. ldp x29, x30, [x0, #16 * 5] 44. ldp d8, d9, [x0, #16 * 6] 45. ldp d10, d11, [x0, #16 * 7] 46. ldp d12, d13, [x0, #16 * 8] 47. ldp d14, d15, [x0, #16 * 9] 48. add sp, x0, #8 * 20 ```
Приведенные ассемблерные вставки — реализация трюка для сбора стека вызовов неактивного файбера на x86_64 и AArch64 соответственно. В них нас интересуют строки 20–25 для x86_64 и строки 30–36 для AArch6.

Как на x86_64, так и на AArch64 мы сохраняем состояние CFI до начала сбора стека вызовов и корректируем значение CFA так, чтобы началу фрейма соответствовал адрес возврата неактивного файбера. Поскольку на AArch64 для адреса возврата есть специально выделенный Link register — LR (x30), на этой платформе необходимо также явно указать, где сохранено его значение. Также для совместимости с трассировкой по Frame pointer необходимо явно указать, где сохранено значение Frame pointer — FP (x29). Затем после сбора стека вызовов на обеих платформах мы восстанавливаем состояние CFI до прежнего, то есть до начала Inline assembly.
Заключение
Вот мы и приоткрыли завесу тайны трассировки стека в среде кооперативной многозадачности. Мы рассмотрели, как устроена кооперативная многозадачность на файберах, как устроена трассировка стека вызовов и как увязать одно с другим с помощью CFI-директив. Надеюсь, что в следующий раз, когда кто-то столкнется с подобной проблемой, эта статья окажется практически полезной и сориентирует в ее решении.
Скачать Tarantool можно на официальном сайте, а получить помощь — в Telegram-чате.
ссылка на оригинал статьи https://habr.com/ru/companies/vk/articles/735794/
Добавить комментарий