Как стать автором
Обновить

Ошибки, которые не ловит Rust

Время на прочтение 61 мин
Количество просмотров 19K
Автор оригинала: fasterthanlime

Мне по-прежнему интересны языки программирования. Но сегодня уже не так сильно, и не из-за того, что они позволяют мне делать, а, скорее, из-за того, что они мне делать не позволяют.

В конечном итоге, возможности того, что можно сделать при помощи языка программирования, редко ограничены самим языком: нет ничего, что можно сделать на C++, но нельзя повторить на C, при наличии бесконечного количества времени.

Если язык полон по Тьюрингу и компилируется в ассемблерный код, каким бы ни был интерфейс, вы общаетесь с одной и той же машиной. Вы ограничены возможностями оборудования, количеством его памяти (и её скоростью), подключенной к нему периферией, и так далее.

На самом деле, достаточно лишь команды mov.

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

Кроме того, есть такие аспекты, как производительность, отладкопригодность (если такого слова нет, то его стоит придумать) и дюжина других факторов, которые стоит рассмотреть при «выборе языка».

Размеры леса


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

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

Учитывая это, возникает соблазн составить рейтинг языков по «количеству допустимых программ». Не стоит ожидать, что все достигнут консенсуса по единственному рейтингу, но некоторые разногласия вполне приемлемы.

Рассмотрим следующую программу на JavaScript:

function foo(i) {
  console.log("foo", i);
}

function bar() {
  console.log("bar!");
}

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  return;
  bar();
}

main();

В этом коде bar() никогда не вызывается — main выполняет возврат до её вызова.

При запуске программы в node.js мы не получим никаких предупреждений:

$ node sample.js
foo 0
foo 1
foo 2

Тот же пример на языке Go тоже не вызовет предупреждений:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

func bar() {
  log.Printf("bar!")
}

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  return
  bar()
}

$ go build ./sample.main
$ ./sample
2022/02/06 17:35:55 foo 0
2022/02/06 17:35:55 foo 1
2022/02/06 17:35:55 foo 2

Однако инструмент go vet (поставляемый в стандартном дистрибутиве Go) отреагирует на этот код:

$ go vet ./sample.go
# command-line-arguments
./sample.go:18:2: unreachable code

Потому что несмотря на то, что, строго говоря, наш код не является неверным, он… подозрительный. Он похож на неверный код. Поэтому linter вежливо спрашивает: «вы на самом деле это имели в виду? если да, то всё в порядке, можете заставить lint замолчать. а если нет, то у вас есть шанс исправить код».

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

fn foo(i: usize) {
    println!("foo {}", i);
}

fn bar() {
    println!("bar!");
}

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
warning: unreachable expression
  --> src/main.rs:14:5
   |
13 |     return;
   |     ------ any code following this expression is unreachable
14 |     bar()
   |     ^^^^^ unreachable expression
   |
   = note: `#[warn(unreachable_code)]` on by default

warning: `lox` (bin "lox") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s
     Running `target/debug/lox`
foo 0
foo 1
foo 2

Мне нравится, что он не просто сообщает, что код недостижим, но и почему этот код недостижим.

Обратите внимание, что это по-прежнему предупреждение, то есть разработчик может просто просмотреть его в нужное время, но выполнению кода оно не помешает. (Если только мы не поместим #![deny(unreachable_code)] в начало main.rs — это эквивалент передачи -Werror=something в gcc/clang).

Ошибёмся сейчас, узнаем об этом… когда?


Давайте немного изменим пример. Допустим, полностью уберём определение bar.

В конце концов, она ведь никогда не вызывается, разве это на что-то повлияет?

function foo(i) {
  console.log("foo", i);
}

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  return;
  bar();
}

main();

$ node sample.js
foo 0
foo 1
foo 2

Реализация node.js считает, что никого не волнует bar, потому что её никогда не вызывают.

Однако Go сильно против исчезновения bar:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  return
  bar()
}

$ go run ./sample.go 
# command-line-arguments
./sample.go:14:2: undefined: bar

… и как всегда краток.

Компилятор Rust тоже расстроен:

fn foo(i: usize) {
    println!("foo {}", i);
}

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0425]: cannot find function `bar` in this scope
  --> src/main.rs:10:5
   |
10 |     bar()
   |     ^^^ not found in this scope

warning: unreachable expression
  --> src/main.rs:10:5
   |
9  |     return;
   |     ------ any code following this expression is unreachable
10 |     bar()
   |     ^^^^^ unreachable expression
   |
   = note: `#[warn(unreachable_code)]` on by default

For more information about this error, try `rustc --explain E0425`.
warning: `lox` (bin "lox") generated 1 warning
error: could not compile `lox` due to previous error; 1 warning emitted

… и продолжает настаивать, что если бы bar существовала (хотя на самом деле сейчас нет), её всё равно никогда не вызывали бы и мы всё равно должны… пересмотреть своё мнение.

Итак, и Go, и Rust отвергают эти программы как недопустимые (они выдают ошибку и отказываются создавать скомпилированную форму программы), даже несмотря на то, что, откровенно говоря, это совершенно правильная программа.

Но этому есть совершенно разумное и практичное объяснение.

По сути, node.js является интерпретатором. В его составе есть компилятор just-in-time (на самом деле, несколько), но это уже подробность реализации. Мы можем представить, что программы исполняются «на лету», в процессе нахождения в коде новых выражений и операторов, и быть достаточно близкими к правде.

Поэтому node.js не нужно озадачиваться существованием символа bar до того самого момента, пока его не вызовут (или получат к нему доступ, или присвоят ему значение, и т. д.).

После чего он выдаст ошибку. В процессе выполнения нашей программы.

function foo(i) {
  console.log("foo", i);
}

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  // 👇 (there used to be a 'return' here)
  bar();
}

main();

$ node sample.js
foo 0
foo 1
foo 2
/home/amos/bearcove/lox/sample.js:10
  bar();
  ^

ReferenceError: bar is not defined
    at main (/home/amos/bearcove/lox/sample.js:10:3)
    at Object.<anonymous> (/home/amos/bearcove/lox/sample.js:13:1)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:17:47

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

Поэтому им нужно знать, какой код нужно сгенерировать для всей функции main. В том числе и адрес bar, которая хоть и является недостижимой частью кода, всё равно вызывается командой в исходном коде.

Если бы мы хотели приблизительно воссоздать, что происходит в node.js, нам нужно было бы использовать указатель функции, который может быть равным null или указывать на функцию: и мы узнали бы об этом, только когда вызывали бы её.

Такой код Go компилирует:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

type Bar func()

var bar Bar

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  bar()
}

$ go build ./sample.go

Но паникует при исполнении:

$ ./sample
2022/02/06 18:08:06 foo 0
2022/02/06 18:08:06 foo 1
2022/02/06 18:08:06 foo 2
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x48756e]

goroutine 1 [running]:
main.main()
        /home/amos/bearcove/lox/sample.go:17 +0x6e

Однако он перестаёт паниковать, если мы инициализируем bar какой-нибудь валидной реализацией:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

type Bar func()

var bar Bar

// 👇 we initialize bar in an `init` function, called implicitly at startup
func init() {
  bar = func() {
    log.Printf("bar!")
  }
}

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }

  bar()
}

Мы можем провернуть тот же трюк и в Rust:

fn foo(i: usize) {
    println!("foo {}", i);
}

fn bar_impl() {
    println!("bar!");
}

static BAR: fn() = bar_impl;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/lox`
foo 0
foo 1
foo 2
bar!

Однако воспроизвести вылет сложнее, потому что мы не можем просто объявить указатель функции, указывающий на ничто.

$ fn foo(i: usize) {
    println!("foo {}", i);
}

// 👇
static BAR: fn();

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error: free static item without body
 --> src/main.rs:5:1
  |
5 | static BAR: fn();
  | ^^^^^^^^^^^^^^^^-
  |                 |
  |                 help: provide a definition for the static: `= <expr>;`

error: could not compile `lox` due to previous error

Если мы хотим учесть вероятность как наличия, так и отсутствия bar, нужно сменить её тип на Option<fn()>:

fn foo(i: usize) {
    println!("foo {}", i);
}

//            👇
static BAR: Option<fn()>;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()
}

И мы всё равно обязаны что-то ей присвоить.

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error: free static item without body
 --> src/main.rs:5:1
  |
5 | static BAR: Option<fn()>;
  | ^^^^^^^^^^^^^^^^^^^^^^^^-
  |                         |
  |                         help: provide a definition for the static: `= <expr>;

(other errors omitted)

В этом случае мы присваиваем None, потому что я пытаюсь показать, что произошло бы, если бы bar не существовала:

fn foo(i: usize) {
    println!("foo {}", i);
}

static BAR: Option<fn()> = None;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()
}

Но теперь мы получаем ошибку в месте вызова:

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0618]: expected function, found enum variant `BAR`
  --> src/main.rs:11:5
   |
5  | static BAR: Option<fn()> = None;
   | -------------------------------- `BAR` defined here
...
11 |     BAR()
   |     ^^^--
   |     |
   |     call expression requires function
   |
help: `BAR` is a unit variant, you need to write it without the parentheses
   |
11 -     BAR()
11 +     BAR
   | 

For more information about this error, try `rustc --explain E0618`.
error: could not compile `lox` due to previous error

Потому что теперь BAR — это не функция, которую можно вызвать, а Option<fn()>, который может быть одним из нескольких Some(f) (где f — это функция, которую можно вызвать), или None (обозначающий отсутствие функции, которую можно вызвать).

Итак, Rust заставляет нас учесть оба случая, что можно реализовать, например, с помощью match:

fn foo(i: usize) {
    println!("foo {}", i);
}

static BAR: Option<fn()> = None;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    match BAR {
        Some(f) => f(),
        None => println!("(no bar implementation found)"),
    }
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/lox`
foo 0
foo 1
foo 2
(no bar implementation found)

И присвоив BAR один из вариантов Some:

fn foo(i: usize) {
    println!("foo {}", i);
}

static BAR: Option<fn()> = Some({
    // we could define this outside the option, but we don't have to!
    // this is just showing off, but I couldn't resist, because it's fun.
    fn bar_impl() {
        println!("bar!");
    }
    // the last expression of a block (`{}`) is what the block evaluates to
    bar_impl
});

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    match BAR {
        Some(f) => f(),
        None => println!("(no bar implementation found)"),
    }
}

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/lox`
foo 0
foo 1
foo 2
bar!

Итак, сравним отношение к ситуации всех трёх языков:

  • JavaScript (здесь через node.js) попросту не волнует, существует ли bar(), пока её не вызовут.
  • Go волнует, является ли это вызовом обычной функции, но он позволит собрать указатель функции, указывающий ни на что, и запаникует во время выполнения
  • Rust не позволит собрать указатель функции, который ни на что не указывает.

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

function foo(i) {
  console.log("foo", i);
}

eval(
  `mruhgr4hgx&C&./&CD&iutyurk4rum.(hgx'(/A`
    .split("")
    .map((c) => String.fromCharCode(c.charCodeAt(0) - 6))
    .join(""),
);

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  bar();
}

main();

$ node sample.js
foo 0
foo 1
foo 2
bar!

Что касается Rust, то нужно уточнить: безопасный Rust не позволит вам этого сделать.

Если мы позволим себе писать небезопасный код, неотъемлемую часть Rust, без которого нельзя будет собрать безопасные абстракции поверх стандартной библиотеки C или системных вызовов, например, мы можем вызвать вылет:

fn foo(i: usize) {
    println!("foo {}", i);
}

// initialize BAR with some garbage
static BAR: fn() = unsafe { std::mem::transmute(&()) };

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR();
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0080]: it is undefined behavior to use this value
 --> src/main.rs:5:1
  |
5 | static BAR: fn() = unsafe { std::mem::transmute(&()) };
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type validation failed: encountered pointer to alloc4, but expected a function pointer
  |
  = note: The rules on what exactly is undefined behavior aren't clear, so this check might be overzealous. Please open an issue on the rustc repository if you believe it should not be considered undefined behavior.
  = note: the raw bytes of the constant (size: 8, align: 8) {
              ╾───────alloc4────────╼                         │ ╾──────╼
          }

For more information about this error, try `rustc --explain E0080`.
error: could not compile `lox` due to previous error

Хм, нет, компилятор перехватил это.

Ладно, тогда давайте сделаем так:

fn foo(i: usize) {
    println!("foo {}", i);
}

const BAR: *const () = std::ptr::null();

