Skip to content

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

Send и Sync, в чем разница и как они выводятся

Ответ

Send означает, что значение типа можно безопасно передать в другой поток.

Sync означает, что &T можно безопасно делить между потоками, то есть T реализует Sync, если &T реализует Send.

Оба трейта auto traits, компилятор сам выводит реализацию по полям. Не Send Rc, потому что счетчик не атомарный. Не Send и не Sync сырые указатели в обертках вроде Cell, потому что мутируют через shared ссылку без синхронизации.

rust
use std::sync::Arc;
use std::thread;

fn main() {
    let v = Arc::new(vec![1, 2, 3]);
    let v2 = v.clone();
    thread::spawn(move || println!("{:?}", v2)).join().unwrap();
}

Если заменить Arc на Rc, компилятор откажется собирать, потому что Rc не Send.

Чем Mutex отличается от RwLock и когда какой выбирать

Ответ

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

rust
use std::sync::RwLock;

fn main() {
    let lock = RwLock::new(0);
    {
        let r1 = lock.read().unwrap();
        let r2 = lock.read().unwrap();
        println!("{} {}", *r1, *r2);
    }
    *lock.write().unwrap() = 5;
}

Из практики. Если не уверены, начинать с Mutex. Менять на RwLock только после измерения, и не в любом стиле, а с учетом приоритета писателей, иначе можно получить starvation.

Что такое poisoning у Mutex и как с ним жить

Ответ

Если поток упал с паникой, удерживая Mutex, лок становится отравленным. Все последующие попытки lock возвращают Err. Это сделано, чтобы случайно не работать с потенциально невалидным состоянием. Часто разумно прочитать данные через into_inner или PoisonError::into_inner и продолжить, если инвариант не нарушен. В новых проектах обычно предпочитают parking_lot::Mutex, у него нет poisoning и он быстрее.

rust
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(0);
    let _ = std::panic::catch_unwind(|| {
        let _g = m.lock().unwrap();
        panic!("boom");
    });
    match m.lock() {
        Ok(g) => println!("{}", *g),
        Err(p) => println!("poisoned, value = {}", *p.into_inner()),
    }
}

Rust не дает молча проигнорировать панику под блокировкой.

Channel в std::sync::mpsc, его особенности

Ответ

У канала mpsc много производителей и один потребитель. Передача по значению, отправитель Send, приемник Send но не ]. Каналы строятся вокруг неограниченной очереди. Это удобно, но опасно. Если получатель медленнее отправителей, память растет. На практике часто используют crossbeam-channel и tokio mpsc, у них есть bounded варианты, select, поддержка отмены.

rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    for i in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || tx.send(i).unwrap());
    }
    drop(tx);
    for v in rx { println!("{}", v); }
}

Цикл for v in rx завершается, когда все клоны tx уничтожены. Поэтому важно сбросить исходный tx, иначе будет дедлок.

Что такое scoped threads и зачем они появились в стандартной библиотеке

Ответ

До стабилизации std::thread::scope для передачи ссылок в поток приходилось либо использовать Arc, либо crate crossbeam. Scoped threads гарантируют, что все потоки внутри scope завершатся до выхода из него, поэтому компилятор разрешает заимствовать стек вызывающего без 'static. Это убрало целый класс лишних аллокаций и упростило код.

rust
use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4];
    thread::scope(|s| {
        s.spawn(|| println!("{:?}", &data[..2]));
        s.spawn(|| println!("{:?}", &data[2..]));
    });
}

После scope data все еще доступна. Никаких клонов и Arc.

Atomic типы, что такое memory ordering и какие порядки бывают

Ответ

Atomic типы дают неблокирующие операции с явным memory ordering.

ПорядокЗначение
Relaxedгарантирует только атомарность самой операции
Acquireзапрещает переупорядочивание последующих чтений выше себя
Releaseзапрещает переупорядочивание предыдущих записей ниже себя
SeqCstглобальный порядок
rust
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static CNT: AtomicUsize = AtomicUsize::new(0);

fn main() {
    let h: Vec<_> = (0..4).map(|_| thread::spawn(|| {
        for _ in 0..1000 { CNT.fetch_add(1, Ordering::Relaxed); }
    })).collect();
    for t in h { t.join().unwrap(); }
    println!("{}", CNT.load(Ordering::Relaxed));
}

Что такое гонка данных и чем она отличается от race condition

Ответ

Гонка данных это ситуация, когда два потока обращаются к одной памяти без синхронизации, и хотя бы один пишет. В безопасном Rust таких гонок нет благодаря системе типов и трейтам Send Sync. Race condition это более широкое понятие, логическая гонка по времени. Например, проверили условие, потом что-то сделали, а между этими шагами другой поток уже изменил состояние. Гонок данных Rust не допустит, race condition в логике вполне может быть.

rust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let m = Arc::new(Mutex::new(false));
    let m2 = m.clone();
    let h = thread::spawn(move || {
        let mut g = m2.lock().unwrap();
        if !*g { *g = true; }
    });
    h.join().unwrap();
    println!("{}", *m.lock().unwrap());
}

Что такое deadlock и как его избегать в Rust

Ответ

Взаимная блокировка - это ситуация, когда несколько потоков ждут друг друга и ни один не может продвинуться. Классически возникает при захвате нескольких блокировок в разном порядке. Основной способ решения, брать блокировки в одном фиксированном порядке. Минимизировать критические секции. По возможности использовать try_lock и таймауты. В асинхронном коде взаимную блокировку легко получить, удерживая лок через await. Лучше явно отпустить лок перед await.

