Вопросы по многопоточности Rust на собеседовании
Отличие от Java и C++ в том, что гонку данных здесь ловит компилятор: Rc не уедет в другой поток, а RefCell не попадёт внутрь Arc. Поэтому вопросы быстро смещаются к тому, что компилятор не ловит: к порядку захвата замков, ложному разделению и цене синхронизации.
Что спрашивают
- +Потоки: spawn и требование 'static, join и результат, thread::scope с заимствованием локальных данных
- +Send и Sync: авто-трейты и вывод по полям, почему Rc не Send, когда пишут unsafe impl
- +Блокировки: Arc<Mutex<T>>, guard и RAII, отравление после паники, когда выгоднее RwLock
- +Каналы: mpsc и закрытие канала, sync_channel и обратное давление, канал против общего состояния
- +Атомики: fetch_add и compare_exchange, Relaxed против SeqCst, пара Release и Acquire
- +Паттерны: rayon и par_iter, воровство задач, шардирование замка, ложное разделение и дедлоки
Из чего состоит тема
Так тема разложена в тренажёре: движок ведёт прогресс по каждой подтеме отдельно и возвращает те, где вы ошибаетесь.
- Mutex, RwLock, Arc12
- Send и Sync12
- Атомики и ordering12
- Каналы (mpsc)12
- Паттерны параллелизма12
- Потоки и join12
Разборы подтем
Конспект по каждой: что это, как отвечать вслух, на чём валятся, плюс вопросы для самопроверки.
- Потоки в Rust: spawn, join и scope12 вопросов
- Send и Sync в Rust12 вопросов
- Mutex, RwLock и Arc в Rust12 вопросов
- Каналы mpsc в Rust12 вопросов
- Атомики и memory ordering в Rust12 вопросов
- Паттерны параллелизма в Rust12 вопросов
Примеры вопросов с разбором
- Что делает fetch_add у AtomicUsize?A)Прибавляет значение и возвращает новое, блокируя другие потокиB)Ставит операцию в очередь, применяя её при следующем чтенииC)Атомарно прибавляет значение и возвращает предыдущееD)Прибавляет значение только если оно не менялось с прошлого чтения
показать ответ и разбор
+C)Атомарно прибавляет значение и возвращает предыдущее// разбор: Это операция read-modify-write на уровне процессора: инкремент не может «потеряться» между чтением и записью, как у обычного i32. Возвращается старое значение — удобно для выдачи уникальных id. Блокировок нет, но и группу операций атомарной это не делает: два fetch_add подряд — уже не одна транзакция.
- Что означает mpsc в названии std::sync::mpsc?A)Много отправителей, один получательB)Многопоточная очередь с приоритетамиC)Много производителей, много потребителейD)Один отправитель, много получателей
показать ответ и разбор
+A)Много отправителей, один получатель// разбор: Sender клонируется и раздаётся потокам, Receiver существует в одном экземпляре — отсюда multi-producer, single-consumer. Такая форма покрывает типовой сбор результатов от воркеров. Когда получателей нужно несколько, берут внешние крейты вроде crossbeam или flume.
- Почему общий счётчик оборачивают именно в Arc<Mutex<i32>>?A)Arc копирует счётчик в каждый поток, Mutex сводит копии в концеB)Arc даёт общее владение, Mutex — эксклюзивный доступC)Так требует thread::spawn: он принимает только такие типыD)Arc защищает от гонок, Mutex продлевает жизнь значения
показать ответ и разбор
+B)Arc даёт общее владение, Mutex — эксклюзивный доступ// разбор: Два разных вопроса решаются двумя обёртками. Кому принадлежит значение, если потоков несколько — Arc с атомарным счётчиком ссылок. Кто именно сейчас его меняет — Mutex, выдающий эксклюзивный guard. По отдельности не работает: Arc<i32> не даст записи, а Mutex без Arc не переживёт передачу в потоки.
- Что делает замена iter() на par_iter() из rayon?A)Запускает по потоку на каждый элемент коллекцииB)Переносит вычисление в асинхронный рантаймC)Раскладывает обход по пулу потоков, сохраняя семантику итератораD)Ускоряет обход за счёт векторизации на одном ядре
показать ответ и разбор
+C)Раскладывает обход по пулу потоков, сохраняя семантику итератора// разбор: rayon делит работу на куски и раздаёт их пулу воркеров с воровством задач, а API остаётся привычным — map, filter, sum. Требование одно: замыкания должны быть Send, а разделяемые данные — Sync, и это проверяет компилятор. Выигрыш появляется на счётной работе; на коротких коллекциях накладные расходы съедают пользу.
- Что означают трейты Send и Sync?A)Send — передать значение в другой поток, Sync — ссылку из несколькихB)Send — для каналов, Sync — для мьютексов и блокировокC)Send — тип потокобезопасен, Sync — тип синхронизирует доступ внутриD)Send — значение копируется при передаче, Sync — разделяется по ссылке
показать ответ и разбор
+A)Send — передать значение в другой поток, Sync — ссылку из нескольких// разбор: Send отвечает за передачу владения через границу потока, Sync — за разделяемый доступ: T: Sync ровно тогда, когда &T: Send. Оба трейта пустые и выводятся автоматически по составу типа, а компилятор проверяет их в границах API вроде thread::spawn. Гарантий безопасности сам тип при этом не даёт — их даёт его устройство.
- Почему thread::spawn(|| println!("{:?}", v)) не компилируется, а с move — да?A)Без move замыкание копирует v, а Vec копировать запрещеноB)spawn принимает только замыкания без захвата переменныхC)Замыкание переживёт функцию, заимствовать локальную v нельзяD)Компилятор требует move для всех замыканий с телом длиннее строки
показать ответ и разбор
+C)Замыкание переживёт функцию, заимствовать локальную v нельзя// разбор: Поток живёт сам по себе и может пережить функцию, которая его запустила, поэтому spawn требует от замыкания 'static: никаких ссылок на чужие локальные значения. move передаёт владение внутрь, и вопрос снимается. Если владение отдавать не хочется, берут thread::scope — там компилятор знает, что потоки завершатся до выхода.
- Чем Ordering::Relaxed отличается от SeqCst для простого счётчика?A)Relaxed может потерять инкремент при высокой конкуренцииB)Relaxed работает только на одном ядре, SeqCst — на всехC)Relaxed откладывает запись до ближайшего барьера памятиD)Relaxed даёт атомарность, но не порядок соседних операций
показать ответ и разбор
+D)Relaxed даёт атомарность, но не порядок соседних операций// разбор: Атомарность у обоих одинаковая — инкремент не потеряется. Разница в видимости соседних операций: Relaxed не мешает компилятору и процессору переставлять другие обращения к памяти вокруг него. Для счётчика, чьё значение читают в конце, этого достаточно; для флага «данные готовы» нужны Release и Acquire.
- Что вернёт recv(), когда все отправители уничтожены, а буфер пуст?A)Ok со значением по умолчанию для типа сообщенияB)Заблокируется навсегда: получатель не знает о судьбе отправителейC)Панику: работа с закрытым каналом считается ошибкойD)Err: канал закрыт, ждать больше нечего
показать ответ и разбор
+D)Err: канал закрыт, ждать больше нечего// разбор: Канал знает, сколько живых Sender осталось: когда последний уничтожен и очередь опустела, recv возвращает Err(RecvError). Это штатный сигнал завершения — цикл for msg in rx просто заканчивается. Отсюда типовая ошибка: держать лишнюю копию отправителя в главном потоке, из-за чего получатель ждёт вечно.
- Когда снимается блокировка, взятая через m.lock().unwrap()?A)Когда guard уходит из области видимости — в его DropB)В конце функции, где была взята блокировкаC)После последнего обращения к данным внутри блокировкиD)При явном вызове m.unlock() — иначе блокировка остаётся
показать ответ и разбор
+A)Когда guard уходит из области видимости — в его Drop// разбор: lock возвращает MutexGuard — RAII-обёртку: пока она жива, блокировка держится, её Drop освобождает мьютекс. Метода unlock нет, потому что он позволил бы обращаться к данным после освобождения. Чтобы сузить критическую секцию, guard кладут в отдельный блок или роняют явным drop(guard).
это 9 из 72
Ещё 63 вопросов по теме — в тренажёре, с движком повторения
Прочитать разбор и ответить самому — разные навыки. В Сеньорчике вопросы идут сессиями, а движок возвращает подтемы, где вы ошибаетесь, пока они не начнут отскакивать. Бесплатно, лимит по энергии.
Частые вопросы
Что означают Send и Sync?
Send — значение можно передать в другой поток, Sync — на него можно смотреть из нескольких одновременно (T: Sync ровно тогда, когда &T: Send). Оба трейта пустые и выводятся компилятором по составу типа, а проверяются в границах API вроде thread::spawn.
Что будет, если поток запаникует с захваченным мьютексом?
Мьютекс станет отравленным, и следующий lock вернёт Err: данные под ним могли остаться в несогласованном состоянии. Добраться до них всё равно можно — через into_inner у ошибки, если вы понимаете, почему это безопасно.
Когда брать атомик вместо мьютекса?
Когда защищаемое состояние — одно машинное слово и операция над ним одна: счётчик, флаг, версия. Как только нужно согласованно поменять два поля, атомиков не хватает, а самодельная синхронизация обходится дороже обычного замка.