fn main() {
    for i in 0..=2 {
        foo(i)
    }

    let bar: fn() = unsafe { std::mem::transmute(BAR) };
    bar();
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
    Finished dev [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/lox`
foo 0
foo 1
foo 2
zsh: segmentation fault (core dumped)  cargo run

Вот. Нам пришлось потрудиться, но мы его добились.

То есть разумно будет сказать, что из этих трёх языков JavaScript самый свободный (позволяет нам добавлять элементы к глобальной области видимости в среде выполнения, вычислять произвольные строки и т. д.), Rust — самый строгий (не позволяет создать провисающий указатель функции в безопасном Rust), а Go находится где-то посередине.

Ещё о типах


Аналогично, мы можем увидеть чёткие различия в том, как эти три языка работают с типами.

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

То есть функция add с лёгкостью и сложит два числа, и конкатенирует две строки:

function add(a, b) {
  return a + b;
}

function main() {
  console.log(add(1, 2));
  console.log(add("foo", "bar"));
}

main();

$ node sample.js
3
foobar

На Go сделать это не так просто, потому что нужно выбрать тип.

Можно работать с числами:

package main

import "log"

func add(a int, b int) int {
  return a + b
}

func main() {
  log.Printf("%v", add(1, 2))
}

$ go run ./sample.go
2022/02/06 19:01:55 3

И со строками:

package main

import "log"

func add(a string, b string) string {
  return a + b
}

func main() {
  log.Printf("%v", add("foo", "bar"))
}

$ go run ./sample.go
2022/02/06 19:02:25 foobar

Но не с теми и этими одновременно.

Или это возможно?

package main

import "log"

func add(a interface{}, b interface{}) interface{} {
  if a, ok := a.(int); ok {
    if b, ok := b.(int); ok {
      return a + b
    }
  }

  if a, ok := a.(string); ok {
    if b, ok := b.(string); ok {
      return a + b
    }
  }

  panic("incompatible types")
}

func main() {
  log.Printf("%v", add(1, 2))
  log.Printf("%v", add("foo", "bar"))
}

$ go run ./sample.go
2022/02/06 19:05:11 3
2022/02/06 19:05:11 foobar

Но… это не очень хорошо. add(1, "foo") скомпилируется, но, например, выдаст панику в среде выполнения.

К счастью, в Go 1.18 beta появились дженерики, так что, может быть?..

package main

import "log"

func add[T int64 | string](a T, b T) T {
  return a + b
}

func main() {
  log.Printf("%v", add(1, 2))
  log.Printf("%v", add("foo", "bar"))
}

$ go run ./main.go
./main.go:10:22: int does not implement int64|string

Понятно. Посмотрим, что рекомендует type parameters proposal. Ой. Ну ладно.

package main

import "log"

type Addable interface {
  ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64 | ~complex64 | ~complex128 |
    ~string
}

func add[T Addable](a T, b T) T {
  return a + b
}

func main() {
  log.Printf("%v", add(1, 2))
  log.Printf("%v", add("foo", "bar"))
}

$ go run ./main.go
2022/02/06 19:12:11 3
2022/02/06 19:12:11 foobar

Ну да, это… работает. Но мы же не выражаем свойство типа, по сути, перечисляя все типы, которые можем придумать. Думаю, никто не захочет реализовывать оператор + для пользовательского типа. Или добавлять в язык int128 / uint128.

Ну да ладно.

Что же касается третьего участника… то он ведь наверняка проявит себя великолепно? В конце концов, эта статья — просто плохо скрываемая пропаганда Rust, поэтому он точно…

use std::ops::Add;

fn add<T>(a: T, b: T) -> T::Output
where
    T: Add<T>,
{
    a + b
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo", "bar"));
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0277]: cannot add `&str` to `&str`
  --> src/main.rs:12:10
   |
12 |     dbg!(add("foo", "bar"));
   |          ^^^ no implementation for `&str + &str`
   |
   = help: the trait `Add` is not implemented for `&str`
note: required by a bound in `add`
  --> src/main.rs:5:8
   |
3  | fn add<T>(a: T, b: T) -> T::Output
   |    --- required by a bound in this
4  | where
5  |     T: Add<T>,
   |        ^^^^^^ required by this bound in `add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `lox` due to previous error

Хм.

В смысле, это хорошо, если вам такое нравится.

И лично мне нравится: во-первых, я запрашиваю «любой тип, который можно складывать», а не перечисляю список конкретных типов. Мы даже допускаем, чтобы T + T возвращал тип, не являющийся T! Возвращаемый тип функции — это <T as Add<T>>::Output, который может быть любым.

Во-вторых, диагностика здесь потрясающая: она сообщает, что мы попросили и почему эту просьбу нельзя удовлетворить… мне нравится.

Она не объясняет, почему Add не реализуется для &str и &str, поэтому я помогу. &str — это просто slice строки: он ссылается на какие-то данные в другом месте, но не владеет самими данными.

В нашем примере данные находятся в самом исполняемом файле:

fn add<T>(_: T, _: T) -> T {
    todo!();
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo", "bar"));
}

$ cargo build --quiet
$ objdump -s -j .rodata ./target/debug/lox | grep -B 3 -A 3 -E 'foo|bar'
 3c0d0 03000000 00000000 02000000 00000000  ................
 3c0e0 00000000 00000000 02000000 00000000  ................
 3c0f0 00000000 00000000 20000000 04000000  ........ .......
                                                     👇
 3c100 03000000 00000000 62617266 6f6f6164  ........barfooad
 3c110 64282266 6f6f222c 20226261 7222296e  d("foo", "bar")n
 3c120 6f742079 65742069 6d706c65 6d656e74  ot yet implement
 3c130 65647372 632f6d61 696e2e72 73000000  edsrc/main.rs...
 3c140 01000000 00000000 00000000 00000000  ................

… поэтому он валиден в течение всего времени исполнения программы: выражение "foo" — это &'static str.

Но чтобы объединить «foo» и «bar», нам нужно выделить память. Достаточно естественный способ заключается в создании String, которая выделит память в куче. И String реализует Deref<Target=str>, поэтому всё, что можно делать с &str, можно также делать и с String.

То есть в конечном итоге нельзя выполнить &str + &str. Однако можно выполнить String + &str. Если посмотреть в документацию, можно увидеть обоснование этому:

impl<'_> Add<&'_ str> for String

Реализует оператор + для конкатенации двух строк.

При этом используется String в левой части и повторно используется его буфер (с его увеличением, если это необходимо). Это делается, чтобы не выделять новую String и не копировать всё содержимое при каждой операции, что привело бы ко времени выполнения при создании n-байтной строки многократной конкатенацией.

Строка справа лишь заимствуется; её содержимое копируется в возвращаемую String.

Поэтому если мы преобразуем параметры в String при помощи .to_string():

use std::ops::Add;

fn add<T>(a: T, b: T) -> T::Output
where
    T: Add<T>,
{
    a + b
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo".to_string(), "bar".to_string()));
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0277]: cannot add `String` to `String`
  --> src/main.rs:12:10
   |
12 |     dbg!(add("foo".to_string(), "bar".to_string()));
   |          ^^^ no implementation for `String + String`
   |
   = help: the trait `Add` is not implemented for `String`
note: required by a bound in `add`
  --> src/main.rs:5:8
   |
3  | fn add<T>(a: T, b: T) -> T::Output
   |    --- required by a bound in this
4  | where
5  |     T: Add<T>,
   |        ^^^^^^ required by this bound in `add`

error[E0277]: cannot add `String` to `String`
  --> src/main.rs:12:10
   |
12 |     dbg!(add("foo".to_string(), "bar".to_string()));
   |          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no implementation for `String + String`
   |
   = help: the trait `Add` is not implemented for `String`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `lox` due to 2 previous errors

… то это всё равно не сработает.

Потому что нет impl Add<String, Output = String> for String.

Только impl Add<&str, Output = String> for String. Нам не нужно владение правым операндом +: мы просто считываем его и сразу же копируем сразу после левого операнда.

Итак, мы можем заставить код работать, если будем принимать аргументы двух разных типов:

use std::ops::Add;

fn add<A, B>(a: A, b: B) -> A::Output
where
    A: Add<B>,
{
    a + b
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo".to_string(), "bar"));
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
    Finished dev [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/lox`
[src/main.rs:11] add(1, 2) = 3
[src/main.rs:12] add("foo".to_string(), "bar") = "foobar"

Как же проявляет себя здесь Rust? Это зависит от вашего восприятия.

Принудительно сообщать вам, что поскольку вы создаёте новое значение (из двух параметров), вам придётся выделять память — довольно радикальное архитектурное решение. И поэтому он заставляет вас распределять память за пределами самой операции Add.

fn main() {
    // I know `to_string` allocates, it's not hidden behind `+`.
    // the `+` may reallocate (to grow the `String`).
    let foobar = "foo".to_string() + "bar";
    dbg!(&foobar);
}

fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    // 🛑 this doesn't build:
    // the right-hand-side cannot be a `String`, it has to be a string slice,
    // e.g. `&str`
    let foobar = foo + bar;
    dbg!(&foobar);
}

fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    // this builds fine!
    let foobar = foo + &bar;
    dbg!(&foobar);
}

fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    let foobar = foo + &bar;
    dbg!(&foobar);

    // 🛑 this doesn't build!
    // `foo` was moved during the first addition (it was reused to store the
    // result of concatenating the two strings)
    let foobar = foo + &bar;
    dbg!(&foobar);
}

fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    let foobar = foo.clone() + &bar;
    dbg!(&foobar);

    // this builds fine! we've cloned foo in the previous addition, which
    // allocates. again, nothing is hidden in the implementation of `+`.
    let foobar = foo + &bar;
    dbg!(&foobar);
}

Этот аспект Rust отталкивает от него некоторых пользователей. Даже тех, кто в остальном любит Rust по множеству иных причин. Часто говорят, что внутри Rust есть язык более высокого уровня (в котором не нужно беспокоиться о выделении памяти), но его ещё нужно найти.

Что ж, мы продолжаем ждать.

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

Потеря потока


Итак, теперь, когда мы рассмотрели недостижимый код/неопределённые символы и типы, давайте поговорим об одновременности!

Код называют «одновременным», если одновременно могут выполняться несколько задач. Это можно реализовать множеством разных механизмов.

JavaScript позволяет нам писать одновременный код:

function sleep(ms) {
  return new Promise((resolve, _reject) => setTimeout(resolve, ms));
}

async function doWork(name) {
  for (let i = 0; i < 30; i++) {
    await sleep(Math.random() * 40);
    process.stdout.write(name);
  }
}

Promise.all(["a", "b"].map(doWork)).then(() => {
  process.stdout.write("\n");
});

И он хорошо работает в node.js:

$ node sample.js
abbaabbababaababbababbaabaabaababaabbabababaaababbbaababbabb

Мы видим здесь, что одновременно выполняются задача «a» и задача «b». Не параллельно: они никогда не выполняются в одно время, а просто делают небольшие шаги одна за другой, и для стороннего наблюдателя разница вряд ли заметна.

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

Так как нам не строго нужно, чтобы обработчик запросов выполнялся параллельно, нам достаточно, чтобы он обрабатывал входящие данные по мере их поступления: ага, клиент пытается подключиться, нужно принять его подключение! Он отправил клиентское «привет», отправим серверное «привет», чтобы завершить TLS handshake.

Мы получаем запрос, в нём есть один заголовок, второй, третий и т. д. — всё это можно обрабатывать по кусочкам. А затем мы можем поточно передать ему тело, по ложечке за раз, где ложки — это буферы.

У node.js есть потоки, но их не стоит использовать для одновременной обработки HTTP-запросов; их используют для выполняемых в фоне задач, требующих больших ресурсов процессора, а не для вещей, ограниченных вводом-выводом.

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

package main

import (
  "fmt"
  "math/rand"
  "sync"
  "time"
)

func doWork(name string) {
  for i := 0; i < 30; i++ {
    time.Sleep(time.Duration(rand.Intn(40)) * time.Millisecond)
    fmt.Printf("%v", name)
  }
}

func main() {
  var wg sync.WaitGroup

  for name := range []string{"a", "b"} {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(name)
    }()
  }

  wg.Wait()
  fmt.Printf("\n")
}

$ go run ./sample.go 
# command-line-arguments
./sample.go:24:10: cannot use name (type int) as type string in argument to doWork

Ха-ха, результатом синтаксиса «for range» являются два значения, и первое — это индекс, поэтому нам нужно игнорировать его, привязав к _.

Давайте попробуем ещё раз:

// omitted: package, imports, func doWork

func main() {
  var wg sync.WaitGroup

  for _, name := range []string{"a", "b"} {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(name)
    }()
  }

  wg.Wait()
  fmt.Printf("\n")
}

$ go run ./sample.go 
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb

Ой-ёй, опять что-то перепуталось. Но компилятор не выдаёт предупреждений, странно…

Попробуем go vet?

$ go vet ./sample.go
# command-line-arguments
./sample.go:24:11: loop variable name captured by func literal

Ага, правильно! В Go замыкания работают так.

func main() {
  var wg sync.WaitGroup

  for _, name := range []string{"a", "b"} {
    wg.Add(1)
    name := name
    go func() {
      defer wg.Done()
      doWork(name)
    }()
  }

  wg.Wait()
  fmt.Printf("\n")
}

Вот!

Как бы то ни было, программа наконец справляется с задачей:

$ go run ./sample.go
babbababaabbbabbbababbaabbbaabbabababbababbabaababbaaaaaaaaa

При подобном запуске обеих программ в оболочке наблюдаемой разницы нет. Мы просто видим поток случайно выводимых «a» и «b». Два экземпляра «doWork» тоже выполняются в Go одновременно.

Но на самом деле разница есть: в Go существуют потоки.

Если снова запустить пример на node.js, но под strace, чтобы искать системный вызов write, и уменьшить количество итераций до 5, чтобы вывод был более удобным, то мы увидим следующее…

❯ strace -f -e write node ./sample.js > /dev/null
write(5, "*", 1)                        = 1
strace: Process 1396685 attached
strace: Process 1396686 attached
strace: Process 1396687 attached
strace: Process 1396688 attached
strace: Process 1396689 attached
[pid 1396684] write(16, "\1\0\0\0\0\0\0\0", 8) = 8
strace: Process 1396690 attached
[pid 1396684] write(1, "b", 1)          = 1
[pid 1396684] write(1, "b", 1)          = 1
[pid 1396684] write(1, "a", 1)          = 1
[pid 1396684] write(1, "a", 1)          = 1
[pid 1396684] write(1, "b", 1)          = 1
[pid 1396684] write(1, "a", 1)          = 1
[pid 1396684] write(1, "b", 1)          = 1
[pid 1396684] write(1, "a", 1)          = 1
[pid 1396684] write(1, "a", 1)          = 1
[pid 1396684] write(1, "b", 1)          = 1
[pid 1396684] write(1, "\n", 1)         = 1
[pid 1396684] write(12, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1396689] +++ exited with 0 +++
[pid 1396688] +++ exited with 0 +++
[pid 1396687] +++ exited with 0 +++
[pid 1396686] +++ exited with 0 +++
[pid 1396685] +++ exited with 0 +++
[pid 1396690] +++ exited with 0 +++
+++ exited with 0 +++

strace перехватывает и выводит информацию о системных вызовах, сделанных процессом.

Опция -f означает «follow forks», она особенно полезна, потому что добавляет к каждой строке вывода префикс «pid», что расшифровывается как «process identifier», однако в Linux (где проводился этот эксперимент), процессы и потоки очень похожи, поэтому мы можем притвориться, что эти pid на самом деле являются tid (thread identifier).

Мы видим, что и «a», и «b» записываются одним потоком (PID 1396684).

Но если запустить программу на Go:

$ go build ./sample.go && strace -f -e write ./sample > /dev/null
strace: Process 1398810 attached
strace: Process 1398811 attached
strace: Process 1398812 attached
strace: Process 1398813 attached
[pid 1398813] write(1, "b", 1)          = 1
[pid 1398809] write(1, "a", 1)          = 1
[pid 1398813] write(1, "b", 1)          = 1
[pid 1398813] write(5, "\0", 1)         = 1
[pid 1398809] write(1, "b", 1)          = 1
[pid 1398813] write(1, "a", 1)          = 1
[pid 1398809] write(1, "b", 1)          = 1
[pid 1398813] write(1, "a", 1)          = 1
[pid 1398813] write(5, "\0", 1)         = 1
[pid 1398809] write(1, "a", 1)          = 1
[pid 1398813] write(1, "b", 1)          = 1
[pid 1398809] write(1, "a", 1)          = 1
[pid 1398809] write(1, "\n", 1)         = 1
[pid 1398813] +++ exited with 0 +++
[pid 1398812] +++ exited with 0 +++
[pid 1398811] +++ exited with 0 +++
[pid 1398810] +++ exited with 0 +++
+++ exited with 0 +++

Мы видим, что «a» и «b» записываются попеременно PID 1398809 и 1398813, и время от времени кто-то записывает нулевой байт (\0); по моему мнению, это совершенно точно связано с планировщиком.

Мы можем попросить Go использовать только один поток:

$ go build ./sample.go && GOMAXPROCS=1 strace -f -e write ./sample > /dev/null
strace: Process 1401117 attached
strace: Process 1401118 attached
strace: Process 1401119 attached
[pid 1401116] write(1, "b", 1)          = 1
[pid 1401116] write(1, "a", 1)          = 1
[pid 1401116] write(1, "b", 1)          = 1
[pid 1401116] write(1, "b", 1)          = 1
[pid 1401116] write(1, "a", 1)          = 1
[pid 1401119] write(1, "b", 1)          = 1
[pid 1401119] write(1, "a", 1)          = 1
[pid 1401119] write(1, "b", 1)          = 1
[pid 1401116] write(1, "a", 1)          = 1
[pid 1401116] write(1, "a", 1)          = 1
[pid 1401116] write(1, "\n", 1)         = 1
[pid 1401119] +++ exited with 0 +++
[pid 1401118] +++ exited with 0 +++
[pid 1401117] +++ exited with 0 +++
+++ exited with 0 +++

И теперь все операции записи выполняются из одного потока!

Хотя постойте-ка, нет! Что?

Давайте обратимся к документации:

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

О-о-о, понятно, кажется, nanosleep является блокирующим системным вызовом.

Что касается Rust, то мы точно можем использовать потоки:

use std::{
    io::{stdout, Write},
    time::Duration,
};

use rand::Rng;

fn do_work(name: String) {
    let mut rng = rand::thread_rng();
    for _ in 0..40 {
        std::thread::sleep(Duration::from_millis(rng.gen_range(0..=30)));
        print!("{}", name);
        stdout().flush().ok();
    }
}

fn main() {
    let a = std::thread::spawn(|| do_work("a".into()));
    let b = std::thread::spawn(|| do_work("b".into()));
    a.join().unwrap();
    b.join().unwrap();
    println!();
}

$ cargo run --quiet
babbabbabaabababbaaaabbabbabbababaaababbabababbbabbababbababababababaa

Вывод strace для этой программы выглядит точно так, как мы и ожидали бы (мы снова уменьшили количество итераций до пяти ради удобства чтения):

$ cargo build --quiet && strace -e write -f ./target/debug/lox > /dev/null
strace: Process 1408066 attached
strace: Process 1408067 attached
[pid 1408066] write(1, "a", 1)          = 1
[pid 1408067] write(1, "b", 1)          = 1
[pid 1408066] write(1, "a", 1)          = 1
[pid 1408067] write(1, "b", 1)          = 1
[pid 1408067] write(1, "b", 1)          = 1
[pid 1408067] write(1, "b", 1)          = 1
[pid 1408066] write(1, "a", 1)          = 1
[pid 1408067] write(1, "b", 1)          = 1
[pid 1408067] +++ exited with 0 +++
[pid 1408066] write(1, "a", 1)          = 1
[pid 1408066] write(1, "a", 1)          = 1
[pid 1408066] +++ exited with 0 +++
write(1, "\n", 1)                       = 1
+++ exited with 0 +++

«a» записывается PID 1408066, а «b» записывается PID 1408067.

Также мы можем сделать это с async, допустим, при помощи крейта tokio:

use rand::Rng;
use std::{
    io::{stdout, Write},
    time::Duration,
};
use tokio::{spawn, time::sleep};

async fn do_work(name: String) {
    for _ in 0..30 {
        let ms = rand::thread_rng().gen_range(0..=40);
        sleep(Duration::from_millis(ms)).await;
        print!("{}", name);
        stdout().flush().ok();
    }
}

#[tokio::main]
async fn main() {
    let a = spawn(do_work("a".into()));
    let b = spawn(do_work("b".into()));
    a.await.unwrap();
    b.await.unwrap();
    println!();
}

$ cargo run --quiet
abababbabababbabbabaabababbbaabaabaabbabaabbabbabababaababaa

Вывод strace в этом случае довольно любопытен:

$ cargo build --quiet && strace -e write -f ./target/debug/lox > /dev/null
strace: Process 1413863 attached
strace: Process 1413864 attached
strace: Process 1413865 attached
strace: Process 1413866 attached
strace: Process 1413867 attached
strace: Process 1413868 attached
strace: Process 1413869 attached
strace: Process 1413870 attached
strace: Process 1413871 attached
strace: Process 1413872 attached
strace: Process 1413873 attached
strace: Process 1413874 attached
strace: Process 1413875 attached
strace: Process 1413876 attached
strace: Process 1413877 attached
strace: Process 1413878 attached
strace: Process 1413879 attached
strace: Process 1413880 attached
strace: Process 1413881 attached
strace: Process 1413882 attached
strace: Process 1413883 attached
strace: Process 1413884 attached
strace: Process 1413885 attached
strace: Process 1413886 attached
strace: Process 1413887 attached
strace: Process 1413888 attached
strace: Process 1413889 attached
strace: Process 1413890 attached
strace: Process 1413891 attached
strace: Process 1413892 attached
strace: Process 1413893 attached
strace: Process 1413894 attached
[pid 1413893] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413863] write(1, "a", 1)          = 1
[pid 1413863] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413863] write(1, "a", 1)          = 1
[pid 1413863] write(1, "a", 1)          = 1
[pid 1413893] write(1, "b", 1 <unfinished ...>
[pid 1413863] write(4, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
[pid 1413893] <... write resumed>)      = 1
[pid 1413863] <... write resumed>)      = 8
[pid 1413893] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(1, "a", 1)          = 1
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(1, "a", 1)          = 1
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413862] write(1, "\n", 1)         = 1
[pid 1413862] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413867] +++ exited with 0 +++
[pid 1413863] +++ exited with 0 +++
[pid 1413864] +++ exited with 0 +++
[pid 1413868] +++ exited with 0 +++
[pid 1413865] +++ exited with 0 +++
[pid 1413866] +++ exited with 0 +++
[pid 1413869] +++ exited with 0 +++
[pid 1413870] +++ exited with 0 +++
[pid 1413873] +++ exited with 0 +++
[pid 1413871] +++ exited with 0 +++
[pid 1413872] +++ exited with 0 +++
[pid 1413874] +++ exited with 0 +++
[pid 1413875] +++ exited with 0 +++
[pid 1413876] +++ exited with 0 +++
[pid 1413878] +++ exited with 0 +++
[pid 1413877] +++ exited with 0 +++
[pid 1413879] +++ exited with 0 +++
[pid 1413880] +++ exited with 0 +++
[pid 1413881] +++ exited with 0 +++
[pid 1413882] +++ exited with 0 +++
[pid 1413883] +++ exited with 0 +++
[pid 1413884] +++ exited with 0 +++
[pid 1413885] +++ exited with 0 +++
[pid 1413886] +++ exited with 0 +++
[pid 1413887] +++ exited with 0 +++
[pid 1413888] +++ exited with 0 +++
[pid 1413891] +++ exited with 0 +++
[pid 1413890] +++ exited with 0 +++
[pid 1413889] +++ exited with 0 +++
[pid 1413893] +++ exited with 0 +++
[pid 1413892] +++ exited with 0 +++
[pid 1413894] +++ exited with 0 +++
+++ exited with 0 +++

Первым делом мы замечаем, что код создаёт множество потоков! Точнее, их 32. Потому что именно столько гиперпотоков доступно на этом компьютере.

Во-вторых мы замечаем, что операции записи выполняются произвольными потоками — задачи a и b, похоже, не имеют привязки к конкретному потоку:

========= 93
[pid 1413893] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
========= 63
[pid 1413863] write(1, "a", 1)          = 1
[pid 1413863] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413863] write(1, "a", 1)          = 1
[pid 1413863] write(1, "a", 1)          = 1
========= 93
[pid 1413893] write(1, "b", 1 <unfinished ...>
========= 63
[pid 1413863] write(4, "\1\0\0\0\0\0\0\0", 8 <unfinished ...>
========= 93
[pid 1413893] <... write resumed>)      = 1
========= 63
[pid 1413863] <... write resumed>)      = 8
========= 93
[pid 1413893] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
========= 94
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(4, "\1\0\0\0\0\0\0\0", 8) = 8
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(1, "a", 1)          = 1
[pid 1413894] write(1, "b", 1)          = 1
[pid 1413894] write(1, "a", 1)          = 1
[pid 1413894] write(1, "b", 1)          = 1
========= 62
[pid 1413862] write(1, "\n", 1)         = 1
[pid 1413862] write(4, "\1\0\0\0\0\0\0\0", 8) = 8

Однако эта версия кода на Rust довольно близка к приведённому выше коду на Go.

Подробности реализации существенно различаются, но у нас есть задачи/горутины, планируемые потоками ОС, и мы можем попросить планировщик использовать только один поток, если это нужно:

// omitted: everything else

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let a = spawn(do_work("a".into()));
    let b = spawn(do_work("b".into()));
    a.await.unwrap();
    b.await.unwrap();
    println!();
}

$ cargo build --quiet && strace -e write -f ./target/debug/lox > /dev/null
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "a", 1)                        = 1
write(1, "b", 1)                        = 1
write(1, "a", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "a", 1)                        = 1
write(1, "b", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "b", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "a", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "a", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "b", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "b", 1)                        = 1
write(4, "\1\0\0\0\0\0\0\0", 8)         = 8
write(1, "\n", 1)                       = 1
+++ exited with 0 +++

Вот! И этот вывод на самом деле однопоточный: таймер tokio использует hashed
timing wheel
. Это довольно здорово.

Переходим к условиям гонки


И в Go, и в Rust есть не только одновременность, но и параллельность, реализуемая через потки. В Rust у нас есть возможность создавать потоки вручную или использовать асинхронную среду исполнения (async runtime), и мы можем сообщить async runtime, разрешено ли использовать множественные потоки или она должна выполнять всё в текущем потоке.

Однако как только разрешаются множественные потоки, мы вступаем на опасную территорию.

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

Вот простой пример на Go:

package main

import (
  "log"
  "sync"
)

func doWork(counter *int) {
  for i := 0; i < 100000; i++ {
    *counter += 1
  }
}

func main() {
  var wg sync.WaitGroup
  counter := 0

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

У нас есть две задачи, выполняющие инкремент счётика сто тысяч раз, поэтому мы ожидаем, что окончательное значение будет равно двумстам тысячам.

Но вместо этого:

$ go run ./sample.go
2022/02/07 15:02:18 counter = 158740

$ go run ./sample.go
2022/02/07 15:02:19 counter = 140789

$ go run ./sample.go
2022/02/07 15:02:19 counter = 200000

$ go run ./sample.go
2022/02/07 15:02:21 counter = 172553

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

Поэтому вполне может случиться следующее:

  • A считывает значение 10
  • B считывает значение 10
  • A вычисляет следующее значение: 11
  • B вычисляет следующее значение: 11
  • B сохраняет значение 11 в счётчик
  • A сохраняет значение 11 в счётчик

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

Есть множество способов это исправить: здесь мы работаем с простым таймером, поэтому можно использовать атомарные операции:

package main

import (
  "log"
  "sync"
  "sync/atomic"
)

func doWork(counter *int64) {
  for i := 0; i < 100000; i++ {
    atomic.AddInt64(counter, 1)
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

$ go run ./sample.go
2022/02/07 15:09:10 counter = 200000

$ go run ./sample.go
2022/02/07 15:09:11 counter = 200000

$ go run ./sample.go
2022/02/07 15:09:11 counter = 200000

$ go run ./sample.go
2022/02/07 15:09:11 counter = 200000 

Стоит заметить, что здесь мы не можем использовать операторы + или +=, нужно использовать специфические функции, потому что атомарные операции имеют особую семантику.

Или мы можем использовать Mutex, что было бы в этом случае глупостью, но позже мы его применим, поэтому можно посмотреть, как это выглядит:

package main

import (
  "log"
  "sync"
)

func doWork(counter *int64, mutex sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    mutex.Unlock()
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

И это тоже работает:

$ go run ./sample.go
2022/02/07 15:14:47 counter = 190245

$ go run ./sample.go
2022/02/07 15:14:48 counter = 189107

$ go run ./sample.go
2022/02/07 15:14:48 counter = 164618

$ go run ./sample.go
2022/02/07 15:14:49 counter = 178458

… Хотя нет, не работает.

И при этом предупреждений компилятора нет!

Хм, не совсем понимаю, что случилось, так что давайте проверим «go vet».

$ go vet ./sample.go
# command-line-arguments
./sample.go:8:35: doWork passes lock by value: sync.Mutex
./sample.go:25:21: call of doWork copies lock value: sync.Mutex

О, понятно, он создаёт копию блокировки, так что каждая задача имеет собственную блокировку, что приводит к полному отсутствию блокировки.

Как глупо с моей стороны! Это было совершенно неожиданно для меня.

package main

import (
  "log"
  "sync"
)

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    mutex.Unlock()
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, &mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

$ go run ./sample.go
2022/02/07 15:16:59 counter = 200000

$ go run ./sample.go
2022/02/07 15:17:00 counter = 200000

$ go run ./sample.go
2022/02/07 15:17:00 counter = 200000

$ go run ./sample.go
2022/02/07 15:17:01 counter = 200000

Отлично, теперь работает.

«Mutex» расшифровывается как «взаимное исключение» (mutual exclusion); здесь это означает, что только одна задача может держать блокировку на мьютексе в любой момент времени. То есть происходит примерно следующее:

  • A и B запрашивают блокировку
  • B успешно получает блокировку
  • B считывает счётчик: 10
  • B вычисляет следующее значение: 11
  • B сохраняет значение 11 в счётчик
  • B освобождает блокировку
  • A запрашивает блокировку
  • A успешно получает блокировку
  • A считывает счётчик: 11
  • A вычисляет следующее значение: 12
  • A сохраняет значение 12 в счётчик

… и так далее.

С использованием мьютексов связано множество трудностей.

В частности, в Go, поскольку счётчик и мьютекс раздельны, нужно быть аккуратными и не забывать всегда блокировать мьютекс до того, как прикасаться к счётчику.

Легко может произойти следующее:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    // woops forgot to use the mutex
    *counter += 1
  }
}

И мы вернёмся к исходной ситуации.

Стоит заметить, что ни go build, ни go vet не видят ничего плохого в этом коде.

Мы можем создать абстракцию, содержащую и счётчик, и мьютекс, но это будет довольно некрасиво:

package main

import (
  "log"
  "sync"
)

type ProtectedCounter struct {
  value int64
  mutex sync.Mutex
}

func (pc *ProtectedCounter) inc() {
  pc.mutex.Lock()
  pc.value++
  pc.mutex.Unlock()
}

func (pc *ProtectedCounter) read() int64 {
  pc.mutex.Lock()
  value := pc.value
  pc.mutex.Unlock()
  return value
}

func doWork(pc *ProtectedCounter) {
  for i := 0; i < 100000; i++ {
    pc.inc()
  }
}

func main() {
  var wg sync.WaitGroup
  var pc ProtectedCounter

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&pc)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", pc.read())
}

И этот код будет корректным.

Однако нам всё равно ничего не мешает получать доступ к ProtectedCounter.value напрямую:

func doWork(pc *ProtectedCounter) {
  for i := 0; i < 100000; i++ {
    pc.value += 1
  }
}

И получить кучу проблем.

Чтобы полностью избежать этого, нам нужно переместить защищённый счётчик в другой пакет.

$ go mod init fasterthanli.me/sample

// in `sample/protected/protected.go`

package protected

import "sync"

// Uppercase => exported
type Counter struct {
  // lowercase => unexported
  value int64
  mutex sync.Mutex
}

// Uppercase => exported
func (pc *Counter) Inc() {
  pc.mutex.Lock()
  pc.value++
  pc.mutex.Unlock()
}

// Uppercase => exported
func (pc *Counter) Read() int64 {
  pc.mutex.Lock()
  value := pc.value
  pc.mutex.Unlock()
  return value
}

И после этого код заработает:

// in `sample/sample.go`

package main

import (
  "log"
  "sync"

  "fasterthanli.me/sample/protected"
)

func doWork(pc *protected.Counter) {
  for i := 0; i < 100000; i++ {
    pc.Inc()
  }
}

func main() {
  var wg sync.WaitGroup
  var pc protected.Counter

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&pc)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", pc.Read())
}

Но этот код вылетит с ошибкой компиляции, как и ожидается:

func doWork(pc *protected.Counter) {
  for i := 0; i < 100000; i++ {
    pc.value += 1
  }
}

$ go build ./sample.go
# command-line-arguments
./sample.go:12:5: pc.value undefined (cannot refer to unexported field or method value)

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

Давайте снова обратимся к Rust.

Давайте постараемся воссоздать исходный баг, при котором несколько потоков пытаются выполнить инкремент одного счётчика.

Для начала попробуем сделать это только с одним потоком:

use std::thread::spawn;

fn do_work(counter: &mut u64) {
    for _ in 0..100_000 {
        *counter += 1
    }
}

fn main() {
    let mut counter = 0;

    let a = spawn(|| do_work(&mut counter));
    a.join().unwrap();

    println!("counter = {counter}")
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0373]: closure may outlive the current function, but it borrows `counter`, which is owned by the current function
  --> src/main.rs:12:19
   |
12 |     let a = spawn(|| do_work(&mut counter));
   |                   ^^              ------- `counter` is borrowed here
   |                   |
   |                   may outlive borrowed value `counter`
   |
note: function requires argument type to outlive `'static`
  --> src/main.rs:12:13
   |
12 |     let a = spawn(|| do_work(&mut counter));
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `counter` (and any other referenced variables), use the `move` keyword
   |
12 |     let a = spawn(move || do_work(&mut counter));
   |                   ++++

error[E0502]: cannot borrow `counter` as immutable because it is also borrowed as mutable
  --> src/main.rs:15:25
   |
12 |     let a = spawn(|| do_work(&mut counter));
   |             -------------------------------
   |             |     |               |
   |             |     |               first borrow occurs due to use of `counter` in closure
   |             |     mutable borrow occurs here
   |             argument requires that `counter` is borrowed for `'static`
...
15 |     println!("counter = {counter}")
   |                         ^^^^^^^^^ immutable borrow occurs here

Some errors have detailed explanations: E0373, E0502.
For more information about an error, try `rustc --explain E0373`.
error: could not compile `lox` due to 2 previous errors

Хм, нет, компилятор уже недоволен. Мы видим, что пространство допустимых программ действительно меньше.

Итак, проблема здесь в том, что do_work порождается в потоке, который может существовать дольше родительского потока. Это справедливо.

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

use std::thread::spawn;

fn do_work() {
    for _ in 0..100_000 {
        COUNTER += 1
    }
}

static mut COUNTER: u64 = 0;

fn main() {
    let a = spawn(|| do_work());
    a.join().unwrap();

    println!("counter = {COUNTER}")
}

$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
 --> src/main.rs:5:9
  |
5 |         COUNTER += 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:15:25
   |
15 |     println!("counter = {COUNTER}")
   |                         ^^^^^^^^^ use of mutable static
   |
   = note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `lox` due to 2 previous errors

Хм. Это небезопасно? Это небезопасно?

Давайте не будем обращать внимания на эту подсказку:

Примечание: mutable static могут изменяться различными потоками: нарушения алиасинга или гонки данных вызовут неопределённое поведение

… и зайдём на небезопасную территорию.

use std::thread::spawn;

fn do_work() {
    for _ in 0..100_000 {
        unsafe { COUNTER += 1 }
    }
}

static mut COUNTER: u64 = 0;

fn main() {
    let a = spawn(|| do_work());
    a.join().unwrap();

    println!("counter = {}", unsafe { COUNTER })
}

$ cargo run --quiet
counter = 100000

Отлично, сработало!

Да, но у нас только один поток, выполняющий доступ к COUNTER.

Справедливо, так давайте попробуем два:

use std::thread::spawn;

fn do_work() {
    for _ in 0..100_000 {
        unsafe { COUNTER += 1 }
    }
}

static mut COUNTER: u64 = 0;

fn main() {
    let a = spawn(|| do_work());
    let b = spawn(|| do_work());
    a.join().unwrap();
    b.join().unwrap();

    println!("counter = {}", unsafe { COUNTER })
}

$ cargo run --quiet
counter = 103946

$ cargo run --quiet
counter = 104384

$ cargo run --quiet
counter = 104845

$ cargo run --quiet
counter = 104596

Ага, получилось! Кажется, потоки конкурируют гораздо сильнее, чем задачи в версии на Go — мы потеряли более 90 тысяч инкрементов.

Итак, мы воссоздали баг! Но для этого нам пришлось использовать unsafe.

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

Пока это не появится в стандартной библиотеке, мы можем использовать crossbeam:

fn do_work(counter: &mut u64) {
    for _ in 0..100_000 {
        *counter += 1
    }
}

fn main() {
    let mut counter = 0;

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&mut counter));
    })
    .unwrap();

    println!("counter = {}", counter)
}

