Парадигмы Rust для разработчика Go

Перевод | Автор оригинала: Ralph Caraveo III

Примечание читателя: эта статья призвана дать некоторое техническое представление о сдвигах парадигмы, которым я подвергся при исследовании и изучении языка программирования Rust, когда дело касается параллелизма. Я потратил 3,5 года на изучение языка программирования Go, и это моя попытка поделиться своими идеями, и я воодушевляю вас; читатель также может изучить эти новые идеи, которые предоставляет Rust. В конце концов, если вы отклоните Rust как не для вас, это нормально, но, надеюсь, вы сможете уйти с новыми концепциями для размышлений.

сейчас хорошее время, чтобы стать программистом, и в нашем распоряжении такие языки, как Rust и Go

У меня есть преимущество перед многими инженерами-программистами, работающими сегодня в индустрии. У меня есть преимущество в том, что я понимаю, что языки программирования - это просто инструменты. Инструменты, которые иногда сильно различаются, а иногда частично совпадают по функциям. Важно понимать, что одни инструменты лучше подходят для работы, чем другие. Например, вы обычно не хотите писать название игры AAA на чистом Python. Причины очевидны: Python не дает вам жесткого контроля над памятью и скоростью, чтобы оправдать создание следующего Call of Duty. Нельзя сказать, что это невозможно, но вы знаете ...

Сегодня в индустрии я наблюдаю, как инженеры-программисты увлекаются своими языками (мальчики, девочки). Они говорят что-то вроде «Мой язык лучше, потому что у него есть обобщения» или «В C++ слишком много беспорядка, чтобы я мог оправдать его использование». Эти фанаты {мальчики, девочки} участвуют в обсуждениях и часами пытаются убедить друг друга, что их язык «лучший во всем», в то время как остальные из нас создают дерьмо и добиваются своего. Когда вы подписываетесь на лучшие практики и стандарты единого языка, построенные вокруг единого сообщества, я обнаруживаю, что вы можете серьезно ограничивать свой потенциал роста, когда дело доходит до изучения новых языков и, что более важно, новых парадигм.

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

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

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

Это подводит нас к сути этой статьи. Последние 3,5 года я полностью посвятил разработке Go. Язык программирования Go - фантастический язык общего назначения для создания высокопроизводительных систем с высокой степенью параллелизма. Я слышал, что его называют языком «Облака», и хотя сегодня существует множество языков, которые используются для создания облачных решений, Go привносит в игру особый тон.

Кстати, язык называется Go not Golang. Golang используется как более избирательное ключевое слово, помогающее нам искать в Интернете контент, связанный с Go.

Давайте поговорим о том, почему нужно использовать Go для решения некоторых проблем:

Перечисленные выше предметы не случайны. Go был разработан специально с учетом этих пунктов.

Это хороший список. Но это лишь поверхностная информация о Go и о том, почему вы хотите его использовать. У этого языка есть и другие аспекты, о которых я расскажу. Go также имеет концепцию облегченных потоков и сопрограмм и даже заимствует стиль параллельного моделирования, называемый моделью CSP, более известной как Communicating Sequential Processes. Если вы не слышали об этих концепциях, прочтите о них, я подожду.

Модель CSP вносит свой собственный сдвиг в парадигму построения и моделирования решений с параллелизмом. Если вы пишете код Go сегодня, не понимая модели CSP, я настоятельно рекомендую вам узнать об этом.

Вот что я имею в виду, если вы один из тех, кто слышал о Go, но никогда не использовал его, выбивает Go, потому что в нем нет универсальных шаблонов, или просто прошел Go Tour в течение 20 минут и понял, что язык отстой. потому что параметры функции перевернуты ... вы только причиняете вред себе. За Go стоит несколько нечестивых концепций, на которые вам обязательно стоит взглянуть, даже если вывод - всего лишь его парадигмы. Также стоит отметить, что Go заимствует парадигмы из языков прошлого. Большинство языков умеют.

Теперь давайте поговорим о Rust и, возможно, почему вы захотели использовать Rust:

Кстати, язык называется Rust… не Rustlang. Кроме того, перечисленные выше пункты не случайны. Rust был специально разработан с учетом этих целей.

Этот список тоже хороший. Это ни в коем случае не полный список, и Rust - совсем другое дело, чем Go. Rust дополнительно имеет модель владения и времени жизни, которая позволяет вам выражать код таким образом, чтобы компилятор мог использовать его в своих интересах. Это означает устранение целого класса проблем, которые обычно мешают низкоуровневым / небезопасным языкам. Вас сбили свисающие указатели? Использовать после того, как бесплатно вызывает головную боль? Двойная свобода мешает вашей структуре данных? Rust за вашу спину, нет ... компилятор Rust за вашу спину, и это абсолютно передовой материал.

Примечание: несколько дней назад я был в комнате отдыха на работе. Я разговаривал с некоторыми коллегами о том, как растет популярность Rust и находится в списке самых любимых, страшных, разыскиваемых пользователей на Stackoverflow.com за 2016 год. Кто-то сказал мне: «Ох, [я] смотрел на Rust… это слишком сложно». У этого человека есть хороший опыт работы с го, что я ответил? Я сказал: «Конечно, это сложнее, чем Go, я согласен, но то, за что вы платите заранее при написании кода Rust, вы получаете в 10 раз обратно с некоторыми чрезвычайно впечатляющими гарантиями времени выполнения». Это напомнило мне о том, что я чувствовал, когда впервые прочитал языковую спецификацию Rust полтора года назад. К счастью, мой дорогой друг посоветовал мне еще раз взглянуть.

Поэтому я пообещал, что эта статья научит вас некоторым парадигмам Rust для разработчиков Go. Давайте погрузимся.

Смена парадигмы: Rust не любит гонки данных

Этот фрагмент кода ниже взят прямо из документации Go в отношении детектора гонки. Инструмент, предназначенный для обнаружения скачков данных, но только если у вас есть надлежащие тесты, которые проверяют параллелизм вашего кода. Детектор гонки НЕ является пуленепробиваемым. Он не даст вам ложных срабатываний - если он обнаружит гонку данных ... это гонка данных. Но… нет абсолютно никакой гарантии, что он найдет все гонки данных. Кроме того, детектор гонки должен оценивать вашу программу во время ее работы. Кроме того, некоторые команды, пишущие производственный код Go сегодня, даже не утруждают себя подключением его к своей системе непрерывной интеграции… .¯ \ _ (ツ) _ / ¯ Приведенный ниже код демонстрирует, что карты Go по умолчанию не являются потокобезопасными. Я все время вижу такой код. Люди играют в splish-splash с горутинами, каналами и общим состоянием, а затем пытаются запустить такой код в производство, а затем удивляются, почему он так ужасно взрывается.

func main() {
	c := make(chan bool)
	m := make(map[string]string)
	go func() {
		m["1"] = "a" // First conflicting access.
		c <- true
	}()
	m["2"] = "b" // Second conflicting access.
	<-c
	for k, v := range m {
		fmt.Println(k, v)
	}
}

Ссылка на игровую площадку Быстрый просмотр приведенного выше кода выглядит следующим образом:

Проницательный читатель поймет, что описанная выше гонка данных может быть исправлена с помощью малейшей настройки кода. Ниже мы синхронизируем мутации, эффективно вводя случай «случилось раньше», читая канал c до того, как может произойти мутация в основном потоке.

func main() {
   c := make(chan bool)
   m := make(map[string]string)
   go func() {
        m["1"] = "a" <-- this will always happen first
        c <- true
   }()
   <-c // Reading from the channel here synchronizes mutation
   m["2"] = "b" <-- this will always happen after
   for k, v := range m {
       fmt.Println(k, v)
   }
}

Ссылка на игровую площадку

С другой стороны, Rust даже не скомпилирует такой код. По сути, он говорит: «Фаааааааак ты, я отказываюсь собирать это дерьмо», и при этом держит два средних пальца прямо к твоему лицу. И поверьте мне, когда вы просыпаетесь в 3 часа ночи, чтобы устранить коварную гонку данных, которую очень сложно отследить, именно здесь вы захотите, чтобы ваш компилятор также сказал вам отвалить.

