Rust глазами Python-разработчика #2
Снова привет! Мы — @cbmw и @AndreyErmilov, часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). И это вторая часть наших размышлений по поводу сравнения Python и Rust (первая часть).
Если не хочется читать эту статью или лень возвращаться к первой части материала, можно посмотреть видео нашего выступления:
Многопоточность
Интересно, что использование типизации в Rust не ограничивается только тем, о чем мы говорили в первой части. Одна из областей, в которой хорошо видны ее преимущества, — многопоточное программирование.
Для начала давайте рассмотрим достаточно классический пример:
import threading
x = 0
def increment_global():
global x
x += 1
def taskof_thread():
for _ in range(50000):
increment_global()
def main():
global x
x = 0
t1 = threading.Thread(target=taskof_thread)
t2 = threading.Thread(target=taskof_thread)
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
for i in range(5):
main()
print("x = {1} after Iteration {0}".format(i, x))
Этот код наглядно иллюстрирует ошибку «состояние гонки». Разберем чуть подробнее, что тут происходит. В коде мы видим запуск двух потоков, которые в цикле инкрементируют значение общего счетчика.
Нетрудно догадаться, что если один поток успевает записать новое значение в тот момент, когда другой поток уже считал старое значение счетчика, но не записал новое, то информация о записи первым потоком просто потеряется — ее перезатрет второй поток.
Надо проверить. Запустим код:
$ python race.py
x = 100000 after Iteration 0
x = 100000 after Iteration 1
x = 86114 after Iteration 2
x = 58422 after Iteration 3
x = 89266 after Iteration 4
Видно, что 2-я, 3-я и 4-я итерации потеряли часть инкрементов.
Надо это исправить. Один из вариантов решения проблемы «состояние гонки» — это использование примитивов синхронизации, в данном случае попробуем использовать Mutex. Все, что нам нужно, — создать сам Mutex и переделать его в качестве аргумента в каждый тред:
lock = threading.Lock()
t1 = threading.Thread(target=taskof_thread, args=(lock,))
t2 = threading.Thread(target=taskof_thread, args=(lock,))
Кроме того, нужно изменить саму функцию taskof_thread
, выполняющуюся в каждом из потоков:
def taskof_thread(lock):
for _ in range(50000):
lock.acquire()
increment_global()
lock.release()
Мы обернули вызов increment_global
функциями взятия и освобождения Mutex и тем самым заблокировали параллельное изменение счетчика из нескольких потоков. Теперь остальные треды будут ждать окончания выполнения инкрементации, если она уже была запущена в каком-либо потоке.
Проверим, что все починилось:
❯ python race.py
x = 100000 after Iteration 0
x = 100000 after Iteration 1
x = 100000 after Iteration 2
x = 100000 after Iteration 3
x = 100000 after Iteration 4
Да, теперь все работает корректно. Ну и по традиции рассмотрим этот пример в Rust. Если попробовать переписать Python-код «в лоб», получится следующее:
use std::thread;
static mut X: i32 = 0;
fn increment_global() {
X += 1;
}
fn thread_task() {
for _ in 0..50_000 {
increment_global()
}
}
fn main_task() {
let t1 = thread::spawn(thread_task);
let t2 = thread::spawn(thread_task);
t1.join().unwrap();
t2.join().unwrap();
}
fn main() {
for i in 0..5 {
main_task();
println!("x = {} after Iteration {}", X, i);
}
}
Этот код делает все то же, что и Python, за одним небольшим исключением — вы не сможете его скомпилировать.
Compiling playground v0.0.1 (/playground)
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
--> src/main.rs:6:5
|
6 | X += 1;
| ^^^^^^ use of mutable static
|
= note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
--> src/main.rs:26:47
|
26 | println!("x = {} after Iteration {}", X, i);
| ^ use of mutable static
|
= note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior
error: aborting due to 2 previous errors
For more information about this error, try `rustc --explain E0133`.
error: could not compile `playground`.
To learn more, run the command again with --verbose.
Компилятор нам прямо сообщает, что изменение переменной static mut
может привести к «состоянию гонки». Да, безусловно, мы можем заставить этот код скомпилироваться, обернув нужные вызовы в unsafe-блоки и переложив ответственность за проверку работы кода с компилятора на разработчика. Не будем приводить этот код тут, можете сами поэкспериментировать с этим. В таком случае код будет работать аналогично неисправленной Python-версии.
Тут явно видны различия в подходах при работе с многопоточным кодом в Python и Rust. Теперь попробуем исправить Rust-версию и проанализируем различия:
use std::{sync::{Arc, Mutex}, thread};
use lazy_static::lazy_static; // 1.4.0
lazy_static! { static ref X: Mutex = Mutex::new(0); }
fn increment_global(x: &Mutex) {
let mut data = x.lock().unwrap();
*data += 1;
}
fn thread_task(x: &Mutex) {
for _ in 0..50_000 {
increment_global(x)
}
}
fn main_task(x: &'static Mutex) {
let mut threads = vec![];
for _ in 0..2 {
threads.push(thread::spawn(move || thread_task(x)));
}
for thread in threads {
thread.join().unwrap();
}
}
fn main() {
for i in 0..5 {
main_task(&X);
let mut data = X.lock().unwrap();
println!("x = {} after Iteration {}", data, i);
}
}
Мы использовали аналогичный подход с использованием Mutex, но в тоже время тут скрывается важное отличие. В Rust Mutex — не просто отдельный тип, никак не связанный с теми данными, к которым он блокирует доступ. В Rust он представляет из себя обертку этих данных, и получить доступ к ним можно только путем взятия этого Mutex. Он отпускается автоматически при выходе из области видимости.
Такой подход исключает возможность по ошибке забыть заблокировать Mutex при доступе к данным или, например, заблокировать другой Mutex, который к этим данным отношения не имеет. В Python это остается вполне возможным, и контроль за этим лежит целиком на плечах разработчика.
Да, код становится чуть сложнее, но все еще вполне сопоставим с Python-вариантом. На наш взгляд, это является весьма малой платой за удобство и гарантии, предоставляемые компилятором Rust.
Еще стоит упомянуть, что threading.Lock
в Python можно использовать в качестве контекстного менеджера, что улучшит ситуацию, но не исправит ее полностью. Как минимум, он не позволит связать его с данными, которые защищены этим локом.
Но на этом различия в реализации многопоточности в Python и Rust не заканчиваются. Помимо примитивов, являющихся частью системы типов, компилятор предоставляет некоторые гарантии контроля возможности возникновения гонки данных и «состоянию гонки», хотя и не исключает последнее полностью.
Но и это еще не все. Rust предоставляет достаточно большой набор библиотек для работы с многопоточностью, и одна из них — это rayon. Не будем вдаваться в подробности работы, просто хотелось бы показать небольшой пример распараллеливания кода с ее использованием.
Последовательный код:
fn main() {
let mut arr = [0, 7, 9, 11];
arr.iter_mut().for_each(|p| *p -= 1);
println!("{:?}", arr);
}
Параллельный код:
use rayon::prelude::*;
fn main() {
let mut arr = [0, 7, 9, 11];
arr.par_iter_mut().for_each(|p| *p -= 1);
println!("{:?}", arr);
}
Асинхронность
Подход, описанный выше, применим и при работе с асинхронным кодом. В целом, если говорить об асинхронщине более обобщенно, можно назвать подходы в Python и Rust достаточно похожими. Рассмотрим небольшой пример кода, который (абсолютно) бесполезен на Python:
import asyncio
async def timers():
futures = []
for s in range(3):
futures.append(asyncio.sleep(s))
await asyncio.gather(*futures)
if __name__ == "__main__":
asyncio.run(timers())
И на Rust:
use tokio::time::{delay_for, Duration};
use futures::future::join_all;
async fn timers() {
let futures = (0..2)
.map(Duration::from_secs)
.map(delay_for);
join_all(futures).await;
}
#[tokio::main]
async fn main() {
timers().await;
}
По коду можно заметить сходство в использовании методов и построении асинхронного кода. Мы сознательно не будем погружаться в различия и сходства реализации асинхронщины, сейчас нам интересно рассмотреть это на более высоком уровне. В целом, создается впечатление, что перейти с Python на Rust не составляет большой проблемы, и, отчасти, это правда. Однако, так было не всегда: привычный Python разработчикам async/await появился лишь в стабильной версии 1.39.0, вышедшей 7 ноября 2019 года, то есть чуть больше года назад. До этого асинхронный код в стабильном Rust представлял из себя последовательность из комбинаторов:
fn main() {
let addr = "127.0.0.1:1234".parse().unwrap();
let future = TcpStream::connect(&addr)
.and_then(|socket| {
io::write_all(socket, b"hello world")
})
.and_then(|(socket, _)| {
// read exactly 11 bytes
io::read_exact(socket, vec![0; 11])
})
.and_then(|(socket, buf)| {
println!("got {:?}", buf);
Ok(())
})
.map_err(|_| println!("failed"));
tokio::run(future);
}
Что выглядит для уже привыкших к async/await Python-разработчиков, достаточно чужеродно. Однако в таком подходе есть и плюсы, и минусы, и, вероятно, многие, сталкивающиеся с функциональными языками, увидят в этом много знакомого. Стоит отметить, что комбинаторы доступны и сейчас, наряду с await
вы вполне можете сочетать оба этих подхода. Помимо всего описанного выше в Rust, в отличие от Python, широко используется концепция каналов.
Из сходств можно отметить множественность реализаций event loop
-ов. Правда, ни один из языков не лишен проблем при написании кода, абстрагированного от библиотек, реализующих event loop.
Функциональная парадигма
В тексте выше мы не раз уже касались функциональных возможностей языков, и вот настало время посмотреть на это чуть пристальнее. На полноту и объективность тут рассчитывать не приходится — сама по себе тема слишком бездонна, но попробуем оценить ее в разрезе нашего опыта.
Функции высшего порядка
Это первая концепция, которая присутствует и в Python, и в Rust. Как уже стало привычным по ходу статьи, попробуем сравнить несколько вариантов кода:
from typing import List, Callable
def map_each(list: List[str], fun: Callable[[str], int]) -> List[int]:
new_array = []
for it in list:
new_array.append(fun(it))
return new_array
if __name__ == '__main__':
languages = [
"Python",
"Rust",
"Go",
"Haskell",
]
out = map_each(languages, lambda it: len(it))
print(out) # [6, 4, 2, 7]
Выше в примере Python мы сразу использовали модуль typing
и, соответственно, описали сигнатуру функции map_each
, выполняющую следующее: она принимает два аргумента, один из которых — список со строками, а второй — Callable
-объект, или говоря проще, функция. Она принимает в качестве аргумента строку, а возвращает — число. Выглядит все достаточно приятно.
Посмотрим, что в Rust:
fn map_each(list: Vec, fun: fn(&String) -> usize) -> Vec {
let mut new_array: Vec = Vec::new();
for it in list.iter() {
new_array.push(fun(it));
}
return new_array;
}
fn main() {
let list = vec![
String::from("Python"),
String::from("Rust"),
String::from("Go"),
String::from("Haskell"),
];
let out = map_each(list, |it| it.len());
println!("{:?}", out); // [6, 4, 2, 7]
}
Выглядит похоже, но есть одно, важное различие: Python не поддерживает многострочные lambda-функции. Это резко ограничивает выразительность и возможности всего функционального подхода. Да, безусловно, это можно пережить, пользуясь, например, обычными функциями, но удобства это определенно не добавляет.
Кроме того, важным отличием являются принципы реализации «комбинаторов». В Rust большая часть из них — часть трейта Iterator, тогда как в Python это, как правило, самостоятельные функции. Исходя из вышеописанного и того, что трейт Iterator реализован для многих стандартных типов и может быть реализован для ваших типов, вытекает и различие в использовании этих методов. В Rust они достаточно органично объединяются в цепочки, описывая сложные трансформации данных.
fn main() {
let list = vec![
Ok(42),
Err("Error - 1"),
Ok(81),
Ok(88),
Err("Error - 2")
];
let out: Vec<_> = list
.iter()
.flatten()
.collect();
println!("{:?}", out); // [42, 81, 88]
}
Выше пример кода, отбрасывающего ошибочные результаты из вектора и преобразующего их в вектор целочисленных значений. Данный код, собственно, строится на том, что уже знакомый нам Result реализует трейт IntoIterator. Еще один пример, иллюстрирующий возможности Rust:
fn main() {
let output = ["42", "43", "sorokchetire"]
.iter()
.map(|string| {
string
.parse::()
.map_err(|_| println!("Parsing error"))
})
.flatten()
.map(|integer| integer * 2)
.fold(0, |base, element| base + element);
println!("{:?}", output);
}
Этот код уже делает чуть больше: происходит итерация по массиву со строками, попытка преобразования каждого элемента массива в i32 и, в случае ошибки, вывода сообщения об ошибке в консоль. Далее происходит отбрасывание некорректных результатов, умножение каждого значения на два и сложение полученных элементов.
В чем же прелесть таких цепочек? На мой взгляд, это читаемость и простота поддержки. Представим, что нам нужно добавить дополнительное преобразование в пример выше перед вызовом .fold
. Очевидно, что добиться этого достаточно просто дополнительным вызовом map. Да, в Python есть концепция Comprehensions, но производить какие-то достаточно сложные манипуляции с данными внутри самого comprehension не совсем удобно.
Замыкания
Сама концепция известна и достаточно широко используется и в Python, и в Rust. Вот только система типов Rust позволяет делать некоторые проверки, недоступные в Python. По традиции рассмотрим пример, и на этот раз начнем с Rust:
fn main() {
let mut holder = vec![];
let sum = |a: usize, b: usize| -> usize {
let c = a + b;
holder.push(a + b);
c
};
println!("{}", sum(10, 20));
}
Выше мы видим код, описывающий замыкание sum
, которое, в свою очередь, принимает два параметра и складывает их. Результат сложения записывается в вектор holder
и возвращается из замыкания. Важно отметить, что holder
является внешним по отношению к самому замыканию, то есть он олицетворяет некоторый глобальный стейт в этом примере. Попробуем скомпилировать и увидим ошибку:
error[E0596]: cannot borrow `sum` as mutable, as it is not declared as mutable
То есть компилятор запрещает нам изменять внешнее состояние в замыкании пока само замыкание не будет объявлено как mut
. Исправляется это достаточно банально:
fn main() {
let mut holder = vec![];
let mut sum = |a: usize, b: usize| -> usize {
let c = a + b;
holder.push(a + b);
c
};
println!("{}", sum(10, 20));
}
Да, полностью отличить чистые функции на уровне типов это не позволяет, но зато можно понять, какие из замыканий явно изменяют внешнее состояние при своем вызове, что уже хорошо. Хотя, возможно, многим хотелось бы большего. Ситуация тут в целом схожа с ситуацией с изменяемыми и неизменяемыми типами в Rust и Python. В случае Rust компилятор явно просит указывать, какие конкретные наборы данных будут изменяемыми, а какие — нет. Что кажется чуть более гибким подходом, чем тот, что используется в Python, где изменяемость определяется самим типом данных и, по сути, изменить ее нельзя.
Рекурсия
И это последняя концепция, которую мы возьмем на рассмотрение. В целом ситуация с рекурсией схожа в обоих языках. Отсутствие оптимизации хвостовой рекурсии характерно и Rust, и Python. Однако llvm (backend rust) позволяет генерировать код с учетом этой оптимизации. Вполне возможно, что однажды в Rust появится и эта оптимизация с гарантированным применением при соблюдении всех условий. Кроме того, различия кроются непосредственно и в ошибках при рекурсивных вызовах. В Python RecursionLimit
является обычным исключением и позволяет при необходимости перехватить его и продолжить выполнение приложения. В Rust при превышении уровня вложенности рекурсивных вызовов может произойти ошибка переполнения стека.
Заключение: Зачем-же питонисту Rust
Как мы уже говорили, питонисту очень многое приходится делать самому — проверять типы, держать в голове, где будет None, а где число, искать в документации, какие исключения может выбросить функция. Это сложно и поэтому возникает большая часть ошибок, о которых мы говорили в начале. Mypy помогает избавиться только от их части, и компилятор Rust сильно выигрывает в этом у mypy, но при этом можно столкнуться с другими подводными камнями, которые мы пока не рассматривали.
У Rust достаточно высокий порог входа. Для его использования нужно осознать многие концепции, которые мы описали выше, и те, которых пока не касались. И некоторые из них действительно сложные. Спустя некоторое время начинаешь осознавать эти концепции, они уже не кажутся такими сложными и не так сильно замедляют процесс разработки. Но это время должно пройти, и этот порог необходимо преодолеть.
В итоге мы пришли к следующему выводу: разработка приложения на Python действительно привлекательна своей высокой скоростью. Но потом, когда разработанное приложение нужно поддерживать, вылезают все те проблемы, которые были проигнорированы во время разработки. В то же время Rust не позволяет игнорировать возможные варианты, требуя больше времени на разработку, но и благодаря этому потом тратится гораздо меньше усилий на поддержку и рефакторинг.