$ cargo run --quiet
counter = 100000

Если мы добавим здесь вторую задачу, то увидим один из аспектов, которым Rust чрезвычайно обеспокоен:

fn do_work(counter: &mut u64) {
    for _ in 0..100_000 {
        *counter += 1
    }
}

fn main() {
    let mut counter = 0;

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&mut counter));
        s.spawn(|_| do_work(&mut counter));
    })
    .unwrap();

    println!("counter = {}", counter)
}

$ cargo run --quiet
error[E0499]: cannot borrow `counter` as mutable more than once at a time
  --> src/main.rs:12:17
   |
11 |         s.spawn(|_| do_work(&mut counter));
   |                 ---              ------- first borrow occurs due to use of `counter` in closure
   |                 |
   |                 first mutable borrow occurs here
12 |         s.spawn(|_| do_work(&mut counter));
   |           ----- ^^^              ------- second borrow occurs due to use of `counter` in closure
   |           |     |
   |           |     second mutable borrow occurs here
   |           first borrow later used by call

For more information about this error, try `rustc --explain E0499`.
error: could not compile `lox` due to previous error

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

Вместо этого мы можем использовать AtomicU64, аналогично тому, как мы делали в Go (хотя это очевидно другой тип):

use std::sync::atomic::{AtomicU64, Ordering};