Давайте посмотрим, как может выглядеть приведенный выше код Go в Rust:

use std::sync::mpsc::channel;
use std::collections::HashMap;
use std::thread;
fn main() {
     let (c_tx, c_rx) = channel();
     let mut m = HashMap::new();
 
     thread::spawn(move || {
         m.insert(“1, “a”);
         c_tx.send(true).unwrap();
     });
     m.insert(“2, “b”);
     c_rx.recv().unwrap();
     for (k, v) in &m {
          println!(“{}, {}”, k, v);
     }
}

Ссылка на игровую площадку

Здесь есть несколько очень реальных различий, на которые стоит обратить внимание. Да, на первый взгляд все немного сложнее. Мы должны соответственно импортировать наши потоки, каналы и пространства имен HashMap. Есть дополнительная семантика синтаксиса, которой нет в версии Go. Конструкция канала фактически требует, чтобы мы имели дело с двумя половинами: передающей стороной: c_tx и принимающей стороной: c_rx. И мы не используем облегченную модель потоковой передачи (как это делает код Go), потому что thread::spawn фактически отображает 1: 1 в собственный поток ОС. Хотя модель потоковой передачи совершенно другая, принципы, с которыми мы имеем дело, точно такие же. Несколько потоков совместно используют доступ и видоизменяют одну и ту же карту, и могут случиться неприятности.

Но, безусловно, самым важным аспектом приведенного выше кода Rust является то, что он не компилируется. Это не потому, что компилятор Rust хочет с вами ссориться. Это не потому, что Rust - отстой. Это потому, что люди из Mozilla очень много работали над тем, чтобы приведенный выше код никогда не компилировался и, следовательно, никогда не запускался в производство только для того, чтобы вызвать в конечном итоге хаос.

Это полный сдвиг парадигмы модели Go, который стоит понять и рассмотреть. Вы хотите сказать мне, что моя программа даже не будет компилироваться, если существует гонка данных? Да! Зарегистрируйтесь, пожалуйста, это настоящий сдвиг в мышлении для построения параллельных систем.

Замечание: приведенный выше код в форме Rust можно синхронизировать аналогичным образом, как мы это сделали с версией Go. Исправление может быть таким тривиальным. Но ради этой статьи давайте погрузимся в глубины Rust, чтобы увидеть, как такой язык вообще защищает нас от подобных ситуаций.

Итак, что компилятор сообщает нам о приведенном выше коде, когда он не компилируется? На самом деле он выдает сообщение об ошибке ниже:

<anon>:14:5: 14:6 error: use of moved value: `m` [E0382]
<anon>:14     m.insert("2", "b");
              ^
