Предположим, вы пишете многопоточное приложение для Linux, которое рассчитано на длительную работу. Может — это СУБД или какой-нибудь сервер. Представим ещё, что ваша программа не рассчитана на какую-нибудь среду выполнения кода (скажем — на JVM, Go или BEAM), которая берёт на себя управление низкоуровневыми вещами. Вы сами управляете порождением потоков (thread), прибегая к системному вызову clone. Когда пишут на C — потоки создают с помощью pthread_create, а в C++ применяется std::thread. (1)

(1) Большая часть того, о чём пойдёт здесь речь, применима на только к потокам, но и к процессами (process). В Linux единственное реальное различие между процессами и потоками заключается в том, что потоки совместно используют виртуальную память.

Кроме того, то, о чём мы будем говорить, справедливо не только для C и C++, но и для любых языков, напрямую использующих потоки Linux. Например — это Rust (thread::spawn) и Zig (std::Thread::spawn).

А как только кто-то начинает заниматься запуском потоков — ему, вероятно, придётся подумать и об их остановке. Правда, первое гораздо легче последнего. Здесь «остановкой» я называю такое завершение работы потока, в ходе которого ему, до полного и окончательного исчезновения, дают шанс прибраться за собой, освободив ресурсы. Иначе говоря, нам нужно, чтобы в ходе остановки потоков обязательно выполнялись бы такие операции, как освобождение памяти, снятие блокировок, сброс логов на диск и так далее. (2)

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

Остановить поток без освобождения ресурсов очень легко: достаточно воспользоваться командой вида pthread_kill(tid, SIGKILL).

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

(Псевдо-)холостое ожидание

Если вы можете себе это позволить — каждый поток можно структурировать вот так:

while (true) {
  if (stop) { break; }
  // Делаем какие-то дела, завершающиеся за разумное время
}

Здесь stop — это логическая переменная, индивидуальная для каждого потока. Когда надо остановить поток — stop устанавливают в значение true, а потом вызывают команду pthread_join или её эквивалент, чтобы убедиться в том, что поток действительно остановился.

Вот — искусственный, но вполне рабочий пример на C++:

#include <thread>
#include <atomic>
#include <stdio.h>
#include <unistd.h>

static std::atomic<bool> stop = false;

int main() {
  std::thread thr([] {
    // каждую секунду, до остановки, выводит сообщения
    for (int i = 0; !stop.load(); i++) {
      printf("iterated %d times\n", i);
      sleep(1);
    }
    printf("thread terminating\n");
  });
  // ждёт 5 секунд, а потом останавливает поток
  sleep(5);
  stop.store(true);
  thr.join();
  printf("thread terminated\n");
  return 0;
}

Этот код выводит следующее:

iterated 0 times
iterated 1 times
iterated 2 times
iterated 3 times
iterated 4 times
thread terminating
thread terminated

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

Обратите внимание на то, что тело цикла не обязательно должно быть представлено полностью неблокирующими командами. Нужно лишь, чтобы у этого цикла была возможность завершиться так быстро, как нам это нужно. Например, если поток читает данные из сокета — можно записать в SO_TIMEOUT значение 100 миллисекунд. Благодаря этому у программиста будет уверенность в том, что итерации смогут завершаться достаточно быстро. (3)

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

Возможно, для языка, на котором вы пишете, уже существует подобный фреймворк (Seastar в C++, async Rust и так далее).

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

А что если нужна блокировка неограниченной длительности?

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

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

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

Поэтому стоит подумать о том, как остановить поток в то время, когда он заблокирован в системном вызове. Самый логичный способ это сделать заключается в применении сигналов. (4)

(4) Обратите внимание: даже если вы пользуетесь псевдо-холостыми циклами (и, на самом деле, это относится ко всем, кто пишет программы), то вам, скорее всего, не уйти от необходимости работы с сигналами.

Например — любое приложение, использующее буферизованные операции вывода данных (например — с помощью команды printf) несёт ответственность за потерю выходных данных в том случае, если оно было остановлено сигналом, и при этом у него нет установленного обработчиков сигналов, сбрасывающих буферы stdio.