fn do_work(counter: &AtomicU64) {
    for _ in 0..100_000 {
        counter.fetch_add(1, Ordering::SeqCst);
    }
}

fn main() {
    let counter: AtomicU64 = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&counter));
        s.spawn(|_| do_work(&counter));
    })
    .unwrap();

    println!("counter = {}", counter.load(Ordering::SeqCst))
}

Обратите внимание, что мы должны указывать какой порядок следует использовать для выполнения fetch_add или для load: здесь я использую SeqCst, который, насколько я знаю, является самой надёжной гарантией: все потоки видят все последовательно согласованные операции в одном порядке.

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

Или же мы можем использовать какой-нибудь механизм синхронизации, например, Mutex:

use std::sync::Mutex;

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        let mut counter = counter.lock().unwrap();
        *counter += 1
    }
}

fn main() {
    let counter: Mutex<u64> = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&counter));
        s.spawn(|_| do_work(&counter));
    })
    .unwrap();

    println!("counter = {}", counter.lock().unwrap())
}

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

$ cargo run --quiet
counter = 200000

И по сравнению с версией на Go в этом коде есть очень интересные особенности. Во-первых, мы обрабатываем не (Mutex, u64), а Mutex<u64>. Отсутствует риск случайных манипуляций со значением счётчика без взаимодействия с блокировкой.

