![](https://habrastorage.org/webt/tf/u5/vi/tfu5vinauktrvp3jxgqwbso1lfe.jpeg)
Мне по-прежнему интересны языки программирования. Но сегодня уже не так сильно, и не из-за того, что они позволяют мне делать, а, скорее, из-за того, что они мне делать не позволяют.
В конечном итоге, возможности того, что можно сделать при помощи языка программирования, редко ограничены самим языком: нет ничего, что можно сделать на 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 просто не предназначен для защиты от этого, по крайней мере, пока.