Надо поговорить о сигналах

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

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

(5) Если так — поздравляю. Вы, вероятно, уже устали от моих разговоров.

Сигналы могут возникать из-за каких-либо аппаратных исключений (6), их могут инициировать и программы. Самый известный пример сигналов, вызываемых программно — это когда командная оболочка системы отправляет SIGINT фоновому процессу, когда пользователь нажимает сочетание клавиш ctrl+c. Все сигналы, инициированные программно, исходят от некоторого количества системных вызовов. Например — сигналы потокам отправляет вызов pthread_kill. (7)

(6) Операция деления на ноль может сгенерировать сигнал SIGFPE, попытка обращения к неотображённой памяти вызовет сигнал SIGSEGV и так далее.

(7) Сигналы могут быть адресованы некоему потоку (например — посредством pthread_kill), или процессу (с помощью kill). Сигналы, ориентированные на потоки, доставляются конкретным потокам. А когда отправляется сигнал, предназначенный для процесса, ядро выбирает поток в процессе, который, как предполагается, должен будет обработать этот сигнал. При этом нет гарантии выбора какого-то определённого потока.

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

  1. Если сигнал был заблокирован потоком-получателем — он будет ждать обработки до тех пор, пока не будет разблокирован.

  2. Если сигнал не заблокирован, то это может означать один из следующих вариантов:

    1. Его проигнорировали.

    2. Он был обработан с помощью механизма, применяемого по умолчанию.

    3. Он был обработан с помощью пользовательского обработчика сигнала.

(8) В сущности, это происходит в двух случаях: когда системный вызов завершает работу, или когда ядро планирует выполнение потока.

Тем, какие именно сигналы блокируются, можно управлять с помощью маски сигналов, используя sigprocmask/pthread_sigmask. А тем, какие действия выполняются в том случае, если сигнал не заблокирован, можно управлять с помощью sigaction.

Исходя из предположения о том, что сигнал не заблокирован, дальнейший ход событий — будет ли выбран вариант 2.a, или 2.b — полностью зависит от ядра. А вот выбор варианта 2.c приводит к тому, что ядро передаёт управление обработчику сигнала пользовательского режима, который, получив сигнал, выполнит некие действия.

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

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

Отмена выполнения потоков — ложная надежда

Давайте для начала исследуем способ остановки потоков, реализованный посредством сигналов, который, как кажется, даёт нам именно то, что нужно. Речь идёт об отмене выполнения потока (thread cancellation).

API для отмены выполнения потоков выглядит весьма многообещающим. А именно — вызов pthread_cancel(tid) «отменяет» поток tid. То, как именно работает данный вызов, сводится к следующему:

  1. Потоку tid отправляют особый сигнал.

  2. Используемый вариант libc (скажем — glibc или musl) настраивает обработчик сигнала таким образом, чтобы, когда будет получен сигнал, поток завершил бы работу.

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

Управление ресурсами + отмена выполнения потоков = ?

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

lock();
// тут выполняется что-то критически важное
unlock();

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

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

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

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

  • Система работы с потоками в Linux включает в себя средства для добавления и удаления глобальных обработчиков очистки, выполняемых при завершении потоков (cleanup handler). Делается это с помощью команд pthread_cleanup_push и pthread_cleanup_pop. Эти обработчики выполняются, когда отменяется выполнение потоков.

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

    И, снова, такой подход чреват ошибками, его применение ведёт к значительному замедлению кода.

  • По умолчанию сигнал, отправленный командой отмены выполнения потока, принимается лишь в особых местах, называемых «точками отмены выполнения потоков» («cancellation points»), которые, если не вдаваться в детали, представляют собой системные вызовы, которые способны создавать блокировки (посмотрите документацию по pthreads).

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

Отмена выполнения потоков несовместима с современным C++

Если вы программируете на C++ или на Rust — то вы, возможно, поднимете на смех вышеизложенные идеи явных блокировок. В вашем распоряжении для таких случаев имеются механизмы RAII:

{
  const std::lock_guard<std::mutex> lock(mutex);
  // тут выполняется что-то критически важное
  // Деструктор `lock` выполнит разблокировку
}

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

Ответ на этот вопрос заключается в том, что сигнал отмены выполнения потока вызовет раскрутку стека (stack unwinding), что аналогично тому, что происходит при возникновении исключения (на самом деле — это реализовано с помощью особого исключения). А значит — при отмене выполнения потока будут вызваны деструкторы. Этот механизм известен как принудительная раскрутка стека (forced unwinding). Замечательно! Правда? (9)

(9) Обратите внимание на то, что раскрутка стека не регулируется какими-либо стандартами. Эта процедура относится к собственным возможностям glibc/libstdc++. Например, если вы пользуетесь musl — никакой раскрутки не будет и деструктор не выполнится.

Существуют и ещё одна странность, о которой стоит знать тем, кто в своей работе полагается на раскрутку стека.

Дело в том, что, так как отмена выполнения потоков реализована с использованием исключений, и отмена выполнения потока может произойти в любой момент его работы, всегда есть риск того, что это случится в блоке noexcept. Это приведёт к аварийному завершению работы программы посредством std::terminate().

В результате — начиная с C++11, и особенно — с C++14, где деструкторы по умолчанию помечаются как noexcept-блоки, механизмы отмены выполнения потоков в C++ оказываются бесполезными. (10)

(10) Тот, кто пишет не на C++, а на C, может выполнить очистку ресурсов, прибегнув к attribute((cleanup)). Функционировать это будет практически так же, как деструкторы, но без проблем, связанных с noexcept.

В теории это будет хороший, рабочий механизм, но на практике это расходится с тем, как вообще пишется код на C. Программист, использующий такой стиль в рамках целого C-проекта, будет, образно говоря, плыть против течения. Более того — этот подход, в любом случае, не лишён недостатков, о чём я говорю в следующем разделе.

Принудительная раскрутка стека — это, в любом случае, небезопасно

Даже если бы этот механизм нормально работал в C++, его, во многих ситуациях, было бы небезопасно использовать. Рассмотрим такой пример:

{
  const std::lock_guard<std::mutex> lock(mutex);
  balance_1 += x;
  balance_2 -= x;
}

Если после команды balance_1 += x случится принудительная раскрутка стека — наши инварианты потеряют смысл. Именно поэтому механизм принудительной раскрутки стека в Java, реализуемый с помощью Thread.stop, выведен из употребления. (11)

(11) В Rust, в случаях, подобных нашим, вызывается panic!(), и блокировки, удерживаемые потоком, «отравляются», помечаются как «poisoned». Это, по крайней мере, помогает поддерживать безопасность кода (но не его корректное функционирование), при условии, что в среде выполнения Rust что-то вроде механизма отмены выполнения потоков реализовано как эквивалент panic!().

Невозможно правильно и аккуратно останавливать потоки, выполняющие код, который мы не контролируем

Позволю себе сделать небольшое отступление: природа сигналов (и, значит — природа отмены выполнения потоков) подразумевает невозможность корректной остановки кода, который мы не контролируем. Нельзя гарантировать отсутствие утечек памяти, гарантировать того, что открытые ранее файлы будут закрыты, что будут освобождены глобальные блокировки и так далее.

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

Управляемая отмена выполнения потоков

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

pthread_setcancelstate(PTHREAD_CANCEL_DISABLE);
while (true) {
  pthread_setcancelstate(PTHREAD_CANCEL_ENABLE);
  // Системный вызов, который может держать блокировку неограниченно долго, вроде
  // чтения данных из сокета
  pthread_setcancelstate(PTHREAD_CANCEL_DISABLE);
  // Завершение выполнения неких задач за разумное время
}

Мы, по умолчанию, отключаем возможность отмены выполнения потока, но включаем её при выполнении блокирующего системного вызова. (12)

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

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

Самодельная система отмены выполнения потоков

После того, как мы всё это сделали, возможно, разумной покажется идея полного избавления от отмены выполнения потоков. Если полагаться на раскрутку стека для освобождения ресурсов — получится решение, не поддающееся переносу на альтернативные версии libc. Такой подход требует от программиста большой осторожности в том случае, если ему нужно выполнить некие действия по очистке ресурсов за пределами деструкторов.

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

(13) USR1 — это удобный вариант, так как ничто не использует его по умолчанию. А это значит, что мы можем применить его для собственных целей. Потом ещё можно заблокировать SIGINT/SIGTERM, а их обработку можно включать только в главном потоке, который затем будет управлять уничтожением дочерних потоков.

Вот — пример, написанный на C++. Интересной частью этого кода я считаю ту, где происходит настройка обработчика сигнала:

// thread_local здесь, при работе с одним потоком, использовать необязательно,
// но это необходимо в том случае, если будет несколько потоков, которые нужно
// уничтожать по-отдельности.
static thread_local std::atomic<bool> stop = false;

static void stop_thread_handler(int signum) {
  stop.store(true);
}

int main() {
  // устанавливаем обработчик сигнала
  {
    struct sigaction act = {{ 0 }};
    act.sa_handler = &stop_thread_handler;
    if (sigaction(SIGUSR1, &act, nullptr) < 0) {
      die_syscall("sigaction");
    }
  }
  ...

Ещё интерес представляет код, проверяющий флаг перед выполнением системного вызова:

ssize_t recvlen;
if (stop.load()) {
  break;
} else {
  recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}
if (recvlen < 0 && errno == EINTR && stop.load()) {
  // мы получили сигнал во время выполнения системного вызова
  break;
}

Но код, который проверяет флаг и запускает системный вызов, подвержен состоянию гонок:

if (stop.load()) {
  break;
} else {
  // здесь выполняется обработчик сигнала, системный вызов блокируется
  // до тех пор, пока не прибудет пакет -- немедленного завершения работы не происходит!
  recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}

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

(14) Но есть сложный способ это сделать. Об этом мы поговорим в последнем разделе.

Ещё один подход к решению этой задачи заключается в том, чтобы USR1, в обычных условиях, был бы заблокирован. А разблокируют его только тогда, когда выполняется системный вызов. Это похоже на то, что мы делали, организуя временное разрешение на отмену выполнения потока. Если системный вызов завершится с EINTR, то мы будем знать о том, что нам нужно завершить работу. (15)

(15) При этом хорошо будет не отказываться от стоп-переменной, устанавливаемой в обработчике. Это позволит различать прерывания работы, вызванные запуском нашего обработчика, и прерывания работы, вызванные другими причинами. Я, для краткости, опустил это в коде.

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

ptread_sigmask(SIG_SETMASK, &unblock_usr1); // разблокировать сигнал USR1
// здесь выполняется обработчик сигнала, системный вызов блокируется
// до тех пор, пока не прибудет пакет -- немедленного завершения работы не происходит!
ssize_t recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
ptread_sigmask(SIG_SETMASK, &block_usr1); // снова заблокировать USR1

Атомарное изменение маски сигналов

Правда, часто можно прибегнуть к простому способу атомарного изменения маски сигналов (sigmask) и выполнения системного вызова:

  • У команд select/poll/epoll_wait имеются варианты pselect/ppoll/epoll_pwait, принимающие аргумент sigmask;

  • Системные вызовы, вроде read/write и подобных им, можно заменить на их неблокирующие версии и применить блокирующий вызов ppoll.

  • Для приостановки потока можно использовать timerfd, или — просто применить вызов ppoll без файлового дескриптора, но с указанием тайм-аута.

  • Недавно появившийся вызов io_uring_enter поддерживает этот сценарий без необходимости дополнительной настройки.

Эти системный вызовы уже охватывают очень большой набор интересующих нас сценариев работы с потоками. (16)

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

Если работать в этом стиле, то цикл приёма данных программы станет таким:

struct pollfd pollsock = {
  .fd = sock,
  .events = POLLIN,
};
if (ppoll(&pollsock, 1, nullptr, &usr1_unmasked) < 0) {
  if (errno == EINTR) {
    break;
  }
  die_syscall("ppoll");
}
ssize_t recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);

Делаем так, чтобы это работало с любыми системными вызовами

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

(17) Интересно, что FUTEX_FD позволил бы использовать futex с ppoll, но его убрали в Linux 2.6.25 из-за того, что он подвержен состоянию гонок. Это — редкий пример того, как разработчики Linux «поломали» пользовательский API.

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

(18) Идею этого фокуса подал Питер Коули.

Напомню, что проблемный код выглядит так:

if (stop.load()) {
  break;
} else {
  // здесь выполняется обработчик сигнала, системный вызов блокируется
  // до тех пор, пока не прибудет пакет -- немедленного завершения работы не происходит!
  recvlen = recvfrom(sock, buffer.data(), buffer.size(), 0, nullptr, nullptr);
}

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

В Linux 4.18 появился системный вызов rseq (restartable sequences, перезапускаемые последовательности) (19), который позволяет этого достичь, хотя и не без определённых усилий. (20) Вот как работают механизмы rseq:

  • Пишут какой-то код, который должен выполняться атомарно по отношению к вытеснению потоков или к сигналам. Это и есть критически важный участок кода.

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

  • Эта память содержит следующие данные:

    1. start_ip — указатель инструкции, с которой начинается критически важный участок кода;

    2. post_commit_offset — длина критического участка;

    3. abort_ip — указатель инструкции, на которую нужно перейти в том случае, если ядру понадобится вытеснить критический участок;

  • Если ядро вытеснило поток, или если потоку должен быть доставлен сигнал, ядро проверяет, не выполняется ли в потоке критический раздел, оформленный с помощью rseq. И если это так — устанавливает счётчик команд потока на abort_ip.

(19) Документация по rseq всё ещё не слишком подробна, но вы можете найти хорошие инструкции и минимальный пример использования этого механизма здесь.

(20) Пользователь PhantomZorba с lobste.rs обратил внимание на то, что описанный здесь вариант «фокуса» можно реализовать и без поддержки rseq, проверяя счётчик команд из обработчика сигнала.

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

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

Обратите внимание на то, что вместо того, чтобы полностью отключать вытеснение потоков, rseq позволяет нам указать некий код (начинающийся с abort_ip), который может что-то очистить в том случае, если работа критического раздела была прервана. Корректное функционирование кода этого раздела, в результате, часто зависит от «инструкции, фиксирующей изменения» (commit instruction), находящейся в самом конце критического участка кода, которая делает изменения, произошедшие в этом участке, видимыми для остального кода.

В нашем случае такой «фиксирующей инструкцией» является syscall — это та самая инструкция, которая обращается к интересующему нас системному вызову. (21)

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

Всё это ведёт нас к следующему x86-64-виджету — шаблону для вызова syscall с 6 аргументами, который атомарно проверяет флаг остановки и выполняет syscall:

// Возвращает -1 и устанавливает errno в EINTR если в `*stop` было значение true
// до запуска syscall.
long syscall_or_stop(bool* stop, long n, long a, long b, long c, long d, long e, long f) {
  long ret;
  register long rd __asm__("r10") = d;
  register long re __asm__("r8")  = e;
  register long rf __asm__("r9")  = f;
  __asm__ __volatile__ (
    R"(
      # struct rseq_cs {
      #     __u32   version;
      #     __u32   flags;
      #     __u64   start_ip;
      #     __u64   post_commit_offset;
      #     __u64   abort_ip;
      # } __attribute__((aligned(32)));
      .pushsection __rseq_cs, "aw"
      .balign 32
      1:
      .long 0, 0                # версия, флаги
      .quad 3f, (4f-3f), 2f     # start_ip, post_commit_offset, abort_ip
      .popsection

      .pushsection __rseq_failure, "ax"
      # вставляем сигнатуру до секции завершения работы
      # в результате чего objdump выведет `ud1 <sig>(%%rip), %%edi`
      .byte 0x0f, 0xb9, 0x3d
      .long 0x53053053
      2:
      # выходим с EINTR
      jmp 5f
      .popsection

      # устанавливаем rseq->rseq_cs на нашу структуру, находящуюся выше.
      # rseq = указатель потока (то есть -- fs) + __rseq_offset
      # rseq_cs расположено по смещению 8
      leaq 1b(%%rip), %%r12
      movq %%r12, %%fs:8(%[rseq_offset])
      3:
      # начало критического раздела -- проверяем -- должны ли мы остановиться,
      # и если это так -- пропускаем syscall
      testb $255, %[stop]
      jnz 5f
      syscall
      # важно, чтобы вызов syscall был бы самым последним действием, выполняемым до
      # выхода из критического раздела. Это нужно, чтобы выполнить контракт rseq
      # на запрет использования системных вызовов в критическом разделе.
      4:
      jmp 6f

      5:
      movq $-4, %%rax # EINTR

      6:
    )"
    : "=a" (ret) // вывод попадает в rax
    : [stop] "m" (*stop),
      [rseq_offset] "r" (__rseq_offset),
      "a"(n), "D"(a), "S"(b), "d"(c), "r"(rd), "r"(re), "r"(rf)
    : "cc", "memory", "rcx", "r11", "r12"
  );
  if (ret < 0 && ret > -4096) {
    errno = -ret;
    ret = -1;
  }
  return ret;
}

// Версия recvfrom, которая атомарно проверяет
// флаг перед запуском.
static long recvfrom_or_stop(bool* stop, int socket, void* buffer, size_t length) {
  return syscall_or_stop(stop, __NR_recvfrom, socket, (long)buffer, length, 0, 0, 0);
}

Мы используем недавно добавленную в glibc поддержку механизма rseq, который даёт нам переменную __rseq_offset, содержащую смещение, где, относительно указателя потока, находится информация критического раздела. Всё, что нам надо сделать в критическом разделе — проверить флаг, и, если он установлен — пропустить системный вызов, а если нет — выполнить его. Если флаг установлен — мы делаем вид, что системный вызов завершился с ошибкой EINTR.

Полный код предыдущего примера, использующего этот «фокус» для вызова recvfrom, можно найти здесь. Я не призываю к использованию этого приёма, но признаю, что это, определённо, любопытная штука.

Итоги

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

А вот, например, в Haskell подобные возможности существуют. Это — асинхронные исключения. Правда, и тот, кто пишет на Haskell, должен очень внимательно относиться к правильной защите критически важных участков кода.

О, а приходите к нам работать? ? ?

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

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

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

Мы проводим соревнование по машинному обучению

Призовой фонд $13,600

Комментарии (3)


  1. HyperWin
    17.11.2025 11:27

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

    Не так давно писал приложение многопоточное (потоков пять, что ли), и когда начал писать тесты - понял, что их нужно еще как то останавливать. При этом не должно быть спинлока, такое в прод не отдать. Спустя несколько дней все удалось к свести к тому, что циклы внутри потоков просто проверяют, стоит ли атомик флаг. Но собсна есть проблема что а вдруг этот цикл не начнет следующую итерацию? Он же повиснет намертво. И вот так оно работает до сих пор. Но благодаря вашей статье узнал что можно прерывания использовать:) спасибо, подумаю как это правильно встроить все. Софт под винду правда, но посмотрим.


    1. sic
      17.11.2025 11:27

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


  1. svpcom
    17.11.2025 11:27

    Статья конечно интересная, но имхо сама постановка задачи не верная. Ввод-вывод должен быть неблокирующим. Использование сигналов в многопоточной программе почти всегда undefined behaviour (про это даже написано в man'ах). Плюс желательно вообще избегать использования несколькоих потоков с общей памятью. Почему - см статью ниже.
    https://glyph.twistedmatrix.com/2014/02/unyielding.html