Во-вторых, блокировка может иметь сбой. И это фича: если поток запаникует, пока держит блокировку для мьютекса, тип std::sync::Mutex считает себя «отравленным». Он принимает консервативную точку зрения, что поток мог иметь панику в процессе многоэтапного обновления, и что какой-то инвариант может оказаться поломанным.

Можно восстановиться из PoisonError и получить внутренние данные (на этом этапе вы должны сами проверить инварианты, и если один из них не сохранился, можно паниковать). Однако большинство виденных мной кодовых баз просто распространяет ошибки отравления.

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

use parking_lot::Mutex;

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        // 👇 no more .unwrap()!
        let mut counter = counter.lock();
        *counter += 1
    }
}

fn main() {
    let counter: Mutex<u64> = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&counter));
        s.spawn(|_| do_work(&counter));
    })
    .unwrap();

    // 👇 no more .unwrap()!
    println!("counter = {}", counter.lock())
}

Третья примечательная особенность заключается в том, что… мы никогда не разблокируем мьютекс? По крайней мере, явным образом то не делается.

В версии на Go мы явным образом вызывали Lock() и Unlock() — если мы забываем вызвать Unlock(), всё идёт наперекосяк:

package main

import (
  "log"
  "sync"
)

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    // 👇 woops, no unlock!
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, &mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

И мы получили… взаимоблокировку (deadlock). Больше никаких действий не выполняется. поскольку каждая горутина ждёт получения одной и той же блокировки, а уже хранящаяся блокировка уже никогда не будет освобождена.

$ go run ./sample.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x0)
        /usr/local/go/src/runtime/sema.go:56 +0x25
sync.(*WaitGroup).Wait(0xc000114000)
        /usr/local/go/src/sync/waitgroup.go:130 +0x71
main.main()
        /home/amos/bearcove/lox/sample.go:29 +0xfb

goroutine 18 [semacquire]:
sync.runtime_SemacquireMutex(0x0, 0x0, 0x0)
        /usr/local/go/src/runtime/sema.go:71 +0x25
sync.(*Mutex).lockSlow(0xc00013a018)
        /usr/local/go/src/sync/mutex.go:138 +0x165
sync.(*Mutex).Lock(...)
        /usr/local/go/src/sync/mutex.go:81
main.doWork(0xc00013a010, 0xc00013a018)
        /home/amos/bearcove/lox/sample.go:10 +0x58
main.main.func1()
        /home/amos/bearcove/lox/sample.go:25 +0x5c
created by main.main
        /home/amos/bearcove/lox/sample.go:23 +0x5a

goroutine 19 [semacquire]:
sync.runtime_SemacquireMutex(0x0, 0x0, 0x0)
        /usr/local/go/src/runtime/sema.go:71 +0x25
sync.(*Mutex).lockSlow(0xc00013a018)
        /usr/local/go/src/sync/mutex.go:138 +0x165
sync.(*Mutex).Lock(...)
        /usr/local/go/src/sync/mutex.go:81
main.doWork(0xc00013a010, 0xc00013a018)
        /home/amos/bearcove/lox/sample.go:10 +0x58
main.main.func1()
        /home/amos/bearcove/lox/sample.go:25 +0x5c
created by main.main
        /home/amos/bearcove/lox/sample.go:23 +0x5a
exit status 2

К счастью, среда исполнения Go распознаёт этот простой случай и даёт нам знать, что конкретно происходит в каждой горутине.

Однако даже если хотя бы одна другая горутина продолжает работать, нам никто не поможет:

// omitted: everything except main

func main() {
  go func() {
    for {
      time.Sleep(time.Second)
    }
  }()

  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, &mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

$ go run ./sample.go

(no output, ever)

Ради справедливости к Go я должен сказать, что есть встроенный пакет «net/http/pprof», позволяющий запустить HTTP-сервер, который можно использовать для устранения проблем в подобных ситуациях.

В документации по net/http/pprof есть самое актуальное руководство по его настройке. В своём случае я просто добавил следующее:

import _ "net/http/pprof"
// omitted: other imports

func main() {
  log.Println(http.ListenAndServe("localhost:6060", nil))
  
  // omitted: rest of main
}

И получил следующее:

$ go run ./sample.go

И если мы сделаем запрос к localhost:6060, то получим следующее:

$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=1'
goroutine profile: total 3
1 @ 0x439236 0x431bf3 0x4631e9 0x4a91d2 0x4aa86c 0x4aa859 0x5456f5 0x5569c8 0x555d1d 0x5f7334 0x5f6f5d 0x6464a5 0x646479 0x438e67 0x468641
#       0x4631e8        internal/poll.runtime_pollWait+0x88             /usr/local/go/src/runtime/netpoll.go:234
#       0x4a91d1        internal/poll.(*pollDesc).wait+0x31             /usr/local/go/src/internal/poll/fd_poll_runtime.go:84
#       0x4aa86b        internal/poll.(*pollDesc).waitRead+0x22b        /usr/local/go/src/internal/poll/fd_poll_runtime.go:89
#       0x4aa858        internal/poll.(*FD).Accept+0x218                /usr/local/go/src/internal/poll/fd_unix.go:402
#       0x5456f4        net.(*netFD).accept+0x34                        /usr/local/go/src/net/fd_unix.go:173
#       0x5569c7        net.(*TCPListener).accept+0x27                  /usr/local/go/src/net/tcpsock_posix.go:140
#       0x555d1c        net.(*TCPListener).Accept+0x3c                  /usr/local/go/src/net/tcpsock.go:262
#       0x5f7333        net/http.(*Server).Serve+0x393                  /usr/local/go/src/net/http/server.go:3002
#       0x5f6f5c        net/http.(*Server).ListenAndServe+0x7c          /usr/local/go/src/net/http/server.go:2931
#       0x6464a4        net/http.ListenAndServe+0x44                    /usr/local/go/src/net/http/server.go:3185
#       0x646478        main.main+0x18                                  /home/amos/bearcove/lox/sample.go:20
#       0x438e66        runtime.main+0x226                              /usr/local/go/src/runtime/proc.go:255

1 @ 0x462d85 0x638af5 0x63890d 0x635a8b 0x64469a 0x64524e 0x5f418f 0x5f5a89 0x5f6dbb 0x5f34e8 0x468641
#       0x462d84        runtime/pprof.runtime_goroutineProfileWithLabels+0x24   /usr/local/go/src/runtime/mprof.go:746
#       0x638af4        runtime/pprof.writeRuntimeProfile+0xb4                  /usr/local/go/src/runtime/pprof/pprof.go:724
#       0x63890c        runtime/pprof.writeGoroutine+0x4c                       /usr/local/go/src/runtime/pprof/pprof.go:684
#       0x635a8a        runtime/pprof.(*Profile).WriteTo+0x14a                  /usr/local/go/src/runtime/pprof/pprof.go:331
#       0x644699        net/http/pprof.handler.ServeHTTP+0x499                  /usr/local/go/src/net/http/pprof/pprof.go:253
#       0x64524d        net/http/pprof.Index+0x12d                              /usr/local/go/src/net/http/pprof/pprof.go:371
#       0x5f418e        net/http.HandlerFunc.ServeHTTP+0x2e                     /usr/local/go/src/net/http/server.go:2047
#       0x5f5a88        net/http.(*ServeMux).ServeHTTP+0x148                    /usr/local/go/src/net/http/server.go:2425
#       0x5f6dba        net/http.serverHandler.ServeHTTP+0x43a                  /usr/local/go/src/net/http/server.go:2879
#       0x5f34e7        net/http.(*conn).serve+0xb07                            /usr/local/go/src/net/http/server.go:1930

1 @ 0x468641

Постойте, но это неверно. Я вижу здесь только горутины HTTP-сервера…

О! Мы должны породить сервер в его собственной горутине, какая глупая ошибка. Надеюсь, больше никто не совершит столь глупой ошибки.

// omitted: everything else

func main() {
  go log.Println(http.ListenAndServe("localhost:6060", nil))

  // etc.
}

Хм, всё равно мы видим только горутины HTTP.

Блин, я совершаю столько ошибок с таким простым языком, наверно, со мной что-то не так.

Давайте разбираться… о! Мы должны обернуть всё это в замыкание, в противном случае код ждёт возврата http.ListenAndServe, чтобы затем он мог породить log.Println в собственной горутине.

Какая глупость с моей стороны.

// omitted: everything else

func main() {
  go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
  }()

  // etc.
}

$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=1'
goroutine profile: total 7
2 @ 0x439236 0x449eac 0x449e86 0x464845 0x479f05 0x646418 0x64642d 0x64663c 0x468641
#       0x464844        sync.runtime_SemacquireMutex+0x24       /usr/local/go/src/runtime/sema.go:71
#       0x479f04        sync.(*Mutex).lockSlow+0x164            /usr/local/go/src/sync/mutex.go:138
#       0x646417        sync.(*Mutex).Lock+0x57                 /usr/local/go/src/sync/mutex.go:81
#       0x64642c        main.doWork+0x6c                        /home/amos/bearcove/lox/sample.go:13
#       0x64663b        main.main.func3+0x5b                    /home/amos/bearcove/lox/sample.go:38

1 @ 0x439236 0x431bf3 0x4631e9 0x4a91d2 0x4aa86c 0x4aa859 0x5456f5 0x5569c8 0x555d1d 0x5f7334 0x5f6f5d 0x646725 0x6466f5 0x468641
#       0x4631e8        internal/poll.runtime_pollWait+0x88             /usr/local/go/src/runtime/netpoll.go:234
#       0x4a91d1        internal/poll.(*pollDesc).wait+0x31             /usr/local/go/src/internal/poll/fd_poll_runtime.go:84
#       0x4aa86b        internal/poll.(*pollDesc).waitRead+0x22b        /usr/local/go/src/internal/poll/fd_poll_runtime.go:89
#       0x4aa858        internal/poll.(*FD).Accept+0x218                /usr/local/go/src/internal/poll/fd_unix.go:402
#       0x5456f4        net.(*netFD).accept+0x34                        /usr/local/go/src/net/fd_unix.go:173
#       0x5569c7        net.(*TCPListener).accept+0x27                  /usr/local/go/src/net/tcpsock_posix.go:140
#       0x555d1c        net.(*TCPListener).Accept+0x3c                  /usr/local/go/src/net/tcpsock.go:262
#       0x5f7333        net/http.(*Server).Serve+0x393                  /usr/local/go/src/net/http/server.go:3002
#       0x5f6f5c        net/http.(*Server).ListenAndServe+0x7c          /usr/local/go/src/net/http/server.go:2931
#       0x646724        net/http.ListenAndServe+0x44                    /usr/local/go/src/net/http/server.go:3185
#       0x6466f4        main.main.func1+0x14                            /home/amos/bearcove/lox/sample.go:21