<anon>:14:5: 14:6 help: see the detailed explanation for E0382
<anon>:9:19: 12:6 note: `m` moved into closure environment here because it has type `std::collections::hash::map::HashMap<&'static str, &'static str>`, which is non-copyable
<anon>: 9     thread::spawn(move || {
<anon>:10         m.insert("1", "a");
<anon>:11         c_tx.send(true).unwrap();
<anon>:12     });
<anon>:9:19: 12:6 help: perhaps you meant to use `clone()`?
<anon>:17:20: 17:21 error: use of moved value: `m` [E0382]
<anon>:17     for (k, v) in &m {
                             ^
<anon>:17:20: 17:21 help: see the detailed explanation for E0382
<anon>:9:19: 12:6 note: `m` moved into closure environment here because it has type `std::collections::hash::map::HashMap<&'static str, &'static str>`, which is non-copyable
<anon>: 9     thread::spawn(move || {
<anon>:10         m.insert("1", "a");
<anon>:11         c_tx.send(true).unwrap();
<anon>:12     });
<anon>:9:19: 12:6 help: perhaps you meant to use `clone()`?
error: aborting due to 2 previous errors

OMG ... черт возьми, это много для того, чтобы проглотить. Да, это более сложно, и я собираюсь не обращать внимания на некоторые детали, но со временем вы научитесь разбираться в таких сообщениях. На самом деле сообщения компилятора практически поэтичны, они рассказывают очень яркую историю о том, почему ваш код не компилируется из-за нарушения модели владения и времени жизни Rust. Rust сообщает нам, что право собственности на карту m было передано созданному потоку. Следовательно, карту m нельзя больше использовать в строке 14, иначе это привело бы к гонке данных.

Давайте обсудим право собственности, детали собственности и то, почему мы видим упоминания о перемещении в приведенной выше ошибке. Rust реализует концепцию наличия одного владельца данных. Думайте о владельце как о переменной, привязанной к некоторым данным. Как владелец, вы можете временно предоставить доступ к своим данным через общую ссылку. Ниже излагаются некоторые основные аксиомы в отношении модели собственности.

Запишите это себе в голову:

Освежение кишечника: что такое снова гонка за данными?

Rust только что сделал нам солидный и поймал идеальный шторм, который мог случиться в продакшене. Вы должны выпить компилятору Rust. Кроме того, гонка данных была обнаружена во время компиляции, а не во время выполнения. Совместная работа компилятора и языковой семантики устраняет все догадки о том, где могут скрываться гонки данных, и вам не нужно писать ни одного параллельного теста, чтобы выявить их.

Загруженный вопрос: действительно ли в Rust чрезвычайно многословный и почти сюжетный обмен сообщениями об ошибках сложнее или он пытается точно определить расхождения данных в производственной среде из-за того, что приложение демонстрирует неопределенное поведение, более сложное?

Смена парадигмы: общая память в Rust не включена

Давайте теперь рассмотрим, как Rust позволяет нам решить проблемы, описанные выше, сначала решив проблемы с общей памятью. Опять же, это действующая модель владения и времени жизни Rust, обеспечивающая соблюдение некоторых очень конкретных правил, чтобы ваш код никогда не делал плохих вещей в производственной среде. Давайте посмотрим, как использование общей памяти выглядит в Rust на немного другом примере.

use std::sync::mpsc::channel;
use std::thread;
use std::collections::HashMap;
use std::sync::Arc;
fn main() {
    let (tx, rx) = channel();
    let mut m = HashMap::new();
    
    m.insert(“a”,1”);
    m.insert(“b”,2”);
 
    let arc = Arc::new(m); // Tells Rust we're sharing state
    for _ in 0..8 {
        let tx = tx.clone();
        let arc = arc.clone();
        thread::spawn(move || {
            let msg = format!(“Accessed {:?}”, arc);
            tx.send(msg).unwrap();
        });
    }
    drop(tx); // Effectively closes the channel
    for data in rx {
        println!(“{:?}”, data);
    }
}

Playground Link

Вот описание кода выше:

Наконец, мы указываем, что закончили с каналом, с помощью вызова drop и loop на принимающей стороне канала, распечатывая результаты. drop - это явный метод, предоставляемый трейтом Drop, и он будет вызываться, когда истечет его время жизни. Обращение к нему «освободит» ресурс до завершения программы.

Теперь, когда мы рассмотрели, как можно обрабатывать совместное использование памяти в Rust с помощью оболочки Arc, в каком случае нам нужно разделить память при возникновении мутации. Давайте вернемся к нашему исходному коду Rust, который был смоделирован после версии Go, и настроим его так, чтобы он компилировался.

use std::sync::mpsc::channel;
use std::collections::HashMap;
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
   let (c_tx, c_rx) = channel();
   let m = HashMap::new();
 
   // Wrap m with a Mutex, wrap the mutex with Arc
   let arc = Arc::new(Mutex::new(m));
   let t_arc = arc.clone();
   thread::spawn(move || {
       let mut z = t_arc.lock().unwrap();
       z.insert(“1, “a”);
       c_tx.send(true).unwrap();
   });
   // Extra scope is needed to avoid the deadlock
   {
       let mut x = arc.lock().unwrap();
       x.insert(“2, “b”);
   }
 
   c_rx.recv().unwrap();
   for (k, v) in m.iter() {
       println!(“{}, {}”, k, v);
   }
}

Playground Link

Давайте разберем приведенный выше код:

Сдвиг парадигмы: блокировка данных, а не кода

Давайте на минутку разойдемся. И в Go, и в Rust есть мьютексы, обеспечивающие защиту целостности общего состояния. Ключевое отличие Rust в том, что в Rust вы блокируете данные напрямую, а не код. Поначалу это различие кажется почти приземленным, но раскрывает мощную концепцию.

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

В Rust такой ситуации просто не может быть. Еще раз спасибо модели владения Rust, поскольку карта m теперь перемещена в Mutex guard, Mutex теперь полностью отвечает за эти данные и разрешает доступ к ним только тогда, когда вы берете lock().

The above code will not compile due to the following error:
<anon>:33:19: 33:20 error: use of moved value: `m` [E0382]
<anon>:33     for (k, v) in m.iter() {
                            ^
<anon>:33:19: 33:20 help: see the detailed explanation for E0382
<anon>:11:35: 11:36 note: `m` moved here because it has type `std::collections::hash::map::HashMap<&'static str, &'static str>`, which is non-copyable
<anon>:11     let arc = Arc::new(Mutex::new(m));
                                            ^
error: aborting due to previous error

The fix is rather easy, since ownership moved into the Mutex we just need to make sure that we kindly go through the mutex for the last step in the code. Therefore the last few lines of code change from this:

for (k, v) in m.iter() {
    println!(“{}, {}”, k, v);
}

Теперь это:

let a = arc.lock().unwrap();
for (k, v) in a.iter() {
    println!(“{}, {}”, k, v);
}

Увы, для завершения мы наконец-то добрались до этого кода, который компилируется отлично.

use std::sync::mpsc::channel;
use std::collections::HashMap;
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
   let (c_tx, c_rx) = channel();
   let m = HashMap::new();
   // Wrap m with a Mutex, wrap the mutex with Arc
   let arc = Arc::new(Mutex::new(m));
   let t_arc = arc.clone();
       thread::spawn(move || {
       let mut z = t_arc.lock().unwrap();
       z.insert(“1, “a”);
       c_tx.send(true).unwrap();
   });
   // Extra scope is needed to avoid the deadlock
   {
       let mut x = arc.lock().unwrap();
       x.insert(“2, “b”);
   }
   c_rx.recv().unwrap();
   let a = arc.lock().unwrap();
   for (k, v) in a.iter() {
       println!(“{}, {}”, k, v);
   }
}

Playground Link

Если вы все еще со мной, мы рассмотрели много материала. Эти три парадигмы действительно предлагают альтернативный способ мышления:

На этом мы завершаем статью о парадигмах Rust для разработчика Go. Я хотел прояснить ситуацию и выделить несколько моментов. Примеры в этом эссе немного надуманы, но просты и полезны, чтобы проиллюстрировать действующие механики.

Кроме того, этот пост никоим образом не имел целью противопоставить один язык другому. Фактически, то, что я узнал в ходе своего исследования с Rust, я думаю, в конечном итоге сделает меня лучшим инженером-программистом, независимо от того, какой язык я использую. Go - это то, чем я занимаюсь сегодня, из-за характера систем, которые я создаю для жизни. Модель облегченных потоков, которую предлагает Go, дает мне значительное преимущество перед таким языком, как Rust в настоящее время. И наоборот, я уверен, что настанет время заняться языком вроде Rust. Когда это время придет, я уверен, что получу удовольствие от работы с таким мощным языком нижнего уровня, который избегает многих ловушек, которые обычно преследуют такие языки.

Еще кое-что! Если вам понравилась эта статья, я буду благодарен за отзывы. Пожалуйста, поделитесь им и подпишитесь на меня в Твиттере. Кроме того, если эта статья содержит предмет, который вас волнует, присоединяйтесь ко мне в SendGrid, где мы весь день будем играть с распределенными системами. (Также потребуется реальная работа.)

И если вам понравилась эта статья, посмотрите мой пост под названием «Танцы с мьютексами го».

Удачного кодирования!