Парсинг текста в C++: скорость и безопасность буферов
Наивный парсер на std::stringstream разбирает 500 МБ лога десятки секунд и открывает дорогу переполнениям. Разбираем, где C++-код теряет скорость и как не выйти за границы буфера.

Сервис парсит access-логи — 500 МБ текста, около 4 млн строк. Первая версия на std::ifstream с std::getline и разбором через std::stringstream разбирала файл за 28 секунд на одном ядре. После переписывания на std::string_view поверх mmap — 1,9 секунды. Разница в 14 раз, и ни одной новой строки бизнес-логики: изменилось только то, копирует парсер данные или смотрит на них по месту. Разбираем, откуда берётся эта разница и где по дороге появляются шансы выйти за границу буфера.
Речь про C++17 и новее: std::string_view появился в C++17, std::from_chars — там же. Замеры сделаны на GCC 13, -O2, файл в page cache. Тема не про grammar-based парсеры и не про JSON — только про разбор строкового потока вручную: разрезать по разделителям, вытащить числа, проверить границы.
Где уходит время
Наивный парсер тратит время не на разбор, а на память. Три источника накладных расходов, в порядке убывания вклада.
Копирование при каждом извлечении подстроки
std::string token = line.substr(start, len) выделяет новую строку и копирует байты. На 4 млн строк по 6-8 полей это десятки миллионов аллокаций. std::string_view не копирует ничего: он хранит указатель и длину, указывая внутрь исходного буфера.
#include <string_view>
#include <vector>
// Разрезать строку по одному символу-разделителю без копий.
// Возвращает view внутрь исходного буфера — он должен жить дольше результата.
std::vector<std::string_view> split(std::string_view line, char sep) {
std::vector<std::string_view> parts;
size_t start = 0;
while (start <= line.size()) {
size_t pos = line.find(sep, start);
if (pos == std::string_view::npos) {
parts.push_back(line.substr(start));
break;
}
parts.push_back(line.substr(start, pos - start));
start = pos + 1;
}
return parts;
}
Здесь substr у string_view не аллоцирует — он возвращает другой view в тот же буфер. Это ключевое отличие от std::string::substr.
Разбор чисел через потоки
std::stringstream и std::stoi тянут за собой локали, проверки и, в случае потока, ещё одну аллокацию. std::from_chars (из <charconv>) не выделяет память, не учитывает локаль и не бросает исключений — он возвращает структуру с указателем на первый неразобранный символ и кодом ошибки.
#include <charconv>
#include <string_view>
#include <optional>
// Разбор беззнакового числа из view без копий и исключений.
std::optional<uint64_t> parse_uint(std::string_view sv) {
uint64_t value = 0;
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), value);
// Успех, только если разбор без ошибки и дошёл до конца view.
if (ec == std::errc{} && ptr == sv.data() + sv.size()) {
return value;
}
return std::nullopt;
}
Проверка ptr == sv.data() + sv.size() важна: без неё "123abc" вернёт 123 и промолчит про хвост. Для лога это скрытая порча данных.
Построчное чтение с копированием буфера
std::getline копирует строку в переданный std::string. При больших файлах выигрывает отображение файла в память через mmap: ядро подтягивает страницы по мере обращения, а парсер работает с одним непрерывным string_view на весь файл и режет его по \n. Копирований в userspace нет вообще.
Правило, которое экономит больше всего: в горячем пути парсера не должно быть ни одной аллокации на элемент данных. Аллокация на строку — уже проиграно, аллокация на поле — катастрофа.
Замеры
Один файл 500 МБ, 4 млн строк access-лога, GCC 13 -O2, файл прогрет в page cache, одно ядро. Время — медиана пяти прогонов.
| Подход | Время | Аллокаций на строку |
|---|---|---|
| getline + stringstream + stoi | 28,4 с | ~10 |
| getline + string_view + from_chars | 4,1 с | 1 (буфер строки) |
| mmap + string_view + from_chars | 1,9 с | 0 |
Цифры зависят от железа и структуры строк — воспроизводите на своих данных. Смысл не в конкретных секундах, а в порядке: убрать копии и потоковый разбор чисел — это разница в разы, а не в проценты.
Где ломается безопасность
Скорость через string_view и указатели переносит ответственность за границы буфера с библиотеки на вас. Четыре места, где чаще всего появляется чтение за пределами буфера.
Висячий view после смерти буфера
string_view не владеет памятью. Если функция возвращает view на локальный std::string, после выхода из функции указатель ведёт в освобождённую память — классический use-after-free.
// ОПАСНО: возвращаем view на строку, которая умрёт на выходе.
std::string_view bad() {
std::string local = read_line();
return local; // local уничтожается — view повисает
}
Правило: view живёт строго не дольше буфера, на который смотрит. Санитайзер -fsanitize=address ловит такие обращения на этапе тестов — включайте его в CI, а не только локально.
Индексация без проверки длины
sv[i] у string_view не проверяет границы — как и у сырого массива. Разбор формата с фиксированными позициями (line[10], line[19]) на укороченной строке читает чужую память. Перед доступом по индексу проверяйте sv.size(), либо используйте sv.at(i), который бросит std::out_of_range.
Арифметика указателей на границе разделителя
Код вида sv.substr(pos + 1) безопасен, только пока pos + 1 <= sv.size(). Если разделитель оказался последним символом, substr вернёт пустой view — это корректно. А вот sv.data() + pos + 1 с последующим разыменованием на такой границе уже читает за концом. С from_chars всегда передавайте end-указатель, а не полагайтесь на нуль-терминатор: у string_view его может не быть.
Отсутствие нуль-терминатора
Самая частая ошибка при миграции с C-строк: string_view не гарантирует \0 в конце. Передача sv.data() в функцию, ожидающую C-строку (atoi, strlen, fopen), читает за пределами view до первого случайного нуля. Для таких вызовов нужен явный std::string(sv) — и это одно из немногих мест, где копия оправдана.
Как проверять, что не вышли за буфер
Ручные проверки границ помогают, но не покрывают всё. Три инструмента, которые ловят то, что глаз пропускает.
- AddressSanitizer (
-fsanitize=addressв GCC и Clang) — ловит чтение и запись за границами кучи и стека, use-after-free. Замедляет выполнение примерно вдвое, поэтому держите отдельную сборку для тестов. - UndefinedBehaviorSanitizer (
-fsanitize=undefined) — ловит переполнение знаковых типов при разборе чисел, если вы делаете арифметику вручную вместоfrom_chars. - Фаззинг (libFuzzer или AFL++) — скармливает парсеру случайные и обрезанные входы. Именно фаззер находит строку без разделителя, пустой файл и обрезанное последнее поле — то, чего нет в ваших тестовых данных.
Что делать дальше
Соберите отдельную конфигурацию с ASan и UBSan и прогоните на ней весь тестовый корпус — до того, как парсер уйдёт в прод. Добавьте в фаззинг-корпус три граничных входа: пустой файл, файл без завершающего \n, строку из одних разделителей. Замерьте свой реальный файл под perf stat и посмотрите на промахи кеша: если парсер упирается в память, а не в CPU, дальнейшая микрооптимизация разбора чисел ничего не даст. Многопоточное чтение mmap-региона по диапазонам строк — отдельная тема, здесь мы её не трогали.
Инфографика для маркетплейса: Три главных преимущества товара короткими фразами, выноски в безопасной зоне превью, точный размер площадки.
Спросить за 60 ₽Источники: std::from_chars — cppreference, std::string_view — cppreference
Частые вопросы
У меня C++14, string_view недоступен. Что делать?
from_chars не собирается — компилятор ругается на charconv.
AddressSanitizer ничего не находит, но в проде падает. Почему?
Насколько mmap быстрее обычного чтения на практике?
Можно ли хранить string_view в структуре и передавать дальше?
from_chars разобрал число, но результат неверный при отрицательных значениях.
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.