Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Потокобезопасность, гонки и взаимоблокировки
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Потокобезопасность, гонки и взаимоблокировки
В прошлом уроке потоки наконец заработали на нас: пул держал сервер под нагрузкой, а
psumс локальной суммой ускорился почти линейно. Там жеzboxотказался запускать задачу в потоке пула и завёл на неё отдельный процесс, потому что егоrun()«небезопасна для потоков». Сегодня разберём, что это значит. Какие функции ломаются, когда их зовут двое, почему функция без единой глобальной переменной может оказаться небезопасной, чем гонка отличается от гонки данных и почему программа с атомиками вместо переменных всё равно теряет деньги. А в конце два потока встанут намертво, и мы научимся не пускать их в эту точку, а если уж пустили, то находить её до того, как она случится у пользователя.
Цели урока
- Различать четыре класса потоконебезопасных функций и для каждого знать лекарство и его цену.
- Объяснить, почему реентерабельность строже потокобезопасности, и показать функцию, которая безопасна для потоков, но не для обработчика сигнала.
- Написать обёртку «блокировка с копированием» над функцией, которая возвращает указатель на статическую память, и назвать три её ограничения.
- Отличать гонку (корректность зависит от порядка) от гонки данных (два доступа без порядка, один из них запись), и знать, почему атомики лечат только второе.
- Найти гонку данных и нарушение порядка захвата через ThreadSanitizer и прочитать его отчёт.
- Увидеть взаимоблокировку на графе выполнения и в графе ожидания, и устранить её правилом общего порядка захвата.
Идея: функцию позвали двое
До сих пор вопрос «правильна ли функция» имел один ответ на все случаи: дали ей аргументы, получили результат. С потоками появляется второй вопрос: правильна ли она, если в ту же секунду её зовёт кто-то ещё. Книга называет функцию потокобезопасной, если ответ «да» при любом числе одновременных вызовов, и потоконебезопасной, если хоть при каком-то перемешивании ответ «нет».
Слово «при каком-то» здесь главное. В уроке про семафоры мы видели это на графе выполнения badcnt: большинство траекторий обходит небезопасную зону, и счётчик выходит верным. Программа проходит тесты, проходит ревью, а потом на машине с другим числом ядер теряет половину прибавлений. Правило оттуда же: конкурентная программа обязана работать при любой траектории, а не при тех, которые нам попались.
Всё, что ломается в такой программе, укладывается в три беды. Функции, которые не рассчитаны на двоих. Гонки, где результат зависит от того, кто успел первым. Взаимоблокировки, где никто не успевает никогда. Разберём их по очереди, а в конце соберём всё в шаге проекта TINY.
Четыре класса небезопасных функций
Книга делит небезопасные функции на четыре класса. Классы пересекаются, одна функция может попасть в два сразу, но у каждого своё лекарство, поэтому различать их полезно. Все примеры ниже живут в одном файле эталона, src/conc/unsafe.zig, его целиком мы соберём в шаге проекта.
Класс 1: общие данные без защиты
Самый знакомый. Функция трогает глобальную переменную (или любую память, до которой дотягиваются два потока) и не защищает её:
var hits: u64 = 0;
pub fn bumpUnsafe() void {
const p: *volatile u64 = &hits;
p.* += 1;
}
Это badcnt из урока про семафоры в виде функции: += 1 это загрузка, сложение и сохранение, и два потока, перемешав эти инструкции, теряют прибавления. volatile здесь ровно для того, для чего и там: не дать компилятору сложить цикл вызовов в одно сложение. Неделимым он доступ не делает.
Лекарство самое дешёвое по правкам: защитить переменную внутри функции, а сигнатуру не трогать.
var hits_mutex: Io.Mutex = .init;
pub fn bumpSafe(io: Io) void {
hits_mutex.lockUncancelable(io);
defer hits_mutex.unlock(io);
hits += 1;
}
Сигнатура почти не изменилась: в Zig 0.16 мьютексу нужен io, и он появился в параметрах. В C вызывающие не заметили бы вообще ничего. Цена в другом: каждый вызов теперь берёт и отпускает мьютекс, и если функцию зовут часто, потоки выстраиваются к ней в очередь. Как это выглядит в числах, показал psum-mutex в прошлом уроке: с мьютексом на каждой итерации четыре потока считали медленнее одного.
У нас в проекте есть пример поинтереснее, и на нём видно, что лечение мьютексом не всегда годится. run() из zbox держит своё состояние в глобальных переменных сторожа из урока про сигналы: группа процессов программы, крайний срок, флаги «ребёнок вышел» и «сработал будильник».
var child_exited: std.atomic.Value(bool) = .init(false);
var alarm_fired: std.atomic.Value(bool) = .init(false);
var group: std.atomic.Value(c.pid_t) = .init(0);
var deadline_ms: std.atomic.Value(u64) = .init(0);
Все они атомарные, так что гонки данных здесь нет: каждое чтение и каждая запись неделимы. И всё равно две задачи в двух потоках сломают друг друга. Вторая run() запишет в group свою группу поверх первой, и будильник первой задачи убьёт чужую программу. Хуже того, часть состояния вообще не переменные: таймер setitimer, обработчики и маска сигналов общие на весь процесс, и SIGCHLD от одного ребёнка приходит в процесс, а не в поток, который этого ребёнка ждёт. Мьютекс вокруг всей run() сделал бы её безопасной, но пустил бы к песочнице одну задачу за раз, и пул из прошлого урока стал бы бессмысленным. Поэтому zbox выбрал другое лекарство: задача это отдельный процесс zbox job, у которого свои глобальные переменные, свой таймер и свои сигналы. Процесс это самая грубая, но и самая надёжная изоляция общего состояния.
Класс 2: состояние между вызовами
Генератор псевдослучайных чисел из стандарта C:
var next_seed: u32 = 1;
pub fn rand() u15 {
next_seed = next_seed *% 1103515245 +% 12345;
return @truncate(next_seed / 65536 % 32768);
}
pub fn srand(seed: u32) void {
next_seed = seed;
}
От одного зерна один поток всегда получает одну и ту же последовательность, на этом держатся воспроизводимые тесты и симуляции. Два потока тянут числа из общей последовательности вперемешку, и ни один не получает своей. Можно обернуть rand мьютексом, как в классе 1, и гонка данных пропадёт, а беда останется: каждый поток по-прежнему получает случайное подмножество чужой последовательности. Защищать тут нечего, сломан сам контракт функции.
Единственное лекарство: вынести состояние наружу, к вызывающему.
pub fn randR(seed: *u32) u15 {
seed.* = seed.* *% 1103515245 +% 12345;
return @truncate(seed.* / 65536 % 32768);
}
Формула та же, только зерно теперь лежит у того, кто зовёт. Два потока с двумя зёрнами не мешают друг другу, а один поток с тем же зерном получит ровно ту же последовательность, что rand после srand: это проверяет тест шага. В libc такая функция называется rand_r, суффикс _r означает «реентерабельная», к этому слову вернёмся через раздел. Цена лечения самая высокая из четырёх классов: меняется сигнатура, а значит, каждое место вызова. В программе, где rand зовут из двухсот мест, это двести правок.
Есть промежуточный вариант, которого в книге нет: объявить зерно как threadlocal var next_seed. Тогда у каждого потока своя копия, и rand становится потокобезопасной без правок у вызывающих. Мы уже встречали локальные переменные потока в уроке про семафоры, когда машина zl перестала отдавать ошибку одного потока другому. Но контракт меняется: каждый рабочий поток начинает со своей копии начального зерна, и srand из главного потока в рабочих ничего не меняет. Это другое обещание вызывающим, и выбрать его должен автор программы, а не библиотеки.
Стандартная библиотека Zig устроена так, чтобы класса 2 в ней не было вовсе. Генератор std.Random.DefaultPrng это значение, которое ты создаёшь и держишь сам. Аллокатор приходит параметром, std.Io приходит параметром. Спрятанного глобального состояния, которое переживает вызов, почти нет, и это то самое «вынести состояние к вызывающему», возведённое в правило языка.
Класс 3: указатель на статическую память
Функция складывает результат в свой статический буфер и отдаёт на него указатель:
var ip_buf: [16]u8 = undefined;
pub fn formatIp(addr: [4]u8) []const u8 {
return std.fmt.bufPrint(&ip_buf, "{d}.{d}.{d}.{d}", .{ addr[0], addr[1], addr[2], addr[3] }) catch unreachable;
}
Беда видна даже без потоков: второй вызов переписывает ответ первого. Тест шага ловит это в одном потоке. После formatIp(.{ 192, 168, 1, 254 }) срез first, который смотрел на 10.0.0.1, показывает 192.168.: те же восемь байт буфера, только в них уже чужая строка. С потоками результат одного потока может переписаться посреди того, как другой его читает.
Так устроены старые функции libc: inet_ntoa, ctime, localtime, gethostbyname. И libc разных систем чинили их по-разному. Посмотрим на inet_ntoa на двух системах:
//! inet_ntoa отдаёт указатель на свой буфер. Сколько таких буферов и чьи
//! они, решает libc, а не вызывающий.
const std = @import("std");
const in_addr = extern struct { s_addr: u32 };
extern "c" fn inet_ntoa(in: in_addr) [*:0]const u8;
/// Байты адреса в памяти уже идут в сетевом порядке, как требует `in_addr`.
fn addr(bytes: [4]u8) in_addr {
return .{ .s_addr = @bitCast(bytes) };
}
fn inThread(out: *[*:0]const u8) void {
out.* = inet_ntoa(addr(.{ 192, 168, 0, 1 }));
}
pub fn main() !void {
const first = inet_ntoa(addr(.{ 127, 0, 0, 1 }));
std.debug.print("первый вызов: {s}\n", .{first});
const second = inet_ntoa(addr(.{ 10, 0, 0, 2 }));
std.debug.print("второй вызов: {s}, а первый теперь {s}\n", .{ second, first });
var other: [*:0]const u8 = undefined;
const t = try std.Thread.spawn(.{}, inThread, .{&other});
t.join();
std.debug.print("вызов в другом потоке: {s}, а первый теперь {s}\n", .{ other, first });
std.debug.print("буфер у потоков {s}\n", .{if (other == first) "общий" else "свой"});
}
$ zig run ntoa.zig -lc # macOS 26.6.2, Apple M4 Max
первый вызов: 127.0.0.1
второй вызов: 10.0.0.2, а первый теперь 10.0.0.2
вызов в другом потоке: 192.168.0.1, а первый теперь 192.168.0.1
буфер у потоков общий
$ zig run ntoa.zig -lc # Debian 12, glibc 2.36, linux/arm64
первый вызов: 127.0.0.1
второй вызов: 10.0.0.2, а первый теперь 10.0.0.2
вызов в другом потоке: 192.168.0.1, а первый теперь 10.0.0.2
буфер у потоков свой
В обеих системах второй вызов в том же потоке затирает первый: это контракт функции, и никакая libc его не отменит. А дальше пути расходятся. В macOS буфер один на процесс, и вызов в другом потоке переписал строку главного. glibc завела буфер на каждый поток (тот самый threadlocal), и чужой поток до нашей строки не дотянулся. Одна и та же функция на одной системе потокобезопасна, на другой нет, и узнать это можно только из документации конкретной libc. glibc помечает такие свойства у каждой функции словами MT-Safe и MT-Unsafe, а что значат эти пометки, объясняет man 7 attributes.
Лекарств два. Первое: переписать функцию так, чтобы буфер давал вызывающий. Именно так устроены замены из той же libc: inet_ntop принимает буфер, getaddrinfo из урока про адреса выделяет результат для каждого вызова отдельно, а в Zig std.fmt.bufPrint вообще не бывает без чужого буфера. Цена та же, что в классе 2: правки у каждого вызывающего. Второе лекарство не трогает саму функцию: блокировка с копированием. Ему отдельный раздел ниже.
Класс 4: зовёт небезопасную функцию
pub fn rollDice() u8 {
return @intCast(rand() % 6 + 1);
}
Своих глобальных переменных у rollDice нет, а небезопасна она всё равно: беду она наследует от rand. Можно ли такую функцию починить, не трогая вызываемую, зависит от класса той. Если внутри класс 1 или 3, помогает мьютекс вокруг вызова и копия результата: функция-обёртка сама станет безопасной, пусть и ценой очереди. Если внутри класс 2, не поможет ничего, кроме переписывания: rollDiceR зовёт randR и получает зерно от своего вызывающего.
pub fn rollDiceR(seed: *u32) u8 {
return @intCast(randR(seed) % 6 + 1);
}
Класс 4 объясняет, почему «моя функция не трогает глобальных переменных» ещё ничего не доказывает. Потокобезопасность наследуется по дереву вызовов, и одна небезопасная функция в самом низу делает небезопасным всё, что над ней. run() из zbox небезопасна по классу 1, и любая функция, которая её зовёт, например старый обработчик POST /run из урока про TINY, автоматически попадает в класс 4.
Сводка по libc
Большинство функций libc, включая malloc, free и printf, потокобезопасны. Исключения известны наперечёт, полный список обязательных по POSIX лежит в man 7 pthreads. Для самых частых есть замены:
| Функция | Класс | Что не так | Замена |
|---|---|---|---|
rand | 2 | общее зерно | rand_r или свой генератор со своим состоянием |
strtok | 2 | помнит позицию в строке между вызовами | strtok_r, в Zig std.mem.tokenizeScalar |
ctime, asctime, localtime | 3 | статический буфер или структура | ctime_r, asctime_r, localtime_r |
gethostbyname, gethostbyaddr | 3 | статическая hostent | getaddrinfo, getnameinfo |
inet_ntoa | 3 | статический буфер | inet_ntop с буфером вызывающего |
Суффикс _r везде значит одно: состояние или буфер переехали в параметры. Функции без замены, как inet_ntoa в книжной таблице, лечатся блокировкой с копированием. Итератор tokenizeScalar в Zig это strtok_r, доведённый до конца: состояние разбора живёт в значении, которое ты держишь в своей переменной, и двух разборов, мешающих друг другу, не бывает по построению.
Реентерабельность строже потокобезопасности
Есть особый подкласс потокобезопасных функций. Реентерабельная функция не трогает никаких общих данных вовсе: всё, с чем она работает, пришло в аргументах или лежит в её кадре стека. Такую функцию можно звать из любого потока, из обработчика сигнала, поверх её же незаконченного вызова, и ничего не сломается, потому что ломать нечего.
Отношение между множествами такое: все реентерабельные функции потокобезопасны, но не наоборот.
| Реентерабельная | Потокобезопасная, не реентерабельная | Потоконебезопасная | |
|---|---|---|---|
| Общие данные | нет | есть, под защитой | есть, без защиты |
| Пример | randR, rollDiceR | bumpSafe, gethostbynameTs | rand, formatIp, bumpUnsafe |
| Синхронизация | не нужна | мьютекс на каждый вызов | нет, и в этом беда |
| Обработчик сигнала | можно | нельзя | нельзя |
Строка про синхронизацию объясняет, почему реентерабельные функции обычно ещё и быстрее: им не нужно ни брать мьютекс, ни стоять в очереди за ним. Строка про обработчик сигнала объясняет, почему «потокобезопасная» не значит «безопасная везде». Вспомни урок про сигналы: обработчик прерывает поток посреди любой инструкции. Если поток в этот момент был внутри bumpSafe и держал hits_mutex, а обработчик тоже зовёт bumpSafe, то обработчик встанет на lock и будет ждать мьютекс, который держит… этот же самый поток, прерванный обработчиком. Поток не продолжится, пока обработчик не вернётся, обработчик не вернётся, пока поток не отпустит мьютекс. Это взаимоблокировка одного потока с самим собой. Io.Mutex не рекурсивный, как и большинство мьютексов, и второй захват в том же потоке виснет навсегда. Поэтому malloc и printf, потокобезопасные благодаря внутренним блокировкам, в список async-signal-safe не входят, а сторож zbox пишет из обработчика только атомарные флаги.
Как по коду понять, реентерабельна ли функция? Если все аргументы переданы по значению, а работает она только с локальными переменными, то да, при любом способе вызова. Книга называет такую функцию явно реентерабельной. Если часть аргументов это указатели, всё зависит от вызывающих: randR реентерабельна, пока каждый поток передаёт указатель на своё зерно, и перестаёт быть ею, как только два потока передадут указатель на одно и то же. Это неявная реентерабельность, и это свойство не функции, а пары «функция и тот, кто её зовёт».
Блокировка с копированием
Вернёмся к классу 3. gethostbyname устарела, её заменил getaddrinfo, но старого кода, который её зовёт, много, и переписать его весь сразу нельзя. Книга предлагает обёртку: взять мьютекс, вызвать небезопасную функцию, скопировать результат в память вызывающего, отпустить мьютекс. Эталон делает ровно это:
var gethost_mutex: Io.Mutex = .init;
pub fn gethostbynameTs(io: Io, name: []const u8, out: *Host) !void {
var name_z: [256]u8 = undefined;
const z = std.fmt.bufPrintZ(&name_z, "{s}", .{name}) catch return error.NameTooLong;
gethost_mutex.lockUncancelable(io);
defer gethost_mutex.unlock(io);
const h = gethostbyname(z.ptr) orelse return error.LookupFailed;
if (h.h_addrtype != c.AF.INET or h.h_length != 4) return error.LookupFailed;
const hname = std.mem.span(h.h_name orelse return error.LookupFailed);
if (hname.len > out.name_buf.len) return error.NameTooLong;
@memcpy(out.name_buf[0..hname.len], hname);
out.name_len = hname.len;
out.addr_count = 0;
const list = h.h_addr_list orelse return;
var i: usize = 0;
while (list[i]) |addr| : (i += 1) {
if (out.addr_count == out.addrs.len) break;
out.addrs[out.addr_count] = addr[0..4].*;
out.addr_count += 1;
}
}
Три места здесь важнее остальных.
Копия глубокая. struct hostent это не данные, а дерево указателей: h_name указывает на строку, h_addr_list на массив указателей, каждый из которых указывает на четыре байта адреса. Всё это лежит в статической памяти libc. Если скопировать только саму структуру (out.* = h.*), вызывающий получит свою копию указателей на те же статические байты, и следующий gethostbyname из любого потока перепишет их у него под ногами. Копировать надо всё, до чего вызывающий дотянется по указателям: байты имени и каждый адрес. У нас Host хранит их в своих массивах фиксированного размера, поэтому обёртке не нужен аллокатор. Книга выделяет память под копию через malloc, и тогда освобождать её обязан вызывающий.
Копия внутри мьютекса. defer gethost_mutex.unlock(io) срабатывает на выходе из функции, то есть после копии. Если отпустить мьютекс до копии, между unlock и @memcpy соседний поток успеет войти, позвать gethostbyname и переписать статическую hostent, и мы скопируем чужой ответ. Копия после unlock выглядит почти как правильная и почти всегда работает, поэтому это самая опасная ошибка в таком коде.
Мьютекс защищает только своих. gethost_mutex знает о вызовах через gethostbynameTs и ничего не знает о прямых вызовах gethostbyname где-то ещё в программе. Одного такого вызова (в чужой библиотеке, в забытом углу своего кода) достаточно, чтобы переписать hostent посреди нашей копии. Блокировка с копированием работает, только если все вызывающие ходят через обёртку.
Итого у приёма три ограничения. Мьютекс превращает параллельные вызовы в очередь. Глубокая копия вложенных структур длинная и легко выходит неполной. И к классу 2 приём неприменим вовсе: у rand нет результата, который можно скопировать, беда в состоянии, которое переживает вызов. Поэтому совет книги простой: если есть версия с суффиксом _r, бери её, а блокировку с копированием оставь для функций, у которых замены нет.
Ещё одна деталь gethostbynameTs. Мьютекс берётся через lockUncancelable, а не через lock. В Zig 0.16 Io.Mutex.lock(io) это точка отмены: если задачу, внутри которой идёт ожидание, отменят, lock вернёт error.Canceled, поэтому его зовут через try. Обёртка вызывается из обычных потоков std.Thread, отменять их некому, и эталон выбирает вариант без отмены. Отмену мы разберём в следующем уроке. Код-задача этого урока, наоборот, берёт try lookup_mutex.lock(io): там ошибка отмены входит в множество ошибок функции.
Гонки
Гонка и гонка данных
Книга определяет гонку через порядок: программа верна, только если поток A дойдёт до точки x раньше, чем поток B дойдёт до точки y. Классический пример мы разобрали в уроке про потоки: главный поток передаёт каждому новому потоку адрес переменной цикла, и результат зависит от того, успеет ли поток прочитать её раньше, чем цикл её увеличит. Лечение там было в том, чтобы у каждого потока был свой экземпляр данных: std.Thread.spawn копирует кортеж аргументов, и индекс, переданный по значению, никто уже не изменит.
Современные модели памяти (C11, C++11, Zig, Rust) вводят рядом второе, более узкое понятие. Гонка данных это два обращения к одной ячейке памяти из разных потоков, хотя бы одно из них запись, и между ними нет порядка «происходит до», который дали бы мьютекс, семафор, атомарная операция с release и acquire, spawn или join. Если хотя бы одно из обращений неатомарное, это не просто ошибка, а неопределённое поведение: компилятор вправе считать, что его нет, и оптимизировать код соответственно. badcnt это гонка данных. Счётчик с fetchAdd уже нет.
Эти два понятия пересекаются, но не совпадают. Бывает гонка данных, которая не меняет результата (два потока пишут в флаг одно и то же значение). И бывает гонка без единой гонки данных, когда каждое обращение атомарно, а программа всё равно неверна. Вот вторая:
//! Гонка без гонки данных: каждый доступ к балансу атомарный, TSan молчит,
//! а деньги всё равно уходят в минус. Проверка и действие по отдельности
//! неделимы, вместе нет.
const std = @import("std");
const Balance = std.atomic.Value(i64);
/// Барьер на два потока: оба прошли проверку, только потом списывают.
/// В жизни это окно открывает планировщик, здесь мы открываем его сами.
const Gate = std.atomic.Value(u32);
fn withdrawBroken(balance: *Balance, amount: i64, gate: *Gate, ok: *bool) void {
if (balance.load(.seq_cst) < amount) return;
_ = gate.fetchAdd(1, .seq_cst);
while (gate.load(.seq_cst) < 2) std.Thread.yield() catch {};
_ = balance.fetchSub(amount, .seq_cst);
ok.* = true;
}
/// Проверка и списание одной операцией: cmpxchg пишет, только если баланс
/// всё ещё тот, который мы проверили.
fn withdraw(balance: *Balance, amount: i64) bool {
var seen = balance.load(.seq_cst);
while (seen >= amount) {
seen = balance.cmpxchgWeak(seen, seen - amount, .seq_cst, .seq_cst) orelse return true;
}
return false;
}
test "проверить, потом списать: оба потока проходят проверку" {
var balance: Balance = .init(100);
var gate: Gate = .init(0);
var ok: [2]bool = .{ false, false };
const t0 = try std.Thread.spawn(.{}, withdrawBroken, .{ &balance, 100, &gate, &ok[0] });
const t1 = try std.Thread.spawn(.{}, withdrawBroken, .{ &balance, 100, &gate, &ok[1] });
t0.join();
t1.join();
try std.testing.expect(ok[0] and ok[1]);
try std.testing.expectEqual(@as(i64, -100), balance.load(.seq_cst));
}
fn tryWithdraw(balance: *Balance, won: *std.atomic.Value(u32)) void {
if (withdraw(balance, 100)) _ = won.fetchAdd(1, .seq_cst);
}
test "cmpxchg: из восьми потоков списать сотню удаётся ровно одному" {
var balance: Balance = .init(100);
var won: std.atomic.Value(u32) = .init(0);
var threads: [8]std.Thread = undefined;
for (&threads) |*t| t.* = try std.Thread.spawn(.{}, tryWithdraw, .{ &balance, &won });
for (threads) |t| t.join();
try std.testing.expectEqual(@as(u32, 1), won.load(.seq_cst));
try std.testing.expectEqual(@as(i64, 0), balance.load(.seq_cst));
}
withdrawBroken делает то, что пишут все: проверил, что денег хватает, списал. Проверка атомарна, списание атомарно, а между ними окно, в которое проходит второй поток со своей проверкой. Барьер gate в тесте держит это окно открытым нарочно, чтобы тест падал не «иногда под нагрузкой», а всегда. Два потока видят баланс 100, оба списывают по 100, баланс уходит в минус сто. Этот шаблон называется «проверить, потом действовать», и он же сидит в run() из zbox: атомарные флаги сторожа не спасают, когда смысл имеет не отдельная переменная, а их согласованное состояние.
Лечение: сделать проверку и действие одной неделимой операцией. Здесь это cmpxchgWeak, сравнение с обменом из урока про атомики в Rust: записать новый баланс, только если он всё ещё равен тому, который мы проверили, иначе взять свежее значение и проверить снова. В общем случае, когда операция длиннее одной переменной, проверка и действие просто идут под одним мьютексом.
$ zig test check_then_act.zig
1/2 check_then_act.test.проверить, потом списать: оба потока проходят проверку...OK
2/2 check_then_act.test.cmpxchg: из восьми потоков списать сотню удаётся ровно одному...OK
All 2 tests passed.
Первый тест проверяет, что сломанная версия действительно ломается, второй, что исправленная нет. Эту разницу между гонкой и гонкой данных стоит держать в голове, когда дойдём до инструмента: он ищет только гонки данных.
Раздел 05-async разбирает те же гонки со стороны прикладного кода на JavaScript, где потоков нет, а гонки есть: между двумя await может выполниться чужой обработчик, и «проверить, потом действовать» ломается точно так же. А Rust из урока про Send и Sync запрещает гонки данных на этапе компиляции, но гонки из этого раздела и взаимоблокировки не запрещает никто.
ThreadSanitizer
Гонку данных трудно поймать тестом: она проявляется на одной траектории из тысяч. ThreadSanitizer (TSan) ловит не неверный результат, а само условие гонки данных. Компилятор вставляет перед каждым чтением и записью вызов рантайма. Рантайм хранит для каждых восьми байт памяти несколько последних обращений: какой поток, чтение или запись, в какой момент по его часам. А для каждого потока и каждого объекта синхронизации ведёт векторные часы, по которым видно, какие события одного потока «происходят до» событий другого. Взятие мьютекса, атомарная операция с acquire, spawn и join передают часы от потока к потоку. Если новое обращение конфликтует с запомненным (другой поток, хотя бы одна запись) и порядка между ними нет, это гонка данных, и рантайм печатает отчёт с обоими стеками.
Главное следствие: TSan находит гонку, даже если на этом прогоне она ничего не испортила. Ему не нужно, чтобы прибавление потерялось, ему достаточно двух доступов без порядка. Цена по документации clang: замедление в пять до пятнадцати раз и памяти в пять до десяти раз больше. Для тестов терпимо, для продакшена нет.
В Zig TSan включается флагом -fsanitize-thread, в build.zig полем .sanitize_thread модуля. В шаге проекта мы добавим -Dtsan в TINY. Вот что он говорит про badcnt на Linux (контейнер linux/arm64 на Apple M4 Max, вывод из README эталона, ... это сокращения):
$ zig build -Dtsan && zig-out/bin/tiny badcnt 100000
badcnt cnt=200000 expected=200000 OK
goodcnt (sem) cnt=200000 expected=200000 OK
mutexcnt cnt=200000 expected=200000 OK
atomiccnt cnt=200000 expected=200000 OK
==================
WARNING: ThreadSanitizer: data race (pid=417)
Read of size 8 at 0xffffc7044c08 by thread T2:
#0 <null> <null> (tiny+0x253c58)
#1 <null> <null> (tiny+0x253a3c)
...
Previous write of size 8 at 0xffffc7044c08 by thread T1:
#0 <null> <null> (tiny+0x253c88)
...
Location is stack of main thread.
...
ThreadSanitizer: reported 2 warnings
$ echo $?
66
Посмотри на первую строку: badcnt под TSan посчитал верно. Инструментированный код в разы медленнее, и потоки перемешиваются по-другому, так что на этом прогоне ни одно прибавление не потерялось. А отчёт всё равно есть, потому что TSan смотрит не на результат. Два предупреждения, чтение и запись cnt из двух потоков, оба про badcnt. Семафор, мьютекс и атомик TSan принимает молча. Код выхода 66 это соглашение TSan: программа отработала, но нашлись гонки, так что тест с таким бинарником в CI упадёт.
Вместо имён функций в отчёте <null>: TSan расшифровывает адреса внешней программой llvm-symbolizer, а в образе с Zig её нет. Бинарник можно вынести на хост и расшифровать там:
$ llvm-symbolizer --obj=tiny 0x253c58 0x253c88 0x253a3c
conc.badcnt.countBad
/tmp/p/src/conc/badcnt.zig:31:25
conc.badcnt.countBad
/tmp/p/src/conc/badcnt.zig:31:31
Thread.callFn__anon_44644
/opt/zig/lib/std/Thread.zig:422:13
Строка 31 файла badcnt.zig это for (0..niters) |_| cnt.* += 1;, колонки 25 и 31 это чтение и запись cnt. «Location is stack of main thread» тоже верно: cnt это локальная переменная badcnt(), и потоки получили указатель на неё.
А пример check_then_act.zig под TSan проходит без единого предупреждения: там все обращения атомарные, гонок данных нет, а гонка есть. Это граница инструмента, и её полезно проверить самому:
$ zig test -lc -fsanitize-thread check_then_act.zig # linux/arm64
1/2 check_then_act.test.проверить, потом списать: оба потока проходят проверку...OK
2/2 check_then_act.test.cmpxchg: из восьми потоков списать сотню удаётся ровно одному...OK
All 2 tests passed.
И второе ограничение: TSan динамический, он видит только тот код, который выполнился. Гонка в ветке обработки ошибки, которая на тестах не сработала, останется незамеченной. Поэтому TSan дополняет рассуждение о коде, а не заменяет его.
Взаимоблокировка
Семафоры и мьютексы лечат гонки, но приносят свою беду. Взаимоблокировка это когда потоки ждут условия, которое никогда не наступит. Самый частый случай: два потока, два мьютекса, первый поток держит s и ждёт t, второй держит t и ждёт s.
На графе выполнения
Граф выполнения из урока про семафоры показывает взаимоблокировку буквально. Поток 1 делает P(s) P(t) V(t) V(s), поток 2 делает P(t) P(s) V(s) V(t). У каждого мьютекса своя запретная зона: прямоугольник, где оба потока держали бы его одновременно. У мьютекса s зона между P(s) и V(s) по обеим осям, у t между P(t) и V(t). Из-за того, что потоки берут мьютексы в разном порядке, зоны легли крест-накрест и между ними осталась точка, из которой нельзя сделать ни шага: шаг вправо ведёт в зону t, шаг вверх в зону s. Это состояние взаимоблокировки. Все точки, из которых любая траектория неизбежно придёт в него, образуют зону взаимоблокировки: войти в неё можно, выйти нельзя.
И вот что делает эту ошибку особенно неприятной: большинство траекторий обходит зону. Если поток 1 успел взять оба мьютекса до того, как поток 2 взял первый, всё проходит. Программа может отработать тысячу раз и повиснуть на тысяча первый, работать на ноутбуке и виснуть на сервере с другим числом ядер. И воспроизвести зависание по заказу почти невозможно.
Виджет: порядок захвата
Виджет ниже собран на том же движке, что граф выполнения в уроке про семафоры. Сверху программы потоков: каждый берёт свои мьютексы по порядку и отпускает в обратном, как это делает defer в Zig. Кнопка «шаг» у потока продвигает его на одну операцию и гаснет, если поток стоит на P занятого мьютекса. Для двух потоков ниже рисуется граф выполнения: серые запретные зоны s и t, жёлтые клетки зоны взаимоблокировки и красный крест в тупике; траекторию можно вести и мышью по сетке, и стрелками. Справа граф «кто ждёт кого»: стрелка от потока к потоку, который держит нужный ему мьютекс, с именем мьютекса. Рёбра цикла красные.
Начни с «довести до тупика»: виджет сам проведёт кратчайшую траекторию в точку, где оба потока ждут, и под графом появится цикл T1 → T2 → T1. Потом нажми «сначала» и пройди до конца руками, обходя крест. А затем нажми «поменять порядок» у второго потока: зоны перестанут пересекаться крест-накрест, жёлтых клеток не останется, и строка внизу скажет, что циклов нет.
В подзаголовке виджета три числа для набора «два потока»: состояний: 19 · тупиков: 1 · обречённых: 1. В книжном примере зона взаимоблокировки это целый прямоугольник, потому что между двумя P у потоков стоят другие инструкции. Здесь между ними ничего нет, и зона сжимается в одну точку, сам тупик. Переключи набор на «три потока по кругу»: там потоки берут a, b, b, c и c, a. Граф выполнения стал трёхмерным, и виджет его не рисует, зато граф ожидания показывает то же самое: тупик в точке, где каждый поток взял свой первый мьютекс, и цикл из трёх стрелок.
Правило порядка захвата
Теперь видно, откуда берётся лечение. Взаимоблокировка на мьютексах это цикл в графе ожидания: T1 ждёт T2, T2 ждёт T3, …, Tn ждёт T1. Представь, что все мьютексы программы пронумерованы, и каждый поток берёт их строго по возрастанию номеров. Каждый поток в цикле ждёт мьютекс с номером больше, чем любой из тех, что он держит, а держатель этого мьютекса ждёт мьютекс с номером ещё больше. Номера вдоль цикла только растут, а цикл возвращается в начало. Противоречие, значит, цикла не бывает. Отсюда правило из книги:
Если все потоки берут мьютексы в одном общем порядке, взаимоблокировки на этих мьютексах не бывает.
Строка внизу виджета проверяет именно это: она строит граф порядка захвата, ребро s → t значит «кто-то берёт t, держа s», и ищет в нём цикл. Цикла нет, значит, тупик невозможен при любой траектории. Кнопка «упорядочить захват» переставляет мьютексы у всех потоков по алфавиту, и тупиков становится ноль в обоих наборах.
Книга формулирует правило чуть строже: брать в одном порядке и отпускать в обратном. Для отсутствия взаимоблокировки важен только порядок захвата, а отпускание в обратном порядке это привычка, которую defer в Zig даёт бесплатно. Проверить это самому предлагает одно из упражнений.
Откуда брать общий порядок? Если мьютексы статические, порядок пишут в комментарии и соблюдают на ревью. Если это поля объектов, которых много и которые создаются на ходу (счета в банке, узлы дерева, соединения), удобно брать порядок адресов в памяти: он общий для всей программы и известен в момент захвата.
//! Правило порядка захвата без глобального списка мьютексов: два мьютекса
//! всегда берутся в порядке адресов. Перевод со счёта на счёт в обе стороны
//! сразу больше не виснет.
const std = @import("std");
const Io = std.Io;
const Account = struct {
mutex: Io.Mutex = .init,
balance: i64,
};
/// Два счёта в порядке адресов. Какой из них откуда, не важно: порядок
/// захвата один на всю программу.
fn ordered(a: *Account, b: *Account) [2]*Account {
return if (@intFromPtr(a) < @intFromPtr(b)) .{ a, b } else .{ b, a };
}
fn transfer(io: Io, from: *Account, to: *Account, amount: i64) void {
const pair = ordered(from, to);
pair[0].mutex.lockUncancelable(io);
defer pair[0].mutex.unlock(io);
pair[1].mutex.lockUncancelable(io);
defer pair[1].mutex.unlock(io);
from.balance -= amount;
to.balance += amount;
}
fn shuttle(io: Io, from: *Account, to: *Account, times: usize) void {
for (0..times) |_| transfer(io, from, to, 1);
}
test "переводы навстречу друг другу не виснут и не теряют денег" {
const io = std.testing.io;
var alice: Account = .{ .balance = 1000 };
var bob: Account = .{ .balance = 1000 };
const t0 = try std.Thread.spawn(.{}, shuttle, .{ io, &alice, &bob, 100_000 });
const t1 = try std.Thread.spawn(.{}, shuttle, .{ io, &bob, &alice, 100_000 });
t0.join();
t1.join();
try std.testing.expectEqual(@as(i64, 2000), alice.balance + bob.balance);
try std.testing.expectEqual(@as(i64, 1000), alice.balance);
}
Наивный transfer брал бы сначала мьютекс from, потом to. Тогда перевод от Алисы Бобу и от Боба Алисе в ту же секунду это ровно поток 1 и поток 2 из виджета: один держит Алису и ждёт Боба, другой держит Боба и ждёт Алису. С ordered оба потока берут сначала тот счёт, у которого адрес меньше, и сто тысяч встречных переводов в каждую сторону проходят без зависаний. Тест проверяет и деньги: сумма не изменилась, а раз переводов в каждую сторону поровну, у Алисы снова 1000.
TSan видит порядок, а не тупик
Проверить правило порядка тестом трудно по той же причине, по какой трудно поймать гонку: тупик случается на редкой траектории. Но у TSan есть второй детектор, который ищет не тупики, а нарушения порядка. Он строит тот же граф порядка захвата, что строка внизу виджета, по всем захватам, которые видел за прогон, и сообщает о цикле, даже если потоки ни разу не встретились:
//! Два мьютекса libc в разном порядке, но по очереди: программа не виснет,
//! а ThreadSanitizer всё равно видит цикл в графе порядка захвата.
const std = @import("std");
const c = std.c;
var a: c.pthread_mutex_t = c.PTHREAD_MUTEX_INITIALIZER;
var b: c.pthread_mutex_t = c.PTHREAD_MUTEX_INITIALIZER;
fn lockBoth(first: *c.pthread_mutex_t, second: *c.pthread_mutex_t) void {
_ = c.pthread_mutex_lock(first);
_ = c.pthread_mutex_lock(second);
_ = c.pthread_mutex_unlock(second);
_ = c.pthread_mutex_unlock(first);
}
pub fn main() !void {
// Потоки идут строго друг за другом: второй стартует после join первого.
const t1 = try std.Thread.spawn(.{}, lockBoth, .{ &a, &b });
t1.join();
const t2 = try std.Thread.spawn(.{}, lockBoth, .{ &b, &a });
t2.join();
std.debug.print("оба потока дошли до конца\n", .{});
}
Потоки здесь вообще не пересекаются во времени: второй создаётся после того, как первый закончился. Зависнуть эта программа не может. Но первый поток взял b, держа a, а второй взял a, держа b, и в графе порядка захвата появился цикл. Прогон в контейнере с Linux (linux/arm64 на Apple M4 Max), стеки сокращены:
$ zig build-exe lock_order.zig -lc -fsanitize-thread && ./lock_order
==================
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock) (pid=346)
Cycle in lock order graph: M0 (0xaaaab6c8d3c0) => M1 (0xaaaab6c8d390) => M0
Mutex M1 acquired here while holding mutex M0 in thread T1:
#0 <null> <null> (lock_order+0x20b100)
#1 <null> <null> (lock_order+0x1f178c)
...
Mutex M0 acquired here while holding mutex M1 in thread T2:
#0 <null> <null> (lock_order+0x20b100)
#1 <null> <null> (lock_order+0x1f178c)
...
SUMMARY: ThreadSanitizer: lock-order-inversion (potential deadlock) (/tmp/p/lock_order+0x20b100)
==================
оба потока дошли до конца
ThreadSanitizer: reported 1 warnings
$ echo $?
66
llvm-symbolizer на хосте расшифровывает адреса: 0x20b100 это перехватчик pthread_mutex_lock в рантайме TSan, 0x1f178c это строка 12 файла lock_order.zig, второй pthread_mutex_lock в lockBoth. С флагом TSAN_OPTIONS=second_deadlock_stack=1 отчёт добавляет и место, где был взят первый мьютекс пары.
Теперь важная оговорка. Замени в этой программе мьютексы libc на Io.Mutex, и TSan промолчит: прогон в том же контейнере заканчивается кодом 0 без единого предупреждения. Детектор порядка знает мьютексы по перехватчикам функций pthread_mutex_lock и pthread_mutex_unlock, а Io.Mutex в Zig 0.16 это не обёртка над pthreads. Это своя структура из одного атомарного слова с тремя состояниями (свободен, занят, занят и есть ждущие) и futex для сна:
pub fn lockUncancelable(m: *Mutex, io: Io) void {
const initial_state = m.state.cmpxchgStrong(.unlocked, .locked_once, .acquire, .monotonic) orelse return;
if (initial_state == .contended) io.futexWaitUncancelable(State, &m.state.raw, .contended);
while (m.state.swap(.contended, .acquire) != .unlocked) {
io.futexWaitUncancelable(State, &m.state.raw, .contended);
}
}
Это сокращённый пересказ lib/std/Io.zig: быстрый путь это одна операция сравнения с обменом, медленный спит на futex, пока слово не станет «свободен». Гонки данных под таким мьютексом TSan видит правильно, потому что атомарные операции с acquire и release дают ему порядок. А в граф порядка захвата такая структура для него не попадает: мьютексом он её не считает. Так что для кода на Io.Mutex правило порядка остаётся на тебе: на ревью, в комментариях и, если хочется автоматики, в своей проверке рангов, которую предлагает домашнее задание.
Когда порядок не выстроить: захват с отказом
Иногда общий порядок ввести нельзя: второй мьютекс становится известен только после того, как первый уже взят (например, узел дерева, найденный под блокировкой родителя). Тогда второй мьютекс берут без ожидания, через tryLock, и при неудаче отпускают всё и начинают сначала. Этот же приём годится, чтобы продемонстрировать тупик в тесте, не повесив тест, и так устроен пример взаимоблокировки в эталоне. Его мы соберём в шаге проекта. Io.Mutex в 0.16 не умеет ждать с таймаутом, поэтому эталон крутит tryLock с паузой в миллисекунду до крайнего срока.
Шаг проекта: небезопасные функции и тупик в TINY
В эталоне TINY шаг 72 не меняет сервер. Он добавляет две учебные части в модуль conc: четыре класса небезопасных функций с исправлениями и воспроизводимую взаимоблокировку двух мьютексов, подкоманду tiny deadlock и сборку всего проекта под ThreadSanitizer.
unsafe.zig
Все четыре класса из первой половины урока в одном файле. Мелкие куски ты уже видел, здесь они целиком, вместе с extern-объявлениями из libc: в std.c Zig 0.16 нет ни rand_r, ни gethostbyname, и файл объявляет их сам. struct hostent одинаковая в glibc и в macOS, поэтому одно объявление годится для обеих систем.
//! Четыре класса потоконебезопасных функций из главы 12, по короткому
//! примеру на каждый, и их исправления.
//!
//! 1. Не защищают общие переменные: `bumpUnsafe` против `bumpSafe`.
//! 2. Хранят состояние между вызовами: `rand` книги против `randR`, у
//! которого состояние передаёт вызывающий. Такая функция реентерабельна:
//! она не трогает ничего общего, и её можно звать откуда угодно.
//! 3. Возвращают указатель на статический буфер: `formatIp` и `gethostbyname`
//! из libc. Лечится блокировкой с копированием, `gethostbynameTs`.
//! 4. Зовут функции класса 2 или 3: `rollDice` зовёт `rand` и наследует
//! его беду; исправление `rollDiceR` зовёт `randR`.
const std = @import("std");
const c = std.c;
const Io = std.Io;
// ---- класс 1 ----
var hits: u64 = 0;
var hits_mutex: Io.Mutex = .init;
/// Небезопасна: `hits += 1` из двух потоков теряет прибавления (см. `badcnt`).
pub fn bumpUnsafe() void {
const p: *volatile u64 = &hits;
p.* += 1;
}
/// Исправление: та же функция под мьютексом. Вызывающие не меняются.
pub fn bumpSafe(io: Io) void {
hits_mutex.lockUncancelable(io);
defer hits_mutex.unlock(io);
hits += 1;
}
pub fn hitsReset() void {
hits = 0;
}
pub fn hitsValue() u64 {
return hits;
}
// ---- класс 2 ----
var next_seed: u32 = 1;
/// `rand` книги (ANSI C): следующее число зависит от `next_seed`, общего для
/// всех потоков. Два потока получат перемешанную последовательность.
pub fn rand() u15 {
next_seed = next_seed *% 1103515245 +% 12345;
return @truncate(next_seed / 65536 % 32768);
}
pub fn srand(seed: u32) void {
next_seed = seed;
}
/// `rand_r`: то же самое, только состояние лежит у вызывающего.
pub fn randR(seed: *u32) u15 {
seed.* = seed.* *% 1103515245 +% 12345;
return @truncate(seed.* / 65536 % 32768);
}
/// `rand_r` из libc: есть и в glibc, и в libSystem macOS.
pub extern "c" fn rand_r(seed: *c_uint) c_int;
// ---- класс 3 ----
var ip_buf: [16]u8 = undefined;
/// Как `inet_ntoa` и `ctime`: результат живёт в одном статическом буфере.
/// Следующий вызов из любого потока его перепишет.
pub fn formatIp(addr: [4]u8) []const u8 {
return std.fmt.bufPrint(&ip_buf, "{d}.{d}.{d}.{d}", .{ addr[0], addr[1], addr[2], addr[3] }) catch unreachable;
}
/// `struct hostent` одинаковый в glibc и в macOS.
pub const hostent = extern struct {
h_name: ?[*:0]u8,
h_aliases: ?[*:null]?[*:0]u8,
h_addrtype: c_int,
h_length: c_int,
h_addr_list: ?[*:null]?[*]u8,
};
/// Устарела, но есть везде: возвращает указатель на статическую `hostent`.
pub extern "c" fn gethostbyname(name: [*:0]const u8) ?*hostent;
/// Копия ответа `gethostbyname`, которая принадлежит вызывающему.
pub const Host = struct {
name_buf: [256]u8 = undefined,
name_len: usize = 0,
addrs: [8][4]u8 = undefined,
addr_count: usize = 0,
pub fn name(h: *const Host) []const u8 {
return h.name_buf[0..h.name_len];
}
pub fn addresses(h: *const Host) []const [4]u8 {
return h.addrs[0..h.addr_count];
}
};
var gethost_mutex: Io.Mutex = .init;
/// Блокировка с копированием (lock-and-copy): берём мьютекс, зовём
/// небезопасную функцию, глубоко копируем результат к себе, отпускаем.
/// Копировать надо всё, до чего дотянется вызывающий: имя и каждый адрес,
/// а не только указатель на `hostent`, иначе следующий вызов их перепишет.
/// Мьютекс защищает только от тех, кто тоже зовёт через `gethostbynameTs`:
/// прямой вызов `gethostbyname` где-то ещё по-прежнему сломает результат.
pub fn gethostbynameTs(io: Io, name: []const u8, out: *Host) !void {
var name_z: [256]u8 = undefined;
const z = std.fmt.bufPrintZ(&name_z, "{s}", .{name}) catch return error.NameTooLong;
gethost_mutex.lockUncancelable(io);
defer gethost_mutex.unlock(io);
const h = gethostbyname(z.ptr) orelse return error.LookupFailed;
if (h.h_addrtype != c.AF.INET or h.h_length != 4) return error.LookupFailed;
const hname = std.mem.span(h.h_name orelse return error.LookupFailed);
if (hname.len > out.name_buf.len) return error.NameTooLong;
@memcpy(out.name_buf[0..hname.len], hname);
out.name_len = hname.len;
out.addr_count = 0;
const list = h.h_addr_list orelse return;
var i: usize = 0;
while (list[i]) |addr| : (i += 1) {
if (out.addr_count == out.addrs.len) break;
out.addrs[out.addr_count] = addr[0..4].*;
out.addr_count += 1;
}
}
// ---- класс 4 ----
/// Небезопасна, потому что зовёт `rand`.
pub fn rollDice() u8 {
return @intCast(rand() % 6 + 1);
}
/// Безопасна: всё состояние у вызывающего.
pub fn rollDiceR(seed: *u32) u8 {
return @intCast(randR(seed) % 6 + 1);
}
Обрати внимание на типы в hostent. h_addr_list это ?[*:null]?[*]u8: возможно пустой указатель на массив, который кончается нулевым указателем, а элементы массива это указатели на байты адреса без длины. Длину говорит поле h_length, поэтому обёртка сначала проверяет, что это IPv4 и четыре байта, и только потом копирует addr[0..4].*. Цикл while (list[i]) |addr| в Zig это ровно книжное for (p = h_addr_list; *p; p++): пока элемент не нулевой, разворачиваем его.
Host хранит не больше восьми адресов и имя до 256 байт, лишние адреса обёртка отбрасывает. Для учебного TINY этого хватает, а настоящая обёртка сообщила бы об обрезке. rand_r из libc лежит в файле ради теста: у libc свой алгоритм, не тот, что в книге, но тоже с состоянием у вызывающего, и тест проверяет только детерминированность по зерну.
deadlock.zig
Воспроизводимая взаимоблокировка. Трудность в том, что тест с настоящим тупиком повиснет навсегда, а тупик «иногда» не годится для теста. Эталон решает обе задачи: барьер гарантирует, что оба потока взяли по мьютексу, а второй мьютекс берётся через tryLock с крайним сроком.
//! Взаимоблокировка из главы 12: два потока, два мьютекса. Первый берёт
//! `a`, потом `b`; второй `b`, потом `a`. На графе выполнения у такой пары
//! есть запретная область, из которой нет выхода: каждый держит то, чего
//! ждёт другой. Правило порядка захвата лечит: если все берут мьютексы
//! в одном порядке (сначала `a`, потом `b`), цикла ожиданий не бывает.
//!
//! Чтобы тест не зависал, второй мьютекс берётся через `tryLock` с
//! дедлайном: не дождался, значит, попали во взаимоблокировку, отпускаем
//! первый и сообщаем. Барьер перед вторым захватом гарантирует, что оба
//! потока уже держат по мьютексу, так что плохой порядок виснет всегда,
//! а не «иногда под нагрузкой».
const std = @import("std");
const Io = std.Io;
pub const Order = enum {
/// Поток 0 берёт `a` потом `b`, поток 1 `b` потом `a`.
opposite,
/// Оба берут `a` потом `b`.
same,
};
pub const Outcome = struct {
/// Сколько потоков дождались второго мьютекса и сделали работу.
finished: usize,
/// Сколько сдались по таймауту: признак взаимоблокировки.
timed_out: usize,
};
const Shared = struct {
io: Io,
a: Io.Mutex = .init,
b: Io.Mutex = .init,
/// Барьер: сколько потоков уже держат первый мьютекс.
holding: std.atomic.Value(usize) = .init(0),
finished: std.atomic.Value(usize) = .init(0),
timed_out: std.atomic.Value(usize) = .init(0),
timeout_ms: i64,
};
fn worker(s: *Shared, first: *Io.Mutex, second: *Io.Mutex, use_barrier: bool) void {
first.lockUncancelable(s.io);
defer first.unlock(s.io);
_ = s.holding.fetchAdd(1, .acq_rel);
// Ждём, пока второй поток тоже возьмёт свой первый мьютекс. При
// одинаковом порядке второй поток стоит на `a` и сюда не дойдёт,
// поэтому барьер нужен только для встречного порядка.
if (use_barrier) while (s.holding.load(.acquire) < 2) std.Thread.yield() catch {};
const deadline = Io.Timestamp.now(s.io, .awake).addDuration(.fromMilliseconds(s.timeout_ms));
while (!second.tryLock()) {
if (Io.Timestamp.now(s.io, .awake).nanoseconds >= deadline.nanoseconds) {
_ = s.timed_out.fetchAdd(1, .monotonic);
return;
}
Io.sleep(s.io, .fromMilliseconds(1), .awake) catch {};
}
defer second.unlock(s.io);
_ = s.finished.fetchAdd(1, .monotonic);
}
pub fn run(io: Io, order: Order, timeout_ms: i64) !Outcome {
var s: Shared = .{ .io = io, .timeout_ms = timeout_ms };
const opposite = order == .opposite;
const t0 = try std.Thread.spawn(.{}, worker, .{ &s, &s.a, &s.b, opposite });
const t1 = if (opposite)
try std.Thread.spawn(.{}, worker, .{ &s, &s.b, &s.a, true })
else
try std.Thread.spawn(.{}, worker, .{ &s, &s.a, &s.b, false });
t0.join();
t1.join();
return .{ .finished = s.finished.load(.monotonic), .timed_out = s.timed_out.load(.monotonic) };
}
Разберём, что происходит при встречном порядке. Поток 0 берёт a, поток 1 берёт b, оба увеличивают holding и ждут, пока он станет равен двум. Барьер держит траекторию ровно в точке тупика из виджета: оба держат по мьютексу. Дальше оба крутят tryLock второго мьютекса, и ни один не может его взять. Кто-то первым доходит до крайнего срока, увеличивает timed_out и возвращается, а defer first.unlock отпускает его первый мьютекс. Второй поток, если его срок ещё не вышел, тут же берёт освободившийся мьютекс и доделывает работу. Поэтому типичный итог finished=1 timed_out=1, а если сроки истекли почти одновременно, бывает и timed_out=2.
При одинаковом порядке барьер не нужен и даже вреден: второй поток стоит на lockUncancelable(a) и не дойдёт до holding, так что первый ждал бы его вечно. Поэтому use_barrier у обоих ложен, первый поток берёт a и b, делает работу и отпускает, второй проходит следом.
Два приёма здесь пригодятся и вне учебного примера. Первый: defer first.unlock отпускает первый мьютекс на любом выходе, и отказ от второго автоматически разрывает цикл ожидания. Это и есть захват с отказом из предыдущего раздела. Второй: Io.Timestamp.now(io, .awake) это монотонные часы, которые не прыгают при переводе системного времени, и крайний срок по ним честный. Пауза Io.sleep на миллисекунду не даёт потоку сжигать ядро в пустом цикле.
main.zig и root.zig
Подкоманда tiny deadlock прогоняет оба порядка с крайним сроком 200 мс:
\\ tiny badcnt [niters] гонка на счётчике и три исправления
+ \\ tiny deadlock два мьютекса во встречном и в одном порядке
\\ tiny psum-bench [--n N] [--n-slow N] [--threads 1,2,4,8] [--repeat R] [--json]
@@
.{ "badcnt", cmdBadcnt },
+ .{ "deadlock", cmdDeadlock },
.{ "psum-bench", cmdPsumBench },
@@
try ctx.out.flush();
}
+fn cmdDeadlock(ctx: Ctx, rest: []const [:0]const u8) !void {
+ _ = rest;
+ inline for (.{ .opposite, .same }) |order| {
+ const r = try conc.deadlock.run(ctx.io, order, 200);
+ try ctx.out.print("{t:<8} finished={d} timed_out={d}\n", .{ order, r.finished, r.timed_out });
+ }
+ try ctx.out.flush();
+}
+
fn cmdPsumBench(ctx: Ctx, rest: []const [:0]const u8) !void {
pub const psum = @import("conc/psum.zig");
+ pub const unsafe = @import("conc/unsafe.zig");
+ pub const deadlock = @import("conc/deadlock.zig");
};
{t} в строке формата печатает имя тега перечисления, так что в выводе opposite и same без ручного switch.
build.zig: флаг -Dtsan
ThreadSanitizer включается на уровне модуля, и включать его надо везде, где есть код, который трогают потоки: в модуле tiny, в программе и в тестах шагов. Модуль, собранный без инструментирования, для TSan невидим: его обращения к памяти рантайм просто не увидит.
-const project_steps = [_]u8{ 63, 64, 65, 66, 68, 69, 70, 71 };
+const project_steps = [_]u8{ 63, 64, 65, 66, 68, 69, 70, 71, 72 };
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
+ // Урок 72: `zig build -Dtsan run -- badcnt` собирает всё с ThreadSanitizer.
+ const tsan = b.option(bool, "tsan", "Собрать с ThreadSanitizer (-fsanitize-thread)") orelse false;
@@
.link_libc = true,
+ .sanitize_thread = tsan,
});
@@
.optimize = optimize,
+ .sanitize_thread = tsan,
.imports = &.{.{ .name = "tiny", .module = tiny }},
@@
.link_libc = true,
+ .sanitize_thread = tsan,
.imports = &.{
CGI-программа adder флага не получает: она однопоточная и живёт в своём процессе.
Тесты шага
//! Шаг 72: четыре класса потоконебезопасных функций, блокировка с
//! копированием над `gethostbyname`, `rand_r`, взаимоблокировка и правило
//! порядка захвата.
const std = @import("std");
const tiny = @import("tiny");
const unsafe = tiny.conc.unsafe;
const deadlock = tiny.conc.deadlock;
const testing = std.testing;
const io = testing.io;
fn bump(n: usize) void {
for (0..n) |_| unsafe.bumpSafe(io);
}
test "класс 1: та же функция под мьютексом не теряет прибавлений" {
unsafe.hitsReset();
const t0 = try std.Thread.spawn(.{}, bump, .{100_000});
const t1 = try std.Thread.spawn(.{}, bump, .{100_000});
t0.join();
t1.join();
try testing.expectEqual(@as(u64, 200_000), unsafe.hitsValue());
}
fn sequence(seed: u32, out: []u15) void {
var s = seed;
for (out) |*x| x.* = unsafe.randR(&s);
}
test "класс 2: randR с состоянием у вызывающего даёт ту же последовательность в любом потоке" {
var alone: [100]u15 = undefined;
sequence(42, &alone);
// Последовательность rand книги с тем же зерном та же: randR это rand
// с вынесенным состоянием.
unsafe.srand(42);
for (alone) |x| try testing.expectEqual(x, unsafe.rand());
var in_threads: [4][100]u15 = undefined;
var threads: [4]std.Thread = undefined;
for (&threads, &in_threads) |*t, *out| t.* = try std.Thread.spawn(.{}, sequence, .{ 42, out });
for (threads) |t| t.join();
for (in_threads) |seq| try testing.expectEqualSlices(u15, &alone, &seq);
// rand_r из libc тоже детерминирован по зерну (алгоритм у libc свой).
var s1: c_uint = 7;
var s2: c_uint = 7;
for (0..10) |_| try testing.expectEqual(unsafe.rand_r(&s1), unsafe.rand_r(&s2));
}
test "класс 3: статический буфер переписывается следующим вызовом" {
const first = unsafe.formatIp(.{ 10, 0, 0, 1 });
try testing.expectEqualStrings("10.0.0.1", first);
_ = unsafe.formatIp(.{ 192, 168, 1, 254 });
// `first` никто не трогал, а под ним уже чужой ответ, обрезанный по
// старой длине.
try testing.expectEqualStrings("192.168.", first);
}
fn lookup(host: *unsafe.Host) void {
for (0..50) |_| {
unsafe.gethostbynameTs(io, "localhost", host) catch {
host.addr_count = 0;
return;
};
}
}
test "класс 3, лечение: gethostbynameTs из четырёх потоков, у каждого своя копия" {
var hosts: [4]unsafe.Host = @splat(.{});
var threads: [4]std.Thread = undefined;
for (&threads, &hosts) |*t, *h| t.* = try std.Thread.spawn(.{}, lookup, .{h});
for (threads) |t| t.join();
for (hosts) |h| {
try testing.expect(h.addr_count >= 1);
try testing.expectEqualSlices(u8, &.{ 127, 0, 0, 1 }, &h.addresses()[0]);
try testing.expect(h.name().len > 0);
}
}
test "класс 4: rollDiceR с зерном повторяем, результат всегда от 1 до 6" {
var a: u32 = 1;
var b: u32 = 1;
for (0..100) |_| {
const x = unsafe.rollDiceR(&a);
try testing.expectEqual(x, unsafe.rollDiceR(&b));
try testing.expect(x >= 1 and x <= 6);
}
unsafe.srand(1);
const d = unsafe.rollDice();
try testing.expect(d >= 1 and d <= 6);
}
test "взаимоблокировка: встречный порядок захвата виснет, одинаковый нет" {
const bad = try deadlock.run(io, .opposite, 100);
// Оба потока держат по мьютексу и ждут второй: хотя бы один сдаётся по
// таймауту (второй может успеть, когда первый отпустит свой).
try testing.expect(bad.timed_out >= 1);
try testing.expectEqual(@as(usize, 2), bad.finished + bad.timed_out);
const good = try deadlock.run(io, .same, 1000);
try testing.expectEqual(@as(usize, 2), good.finished);
try testing.expectEqual(@as(usize, 0), good.timed_out);
}
Что здесь стоит заметить:
- Тест класса 1 проверяет только исправление. Проверить, что
bumpUnsafeтеряет прибавления, нельзя надёжно: на удачной траектории она их не теряет, и тест иногда проходил бы. Сломанную версию ловит TSan, а неexpect. - Тест класса 2 сравнивает три вещи:
randRв одиночку,randкниги с тем же зерном иrandRв четырёх потоках сразу. Все три последовательности совпадают. Именно этоrandпотеряла бы, если бы её звали из четырёх потоков. - Тест класса 3 ломается нарочно и в одном потоке: беда статического буфера не требует потоков вовсе.
- Лечение класса 3 проверяется на настоящем
gethostbynameиз libc и имениlocalhost, которое есть в/etc/hostsлюбой системы: сети тест не требует. Четыре потока по пятьдесят раз, и каждый в конце видит у себя127.0.0.1. - Тест взаимоблокировки не виснет благодаря крайнему сроку и проверяет обе стороны правила: встречный порядок хотя бы у одного потока кончается отказом, одинаковый у обоих работой.
Прогон
$ zig build test -Dstep=72 --summary all
Build Summary: 6/6 steps succeeded; 6/6 tests passed
$ zig build && zig-out/bin/tiny deadlock
opposite finished=1 timed_out=1
same finished=2 timed_out=0
$ zig-out/bin/tiny badcnt 1000000
badcnt cnt=1073933 expected=2000000 BOOM!
goodcnt (sem) cnt=2000000 expected=2000000 OK
mutexcnt cnt=2000000 expected=2000000 OK
atomiccnt cnt=2000000 expected=2000000 OK
Это macOS 26.6.2 на Apple M4 Max (16 ядер), отладочная сборка. tiny deadlock три прогона подряд дал одно и то же: один поток сдался, второй доделал. Без TSan badcnt потерял почти половину прибавлений, под TSan (выше) ни одного, и это ещё раз о том, почему по результату гонку не ищут.
В контейнере с Linux (linux/arm64) тесты шага зелёные и в обычной сборке, и с -Dtsan: шесть из шести, без единого предупреждения. Это хорошая проверка самих тестов: в них нет ни одного обращения к общей памяти без синхронизации, даже в тесте, который ломает formatIp, потому что он делает это в одном потоке.
На macOS
Весь шаг проекта работает на macOS напрямую: тесты шага, tiny deadlock, tiny badcnt, листинги ntoa.zig, check_then_act.zig и lock_pair.zig. Без изменений они проходят и в контейнере с Linux (Debian 12, linux/arm64).
Разница в двух местах. Первое ты уже видел: inet_ntoa в macOS держит один буфер на процесс, а в glibc по буферу на поток. С gethostbyname расхождение ещё заметнее. В glibc два вызова подряд возвращают один и тот же указатель на статическую hostent, и первый ответ просто переписан вторым. В macOS указатели разные, но это не спасает: после gethostbyname("localhost") и gethostbyname("broadcasthost") (оба имени есть в /etc/hosts на macOS) по первому указателю поле h_name уже пусто, прошлый ответ libc успела выбросить. А если второй вызов спрашивает числовой адрес вроде 10.20.30.40, первый ответ остаётся цел. Полагаться на то, как долго живёт ответ, нельзя ни там, ни там. Обёртка gethostbynameTs правильна для обеих систем, потому что копирует ответ сразу, до любого следующего вызова.
Второе: ThreadSanitizer. Рантайм TSan, который поставляется с Zig 0.16.0, на macOS 26.6.2 падает с SIGSEGV ещё до main на любой программе: и на tiny, и на десятистрочном примере с двумя потоками. С C через zig cc -fsanitize=thread результат тот же: SIGSEGV, код 139. Поэтому всё, что в уроке собрано с -fsanitize-thread или -Dtsan, гоняй в контейнере с Linux по рецепту из README эталона. Системный clang от Apple TSan умеет, но только для C: пример с cnt++ в двух потоках, собранный /usr/bin/clang -g -fsanitize=thread race.c, печатает WARNING: ThreadSanitizer: data race с номером строки. А вот детектор порядка захвата в сборке Apple молчит: тот же lock_order, переписанный на C, отработал на macOS без предупреждений даже с TSAN_OPTIONS=detect_deadlocks=1, хотя на Linux та же программа на Zig получила lock-order-inversion.
В контейнере для llvm-symbolizer места нет, поэтому бинарник удобно собрать в каталог, подключённый к хосту (-v "$PWD/out":/out), и расшифровать адреса на хосте: на macOS llvm-symbolizer ставится вместе с brew install llvm и понимает ELF.
Практика
Задача повторяет домашнее 12.26 книги на справочнике без сети. lookupUnsafe(query) ведёт себя как gethostbyname: результат, имя и адрес, лежат в статической памяти, одной на процесс, и посреди записи имени функция уступает процессор через std.Thread.yield(), как настоящий резолвер, который ждёт ответа из сети. Твоя обёртка lookupTs(io, query, out, storage) должна быть блокировкой с копированием по всем правилам из урока: мьютекс lookup_mutex захвачен на всё время вызова и копии, копия глубокая (байты имени переезжают в storage вызывающего, и out.name смотрит туда, а не в статическую память), имя, которое не влезает в storage, это error.NoSpace. Мьютекс берётся через try lookup_mutex.lock(io), потому что в множество ошибок функции входит Io.Cancelable.
Тесты ловят обе ошибки, о которых шла речь в разделе про обёртку: мелкую копию (out.* = shared.* оставляет срез смотреть в статическую память) и копию после unlock (шесть потоков по четыреста раз спрашивают каждый своё имя, и ни один ответ не должен оказаться чужим).
Упражнения
Итоги
- Потокобезопасная функция даёт верный результат при любом числе одновременных вызовов. «При любом» значит при любой траектории, а не при тех, что попались на тестах.
- Четыре класса небезопасных функций: без защиты общих данных (лечится мьютексом внутри, сигнатура не меняется), с состоянием между вызовами (лечится только выносом состояния к вызывающему, как
randRиrand_r), с указателем на статическую память (буфер от вызывающего или блокировка с копированием), зовущие небезопасную (наследуют беду вызываемой). - Реентерабельная функция не трогает общих данных вовсе. Это подмножество потокобезопасных: функция с мьютексом безопасна для потоков, но не для обработчика сигнала, который прервал её же вызов в том же потоке.
- Блокировка с копированием: мьютекс, вызов, глубокая копия,
unlock. Копия послеunlockи мелкая копия выглядят правильно и почти всегда работают. Мьютекс защищает только тех, кто ходит через обёртку, а класс 2 этим приёмом не лечится. - Гонка: корректность зависит от порядка. Гонка данных: два доступа без порядка, хотя бы одна запись, хотя бы один неатомарный. Атомики убирают гонку данных, но не гонку «проверить, потом действовать».
- ThreadSanitizer ловит гонки данных по условию, а не по результату, поэтому находит их даже на удачном прогоне. Он видит только выполненный код и только гонки данных. Его детектор порядка захвата понимает мьютексы libc, но не
Io.Mutex. - Взаимоблокировка на графе выполнения это точка на пересечении запретных зон, из которой нет ни одного допустимого шага; в графе ожидания это цикл.
- Общий порядок захвата делает цикл невозможным. Для динамических объектов удобен порядок адресов. Когда порядок не выстроить, второй мьютекс берут через
tryLockи при неудаче отпускают первый. - В macOS и glibc одни и те же функции libc устроены по-разному (
inet_ntoaс общим буфером и с буфером на поток), а TSan из Zig 0.16 работает только на Linux.
Дальше
Сегодня мы собрали всё, что ломается в конкурентной программе: функции, не рассчитанные на двоих, гонки, которые атомики не лечат, и тупики, которые случаются раз в тысячу прогонов. У каждой беды нашлось лекарство и инструмент: классы небезопасных функций и их исправления, блокировка с копированием, ThreadSanitizer для гонок данных и порядок захвата против взаимоблокировок.
В последнем уроке блока вернёмся к серверу. В Zig 0.16 асинхронность не красит функции: код получает std.Io так же, как аллокатор, и одна и та же функция работает и на потоках, и на цикле событий. Посмотрим на io.async, Io.Group, очереди и отмену, положим их рядом с тремя моделями сервера из этого блока, а в финале соберём кэширующий веб-прокси. У него будет пул потоков из прошлого урока и кэш, который читают многие потоки сразу, а пишут по одному: блокировка читателей и писателей, копия ответа под ней и всё, что мы сегодня узнали о том, как не отдать одному клиенту чужой ответ.
домашка