1 @ 0x439236 0x449eac 0x449e86 0x464725 0x47b751 0x64657b 0x438e67 0x468641
#       0x464724        sync.runtime_Semacquire+0x24    /usr/local/go/src/runtime/sema.go:56
#       0x47b750        sync.(*WaitGroup).Wait+0x70     /usr/local/go/src/sync/waitgroup.go:130
#       0x64657a        main.main+0x11a                 /home/amos/bearcove/lox/sample.go:42
#       0x438e66        runtime.main+0x226              /usr/local/go/src/runtime/proc.go:255

1 @ 0x439236 0x4654ce 0x64679e 0x468641
#       0x4654cd        time.Sleep+0x12d        /usr/local/go/src/runtime/time.go:193
#       0x64679d        main.main.func2+0x1d    /home/amos/bearcove/lox/sample.go:26

1 @ 0x462d85 0x638af5 0x63890d 0x635a8b 0x64469a 0x64524e 0x5f418f 0x5f5a89 0x5f6dbb 0x5f34e8 0x468641
#       0x462d84        runtime/pprof.runtime_goroutineProfileWithLabels+0x24   /usr/local/go/src/runtime/mprof.go:746
#       0x638af4        runtime/pprof.writeRuntimeProfile+0xb4                  /usr/local/go/src/runtime/pprof/pprof.go:724
#       0x63890c        runtime/pprof.writeGoroutine+0x4c                       /usr/local/go/src/runtime/pprof/pprof.go:684
#       0x635a8a        runtime/pprof.(*Profile).WriteTo+0x14a                  /usr/local/go/src/runtime/pprof/pprof.go:331
#       0x644699        net/http/pprof.handler.ServeHTTP+0x499                  /usr/local/go/src/net/http/pprof/pprof.go:253
#       0x64524d        net/http/pprof.Index+0x12d                              /usr/local/go/src/net/http/pprof/pprof.go:371
#       0x5f418e        net/http.HandlerFunc.ServeHTTP+0x2e                     /usr/local/go/src/net/http/server.go:2047
#       0x5f5a88        net/http.(*ServeMux).ServeHTTP+0x148                    /usr/local/go/src/net/http/server.go:2425
#       0x5f6dba        net/http.serverHandler.ServeHTTP+0x43a                  /usr/local/go/src/net/http/server.go:2879
#       0x5f34e7        net/http.(*conn).serve+0xb07                            /usr/local/go/src/net/http/server.go:1930

1 @ 0x496ae5 0x494e2d 0x4a9da5 0x4a9d8d 0x4a9b45 0x544529 0x54ee45 0x5ed6bf 0x468641
#       0x496ae4        syscall.Syscall+0x4                             /usr/local/go/src/syscall/asm_linux_amd64.s:20
#       0x494e2c        syscall.read+0x4c                               /usr/local/go/src/syscall/zsyscall_linux_amd64.go:687
#       0x4a9da4        syscall.Read+0x284                              /usr/local/go/src/syscall/syscall_unix.go:189
#       0x4a9d8c        internal/poll.ignoringEINTRIO+0x26c             /usr/local/go/src/internal/poll/fd_unix.go:582
#       0x4a9b44        internal/poll.(*FD).Read+0x24                   /usr/local/go/src/internal/poll/fd_unix.go:163
#       0x544528        net.(*netFD).Read+0x28                          /usr/local/go/src/net/fd_posix.go:56
#       0x54ee44        net.(*conn).Read+0x44                           /usr/local/go/src/net/net.go:183
#       0x5ed6be        net/http.(*connReader).backgroundRead+0x3e      /usr/local/go/src/net/http/server.go:672

Ага, теперь мы видим все наши горутины. Так что да, pprof довольно удобен! У него гораздо больше возможностей, чем рассказано здесь, вам стоит прочитать документацию.

Для Rust похожим инструментом является tokio-console, который мне очень нравится.

Итак, вернёмся к нашему примеру на Rust!

У нас имелось следующее:

use parking_lot::Mutex;

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        let mut counter = counter.lock();
        *counter += 1
    }
}

fn main() {
    let counter: Mutex<u64> = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&counter));
        s.spawn(|_| do_work(&counter));
    })
    .unwrap();

    println!("counter = {}", counter.lock())
}

И мы говорили, что любопытно отсутствие необходимости явной разблокировки мьютекса: так как counter в let mut counter в fn do_work() является MutexGuard<u64>, а этот тип имеет реализацию Drop: поэтому Mutex просто разблокируется, когда защитная блокировка выходит из области видимости.

В Go можно использовать паттерн defer mutex.Unlock() чтобы частично реализовать подобное, но это не полностью аналогично.

Рассмотрим следующий пример:

package main

import (
  "log"
  _ "net/http/pprof"
  "sync"
)

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    defer mutex.Unlock()
    *counter += 1
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, &mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}

Он зависает навечно.

Мы не получаем даже удобного «автоматического обнаружения взаимоблокировок», которое работает только в тривиальных случаях, ведь у нас здесь есть импорт:

import 	_ "net/http/pprof"

Заметили это? Уж точно нет.

И этот импорт имеет функцию init, которая в конечном итоге, очевидно, запускает горутину, поэтому механизм обнаружения взаимоблокировок терпит крах (даже несмотря на то, что мы ещё даже не запустили HTTP-сервер!)

Но сама суть проблемы в следующем: defer откладывает вызов функции до выхода из ambient function. А не до конца области видимости.

То есть этот совершенно невинный фрагмент:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    defer mutex.Unlock()
    *counter += 1
  }
}

Совершенно ошибочен.

Вместо этого используется ещё один распространённый паттерн:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    func() {
      mutex.Lock()
      defer mutex.Unlock()
      *counter += 1
    }()
  }
}

И он уже делает всё правильно.

Видите ли вы здесь закономерность? Клянусь, я даже не пытаюсь искать вещи, на которые можно жаловаться в Go: всё это не запланировано и просто возникло случайно. Пока мы писали довольно простой код примеров.

И есть ещё много всего. Гораздо больше всего.

Для сравнения — следующий код работает, как и ожидается:

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        {
            let mut counter = counter.lock();
            *counter += 1
        }
        {
            let mut counter = counter.lock();
            *counter += 1
        }
    }
}

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

Нельзя использовать старую карту для исследования нового мира


Давайте рассмотрим другой пример:

package main

import (
  "log"
  "math/rand"
  "sync"
)

func doWork(m map[uint64]uint64) {
  for i := 0; i < 100; i++ {
    key := uint64(rand.Intn(10))
    m[key] += 1
  }
}

func main() {
  var wg sync.WaitGroup
  var m map[uint64]uint64

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(m)
    }()
  }

  wg.Wait()
  log.Printf("map = %#v", m)
}

Как думаете, что здесь происходит? У нас есть две задачи, параллельно изменяющие один и тот же map. Обязательно должны возникнуть какие-то тонкости, связанные с одновременностью!

Разумеется!

$ go run ./sample.go
panic: assignment to entry in nil map

goroutine 7 [running]:
main.doWork(0x0)
        /home/amos/bearcove/lox/sample.go:12 +0x48
main.main.func1()
        /home/amos/bearcove/lox/sample.go:24 +0x58
created by main.main
        /home/amos/bearcove/lox/sample.go:22 +0x45
exit status 2

Хотя нет, забудьте, никакой связи с одновременностью.

Это просто нулевое значение для map Go (nil)!

Мы можем использовать len(), чтобы получить его длину и можем считать из него, но не можем присваивать:

package main

import "log"

func main() {
  var m map[uint64]uint64
  log.Printf("len(m) = %v", len(m))
  log.Printf("m[234] = %v", m[234])
  m[234] = 432
}

$ go run ./scratch.go
2022/02/07 22:47:56 len(m) = 0
2022/02/07 22:47:56 m[234] = 0
panic: assignment to entry in nil map

goroutine 1 [running]:
main.main()
        /home/amos/bearcove/lox/scratch.go:9 +0xb8
exit status 2

Давайте устраним этот баг и посмотрим на проблему, связанную с одновременностью:

// omitted: everything except main

func main() {
  var wg sync.WaitGroup
  m := make(map[uint64]uint64)

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(m)
    }()
  }

  wg.Wait()
  log.Printf("map = %#v", m)
}

$ go run ./sample.go
2022/02/07 22:49:17 map = map[uint64]uint64{0x0:0x19, 0x1:0x16, 0x2:0x10, 0x3:0x17, 0x4:0xe, 0x5:0x13, 0x6:0x16, 0x7:0x18, 0x8:0x15, 0x9:0xe}

Ха! Никаких проблем с одновременностью.

Но постойте-ка, что это за форматирование?

$ go get github.com/davecgh/go-spew/spew

// omitted: everything besides main

func main() {
  var wg sync.WaitGroup
  m := make(map[uint64]uint64)

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(m)
    }()
  }

  wg.Wait()
  // 👇 instead of using the "%#v" specifier
  spew.Dump(m)
}

$ go run ./sample.go
(map[uint64]uint64) (len=10) {
 (uint64) 8: (uint64) 21,
 (uint64) 5: (uint64) 19,
 (uint64) 6: (uint64) 22,
 (uint64) 4: (uint64) 14,
 (uint64) 2: (uint64) 16,
 (uint64) 7: (uint64) 24,
 (uint64) 9: (uint64) 14,
 (uint64) 3: (uint64) 23,
 (uint64) 1: (uint64) 22,
 (uint64) 0: (uint64) 25
}

Вот, так-то лучше!

Здесь нет багов с одновременностью, как и ожидалось. Мы распределяем 200 инкрементов по 10 участкам, поэтому их значение приблизительно равно 20.

Стоит просуммировать их, чтобы убедиться.

Наверно, для этого есть функция в стандартной библиотеке Go!

func main() {
  // omitted: start of main

  sum := 0
  for _, v := range m {
    sum += v
  }
  spew.Dump(sum)
}

$  go run ./sample.go
# command-line-arguments
./sample.go:34:7: invalid operation: sum += v (mismatched types int and uint64)

А-ха-ха. Да кому нужно выведение типов.

func main() {
  // omitted: start of main

  sum := uint64(0) // you can't make me use var!
  for _, v := range m {
    sum += v
  }
  spew.Dump(sum)
}

$ go run ./sample.go
(map[uint64]uint64) (len=10) {
 (uint64) 5: (uint64) 19,
 (uint64) 0: (uint64) 25,
 (uint64) 2: (uint64) 16,
 (uint64) 7: (uint64) 24,
 (uint64) 8: (uint64) 21,
 (uint64) 6: (uint64) 22,
 (uint64) 4: (uint64) 14,
 (uint64) 3: (uint64) 23,
 (uint64) 1: (uint64) 22,
 (uint64) 9: (uint64) 14
}
(uint64) 200

$ go run ./sample.go
(map[uint64]uint64) (len=10) {
 (uint64) 7: (uint64) 24,
 (uint64) 9: (uint64) 14,
 (uint64) 8: (uint64) 21,
 (uint64) 4: (uint64) 14,
 (uint64) 2: (uint64) 16,
 (uint64) 1: (uint64) 22,
 (uint64) 5: (uint64) 19,
 (uint64) 0: (uint64) 25,
 (uint64) 6: (uint64) 22,
 (uint64) 3: (uint64) 23
}
(uint64) 200

$ go run ./sample.go
(map[uint64]uint64) (len=10) {
 (uint64) 9: (uint64) 14,
 (uint64) 0: (uint64) 25,
 (uint64) 3: (uint64) 23,
 (uint64) 1: (uint64) 22,
 (uint64) 7: (uint64) 24,
 (uint64) 8: (uint64) 21,
 (uint64) 5: (uint64) 19,
 (uint64) 6: (uint64) 22,
 (uint64) 4: (uint64) 14,
 (uint64) 2: (uint64) 16
}
(uint64) 200

Да, тут никаких проблем с одновременностью! И сумма каждый раз равна 200!

Распределение отличается, но это хорошо, ведь нам и нужны псевдослучайные числа.

Порядок итераций случаен, но это фича:

package main

import "fmt"

func main() {
  var m = make(map[string]struct{})
  for _, s := range []string{"a", "b", "c", "A"} {
    m[s] = struct{}{}
  }

  for i := 0; i < 5; i++ {
    for k := range m {
      fmt.Printf("%v", k)
    }
    fmt.Println()
  }
}

$ go run ./scratch.go
bcAa
cAab
bcAa
abcA
Acab

Любопытная идея. Реализации Map обычно довольно явно сообщают, поддерживают ли они порядок вставок. HashMap в Rust тоже не сохраняет порядок вставок.

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert("a", 1);
    map.insert("b", 2);
    map.insert("c", 3);

    for _ in 0..5 {
        for k in map.keys() {
            print!("{}", k);
        }
        println!();
    }
}