rust
use std::sync::Mutex;

fn safe(m: &Mutex<i32>) -> i32 {
    let v = {
        let g = m.lock().unwrap();
        *g
    };
    v + 1
}

fn main() {
    let m = Mutex::new(10);
    println!("{}", safe(&m));
}

Лок берется в блоке и сразу отпускается. Дальнейшая логика выполняется без удерживания.

Rayon, как он работает и где его уместно применять

Ответ

Rayon это библиотека параллельной обработки данных с work stealing. Главная сильная сторона это адаптер par_iter, который превращает обычный итератор в параллельный. Под капотом он рекурсивно делит работу на пары и распределяет задачи между потоками пула. Хорошо подходит для CPU bound задач над коллекциями. Плохо подходит для IO, для смешанных рабочих нагрузок с блокировками и для задач с сильной зависимостью между шагами.

rust
use rayon::prelude::*;

fn main() {
    let sum: u64 = (1u64..=1_000_000).into_par_iter().map(|x| x * x).sum();
    println!("{}", sum);
}

Если в map встретится блокирующий вызов, поток пула будет занят, и parallelism деградирует. Это типичная ловушка при смешении rayon с синхронным IO.

Что такое work stealing и почему он эффективен

Ответ

Work stealing это стратегия планирования, при которой каждый поток имеет собственную очередь задач и берет работу с ее головы. Когда у потока закончились свои задачи, он крадет задачу с хвоста очереди другого потока. Это снижает конкуренцию за общую очередь, дает хорошую локальность, потому что свежие задачи берутся свои, и при этом распределяет нагрузку. На этом принципе работают rayon, tokio multi thread, async-std.

Практический вывод. Если задачи короткие и однородные, work stealing справляется почти идеально. Если задачи разной длины, важно избегать слишком длинных, иначе они тормозят балансировку. В tokio то же самое означает не блокировать поток.

Что такое thread pool и почему ручное spawn не всегда хорошая идея

Ответ

Создание потока не бесплатная операция. Это системный вызов, стек, регистрация в ядре. Если задачи короткие, тратить отдельный поток на каждую расточительно. Thread pool создает потоки заранее и переиспользует их. В Rust типовые решения это rayon::ThreadPool, tokio::runtime, threadpool. Создание потока руками оправдан для долгоживущих сущностей, например для фонового логгера или для отдельного цикла обработки IO.

rust
use rayon::ThreadPoolBuilder;

fn main() {
    let pool = ThreadPoolBuilder::new().num_threads(4).build().unwrap();
    let r = pool.install(|| (0..100).into_iter().sum::<i32>());
    println!("{}", r);
}

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

Что такое barrier и когда он нужен

Ответ

Барьер синхронизирует группу потоков. Все потоки доходят до барьера и ждут, пока не подойдут все остальные, после чего проходят дальше одновременно. Используется в симуляциях по шагам, в параллельных алгоритмах, где фаза вычисления должна полностью закончиться до начала следующей. В Rust есть std::sync::Barrier.

rust
use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
    let b = Arc::new(Barrier::new(3));
    let mut h = vec![];
    for i in 0..3 {
        let b = b.clone();
        h.push(thread::spawn(move || {
            println!("{} before", i);
            b.wait();
            println!("{} after", i);
        }));
    }
    for t in h { t.join().unwrap(); }
}

Вывод гарантирует, что все before напечатаются до любого after.

Что такое condvar и когда он лучше, чем busy wait

Ответ

Condvar это условная переменная. Поток, удерживая лок, ждет условие через wait. На время wait лок отпускается. Другой поток меняет состояние и вызывает notify_one или notify_all. После пробуждения wait автоматически снова берет лок. Это правильный способ ждать события, без сжигания процессора в цикле проверки.

rust
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair2 = pair.clone();
    thread::spawn(move || {
        let (m, cv) = &*pair2;
        let mut g = m.lock().unwrap();
        *g = true;
        cv.notify_one();
    });
    let (m, cv) = &*pair;
    let mut g = m.lock().unwrap();
    while !*g { g = cv.wait(g).unwrap(); }
    println!("started");
}

Проверка в while нужна потому, что бывают spurious wakeups, и одного wait недостаточно.

Что такое spinlock и когда он оправдан

Ответ

Spinlock это лок, который вместо блокировки потока крутится в цикле проверки. Имеет смысл только тогда, когда критическая секция очень короткая и переключение контекста было бы дороже самого ожидания. В прикладном коде это редкость. Встречается в драйверах и низкоуровневых библиотеках. В Rust стандартного spinlock нет, есть параметризованные реализации в crate spin. Но в большинстве случаев правильный ответ это parking_lot::Mutex, который сам решает, спинить или парковать.

Что такое thread local и где он реально нужен

Ответ

Thread local это значение, у которого свой экземпляр в каждом потоке. В Rust это макрос thread_local! и тип LocalKey. Полезен для аллокаторов, для пер потокового кэша, для трассировки, для случайных генераторов. Важно понимать, что данные thread local не Send. Если попытаться унести из них что-то в другой поток, надо клонировать.

rust
use std::cell::RefCell;

thread_local! {
 static COUNTER: RefCell<u64> = RefCell::new(0);
}

fn bump() { COUNTER.with(|c| *c.borrow_mut() += 1); }

fn main() {
    bump();
    bump();
    COUNTER.with(|c| println!("{}", c.borrow()));
}

Это частый паттерн для статистики и для тестового хука вместо глобального состояния.