Занятие 16. std::vector, std::chrono, std::thread
Эта лекция — легализация подполья. std::vector мы «писали сами» в занятии 8, все замеры курса пятнадцать занятий делались через std::chrono, операторы перегружал ещё BigInt из занятия 7, а std:: стоит в каждой программе с первого дня. Сегодня раскладываем всё это по полкам — и добавляем действительно новое: std::thread. Замеры реальные (Apple M-серия, 12 ядер, clang++ -O2; демо гонки — -O0, иначе оптимизатор схлопывает цикл).
std::vector под микроскопом
Три указателя
std::vector — это наш DynArray из занятия 8, доведённый до ума: непрерывный буфер в куче и три указателя.
size()— сколько элементов живёт;capacity()— на сколько выкуплено место; хвост между ними — оплаченный запас под рост.- Рост при переполнении — ×2; «
push_backза $O(1)$ амортизированно» — требование стандарта, а доказательство мы сдавали на РК1 (занятие 10). reserve(n)выкупает ёмкость заранее;shrink_to_fit()просит вернуть излишки (совет компилятору, не приказ).- Непрерывность буфера — то самое преимущество из занятия 8: кэш и предвыборка работают на полную.
Цена переездов: замер
$10^8$ вызовов push_back:
28 переездов на сто миллионов вставок — логарифм в действии (занятие 8 насчитало 21 на миллион). Итоговая ёмкость $134\,217\,728 = 2^{27}$ — рост ×2 виден невооружённым глазом. Одна строчка reserve убирает все переезды: ×5 быстрее. Заодно она лечит пики латентности из занятия 10 — тот самый push_back за 4.7 мс на фоне медианы 0 нс.
Правило курса
Знаешь итоговый размер — пиши reserve сразу.
Инвалидация: контракт, который нарушают молча
Переезд — это new + копирование + освобождение старого буфера: все указатели, ссылки и итераторы внутрь становятся висячими. Катастрофа № 2 из занятия 11 — только устраивает её сам контейнер, и никакого «golden rule» компилятор здесь не проверит. Это не баг, а задокументированный контракт: push_back с переездом инвалидирует всё; без переезда — ничего. Той же болезнью болел string_view, глядящий в растущую строку (занятие 9).
Правило
Не храните указатели и итераторы внутрь растущего вектора. Нужны стабильные адреса — reserve до заполнения или индексы вместо указателей.
Четыре факта, экономящие часы отладки
emplace_back("42")— конструирует объект прямо в буфере, без промежуточного временного (сравните сpush_back(BigInt("42"))).data()— указатель на непрерывный буфер: vector дружит с любым C-интерфейсом, какc_str()у строк (занятие 9).vector<bool>— упакован по битам,b[0]возвращает прокси-объект, а неbool— ловушка дляautoиз занятия 11. Нужны честные байты — беритеvector<char>.at(i)— доступ с проверкой границ: пара наносекунд против часа с санитайзером;v[i]мимо массива — молчаливое UB.
std::chrono: время в типах
Которые часы?
system_clock |
steady_clock |
|
|---|---|---|
| что это | настенные часы | секундомер |
| привязка | календарь, UTC | только «вперёд» |
| может прыгнуть | да: NTP, перевод времени | никогда |
| годится для | меток «когда случилось» | замеров «сколько заняло» |
Замер на system_clock — мина: NTP подвёл часы на секунду назад, и бенчмарк «занял −0.3 с». Поэтому все замеры курса сделаны на steady_clock — его монотонность гарантирует стандарт. Обратно: метка времени в логе или дата файла — только system_clock, секундомер не знает, который час. high_resolution_clock — псевдоним одного из этих двух (какого — зависит от реализации), в новом коде его не используют.
Мнемоника: интервалы — steady, календарь — system.
duration: единица измерения живёт в типе
Классический баг индустрии: void connect(int timeout) — секунды? миллисекунды? Ответа нет в типе — значит, он появится в багтрекере. duration хранит число плюс единицу в типе: складывать разные единицы можно (компилятор приведёт без потерь), а сужающая конвертация мс → с требует явного duration_cast. Это та же идея, что enum class в занятии 11 и сильные типы вообще: смысл — в тип, проверку — компилятору.
Сигнатура в духе курса: void connect(std::chrono::milliseconds timeout) — неправильно вызвать не получится.
Методика замеров курса — официально
Три врага честного бенчмарка:
- Оптимизатор. Результат, который никто не читает,
-O2выкидывает вместе с циклом — на подготовке занятия 12 первый замер бинпоиска показал «0.0 мс». Лекарство: checksum, выведенный наружу. - Холодный старт. Первый прогон греет кэши (занятие 8) — перед замером нужен прогрев.
- Разброс. Система живёт своей жизнью — повторы и медиана вместо единственного запуска (занятие 10 объясняло разницу медианы и пика).
Каждая цифра на слайдах курса прошла через этот шаблон.
std::thread: первый взгляд
Поток — ещё один исполнитель в той же памяти
Карта памяти из занятия 3 дополняется: у каждого потока — свой стек (занятие 5), а куча, глобальные данные и код — общие. Общая память — одновременно суперсила (ничего не нужно пересылать) и главный источник бед. Потоку можно отдать функцию, указатель на функцию или лямбду — занятие 14 здесь расцветает. Забытый join — аварийное завершение всей программы.
Параллельная сумма: ускорение и его потолок
Массив $10^8$ int режем на $T$ кусков; каждый поток суммирует свой кусок и пишет результат только в свою ячейку part[t]:
Почему потолок ×3.2 на 12 ядрах? Ядер много, а шина памяти одна: сумма упирается в подачу данных, не в арифметику. Занятия 8 и 12 предупреждали: память — такой же ресурс, как процессор, и здесь он кончается первым.
Гонка данных: куда пропала половина
counter++ — не один шаг, а три: прочитать → прибавить → записать (машинные шаги из занятия 5). Когда два потока читают одно и то же старое значение, две единицы схлопываются в одну — потеряна почти половина, и каждый запуск даёт новое число. По стандарту одновременная запись без синхронизации — data race, неопределённое поведение: не «иногда неточно», а «программа некорректна». Ловится инструментом: -fsanitize=thread покажет оба стека, наступившие на одну ячейку, — как ASan ловил утечки.
Заметьте: в параллельной сумме гонки не было — part[t] у каждого свой. Это не везение, это дизайн.
Правило курса — сегодня
Потоки не делят изменяемые данные: каждый пишет только в своё, читать общее можно, пока никто не пишет, итоги складывает главный поток после join. Замки (std::mutex), неделимые операции (std::atomic) и цена синхронизации — во втором семестре.
И мораль замеров: сначала алгоритм (занятия 2–15), потом ядра. Плохой алгоритм на 12 ядрах — всё ещё плохой алгоритм.
Правила дома
Пространства имён: фамилии для кода
Болезнь та же, что у старого enum из занятия 11: без фамилий имена сыплются в общую кучу и сталкиваются. std:: — просто фамилия стандартной библиотеки.
using geometry::Point;— точечный импорт одного имени: нормальная практика в .cpp.using namespace std;— высыпать в свой код тысячи имён (count,size,distance,sort…): коллизии гарантированы. В заголовочном файле — преступление против всех, кто его включит; в этом курсе не пишем нигде.- Анонимный
namespace { ... }— современная внутренняя линковка: static № 2 из занятия 11.
Перегрузка операторов: правила игры
Занятие 7 уже перегружало += у BigInt — теперь свод правил:
- Перегружается почти всё; нельзя:
::,.,?:,sizeof. Новых операторов не изобрести, приоритеты не изменить. - Симметричные операторы — свободными функциями: метод не позволит
2 + x(слева литерал, у него методов нет), свободная функция — позволит. - C++20:
operator<=>(«spaceship») со= default— одна строка, и все шесть сравнений корректны, лексикографически по полям в порядке объявления. - Принцип наименьшего удивления:
+складывает,==сравнивает. Перегрузка — про читаемость, не про остроумие.
Два оператора, которые вы уже используете
Функтор — класс с operator(). Лямбда из занятия 14 — ровно это: компилятор сам генерирует такой класс, а захваты становятся полями (мы даже мерили их sizeof). Круг замкнулся.
operator<< возвращает ostream& — потому и работают цепочки cout << a << b: тот же *this-приём, что в занятиях 5 и 11. И он обязан быть свободной функцией: слева стоит чужой класс ostream, метод в него не добавить.
Проверьте себя
-
Что напечатает?
-
Про гонку. Запуски дают 10 074 087, 9 996 053, 10 004 715… Почему все результаты ≈ $10^7$, а не $2 \cdot 10^7$ и не случайные числа около нуля? И почему это UB, даже если «числа похожи на правду»?
-
Оператор. Отсортировать
Student {name, height}по имени, при равенстве — по росту. Два решения?
Домашнее задание (сдача через Git)
- Бенчмарк-обвязка: функция
measure(f)по методике курса (steady_clock, checksum, прогрев, медиана из 5 запусков). Перемерьте ею свойlowerBoundиз ДЗ-12 противstd::lower_bound. - Параллельный минимум: минимум массива в T потоков без общих изменяемых данных; таблица времени для T = 1, 2, 4, 8 со своей машины и объяснение потолка.
- Свой тип — полноправный: класс
Fraction(дробь) сoperator+,operator==,operator<=>,operator<<; вектор дробей сортируется без компаратора. - Охота на инвалидацию: в выданном фрагменте три обращения к элементам vector после модификаций — какие валидны, какие UB и почему.
- * Гонка под микроскопом: цикл инкрементов — один поток vs два с гонкой vs
std::atomic<int>в два. Три числа и объяснение каждого.
Следующее занятие — STL целиком: контейнеры от deque до unordered_map, итераторы и их категории, алгоритмы и компараторы. Весь зоопарк на одной карте — с ценами операций.