Но это также не рандомизирует порядок во время выполнения:

$ cargo run --quiet 
bca
bca
bca
bca
bca

$ cargo run --quiet
abc
abc
abc
abc
abc

$ cargo run --quiet
acb
acb
acb
acb
acb

Стоит ли тратить ресурсы на рандомизацию порядка итераций во время выполнения — спорный вопрос, но уж имеем, что имеем.

Вернёмся к программе на Go: итак, нет никаких проблем, связанных с одновременностью. А если мы усложним задачу?

Допустим, у нас есть цикл в doWork, выполняющий пятьдесят тысяч итераций.

func doWork(m map[uint64]uint64) {
  //               👇
  for i := 0; i < 50000; i++ {
    key := uint64(rand.Intn(10))
    m[key] += 1
  }
}

$ go run ./sample.go
fatal error: concurrent map writes

goroutine 7 [running]:
runtime.throw({0x4cb240, 0xc000086f60})
        /usr/local/go/src/runtime/panic.go:1198 +0x71 fp=0xc000086f38 sp=0xc000086f08 pc=0x431311
runtime.mapassign_fast64(0x4b8080, 0xc0000ba390, 0x1)
        /usr/local/go/src/runtime/map_fast64.go:101 +0x2c5 fp=0xc000086f70 sp=0xc000086f38 pc=0x410425
main.doWork(0x0)
        /home/amos/bearcove/lox/sample.go:13 +0x48 fp=0xc000086fa8 sp=0xc000086f70 pc=0x4abac8
main.main.func1()
        /home/amos/bearcove/lox/sample.go:25 +0x58 fp=0xc000086fe0 sp=0xc000086fa8 pc=0x4abd78
runtime.goexit()
        /usr/local/go/src/runtime/asm_amd64.s:1581 +0x1 fp=0xc000086fe8 sp=0xc000086fe0 pc=0x45d421
created by main.main
        /home/amos/bearcove/lox/sample.go:23 +0x4f

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x0)
        /usr/local/go/src/runtime/sema.go:56 +0x25
sync.(*WaitGroup).Wait(0xc000084728)
        /usr/local/go/src/sync/waitgroup.go:130 +0x71
main.main()
        /home/amos/bearcove/lox/sample.go:29 +0xd8

goroutine 6 [runnable]:
math/rand.(*lockedSource).Int63(0xc000010030)
        /usr/local/go/src/math/rand/rand.go:387 +0xfe
math/rand.(*Rand).Int63(...)
        /usr/local/go/src/math/rand/rand.go:84
math/rand.(*Rand).Int31(...)
        /usr/local/go/src/math/rand/rand.go:98
math/rand.(*Rand).Int31n(0xc0000ba000, 0xa)
        /usr/local/go/src/math/rand/rand.go:133 +0x59
math/rand.(*Rand).Intn(0x4b8080, 0xc0000ba390)
        /usr/local/go/src/math/rand/rand.go:171 +0x2e
math/rand.Intn(...)
        /usr/local/go/src/math/rand/rand.go:337
main.doWork(0x0)
        /home/amos/bearcove/lox/sample.go:12 +0x34
main.main.func1()
        /home/amos/bearcove/lox/sample.go:25 +0x58
created by main.main
        /home/amos/bearcove/lox/sample.go:23 +0x4f
exit status 2

Ха-ха! Как и ожидалось! Возникли проблемы с одновременностью.

Это неудивительно, ведь map в Go не потокобезопасны, об этом написано в спецификации языка.

Хотя нет, не написано. Разве? Но ведь map — это встроенный тип. Да, так и есть, это задокументировано только в посте из блога!

Поэтому в этом случае тоже если мы хотим безопасно получать доступ к map из нескольких горутин, способных выполняться параллельно, нам нужен Mutex или любой другой тип блокировки, например RWLock.

Или можно использовать другие примитивы одновременности, например, канал, задача которого заключается в обновлении map.

Так как каналы — это более новая идея, они гораздо меньше подвержены ошибкам:

package main

import (
  "math/rand"
  "sync"

  "github.com/davecgh/go-spew/spew"
)

func doWork(increments chan uint64) {
  for i := 0; i < 50000; i++ {
    key := uint64(rand.Intn(10))
    // we're just sending a "unit of work" to the updater goroutine
    increments <- key
  }
}

func main() {
  var wg sync.WaitGroup

  m := make(map[uint64]uint64)
  var increments chan uint64

  // this goroutine will be in charge of updating the map
  go func() {
    for increment := range increments {
      m[increment] += 1
    }
  }()

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(increments)
    }()
  }

  wg.Wait()
  spew.Dump(m)

  sum := uint64(0)
  for _, v := range m {
    sum += v
  }
  spew.Dump(sum)
}

$ go run ./sample.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x0)
        /usr/local/go/src/runtime/sema.go:56 +0x25
sync.(*WaitGroup).Wait(0xc000084728)
        /usr/local/go/src/sync/waitgroup.go:130 +0x71
main.main()
        /home/amos/bearcove/lox/sample.go:38 +0x111

goroutine 6 [chan receive (nil chan)]:
main.main.func1()
        /home/amos/bearcove/lox/sample.go:25 +0x59
created by main.main
        /home/amos/bearcove/lox/sample.go:24 +0x8f

goroutine 7 [chan send (nil chan)]:
main.doWork(0x0)
        /home/amos/bearcove/lox/sample.go:13 +0x48
main.main.func2()
        /home/amos/bearcove/lox/sample.go:34 +0x58
created by main.main
        /home/amos/bearcove/lox/sample.go:32 +0xa5

goroutine 8 [chan send (nil chan)]:
main.doWork(0x0)
        /home/amos/bearcove/lox/sample.go:13 +0x48
main.main.func2()
        /home/amos/bearcove/lox/sample.go:34 +0x58
created by main.main
        /home/amos/bearcove/lox/sample.go:32 +0xa5
exit status 2

Ой, какая неуклюжесть. Что произошло?

На этот вопрос отвечает трассировка стека: в ней говорится, что мы пытаемся отправить в nil chan, нулевое значение для канала, а согласно четырём аксиомам каналов, которые тоже изложены в другом посте, отправка в nil channel просто блокируется бесконечно.

В посте написано, что это поведение «немного неожиданно для новичка», но не объясняется причина. Наверно, мы никогда её не узнаем.

Давайте устраним наш баг:

// (cut)

func main() {
  // (cut)

  m := make(map[uint64]uint64)
  // 👇
  increments := make(chan uint64)

  // (cut)
}

И теперь всё работает как должно!

$ go run ./sample.go
(map[uint64]uint64) (len=10) {
 (uint64) 9: (uint64) 9755,
 (uint64) 5: (uint64) 10032,
 (uint64) 0: (uint64) 10152,
 (uint64) 1: (uint64) 10115,
 (uint64) 7: (uint64) 10021,
 (uint64) 4: (uint64) 9884,
 (uint64) 2: (uint64) 9901,
 (uint64) 3: (uint64) 9913,
 (uint64) 8: (uint64) 9984,
 (uint64) 6: (uint64) 10242
}
(uint64) 99999

Но нет. Что?

Сумма не равна ста тысячам.

А, ладно, значит, где-то единица потерялась, но если запустим программу ещё раз, на этот раз сумма будет правильной!

Да нет, конечно. Давайте это исправим.

Есть множество способов исправить её, но ни один мне не нравится.

func main() {
	var wg sync.WaitGroup

	m := make(map[uint64]uint64)
	increments := make(chan uint64)
	signal := make(chan struct{})

	go func() {
		for increment := range increments {
			m[increment] += 1
		}
		close(signal)
	}()

	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			doWork(increments)
		}()
	}

	// wait for workers...
	wg.Wait()

	// signal end of "units of work"
	close(increments)

	// wait for updater goroutine to finish
	<-signal

	spew.Dump(m)

	sum := uint64(0)
	for _, v := range m {
		sum += v
	}
	spew.Dump(sum)
}

И теперь код правильный. Наверно. Мы даже устранили утечку памяти! Ранее обновляющая горутина сохранялась бы вечно, потому что содержит ссылку на канал «increments», который никогда не закрывается.

Для языка со сборкой мусора это чрезвычайно простой способ создания утечки памяти.

Но давайте забудем о своих печалях и вернёмся к честным сравнениям. Никто не будет спорить, что «небезопасность map в Go для одновременного доступа» — это выстрел в ногу.

Есть ли такой же выстрел в ногу в Rust?

Проверим:

use rand::Rng;
use std::collections::HashMap;

fn do_work(m: &mut HashMap<u64, u64>) {
    let mut rng = rand::thread_rng();
    for _ in 0..100_000 {
        let key: u64 = rng.gen_range(0..10);
        *m.entry(key).or_default() += 1
    }
}

fn main() {
    let mut m: HashMap<u64, u64> = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&mut m));
        s.spawn(|_| do_work(&mut m));
    })
    .unwrap();

    // format strings can capture arguments, as of Rust 1.58:
    println!("map = {m:#?}");
    println!("sum = {}", m.values().copied().sum::<u64>());
}

$ cargo run --quiet
error[E0499]: cannot borrow `m` as mutable more than once at a time
  --> src/main.rs:17:17
   |
16 |         s.spawn(|_| do_work(&mut m));
   |                 ---              - first borrow occurs due to use of `m` in closure
   |                 |
   |                 first mutable borrow occurs here
17 |         s.spawn(|_| do_work(&mut m));
   |           ----- ^^^              - second borrow occurs due to use of `m` in closure
   |           |     |
   |           |     second mutable borrow occurs here
   |           first borrow later used by call

For more information about this error, try `rustc --explain E0499`.
error: could not compile `lox` due to previous error

Нет! Его не существует. Потому что можно считать map с помощью &HashMap (неизменяемой ссылки), но для изменения map нужен &mut HashMap (изменяемая ссылка), а одновременно может существовать только один.

Та же методика применима и здесь, мы можем использовать Mutex:

use parking_lot::Mutex;
use rand::Rng;
use std::collections::HashMap;

//               👇
fn do_work(m: &Mutex<HashMap<u64, u64>>) {
    let mut rng = rand::thread_rng();
    for _ in 0..100_000 {
        let key: u64 = rng.gen_range(0..10);
        // 👇
        *m.lock().entry(key).or_default() += 1
    }
}

fn main() {
    // note that `Default::default()` can still be used to build this type!
    let m: Mutex<HashMap<u64, u64>> = Default::default();

    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&m));
        s.spawn(|_| do_work(&m));
    })
    .unwrap();

    // and that we can take the map out of the mutex afterwards!
    let m = m.into_inner();

    println!("map = {m:#?}");
    println!("sum = {}", m.values().copied().sum::<u64>());
}

Это работает:

$ cargo run --quiet
map = {
    4: 19962,
    2: 19952,
    7: 20034,
    1: 20209,
    3: 20047,
    6: 19820,
    5: 20101,
    0: 20398,
    9: 19807,
    8: 19670,
}
sum = 200000

Или мы можем создать поток, предназначенный для обновления map, точно так же, как мы делали это с горутинами:

use rand::Rng;
use std::{collections::HashMap, sync::mpsc};

fn do_work(tx: mpsc::Sender<u64>) {
    let mut rng = rand::thread_rng();
    for _ in 0..100_000 {
        let key: u64 = rng.gen_range(0..10);
        tx.send(key).unwrap();
    }
}

fn main() {
    let mut m: HashMap<u64, u64> = Default::default();

    crossbeam::scope(|s| {
        let (tx1, rx) = mpsc::channel();
        let tx2 = tx1.clone();
        let m = &mut m;

        s.spawn(move |_| {
            while let Ok(key) = rx.recv() {
                *m.entry(key).or_default() += 1
            }
        });
        s.spawn(move |_| do_work(tx1));
        s.spawn(move |_| do_work(tx2));
    })
    .unwrap();

    println!("map = {m:#?}");
    println!("sum = {}", m.values().copied().sum::<u64>());
}

$ cargo run --quiet
map = {
    2: 19931,
    5: 20027,
    3: 20023,
    7: 19937,
    8: 20007,
    4: 20003,
    6: 20122,
    9: 20030,
    1: 20013,
    0: 19907,
}
sum = 200000

Обратите внимание, что здесь «отправляющий» и «принимающий» концы каналы отдельны: обновляющий получает приёмник, а каждый рабочий поток получает собственного отправителя.

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

Но не всё можно предотвратить


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

Это естественное следствие того, что множество допустимых программ меньше! Многие полезные программы из него исключены! Поэтому иногда нам нужно искать альтернативные формулировки для эквивалентных программ, одобряемых компилятором Rust.

Именно эту мысль я хотел передать в статье Frustrated? It's not you, it's Rust.

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

Впрочем, это не значит, что нет смысла отлавливать ошибки.

Отлавливание части всё равно намного лучше, чем отсутствие отлавливания.

