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

Комментарии 23

Не заметил, что перевод, ожидал от статьи гораздо большего.

Претензия к оригиналу — как обычно, про спорные аспекты поведения рантайма ни слова.
Nем более «free lunch is over» в переводе есть на хабре Бесплатного супа больше не будет.
Основные ситуации, в которых горутина вернёт управление планировщику

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

Любой функции?

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

Кроме того, вызов runtime-планировщика не такая уж и дорогая операция т.к. в отличие от планировщика ОС нет переключения контекста userspace -> kernel space. Это по сути вызов функции.

Но вообще, на каждый байт большого буффера/на каждый символ строки вызывать по несколько функций может быть довольно дорого если код критичный по производительности.
Да, проверка на передачу управления происходит там же, где увеличивается размер стека. Естественно при каждом вызове не будет происходить переключения на другую горутину, так же как и стек никто все время не трогает. Какой-то оверхед это имеет, но куда важнее то, что это обеспечивает более эффективное распределение ресурсов процессора между горутинами.
Я на самом деле хотел подчеркнуть, что не надо пугаться и в шедулер мы, строго говоря, не попадаем при каждом вызове функции :) Просто проверяем флажок.
Любой, которую компилятор посчитает подходящей. Что-то заинлайнит, что-то посчитает слишком простой функцией. Сделано это было для обеспечения более эффективной кооперативной многозадачности, чтобы было меньше голодания горутин.

Вот искусственный пример, который позволяет увидеть это в действии. Собирать нужно с помощью «go build -gcflags '-l -N'», чтобы отключить оптимизации. Данный пример использует лишь один поток ОС для выполнения горутин, но горутина все равно будет выполнена, хотя рантайму нигде не передается управление.
package main

import (
    "fmt"
    "runtime"
)

func foo() {
    m := 0
    
    for i := 0; i < 100; i++ {
        m++
    }
    
    for i := 0; i < 100; i++ {
        m++
    }
    
    for i := 0; i < 100; i++ {
        m++
    }
    
    for i := 0; i < 100; i++ {
        m++
    }
    
    for i := 0; i < 100; i++ {
        m++
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    
    go func(){
        fmt.Println("I'm alive")
    }()
    
    for i := 0; i < 10000000; i++ {
        foo()
    }
}
Как то неочивидно, почему стек в 2КБ менее ресурсоемок, чем, например в 24МБ, это же виртуальная память и выделить ее должно быть одинаково затратно. Так же как то неясно как может закончится виртуальная память, ее же сильно много. Я видел процессы в сотни гигабайт виртуальной памяти и они прекрасно выделяли память дальше. Возможно я чего то недопонял или не знаю.
Виртуальная память, в конечном итоге, мапится на физическую. И при каждом вызове выделять по 24МБ из кучи (которая совсем не бесконечная и быстро приведет к свопингу) — вы представляет, что будет твориться с типичной Go программой, которая сотнями создает и убивает горутины постоянно? 2КБ хватит для большинства горутин, т.к. глубина стека у них обычно довольно маленькая. Если надо, объем этот увеличится или уменьшится. Все это делается, чтобы горутины были легковесными и быстрыми. Это не потоки, которые создаешь и держишься за них всю программу. Язык поощряет создавать большое количество маленьких короткоживущих горутин, а им большой стек совсем не нужен. Что, в свою очередь, упрощает написание конкурентного кода.

Вот статья от того же автора на эту тему http://dave.cheney.net/2013/06/02/why-is-a-goroutines-stack-infinite
Вроде речь про сервера идет, я там не встречал свопа. В том то и дело, что malloc(2KB) и malloc(24MB) одинаково имеют одинаковую сложность и тоже самое с освобождением. Тк это всеголишь виртуальная и из нее использовано только несколько страниц будет.
Вообще говоря, malloc 2Kb и 24Mb будет выделять разными механизмами — 2Kb через brk (то есть, если место в куче есть, syscall-ов не делаем), 24Mb — через mmap.
Зависит от реализации аллокатора, но да, скорее всего большой кусок будет mmap-ом.
Если выделить честные реальные 24Мб на горутину, то это будет ресурсоемко по памяти. Если выделять виртуальную и обрабатывать page fault-ы, то это будет ресурсоемко по CPU, т.к. не выходя из user space мы в принципе не сможем обработать этот случай. Более того, придется делать больше одного context switch-а.

Кроме того, нужно будет уметь отдавать обратно память в систему, что тоже проблематично из user space. Если использовать аллокации из кучи и копирование, то это в типичном случае должно быть быстрее, чем переключение контекста.

Ну и пока еще не умерли 32-битные архитектуры.
То есть идея в том, что вместо page-fault-ов при хождении в грубь стека и переключения в режим ядра го делает realloc стека по сути и это должно быть быстрее?

А что проблемотичного отдать память назад? mmap/unmap.

Чет мне кажется 32-х битные это уже какие то встраиваемые вещи, вроде на телефонах даже уже 64.
Да, мне кажется, что основное преимущество тут в отсутствии переключений в режим ядра. Может быть я что-то еще из вида упускаю.

Получается, что для запуска горутины нужно сделать аллокацию на куче в 2Кб, поменять значение регистров и завести нужные структуры в шедулере. Все это быстро, т.к. почти всегда в user-space. Эти 2Кб целиком даже не обязательно трогать, если горутине будет достаточно всего, скажем, байт 100 стека. Отсюда и легковесность.

ARM-ов 32-битных по-моему еще достаточно много в телефонах. Например как минимум половина андроидов еще на версии ниже 5 (в 5-й начали поддерживать 64 бита). И, если я правильно понимаю, go достаточно активно пытаются внедрить на мобильных платформах.
Еще, по поводу mmap/unmap и соответствующих context switch. Цифр не нашел, специально померил — 150нс уходит просто на то, чтобы вернуть EINVAL. То есть это просто оверхед на сам факт вызова. А таких вызова будет 2 (mmap + munmap).

В Linux, адресное пространство у процесса меняется под несколькими локами (page_table_lock, mmap_sem, https://www.kernel.org/doc/gorman/html/understand/understand007.html) и такие операции будут выстраиваться в очередь. Плюс, там еще сама логика какое-то время будет занимать.

В простом бенчмарке запущенном с GOMAXPROCS=1 у меня получился оверхед на запуск пустой горутины в ~230нс (при этом все время проведено в user space):

package main

import (
    "runtime"
)

func main() {
    for i := 0; i < 1e7; i++ {
        go func() {}()
        if i%20 == 0 {
            runtime.Gosched()
        }
    }
}
По-моему, тема event-loop'а не раскрыта (хотя он упоминается в названии статьи). Только вскользь сказано, что их минус — callback'и, хотя проблему с callback hell в js, например, уже давно решают с async/await. Это, конечно, претензия к оригиналу, но хотелось бы подробностей, как горутины помогают удобнее писать код, задействующий все ядра процессора.

Самое забавное, что в NodeJS проблема callback-hell решается аналогично Go — через сопрограммы (node-fibers, а не конечный автомат async-await). Хотя из статьи может сложиться впечатление, будто сопрограммы есть только в Go.

только node.js однотредный в отличии от go и тут очень большая разница
Async/await выглядит похожим механизмом. Но без поддержки со стороны языка (прологи функций в go), например, нельзя сделать «автоматическое» вытеснение. Не то, чтобы без этого никак, но это удобно.

Горутины прозрачно запускаются в нескольких системных тредах, в этом принципиальное отличие от стандартных имплементаций корутин/fiber-ов.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий