std.Io.Threaded — одна из реализаций нового интерфейса Io в Zig, обеспечивающего конкурентность. Это скучная реализация в духе «просто используй потоки». Тем не менее она интересна тем, что делает странную вещь, которую давно хотелось реализовать, но насколько известно, никто до сих пор не делал это правильно — а здесь получилось даже лучше, чем казалось возможным.

Io.Threaded использует блокирующие системные вызовы и при этом полностью поддерживает отмену.

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

Цитируя @tedinski:

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

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

Для интуитивного понимания полезны два теста. Первый: параллелизм детерминирован или «декларативен»:

use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
    input.par_iter()
         .map(|i| i * i)
         .sum()
}

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

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

Именно в этом и заключается проблема подхода

Просто используй потоки

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

while (true) {
    if (is_canceled()) return error.Canceld; /// Easy!
    ...
}

Но когда поток заблокирован внутри системного вызова в ядре, API языков программирования обычно не дают способа его разблокировать:

const read_size = try read(fd, buffer); // ???

Было бы здорово использовать обычные потоки ОС, блокирующие API, избегая новомодных решений вроде io_uring, но при этом иметь возможность надёжно отменить любую работу. Именно это и предоставляет std.Io.Threaded в Zig.

SIGIO

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

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

Реальный протокол работает так: отменяющий поток устанавливает флаг в разделяемой памяти, запрашивая отмену, а затем в цикле сигнализирует отменяемому потоку, пока отмена не будет подтверждена (другим значением того же флага). Получив EINTR от системного вызова, поток, который потенциально отменяется, проверяет значение флага и либо повторяет вызов, либо подтверждает отмену и начинает разворачивание стека. Обе половины этого протокола можно увидеть в signalCanceledSyscall и, например, в fileReadPositionalPosix.

На стороне пользователя запрос отмены материализуется как error.Canceled. Управление ошибками как функция — это сочетание отмены, ветвления и оповещения, и Zig реализует первые два аспекта. Отмена не является ошибкой не потому, что это удачное завершение, а наоборот — потому что ошибка представляет собой отмену плюс некоторую полезную нагрузку.

В Windows есть куда более прямолинейный способ — NtCancelSynchronousIoFile. Отличное название! В целом, с учётом файберов, IO Completion Ports, Job objects и этого механизма, кажется, что в NT концепция конкурентности продумана лучше, чем в Unix.

Похожие решения

В Java есть похожий на вид механизм прерывания потоков. Критически важно, что он не поддерживает прерывание системных вызовов: IOException и InterruptedException — оба проверяемые исключения, но никак не связанные друг с другом, поэтому функции ввода-вывода не являются прерываемыми. В Zig интерфейсы reader и writer полностью стирают типы ошибок и поэтому поддерживают отмену, хотя это требует дополнительной аккуратности при корректной обработке — сверх обычного правила не забывай про flush.

pthread_cancel реализует похожий механизм на основе сигналов и флагов. Однако он не интегрируется с механизмами отмены на уровне языка (try, defer), что делает очистку после отмены громоздкой и медленной. В более широком смысле, значительная часть трудностей вокруг конкурентности объясняется тем, что она находится ровно в серой зоне между ядром, рантаймом и языком. На уровне процессора конкурентности почти нет (не считая прерываний) — это иллюзия со смешанным авторством. Язык обычно лучше приспособлен для решения этой проблемы, но традиционно она отдаётся на откуп ядру и libc, что негативно сказывается на дизайне языков.

Ещё одна проблема pthread_cancel в том, что он полностью уничтожает поток целиком, что было бы нормально, если бы потоки были дешёвыми. Но создание потоков всё ещё медленная операция, а системный лимит на количество потоков обычно невелик, поэтому обычно разумно использовать пул потоков ОС. Механизм Io в Zig изящно решает эту проблему, разделяя на уровне интерфейса понятия «может выполняться конкурентно» и «должно выполняться конкурентно»:

https://kristoff.it/blog/asynchrony-is-not-concurrency/

Это достигает эффекта, похожего на std::launch policy (пункт 36 в Effective Modern C++, если книга под рукой). Явно называя происходящее (io.async против io.concurrent), Zig упрощает понимание того, что реально происходит, а заодно даёт более точные сигнатуры (concurrent всегда может завершиться с ошибкой, async — никогда). Разумеется, за concurrent стоит пул потоков, и создание нового потока происходит только тогда, когда пул исчерпан.