Мы видим подобное искажение и в решении проблем физического мира. Например, может казаться бессмысленным заботиться о себе, когда вокруг так много беспорядка.

Но мы должны с чего-то начать.

Например, вот допустимая программа на Rust:

fn add(a: u64, b: u64) -> u64 {
    a - b
}

fn main() {
    dbg!(add(1, 3));
}

cargo check ничего о ней не говорит. Но человек сказал бы. Эта функция называется add, но на самом деле она вычитает.

Для примера ещё одна совершенно допустимая программа:

use parking_lot::Mutex;

fn main() {
    let m: Mutex<u64> = Default::default();

    let mut guard = m.lock();
    *guard += 1;

    println!("m = {}", m.lock());
}

Тем не менее, она приводит к взаимоблокировке:

$ cargo run --quiet
(nothing is printed)

Запуск этой программы под miri (с отключенной изоляцией) выявляет взаимоблокировку:

$ cargo clean; MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri run
   Compiling cfg-if v1.0.0
   (cut)
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
    Finished dev [unoptimized + debuginfo] target(s) in 5.73s
     Running `/home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/bin/cargo-miri target/miri/x86_64-unknown-linux-gnu/debug/lox`
error: deadlock: the evaluated program deadlocked
   --> /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/thread_parker/linux.rs:118:13
    |
118 |             )
    |             ^ the evaluated program deadlocked
    |
    = note: inside `parking_lot_core::thread_parker::imp::ThreadParker::futex_wait` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/thread_parker/linux.rs:118:13
    = note: inside `<parking_lot_core::thread_parker::imp::ThreadParker as parking_lot_core::thread_parker::ThreadParkerT>::park` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/thread_parker/linux.rs:66:13
    = note: inside closure at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/parking_lot.rs:635:17
    = note: inside `parking_lot_core::parking_lot::with_thread_data::<parking_lot_core::parking_lot::ParkResult, [closure@parking_lot_core::parking_lot::park<[closure@parking_lot::RawMutex::lock_slow::{closure#0}], [closure@parking_lot::RawMutex::lock_slow::{closure#1}], [closure@parking_lot::RawMutex::lock_slow::{closure#2}]>::{closure#0}]>` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/parking_lot.rs:207:5
    = note: inside `parking_lot_core::parking_lot::park::<[closure@parking_lot::RawMutex::lock_slow::{closure#0}], [closure@parking_lot::RawMutex::lock_slow::{closure#1}], [closure@parking_lot::RawMutex::lock_slow::{closure#2}]>` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot_core-0.9.1/src/parking_lot.rs:600:5
    = note: inside `parking_lot::RawMutex::lock_slow` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot-0.12.0/src/raw_mutex.rs:262:17
    = note: inside `<parking_lot::RawMutex as parking_lot::lock_api::RawMutex>::lock` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/parking_lot-0.12.0/src/raw_mutex.rs:72:13
    = note: inside `parking_lot::lock_api::Mutex::<parking_lot::RawMutex, u64>::lock` at /home/amos/.cargo/registry/src/github.com-1ecc6299db9ec823/lock_api-0.4.6/src/mutex.rs:214:9
note: inside `main` at src/main.rs:9:24
   --> src/main.rs:9:24
    |
9   |     println!("m = {}", m.lock());
    |                        ^^^^^^^^
    = note: inside `<fn() as std::ops::FnOnce<()>>::call_once - shim(fn())` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:227:5
    = note: inside `std::sys_common::backtrace::__rust_begin_short_backtrace::<fn(), ()>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys_common/backtrace.rs:123:18
    = note: inside closure at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/rt.rs:145:18
    = note: inside `std::ops::function::impls::<impl std::ops::FnOnce<()> for &dyn std::ops::Fn() -> i32 + std::marker::Sync + std::panic::RefUnwindSafe>::call_once` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:259:13
    = note: inside `std::panicking::r#try::do_call::<&dyn std::ops::Fn() -> i32 + std::marker::Sync + std::panic::RefUnwindSafe, i32>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:485:40
    = note: inside `std::panicking::r#try::<i32, &dyn std::ops::Fn() -> i32 + std::marker::Sync + std::panic::RefUnwindSafe>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:449:19
    = note: inside `std::panic::catch_unwind::<&dyn std::ops::Fn() -> i32 + std::marker::Sync + std::panic::RefUnwindSafe, i32>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panic.rs:136:14
    = note: inside closure at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/rt.rs:128:48
    = note: inside `std::panicking::r#try::do_call::<[closure@std::rt::lang_start_internal::{closure#2}], isize>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:485:40
    = note: inside `std::panicking::r#try::<isize, [closure@std::rt::lang_start_internal::{closure#2}]>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:449:19
    = note: inside `std::panic::catch_unwind::<[closure@std::rt::lang_start_internal::{closure#2}], isize>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panic.rs:136:14
    = note: inside `std::rt::lang_start_internal` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/rt.rs:128:20
    = note: inside `std::rt::lang_start::<()>` at /home/amos/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/rt.rs:144:17

error: aborting due to previous error

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

Сделанная мной ошибка, которую не отловил Rust, гораздо более незаметна:

use parking_lot::RwLock;
use rand::Rng;
use std::{
    collections::HashMap,
    sync::Arc,
    time::{Duration, Instant},
};

#[derive(Default)]
struct State {
    entries: RwLock<HashMap<u64, u64>>,
}

impl State {
    fn update_state(&self) {
        let mut entries = self.entries.write();
        let key = rand::thread_rng().gen_range(0..10);
        *entries.entry(key).or_default() += 1;
    }

    fn foo(&self) {
        let entries = self.entries.read();
        if entries.get(&4).copied().unwrap_or_default() % 2 == 0 {
            // do something
        } else {
            self.bar();
        }
    }

    fn bar(&self) {
        let entries = self.entries.read();
        if entries.get(&2).is_some() {
            // do something
        } else {
            // do something else
        }
    }
}

fn main() {
    let s: Arc<State> = Default::default();

    std::thread::spawn({
        let s = s.clone();
        move || loop {
            std::thread::sleep(Duration::from_millis(1));
            s.update_state();
        }
    });

    let before = Instant::now();
    for _ in 0..10_000 {
        s.foo();
    }
    println!("All done in {:?}", before.elapsed());
}

Видите баг?

Похоже, программа работает правильно…

$ cargo run --quiet
All done in 3.520651ms

Но если убрать sleep в фоновом потоке…

// in main:
    std::thread::spawn({
        let s = s.clone();
        move || loop {
            s.update_state();
        }
    });

$ cargo run --quiet
(nothing is ever printed)

то происходит взаимоблокировка.

Если вы ещё не нашли баг, то попробуйте закомменировать вызов self.bar() в State::foo и запустить программу заново. Она заработает:

$ cargo run --quiet
warning: associated function is never used: `bar`
  --> src/main.rs:26:8
   |
26 |     fn bar(&self) {
   |        ^^^
   |
   = note: `#[warn(dead_code)]` on by default

All done in 1.891049988s

За эту блокировку есть активная конкуренция (обновляющий поток занят выполнением циклов!), но каким-то образом нам всё равно удаётся получить для неё блокировку считывания десять тысяч раз меньше чем за две секунды.

Проблема здесь в том, что для завершения foo() время от времени требуется получать две блокировки записи для entries.

Следующая ситуация приемлема:

  • update_state получает блокировку записи
  • foo пытается получить блокировку чтения… (она блокируется на какое-то время)
  • update_state обновляет состояние
  • update_state освобождает блокировку записи
  • foo успешно получает блокировку чтения
  • foo вызывает bar
  • bar получает блокировку чтения
  • bar освобождает блокировку чтения
  • foo освобождает свою блокировку чтения

А эта ситуация неприемлема:

  • foo получает блокировку чтения
  • update_state пытается получить блокировку записи… (она пока блокируется)
  • foo вызывает bar
  • bar пытается получить блокировку чтения… (она пока блокируется)

И ни bar, ни update_state не могут получить свою блокировку. Поскольку блокировка записи «ожидается», дополнительные блокировки чтения получить нельзя. Но поскольку foo вызвала bar, нам нужно две блокировки чтения, чтобы вернуться из foo (и освободить её блокировку чтения).

Иными словами, мы получили перемежающиеся «RWR», и это взаимоблокировка. «WRR» сработала бы нормально, как и «RRW», но не «RWR».

Итак, вот ошибка, которую не отлавливает Rust.

Разумеется, мы можем отрефакторить код так, чтобы вероятность возникновения ошибки была меньше!

Например, мы можем переместить entries из State и заставить каждую функцию, которой она нужна, получать неизменяемую ссылку на неё:

use parking_lot::RwLock;
use rand::Rng;
use std::{collections::HashMap, sync::Arc, time::Instant};

#[derive(Default)]
struct State {}

impl State {
    fn update_state(&self, entries: &mut HashMap<u64, u64>) {
        let key = rand::thread_rng().gen_range(0..10);
        *entries.entry(key).or_default() += 1;
    }

    fn foo(&self, entries: &HashMap<u64, u64>) {
        if entries.get(&4).copied().unwrap_or_default() % 2 == 0 {
            // do something
        } else {
            self.bar(entries);
        }
    }

    fn bar(&self, entries: &HashMap<u64, u64>) {
        if entries.get(&2).is_some() {
            // do something
        } else {
            // do something else
        }
    }
}

fn main() {
    let entries: Arc<RwLock<HashMap<u64, u64>>> = Default::default();
    let s: Arc<State> = Default::default();

    std::thread::spawn({
        let s = s.clone();
        let entries = entries.clone();
        move || loop {
            s.update_state(&mut entries.write());
        }
    });

    let before = Instant::now();
    for _ in 0..10_000 {
        s.foo(&entries.read());
    }
    println!("All done in {:?}", before.elapsed());
}

Но тогда нам придётся выполнять кучу работы по отслеживанию.

Ещё один вариант — создать вторую struct, ReadLockedState, имеющую собственную блокировку чтения:

use parking_lot::{RwLock, RwLockReadGuard};
use rand::Rng;
use std::{collections::HashMap, sync::Arc, time::Instant};

#[derive(Default)]
struct State {
    entries: Arc<RwLock<HashMap<u64, u64>>>,
}

impl State {
    fn update_state(&self) {
        let mut entries = self.entries.write();
        let key: u64 = rand::thread_rng().gen_range(0..10);
        *entries.entry(key).or_default() += 1;
    }

    fn read(&self) -> ReadLockedState<'_> {
        ReadLockedState {
            entries: self.entries.read(),
        }
    }
}

struct ReadLockedState<'a> {
    entries: RwLockReadGuard<'a, HashMap<u64, u64>>,
}

impl ReadLockedState<'_> {
    fn foo(&self) {
        if self.entries.get(&4).copied().unwrap_or_default() % 2 == 0 {
            // do something
        } else {
            self.bar();
        }
    }

    fn bar(&self) {
        if self.entries.get(&2).is_some() {
            // do something
        } else {
            // do something else
        }
    }
}

fn main() {
    let s: Arc<State> = Default::default();

    std::thread::spawn({
        let s = s.clone();
        move || loop {
            s.update_state();
        }
    });

    let before = Instant::now();
    for _ in 0..10_000 {
        s.read().foo();
    }
    println!("All done in {:?}", before.elapsed());
}

$ cargo run --quiet
All done in 1.96135045s

Это решение нравится мне намного больше, но оно тоже неидеально. Вероятно, в State есть другие поля, и вам может понадобиться получить к ним доступ и из ReadLockedState, так что вам или придётся ссылаться на них все, или иметь там &'a State (что снова возвращает опасность вызова self.state.entries.read()), или разбить State на две подструктуры: одну с защитой RwLock, другую без (и struct ReadLockedState будет иметь &'a ReadOnlyState и RwLockReadGuard<'a, ProtectedState>, или что-то подобное).

Но, возможно, там есть и какие-то другие поля Arc<RwLock<T>>,
что больше усложняет ситуацию. Какого-то идеального общего решения не существует.

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

В конечном итоге, это довольно легко анализировать статически:

impl State {
    fn foo(&self) {
        let entries = self.entries.read();
        if entries.get(&4).copied().unwrap_or_default() % 2 == 0 {
            // do something
        } else {
            self.bar();
        }
    }

    fn bar(&self) {
        // 🛑 error! cannot call `self.entries.read()` because `bar()` can be
        // called by `foo()`, which is already holding a read lock to
        // `self.entries`.
        let entries = self.entries.read();
        if entries.get(&2).is_some() {
            // do something
        } else {
            // do something else
        }
    }
}

Rust просто не предназначен для защиты от этого, по крайней мере, пока.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+66
Комментарии 12
Комментарии Комментарии 12

Публикации

Истории

Работа

Go разработчик
122 вакансии

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн